diff --git a/public/termine-fragebogen.js b/public/termine-fragebogen.js new file mode 100644 index 0000000..4be3d52 --- /dev/null +++ b/public/termine-fragebogen.js @@ -0,0 +1,502 @@ +/* Terminplaner im Kundenbereich: Fragebogen vor der Bestellung (TP2). renderFragebogen(root, ctx) zeigt in der Huelle (termine.js) die acht Schritte + und danach die Bestellseite (funktion-bestellen.js). ctx ist der Kontext der Reiter (baueCtx in termine.js, hinweise() bleibt leer); ctx.daten ist die + Antwort von GET /api/termine/einstellungen im Zustand off (einstellung, version, fragebogen, branchen, betriebVorschlag, kundenansicht, offen, bestellung). + Aufbau: Fortschrittsleiste (
    , aria-current="step"; am Handy dazu „Schritt n von 8“), darunter der Schritt. Der Start ist der erste offene + Schritt (alles bestaetigt: die Bestellseite); die Leiste erlaubt freie Navigation, nur ohne Entwurf fuehrt sie allein in die Branchenwahl. + Jeder Schritt steht in BAU: key ⇒ (t) ⇒ Element mit t = { ctx, key, nr, titel, gehe(key), neuZeichnen(), weiter(), zurueck }. Ein Schritt mit + Feldern ist ein
    ; „Weiter“ (verdrahte) macht genau EINEN PATCH ueber den neuerSpeicher des Schritts (termine-speichern.js, der einzige + PATCH-Weg; nur wenn der Schritt Felder hat) und danach PUT /fragebogen mit dem Schritt in bestaetigt (bestaetige, der einzige PUT). Fehler stehen am + Feld (setzeFehler); beim Wechsel bekommt die Ueberschrift des Schritts den Fokus. Kundenansicht (daten.kundenansicht): PATCH bleibt moeglich, es gibt + keinen PUT, denn Schritte bestaetigt nur der Kunde selbst. Die Schritte 4, 5, 6 und 8 traegt C9b in BAU nach. Texte der Kunden kommen nur ueber + value/textContent in die Seite (h aus termine-dom.js), nie als HTML. */ + +import { FRAGEBOGEN_SCHRITTE, LIMITS, MAX, ersterOffenerSchritt, preisAnzeige, ressourcenAusNamen, schrittStatus } from './termine-view.js' +import { allgemeinerFehler, feld, frage, h, hakenFeld, setzeFehler, zeigeFehler } from './termine-dom.js' +import { neuerSpeicher } from './termine-speichern.js' +import { render as renderLeistungen } from './termine-tab-leistungen.js' +import { betrieb, darstellung, erinnerung } from './termine-tab-einstellungen.js' +import { renderBestellen } from './funktion-bestellen.js' + +const BESTELLEN = 'bestellen' +const TITEL = new Map([...FRAGEBOGEN_SCHRITTE, [BESTELLEN, 'Prüfen und bestellen']]) +const AUFBEWAHRUNG = 'Wir speichern deinen Entwurf bis zu 90 Tage nach deiner letzten Änderung.' +const KUNDENANSICHT = 'Du siehst den Fragebogen in der Kundenansicht. Angaben kannst du ändern, Schritte bestätigen und bestellen kann nur der Kunde selbst.' +const GESUNDHEIT_TEXT = 'Für den Gesundheitsbereich ist der Terminplaner nicht verfügbar. Schon der Name einer Leistung oder ein Terminwunsch kann etwas über die Gesundheit deiner Gäste verraten, ' + + 'und solche Angaben sind besonders geschützt. Gehört dein Angebot nicht zum Gesundheitsbereich, wähle die Branche, die am besten passt. Fragen dazu beantworten wir dir im Chat.' +const WECHSEL_TEXT = 'Das ersetzt deine Leistungen, Zeiten und Regeln durch die Vorschläge der neuen Branche. Alle Bestätigungen gehen zurück, du bestätigst die Schritte danach noch einmal. ' + + 'Deine Angaben zum Betrieb bleiben erhalten.' +const NACH_VORLAGE_FEHLT = 'Deine Branche ist angelegt, aber nicht alle Angaben konnten gespeichert werden. Bitte prüfe sie und klick auf „Weiter“.' +const NACH_VORLAGE_LADEN = 'Deine Branche ist angelegt. Wir konnten den Stand aber nicht laden. Bitte lade die Seite neu.' + +// Betriebsangaben von Schritt 1, ohne Entwurf und mit Entwurf +const BETRIEB_NUR = ['name', 'adresse', 'kleinunternehmer'] +// POST vorlage uebernimmt aus betrieb nur diese Felder (server/routes/termine.js) ... +const KONTAKT_FELDER = ['name', 'adresse', 'telefon', 'email', 'websiteUrl'] +// ... diese bleiben dabei auf der Strecke; der Client traegt sie mit einem PATCH nach (betriebNachtrag) +const NACHTRAG_FELDER = ['kleinunternehmer', 'impressumUrl', 'datenschutzUrl', 'agbUrl'] + +const WER_ARTEN = [ + ['einzel', 'Einzelperson', 'Eine Person wird gebucht, zum Beispiel du selbst.'], + ['team', 'Team', 'Mehrere Personen können gebucht werden, jede mit eigenen Zeiten.'], + ['plaetze', 'Plätze', 'Gebucht werden Plätze oder Räume, zum Beispiel Stühle oder Zimmer.'], +] + +const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert) + +/* ---------------------------------------------------------------- Gerüst --- */ +export function renderFragebogen(root, ctx) { + const stand = h('p', { class: 'tm-schritt-stand' }) + const liste = h('ol', { class: 'tm-schritte' }) + const inhalt = h('div', { class: 'tm-fb-inhalt' }) + root.replaceChildren(h('div', { class: 'tm-fragebogen' }, + ctx.daten.kundenansicht === true ? h('div', { class: 'tm-box tm-box-info', role: 'note' }, h('span', null, KUNDENANSICHT)) : null, + h('nav', { class: 'tm-fortschritt', 'aria-label': 'Fortschritt im Fragebogen' }, stand, liste), + inhalt, + h('p', { class: 'tm-hinweis tm-fb-aufbewahrung' }, AUFBEWAHRUNG))) + let angezeigt = null // Schluessel des Schritts, der gerade steht + + // ohne Entwurf gibt es nur die Branchenwahl; alle anderen Schritte brauchen eine Einstellung + const darfHin = (key) => key === 'branche' || Boolean(ctx.daten.einstellung) + + function zeichneLeiste() { + const eintraege = [...schrittStatus(ctx.daten.fragebogen), { key: BESTELLEN, titel: TITEL.get(BESTELLEN), erledigt: false }] + liste.replaceChildren(...eintraege.map(({ key, titel, erledigt }, i) => h('li', { + class: 'tm-schritt', 'data-schritt': key, 'data-erledigt': erledigt ? 'true' : null, 'aria-current': key === angezeigt ? 'step' : null, + }, h('button', { type: 'button', class: 'tm-schritt-knopf', disabled: !darfHin(key), onclick: () => { if (key !== angezeigt) zeige(key) } }, + h('span', { class: 'tm-schritt-nr', 'aria-hidden': 'true' }, erledigt ? '✓' : String(i + 1)), + h('span', { class: 'tm-schritt-titel' }, titel), + erledigt ? h('span', { class: 'tm-sr' }, ' (erledigt)') : null)))) + const nr = FRAGEBOGEN_SCHRITTE.findIndex(([key]) => key === angezeigt) + stand.textContent = nr < 0 ? TITEL.get(BESTELLEN) : `Schritt ${nr + 1} von ${FRAGEBOGEN_SCHRITTE.length}` + } + + function bestellseite() { + const seite = h('div', { class: 'tm-fb-bestellen' }) + renderBestellen(seite, { ctx, daten: ctx.daten, zumSchritt: (key) => zeige(key) }) + return seite + } + + /** Schritt zeigen (=> sein Element). fokus: die Ueberschrift bekommt den Fokus, so wie bei jedem Wechsel; der erste Aufruf nimmt keinen. */ + function zeige(key, { fokus = true } = {}) { + const ziel = darfHin(key) ? key : 'branche' + angezeigt = ziel + zeichneLeiste() + const el = ziel === BESTELLEN ? bestellseite() : (BAU[ziel] ?? schrittOhneInhalt)(baueT(ziel)) + inhalt.replaceChildren(el) + const kopf = fokus ? el.querySelector('h3') : null + if (kopf) { + kopf.setAttribute('tabindex', '-1') + kopf.focus() + } + return el + } + + function baueT(key) { + const nr = FRAGEBOGEN_SCHRITTE.findIndex(([k]) => k === key) + return { + ctx, + key, + nr, + titel: TITEL.get(key), + gehe: (ziel) => zeige(ziel), + // nur solange der Schritt noch steht: eine spaete Antwort holt den Kunden nicht aus einem anderen Schritt zurueck + neuZeichnen: () => (angezeigt === key ? zeige(key, { fokus: false }) : null), + weiter: () => zeige(FRAGEBOGEN_SCHRITTE[nr + 1]?.[0] ?? BESTELLEN), + zurueck: nr > 0 ? () => zeige(FRAGEBOGEN_SCHRITTE[nr - 1][0]) : null, + } + } + + zeige(ersterOffenerSchritt(ctx.daten.fragebogen), { fokus: false }) +} + +/* ------------------------------------------------------------ Bausteine --- */ +/** Rahmen eines Schritts: Ueberschrift (Fokusziel beim Wechsel) und ein erklaerender Satz; der Schritt haengt seinen Inhalt an. */ +function rahmen(t, intro) { + return h('section', { class: 'tm-fb-schritt', 'data-schritt': t.key }, + h('h3', { id: `tm-fb-${t.key}`, tabindex: '-1' }, t.titel), + intro ? h('p', { class: 'tm-hinweis tm-abschnitt-text' }, intro) : null) +} + +const schrittForm = (t, ...kinder) => h('form', { class: 'tm-fb-form', novalidate: true, 'aria-labelledby': `tm-fb-${t.key}` }, ...kinder) + +/** Zurueck (ohne zu speichern) und Weiter (sendet das Formular). */ +function knoepfe(t, { mitWeiter = true } = {}) { + return h('div', { class: 'tm-frage-knoepfe' }, + t.zurueck ? h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'zurueck', onclick: () => t.zurueck() }, 'Zurück') : null, + mitWeiter ? h('button', { type: 'submit', class: 'kb-btn-primary', 'data-aktion': 'weiter' }, 'Weiter') : null) +} + +/** + * PUT /fragebogen: der Schritt kommt in bestaetigt, aenderung(fragebogen) setzt weitere Angaben (z. B. preiseGeprueft). Gesendet wird der ganze Fragebogen + * (PUT ersetzt ihn) auf dem Stand von ctx.daten; die Antwort gilt danach in ctx.daten (fragebogen und die offenen Punkte der Bestellung). + * In der Kundenansicht passiert nichts: Schritte bestaetigt nur der Kunde selbst. + */ +async function bestaetige(t, aenderung) { + const { ctx } = t + if (ctx.daten.kundenansicht === true) return + const fragebogen = structuredClone(istObjekt(ctx.daten.fragebogen) ? ctx.daten.fragebogen : {}) + aenderung?.(fragebogen) + fragebogen.bestaetigt = [...new Set([...(Array.isArray(fragebogen.bestaetigt) ? fragebogen.bestaetigt : []), t.key])] + const antwort = await ctx.api.send('PUT', '/fragebogen', { fragebogen }) + if (!ctx.aktuell()) return + if (istObjekt(antwort?.fragebogen)) ctx.daten.fragebogen = antwort.fragebogen + if (antwort?.offen !== undefined) ctx.daten.offen = antwort.offen +} + +/** + * „Weiter“ eines Schritts: pruefe() (Fehler ⇒ { feld: text } am Feld, nichts wird gesendet), aendere(entwurf) (ein PATCH ueber den Speicher des + * Schritts; false = der Schritt hat selbst Fehler gezeigt; fehlt aendere, gibt es keinen PATCH), dann PUT mit dem Schritt (fragebogen(f) setzt weitere + * Angaben) und der naechste Schritt. Nur ein Durchlauf zur Zeit; verlaesst der Kunde den Schritt oder die Ansicht, fasst nichts mehr die Seite an. + */ +function verdrahte(t, form, sp, { pruefe, aendere, fragebogen } = {}) { + const weiter = form.querySelector('[data-aktion="weiter"]') + let laeuft = false + form.addEventListener('submit', async (ev) => { + ev.preventDefault() + if (laeuft) return + laeuft = true + const hatteFokus = document.activeElement === weiter + weiter.disabled = true + try { + setzeFehler(form, {}) + const fehler = pruefe?.() + if (fehler) { + zeigeFehler(form, { fehler }) + return + } + if (aendere && !(await sp.speichere(aendere, { container: form }))) return + if (!sp.nochHier()) return + try { + await bestaetige(t, fragebogen) + } catch (err) { + if (sp.nochHier()) zeigeFehler(form, err) + return + } + if (sp.nochHier()) t.weiter() + } finally { + laeuft = false + weiter.disabled = false + // ein gesperrter Knopf verliert im Browser den Fokus: bleibt der Schritt stehen und hat ihn niemand sonst (ein Feld mit Fehler), kehrt er zurueck + const jetzt = document.activeElement + if (hatteFokus && weiter.isConnected && (!jetzt || jetzt === document.body || jetzt === weiter)) weiter.focus() + } + }) +} + +/** Schritt neu zeichnen und im neuen Formular einen allgemeinen Fehler zeigen (nach einem Teilerfolg, den die alte Ansicht nicht kennt). */ +function zeigeNeu(t, text) { + const form = t.neuZeichnen()?.querySelector('form') + if (form) setzeFehler(form, { _: text }) +} + +/* ------------------------------------------------- Schritt 1: Branche und Betrieb --- */ +/** + * Radiogruppe der Branchen aus daten.branchen. Der Gesundheitsbereich zeigt den Erklaertext und KEINEN Knopf (es geht nie eine Anfrage raus), jede andere + * Wahl den Knopf (type submit, data-aktion branche-starten). => { liste, hinweis, aktion, gewaehlt() }: der Aufrufer setzt die drei Elemente. + */ +function brancheWahl(daten, { name, knopf }) { + let gewaehlt = null + const hinweis = h('div', { class: 'tm-fb-branchenhinweis' }) + const aktion = h('div', { class: 'tm-frage-knoepfe' }) + const liste = h('fieldset', { class: 'tm-vorlagen-liste tm-fb-branchen' }, + h('legend', { class: 'tm-sr' }, 'Branche'), + (daten.branchen || []).map((b) => { + const radio = h('input', { type: 'radio', name, value: b.key }) + radio.addEventListener('change', () => { gewaehlt = b; zeichne() }) + const beschreibung = (daten.vorlagen || []).find((v) => v.key === b.key)?.beschreibung + return h('label', { class: 'tm-vorlage' }, radio, h('span', null, h('strong', null, b.name), beschreibung ? h('small', null, beschreibung) : null)) + })) + function zeichne() { + if (gewaehlt?.gesundheit) { + hinweis.replaceChildren(h('div', { class: 'tm-box tm-box-warn', role: 'note', 'data-hinweis': 'gesundheit' }, + h('span', null, GESUNDHEIT_TEXT), + h('button', { type: 'button', class: 'kb-btn-outline', 'data-view': 'chat' }, 'Chat öffnen'))) + aktion.replaceChildren() + return + } + hinweis.replaceChildren() + aktion.replaceChildren(h('button', { type: 'submit', class: 'kb-btn-primary', 'data-aktion': 'branche-starten', disabled: gewaehlt === null }, knopf)) + } + zeichne() + return { liste, hinweis, aktion, gewaehlt: () => gewaehlt } +} + +/** Nur die nichtleeren Texte der Felder, die POST vorlage aus betrieb uebernimmt. */ +const kontaktVon = (angaben) => Object.fromEntries(KONTAKT_FELDER + .filter((name) => typeof angaben?.[name] === 'string' && angaben[name].trim() !== '') + .map((name) => [name, angaben[name]])) + +/** Was der Kunde angegeben hatte und POST vorlage nicht uebernommen hat (Haken gesetzt, Adresse eingetragen), im Vergleich zum neuen Entwurf. */ +function betriebNachtrag(gewuenscht, neu) { + const nachtrag = {} + for (const name of NACHTRAG_FELDER) { + const wunsch = gewuenscht?.[name] + if (wunsch && wunsch !== neu?.[name]) nachtrag[name] = wunsch + } + return nachtrag +} + +/** + * Nach einem erfolgreichen POST vorlage: Daten neu laden (Einstellung, Version, Fragebogen) und nachtragen, was der Server aus betrieb nicht uebernimmt + * (ein PATCH, nur wenn etwas fehlt). => true, wenn der Entwurf danach vollstaendig steht. + */ +async function nachVorlage(t, sp, container, gewuenscht) { + const { ctx } = t + try { + await ctx.neuLaden() + } catch { + return false + } + if (!sp.nochHier() || !ctx.daten.einstellung) return false + const nachtrag = betriebNachtrag(gewuenscht, ctx.daten.einstellung.betrieb) + if (Object.keys(nachtrag).length === 0) return true + return sp.speichere((entwurf) => { entwurf.betrieb = { ...entwurf.betrieb, ...nachtrag }; return true }, { container }) +} + +function schrittBranche(t) { + return t.ctx.daten.einstellung ? brancheMitEntwurf(t) : brancheStart(t) +} + +/** Ohne Entwurf: Branche waehlen und die Betriebsangaben pruefen; „Mit dieser Branche starten“ legt den Entwurf an und bestaetigt den Schritt. */ +function brancheStart(t) { + const { ctx } = t + const wurzel = rahmen(t, 'Such dir die Branche aus, die am besten zu deinem Betrieb passt. Daraus legen wir Leistungen, Zeiten und Regeln als Vorschlag an. Du kannst danach alles ändern.') + const sp = neuerSpeicher(ctx, wurzel, t.neuZeichnen) + const angaben = betrieb(ctx, { betrieb: ctx.daten.betriebVorschlag ?? {} }, { nur: BETRIEB_NUR }) + const wahl = brancheWahl(ctx.daten, { name: 'termine-branche', knopf: 'Mit dieser Branche starten' }) + const form = schrittForm(t, h('div', { class: 'kb-panel' }, wahl.liste, wahl.hinweis), angaben.el, allgemeinerFehler(), wahl.aktion) + + let laeuft = false + form.addEventListener('submit', async (ev) => { + ev.preventDefault() + const branche = wahl.gewaehlt() + if (laeuft || branche === null || branche.gesundheit) return // der Gesundheitsbereich sendet nie etwas + laeuft = true + const knopf = form.querySelector('[data-aktion="branche-starten"]') + if (knopf) knopf.disabled = true + try { + setzeFehler(form, {}) + const gewuenscht = angaben.lese() + // die Website des Projekts kennt nur der Vorschlag (der Server nimmt Name, Anschrift, E-Mail und Telefon selbst aus dem Konto): sie geht mit + const website = typeof ctx.daten.betriebVorschlag?.websiteUrl === 'string' ? ctx.daten.betriebVorschlag.websiteUrl.trim() : '' + try { + await ctx.api.send('POST', '/einstellungen/vorlage', { branche: branche.key, betrieb: website ? { ...gewuenscht, websiteUrl: website } : gewuenscht }) + } catch (err) { + if (sp.nochHier()) zeigeFehler(form, err) + return + } + // ab hier gibt es den Entwurf: was schiefgeht, zeigt den Schritt neu (mit Entwurf) statt noch einmal anzulegen + const vollstaendig = await nachVorlage(t, sp, form, gewuenscht) + if (!sp.nochHier()) return + if (!vollstaendig) { + if (ctx.daten.einstellung) zeigeNeu(t, NACH_VORLAGE_FEHLT) + else zeigeFehler(form, { fehler: { _: NACH_VORLAGE_LADEN } }) + return + } + try { + await bestaetige(t) + } catch (err) { + if (sp.nochHier()) zeigeNeu(t, err.message) + return + } + if (!sp.nochHier()) return + ctx.meldung(`Die Branche „${branche.name}“ ist angelegt. Alles darin ist ein Vorschlag, den du anpassen kannst.`) + t.weiter() + } finally { + laeuft = false + if (knopf) knopf.disabled = false + } + }) + + wurzel.append(sp.konflikt, form) + return wurzel +} + +/** Mit Entwurf: gewaehlte Branche (wechseln nur mit Rueckfrage), Betriebsangaben, „Weiter“ speichert sie und bestaetigt den Schritt. */ +function brancheMitEntwurf(t) { + const { ctx } = t + const e = ctx.daten.einstellung + const branche = (ctx.daten.branchen || []).find((b) => b.key === ctx.daten.fragebogen?.branche) + const wurzel = rahmen(t, 'Prüfe die Angaben zu deinem Betrieb. Die Branche bestimmt, welche Vorschläge für Leistungen, Zeiten und Regeln du siehst.') + const sp = neuerSpeicher(ctx, wurzel, t.neuZeichnen) + const angaben = betrieb(ctx, e, { nur: BETRIEB_NUR }) + const wechsel = wechselFrage(t, sp, angaben, branche !== undefined) + const form = schrittForm(t, angaben.el, allgemeinerFehler(), knoepfe(t)) + verdrahte(t, form, sp, { + pruefe: () => (branche ? null : { _: 'Bitte wähle zuerst deine Branche.' }), + aendere: (entwurf) => { angaben.lese(entwurf); return true }, + }) + wurzel.append(sp.konflikt, + h('div', { class: 'tm-fb-branche' }, h('p', null, 'Deine Branche: ', h('strong', null, branche?.name ?? 'noch nicht gewählt')), wechsel.knopf), + wechsel.box, + form) + return wurzel +} + +/** + * „Branche wechseln“ mit Rueckfrage in der Seite (kein confirm()): ersetzt Leistungen, Zeiten und Regeln und setzt alle Bestaetigungen zurueck + * (POST vorlage mit ersetzen: true und der gelesenen version). Die Betriebsangaben gehen mit; was der Server dabei nicht uebernimmt, wird nachgetragen. + * Danach steht Schritt 1 neu da, ein PUT gibt es nicht. => { knopf, box }: der Aufrufer setzt beide NEBEN das Formular des Schritts. + */ +function wechselFrage(t, sp, angaben, hatBranche) { + const { ctx } = t + const box = h('div', { class: 'tm-frage' }) + const knopf = h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'branche-wechseln' }, hatBranche ? 'Branche wechseln' : 'Branche wählen') + const zu = () => { box.replaceChildren(); knopf.focus() } + knopf.addEventListener('click', () => { + const wahl = brancheWahl(ctx.daten, { name: 'termine-branche-neu', knopf: 'Ja, Branche wechseln' }) + const abbrechen = h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'abbrechen', onclick: zu }, 'Abbrechen') + const rueckfrage = frage('Branche wechseln?', h('p', null, WECHSEL_TEXT), wahl.liste, wahl.hinweis, allgemeinerFehler(), h('div', { class: 'tm-frage-knoepfe' }, wahl.aktion, abbrechen)) + let laeuft = false + rueckfrage.addEventListener('submit', async () => { + const neu = wahl.gewaehlt() + if (laeuft || neu === null || neu.gesundheit) return + laeuft = true + const ok = rueckfrage.querySelector('[data-aktion="branche-starten"]') + if (ok) ok.disabled = true + try { + setzeFehler(rueckfrage, {}) + const gewuenscht = { ...ctx.daten.einstellung.betrieb, ...angaben.lese() } + try { + await ctx.api.send('POST', '/einstellungen/vorlage', { branche: neu.key, betrieb: kontaktVon(gewuenscht), ersetzen: true, version: ctx.daten.version }) + } catch (err) { + if (!sp.nochHier()) return + if (err.code === 'EINSTELLUNG_VERALTET') { + box.replaceChildren() + sp.konflikt.hidden = false // „Neu laden“ des Speichers + sp.konflikt.scrollIntoView({ block: 'nearest' }) + } else zeigeFehler(rueckfrage, err) + return + } + const vollstaendig = await nachVorlage(t, sp, rueckfrage, gewuenscht) + if (!sp.nochHier()) return + if (vollstaendig) t.gehe('branche') + else zeigeNeu(t, NACH_VORLAGE_FEHLT) + } finally { + laeuft = false + if (ok) ok.disabled = false + } + }) + box.replaceChildren(rueckfrage) + rueckfrage.focus() + }) + return { knopf, box } +} + +/* ------------------------------------------------------ Schritt 2: Wer wird gebucht --- */ +/** Vorbelegung der Art: nur Plaetze ⇒ Plaetze, mehr als ein Eintrag ⇒ Team, sonst Einzelperson. */ +function vorgabeArt(ressourcen) { + if (ressourcen.length > 0 && ressourcen.every((r) => r.typ === 'platz')) return 'plaetze' + return ressourcen.length > 1 ? 'team' : 'einzel' +} + +function schrittWer(t) { + const { ctx } = t + const wurzel = rahmen(t, 'Trag ein, wer oder was bei dir gebucht werden kann. Neue Personen oder Plätze können zunächst alle Leistungen anbieten, das änderst du im nächsten Schritt.') + const sp = neuerSpeicher(ctx, wurzel, t.neuZeichnen) + const ressourcen = ctx.daten.einstellung.ressourcen || [] + const vorgabe = vorgabeArt(ressourcen) + const radios = WER_ARTEN.map(([wert, name, beschreibung]) => h('label', { class: 'tm-vorlage' }, + h('input', { type: 'radio', name: 'termine-wer-art', value: wert, checked: wert === vorgabe }), + h('span', null, h('strong', null, name), h('small', null, beschreibung)))) + const namen = h('textarea', { rows: '4', maxlength: String(LIMITS.ressourcen * (MAX.ressourcenName + 1)), autocomplete: 'off' }, ressourcen.map((r) => r.name).join('\n')) + const form = schrittForm(t, + h('div', { class: 'kb-panel' }, + h('fieldset', { class: 'tm-vorlagen-liste tm-fb-branchen' }, h('legend', { class: 'tm-sr' }, 'Wer wird gebucht'), radios), + feld('ressourcen', 'Namen (einer pro Zeile)', namen, `Höchstens ${LIMITS.ressourcen} Einträge mit je höchstens ${MAX.ressourcenName} Zeichen. Bei einer Einzelperson trägst du nur einen Namen ein.`)), + allgemeinerFehler(), + knoepfe(t)) + + let gelesen = null + const art = () => form.querySelector('input[name="termine-wer-art"]:checked')?.getAttribute('value') ?? vorgabe + verdrahte(t, form, sp, { + pruefe: () => { + const zeilen = namen.value.split(/\r?\n/) + const gefuellt = zeilen.filter((zeile) => zeile.trim() !== '') + let fehler = null + if (gefuellt.length === 0) fehler = 'Bitte trag mindestens einen Namen ein.' + else if (art() === 'einzel' && gefuellt.length > 1) fehler = 'Für eine Einzelperson trägst du nur einen Namen ein. Wähle „Team“, wenn mehrere Personen gebucht werden können.' + else if (gefuellt.length > LIMITS.ressourcen) fehler = `Es sind höchstens ${LIMITS.ressourcen} Einträge möglich.` + else if (gefuellt.some((zeile) => [...zeile.trim()].length > MAX.ressourcenName)) fehler = `Ein Name darf höchstens ${MAX.ressourcenName} Zeichen lang sein.` + if (fehler) return { ressourcen: fehler } + gelesen = ressourcenAusNamen({ bisher: ctx.daten.einstellung.ressourcen, typ: art() === 'plaetze' ? 'platz' : 'person', namen: zeilen }) + return null + }, + // neue Eintraege bieten alle Leistungen an (der Validator verlangt eine aktive Ressource), entfernte verschwinden aus Leistungen und Abwesenheiten + aendere: (entwurf) => { + const { ressourcen: neu, entfernt, neu: hinzu } = gelesen + entwurf.ressourcen = neu + for (const leistung of entwurf.leistungen || []) leistung.ressourcen = [...(leistung.ressourcen || []).filter((id) => !entfernt.includes(id)), ...hinzu] + entwurf.abwesenheiten = (entwurf.abwesenheiten || []).filter((a) => !entfernt.includes(a.ressourceId)) + return true + }, + }) + wurzel.append(sp.konflikt, form) + return wurzel +} + +/* ---------------------------------------------------------- Schritt 3: Leistungen --- */ +/** Leistungen, die Gaeste sehen: aktiv und mit mindestens einer aktiven Person oder einem aktiven Platz (wie sichtbareLeistungen auf dem Server). */ +function sichtbareLeistungen(einstellung) { + const aktiv = new Set((einstellung?.ressourcen || []).filter((r) => r?.aktiv).map((r) => r.id)) + return (einstellung?.leistungen || []).filter((l) => l?.aktiv && (l.ressourcen || []).some((id) => aktiv.has(id))) +} + +const hatPreis = (leistung) => preisAnzeige(leistung) !== 'ohne Preis' + +function schrittLeistungen(t) { + const { ctx } = t + const wurzel = rahmen(t, 'Prüfe die Leistungen, die deine Gäste buchen können, und ändere sie, wie du sie brauchst. Änderungen an einer Leistung werden sofort gespeichert.') + const sp = neuerSpeicher(ctx, wurzel, t.neuZeichnen) + // der Reiter Leistungen speichert selbst; sein Platz ist ein eigenes Element, das Formular des Schritts steht daneben (kein verschachteltes Formular) + const leistungen = h('div', { class: 'tm-fb-leistungen' }) + renderLeistungen(leistungen, ctx) + const haken = h('input', { type: 'checkbox', checked: ctx.daten.fragebogen?.preiseGeprueft === true }) + const form = schrittForm(t, + hakenFeld('preiseGeprueft', haken, 'Ich habe die Preise geprüft (die Preise der Vorlage sind Platzhalter)'), + h('p', { class: 'tm-fehler', 'data-fehler': 'leistungen', role: 'alert' }), + allgemeinerFehler(), + knoepfe(t)) + verdrahte(t, form, sp, { + pruefe: () => { + const sichtbar = sichtbareLeistungen(ctx.daten.einstellung) + if (sichtbar.length === 0) return { leistungen: 'Bitte lege mindestens eine aktive Leistung an, die einer aktiven Person oder einem aktiven Platz zugeordnet ist. Nur solche Leistungen sehen Gäste.' } + if (sichtbar.some(hatPreis) && !haken.checked) return { preiseGeprueft: 'Bitte bestätige, dass du die Preise geprüft hast.' } + return null + }, + fragebogen: (f) => { f.preiseGeprueft = haken.checked }, + }) + wurzel.append(sp.konflikt, leistungen, form) + return wurzel +} + +/* ------------------------------------------------- Schritt 7: Benachrichtigung --- */ +function schrittBenachrichtigung(t) { + const { ctx } = t + const wurzel = rahmen(t, 'Wohin sollen die Nachrichten zu Terminen gehen, wie sprichst du deine Gäste an und bekommen Gäste eine Erinnerung?') + const sp = neuerSpeicher(ctx, wurzel, t.neuZeichnen) + const e = ctx.daten.einstellung + const abschnitte = [betrieb(ctx, e, { nur: ['email'], titel: 'Deine E-Mail-Adresse', hinweis: null }), darstellung(e), erinnerung(e)] + const form = schrittForm(t, abschnitte.map((a) => a.el), allgemeinerFehler(), knoepfe(t)) + verdrahte(t, form, sp, { + aendere: (entwurf) => { + const fehler = {} + for (const a of abschnitte) a.lese(entwurf, fehler) + if (Object.keys(fehler).length === 0) return true + zeigeFehler(form, { fehler }) + return false + }, + }) + wurzel.append(sp.konflikt, form) + return wurzel +} + +/* ------------------------------------------------------ Schritte ohne Inhalt --- */ +/** Platz fuer die Schritte, die C9b nachtraegt (4, 5, 6, 8): Ueberschrift, Hinweis und Zurueck, aber nichts zu bestaetigen. */ +function schrittOhneInhalt(t) { + const wurzel = rahmen(t, 'Dieser Schritt ist noch nicht verfügbar.') + wurzel.append(knoepfe(t, { mitWeiter: false })) + return wurzel +} + +const BAU = { branche: schrittBranche, wer: schrittWer, leistungen: schrittLeistungen, benachrichtigung: schrittBenachrichtigung } diff --git a/public/termine.css b/public/termine.css index 0bfca9f..6aafb9b 100644 --- a/public/termine.css +++ b/public/termine.css @@ -227,6 +227,38 @@ .tm-statuskarte a.kb-btn-outline { display: inline-flex; align-items: center; text-decoration: none; } .tm-danke { padding: 12px 16px; border-radius: 12px; background: rgba(74, 222, 128, 0.1); border: 1px solid rgba(74, 222, 128, 0.4); font-weight: 600; } +/* ------------------------------------------ Fragebogen (termine-fragebogen.js) --- */ +.tm-fragebogen > * + * { margin-top: 16px; } +.tm-fragebogen > .tm-box { margin-bottom: 0; } +.tm-fortschritt { padding-bottom: 14px; border-bottom: 1px solid var(--kb-border); } +.tm-schritt-stand { display: none; margin: 0 0 10px; font-size: 14px; font-weight: 700; } +.tm-schritte { display: flex; flex-wrap: wrap; gap: 6px; margin: 0; padding: 0; list-style: none; } +.tm-schritt { margin: 0; } +.tm-schritt-knopf { + display: inline-flex; align-items: center; gap: 8px; padding: 8px 14px 8px 8px; border-radius: 999px; border: 1px solid transparent; + background: transparent; color: var(--kb-muted); font: inherit; font-size: 14px; font-weight: 700; text-align: left; cursor: pointer; +} +.tm-schritt-knopf:hover:not(:disabled) { color: #fff; background: rgba(255, 255, 255, 0.06); } +.tm-schritt-knopf:disabled { opacity: 0.5; cursor: not-allowed; } +.tm-schritt[aria-current='step'] .tm-schritt-knopf { color: #fff; background: var(--kb-purple-soft); border-color: var(--kb-purple-line); } +.tm-schritt-nr { + display: inline-grid; place-items: center; flex: none; width: 26px; height: 26px; border-radius: 50%; border: 1px solid var(--kb-border-strong); + font-size: 12px; font-variant-numeric: tabular-nums; +} +.tm-schritt[data-erledigt] .tm-schritt-nr { color: #86efac; background: rgba(74, 222, 128, 0.1); border-color: rgba(74, 222, 128, 0.4); } +.tm-schritt[aria-current='step'] .tm-schritt-nr { color: #fff; background: var(--kb-purple); border-color: var(--kb-purple); } +.tm-fb-schritt > * + * { margin-top: 16px; } +.tm-fb-schritt > h3 { margin: 0; } +.tm-fb-schritt > h3:focus { outline: none; } +.tm-fb-schritt > .tm-abschnitt-text { margin-bottom: 0; line-height: 1.55; } +.tm-fb-form > * + * { margin-top: 16px; } +.tm-fb-form > .tm-box, .tm-fb-form .tm-fb-branchenhinweis .tm-box { margin-bottom: 0; } +.tm-fb-branchenhinweis:empty { display: none; } +.tm-fb-branchen { margin-bottom: 0; } +.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; } + /* ----------------------------------------------------------------- Handy --- */ @media (max-width: 880px), (pointer: coarse) { #view-termine button:not(.tm-link-knopf), #view-termine .kb-btn-outline, #view-termine .kb-btn-primary { min-height: 44px; } @@ -236,6 +268,11 @@ .tm-haken { align-items: center; min-height: 44px; margin-top: 0; margin-bottom: 0; } .tm-haken input { margin-top: 0; } .tm-link-feld, .tm-code-feld { min-height: 44px; font-size: 16px; } /* 16 px: iOS zoomt beim Antippen kleinerer Felder */ + /* Fragebogen: „Schritt n von 8“ und eine Reihe nummerierter Punkte statt der Titel (der Titel steht als Ueberschrift darunter) */ + .tm-schritt-stand { display: block; } + .tm-schritte { display: grid; grid-template-columns: repeat(auto-fill, minmax(44px, 1fr)); gap: 6px; } + .tm-schritt-knopf { width: 100%; justify-content: center; padding: 0; } + .tm-schritt-titel { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; } } @media (max-width: 600px) { .tm-reiterleiste { grid-template-columns: repeat(2, minmax(0, 1fr)); } diff --git a/test/termineFragebogenView.test.js b/test/termineFragebogenView.test.js index fe6c57f..37500e8 100644 --- a/test/termineFragebogenView.test.js +++ b/test/termineFragebogenView.test.js @@ -16,6 +16,7 @@ import * as einstellungenTab from '../public/termine-tab-einstellungen.js' import * as teamTab from '../public/termine-tab-team.js' import * as huelle from '../public/termine.js' import * as bestellen from '../public/funktion-bestellen.js' +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' @@ -1522,3 +1523,802 @@ test('statusKarte: Texte vom Server (Ticketnummer, Datum, Einrichtung) nur als T assert.equal(fx.alle(wurzel, 'img').length, 0) assert.ok(fx.text(wurzel).includes(boese)) })) + +// ------------------------------------------------------- Fragebogen-Oberfläche: Gerüst und Schritte 1 bis 3 (TP2, Task C9a) +// renderFragebogen(root, ctx) in public/termine-fragebogen.js. Fake-DOM und Fake-fetch wie oben; ctx kommt aus baueCtx der Hülle. Ohne +// ausdrückliches neuLaden-Protokoll ersetzt ein Stummel neuLaden (er liest nichts nach), damit fetchLog genau die Schreibzugriffe zeigt. +const FB_GET = '/api/termine/einstellungen?projectId=p1' +const FB_PATCH = ['PATCH', '/api/termine/einstellungen'] +const FB_PUT = ['PUT', '/api/termine/fragebogen'] +const FB_POST = ['POST', '/api/termine/einstellungen/vorlage'] +const BETRIEB_KONTO = { name: 'Salon Muster', adresse: 'Hauptstraße 1, 67655 Kaiserslautern', email: 'salon@example.invalid', telefon: '06301 12345', websiteUrl: '' } + +/** Antwort von GET /api/termine/einstellungen im Zustand off mit Entwurf (alles, was der Fragebogen aus den Daten liest). */ +const fbDaten = (aenderung = {}) => ({ + state: 'off', + einstellung: salon(), + version: 3, + fragebogen: { ...leerFragebogen(), branche: 'salon' }, + branchen: BRANCHEN.map(({ key, name, gesundheit }) => ({ key, name, gesundheit })), + vorlagen: [{ key: 'salon', name: 'Friseur und Salon', beschreibung: 'Haarschnitt, Färben und Bartpflege.' }], + kundenansicht: false, + fehlt: [], + abo: { ok: true }, + offen: [], + bestellung: null, + ...aenderung, +}) +/** Daten ohne Entwurf: der Kunde sieht die Branchenwahl. */ +const fbStart = (aenderung = {}) => fbDaten({ einstellung: null, version: 0, fragebogen: leerFragebogen(), betriebVorschlag: BETRIEB_KONTO, ...aenderung }) +/** Fragebogen, dessen erste n Schritte bestätigt sind. */ +const fbBis = (n, aenderung = {}) => ({ ...leerFragebogen(), branche: 'salon', bestaetigt: SCHRITTE.slice(0, n), ...aenderung }) +const fbAntwort = (n, aenderung = {}) => ({ body: { fragebogen: fbBis(n, aenderung), offen: [] } }) +const fbPatchAntwort = (version = 4, einstellung = salon()) => ({ body: { einstellung, version, fehlt: [] } }) + +/** Fragebogen in den Baum hängen. => { wurzel, ctx, s }; stumm: neuLaden liest nichts nach (Standard). */ +function fbSeite(daten, { stumm = true } = {}) { + const wurzel = h('div') + document.body.append(wurzel) + const s = { daten, nurLesen: false, meldungEl: h('div', { tabindex: '-1' }) } + document.body.append(s.meldungEl) + const ctx = huelle.baueCtx(s, { projekt: { projectId: 'p1' }, aktuell: () => true }) + if (stumm) ctx.neuLaden = async () => ctx.daten + fragebogenAnsicht.renderFragebogen(wurzel, ctx) + return { wurzel, ctx, s } +} +const fbSchritt = (fx, wurzel) => fx.finde(wurzel, 'li[aria-current="step"]')?.dataset.schritt +const fbWeiter = (fx, wurzel) => fx.finde(wurzel, '[data-aktion="weiter"]') +const fbAufrufe = (fx) => fx.fetchLog.map((a) => [a.method, a.url]) +const fbKnoepfe = (fx, wurzel) => fx.alle(wurzel, 'button').map((k) => fx.text(k)) +const fbRadio = (fx, baum, wert) => fx.finde(baum, `input[type="radio"][value="${wert}"]`) +const fbGehe = (fx, wurzel, key) => fx.klick(fx.finde(wurzel, `li[data-schritt="${key}"] button`)) +const fbFeld = (fx, baum, name) => fx.finde(baum, `[data-feld="${name}"]`) +const fbFehler = (fx, baum, name) => fx.text(fx.finde(baum, `[data-fehler="${name}"]`)) + +// ------------------------------------------------------- Gerüst +test('renderFragebogen: Fortschrittsleiste ist eine
      mit neun Punkten, genau einer trägt aria-current="step"', mitDom(undefined, (fx) => { + const { wurzel } = fbSeite(fbDaten({ fragebogen: fbBis(2) })) + const liste = fx.finde(wurzel, 'ol.tm-schritte') + assert.ok(liste, 'geordnete Liste') + const punkte = fx.alle(liste, 'li.tm-schritt') + assert.deepEqual(punkte.map((li) => li.dataset.schritt), [...SCHRITTE, 'bestellen']) + assert.deepEqual(punkte.map((li) => fx.text(fx.finde(li, '.tm-schritt-titel'))), [...SCHRITTE.map((key) => SCHRITT_TITEL[key]), SCHRITT_TITEL.bestellen]) + assert.deepEqual(fx.alle(wurzel, '[aria-current]').map((el) => [el.tagName, el.getAttribute('aria-current'), el.dataset.schritt]), [['LI', 'step', 'leistungen']]) + assert.deepEqual(punkte.map((li) => li.hasAttribute('data-erledigt')), [true, true, false, false, false, false, false, false, false], 'erledigt: nur die bestätigten') + assert.match(fx.text(punkte[0]), /erledigt/, 'auch für den Screenreader') +})) + +test('renderFragebogen: Handy-Zähler „Schritt n von 8“ und Titel des Schritts als Überschrift', mitDom(undefined, (fx) => { + const { wurzel } = fbSeite(fbDaten({ fragebogen: fbBis(2) })) + assert.equal(fx.text(fx.finde(wurzel, '.tm-schritt-stand')), 'Schritt 3 von 8') + assert.equal(fx.text(fx.finde(wurzel, 'h3')), 'Leistungen') + const { wurzel: start } = fbSeite(fbStart()) + assert.equal(fx.text(fx.finde(start, '.tm-schritt-stand')), 'Schritt 1 von 8') +})) + +test('renderFragebogen: Start beim ersten offenen Schritt (die erste Lücke, nicht die Zahl der Haken)', mitDom(undefined, (fx) => { + assert.equal(fbSchritt(fx, fbSeite(fbDaten({ fragebogen: fbBis(0) })).wurzel), 'branche') + assert.equal(fbSchritt(fx, fbSeite(fbDaten({ fragebogen: fbBis(2) })).wurzel), 'leistungen') + assert.equal(fbSchritt(fx, fbSeite(fbDaten({ fragebogen: fbBis(6) })).wurzel), 'benachrichtigung') + const luecke = { ...fbBis(0), bestaetigt: ['branche', 'leistungen', 'zeiten'] } + assert.equal(fbSchritt(fx, fbSeite(fbDaten({ fragebogen: luecke })).wurzel), 'wer', 'Lücke in der Mitte') + assert.equal(fbSchritt(fx, fbSeite(fbStart()).wurzel), 'branche', 'ohne Entwurf') +})) + +test('renderFragebogen: sind alle Schritte bestätigt, steht die Bestellseite da', mitDom(undefined, async (fx) => { + const daten = await bestellDaten() + const { wurzel } = fbSeite(daten) + assert.equal(fbSchritt(fx, wurzel), 'bestellen') + assert.ok(fx.finde(wurzel, '.tm-bestellen'), 'renderBestellen hat die Seite gezeichnet') + assert.equal(fx.text(bestellKnopf(fx, wurzel)), daten.bestellung.consent.knopf) + assert.equal(fx.text(fx.finde(wurzel, '.tm-schritt-stand')), 'Prüfen und bestellen') + assert.equal(fx.alle(wurzel, '.tm-schritt[data-erledigt]').length, 8) + assert.deepEqual(fx.fetchLog, [], 'nichts nachgeladen') +})) + +test('renderFragebogen: „Ändern“ und „Zum Schritt“ der Bestellseite springen in den Schritt', mitDom(undefined, async (fx) => { + const daten = await bestellDaten({ offen: [{ schritt: 'website', text: 'Bitte trage die Adresse deines Impressums ein.' }] }) + const { wurzel } = fbSeite(daten) + await fx.klick(fx.finde(wurzel, '[data-aktion="aendern"][data-schritt="zeiten"]')) + assert.equal(fbSchritt(fx, wurzel), 'zeiten') + assert.equal(fx.finde(wurzel, '.tm-bestellen'), null, 'die Bestellseite ist weg') + assert.equal(fx.text(fx.finde(wurzel, 'h3')), 'Zeiten') +})) + +test('renderFragebogen: freie Navigation über die Liste; der Fokus geht auf die Schrittüberschrift', mitDom(undefined, async (fx) => { + const { wurzel } = fbSeite(fbDaten({ fragebogen: fbBis(2) })) + await fx.klick(fx.finde(wurzel, 'li[data-schritt="wer"] button')) + assert.equal(fbSchritt(fx, wurzel), 'wer') + const kopf = fx.finde(wurzel, 'h3') + assert.equal(fx.text(kopf), 'Wer wird gebucht') + assert.equal(kopf.getAttribute('tabindex'), '-1') + assert.ok(document.activeElement === kopf) + assert.deepEqual(fx.fetchLog, [], 'Navigation allein ruft nichts auf') +})) + +test('renderFragebogen: die Leiste führt auch zur Bestellseite; dort geht der Fokus auf die Überschrift', mitDom(undefined, async (fx) => { + const { wurzel } = fbSeite(await bestellDaten({ fragebogen: fbBis(2) })) + assert.equal(fbSchritt(fx, wurzel), 'leistungen') + await fx.klick(fx.finde(wurzel, 'li[data-schritt="bestellen"] button')) + assert.equal(fbSchritt(fx, wurzel), 'bestellen') + assert.ok(fx.finde(wurzel, '.tm-bestellen')) + assert.ok(document.activeElement === fx.finde(wurzel, 'h3'), 'Fokus auf der Überschrift der Bestellseite') + assert.deepEqual(fx.fetchLog, []) +})) + +test('renderFragebogen: der erste Aufruf nimmt keinen Fokus, nur der Wechsel', mitDom(undefined, (fx) => { + const { wurzel } = fbSeite(fbDaten({ fragebogen: fbBis(2) })) + assert.ok(document.activeElement !== fx.finde(wurzel, 'h3')) +})) + +test('renderFragebogen: ohne Entwurf führt die Liste nur in die Branchenwahl (die anderen Schritte brauchen eine Einstellung)', mitDom(undefined, async (fx) => { + const { wurzel } = fbSeite(fbStart()) + const knoepfe = fx.alle(wurzel, '.tm-schritt button') + assert.deepEqual(knoepfe.map((k) => k.disabled), [false, true, true, true, true, true, true, true, true]) + await fx.klick(knoepfe[3]) + assert.equal(fbSchritt(fx, wurzel), 'branche') +})) + +test('renderFragebogen: Hinweis zur Aufbewahrung des Entwurfs steht auf jedem Schritt', mitDom(undefined, async (fx) => { + const HINWEIS = 'Wir speichern deinen Entwurf bis zu 90 Tage nach deiner letzten Änderung.' + const { wurzel } = fbSeite(fbDaten({ fragebogen: fbBis(2) })) + assert.ok(fx.text(wurzel).includes(HINWEIS)) + await fx.klick(fx.finde(wurzel, 'li[data-schritt="bestellen"] button')) + assert.ok(fx.text(wurzel).includes(HINWEIS), 'auch auf der Bestellseite') + assert.ok(fx.text(fbSeite(fbStart()).wurzel).includes(HINWEIS), 'auch bei der Branchenwahl') +})) + +test('renderFragebogen: Kundenansicht zeigt den Hinweis, sonst nicht', mitDom(undefined, (fx) => { + const mit = fbSeite(fbDaten({ kundenansicht: true })).wurzel + assert.match(fx.text(fx.finde(mit, '.tm-box[role="note"]')), /Kundenansicht/) + 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()) + const radios = fx.alle(wurzel, 'input[type="radio"]') + assert.deepEqual(radios.map((r) => r.getAttribute('value')), BRANCHEN.map((b) => b.key)) + assert.equal(new Set(radios.map((r) => r.name)).size, 1, 'eine Gruppe') + assert.ok(radios.every((r) => !r.checked), 'keine Vorauswahl') + assert.deepEqual(fbKnoepfe(fx, wurzel).filter((t) => /starten|Weiter/.test(t)), ['Mit dieser Branche starten']) + const start = fx.finde(wurzel, '[data-aktion="branche-starten"]') + assert.equal(start.disabled, true) + assert.deepEqual(felderVon(fx, wurzel).filter((n) => n.startsWith('betrieb.')), ['betrieb.name', 'betrieb.adresse', 'betrieb.kleinunternehmer'], 'nur diese drei Betriebsfelder') + assert.equal(fbFeld(fx, wurzel, 'betrieb.name').value, BETRIEB_KONTO.name, 'betriebVorschlag') + assert.equal(fbFeld(fx, wurzel, 'betrieb.adresse').value, BETRIEB_KONTO.adresse) + assert.equal(fbFeld(fx, wurzel, 'betrieb.kleinunternehmer').checked, false) + await fx.klick(fbRadio(fx, wurzel, 'salon')) + assert.equal(fx.finde(wurzel, '[data-aktion="branche-starten"]').disabled, false) + assert.ok(fx.text(wurzel).includes('Friseur und Salon')) + assert.ok(fx.text(wurzel).includes('Haarschnitt, Färben und Bartpflege.'), 'Beschreibung der Vorlage') +})) + +test('Schritt 1 (a): Gesundheitsbereich zeigt den Erklärtext statt „Weiter“, es gibt keinen Start-Knopf und es geht keine Anfrage raus', mitDom(undefined, async (fx) => { + const { wurzel } = fbSeite(fbStart()) + await fx.klick(fbRadio(fx, wurzel, 'salon')) + assert.ok(fx.finde(wurzel, '[data-aktion="branche-starten"]')) + await fx.klick(fbRadio(fx, wurzel, 'gesundheit')) + const hinweis = fx.finde(wurzel, '[data-hinweis="gesundheit"]') + assert.ok(hinweis, 'der Erklärtext steht im Baum') + assert.match(fx.text(hinweis), /Gesundheitsbereich/) + assert.match(fx.text(hinweis), /nicht verfügbar/) + assert.equal(fx.finde(wurzel, '[data-aktion="branche-starten"]'), null) + assert.deepEqual(fbKnoepfe(fx, wurzel).filter((t) => /Mit dieser Branche starten|Weiter/.test(t)), []) + // auch ein Absenden des Formulars (Enter in einem Feld) ruft nichts auf + await fx.sende(fx.finde(wurzel, 'form'), 'submit') + await fx.klick(fx.finde(wurzel, 'li[data-schritt="branche"] button')) + assert.deepEqual(fx.fetchLog, [], 'kein einziger Aufruf') + // zurück zu einer anderen Branche: Knopf wieder da, Erklärtext weg + await fx.klick(fbRadio(fx, wurzel, 'kosmetik')) + assert.ok(fx.finde(wurzel, '[data-aktion="branche-starten"]')) + assert.equal(fx.finde(wurzel, '[data-hinweis="gesundheit"]'), null) +})) + +test('Schritt 1 starten: POST vorlage { branche, betrieb }, danach Neuladen und PUT mit „branche“ in bestaetigt, weiter zu Schritt 2', mitDom({ + antworten: [ + { status: 201, body: { einstellung: salon(), version: 1, fehlt: [], fragebogen: fbBis(0) } }, + { body: fbDaten({ version: 1, fragebogen: fbBis(0) }) }, + fbAntwort(1), + ], +}, async (fx) => { + const { wurzel, ctx } = fbSeite(fbStart(), { stumm: false }) + await fx.setze(fbFeld(fx, wurzel, 'betrieb.name'), 'Salon Neu') + await fx.klick(fbRadio(fx, wurzel, 'salon')) + await fx.klick(fx.finde(wurzel, '[data-aktion="branche-starten"]')) + assert.deepEqual(fbAufrufe(fx), [FB_POST, ['GET', FB_GET], FB_PUT]) + assert.deepEqual(fx.fetchLog[0].body, { projectId: 'p1', branche: 'salon', betrieb: { name: 'Salon Neu', adresse: BETRIEB_KONTO.adresse, kleinunternehmer: false } }) + assert.equal(fx.fetchLog[2].body.projectId, 'p1') + assert.deepEqual(fx.fetchLog[2].body.fragebogen.bestaetigt, ['branche']) + assert.equal(fx.fetchLog[2].body.fragebogen.branche, 'salon') + assert.equal(fbSchritt(fx, wurzel), 'wer') + assert.deepEqual(ctx.daten.fragebogen.bestaetigt, ['branche'], 'die Antwort des PUT gilt') + assert.ok(document.activeElement === fx.finde(wurzel, 'h3')) +})) + +test('Schritt 1 starten: die Website des Projekts aus dem Vorschlag geht mit in betrieb (nur der Vorschlag kennt sie)', mitDom({ + antworten: [ + { status: 201, body: { einstellung: salon(), version: 1, fehlt: [], fragebogen: fbBis(0) } }, + { body: fbDaten({ version: 1, fragebogen: fbBis(0) }) }, + fbAntwort(1), + ], +}, async (fx) => { + const { wurzel } = fbSeite(fbStart({ betriebVorschlag: { ...BETRIEB_KONTO, websiteUrl: 'https://salon.example.invalid' } }), { stumm: false }) + await fx.klick(fbRadio(fx, wurzel, 'salon')) + await fx.klick(fx.finde(wurzel, '[data-aktion="branche-starten"]')) + assert.equal(fx.fetchLog[0].body.betrieb.websiteUrl, 'https://salon.example.invalid') + assert.deepEqual(Object.keys(fx.fetchLog[0].body.betrieb).sort(), ['adresse', 'kleinunternehmer', 'name', 'websiteUrl']) +})) + +test('Schritt 1 starten: übernimmt der Server „Kleinunternehmer“ nicht (er kennt nur fünf Betriebsfelder), geht es in einem Nachtrag-PATCH nach', mitDom({ + antworten: [ + { status: 201, body: { einstellung: salon(), version: 1, fehlt: [], fragebogen: fbBis(0) } }, + { body: fbDaten({ version: 1, fragebogen: fbBis(0) }) }, + fbPatchAntwort(2), + { body: fbDaten({ version: 2, fragebogen: fbBis(0) }) }, + fbAntwort(1), + ], +}, async (fx) => { + const { wurzel } = fbSeite(fbStart(), { stumm: false }) + await fx.klick(fbFeld(fx, wurzel, 'betrieb.kleinunternehmer')) + await fx.klick(fbRadio(fx, wurzel, 'salon')) + await fx.klick(fx.finde(wurzel, '[data-aktion="branche-starten"]')) + assert.deepEqual(fbAufrufe(fx), [FB_POST, ['GET', FB_GET], FB_PATCH, ['GET', FB_GET], FB_PUT]) + assert.equal(fx.fetchLog[0].body.betrieb.kleinunternehmer, true) + assert.equal(fx.fetchLog[2].body.version, 1) + assert.equal(fx.fetchLog[2].body.einstellung.betrieb.kleinunternehmer, true) + assert.equal(fbSchritt(fx, wurzel), 'wer') +})) + +test('Schritt 1 starten: ein Fehler des Servers steht am Feld, danach kein Neuladen und kein PUT, der Knopf ist wieder frei', mitDom({ + antworten: [{ status: 400, body: { error: 'Bitte gib den Namen an.', fehler: { 'betrieb.name': 'Bitte gib den Namen deines Betriebs an.' } } }], +}, async (fx) => { + const { wurzel } = fbSeite(fbStart(), { stumm: false }) + await fx.klick(fbRadio(fx, wurzel, 'salon')) + await fx.klick(fx.finde(wurzel, '[data-aktion="branche-starten"]')) + assert.deepEqual(fbAufrufe(fx), [FB_POST]) + assert.equal(fbFehler(fx, wurzel, 'betrieb.name'), 'Bitte gib den Namen deines Betriebs an.') + assert.equal(fbFeld(fx, wurzel, 'betrieb.name').getAttribute('aria-invalid'), 'true') + assert.equal(fx.finde(wurzel, '[data-aktion="branche-starten"]').disabled, false) + assert.equal(fbSchritt(fx, wurzel), 'branche') +})) + +test('Schritt 1 starten in der Kundenansicht: POST und Neuladen ja, PUT nein, weiter zu Schritt 2', mitDom({ + antworten: [ + { status: 201, body: { einstellung: salon(), version: 1, fehlt: [], fragebogen: fbBis(0) } }, + { body: fbDaten({ version: 1, fragebogen: fbBis(0), kundenansicht: true }) }, + ], +}, async (fx) => { + const { wurzel } = fbSeite(fbStart({ kundenansicht: true }), { stumm: false }) + await fx.klick(fbRadio(fx, wurzel, 'salon')) + await fx.klick(fx.finde(wurzel, '[data-aktion="branche-starten"]')) + assert.deepEqual(fbAufrufe(fx), [FB_POST, ['GET', FB_GET]]) + assert.equal(fbSchritt(fx, wurzel), 'wer') +})) + +test('Schritt 1 starten: Fehlschlag des PUT nach angelegtem Entwurf zeichnet den Schritt mit Entwurf neu und meldet den Fehler', mitDom({ + antworten: [ + { status: 201, body: { einstellung: salon(), version: 1, fehlt: [], fragebogen: fbBis(0) } }, + { body: fbDaten({ version: 1, fragebogen: fbBis(0) }) }, + { status: 500, body: { error: 'Fehler 500' } }, + ], +}, async (fx) => { + const { wurzel } = fbSeite(fbStart(), { stumm: false }) + await fx.klick(fbRadio(fx, wurzel, 'salon')) + await fx.klick(fx.finde(wurzel, '[data-aktion="branche-starten"]')) + assert.equal(fbSchritt(fx, wurzel), 'branche') + assert.equal(fx.finde(wurzel, '[data-aktion="branche-starten"]'), null, 'der Entwurf besteht: keine zweite Anlage') + assert.ok(fbWeiter(fx, wurzel), 'stattdessen „Weiter“') + assert.equal(fbFehler(fx, wurzel, '_'), 'Fehler 500') +})) + +// ------------------------------------------------------- Schritt 1 mit Entwurf: Branche wechseln +test('Schritt 1 mit Entwurf: gewählte Branche, Betriebsfelder, „Weiter“ speichert in einem PATCH und bestätigt mit PUT', mitDom({ + antworten: [fbPatchAntwort(4), fbAntwort(1)], +}, async (fx) => { + const { wurzel } = fbSeite(fbDaten()) + assert.match(fx.text(fx.finde(wurzel, '.tm-fb-branche')), /Friseur und Salon/) + assert.deepEqual(felderVon(fx, wurzel).filter((n) => n.startsWith('betrieb.')), ['betrieb.name', 'betrieb.adresse', 'betrieb.kleinunternehmer']) + assert.equal(fx.alle(wurzel, 'input[type="radio"]').length, 0, 'die Branche wechselt man nur über die Rückfrage') + await fx.setze(fbFeld(fx, wurzel, 'betrieb.adresse'), 'Neue Straße 2, 67655 Kaiserslautern') + await fx.klick(fbWeiter(fx, wurzel)) + assert.deepEqual(fbAufrufe(fx), [FB_PATCH, FB_PUT]) + assert.equal(fx.fetchLog[0].body.einstellung.betrieb.adresse, 'Neue Straße 2, 67655 Kaiserslautern') + assert.equal(fx.fetchLog[0].body.version, 3) + assert.deepEqual(fx.fetchLog[1].body.fragebogen.bestaetigt, ['branche']) + assert.equal(fbSchritt(fx, wurzel), 'wer') +})) + +test('Schritt 1 mit Entwurf: ohne gespeicherte Branche kein Aufruf, der Hinweis steht allgemein', mitDom(undefined, async (fx) => { + const { wurzel } = fbSeite(fbDaten({ fragebogen: { ...leerFragebogen(), branche: null } })) + assert.equal(fx.text(fx.finde(wurzel, '[data-aktion="branche-wechseln"]')), 'Branche wählen') + await fx.klick(fbWeiter(fx, wurzel)) + assert.deepEqual(fx.fetchLog, []) + assert.match(fbFehler(fx, wurzel, '_'), /Branche/) +})) + +test('Branche wechseln: Rückfrage in der Seite mit dem Wortlaut, Abbrechen ruft nichts auf', mitDom(undefined, async (fx) => { + const { wurzel } = fbSeite(fbDaten({ fragebogen: fbBis(3) })) + await fbGehe(fx, wurzel, 'branche') + assert.equal(fx.finde(wurzel, 'form[aria-label="Branche wechseln?"]'), null) + await fx.klick(fx.finde(wurzel, '[data-aktion="branche-wechseln"]')) + const frage = fx.finde(wurzel, 'form[aria-label="Branche wechseln?"]') + assert.ok(frage, 'Rückfrage') + assert.match(fx.text(frage), /ersetzt deine Leistungen, Zeiten und Regeln/) + assert.match(fx.text(frage), /Bestätigungen gehen zurück/) + assert.ok(document.activeElement === frage) + assert.equal(frage.closest('form[aria-labelledby]'), null, 'nicht in das Formular des Schritts geschachtelt') + assert.equal(fx.finde(frage, '[data-aktion="branche-starten"]').disabled, true, 'bis zur Wahl gesperrt') + await fx.klick(fx.alle(frage, 'button').find((k) => fx.text(k) === 'Abbrechen')) + assert.equal(fx.finde(wurzel, 'form[aria-label="Branche wechseln?"]'), null) + assert.deepEqual(fx.fetchLog, []) + assert.ok(document.activeElement === fx.finde(wurzel, '[data-aktion="branche-wechseln"]'), 'Fokus zurück auf den Knopf') +})) + +test('Branche wechseln: „Ja, Branche wechseln“ sendet ersetzen: true mit version, übernimmt den Betrieb und zeigt Schritt 1 neu; kein PUT', mitDom({ + antworten: [ + { status: 201, body: { einstellung: salon(), version: 6, fehlt: [], fragebogen: fbBis(0, { branche: 'kosmetik' }) } }, + // der Server übernimmt Impressum/Datenschutz/AGB/Kleinunternehmer nicht: im neuen Entwurf fehlen sie + { body: fbDaten({ version: 6, fragebogen: fbBis(0, { branche: 'kosmetik' }), einstellung: { ...salon(), betrieb: { ...salon().betrieb, impressumUrl: '', datenschutzUrl: '' } } }) }, + fbPatchAntwort(7), + { body: fbDaten({ version: 7, fragebogen: fbBis(0, { branche: 'kosmetik' }) }) }, + ], +}, async (fx) => { + const daten = fbDaten({ fragebogen: fbBis(3), version: 5 }) + const { wurzel } = fbSeite(daten, { stumm: false }) + await fbGehe(fx, wurzel, 'branche') + await fx.klick(fx.finde(wurzel, '[data-aktion="branche-wechseln"]')) + const frage = fx.finde(wurzel, 'form[aria-label="Branche wechseln?"]') + await fx.klick(fbRadio(fx, frage, 'kosmetik')) + await fx.klick(fx.finde(frage, '[data-aktion="branche-starten"]')) + assert.deepEqual(fbAufrufe(fx), [FB_POST, ['GET', FB_GET], FB_PATCH, ['GET', FB_GET]]) + const post = fx.fetchLog[0].body + assert.equal(post.ersetzen, true) + assert.equal(post.version, 5) + assert.equal(post.branche, 'kosmetik') + assert.equal(post.projectId, 'p1') + assert.equal(post.betrieb.name, BETRIEB.name) + assert.equal(post.betrieb.telefon, BETRIEB.telefon) + assert.equal(post.betrieb.email, BETRIEB.email) + assert.equal(post.betrieb.adresse, BETRIEB.adresse) + assert.equal(fx.fetchLog[2].body.einstellung.betrieb.impressumUrl, BETRIEB.impressumUrl, 'Nachtrag: was der Server nicht übernimmt, kommt zurück') + assert.equal(fx.fetchLog[2].body.einstellung.betrieb.datenschutzUrl, BETRIEB.datenschutzUrl) + assert.equal(fx.fetchLog[2].body.version, 6) + assert.equal(fbSchritt(fx, wurzel), 'branche', 'Schritt 1 neu, alle Bestätigungen sind weg') + assert.equal(fx.alle(wurzel, '.tm-schritt[data-erledigt]').length, 0) + assert.equal(fx.finde(wurzel, 'form[aria-label="Branche wechseln?"]'), null) +})) + +test('Branche wechseln: Gesundheitsbereich in der Rückfrage ⇒ Erklärtext, kein Bestätigen-Knopf, kein Aufruf', mitDom(undefined, async (fx) => { + const { wurzel } = fbSeite(fbDaten()) + await fx.klick(fx.finde(wurzel, '[data-aktion="branche-wechseln"]')) + const frage = fx.finde(wurzel, 'form[aria-label="Branche wechseln?"]') + await fx.klick(fbRadio(fx, frage, 'gesundheit')) + assert.ok(fx.finde(frage, '[data-hinweis="gesundheit"]')) + assert.equal(fx.finde(frage, '[data-aktion="branche-starten"]'), null) + await fx.sende(frage, 'submit') + assert.deepEqual(fx.fetchLog, []) +})) + +test('Branche wechseln: 409 EINSTELLUNG_VERALTET zeigt den Hinweis „Neu laden“, die Rückfrage bleibt ohne Folgeaufruf', mitDom({ + antworten: [{ status: 409, body: { error: 'Die Einstellungen wurden inzwischen geändert.', code: 'EINSTELLUNG_VERALTET' } }], +}, async (fx) => { + const { wurzel } = fbSeite(fbDaten(), { stumm: false }) + await fx.klick(fx.finde(wurzel, '[data-aktion="branche-wechseln"]')) + const frage = fx.finde(wurzel, 'form[aria-label="Branche wechseln?"]') + await fx.klick(fbRadio(fx, frage, 'kosmetik')) + await fx.klick(fx.finde(frage, '[data-aktion="branche-starten"]')) + assert.deepEqual(fbAufrufe(fx), [FB_POST]) + const konflikt = fx.finde(wurzel, '.tm-box-warn[role="alert"]') + assert.equal(konflikt.hidden, false) + assert.match(fx.text(konflikt), /Neu laden/) +})) + +test('Branche wechseln: ein anderer Fehler steht in der Rückfrage', mitDom({ + antworten: [{ status: 409, body: { error: 'Die Branche lässt sich jetzt nicht mehr wechseln.', code: 'nicht_ersetzbar' } }], +}, async (fx) => { + const { wurzel } = fbSeite(fbDaten(), { stumm: false }) + await fx.klick(fx.finde(wurzel, '[data-aktion="branche-wechseln"]')) + const frage = fx.finde(wurzel, 'form[aria-label="Branche wechseln?"]') + await fx.klick(fbRadio(fx, frage, 'kosmetik')) + await fx.klick(fx.finde(frage, '[data-aktion="branche-starten"]')) + assert.equal(fbFehler(fx, frage, '_'), 'Die Branche lässt sich jetzt nicht mehr wechseln.') + assert.equal(fx.finde(frage, '[data-aktion="branche-starten"]').disabled, false) +})) + +// ------------------------------------------------------- Schritt 2: Wer wird gebucht +const fbWerSeite = (aenderung = {}) => fbSeite(fbDaten({ fragebogen: fbBis(1), ...aenderung })) +const fbNamen = (fx, wurzel) => fbFeld(fx, wurzel, 'ressourcen') + +test('Schritt 2: Einzelperson / Team / Plätze, vorbelegt aus den Ressourcen; die Namen stehen je Zeile', mitDom(undefined, async (fx) => { + const { wurzel } = fbWerSeite() + assert.equal(fbSchritt(fx, wurzel), 'wer') + assert.deepEqual(fx.alle(wurzel, 'input[name="termine-wer-art"]').map((r) => r.getAttribute('value')), ['einzel', 'team', 'plaetze']) + assert.equal(fbRadio(fx, wurzel, 'einzel').checked, true, 'eine Person: Einzelperson') + assert.equal(fbNamen(fx, wurzel).value, 'Stylist/in') + const e = salon() + e.ressourcen = zweiRessourcen() + const team = fbWerSeite({ einstellung: e }).wurzel + assert.equal(fbRadio(fx, team, 'team').checked, true) + assert.equal(fbNamen(fx, team).value, 'Anna\nBen') + const plaetze = salon() + plaetze.ressourcen = zweiRessourcen().map((r) => ({ ...r, typ: 'platz' })) + assert.equal(fbRadio(fx, fbWerSeite({ einstellung: plaetze }).wurzel, 'plaetze').checked, true) +})) + +test('Schritt 2 (f): die Namen „Anna“ und „Ben“ ⇒ der PATCH enthält beide Ressourcen und jede Leistung ist beiden zugeordnet', mitDom({ + antworten: [fbPatchAntwort(4), fbAntwort(2)], +}, async (fx) => { + const { wurzel, ctx } = fbWerSeite() + const alt = ctx.daten.einstellung.ressourcen[0] + await fx.klick(fbRadio(fx, wurzel, 'team')) + await fx.setze(fbNamen(fx, wurzel), 'Anna\nBen') + await fx.klick(fbWeiter(fx, wurzel)) + assert.deepEqual(fbAufrufe(fx), [FB_PATCH, FB_PUT]) + const einstellung = fx.fetchLog[0].body.einstellung + assert.deepEqual(einstellung.ressourcen.map((r) => [r.name, r.typ, r.aktiv]), [['Anna', 'person', true], ['Ben', 'person', true]]) + assert.equal(einstellung.ressourcen[0].id, alt.id, 'der erste Eintrag wird umbenannt: ID bleibt') + assert.deepEqual(einstellung.ressourcen[1].plan, einstellung.ressourcen[0].plan, 'der neue Eintrag übernimmt den Plan') + const ids = einstellung.ressourcen.map((r) => r.id) + assert.equal(new Set(ids).size, 2) + assert.ok(einstellung.leistungen.length > 0) + for (const l of einstellung.leistungen) assert.deepEqual([...l.ressourcen].sort(), [...ids].sort(), `${l.name}: beiden zugeordnet`) + assert.deepEqual(fx.fetchLog[1].body.fragebogen.bestaetigt, ['branche', 'wer']) + assert.equal(fbSchritt(fx, wurzel), 'leistungen') +})) + +test('Schritt 2: entfernte Personen verschwinden aus den Leistungen und aus den Abwesenheiten; eine Abwesenheit für alle bleibt', mitDom({ + antworten: [fbPatchAntwort(4), fbAntwort(2)], +}, async (fx) => { + const e = salon() + e.ressourcen = zweiRessourcen() + for (const l of e.leistungen) l.ressourcen = ['r_anna0001', 'r_ben00002'] + e.abwesenheiten = [ + { id: 'a_test0001', ressourceId: 'r_ben00002', von: '2026-12-24', bis: '2026-12-26', titel: 'Urlaub Ben' }, + { id: 'a_test0002', ressourceId: 'r_anna0001', von: '2027-01-02', bis: '2027-01-03', titel: 'Urlaub Anna' }, + { id: 'a_test0003', ressourceId: null, von: '2027-01-04', bis: '2027-01-04', titel: 'Inventur' }, + ] + const { wurzel } = fbWerSeite({ einstellung: e }) + await fx.setze(fbNamen(fx, wurzel), 'Anna') + await fx.klick(fbRadio(fx, wurzel, 'einzel')) + await fx.klick(fbWeiter(fx, wurzel)) + const einstellung = fx.fetchLog[0].body.einstellung + assert.deepEqual(einstellung.ressourcen.map((r) => r.id), ['r_anna0001']) + for (const l of einstellung.leistungen) assert.deepEqual(l.ressourcen, ['r_anna0001']) + assert.deepEqual(einstellung.abwesenheiten.map((a) => a.id), ['a_test0002', 'a_test0003']) +})) + +test('Schritt 2: Plätze ⇒ typ platz für alle Einträge', mitDom({ antworten: [fbPatchAntwort(4), fbAntwort(2)] }, async (fx) => { + const { wurzel } = fbWerSeite() + await fx.klick(fbRadio(fx, wurzel, 'plaetze')) + await fx.setze(fbNamen(fx, wurzel), 'Stuhl 1\nStuhl 2\n\n Stuhl 3 ') + await fx.klick(fbWeiter(fx, wurzel)) + assert.deepEqual(fx.fetchLog[0].body.einstellung.ressourcen.map((r) => [r.name, r.typ]), [['Stuhl 1', 'platz'], ['Stuhl 2', 'platz'], ['Stuhl 3', 'platz']]) +})) + +test('Schritt 2: mindestens ein Name, sonst Fehler am Feld und kein Aufruf', mitDom(undefined, async (fx) => { + const { wurzel } = fbWerSeite() + await fx.setze(fbNamen(fx, wurzel), ' \n \n') + await fx.klick(fbWeiter(fx, wurzel)) + assert.deepEqual(fx.fetchLog, []) + assert.match(fbFehler(fx, wurzel, 'ressourcen'), /mindestens einen Namen/) + assert.equal(fbNamen(fx, wurzel).getAttribute('aria-invalid'), 'true') + assert.equal(fbSchritt(fx, wurzel), 'wer') +})) + +test('Schritt 2: Einzelperson mit zwei Namen, zu viele Einträge und zu lange Namen werden am Feld abgewiesen', mitDom(undefined, async (fx) => { + const { wurzel } = fbWerSeite() + await fx.setze(fbNamen(fx, wurzel), 'Anna\nBen') + await fx.klick(fbWeiter(fx, wurzel)) + assert.match(fbFehler(fx, wurzel, 'ressourcen'), /Einzelperson/) + await fx.klick(fbRadio(fx, wurzel, 'team')) + await fx.setze(fbNamen(fx, wurzel), Array.from({ length: LIMITS.ressourcen + 1 }, (_, i) => `Person ${i + 1}`).join('\n')) + await fx.klick(fbWeiter(fx, wurzel)) + assert.match(fbFehler(fx, wurzel, 'ressourcen'), new RegExp(String(LIMITS.ressourcen))) + await fx.setze(fbNamen(fx, wurzel), 'x'.repeat(MAX.ressourcenName + 1)) + await fx.klick(fbWeiter(fx, wurzel)) + assert.match(fbFehler(fx, wurzel, 'ressourcen'), new RegExp(String(MAX.ressourcenName))) + assert.deepEqual(fx.fetchLog, [], 'nichts davon geht raus') +})) + +// ------------------------------------------------------- Schritt 3: Leistungen +const fbLeistungenSeite = (aenderung = {}) => fbSeite(fbDaten({ fragebogen: fbBis(2), ...aenderung })) + +test('Schritt 3: der Reiter Leistungen steckt unverändert im Schritt, dazu der Haken „Preise geprüft“', mitDom(undefined, (fx) => { + const { wurzel } = fbLeistungenSeite() + assert.equal(fbSchritt(fx, wurzel), 'leistungen') + assert.ok(fx.finde(wurzel, '[data-werkzeug="leistung-neu"]'), '+ Leistung anlegen') + assert.equal(fx.alle(wurzel, 'li.tm-eintrag[data-id]').length, salon().leistungen.length, 'die Leistungen der Vorlage') + const haken = fbFeld(fx, wurzel, 'preiseGeprueft') + assert.equal(haken.type, 'checkbox') + assert.equal(haken.checked, false) + assert.equal(fx.text(haken.closest('label')), 'Ich habe die Preise geprüft (die Preise der Vorlage sind Platzhalter)') + assert.equal(fbSeite(fbDaten({ fragebogen: fbBis(2, { preiseGeprueft: true }) })).wurzel.querySelector('[data-feld="preiseGeprueft"]').checked, true, 'wie gespeichert') + const form = fbWeiter(fx, wurzel).closest('form') + assert.equal(form.querySelector('form'), null) + assert.equal(fx.alle(form, '.tm-eintrag').length, 0, 'die Leistungen stehen nicht im Formular des Schritts (kein verschachteltes Formular)') +})) + +test('Schritt 3 (c): mit Preisen ohne den Haken meldet „Weiter“ den Fehler am Haken und ruft nichts auf', mitDom(undefined, async (fx) => { + const { wurzel } = fbLeistungenSeite() + await fx.klick(fbWeiter(fx, wurzel)) + assert.deepEqual(fx.fetchLog, []) + assert.match(fbFehler(fx, wurzel, 'preiseGeprueft'), /Preise/) + assert.equal(fbFeld(fx, wurzel, 'preiseGeprueft').getAttribute('aria-invalid'), 'true') + assert.ok(document.activeElement === fbFeld(fx, wurzel, 'preiseGeprueft')) + assert.equal(fbSchritt(fx, wurzel), 'leistungen') +})) + +test('Schritt 3 (c): mit dem Haken genau ein PUT (kein PATCH) mit preiseGeprueft: true und dem Schritt in bestaetigt', mitDom({ + antworten: [fbAntwort(3, { preiseGeprueft: true })], +}, async (fx) => { + const { wurzel } = fbLeistungenSeite() + await fx.klick(fbFeld(fx, wurzel, 'preiseGeprueft')) + await fx.klick(fbWeiter(fx, wurzel)) + assert.deepEqual(fbAufrufe(fx), [FB_PUT]) + assert.equal(fx.fetchLog[0].body.projectId, 'p1') + assert.equal(fx.fetchLog[0].body.fragebogen.preiseGeprueft, true) + assert.deepEqual(fx.fetchLog[0].body.fragebogen.bestaetigt, ['branche', 'wer', 'leistungen']) + assert.equal(fbSchritt(fx, wurzel), 'zeiten') +})) + +test('Schritt 3: ohne Preise braucht „Weiter“ den Haken nicht', mitDom({ antworten: [fbAntwort(3)] }, async (fx) => { + const e = salon() + for (const l of e.leistungen) Object.assign(l, { preisArt: 'keiner', preisCent: 0 }) + const { wurzel } = fbLeistungenSeite({ einstellung: e }) + await fx.klick(fbWeiter(fx, wurzel)) + assert.deepEqual(fbAufrufe(fx), [FB_PUT]) + assert.equal(fx.fetchLog[0].body.fragebogen.preiseGeprueft, false) +})) + +test('Schritt 3: ohne sichtbare Leistung (alle inaktiv oder ohne aktive Person) meldet „Weiter“ den Fehler und ruft nichts auf', mitDom(undefined, async (fx) => { + const keine = salon() + keine.leistungen = [] + const inaktiv = salon() + for (const l of inaktiv.leistungen) l.aktiv = false + const ohnePerson = salon() + ohnePerson.ressourcen[0].aktiv = false + for (const e of [keine, inaktiv, ohnePerson]) { + const { wurzel } = fbLeistungenSeite({ einstellung: e }) + await fx.klick(fbFeld(fx, wurzel, 'preiseGeprueft')) + await fx.klick(fbWeiter(fx, wurzel)) + assert.match(fbFehler(fx, wurzel, 'leistungen'), /mindestens eine aktive Leistung/) + } + assert.deepEqual(fx.fetchLog, []) +})) + +test('Schritt 3: Leistungen, die der Kunde im Schritt ändert, zählen sofort (Prüfung liest ctx.daten frisch)', mitDom({ antworten: [fbAntwort(3)] }, async (fx) => { + const { wurzel, ctx } = fbLeistungenSeite() + ctx.daten.einstellung.leistungen = [] + await fx.klick(fbWeiter(fx, wurzel)) + assert.match(fbFehler(fx, wurzel, 'leistungen'), /mindestens eine aktive Leistung/) + ctx.daten.einstellung.leistungen = salon().leistungen.map((l) => ({ ...l, ressourcen: ctx.daten.einstellung.ressourcen.map((r) => r.id), preisArt: 'keiner', preisCent: 0 })) + await fx.klick(fbWeiter(fx, wurzel)) + assert.deepEqual(fbAufrufe(fx), [FB_PUT]) +})) + +// ------------------------------------------------------- „Weiter“ allgemein (Schritt 7 als Schritt mit Feldern) +const fbSiebenSeite = (aenderung = {}) => fbSeite(fbDaten({ fragebogen: fbBis(6), ...aenderung })) + +test('Schritt 7: Betrieb nur mit E-Mail, Anrede und Farbe, Erinnerung; Zurück und Weiter', mitDom(undefined, (fx) => { + const { wurzel } = fbSiebenSeite() + assert.equal(fbSchritt(fx, wurzel), 'benachrichtigung') + assert.deepEqual(felderVon(fx, wurzel).filter((n) => n.startsWith('betrieb.')), ['betrieb.email']) + assert.ok(fbFeld(fx, wurzel, 'anrede')) + assert.ok(fbFeld(fx, wurzel, 'erinnerung.anbieten')) + assert.deepEqual(fbKnoepfe(fx, wurzel).filter((t) => /Zurück|Weiter/.test(t)), ['Zurück', 'Weiter']) +})) + +test('„Weiter“ (b): genau ein PATCH, danach ein PUT mit dem Schritt in bestaetigt; PATCH und PUT in dieser Reihenfolge', mitDom({ + antworten: [fbPatchAntwort(4), fbAntwort(7)], +}, async (fx) => { + const { wurzel } = fbSiebenSeite() + await fx.setze(fbFeld(fx, wurzel, 'betrieb.email'), 'neu@example.invalid') + await fx.klick(fbWeiter(fx, wurzel)) + assert.deepEqual(fx.fetchLog.map((a) => [a.method, a.url]), [FB_PATCH, FB_PUT]) + const [patch, put] = fx.fetchLog + assert.equal(patch.body.projectId, 'p1') + assert.equal(patch.body.version, 3) + assert.equal(patch.body.einstellung.betrieb.email, 'neu@example.invalid') + assert.equal(put.body.projectId, 'p1') + assert.deepEqual(put.body.fragebogen.bestaetigt, [...SCHRITTE.slice(0, 6), 'benachrichtigung']) + assert.equal(fbSchritt(fx, wurzel), 'website') + assert.ok(document.activeElement === fx.finde(wurzel, 'h3'), 'Fokus auf der Überschrift des nächsten Schritts') +})) + +test('„Weiter“ (b): liest der Speicher nach dem PATCH neu (GET), steht der PUT danach und trägt den frischen Fragebogen', mitDom({ + antworten: [fbPatchAntwort(4), { body: fbDaten({ version: 4, fragebogen: fbBis(6, { bundeslandGewaehlt: true }) }) }, fbAntwort(7, { bundeslandGewaehlt: true })], +}, async (fx) => { + const { wurzel } = fbSeite(fbDaten({ fragebogen: fbBis(6) }), { stumm: false }) + await fx.klick(fbWeiter(fx, wurzel)) + assert.deepEqual(fbAufrufe(fx), [FB_PATCH, ['GET', FB_GET], FB_PUT]) + assert.equal(fx.fetchLog[2].body.fragebogen.bundeslandGewaehlt, true, 'der PUT baut auf dem frisch geladenen Stand auf') +})) + +test('„Weiter“ (b): antwortet der PATCH mit 400 und fehler, folgt kein PUT, der Fehler steht am Feld, der Schritt bleibt', mitDom({ + antworten: [{ status: 400, body: { error: 'Bitte gib eine gültige E-Mail-Adresse an.', fehler: { 'betrieb.email': 'Bitte gib eine gültige E-Mail-Adresse an.' } } }], +}, async (fx) => { + const { wurzel } = fbSiebenSeite() + await fx.setze(fbFeld(fx, wurzel, 'betrieb.email'), 'kaputt') + await fx.klick(fbWeiter(fx, wurzel)) + assert.deepEqual(fbAufrufe(fx), [FB_PATCH], 'kein PUT') + assert.equal(fbFehler(fx, wurzel, 'betrieb.email'), 'Bitte gib eine gültige E-Mail-Adresse an.') + assert.equal(fbFeld(fx, wurzel, 'betrieb.email').getAttribute('aria-invalid'), 'true') + assert.equal(fbSchritt(fx, wurzel), 'benachrichtigung') + assert.equal(fbWeiter(fx, wurzel).disabled, false, 'wieder frei') +})) + +test('„Weiter“: 409 EINSTELLUNG_VERALTET zeigt den Hinweis „Neu laden“ ohne PUT', mitDom({ + antworten: [{ status: 409, body: { error: 'Die Einstellungen wurden inzwischen geändert.', code: 'EINSTELLUNG_VERALTET' } }], +}, async (fx) => { + const { wurzel } = fbSiebenSeite() + await fx.klick(fbWeiter(fx, wurzel)) + assert.deepEqual(fbAufrufe(fx), [FB_PATCH]) + assert.equal(fx.finde(wurzel, '.tm-box-warn[role="alert"]').hidden, false) + assert.equal(fbSchritt(fx, wurzel), 'benachrichtigung') +})) + +test('„Weiter“: schlägt der PUT fehl, bleibt der Schritt stehen und der Fehler steht im Formular', mitDom({ + antworten: [fbPatchAntwort(4), { status: 409, body: { error: 'Es gibt noch keine Einstellungen.', code: 'keine_einstellung' } }], +}, async (fx) => { + const { wurzel } = fbSiebenSeite() + await fx.klick(fbWeiter(fx, wurzel)) + assert.deepEqual(fbAufrufe(fx), [FB_PATCH, FB_PUT]) + assert.equal(fbFehler(fx, wurzel, '_'), 'Es gibt noch keine Einstellungen.') + assert.equal(fbSchritt(fx, wurzel), 'benachrichtigung') + assert.equal(fbWeiter(fx, wurzel).disabled, false) +})) + +test('„Weiter“: ein PUT-Fehler mit Feldfehlern steht am Feld, einer ohne Platz im Formular allgemein', mitDom({ + antworten: [ + { status: 400, body: { error: 'Bitte „ja“ oder „nein“ wählen.', fehler: { preiseGeprueft: 'Bitte „ja“ oder „nein“ wählen.' } } }, + { status: 400, body: { error: 'Der Fragebogen ist ungültig.', fehler: { 'einbau.modus': 'Bitte wähle den Modus.' } } }, + ], +}, async (fx) => { + const { wurzel } = fbLeistungenSeite() + const form = fbWeiter(fx, wurzel).closest('form') + await fx.klick(fbFeld(fx, wurzel, 'preiseGeprueft')) + await fx.klick(fbWeiter(fx, wurzel)) + assert.equal(fbFehler(fx, form, 'preiseGeprueft'), 'Bitte „ja“ oder „nein“ wählen.') + assert.equal(fbSchritt(fx, wurzel), 'leistungen') + await fx.klick(fbWeiter(fx, wurzel)) + assert.equal(fbFehler(fx, form, '_'), 'Bitte wähle den Modus.', 'ohne Feld im Formular: allgemein') + assert.equal(fbFehler(fx, form, 'preiseGeprueft'), '', 'der alte Fehler ist weg') +})) + +test('„Weiter“: bleibt der Schritt stehen, hat der Knopf danach wieder den Fokus, der ihn beim Sperren verlieren kann; ein Feld mit Fehler behält ihn', mitDom({ + antworten: [{ status: 409, body: { error: 'Es gibt noch keine Einstellungen.', code: 'keine_einstellung' } }], +}, async (fx) => { + const { wurzel } = fbLeistungenSeite({ fragebogen: fbBis(2, { preiseGeprueft: true }) }) + const knopf = fbWeiter(fx, wurzel) + knopf.focus() + await fx.klick(knopf) + assert.deepEqual(fbAufrufe(fx), [FB_PUT]) + assert.ok(document.activeElement === knopf, 'wieder auf „Weiter“') + // Fehler am Feld: der Fokus gehört dem Feld + await fx.klick(fbFeld(fx, wurzel, 'preiseGeprueft')) + knopf.focus() + await fx.klick(knopf) + assert.ok(document.activeElement === fbFeld(fx, wurzel, 'preiseGeprueft')) +})) + +test('„Weiter“: zwei Absendungen gleichzeitig ergeben einen PATCH und einen PUT', mitDom({ antworten: [fbPatchAntwort(4), fbAntwort(7)] }, async (fx) => { + const { wurzel } = fbSiebenSeite() + const form = fbWeiter(fx, wurzel).closest('form') + await Promise.all([fx.sende(form, 'submit'), fx.sende(form, 'submit')]) + assert.deepEqual(fbAufrufe(fx), [FB_PATCH, FB_PUT]) +})) + +test('„Weiter“: während des Speicherns ist der Knopf gesperrt', mitDom(undefined, async (fx) => { + let freigeben + const warten = new Promise((r) => { freigeben = r }) + const { wurzel } = fbSiebenSeite() + fx.antworten.push(fbPatchAntwort(4), fbAntwort(7)) + // die Antwort des PATCH wird zurückgehalten: solange sie fehlt, ist der Knopf gesperrt + const original = globalThis.fetch + globalThis.fetch = async (url, optionen) => { await warten; return original(url, optionen) } + const knopf = fbWeiter(fx, wurzel) + const klick = fx.klick(knopf) + await fx.ruhe() + assert.equal(knopf.disabled, true) + assert.equal(fx.finde(wurzel, 'section[data-schritt]').getAttribute('aria-busy'), 'true', 'der Schritt ist aria-busy') + freigeben() + await klick + assert.deepEqual(fbAufrufe(fx), [FB_PATCH, FB_PUT]) + assert.equal(fbSchritt(fx, wurzel), 'website') +})) + +test('„Weiter“ (d): in der Kundenansicht sendet Schritt 7 den PATCH, aber keinen PUT und wechselt trotzdem zum nächsten Schritt', mitDom({ antworten: [fbPatchAntwort(4)] }, async (fx) => { + const { wurzel } = fbSiebenSeite({ kundenansicht: true }) + await fx.klick(fbWeiter(fx, wurzel)) + assert.deepEqual(fbAufrufe(fx), [FB_PATCH]) + assert.equal(fbSchritt(fx, wurzel), 'website') +})) + +test('„Weiter“ (d): in der Kundenansicht geht Schritt 3 ohne jeden Aufruf weiter', mitDom(undefined, async (fx) => { + const { wurzel } = fbLeistungenSeite({ kundenansicht: true }) + await fx.klick(fbFeld(fx, wurzel, 'preiseGeprueft')) + await fx.klick(fbWeiter(fx, wurzel)) + assert.deepEqual(fx.fetchLog, []) + assert.equal(fbSchritt(fx, wurzel), 'zeiten') +})) + +test('Kundenansicht: die Bestellseite bleibt gesperrt, auch wenn alles bestätigt und angehakt ist', mitDom(undefined, async (fx) => { + const { wurzel } = fbSeite(await bestellDaten({ kundenansicht: true })) + await alleAnhaken(fx, wurzel) + assert.equal(bestellKnopf(fx, wurzel).disabled, true) + assert.match(fx.text(wurzel), /In der Kundenansicht kann nicht bestellt werden/) +})) + +test('„Weiter“: verlässt der Kunde den Schritt oder die Ansicht, während der PATCH läuft, wird nichts mehr bestätigt oder gezeichnet', mitDom({ antworten: [fbPatchAntwort(4), fbAntwort(7)] }, async (fx) => { + const wurzel = h('div') + document.body.append(wurzel) + const s = { daten: fbDaten({ fragebogen: fbBis(6) }), nurLesen: false, meldungEl: h('div') } + let aktuell = true + const ctx = huelle.baueCtx(s, { projekt: { projectId: 'p1' }, aktuell: () => aktuell }) + ctx.neuLaden = async () => ctx.daten + fragebogenAnsicht.renderFragebogen(wurzel, ctx) + const klick = fx.klick(fbWeiter(fx, wurzel)) + aktuell = false + await klick + assert.deepEqual(fbAufrufe(fx), [FB_PATCH], 'kein PUT für eine verlassene Ansicht') + assert.equal(fbSchritt(fx, wurzel), 'benachrichtigung', 'und kein Wechsel') +})) + +test('Zurück: wechselt ohne Aufruf zum vorigen Schritt, im ersten Schritt gibt es den Knopf nicht', mitDom(undefined, async (fx) => { + const { wurzel } = fbSiebenSeite() + await fx.klick(fx.finde(wurzel, '[data-aktion="zurueck"]')) + assert.equal(fbSchritt(fx, wurzel), 'gastfelder') + assert.deepEqual(fx.fetchLog, []) + assert.equal(fx.finde(fbSeite(fbDaten()).wurzel, '[data-aktion="zurueck"]'), null) +})) + +test('Texte der Kunden stehen nur als Text im Baum (Namen, Vorschlag)', mitDom(undefined, (fx) => { + const boese = '' + const e = salon() + e.ressourcen[0].name = boese + const { wurzel } = fbWerSeite({ einstellung: e }) + assert.equal(fx.alle(wurzel, 'img').length, 0) + assert.equal(fbNamen(fx, wurzel).value, boese) + const start = fbSeite(fbStart({ betriebVorschlag: { ...BETRIEB_KONTO, name: boese } })).wurzel + assert.equal(fx.alle(start, 'img').length, 0) + assert.equal(fbFeld(fx, start, 'betrieb.name').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'", + ]) + assert.equal(code.match(/'PUT'/g).length, 1, 'PUT /fragebogen an genau einer Stelle') + assert.doesNotMatch(code, /'PATCH'/, 'PATCH nur über neuerSpeicher') + assert.doesNotMatch(code, /\bfetch\(|XMLHttpRequest/, 'Aufrufe nur über ctx.api') + assert.doesNotMatch(code, /localStorage|sessionStorage|document\.cookie|innerHTML|insertAdjacentHTML|outerHTML/) + assert.doesNotMatch(code, /\bSie\b|\bIhr(e[nmrs]?)?\b/) + assert.doesNotMatch(code, /\bhref\b/, 'kein Link in diesem Modul') + assert.match(code, /export function renderFragebogen\(root, ctx\)/) + assert.doesNotMatch(code, /import\s*\(/) +}) +const fbQuelleText = () => readFileSync(new URL('../public/termine-fragebogen.js', import.meta.url), 'utf8') + +test('termine.css: Fortschrittsleiste (tm-schritte, tm-schritt) und die Handy-Ansicht mit „Schritt n von 8“', () => { + const css = readFileSync(new URL('../public/termine.css', import.meta.url), 'utf8') + for (const klasse of ['tm-fragebogen', 'tm-schritte', 'tm-schritt', 'tm-schritt-knopf', 'tm-schritt-nr', 'tm-schritt-stand', 'tm-fb-schritt']) assert.match(css, new RegExp(`\\.${klasse}[\\s{,.:\\[]`), `.${klasse} ist gestaltet`) + assert.match(css, /\.tm-schritt\[aria-current='step'\]/, 'der aktuelle Schritt ist hervorgehoben') + assert.match(css.match(/\.tm-schritt-stand\s*\{[^}]*\}/)?.[0] ?? '', /display:\s*none/, 'am Rechner ohne Zähler') + const handy = css.slice(css.indexOf('@media (max-width: 880px)')) + 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') +}) diff --git a/test/termineView.test.js b/test/termineView.test.js index 84e427a..125743a 100644 --- a/test/termineView.test.js +++ b/test/termineView.test.js @@ -318,7 +318,7 @@ test('Portal: Ansicht #termine ist verdrahtet, funktionen.js lädt keine Terminp test('Ansicht-Module (public/termine*.js): Fremdtext nur als Text, keine Browser-Dialoge, Du-Form, ein einziger Link', () => { const dateien = readdirSync(new URL('../public/', import.meta.url)).filter((f) => /^termine.*\.js$/.test(f)).sort() assert.deepEqual(dateien, [ - 'termine-aktionen.js', 'termine-dom.js', 'termine-formulare.js', 'termine-gaestetexte.js', 'termine-speichern.js', 'termine-tab-einstellungen.js', + 'termine-aktionen.js', 'termine-dom.js', 'termine-formulare.js', 'termine-fragebogen.js', 'termine-gaestetexte.js', 'termine-speichern.js', 'termine-tab-einstellungen.js', 'termine-tab-leistungen.js', 'termine-tab-team.js', 'termine-tab-termine.js', 'termine-team-ausnahmen.js', 'termine-view.js', 'termine.js', ]) const ohneKommentare = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '') @@ -339,10 +339,12 @@ test('Ansicht-Module (public/termine*.js): Fremdtext nur als Text, keine Browser assert.ok(dom.includes('`/api/termine/export.csv?projectId=${encodeURIComponent(projectId)}`'), 'exportUrl codiert die Projekt-ID') // Speichern der Einstellungen gibt es genau einmal (termine-speichern.js), nicht in jedem Reiter assert.deepEqual(quellen.filter(([, quelle]) => /'PATCH'/.test(quelle)).map(([datei]) => datei), ['termine-speichern.js']) + // Den Fragebogen speichert genau eine Datei (termine-fragebogen.js, TP2): PUT /fragebogen gibt es nirgends sonst + assert.deepEqual(quellen.filter(([, quelle]) => /'PUT'/.test(quelle)).map(([datei]) => datei), ['termine-fragebogen.js']) // Editoren klappen ueber einen einzigen Baustein auf (editorUmschalter in termine-dom.js), nicht jeder mit eigenem Code assert.deepEqual(quellen.filter(([, quelle]) => /'aria-expanded', 'true'|setAttribute\('aria-expanded', 'true'\)/.test(quelle)).map(([datei]) => datei), ['termine-dom.js']) // Editoren merken sich keine Stelle in der Liste (sie kann veraltet sein), sondern suchen den Eintrag per ID - for (const [datei, quelle] of quellen.filter(([f]) => /^termine-(tab-(leistungen|team|einstellungen)|team-ausnahmen)\.js$/.test(f))) { + for (const [datei, quelle] of quellen.filter(([f]) => /^termine-(tab-(leistungen|team|einstellungen)|team-ausnahmen|fragebogen)\.js$/.test(f))) { assert.doesNotMatch(quelle, /entwurf\.\w+\[(index|i)\]/, `${datei}: Eintrag per ID suchen, nicht per Stelle`) } }) @@ -358,11 +360,19 @@ test('Ansicht-Module: Reiter sind Module mit render(panel, ctx), die Huelle kenn ['termine', 'Termine', 'renderTermine'], ['leistungen', 'Leistungen', 'renderLeistungen'], ['team', 'Team & Plätze', 'renderTeam'], ['einstellungen', 'Einstellungen', 'renderEinstellungen'], ]) // Reiter lesen Zustand nur aus ctx, nicht aus der Huelle - for (const datei of ['termine-tab-termine.js', 'termine-tab-leistungen.js', 'termine-tab-team.js', 'termine-tab-einstellungen.js', 'termine-team-ausnahmen.js', 'termine-speichern.js', 'termine-aktionen.js', 'termine-formulare.js']) { + for (const datei of ['termine-tab-termine.js', 'termine-tab-leistungen.js', 'termine-tab-team.js', 'termine-tab-einstellungen.js', 'termine-team-ausnahmen.js', 'termine-speichern.js', 'termine-aktionen.js', 'termine-formulare.js', 'termine-fragebogen.js']) { assert.doesNotMatch(lies(`public/${datei}`), /from '\.\/termine\.js'/, `${datei} importiert die Huelle nicht`) } }) +test('Fragebogen (TP2 C9a): renderFragebogen wird nur über termine.js geladen, nie absolut, nie von einem Reiter oder von funktionen.js', () => { + const dateien = readdirSync(new URL('../public/', import.meta.url)).filter((f) => /\.(js|html)$/.test(f)) + const ladende = dateien.filter((f) => /termine-fragebogen\.js/.test(lies(`public/${f}`))) + assert.ok(ladende.every((f) => f === 'termine.js'), `nur die Huelle darf den Fragebogen laden: ${ladende.join(', ')}`) + for (const f of dateien) assert.doesNotMatch(lies(`public/${f}`), /['"]\/termine-fragebogen\.js['"]/, `${f}: kein absoluter Import`) + assert.match(lies('public/termine-fragebogen.js'), /export function renderFragebogen\(root, ctx\)/) +}) + 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)