diff --git a/public/app.js b/public/app.js
index 6b7e410..ce23af1 100644
--- a/public/app.js
+++ b/public/app.js
@@ -139,7 +139,7 @@ function projectState(project) {
return ['kb-state-off', 'Noch keine Website']
}
-const FEATURE_LABELS = { preview: 'Vorschau', chat: 'Chat', invoices: 'Rechnungen', news: 'News-Pinnwand' }
+const FEATURE_LABELS = { preview: 'Vorschau', chat: 'Chat', invoices: 'Rechnungen', news: 'News-Pinnwand', termine: 'Terminplaner' }
function renderProjectCard(project, features) {
const li = document.createElement('li')
@@ -1910,7 +1910,7 @@ function initPasswordView() {
// "projekt" (Projektdetails als eigene Seite) gibt es ebenfalls nur in der App-Ansicht.
// "passwort" oeffnet das Konto-Menue (Profilbild). #fortschritt fuehrt zu Projekte und scrollt zum Abschnitt
// "Was wir zuletzt erledigt haben" (#pj-activity); hinter dem Hash darf ein Query stehen (#news?bestellt=1, Rueckkehr von Stripe).
-const VIEW_HASHES = { projects: '', funktionen: 'funktionen', news: 'news', analyse: 'analyse', invoices: 'rechnungen', chat: 'chat', vertraege: 'vertraege', stunden: 'stunden', 'bestellen-hosting': 'bestellen-hosting', 'bestellen-wartung': 'bestellen-wartung', wechsel: 'wechsel', takeover: 'gutschrift', projekt: 'projekt', passwort: 'passwort' }
+const VIEW_HASHES = { projects: '', funktionen: 'funktionen', news: 'news', analyse: 'analyse', termine: 'termine', invoices: 'rechnungen', chat: 'chat', vertraege: 'vertraege', stunden: 'stunden', 'bestellen-hosting': 'bestellen-hosting', 'bestellen-wartung': 'bestellen-wartung', wechsel: 'wechsel', takeover: 'gutschrift', projekt: 'projekt', passwort: 'passwort' }
const HASH_ALIASES = { wartung: 'vertraege', abo: 'vertraege' }
const viewLoaders = { chat: initChat }
// Funktionen und News laden bei jedem Oeffnen neu (Zustand aendert sich durch Bestellung und Freischaltung); Modul erst dann laden
@@ -1918,6 +1918,7 @@ const viewRefreshers = {
funktionen: () => import('/funktionen.js').then((m) => m.initFunktionen(document.getElementById('funktionen-body'))),
news: () => import('/funktionen.js').then((m) => m.initNews(document.getElementById('news-body'))),
analyse: () => import('/funktionen.js').then((m) => m.initAnalyse(document.getElementById('analyse-body'))),
+ termine: () => import('/termine.js').then((m) => m.initTermine(document.getElementById('termine-body'))),
}
const loadedViews = new Set()
@@ -2110,7 +2111,7 @@ const appLayout = window.matchMedia('(max-width: 880px), (pointer: coarse) and (
const coarsePointer = window.matchMedia('(pointer: coarse)')
// Unterseite -> Tab, zu dem sie gehoert (und Text neben dem Zurueck-Pfeil).
// "passwort" setzt onAppViewShown: die Ansicht, aus der das Konto-Menue geoeffnet wurde.
-const APP_PARENT = { news: 'funktionen', analyse: 'funktionen', stunden: 'vertraege', 'bestellen-hosting': 'vertraege', 'bestellen-wartung': 'vertraege', wechsel: 'vertraege', takeover: 'vertraege', projekt: 'projects' }
+const APP_PARENT = { news: 'funktionen', analyse: 'funktionen', termine: 'funktionen', stunden: 'vertraege', 'bestellen-hosting': 'vertraege', 'bestellen-wartung': 'vertraege', wechsel: 'vertraege', takeover: 'vertraege', projekt: 'projects' }
const APP_PARENT_LABEL = { vertraege: 'Verträge', projects: 'Projekte', funktionen: 'Funktionen', invoices: 'Rechnungen', chat: 'Chat' }
const appShell = { view: 'projects', scroll: {}, fromHistory: false, sideCards: [], installPrompt: null }
const TEXT_INPUT = 'textarea, input:not([type=checkbox]):not([type=radio]):not([type=file]):not([type=date]):not([type=button]):not([type=submit]):not([type=range]):not([type=color])'
diff --git a/public/dashboard.html b/public/dashboard.html
index 66b2ab7..6b48c97 100644
--- a/public/dashboard.html
+++ b/public/dashboard.html
@@ -22,6 +22,7 @@
+
@@ -185,6 +186,15 @@
+
+
+
diff --git a/public/funktionen.js b/public/funktionen.js
index c5b92cd..f8f1230 100644
--- a/public/funktionen.js
+++ b/public/funktionen.js
@@ -136,6 +136,58 @@ function analyseTile(projects) {
`
}
+/* Terminplaner: Kachel nur, wenn der Schalter es erlaubt (termine.verfuegbar), sonst "Kommt bald". Die Ansicht selbst
+ (termine.js) wird von app.js geladen; hier steht nur die Kachel, damit funktionen.js keinen weiteren Import braucht. */
+const TERMINE_TEXT = 'Deine Kundschaft bucht Termine direkt über deine Website.'
+const termineOf = (project) => project.features?.termine || { state: 'off' }
+const anfragenText = (n) => (n === 1 ? '1 Anfrage wartet' : `${n} Anfragen warten`)
+
+function pickTermineProject(projects) {
+ return projects.find((p) => termineOf(p).state === 'active') || projects.find((p) => termineOf(p).state === 'disabled') || projects[0]
+}
+
+function termineTile(projects) {
+ const states = projects.map((p) => termineOf(p).state)
+ const active = states.filter((s) => s === 'active').length
+ const target = pickTermineProject(projects)
+ const disabled = !active && states.length > 0 && states.every((s) => s === 'disabled')
+ const pill = active ? ['Aktiv', 'on'] : disabled ? ['Abgeschaltet', 'off'] : ['Aus', 'off']
+ let extra
+ let action
+ if (active) {
+ extra = ''
+ action = ``
+ } else if (disabled) {
+ extra = 'Der Terminplaner ist für diese Website abgeschaltet. Deine Termine kannst du noch ansehen und exportieren.
'
+ action = ``
+ } else {
+ extra = 'Wir schalten den Terminplaner für dich frei.
'
+ action = ''
+ }
+ const count = projects.length > 1 && active ? `aktiv für ${active} von ${projects.length} Websites
` : ''
+ return `
+
+ ${svg('calendar')}${pill[0]}
+ Terminplaner
+ ${TERMINE_TEXT}
+ ${extra}
+ ${count}
+ ${action}
+ `
+}
+
+// "{n} Anfragen warten" aus anfragenOffen der aktiven Websites; ein Fehler laesst die Kachel einfach ohne Zahl stehen
+async function showTermineCount(root, projects) {
+ const el = root.querySelector('[data-termine-count]')
+ const active = projects.filter((p) => termineOf(p).state === 'active')
+ if (!el || !active.length) return
+ const counts = await Promise.all(active.map((p) => request(`/api/termine/einstellungen?projectId=${encodeURIComponent(p.projectId)}`).then((d) => Number(d.anfragenOffen) || 0, () => 0)))
+ const open = counts.reduce((sum, n) => sum + n, 0)
+ if (open < 1) return
+ el.textContent = anfragenText(open)
+ el.hidden = false
+}
+
const soonTile = (icon, title, text) => `
${svg(icon)}Kommt bald
@@ -145,15 +197,21 @@ const soonTile = (icon, title, text) => `
export async function initFunktionen(root) {
if (!root) return
- const { projects = [] } = await request('/api/funktionen')
+ const { projects = [], termine } = await request('/api/funktionen')
root.innerHTML = `
${newsTile(projects)}
- ${soonTile('calendar', 'Terminplaner', 'Deine Kundschaft bucht Termine direkt über deine Website.')}
+ ${termine?.verfuegbar ? termineTile(projects) : soonTile('calendar', 'Terminplaner', TERMINE_TEXT)}
${analyseTile(projects)}
`
root.querySelector('[data-fn-tile="news"] .fn-tile-cta')?.addEventListener('click', (ev) => { selection.projectId = ev.currentTarget.dataset.project })
root.querySelector('[data-fn-tile="analyse"] .fn-tile-cta[data-project]')?.addEventListener('click', (ev) => { analyseSel.projectId = ev.currentTarget.dataset.project })
+ // Die Terminplaner-Ansicht liest das gewaehlte Projekt aus ihrem Behaelter (termine.js, initTermine)
+ root.querySelector('[data-fn-tile="termine"] .fn-tile-cta[data-project]')?.addEventListener('click', (ev) => {
+ const body = document.getElementById('termine-body')
+ if (body) body.dataset.wunschProjekt = ev.currentTarget.dataset.project
+ })
+ if (termine?.verfuegbar) await showTermineCount(root, projects)
}
/* ---------------------------------------------------------------- News --- */
diff --git a/public/termine-view.js b/public/termine-view.js
new file mode 100644
index 0000000..575d4fc
--- /dev/null
+++ b/public/termine-view.js
@@ -0,0 +1,247 @@
+/* Terminplaner im Kundenbereich: reine Helfer ohne DOM (ES-Modul; termine.js nutzt sie, test/termineView.test.js prueft sie).
+ Tage sind Berliner Kalendertage 'JJJJ-MM-TT' (so liefert und erwartet sie /api/termine). Gerechnet wird nur mit dem Kalender
+ (UTC-Mitternacht), nie mit der Zeitzone des Browsers; nur heuteBerlin() und endeZeit() fragen die Uhr bzw. Zeitzone Berlin. */
+
+const TAG_RE = /^(\d{4})-(\d{2})-(\d{2})$/
+const MONATE = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']
+const WOCHENTAGE = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag']
+const TAG_MS = 24 * 60 * 60 * 1000
+/** Die Schnittstelle liefert Buchungen fuer hoechstens 62 Tage nach dem Beginn des Zeitraums. */
+const MAX_TAGE = 62
+const GELOESCHT = 'Gelöscht'
+
+/* ------------------------------------------------------------ Kalender --- */
+function utc(tag) {
+ const m = TAG_RE.exec(String(tag))
+ return Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]))
+}
+
+const tagVon = (ms) => new Date(ms).toISOString().slice(0, 10)
+
+/** Der Tag n Kalendertage nach (oder vor, n < 0) dem Tag. */
+export function tagPlus(tag, n) {
+ return tagVon(utc(tag) + n * TAG_MS)
+}
+
+/** Montag bis Sonntag der Woche, in der der Tag liegt. */
+export function wocheVon(tag) {
+ const wochentag = new Date(utc(tag)).getUTCDay() // 0 = Sonntag
+ const montag = tagPlus(tag, -((wochentag + 6) % 7))
+ return { von: montag, bis: tagPlus(montag, 6) }
+}
+
+/** Erster bis letzter Tag des Monats, in dem der Tag liegt. */
+export function monatVon(tag) {
+ const [jahr, monat] = String(tag).split('-').map(Number)
+ return { von: `${tag.slice(0, 7)}-01`, bis: tagVon(Date.UTC(jahr, monat, 0)) }
+}
+
+/**
+ * Zeitraum fuer die Abfrage der Buchungen. ansicht: 'tag' | 'woche' | 'monat' | 'anfragen' (ab heute, so weit die
+ * Schnittstelle reicht).
+ */
+export function bereichFuer(ansicht, tag, heute) {
+ if (ansicht === 'woche') return wocheVon(tag)
+ if (ansicht === 'monat') return monatVon(tag)
+ if (ansicht === 'anfragen') return { von: heute, bis: tagPlus(heute, MAX_TAGE) }
+ return { von: tag, bis: tag }
+}
+
+/** Naechster (richtung 1) oder vorheriger (-1) Zeitraum: Tag +-1, Woche +-7, Monat auf den 1. des Nachbarmonats. */
+export function verschiebeTag(ansicht, tag, richtung) {
+ if (ansicht === 'woche') return tagPlus(tag, 7 * richtung)
+ if (ansicht === 'monat') {
+ const [jahr, monat] = tag.split('-').map(Number)
+ return tagVon(Date.UTC(jahr, monat - 1 + richtung, 1))
+ }
+ return tagPlus(tag, richtung)
+}
+
+/** Heutiger Kalendertag in Berlin (nicht der des Browsers). */
+export function heuteBerlin(jetzt = new Date()) {
+ return jetzt.toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' })
+}
+
+/* --------------------------------------------------------------- Datum --- */
+const tagOhneJahr = (tag) => `${Number(tag.slice(8))}. ${MONATE[Number(tag.slice(5, 7)) - 1]}`
+
+/** 'Montag, 5. Oktober 2026' */
+export function datumLang(tag) {
+ return `${WOCHENTAGE[new Date(utc(tag)).getUTCDay()]}, ${tagOhneJahr(tag)} ${tag.slice(0, 4)}`
+}
+
+/** '05.10.2026' */
+export function datumKurz(tag) {
+ return `${tag.slice(8)}.${tag.slice(5, 7)}.${tag.slice(0, 4)}`
+}
+
+/** Ueberschrift eines Tages in der Liste: 'Heute · Montag, 5. Oktober'; das Jahr nur, wenn es nicht das heutige ist. */
+export function tagUeberschrift(tag, heute) {
+ const rel = tag === heute ? 'Heute · ' : tag === tagPlus(heute, 1) ? 'Morgen · ' : tag === tagPlus(heute, -1) ? 'Gestern · ' : ''
+ const jahr = tag.slice(0, 4) === heute.slice(0, 4) ? '' : ` ${tag.slice(0, 4)}`
+ return `${rel}${WOCHENTAGE[new Date(utc(tag)).getUTCDay()]}, ${tagOhneJahr(tag)}${jahr}`
+}
+
+/** Titel des gewaehlten Zeitraums ueber der Liste: '5.–11. Oktober 2026', 'Oktober 2026', 'Montag, 5. Oktober 2026'. */
+export function zeitraumTitel(ansicht, tag) {
+ if (ansicht === 'anfragen') return 'Offene Anfragen'
+ if (ansicht === 'monat') return `${MONATE[Number(tag.slice(5, 7)) - 1]} ${tag.slice(0, 4)}`
+ if (ansicht === 'woche') {
+ const { von, bis } = wocheVon(tag)
+ if (von.slice(0, 7) === bis.slice(0, 7)) return `${Number(von.slice(8))}.–${tagOhneJahr(bis)} ${bis.slice(0, 4)}`
+ if (von.slice(0, 4) === bis.slice(0, 4)) return `${tagOhneJahr(von)} – ${tagOhneJahr(bis)} ${bis.slice(0, 4)}`
+ return `${tagOhneJahr(von)} ${von.slice(0, 4)} – ${tagOhneJahr(bis)} ${bis.slice(0, 4)}`
+ }
+ return datumLang(tag)
+}
+
+const ENDE_FORMAT = new Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })
+
+/** Ende des Termins als Berliner Wanduhr 'HH:MM' (aus endsAt); '' wenn kein Zeitpunkt. */
+export function endeZeit(buchung) {
+ const ende = new Date(buchung?.endsAt)
+ return Number.isNaN(ende.getTime()) ? '' : ENDE_FORMAT.format(ende)
+}
+
+/* ------------------------------------------------------------- Uhrzeit --- */
+/** 'H:MM', 'HH:MM' oder 'HH.MM' ⇒ 'HH:MM'; ende erlaubt 24:00. Das 5-Minuten-Raster prueft der Server (und fensterParsen). */
+export function zeitNormal(text, { ende = false } = {}) {
+ const m = typeof text === 'string' ? /^(\d{1,2})[:.](\d{2})$/.exec(text.trim()) : null
+ if (!m) return null
+ const stunde = Number(m[1])
+ const minute = Number(m[2])
+ if (minute > 59 || stunde > 24 || (stunde === 24 && (!ende || minute !== 0))) return null
+ return `${String(stunde).padStart(2, '0')}:${m[2]}`
+}
+
+const inMinuten = (zeit) => Number(zeit.slice(0, 2)) * 60 + Number(zeit.slice(3))
+
+/**
+ * Zeitfenster eines Wochentags aus Text: '09:00–12:30, 13:30–18:00' ⇒ [['09:00','12:30'], ['13:30','18:00']].
+ * Getrennt durch Komma, Strichpunkt, Zeilenumbruch oder „und“; Bis-Strich: -, –, — oder „bis“. Leerer Text ⇒ [] (geschlossen).
+ * Ungueltig ⇒ null: keine Uhrzeit, kein 5-Minuten-Raster, Ende nicht nach dem Beginn, nicht aufsteigend, Ueberlappung.
+ */
+export function fensterParsen(text) {
+ if (typeof text !== 'string') return null
+ if (text.trim() === '') return []
+ const fenster = []
+ let vorher = null
+ for (const teil of text.split(/[,;\n]|\bund\b/)) {
+ const m = /^\s*(\S+?)\s*(?:[-–—]|\bbis\b)\s*(\S+)\s*$/.exec(teil)
+ const von = m ? zeitNormal(m[1]) : null
+ const bis = m ? zeitNormal(m[2], { ende: true }) : null
+ if (von === null || bis === null || inMinuten(von) % 5 !== 0 || inMinuten(bis) % 5 !== 0) return null
+ if (inMinuten(bis) <= inMinuten(von) || (vorher !== null && inMinuten(von) < inMinuten(vorher))) return null
+ vorher = bis
+ fenster.push([von, bis])
+ }
+ return fenster
+}
+
+/** [['09:00','12:30'], ['13:30','18:00']] ⇒ '09:00–12:30, 13:30–18:00' (Umkehrung von fensterParsen). */
+export function fensterText(fenster) {
+ return (Array.isArray(fenster) ? fenster : []).map(([von, bis]) => `${von}–${bis}`).join(', ')
+}
+
+/* -------------------------------------------------------------- Status --- */
+const STATUS = {
+ gehalten: ['Reserviert', 'wait'],
+ angefragt: ['Angefragt', 'wait'],
+ bestaetigt: ['Bestätigt', 'ok'],
+ erledigt: ['Erledigt', 'off'],
+ nichterschienen: ['Nicht erschienen', 'bad'],
+ abgesagt: ['Abgesagt', 'off'],
+ abgelehnt: ['Abgelehnt', 'off'],
+ verfallen: ['Verfallen', 'off'],
+}
+
+/** Anzeigetext eines Status; Unbekanntes nie roh. */
+export function statusLabel(status) {
+ return Object.hasOwn(STATUS, status) ? STATUS[status][0] : 'Unbekannt'
+}
+
+/** Farbton der Pille: ok | wait | off | bad. */
+export function statusTon(status) {
+ return Object.hasOwn(STATUS, status) ? STATUS[status][1] : 'off'
+}
+
+/** Filter der Terminliste; status = Liste fuer ?status= der Schnittstelle (null = alle). */
+export const FILTER = [
+ { key: 'alle', label: 'Alle', status: null },
+ { key: 'offen', label: 'Offen', status: ['angefragt', 'bestaetigt'] },
+ { key: 'angefragt', label: 'Angefragt', status: ['angefragt'] },
+ { key: 'bestaetigt', label: 'Bestätigt', status: ['bestaetigt'] },
+ { key: 'abgesagt', label: 'Abgesagt', status: ['abgesagt', 'abgelehnt', 'verfallen'] },
+]
+
+/** Wert fuer ?status= ('' = alle Status). */
+export function filterStatus(key) {
+ return FILTER.find((f) => f.key === key)?.status?.join(',') ?? ''
+}
+
+/** „3 Anfragen warten“ / „1 Anfrage wartet“; bei 0 oder ohne Zahl ''. */
+export function anfragenText(n) {
+ if (!Number.isInteger(n) || n < 1) return ''
+ return n === 1 ? '1 Anfrage wartet' : `${n} Anfragen warten`
+}
+
+/** Gruende fuer die Absage durch den Betrieb (Pflichtauswahl; der Gast liest sie in der Absage-Mail). */
+export const ABSAGE_GRUENDE = ['Krankheit', 'Betriebliche Gründe', 'Urlaub oder Betriebsferien', 'Auf Wunsch des Gastes', 'Sonstiges']
+
+/* ----------------------------------------------------------- Einstellung --- */
+/** „Person“, „Platz“ oder „Person/Platz“, je nachdem, was der Betrieb als Ressourcen hat. */
+export function ressourcenWort(ressourcen) {
+ const typen = new Set((Array.isArray(ressourcen) ? ressourcen : []).map((r) => r?.typ))
+ if (typen.size === 1 && typen.has('platz')) return 'Platz'
+ if (typen.size === 1 && typen.has('person')) return 'Person'
+ return 'Person/Platz'
+}
+
+const FEHLT = { 'betrieb.telefon': 'deine Telefonnummer', 'betrieb.adresse': 'deine Anschrift' }
+
+/** Was fuer die Buchbarkeit noch fehlt (fehlt = Pfade aus GET /einstellungen): „deine Telefonnummer und deine Anschrift“; '' wenn nichts. */
+export function fehltText(fehlt) {
+ const teile = (Array.isArray(fehlt) ? fehlt : []).map((pfad) => FEHLT[pfad]).filter(Boolean)
+ return teile.join(' und ')
+}
+
+/* ------------------------------------------------------------- Buchungen --- */
+export const istSperre = (buchung) => buchung?.quelle === 'sperre'
+
+/** Buchungen nach Berliner Tag gruppiert (datum), Tage und Termine aufsteigend: [{ tag, buchungen }]. */
+export function gruppiereNachTag(buchungen) {
+ const nachTag = new Map()
+ for (const b of [...(Array.isArray(buchungen) ? buchungen : [])].sort((a, c) => (a.startsAt < c.startsAt ? -1 : a.startsAt > c.startsAt ? 1 : 0))) {
+ if (!nachTag.has(b.datum)) nachTag.set(b.datum, [])
+ nachTag.get(b.datum).push(b)
+ }
+ return [...nachTag.keys()].sort().map((tag) => ({ tag, buchungen: nachTag.get(tag) }))
+}
+
+const hatGastdaten = (b) => Boolean((b.gastName && b.gastName !== GELOESCHT) || b.gastEmail || b.gastTelefon || b.gastNachricht)
+
+/**
+ * Aktionen, die der Betrieb fuer die Buchung anbieten darf (jetzt = ms), in der Reihenfolge der Knoepfe:
+ * annehmen | ablehnen | verschieben | absagen | nichterschienen | anonymisieren | entsperren.
+ * Der Server prueft alles noch einmal; das hier haelt nur sinnlose Knoepfe weg.
+ */
+export function moeglicheAktionen(buchung, jetzt) {
+ const b = buchung || {}
+ const beginn = Date.parse(b.startsAt)
+ const ende = Date.parse(b.endsAt)
+ if (istSperre(b)) return ende > jetzt ? ['entsperren'] : []
+ const aktionen = []
+ const begonnen = !(beginn > jetzt)
+ if (b.status === 'angefragt') {
+ if (!begonnen) aktionen.push('annehmen')
+ aktionen.push('ablehnen')
+ if (!begonnen) aktionen.push('verschieben')
+ } else if (b.status === 'bestaetigt') {
+ if (!begonnen) aktionen.push('verschieben', 'absagen')
+ else aktionen.push('nichterschienen')
+ } else if (b.status === 'erledigt') {
+ aktionen.push('nichterschienen')
+ }
+ if (b.status !== 'gehalten' && hatGastdaten(b)) aktionen.push('anonymisieren')
+ return aktionen
+}
diff --git a/public/termine.css b/public/termine.css
new file mode 100644
index 0000000..d16457d
--- /dev/null
+++ b/public/termine.css
@@ -0,0 +1,154 @@
+/* Terminplaner im Kundenbereich (termine.js): Projektauswahl, Vorlagen, Reiter, Terminliste, Rueckfragen und Formulare.
+ Farben und Abstaende aus den Tokens von kundenbereich.css (.kb-page); nur Klassen mit tm-. Handy: bis 880px wie die App-Ansicht. */
+
+#view-termine .kb-panel { max-width: none; }
+#view-termine .fn-project { margin-bottom: 16px; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding: 14px 18px; }
+#view-termine button:focus-visible, #view-termine input:focus-visible, #view-termine select:focus-visible, #view-termine textarea:focus-visible,
+#view-termine summary:focus-visible, #view-termine a:focus-visible { outline: 3px solid #d4b4ff; outline-offset: 2px; }
+.tm-sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
+
+/* -------------------------------------------------- Hinweise und Meldungen --- */
+.tm-box { display: flex; align-items: center; justify-content: space-between; gap: 10px 16px; flex-wrap: wrap; margin-bottom: 16px; padding: 12px 16px; border-radius: 12px; font-size: 14px; line-height: 1.5; }
+.tm-box[hidden] { display: none; }
+.tm-box-warn { background: rgba(251, 191, 36, 0.12); border: 1px solid rgba(251, 191, 36, 0.45); }
+.tm-box-info { background: var(--kb-purple-soft); border: 1px solid var(--kb-purple-line); }
+.tm-meldung { margin: 0 0 16px; padding: 12px 16px; border-radius: 12px; font-size: 14px; line-height: 1.5; }
+.tm-meldung[hidden] { display: none; }
+.tm-meldung-ok { background: rgba(74, 222, 128, 0.1); border: 1px solid rgba(74, 222, 128, 0.4); }
+.tm-meldung-fehler { background: rgba(248, 113, 113, 0.1); border: 1px solid rgba(248, 113, 113, 0.5); color: #fecaca; }
+.tm-info p { margin: 0 0 12px; line-height: 1.55; color: var(--kb-muted); }
+.tm-info p:last-child { margin-bottom: 0; }
+
+/* ----------------------------------------------------------------- Reiter --- */
+.tm-reiterleiste { display: grid; grid-template-columns: repeat(4, auto); justify-content: start; gap: 6px; margin-bottom: 18px; padding-bottom: 12px; border-bottom: 1px solid var(--kb-border); }
+.tm-reiter {
+ padding: 10px 16px; border-radius: 999px; border: 1px solid transparent; background: transparent; color: var(--kb-muted);
+ font: inherit; font-size: 14px; font-weight: 700; white-space: nowrap; cursor: pointer;
+}
+.tm-reiter:hover { color: #fff; background: rgba(255, 255, 255, 0.06); }
+.tm-reiter[aria-selected='true'] { color: #fff; background: var(--kb-purple-soft); border-color: var(--kb-purple-line); }
+.tm-panel:focus { outline: none; }
+
+/* ------------------------------------------------------------- Werkzeugleiste --- */
+.tm-leiste { display: flex; align-items: center; gap: 10px 14px; flex-wrap: wrap; }
+.tm-leiste [hidden], .tm-filter[hidden] { display: none; }
+.tm-segment { display: inline-flex; border: 1px solid var(--kb-border-strong); border-radius: 999px; overflow: hidden; }
+.tm-seg { padding: 9px 18px; border: none; background: transparent; color: inherit; font: inherit; font-size: 14px; font-weight: 700; cursor: pointer; }
+.tm-seg + .tm-seg { border-left: 1px solid var(--kb-border-strong); }
+.tm-seg[aria-pressed='true'] { background: var(--kb-purple); color: #fff; }
+.tm-nav { display: inline-flex; align-items: center; gap: 6px; }
+.tm-pfeil {
+ display: grid; place-items: center; width: 40px; height: 40px; padding: 0; border-radius: 50%; border: 1px solid var(--kb-border-strong);
+ background: transparent; color: #fff; font: inherit; font-size: 22px; line-height: 1; cursor: pointer;
+}
+.tm-pfeil:hover { background: rgba(255, 255, 255, 0.07); }
+.tm-sprung { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; color: var(--kb-muted); }
+.tm-zeitraum { margin: 18px 0 10px; font-size: 19px; }
+.tm-filter { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; }
+.tm-chip {
+ padding: 8px 15px; border-radius: 999px; border: 1px solid var(--kb-border-strong); background: transparent; color: inherit;
+ font: inherit; font-size: 13.5px; font-weight: 600; cursor: pointer;
+}
+.tm-chip:hover { background: rgba(255, 255, 255, 0.07); }
+.tm-chip[aria-pressed='true'] { background: var(--kb-purple-soft); border-color: var(--kb-purple-line); color: #fff; }
+.tm-werkzeug-zeile { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 16px; }
+.tm-werkzeuge { display: contents; }
+.tm-werkzeuge button[aria-expanded='true'] { box-shadow: 0 0 0 3px var(--kb-purple-soft); }
+#view-termine .kb-btn-outline, #view-termine .kb-btn-primary { min-height: 40px; }
+
+/* ------------------------------------------------------------------- Liste --- */
+.tm-tag-kopf { margin: 22px 0 8px; font-size: 14px; font-weight: 700; color: var(--kb-muted); }
+.tm-tag:first-child .tm-tag-kopf { margin-top: 6px; }
+.tm-liste[aria-busy='true'] { opacity: 0.55; }
+.tm-leer { margin: 0 0 8px; padding: 14px 4px; font-size: 14px; color: var(--kb-faint); }
+.tm-eintraege { display: grid; gap: 10px; margin: 0; padding: 0; list-style: none; }
+.tm-eintrag { padding: 14px 16px; border-radius: 14px; background: var(--kb-surface); border: 1px solid var(--kb-border); }
+.tm-eintrag.tm-offen { border-color: var(--kb-purple-line); }
+.tm-sperre {
+ border-style: dashed; border-color: var(--kb-border-strong);
+ background: repeating-linear-gradient(135deg, rgba(255, 255, 255, 0.035) 0 10px, transparent 10px 20px), var(--kb-surface);
+}
+.tm-zeile { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 4px 14px; align-items: start; }
+.tm-zeit { font-weight: 700; font-variant-numeric: tabular-nums; white-space: nowrap; padding-top: 1px; }
+.tm-haupt { min-width: 0; }
+.tm-name, .tm-meta { display: block; overflow-wrap: anywhere; }
+.tm-meta { margin-top: 2px; font-size: 13.5px; color: var(--kb-muted); }
+.tm-pille { display: inline-block; padding: 3px 11px; border-radius: 999px; font-size: 12px; font-weight: 700; white-space: nowrap; }
+.tm-pille-ok { color: #86efac; background: rgba(74, 222, 128, 0.1); border: 1px solid rgba(74, 222, 128, 0.4); }
+.tm-pille-wait { color: #fcd34d; background: rgba(251, 191, 36, 0.1); border: 1px solid rgba(251, 191, 36, 0.4); }
+.tm-pille-off { color: var(--kb-muted); background: rgba(255, 255, 255, 0.05); border: 1px solid var(--kb-border-strong); }
+.tm-pille-bad { color: #fca5a5; background: rgba(248, 113, 113, 0.1); border: 1px solid rgba(248, 113, 113, 0.45); }
+.tm-hinweis-zeile { margin: 8px 0 0; font-size: 13.5px; line-height: 1.5; color: var(--kb-muted); overflow-wrap: anywhere; }
+.tm-hinweis-zeile.tm-warn { color: #fcd34d; }
+.tm-aktionen { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
+.tm-frage:empty { display: none; }
+.tm-frage-box {
+ display: grid; gap: 10px; margin-top: 12px; padding: 14px; border-radius: 12px; background: var(--kb-purple-soft); border: 1px solid var(--kb-purple-line);
+}
+.tm-frage-box:focus { outline: none; }
+.tm-frage-box p { margin: 0; font-size: 14px; line-height: 1.5; }
+.tm-frage-knoepfe { display: flex; flex-wrap: wrap; gap: 10px; }
+.tm-details { margin-top: 10px; font-size: 14px; }
+.tm-details summary { display: inline-block; padding: 4px 0; color: var(--kb-lilac); font-size: 13.5px; cursor: pointer; }
+.tm-fakten { display: grid; gap: 8px; margin: 8px 0 0; }
+.tm-fakten > div { display: grid; grid-template-columns: 9.5rem minmax(0, 1fr); gap: 2px 12px; }
+.tm-fakten dt { color: var(--kb-muted); font-size: 13px; }
+.tm-fakten dd { margin: 0; overflow-wrap: anywhere; white-space: pre-wrap; }
+.tm-link-knopf { margin-top: 10px; padding: 4px 0; border: none; background: none; color: #fca5a5; font: inherit; font-size: 13.5px; text-decoration: underline; cursor: pointer; }
+
+/* ------------------------------------------------------------------ Formulare --- */
+.tm-formular { margin-bottom: 16px; }
+.tm-formular h3 { margin-bottom: 6px; }
+.tm-raster { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 0 14px; }
+.tm-feld { display: grid; gap: 6px; margin: 12px 0 0; min-width: 0; }
+.tm-feld > label, .tm-label { font-size: 13px; font-weight: 600; }
+.tm-feld input:not([type=checkbox]):not([type=radio]), .tm-feld select, .tm-feld textarea {
+ width: 100%; box-sizing: border-box; padding: 11px 12px; border-radius: 12px; font: inherit; font-size: 16px; resize: vertical;
+ border: 1px solid var(--kb-border-strong); background: rgba(255, 255, 255, 0.05); color: inherit;
+}
+.tm-feld select option { color: #111; }
+.tm-feld [aria-invalid='true'] { border-color: #f87171; }
+.tm-feld input:focus, .tm-feld select:focus, .tm-feld textarea:focus { border-color: var(--kb-purple-line); box-shadow: 0 0 0 3px var(--kb-purple-soft); }
+.tm-hinweis { margin: 0; font-size: 12.5px; color: var(--kb-muted); }
+.tm-fehler { margin: 0; font-size: 13px; color: #fca5a5; }
+.tm-fehler:empty { display: none; }
+.tm-zeiten { display: flex; flex-wrap: wrap; gap: 8px; }
+.tm-haken { display: flex; align-items: flex-start; gap: 10px; margin: 14px 0 6px; font-size: 14px; line-height: 1.5; cursor: pointer; }
+.tm-haken input { flex: none; width: 18px; height: 18px; margin-top: 2px; accent-color: var(--kb-purple); }
+.tm-formular .tm-frage-knoepfe { margin-top: 16px; }
+
+/* ------------------------------------------------------------- Vorlagenauswahl --- */
+.tm-vorlagen > p { margin: 0 0 14px; line-height: 1.55; color: var(--kb-muted); }
+.tm-vorlagen-liste { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; margin: 0 0 16px; padding: 0; border: 0; min-width: 0; }
+.tm-vorlage { display: flex; align-items: flex-start; gap: 12px; padding: 14px; border-radius: 14px; border: 2px solid var(--kb-border-strong); cursor: pointer; }
+.tm-vorlage:hover { background: rgba(255, 255, 255, 0.04); }
+.tm-vorlage:has(input:checked) { border-color: var(--kb-purple); background: var(--kb-purple-soft); }
+.tm-vorlage input { flex: none; width: 18px; height: 18px; margin-top: 2px; accent-color: var(--kb-purple); }
+.tm-vorlage strong, .tm-vorlage small { display: block; }
+.tm-vorlage small { margin-top: 3px; font-size: 13px; line-height: 1.45; color: var(--kb-muted); }
+
+/* ----------------------------------------------------------------- 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; }
+ .tm-pfeil { width: 44px; height: 44px; }
+ .tm-link-knopf { padding: 11px 0; }
+ .tm-details summary { display: flex; align-items: center; min-height: 44px; padding: 0; }
+}
+@media (max-width: 600px) {
+ .tm-reiterleiste { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+ .tm-reiter { text-align: center; }
+ .tm-leiste { gap: 10px; }
+ .tm-segment { width: 100%; }
+ .tm-seg { flex: 1; padding-left: 8px; padding-right: 8px; }
+ .tm-nav { width: 100%; }
+ .tm-nav .tm-heute { flex: 1; }
+ .tm-sprung { width: 100%; }
+ .tm-sprung input { flex: 1; min-width: 0; }
+ .tm-zeile { grid-template-columns: minmax(0, 1fr) auto; }
+ .tm-haupt { grid-column: 1 / -1; grid-row: 2; }
+ .tm-fakten > div { grid-template-columns: minmax(0, 1fr); }
+ .tm-werkzeug-zeile > *, .tm-werkzeuge > * { flex: 1 1 calc(50% - 5px); }
+ .tm-werkzeuge > .kb-btn-primary { flex-basis: 100%; }
+ .tm-aktionen > * { flex: 1 1 auto; }
+}
+@media (prefers-reduced-motion: reduce) { #view-termine * { transition: none !important; animation: none !important; } }
diff --git a/public/termine.js b/public/termine.js
new file mode 100644
index 0000000..c837fdc
--- /dev/null
+++ b/public/termine.js
@@ -0,0 +1,832 @@
+/* Terminplaner im Kundenbereich (ES-Modul; app.js laedt es erst beim Oeffnen der Ansicht #termine).
+ initTermine(rootEl, { projectId }): Projektauswahl, Vorlagenauswahl beim ersten Oeffnen, Reiter-Geruest und der Reiter "Termine".
+ Die Reiter stehen in REITER: jeder ist eine Funktion (panel, ctx) => void | Promise. Leistungen, Team & Plaetze und Einstellungen
+ (Aufgabe 21b) ersetzen dort nur ihren Platzhalter. ctx = { s, projectId, daten, nurLesen, api, meldung, zumReiter, neuLaden }:
+ daten ist die Antwort von GET /api/termine/einstellungen, nurLesen gilt fuer abgeschaltete Funktionen.
+ Kundentext kommt nur ueber textContent in die Seite (Hilfsfunktion h), nie ueber innerHTML. Server: server/routes/termine.js.
+ Tage sind Berliner Kalendertage 'JJJJ-MM-TT'; gerechnet wird in termine-view.js. Styles: termine.css. */
+
+import {
+ ABSAGE_GRUENDE, FILTER, anfragenText, bereichFuer, datumKurz, endeZeit, fehltText, filterStatus, gruppiereNachTag, heuteBerlin, istSperre,
+ moeglicheAktionen, ressourcenWort, statusLabel, statusTon, tagPlus, tagUeberschrift, verschiebeTag, zeitNormal, zeitraumTitel,
+} from './termine-view.js'
+
+const NAME_MAX = 100
+const TELEFON_MAX = 40
+const NACHRICHT_MAX = 500
+const TITEL_MAX = 120
+
+// Auswahl bleibt beim Wechsel zwischen Funktionen und Terminplaner erhalten (der Tag nicht: er beginnt immer bei heute)
+const auswahl = { projectId: '', reiter: 'termine', ansicht: 'tag', filter: 'alle' }
+let S = null // aktuelle Sitzung der Ansicht; eine alte Antwort darf eine neuere nicht ueberschreiben (S !== s)
+
+/* ------------------------------------------------------------ Helfer --- */
+/** Element bauen: Texte nur als Textknoten, Attribute mit on… als Ereignis, true setzt ein Attribut ohne Wert, false/null/undefined lassen es weg. */
+function h(tag, attrs, ...kinder) {
+ const el = document.createElement(tag)
+ for (const [name, wert] of Object.entries(attrs || {})) {
+ if (wert === undefined || wert === null || wert === false) continue
+ if (name === 'class') el.className = wert
+ else if (name.startsWith('on')) el.addEventListener(name.slice(2), wert)
+ else el.setAttribute(name, wert === true ? '' : wert)
+ }
+ for (const kind of kinder.flat()) if (kind !== undefined && kind !== null && kind !== false) el.append(kind)
+ return el
+}
+
+async function request(path, { method = 'GET', body } = {}) {
+ const response = await fetch(path, {
+ method,
+ credentials: 'same-origin',
+ headers: body ? { 'Content-Type': 'application/json' } : {},
+ body: body ? JSON.stringify(body) : undefined,
+ })
+ const data = await response.json().catch(() => ({}))
+ if (!response.ok) {
+ const err = new Error(data.error || (response.status === 502 ? 'Server nicht erreichbar. Bitte kurz warten und noch einmal versuchen.' : `Fehler ${response.status}`))
+ err.status = response.status
+ err.fehler = data.fehler || {}
+ err.code = data.code
+ throw err
+ }
+ return data
+}
+
+let idZaehler = 0
+const neueId = () => `tm-f${++idZaehler}`
+
+/** Formularfeld mit Beschriftung, Hinweis und Platz fuer den Fehler (data-feld = Feldname der Schnittstelle). */
+function feld(name, label, steuer, hinweis) {
+ const id = steuer.id || (steuer.id = neueId())
+ steuer.dataset.feld = name
+ return h('div', { class: 'tm-feld' },
+ h('label', { for: id }, label),
+ steuer,
+ hinweis ? h('p', { class: 'tm-hinweis' }, hinweis) : null,
+ h('p', { class: 'tm-fehler', 'data-fehler': name, role: 'alert' }))
+}
+
+function setzeFehler(form, fehler) {
+ for (const el of form.querySelectorAll('[data-fehler]')) el.textContent = ''
+ for (const el of form.querySelectorAll('[aria-invalid]')) el.removeAttribute('aria-invalid')
+ const rest = []
+ for (const [name, text] of Object.entries(fehler || {})) {
+ const ziel = form.querySelector(`[data-fehler="${CSS.escape(name)}"]`)
+ if (ziel) {
+ ziel.textContent = text
+ form.querySelector(`[data-feld="${CSS.escape(name)}"]`)?.setAttribute('aria-invalid', 'true')
+ } else {
+ rest.push(text)
+ }
+ }
+ const allgemein = form.querySelector('[data-fehler="_"]')
+ if (allgemein) allgemein.textContent = rest.join(' ')
+}
+
+/** Fehler der Schnittstelle am Formular zeigen (Feldfehler am Feld, sonst allgemein). */
+function zeigeFehler(form, err) {
+ const fehler = err.fehler && Object.keys(err.fehler).length ? err.fehler : { _: err.message }
+ setzeFehler(form, fehler)
+ form.querySelector('[aria-invalid]')?.focus()
+}
+
+const infoKarte = (titel, text, { chat = false } = {}) => h('div', { class: 'kb-panel tm-info' },
+ h('h3', null, titel),
+ text ? h('p', null, text) : null,
+ chat ? h('button', { type: 'button', class: 'kb-btn-outline', 'data-view': 'chat' }, 'Chat öffnen') : null)
+
+const fehlerKarte = (text, nochmal) => h('div', { class: 'kb-panel tm-info', role: 'alert' },
+ h('p', null, text),
+ nochmal ? h('button', { type: 'button', class: 'kb-btn-outline', onclick: nochmal }, 'Noch einmal versuchen') : null)
+
+/* --------------------------------------------------------- Einstieg --- */
+export async function initTermine(root, { projectId } = {}) {
+ if (!root) return
+ const wunsch = projectId || root.dataset.wunschProjekt || '' // die Kachel in Funktionen legt ihr Projekt hier ab
+ delete root.dataset.wunschProjekt
+ if (wunsch) auswahl.projectId = wunsch
+ const { projects = [], termine } = await request('/api/funktionen')
+ const s = { root, projects, projekt: null, daten: null, tag: heuteBerlin(), nurLesen: false, buehne: null, meldungEl: null }
+ S = s
+ if (!termine?.verfuegbar) {
+ root.replaceChildren(infoKarte('Der Terminplaner ist noch nicht verfügbar', 'Sobald er für dich bereit ist, findest du ihn hier.'))
+ return
+ }
+ if (!projects.length) {
+ root.replaceChildren(h('div', { class: 'kb-panel kb-empty' }, 'Noch keine Website-Projekte zugewiesen.'))
+ return
+ }
+ const zustand = (p) => p.features?.termine?.state
+ s.projekt = projects.find((p) => p.projectId === auswahl.projectId)
+ || projects.find((p) => zustand(p) === 'active') || projects.find((p) => zustand(p) === 'disabled') || projects[0]
+ auswahl.projectId = s.projekt.projectId
+ s.buehne = h('div', { id: 'termine-buehne' })
+ root.replaceChildren(...(projects.length > 1 ? [projektwahl(s)] : []), s.buehne)
+ await zeigeProjekt(s)
+}
+
+function projektwahl(s) {
+ const select = h('select', { id: 'termine-project' }, s.projects.map((p) => {
+ const option = h('option', { value: p.projectId }, p.projectName)
+ option.selected = p.projectId === s.projekt.projectId
+ return option
+ }))
+ select.addEventListener('change', () => {
+ auswahl.projectId = select.value
+ s.projekt = s.projects.find((p) => p.projectId === select.value)
+ s.tag = heuteBerlin()
+ zeigeProjekt(s)
+ })
+ return h('div', { class: 'fn-project kb-panel' }, h('label', { for: 'termine-project' }, 'Website'), select)
+}
+
+const ladeDaten = (s) => request(`/api/termine/einstellungen?projectId=${encodeURIComponent(s.projekt.projectId)}`)
+
+/** Je nach Zustand der Funktion: nicht freigeschaltet, Vorlage waehlen oder die Reiter. */
+async function zeigeProjekt(s) {
+ const zustand = s.projekt.features?.termine?.state || 'off'
+ s.nurLesen = zustand !== 'active'
+ if (zustand !== 'active' && zustand !== 'disabled') {
+ s.buehne.replaceChildren(infoKarte('Der Terminplaner ist für diese Website noch nicht freigeschaltet', 'Wir schalten den Terminplaner für dich frei.', { chat: true }))
+ return
+ }
+ s.buehne.replaceChildren(h('p', { class: 'kb-empty' }, 'Lade…'))
+ try {
+ s.daten = await ladeDaten(s)
+ } catch (err) {
+ if (S === s) s.buehne.replaceChildren(fehlerKarte(err.message, () => zeigeProjekt(s)))
+ return
+ }
+ if (S !== s) return
+ if (s.daten.einstellung) zeigeReiter(s)
+ else if (s.nurLesen) s.buehne.replaceChildren(infoKarte('Der Terminplaner ist für diese Website abgeschaltet', 'Es wurden keine Termine eingerichtet.', { chat: true }))
+ else zeigeVorlagen(s)
+}
+
+/* ---------------------------------------------------- Vorlage wählen --- */
+function zeigeVorlagen(s) {
+ const projektId = s.projekt.projectId
+ const fehler = h('p', { class: 'tm-fehler', role: 'alert' })
+ const start = h('button', { type: 'button', class: 'kb-btn-primary', disabled: true }, 'Mit dieser Vorlage starten')
+ let gewaehlt = null
+ const karten = (s.daten.vorlagen || []).map((v) => {
+ const radio = h('input', { type: 'radio', name: 'termine-vorlage', value: v.key })
+ radio.addEventListener('change', () => { gewaehlt = v; start.disabled = false; fehler.textContent = '' })
+ return h('label', { class: 'tm-vorlage' }, radio, h('span', null, h('strong', null, v.name), h('small', null, v.beschreibung)))
+ })
+ start.addEventListener('click', async () => {
+ if (!gewaehlt) return
+ start.disabled = true
+ fehler.textContent = ''
+ try {
+ await request('/api/termine/einstellungen/vorlage', { method: 'POST', body: { projectId: projektId, vorlage: gewaehlt.key } })
+ s.daten = await ladeDaten(s)
+ if (S !== s) return
+ zeigeReiter(s)
+ zeigeMeldung(s, `Die Vorlage „${gewaehlt.name}“ ist angelegt. Alles darin ist ein Vorschlag, den du anpassen kannst.`)
+ } catch (err) {
+ fehler.textContent = err.message
+ start.disabled = false
+ }
+ })
+ s.buehne.replaceChildren(h('div', { class: 'kb-panel tm-vorlagen' },
+ h('h3', null, 'Womit möchtest du starten?'),
+ h('p', null, 'Such dir die Vorlage aus, die am besten zu deinem Betrieb passt. Die Vorlage legt Leistungen, Zeiten und Regeln als Vorschlag an. Du kannst danach alles ändern.'),
+ h('fieldset', { class: 'tm-vorlagen-liste' }, h('legend', { class: 'tm-sr' }, 'Vorlage'), karten),
+ fehler,
+ h('div', { class: 'abo-actions' }, start)))
+}
+
+/* -------------------------------------------------------- Reiter-Gerüst --- */
+const platzhalter = (titel, text) => (panel) => {
+ panel.replaceChildren(h('div', { class: 'kb-panel tm-info' }, h('h3', null, titel), h('p', null, text)))
+}
+
+/** key, Beschriftung, Funktion (panel, ctx). Aufgabe 21b ersetzt die Platzhalter, sonst bleibt alles wie es ist. */
+const REITER = [
+ { key: 'termine', label: 'Termine', render: renderTermine },
+ { key: 'leistungen', label: 'Leistungen', render: platzhalter('Leistungen', 'Hier legst du bald fest, was deine Kundschaft buchen kann – mit Dauer, Preis und Reihenfolge.') },
+ { key: 'team', label: 'Team & Plätze', render: platzhalter('Team & Plätze', 'Hier pflegst du bald Personen und Plätze mit ihren Zeiten, Urlaub und Kalender-Links.') },
+ { key: 'einstellungen', label: 'Einstellungen', render: platzhalter('Einstellungen', 'Hier ändern wir bald Betriebsangaben, Regeln und Texte für deine Gäste.') },
+]
+
+/** Meldung ueber der Reiterleiste: art 'ok' (Standard) oder 'fehler'; leerer Text blendet sie aus. */
+function zeigeMeldung(s, text, art = 'ok') {
+ const el = s.meldungEl
+ if (!el) return
+ el.textContent = text || ''
+ el.hidden = !text
+ el.className = `tm-meldung tm-meldung-${art}`
+ el.setAttribute('role', art === 'fehler' ? 'alert' : 'status')
+}
+
+function zeigeReiter(s) {
+ const projectId = s.projekt.projectId
+ const nurLesen = s.nurLesen
+ s.meldungEl = h('div', { class: 'tm-meldung', hidden: true })
+ const ctx = {
+ s,
+ projectId,
+ daten: s.daten,
+ nurLesen,
+ api: {
+ get: (pfad, query = {}) => request(`/api/termine${pfad}?${new URLSearchParams({ projectId, ...query })}`),
+ send: (method, pfad, body = {}) => (method === 'DELETE'
+ ? request(`/api/termine${pfad}?${new URLSearchParams({ projectId })}`, { method })
+ : request(`/api/termine${pfad}`, { method, body: { projectId, ...body } })),
+ },
+ meldung: (text, art) => zeigeMeldung(s, text, art),
+ zumReiter: (key) => waehleReiter(key),
+ neuLaden: async () => {
+ s.daten = await ladeDaten(s)
+ ctx.daten = s.daten
+ return s.daten
+ },
+ }
+
+ const hinweise = []
+ if (nurLesen) {
+ hinweise.push(h('div', { class: 'tm-box tm-box-warn', role: 'note' },
+ 'Der Terminplaner ist für diese Website abgeschaltet. Du kannst Termine nur noch ansehen und ',
+ h('a', { href: `/api/termine/export.csv?projectId=${encodeURIComponent(projectId)}`, download: 'termine.csv' }, 'exportieren'),
+ '.'))
+ } else if ((s.daten.fehlt || []).length) {
+ hinweise.push(h('div', { class: 'tm-box tm-box-warn', role: 'note' },
+ h('span', null, `Noch nicht buchbar: Es fehlt ${fehltText(s.daten.fehlt) || 'eine Angabe zu deinem Betrieb'}. Solange das fehlt, können Gäste über deine Website nicht buchen.`),
+ h('button', { type: 'button', class: 'kb-btn-outline', onclick: () => waehleReiter('einstellungen') }, 'Zu den Einstellungen')))
+ }
+
+ const panel = h('div', { class: 'tm-panel', role: 'tabpanel', id: 'tm-panel', tabindex: '0' })
+ const knoepfe = REITER.map((r) => h('button', {
+ type: 'button', role: 'tab', class: 'tm-reiter', id: `tm-tab-${r.key}`, 'aria-controls': 'tm-panel', 'data-reiter': r.key,
+ onclick: () => waehleReiter(r.key),
+ }, r.label))
+ const leiste = h('div', { class: 'tm-reiterleiste', role: 'tablist', 'aria-label': 'Bereiche des Terminplaners' }, knoepfe)
+ leiste.addEventListener('keydown', (ev) => {
+ const i = REITER.findIndex((r) => r.key === auswahl.reiter)
+ const ziel = { ArrowRight: (i + 1) % REITER.length, ArrowLeft: (i + REITER.length - 1) % REITER.length, Home: 0, End: REITER.length - 1 }[ev.key]
+ if (ziel === undefined) return
+ ev.preventDefault()
+ waehleReiter(REITER[ziel].key)
+ knoepfe[ziel].focus()
+ })
+
+ function waehleReiter(key) {
+ const reiter = REITER.find((r) => r.key === key) || REITER[0]
+ auswahl.reiter = reiter.key
+ for (const k of knoepfe) {
+ const an = k.dataset.reiter === reiter.key
+ k.setAttribute('aria-selected', String(an))
+ k.tabIndex = an ? 0 : -1
+ }
+ panel.setAttribute('aria-labelledby', `tm-tab-${reiter.key}`)
+ zeigeMeldung(s, '')
+ panel.replaceChildren()
+ Promise.resolve()
+ .then(() => reiter.render(panel, ctx))
+ .catch((err) => { if (S === s) panel.replaceChildren(fehlerKarte(err.message, () => waehleReiter(reiter.key))) })
+ }
+
+ s.buehne.replaceChildren(...hinweise, s.meldungEl, leiste, panel)
+ waehleReiter(auswahl.reiter)
+}
+
+/* -------------------------------------------------------- Reiter Termine --- */
+const AKTION_TEXT = {
+ annehmen: 'Annehmen', ablehnen: 'Ablehnen', verschieben: 'Verschieben', absagen: 'Absagen', nichterschienen: 'Nicht erschienen',
+ anonymisieren: 'Gastdaten löschen', entsperren: 'Sperre aufheben',
+}
+const ABSENDER = { gast: 'Vom Gast abgesagt', betrieb: 'Von dir abgesagt', system: 'Automatisch abgelehnt (Termin vorbei)' }
+
+function renderTermine(panel, ctx) {
+ const t = { ctx, panel, nr: 0, buchungen: [], offen: null, formular: null }
+ const { s } = ctx
+
+ // Kopf: Ansicht, Navigation, Titel, Filter, Werkzeuge
+ const ansichtKnoepfe = [['tag', 'Tag'], ['woche', 'Woche'], ['monat', 'Monat']].map(([key, text]) =>
+ h('button', { type: 'button', class: 'tm-seg', 'data-ansicht': key, onclick: () => setze(t, { ansicht: key }) }, text))
+ t.zurueck = h('button', { type: 'button', class: 'kb-btn-outline', hidden: true, onclick: () => setze(t, { ansicht: 'tag' }) }, 'Zurück zur Übersicht')
+ t.nav = h('div', { class: 'tm-nav' },
+ h('button', { type: 'button', class: 'tm-pfeil', 'aria-label': 'Zurück', onclick: () => setze(t, { tag: verschiebeTag(auswahl.ansicht, s.tag, -1) }) }, h('span', { 'aria-hidden': 'true' }, '‹')),
+ h('button', { type: 'button', class: 'kb-btn-outline tm-heute', onclick: () => setze(t, { tag: heuteBerlin() }) }, 'Heute'),
+ h('button', { type: 'button', class: 'tm-pfeil', 'aria-label': 'Weiter', onclick: () => setze(t, { tag: verschiebeTag(auswahl.ansicht, s.tag, 1) }) }, h('span', { 'aria-hidden': 'true' }, '›')))
+ t.datumFeld = h('input', { type: 'date', id: 'tm-sprung', 'aria-label': 'Zu einem Datum springen' })
+ t.datumFeld.addEventListener('change', () => { if (t.datumFeld.value) setze(t, { tag: t.datumFeld.value }) })
+ t.titel = h('h3', { class: 'tm-zeitraum', 'aria-live': 'polite' })
+ const filterKnoepfe = FILTER.map((f) => h('button', { type: 'button', class: 'tm-chip', 'data-filter': f.key, onclick: () => setze(t, { filter: f.key }) }, f.label))
+ t.filterBox = h('div', { class: 'tm-filter', role: 'group', 'aria-label': 'Nach Status filtern' }, filterKnoepfe)
+ t.ansichtBox = h('div', { class: 'tm-segment', role: 'group', 'aria-label': 'Ansicht' }, ansichtKnoepfe)
+ t.werkzeuge = ctx.nurLesen ? null : h('div', { class: 'tm-werkzeuge' },
+ h('button', { type: 'button', class: 'kb-btn-primary', 'data-werkzeug': 'manuell', onclick: () => oeffneFormular(t, 'manuell') }, '+ Termin eintragen'),
+ h('button', { type: 'button', class: 'kb-btn-outline', 'data-werkzeug': 'sperre', onclick: () => oeffneFormular(t, 'sperre') }, 'Zeit sperren'))
+ t.neuBtn = h('button', { type: 'button', class: 'kb-btn-outline', onclick: () => ladeListe(t, { auchZahl: true }) }, 'Aktualisieren')
+ t.anfragenBox = h('div', { class: 'tm-box tm-box-info', role: 'status', hidden: true })
+ t.formularBox = h('div', { class: 'tm-formular-box' })
+ t.liste = h('div', { class: 'tm-liste', 'aria-live': 'polite' })
+
+ panel.replaceChildren(
+ h('div', { class: 'tm-leiste' }, t.ansichtBox, t.nav, h('label', { class: 'tm-sprung', for: 'tm-sprung' }, h('span', null, 'Datum'), t.datumFeld), t.zurueck),
+ t.titel,
+ t.filterBox,
+ h('div', { class: 'tm-werkzeug-zeile' }, t.werkzeuge, t.neuBtn),
+ t.anfragenBox,
+ t.formularBox,
+ t.liste)
+ aktualisiereKopf(t)
+ ladeListe(t)
+}
+
+/** Auswahl aendern (Ansicht, Filter, Tag) und die Liste neu laden. */
+function setze(t, aenderung) {
+ const { s } = t.ctx
+ if (aenderung.ansicht) auswahl.ansicht = aenderung.ansicht
+ if (aenderung.filter) auswahl.filter = aenderung.filter
+ if (aenderung.tag) s.tag = aenderung.tag
+ t.ctx.meldung('')
+ schliesseFrage(t)
+ aktualisiereKopf(t)
+ ladeListe(t)
+}
+
+function aktualisiereKopf(t) {
+ const { s, daten } = t.ctx
+ const anfragen = auswahl.ansicht === 'anfragen'
+ for (const k of t.ansichtBox.children) k.setAttribute('aria-pressed', String(!anfragen && k.dataset.ansicht === auswahl.ansicht))
+ for (const k of t.filterBox.children) k.setAttribute('aria-pressed', String(!anfragen && k.dataset.filter === auswahl.filter))
+ t.ansichtBox.hidden = anfragen
+ t.nav.hidden = anfragen
+ t.filterBox.hidden = anfragen
+ t.datumFeld.parentElement.hidden = anfragen
+ t.zurueck.hidden = !anfragen
+ t.titel.textContent = zeitraumTitel(auswahl.ansicht, s.tag)
+ t.datumFeld.value = s.tag
+ const n = daten.anfragenOffen
+ t.anfragenBox.hidden = anfragen || !(n > 0)
+ if (!t.anfragenBox.hidden) {
+ t.anfragenBox.replaceChildren(
+ h('span', null, anfragenText(n)),
+ h('button', { type: 'button', class: 'kb-btn-outline', onclick: () => setze(t, { ansicht: 'anfragen' }) }, 'Anfragen ansehen'))
+ }
+}
+
+async function ladeListe(t, { auchZahl = false } = {}) {
+ const { ctx } = t
+ const { s } = ctx
+ const nr = ++t.nr
+ const anfragen = auswahl.ansicht === 'anfragen'
+ const { von, bis } = bereichFuer(auswahl.ansicht, s.tag, heuteBerlin())
+ const status = anfragen ? 'angefragt' : filterStatus(auswahl.filter)
+ t.liste.setAttribute('aria-busy', 'true')
+ try {
+ const [antwort] = await Promise.all([
+ ctx.api.get('/buchungen', { von, bis, ...(status ? { status } : {}) }),
+ auchZahl ? ctx.neuLaden().then(() => aktualisiereKopf(t), () => {}) : null, // die Zahl der Anfragen ist Beiwerk: ein Fehler dabei kostet nicht die Liste
+ ])
+ if (nr !== t.nr || S !== s || !t.liste.isConnected) return
+ t.buchungen = antwort.buchungen || []
+ zeichneListe(t)
+ } catch (err) {
+ if (nr !== t.nr || S !== s) return
+ t.liste.replaceChildren(fehlerKarte(err.message, () => ladeListe(t)))
+ } finally {
+ if (nr === t.nr) t.liste.removeAttribute('aria-busy')
+ }
+}
+
+/** Nach einer Aenderung: Liste und Zahl der Anfragen neu holen. */
+const nachAenderung = (t) => ladeListe(t, { auchZahl: true })
+
+function leerText() {
+ if (auswahl.ansicht === 'anfragen') return 'Es warten keine Anfragen.'
+ const wo = auswahl.ansicht === 'monat' ? 'in diesem Monat' : 'an diesem Tag'
+ return auswahl.filter === 'alle' ? `Keine Termine ${wo}.` : `Keine Termine mit diesem Filter ${wo}.`
+}
+
+function zeichneListe(t) {
+ const { s } = t.ctx
+ const heute = heuteBerlin()
+ const gruppen = gruppiereNachTag(t.buchungen)
+ let tage = gruppen
+ // Tag und Woche zeigen jeden Tag des Zeitraums, auch leere; Monat und Anfragen nur Tage mit Terminen
+ if (auswahl.ansicht === 'tag' || auswahl.ansicht === 'woche') {
+ const { von, bis } = bereichFuer(auswahl.ansicht, s.tag, heute)
+ tage = []
+ for (let tag = von; tag <= bis; tag = tagPlus(tag, 1)) tage.push(gruppen.find((g) => g.tag === tag) || { tag, buchungen: [] })
+ }
+ t.offen = null
+ if (!gruppen.length && auswahl.ansicht !== 'woche') {
+ t.liste.replaceChildren(h('p', { class: 'tm-leer' }, leerText()))
+ return
+ }
+ t.liste.replaceChildren(...tage.map((g) => h('section', { class: 'tm-tag', 'data-tag': g.tag },
+ auswahl.ansicht === 'tag' ? null : h('h4', { class: 'tm-tag-kopf' }, tagUeberschrift(g.tag, heute)),
+ g.buchungen.length
+ ? h('ul', { class: 'tm-eintraege' }, g.buchungen.map((b) => eintrag(t, b)))
+ : h('p', { class: 'tm-leer' }, 'Keine Termine'))))
+}
+
+function eintrag(t, b) {
+ const { ctx } = t
+ const sperre = istSperre(b)
+ const aktionen = ctx.nurLesen ? [] : moeglicheAktionen(b, Date.now())
+ const kontext = `${sperre ? 'Sperre' : b.leistung}, ${datumKurz(b.datum)} um ${b.zeit} Uhr`
+ const li = h('li', { class: `tm-eintrag${sperre ? ' tm-sperre' : ''}`, 'data-id': b.id, 'data-status': b.status, 'data-quelle': b.quelle })
+
+ const titelBlock = sperre
+ ? [h('strong', { class: 'tm-name' }, 'Gesperrt'), b.titel && b.titel !== 'Gesperrt' ? h('span', { class: 'tm-meta' }, b.titel) : null, b.person ? h('span', { class: 'tm-meta' }, b.person) : null]
+ : [h('strong', { class: 'tm-name' }, b.leistung || 'Termin'),
+ h('span', { class: 'tm-meta' }, [b.person, b.gastName || 'Ohne Namen', b.quelle === 'manuell' ? 'von dir eingetragen' : ''].filter(Boolean).join(' · '))]
+ li.append(h('div', { class: 'tm-zeile' },
+ h('span', { class: 'tm-zeit' }, `${b.zeit}–${endeZeit(b)}`),
+ h('div', { class: 'tm-haupt' }, titelBlock),
+ sperre ? h('span', { class: 'tm-pille tm-pille-off' }, 'Gesperrt') : h('span', { class: `tm-pille tm-pille-${statusTon(b.status)}` }, statusLabel(b.status))))
+
+ const hinweise = []
+ if (b.zustellFehler) hinweise.push(h('p', { class: 'tm-hinweis-zeile tm-warn' }, h('strong', null, 'Bestätigung nicht zugestellt'), ' – die E-Mail an den Gast ist nicht angekommen. Melde dich am besten telefonisch.'))
+ if (b.status === 'abgesagt') {
+ // absageGrund steht auch bei Vermerken an noch bestehenden Terminen (z. B. spaeter Widerruf): als Grund nur bei einer Absage zeigen
+ if (b.widerrufenAt) hinweise.push(h('p', { class: 'tm-hinweis-zeile' }, 'Vom Gast widerrufen'))
+ else if (ABSENDER[b.abgesagtVon]) hinweise.push(h('p', { class: 'tm-hinweis-zeile' }, ABSENDER[b.abgesagtVon]))
+ if (b.absageGrund) hinweise.push(h('p', { class: 'tm-hinweis-zeile' }, `${b.abgesagtVon === 'betrieb' ? 'Grund' : 'Vermerk'}: ${b.absageGrund}`))
+ }
+ if (b.status === 'abgelehnt' && b.abgesagtVon === 'system') hinweise.push(h('p', { class: 'tm-hinweis-zeile' }, ABSENDER.system))
+ li.append(...hinweise)
+
+ const knoepfe = aktionen.filter((a) => a !== 'anonymisieren')
+ if (knoepfe.length) {
+ li.append(h('div', { class: 'tm-aktionen' }, knoepfe.map((a) => h('button', {
+ type: 'button',
+ class: a === 'annehmen' ? 'kb-btn-primary tm-knopf' : 'kb-btn-outline tm-knopf',
+ 'data-aktion': a,
+ 'aria-label': `${AKTION_TEXT[a]}: ${kontext}`,
+ onclick: (ev) => starteAktion(t, li, b, a, ev.currentTarget),
+ }, AKTION_TEXT[a]))))
+ }
+ li.append(h('div', { class: 'tm-frage', 'data-frage': '' }))
+
+ if (!sperre) {
+ const fakten = [
+ ['Buchungsnummer', b.nummer], ['Dauer', `${b.dauer} Minuten`], ['Name', b.gastName], ['E-Mail', b.gastEmail], ['Telefon', b.gastTelefon],
+ ['Nachricht', b.gastNachricht], ['Erinnerung', b.erinnerung ? 'Per E-Mail gewünscht' : ''],
+ ].filter(([, wert]) => wert)
+ li.append(h('details', { class: 'tm-details' },
+ h('summary', { 'aria-label': `Details: ${kontext}` }, 'Details'),
+ h('dl', { class: 'tm-fakten' }, fakten.map(([name, wert]) => h('div', null, h('dt', null, name), h('dd', null, wert)))),
+ aktionen.includes('anonymisieren')
+ ? h('button', { type: 'button', class: 'tm-link-knopf', 'data-aktion': 'anonymisieren', onclick: (ev) => starteAktion(t, li, b, 'anonymisieren', ev.currentTarget) }, 'Gastdaten löschen')
+ : null))
+ }
+ return li
+}
+
+/* ------------------------------------------------- Aktionen mit Rückfrage --- */
+function schliesseFrage(t, fokus = false) {
+ if (!t.offen) return
+ const { li, knopf } = t.offen
+ li.querySelector('[data-frage]')?.replaceChildren()
+ li.classList.remove('tm-offen')
+ t.offen = null
+ if (fokus && knopf?.isConnected) knopf.focus()
+}
+
+function starteAktion(t, li, b, art, knopf) {
+ t.ctx.meldung('')
+ if (art === 'annehmen') {
+ schliesseFrage(t)
+ fuehreAus(t, b, { aktion: 'annehmen' }, b.gastEmail ? `Anfrage angenommen. ${b.gastName} bekommt eine Bestätigung per E-Mail.` : 'Anfrage angenommen.', null, knopf)
+ return
+ }
+ schliesseFrage(t)
+ const zu = () => schliesseFrage(t, true)
+ const fragen = { ablehnen: frageAblehnen, absagen: frageAbsagen, verschieben: frageVerschieben, nichterschienen: frageNichtErschienen, anonymisieren: frageAnonymisieren, entsperren: frageEntsperren }
+ const box = fragen[art](t, b, zu)
+ li.querySelector('[data-frage]').replaceChildren(box)
+ li.classList.add('tm-offen')
+ t.offen = { li, knopf, art }
+ // Fokus ins erste Eingabefeld; ohne Eingabefeld auf die Rueckfrage selbst, damit ein Enter nichts bestaetigt
+ const erster = box.querySelector('select, textarea, input') || box
+ erster.focus()
+}
+
+/**
+ * Aktion an die Schnittstelle schicken. erfolg = Text der Meldung. form = Rueckfrage-Formular fuer Fehler (400 und belegte Zeit
+ * bleiben dort), sonst Meldung ueber den Reitern; bei Fehlern, die auf einen geaenderten Stand deuten (404, 409), wird die Liste neu geladen.
+ */
+async function fuehreAus(t, b, body, erfolg, form, knopf) {
+ const { ctx } = t
+ knopf.disabled = true
+ try {
+ const entsperren = body.aktion === 'entsperren'
+ const antwort = entsperren
+ ? await ctx.api.send('DELETE', `/buchungen/${encodeURIComponent(b.id)}`)
+ : await ctx.api.send('POST', `/buchungen/${encodeURIComponent(b.id)}/aktion`, body)
+ await nachAenderung(t) // erst die Liste, dann die Meldung: wer die Meldung sieht, sieht auch den neuen Stand
+ ctx.meldung(antwort.schon ? 'Das war schon so erledigt.' : erfolg)
+ } catch (err) {
+ const imFormular = form && (err.status === 400 || err.code === 'SLOT_VERGEBEN')
+ if (imFormular) {
+ zeigeFehler(form, err)
+ knopf.disabled = false
+ return
+ }
+ ctx.meldung(err.message, 'fehler')
+ if (err.status === 404 || err.status === 409) await nachAenderung(t)
+ else knopf.disabled = false
+ }
+}
+
+const mailHinweis = (b, text) => (b.gastEmail ? text : 'Der Gast bekommt keine E-Mail, weil keine Adresse hinterlegt ist.')
+
+function frage(titel, ...inhalt) {
+ return h('form', { class: 'tm-frage-box', novalidate: true, tabindex: '-1', 'aria-label': titel, onsubmit: (ev) => ev.preventDefault() }, h('strong', null, titel), ...inhalt)
+}
+
+/** Knopfzeile einer Rueckfrage: Bestaetigen (ok, schickt das Formular ab) und Abbrechen. */
+function knopfZeile(bestaetigen, zu) {
+ const ok = h('button', { type: 'submit', class: 'kb-btn-primary' }, bestaetigen)
+ return { ok, zeile: h('div', { class: 'tm-frage-knoepfe' }, ok, h('button', { type: 'button', class: 'kb-btn-outline', onclick: zu }, 'Abbrechen')) }
+}
+
+const allgemeinerFehler = () => h('p', { class: 'tm-fehler', 'data-fehler': '_', role: 'alert' })
+
+function frageAblehnen(t, b, zu) {
+ const nachricht = h('textarea', { rows: '3', maxlength: String(NACHRICHT_MAX) })
+ const { ok, zeile } = knopfZeile('Anfrage ablehnen', zu)
+ const form = frage('Anfrage ablehnen?',
+ h('p', null, mailHinweis(b, 'Der Gast bekommt eine E-Mail, dass der Termin nicht möglich ist. Die Zeit wird wieder frei.')),
+ feld('nachricht', 'Nachricht an den Gast (optional)', nachricht),
+ allgemeinerFehler(),
+ zeile)
+ form.addEventListener('submit', () => fuehreAus(t, b, { aktion: 'ablehnen', nachricht: nachricht.value }, 'Anfrage abgelehnt.', form, ok))
+ return form
+}
+
+function frageAbsagen(t, b, zu) {
+ const grund = h('select', null, h('option', { value: '' }, 'Bitte wählen'), ABSAGE_GRUENDE.map((g) => h('option', { value: g }, g)))
+ const nachricht = h('textarea', { rows: '3', maxlength: String(NACHRICHT_MAX) })
+ const { ok, zeile } = knopfZeile('Termin absagen', zu)
+ const form = frage('Termin absagen?',
+ h('p', null, mailHinweis(b, 'Der Gast bekommt eine E-Mail mit dem Grund. Die Zeit wird wieder frei.')),
+ feld('grund', 'Grund', grund),
+ feld('nachricht', 'Nachricht an den Gast (optional)', nachricht),
+ allgemeinerFehler(),
+ zeile)
+ form.addEventListener('submit', () => {
+ if (!grund.value) {
+ setzeFehler(form, { grund: 'Bitte wähle einen Grund aus.' })
+ grund.focus()
+ return
+ }
+ fuehreAus(t, b, { aktion: 'absagen', grund: grund.value, nachricht: nachricht.value }, 'Termin abgesagt.', form, ok)
+ })
+ return form
+}
+
+function frageNichtErschienen(t, b, zu) {
+ const { ok, zeile } = knopfZeile('Ja, nicht erschienen', zu)
+ const form = frage('Als „nicht erschienen“ markieren?',
+ h('p', null, 'Der Termin bleibt in der Liste und wird als „Nicht erschienen“ geführt. Der Gast bekommt keine E-Mail.'),
+ allgemeinerFehler(),
+ zeile)
+ form.addEventListener('submit', () => fuehreAus(t, b, { aktion: 'nichterschienen' }, 'Als „nicht erschienen“ markiert.', form, ok))
+ return form
+}
+
+function frageAnonymisieren(t, b, zu) {
+ const { ok, zeile } = knopfZeile('Gastdaten löschen', zu)
+ const form = frage('Gastdaten löschen?',
+ h('p', null, 'Name, E-Mail, Telefon und Nachricht werden gelöscht. Das lässt sich nicht rückgängig machen. Der Termin bleibt ohne Namen bestehen, die Links in alten E-Mails funktionieren danach nicht mehr.'),
+ allgemeinerFehler(),
+ zeile)
+ form.addEventListener('submit', () => fuehreAus(t, b, { aktion: 'anonymisieren' }, 'Gastdaten gelöscht.', form, ok))
+ return form
+}
+
+function frageEntsperren(t, b, zu) {
+ const { ok, zeile } = knopfZeile('Sperre aufheben', zu)
+ const form = frage('Sperre aufheben?',
+ h('p', null, 'Die Zeit ist danach wieder buchbar.'),
+ allgemeinerFehler(),
+ zeile)
+ form.addEventListener('submit', () => fuehreAus(t, b, { aktion: 'entsperren' }, 'Sperre aufgehoben.', form, ok))
+ return form
+}
+
+function frageVerschieben(t, b, zu) {
+ const { ctx } = t
+ const datum = h('input', { type: 'date', value: b.datum, min: heuteBerlin() })
+ const zeiten = zeitWahl(ctx, () => ({ ohne: b.id, tag: datum.value }))
+ const { ok, zeile } = knopfZeile('Verschieben', zu)
+ const form = frage('Termin verschieben',
+ h('p', null, mailHinweis(b, 'Der Gast bekommt eine E-Mail mit der neuen Zeit. Es gelten keine Fristen und kein Vorlauf.')),
+ feld('tag', 'Neuer Tag', datum),
+ zeiten.el,
+ allgemeinerFehler(),
+ zeile)
+ datum.addEventListener('change', () => zeiten.laden())
+ zeiten.laden()
+ form.addEventListener('submit', () => {
+ if (!zeiten.wert()) {
+ setzeFehler(form, { zeit: 'Bitte wähle eine Uhrzeit aus.' })
+ return
+ }
+ fuehreAus(t, b, { aktion: 'verschieben', tag: datum.value, zeit: zeiten.wert() }, `Termin verschoben auf ${datumKurz(datum.value)}, ${zeiten.wert()} Uhr.`, form, ok)
+ })
+ return form
+}
+
+/** Freie Uhrzeiten als Auswahl-Knoepfe: laden() holt sie fuer query() (leistung/ressource/tag oder ohne/tag), wert() ist die gewaehlte. */
+function zeitWahl(ctx, query, { onWahl } = {}) {
+ let gewaehlt = ''
+ let nr = 0
+ const status = h('p', { class: 'tm-hinweis', 'aria-live': 'polite' })
+ const knoepfe = h('div', { class: 'tm-zeiten', role: 'group', 'aria-label': 'Freie Uhrzeiten' })
+ const fehler = h('p', { class: 'tm-fehler', 'data-fehler': 'zeit', role: 'alert' })
+ const el = h('div', { class: 'tm-feld', 'data-feld': 'zeit' }, h('span', { class: 'tm-label' }, 'Uhrzeit'), status, knoepfe, fehler)
+ async function laden() {
+ const mein = ++nr
+ gewaehlt = ''
+ knoepfe.replaceChildren()
+ fehler.textContent = ''
+ const q = query()
+ if (!q || !q.tag) { status.textContent = 'Wähle zuerst einen Tag.'; return }
+ status.textContent = 'Freie Zeiten werden geladen …'
+ try {
+ const { zeiten } = await ctx.api.get('/zeiten', q)
+ if (mein !== nr) return
+ status.textContent = zeiten.length ? 'Wähle eine Uhrzeit:' : 'An diesem Tag ist nichts frei.'
+ knoepfe.replaceChildren(...zeiten.map((z) => h('button', {
+ type: 'button', class: 'tm-chip tm-zeit-knopf', 'aria-pressed': 'false', 'data-zeit': z,
+ onclick: (ev) => {
+ gewaehlt = z
+ for (const k of knoepfe.children) k.setAttribute('aria-pressed', String(k === ev.currentTarget))
+ fehler.textContent = ''
+ onWahl?.(z)
+ },
+ }, z)))
+ } catch (err) {
+ if (mein === nr) status.textContent = err.message
+ }
+ }
+ return { el, laden, wert: () => gewaehlt }
+}
+
+/* ---------------------------------------- Termin eintragen / Zeit sperren --- */
+function oeffneFormular(t, art) {
+ schliesseFrage(t)
+ t.ctx.meldung('')
+ if (t.formular === art) { schliesseFormular(t); return }
+ t.formular = art
+ for (const k of t.werkzeuge.children) k.setAttribute('aria-expanded', String(k.dataset.werkzeug === art))
+ const form = art === 'manuell' ? formularTermin(t) : formularSperre(t)
+ t.formularBox.replaceChildren(form)
+ form.querySelector('select, input')?.focus()
+}
+
+function schliesseFormular(t) {
+ t.formular = null
+ t.formularBox.replaceChildren()
+ for (const k of t.werkzeuge?.children || []) k.setAttribute('aria-expanded', 'false')
+}
+
+/** Startwert fuer Tag-Felder: der gewaehlte Tag, wenn er nicht in der Vergangenheit liegt, sonst heute. */
+const startTag = (t) => (t.ctx.s.tag >= heuteBerlin() ? t.ctx.s.tag : heuteBerlin())
+
+async function nachErfolg(t, tag, text) {
+ schliesseFormular(t)
+ t.ctx.s.tag = tag
+ if (auswahl.ansicht === 'anfragen') auswahl.ansicht = 'tag'
+ aktualisiereKopf(t)
+ await nachAenderung(t)
+ t.ctx.meldung(text)
+}
+
+function formularTermin(t) {
+ const { ctx } = t
+ const e = ctx.daten.einstellung
+ const aktiveRessourcen = (e.ressourcen || []).filter((r) => r.aktiv)
+ const leistungen = (e.leistungen || []).filter((l) => l.aktiv && (l.ressourcen || []).some((id) => aktiveRessourcen.some((r) => r.id === id)))
+ if (!leistungen.length) {
+ return h('div', { class: 'kb-panel tm-formular' }, h('p', null, 'Lege zuerst im Reiter „Leistungen“ eine Leistung an, die du buchen kannst.'),
+ h('button', { type: 'button', class: 'kb-btn-outline', onclick: () => ctx.zumReiter('leistungen') }, 'Zu den Leistungen'))
+ }
+ const wort = ressourcenWort(aktiveRessourcen)
+ const leistung = h('select', null, leistungen.map((l) => h('option', { value: l.id }, `${l.name} (${l.dauer} Min.)`)))
+ const person = h('select')
+ const tag = h('input', { type: 'date', value: startTag(t), min: heuteBerlin() })
+ const name = h('input', { type: 'text', maxlength: String(NAME_MAX), autocomplete: 'off' })
+ const email = h('input', { type: 'email', maxlength: '254', autocomplete: 'off' })
+ const telefon = h('input', { type: 'tel', maxlength: String(TELEFON_MAX), autocomplete: 'off' })
+ const nachricht = h('textarea', { rows: '3', maxlength: String(NACHRICHT_MAX) })
+ const senden = h('input', { type: 'checkbox', id: 'tm-senden' })
+ const sendenHinweis = h('span', { class: 'tm-hinweis' })
+ const zeiten = zeitWahl(ctx, () => ({ leistung: leistung.value, ressource: person.value, tag: tag.value }))
+
+ function ressourcenOptionen() {
+ const l = leistungen.find((x) => x.id === leistung.value)
+ const infrage = aktiveRessourcen.filter((r) => (l.ressourcen || []).includes(r.id))
+ person.replaceChildren(...(infrage.length > 1 ? [h('option', { value: 'egal' }, 'Egal (erste freie)')] : []), ...infrage.map((r) => h('option', { value: r.id }, r.name)))
+ person.disabled = infrage.length === 1
+ }
+ ressourcenOptionen()
+ leistung.addEventListener('change', () => { ressourcenOptionen(); zeiten.laden() })
+ person.addEventListener('change', () => zeiten.laden())
+ tag.addEventListener('change', () => zeiten.laden())
+ const mailAn = () => { senden.disabled = email.value.trim() === ''; if (senden.disabled) senden.checked = false; sendenHinweis.textContent = senden.disabled ? ' (nur mit E-Mail-Adresse)' : '' }
+ email.addEventListener('input', mailAn)
+ mailAn()
+
+ const ok = h('button', { type: 'submit', class: 'kb-btn-primary' }, 'Termin eintragen')
+ const form = h('form', { class: 'kb-panel tm-formular', novalidate: true, 'aria-label': 'Termin eintragen' },
+ h('h3', null, 'Termin eintragen'),
+ h('p', { class: 'tm-hinweis' }, 'Für Anrufe und Laufkundschaft. Der Termin ist sofort bestätigt. Fristen und Vorlauf gelten dafür nicht.'),
+ h('div', { class: 'tm-raster' },
+ feld('leistung', 'Leistung', leistung),
+ feld('ressource', wort, person),
+ feld('tag', 'Tag', tag)),
+ zeiten.el,
+ h('div', { class: 'tm-raster' },
+ feld('name', 'Name des Gastes', name),
+ feld('email', 'E-Mail (optional)', email),
+ feld('telefon', 'Telefon (optional)', telefon)),
+ feld('nachricht', 'Notiz (optional)', nachricht, 'Bitte keine Gesundheitsangaben.'),
+ h('label', { class: 'tm-haken', for: 'tm-senden' }, senden, h('span', null, 'Bestätigung an den Gast senden', sendenHinweis)),
+ h('p', { class: 'tm-fehler', 'data-fehler': '_', role: 'alert' }),
+ h('div', { class: 'tm-frage-knoepfe' }, ok, h('button', { type: 'button', class: 'kb-btn-outline', onclick: () => { schliesseFormular(t); t.werkzeuge.querySelector('[data-werkzeug="manuell"]').focus() } }, 'Abbrechen')))
+ zeiten.laden()
+ form.addEventListener('submit', async (ev) => {
+ ev.preventDefault()
+ setzeFehler(form, {})
+ if (!zeiten.wert()) {
+ setzeFehler(form, { zeit: 'Bitte wähle eine Uhrzeit aus.' })
+ return
+ }
+ ok.disabled = true
+ try {
+ const gewaehlt = tag.value
+ await ctx.api.send('POST', '/buchungen', {
+ art: 'manuell', leistung: leistung.value, ressource: person.value, tag: gewaehlt, zeit: zeiten.wert(), name: name.value,
+ email: email.value, telefon: telefon.value, nachricht: nachricht.value, bestaetigungSenden: senden.checked,
+ })
+ await nachErfolg(t, gewaehlt, 'Termin eingetragen.')
+ } catch (err) {
+ ok.disabled = false
+ if (err.status === 400 || err.code === 'SLOT_VERGEBEN') {
+ zeigeFehler(form, err)
+ if (err.code === 'SLOT_VERGEBEN') zeiten.laden()
+ } else {
+ ctx.meldung(err.message, 'fehler')
+ }
+ }
+ })
+ return form
+}
+
+function formularSperre(t) {
+ const { ctx } = t
+ const e = ctx.daten.einstellung
+ const ressourcen = (e.ressourcen || []).filter((r) => r.aktiv)
+ const wort = ressourcenWort(ressourcen)
+ const person = h('select', null, ressourcen.map((r) => h('option', { value: r.id }, r.name)))
+ const tag = h('input', { type: 'date', value: startTag(t), min: heuteBerlin() })
+ const von = h('input', { type: 'text', inputmode: 'numeric', maxlength: '5', placeholder: '12:00', autocomplete: 'off' })
+ const bis = h('input', { type: 'text', inputmode: 'numeric', maxlength: '5', placeholder: '13:00', autocomplete: 'off' })
+ const titel = h('input', { type: 'text', maxlength: String(TITEL_MAX), placeholder: 'z. B. Mittagspause', autocomplete: 'off' })
+ const ok = h('button', { type: 'submit', class: 'kb-btn-primary' }, 'Zeit sperren')
+ const form = h('form', { class: 'kb-panel tm-formular', novalidate: true, 'aria-label': 'Zeit sperren' },
+ h('h3', null, 'Zeit sperren'),
+ h('p', { class: 'tm-hinweis' }, 'In dieser Zeit kann niemand buchen. Eine Sperre kann höchstens 8 Stunden 20 Minuten (500 Minuten) dauern. Für ganze Tage oder Urlaub nutze die Abwesenheiten im Reiter „Team & Plätze“.'),
+ h('div', { class: 'tm-raster' },
+ feld('ressource', wort, person),
+ feld('tag', 'Tag', tag)),
+ h('div', { class: 'tm-raster' },
+ feld('von', 'Von (Uhrzeit)', von, 'In 5-Minuten-Schritten, z. B. 12:00.'),
+ feld('bis', 'Bis (Uhrzeit)', bis, 'Auch 24:00 ist möglich.')),
+ feld('titel', 'Titel (optional)', titel, 'Nur für dich sichtbar.'),
+ h('p', { class: 'tm-fehler', 'data-fehler': '_', role: 'alert' }),
+ h('div', { class: 'tm-frage-knoepfe' }, ok, h('button', { type: 'button', class: 'kb-btn-outline', onclick: () => { schliesseFormular(t); t.werkzeuge.querySelector('[data-werkzeug="sperre"]').focus() } }, 'Abbrechen')))
+ form.addEventListener('submit', async (ev) => {
+ ev.preventDefault()
+ setzeFehler(form, {})
+ const vonZeit = zeitNormal(von.value)
+ const bisZeit = zeitNormal(bis.value, { ende: true })
+ const fehler = {}
+ if (!vonZeit) fehler.von = 'Bitte gib die Uhrzeit so an: 12:00.'
+ if (!bisZeit) fehler.bis = 'Bitte gib die Uhrzeit so an: 13:00.'
+ if (Object.keys(fehler).length) {
+ zeigeFehler(form, { fehler })
+ return
+ }
+ ok.disabled = true
+ try {
+ const gewaehlt = tag.value
+ await ctx.api.send('POST', '/buchungen', { art: 'sperre', ressource: person.value, tag: gewaehlt, von: vonZeit, bis: bisZeit, titel: titel.value })
+ await nachErfolg(t, gewaehlt, 'Zeit gesperrt.')
+ } catch (err) {
+ ok.disabled = false
+ if (err.status === 400 || err.code === 'SLOT_VERGEBEN') zeigeFehler(form, err)
+ else ctx.meldung(err.message, 'fehler')
+ }
+ })
+ return form
+}
diff --git a/test/funktionenTiles.test.js b/test/funktionenTiles.test.js
index 0e35e16..df39ae5 100644
--- a/test/funktionenTiles.test.js
+++ b/test/funktionenTiles.test.js
@@ -49,6 +49,103 @@ test('Kacheln rendern ohne Fehler: News, Analyse comingSoon/aus/aktiv/gesperrt',
assert.doesNotMatch(disabled, /Dev-Modus/)
})
+// ---- Terminplaner-Kachel: nur mit Schalter (termine.verfuegbar), Zustand je Projekt aus features.termine
+const withTermine = (id, state) => ({ projectId: id, projectName: `P ${id}`, liveUrl: '', features: { news, termine: { state } } })
+const noAnalyse = { comingSoon: true }
+async function renderTermine(projects, { verfuegbar, anfragen = {} } = {}) {
+ const calls = []
+ globalThis.fetch = async (url) => {
+ calls.push(String(url))
+ if (String(url).startsWith('/api/termine/einstellungen')) {
+ const id = new URL(url, 'http://x').searchParams.get('projectId')
+ return { ok: true, status: 200, json: async () => ({ anfragenOffen: anfragen[id] ?? 0 }) }
+ }
+ return { ok: true, status: 200, json: async () => ({ projects, ...(verfuegbar === undefined ? {} : { termine: { verfuegbar } }) }) }
+ }
+ const count = { textContent: '', hidden: true }
+ const root = { innerHTML: '', querySelector: (sel) => (sel === '[data-termine-count]' ? count : null) }
+ await initFunktionen(root)
+ return { html: root.innerHTML, count, calls }
+}
+
+test('Terminplaner-Kachel: fehlt termine.verfuegbar oder ist false ⇒ „Kommt bald“ ohne Knopf', async () => {
+ for (const verfuegbar of [undefined, false]) {
+ const { html, calls } = await renderTermine([{ ...withTermine('a', 'active'), analyse: noAnalyse }], { verfuegbar })
+ assert.doesNotMatch(html, /data-fn-tile="termine"/)
+ assert.match(html, /fn-tile-soon[\s\S]*Terminplaner/)
+ assert.match(html, /Kommt bald/)
+ assert.doesNotMatch(html, /data-view="termine"/)
+ assert.deepEqual(calls, ['/api/funktionen'], 'ohne Schalter keine Abfrage an /api/termine')
+ }
+})
+
+test('Terminplaner-Kachel: Aus ⇒ „Wir schalten den Terminplaner für dich frei.“ + Chat', async () => {
+ for (const projects of [[{ ...project('a', noAnalyse), features: { news } }], [withTermine('a', 'off')], [withTermine('a', 'ordered')]]) {
+ const { html } = await renderTermine(projects, { verfuegbar: true })
+ const kachel = tile(html, 'termine')
+ assert.match(kachel, /Terminplaner/)
+ assert.match(kachel, /fn-pill-off">Aus)
+ assert.match(kachel, /Wir schalten den Terminplaner für dich frei\./)
+ assert.match(kachel, /data-view="chat"[^>]*>Chat öffnen)
+ assert.doesNotMatch(kachel, /data-view="termine"/)
+ }
+})
+
+test('Terminplaner-Kachel: Aktiv ⇒ „Termine öffnen“ mit Projekt, Anfragen aus anfragenOffen', async () => {
+ const { html, count, calls } = await renderTermine([withTermine('a', 'off'), withTermine('b', 'active')], { verfuegbar: true, anfragen: { b: 3 } })
+ const kachel = tile(html, 'termine')
+ assert.match(kachel, /fn-pill-on">Aktiv)
+ assert.match(kachel, /data-view="termine" data-project="b"[^>]*>Termine öffnen)
+ assert.deepEqual(calls, ['/api/funktionen', '/api/termine/einstellungen?projectId=b'], 'nur aktive Projekte werden gefragt')
+ assert.equal(count.textContent, '3 Anfragen warten')
+ assert.equal(count.hidden, false)
+})
+
+test('Terminplaner-Kachel: Anfragen zählen über alle aktiven Websites, 1 ⇒ Einzahl, 0 ⇒ nichts', async () => {
+ const zwei = await renderTermine([withTermine('a', 'active'), withTermine('b', 'active')], { verfuegbar: true, anfragen: { a: 1, b: 2 } })
+ assert.equal(zwei.count.textContent, '3 Anfragen warten')
+ const eine = await renderTermine([withTermine('a', 'active')], { verfuegbar: true, anfragen: { a: 1 } })
+ assert.equal(eine.count.textContent, '1 Anfrage wartet')
+ const keine = await renderTermine([withTermine('a', 'active')], { verfuegbar: true, anfragen: {} })
+ assert.equal(keine.count.textContent, '')
+ assert.equal(keine.count.hidden, true)
+})
+
+test('Terminplaner-Kachel: Fehler bei der Anfragen-Zahl lässt die Kachel stehen', async () => {
+ globalThis.fetch = async (url) => {
+ if (String(url).startsWith('/api/termine/')) throw new Error('offline')
+ return { ok: true, status: 200, json: async () => ({ projects: [withTermine('a', 'active')], termine: { verfuegbar: true } }) }
+ }
+ const count = { textContent: '', hidden: true }
+ const root = { innerHTML: '', querySelector: (sel) => (sel === '[data-termine-count]' ? count : null) }
+ await initFunktionen(root)
+ assert.match(tile(root.innerHTML, 'termine'), /Termine öffnen/)
+ assert.equal(count.textContent, '')
+ assert.equal(count.hidden, true)
+})
+
+test('Terminplaner-Kachel: Abgeschaltet ⇒ Pille, Hinweis, Termine nur ansehen', async () => {
+ const { html, calls } = await renderTermine([withTermine('a', 'disabled')], { verfuegbar: true })
+ const kachel = tile(html, 'termine')
+ assert.match(kachel, /fn-pill-off">Abgeschaltet)
+ assert.match(kachel, /abgeschaltet/)
+ assert.match(kachel, /data-view="termine" data-project="a"[^>]*>Termine ansehen)
+ assert.deepEqual(calls, ['/api/funktionen'], 'abgeschaltet: keine Anfragen-Zahl')
+})
+
+test('Terminplaner-Kachel: mehrere Websites, Hinweis „aktiv für n von m“, Aktive zuerst', async () => {
+ const { html } = await renderTermine([withTermine('a', 'disabled'), withTermine('b', 'off'), withTermine('c', 'active')], { verfuegbar: true })
+ const kachel = tile(html, 'termine')
+ assert.match(kachel, /aktiv für 1 von 3 Websites/)
+ assert.match(kachel, /data-project="c"/)
+})
+
+test('Terminplaner-Kachel: Projektname und Zustand kommen nur maskiert in die Kachel', async () => {
+ const { html } = await renderTermine([{ ...withTermine('a', 'active'), projectName: '
' }], { verfuegbar: true })
+ assert.doesNotMatch(html, /
{
+ assert.deepEqual(fensterParsen('09:00–12:30, 13:30–18:00'), [['09:00', '12:30'], ['13:30', '18:00']])
+ assert.deepEqual(fensterParsen('9:00 - 12:30; 13:30 bis 18:00'), [['09:00', '12:30'], ['13:30', '18:00']])
+ assert.deepEqual(fensterParsen('9.00—24:00'), [['09:00', '24:00']])
+ assert.deepEqual(fensterParsen('08:00-12:00 und 14:00-18:00'), [['08:00', '12:00'], ['14:00', '18:00']])
+ assert.deepEqual(fensterParsen(' '), [], 'leer = an diesem Tag geschlossen')
+ assert.deepEqual(fensterParsen(''), [])
+})
+
+test('fensterParsen: ungültige Eingaben ⇒ null', () => {
+ for (const schlecht of [
+ 'abc', '09:00', '09:00–', '–12:00', '12:00–09:00', '09:00–09:00', '09:07–12:00', '25:00–26:00', '24:00–24:00',
+ '09:00–12:00, 11:00–13:00', '13:00–14:00, 09:00–10:00', '09:00–12:00,', '09:00–12:00 13:00–14:00', null, undefined, 5,
+ ]) {
+ assert.equal(fensterParsen(schlecht), null, String(schlecht))
+ }
+})
+
+test('fensterText ist die Umkehrung von fensterParsen', () => {
+ assert.equal(fensterText([['09:00', '12:30'], ['13:30', '18:00']]), '09:00–12:30, 13:30–18:00')
+ assert.equal(fensterText([]), '')
+ assert.equal(fensterText(undefined), '')
+ assert.deepEqual(fensterParsen(fensterText([['08:00', '24:00']])), [['08:00', '24:00']])
+})
+
+test('zeitNormal: HH:MM, Ende darf 24:00 sein', () => {
+ assert.equal(zeitNormal('9:05'), '09:05')
+ assert.equal(zeitNormal(' 09:00 '), '09:00')
+ assert.equal(zeitNormal('17.30'), '17:30')
+ assert.equal(zeitNormal('24:00'), null)
+ assert.equal(zeitNormal('24:00', { ende: true }), '24:00')
+ assert.equal(zeitNormal('24:05', { ende: true }), null)
+ assert.equal(zeitNormal('12:07'), '12:07', 'Zeit ohne 5er-Raster wird nur normalisiert, der Server prüft das Raster')
+ assert.equal(zeitNormal('12:60'), null)
+ assert.equal(zeitNormal('abc'), null)
+ assert.equal(zeitNormal(''), null)
+ assert.equal(zeitNormal(undefined), null)
+})
+
+test('statusLabel: alle acht Status deutsch, Unbekanntes nie roh', () => {
+ assert.deepEqual(
+ ['gehalten', 'angefragt', 'bestaetigt', 'erledigt', 'nichterschienen', 'abgesagt', 'abgelehnt', 'verfallen'].map(statusLabel),
+ ['Reserviert', 'Angefragt', 'Bestätigt', 'Erledigt', 'Nicht erschienen', 'Abgesagt', 'Abgelehnt', 'Verfallen'],
+ )
+ assert.equal(statusLabel('geheim'), 'Unbekannt')
+ assert.equal(statusLabel(undefined), 'Unbekannt')
+ for (const s of ['gehalten', 'angefragt', 'bestaetigt', 'erledigt', 'nichterschienen', 'abgesagt', 'abgelehnt', 'verfallen']) {
+ assert.match(statusTon(s), /^(ok|wait|off|bad)$/, s)
+ }
+ assert.equal(statusTon('bestaetigt'), 'ok')
+ assert.equal(statusTon('angefragt'), 'wait')
+})
+
+test('Kalender: wocheVon (Montag bis Sonntag), monatVon, tagPlus über Monats- und Jahresgrenzen', () => {
+ assert.deepEqual(wocheVon('2026-10-05'), { von: '2026-10-05', bis: '2026-10-11' }, 'Montag')
+ assert.deepEqual(wocheVon('2026-10-11'), { von: '2026-10-05', bis: '2026-10-11' }, 'Sonntag gehört zur Woche davor')
+ assert.deepEqual(wocheVon('2026-10-07'), { von: '2026-10-05', bis: '2026-10-11' })
+ assert.deepEqual(wocheVon('2026-12-31'), { von: '2026-12-28', bis: '2027-01-03' }, 'Jahreswechsel')
+ assert.deepEqual(monatVon('2026-10-15'), { von: '2026-10-01', bis: '2026-10-31' })
+ assert.deepEqual(monatVon('2028-02-10'), { von: '2028-02-01', bis: '2028-02-29' }, 'Schaltjahr')
+ assert.deepEqual(monatVon('2026-12-01'), { von: '2026-12-01', bis: '2026-12-31' })
+ assert.equal(tagPlus('2026-10-31', 1), '2026-11-01')
+ assert.equal(tagPlus('2026-01-01', -1), '2025-12-31')
+ assert.equal(tagPlus('2026-03-29', 1), '2026-03-30', 'Zeitumstellung spielt beim Kalender keine Rolle')
+ assert.equal(tagPlus('2026-10-25', 1), '2026-10-26')
+})
+
+test('bereichFuer und verschiebeTag je Ansicht', () => {
+ assert.deepEqual(bereichFuer('tag', '2026-10-07', '2026-10-05'), { von: '2026-10-07', bis: '2026-10-07' })
+ assert.deepEqual(bereichFuer('woche', '2026-10-07', '2026-10-05'), { von: '2026-10-05', bis: '2026-10-11' })
+ assert.deepEqual(bereichFuer('monat', '2026-10-07', '2026-10-05'), { von: '2026-10-01', bis: '2026-10-31' })
+ assert.deepEqual(bereichFuer('anfragen', '2026-10-07', '2026-10-05'), { von: '2026-10-05', bis: '2026-12-06' }, 'ab heute, 62 Tage (Grenze der Schnittstelle)')
+ assert.equal(verschiebeTag('tag', '2026-10-31', 1), '2026-11-01')
+ assert.equal(verschiebeTag('tag', '2026-10-01', -1), '2026-09-30')
+ assert.equal(verschiebeTag('woche', '2026-10-05', 1), '2026-10-12')
+ assert.equal(verschiebeTag('woche', '2026-10-05', -1), '2026-09-28')
+ assert.equal(verschiebeTag('monat', '2026-10-17', 1), '2026-11-01')
+ assert.equal(verschiebeTag('monat', '2026-01-17', -1), '2025-12-01')
+ assert.equal(verschiebeTag('monat', '2026-12-31', 1), '2027-01-01')
+})
+
+test('Daten: datumLang, datumKurz, tagUeberschrift, zeitraumTitel', () => {
+ assert.equal(datumLang('2026-10-05'), 'Montag, 5. Oktober 2026')
+ assert.equal(datumLang('2026-03-01'), 'Sonntag, 1. März 2026')
+ assert.equal(datumKurz('2026-10-05'), '05.10.2026')
+ assert.equal(tagUeberschrift('2026-10-05', '2026-10-05'), 'Heute · Montag, 5. Oktober')
+ assert.equal(tagUeberschrift('2026-10-06', '2026-10-05'), 'Morgen · Dienstag, 6. Oktober')
+ assert.equal(tagUeberschrift('2026-10-04', '2026-10-05'), 'Gestern · Sonntag, 4. Oktober')
+ assert.equal(tagUeberschrift('2026-10-12', '2026-10-05'), 'Montag, 12. Oktober')
+ assert.equal(tagUeberschrift('2027-01-04', '2026-10-05'), 'Montag, 4. Januar 2027', 'anderes Jahr nennt das Jahr')
+ assert.equal(zeitraumTitel('tag', '2026-10-05'), 'Montag, 5. Oktober 2026')
+ assert.equal(zeitraumTitel('woche', '2026-10-07'), '5.–11. Oktober 2026')
+ assert.equal(zeitraumTitel('woche', '2026-10-28'), '26. Oktober – 1. November 2026')
+ assert.equal(zeitraumTitel('woche', '2026-12-30'), '28. Dezember 2026 – 3. Januar 2027')
+ assert.equal(zeitraumTitel('monat', '2026-10-17'), 'Oktober 2026')
+ assert.equal(zeitraumTitel('anfragen', '2026-10-17'), 'Offene Anfragen')
+})
+
+test('heuteBerlin: der Berliner Kalendertag, nicht der des Browsers', () => {
+ assert.equal(heuteBerlin(new Date('2026-10-04T22:30:00Z')), '2026-10-05', '00:30 Berlin (MESZ) ist schon Montag')
+ assert.equal(heuteBerlin(new Date('2026-10-05T21:59:00Z')), '2026-10-05')
+ assert.equal(heuteBerlin(new Date('2026-12-31T23:30:00Z')), '2027-01-01', 'MEZ')
+ assert.match(heuteBerlin(), /^\d{4}-\d{2}-\d{2}$/)
+})
+
+test('endeZeit: Ende des Termins in Berliner Wanduhr aus endsAt', () => {
+ assert.equal(endeZeit({ endsAt: '2026-10-05T08:45:00.000Z' }), '10:45')
+ assert.equal(endeZeit({ endsAt: '2026-12-01T08:45:00.000Z' }), '09:45', 'Winterzeit')
+ assert.equal(endeZeit({ endsAt: '2026-10-05T22:00:00.000Z' }), '00:00')
+ assert.equal(endeZeit({ endsAt: 'kaputt' }), '')
+ assert.equal(endeZeit({}), '')
+})
+
+const B = (extra = {}) => ({
+ id: 'b1', status: 'bestaetigt', quelle: 'online', startsAt: '2026-10-05T08:00:00.000Z', endsAt: '2026-10-05T08:45:00.000Z', datum: '2026-10-05', zeit: '10:00',
+ gastName: 'Max', gastEmail: 'max@example.com', gastTelefon: '', gastNachricht: '', ...extra,
+})
+
+test('gruppiereNachTag: nach Berliner Tag, Tage und Termine aufsteigend, Termine der Liste bleiben vollständig', () => {
+ const liste = [
+ B({ id: 'c', datum: '2026-10-06', zeit: '09:00', startsAt: '2026-10-06T07:00:00.000Z' }),
+ B({ id: 'b', datum: '2026-10-05', zeit: '11:00', startsAt: '2026-10-05T09:00:00.000Z' }),
+ B({ id: 'a', datum: '2026-10-05', zeit: '10:00', startsAt: '2026-10-05T08:00:00.000Z' }),
+ ]
+ const gruppen = gruppiereNachTag(liste)
+ assert.deepEqual(gruppen.map((g) => g.tag), ['2026-10-05', '2026-10-06'])
+ assert.deepEqual(gruppen[0].buchungen.map((b) => b.id), ['a', 'b'])
+ assert.deepEqual(gruppen[1].buchungen.map((b) => b.id), ['c'])
+ assert.deepEqual(gruppiereNachTag([]), [])
+ assert.deepEqual(gruppiereNachTag(undefined), [])
+ assert.deepEqual(liste.map((b) => b.id), ['c', 'b', 'a'], 'Eingabe bleibt unverändert')
+})
+
+test('istSperre: nur quelle sperre', () => {
+ assert.equal(istSperre({ quelle: 'sperre' }), true)
+ assert.equal(istSperre({ quelle: 'manuell' }), false)
+ assert.equal(istSperre({ quelle: 'online' }), false)
+ assert.equal(istSperre({}), false)
+})
+
+test('moeglicheAktionen: je Status und Zeit; Sperren nie mit Gast-Aktionen', () => {
+ const vorher = Date.parse('2026-10-05T07:00:00Z') // vor Beginn
+ const mitten = Date.parse('2026-10-05T08:10:00Z') // nach Beginn
+ const danach = Date.parse('2026-10-05T09:00:00Z') // nach Ende
+ assert.deepEqual(moeglicheAktionen(B({ status: 'angefragt' }), vorher), ['annehmen', 'ablehnen', 'verschieben', 'anonymisieren'])
+ assert.deepEqual(moeglicheAktionen(B({ status: 'angefragt' }), mitten), ['ablehnen', 'anonymisieren'], 'begonnene Anfrage lässt sich nicht mehr annehmen')
+ assert.deepEqual(moeglicheAktionen(B({ status: 'bestaetigt' }), vorher), ['verschieben', 'absagen', 'anonymisieren'])
+ assert.deepEqual(moeglicheAktionen(B({ status: 'bestaetigt' }), mitten), ['nichterschienen', 'anonymisieren'])
+ assert.deepEqual(moeglicheAktionen(B({ status: 'bestaetigt' }), danach), ['nichterschienen', 'anonymisieren'])
+ assert.deepEqual(moeglicheAktionen(B({ status: 'erledigt' }), danach), ['nichterschienen', 'anonymisieren'])
+ for (const s of ['nichterschienen', 'abgesagt', 'abgelehnt', 'verfallen']) assert.deepEqual(moeglicheAktionen(B({ status: s }), danach), ['anonymisieren'], s)
+ assert.deepEqual(moeglicheAktionen(B({ status: 'gehalten' }), vorher), [], 'Reservierung wartet auf den Gast')
+ // Gastdaten schon gelöscht oder nie erfasst: nichts zu löschen
+ assert.deepEqual(moeglicheAktionen(B({ status: 'abgesagt', gastName: 'Gelöscht', gastEmail: '' }), danach), [])
+ assert.deepEqual(moeglicheAktionen(B({ status: 'abgesagt', gastName: '', gastEmail: '' }), danach), [])
+ assert.deepEqual(moeglicheAktionen(B({ status: 'abgesagt', gastName: 'Gelöscht', gastEmail: 'x@y.de' }), danach), ['anonymisieren'])
+ // Sperre: nur aufheben, solange sie noch nicht vorbei ist
+ assert.deepEqual(moeglicheAktionen(B({ quelle: 'sperre', status: 'bestaetigt', gastName: '' }), vorher), ['entsperren'])
+ assert.deepEqual(moeglicheAktionen(B({ quelle: 'sperre', status: 'bestaetigt', gastName: '' }), mitten), ['entsperren'])
+ assert.deepEqual(moeglicheAktionen(B({ quelle: 'sperre', status: 'erledigt', gastName: '' }), danach), [])
+})
+
+test('Filter: Statuslisten für die Schnittstelle', () => {
+ assert.deepEqual(FILTER.map((f) => f.key), ['alle', 'offen', 'angefragt', 'bestaetigt', 'abgesagt'])
+ assert.equal(filterStatus('alle'), '')
+ assert.equal(filterStatus('offen'), 'angefragt,bestaetigt')
+ assert.equal(filterStatus('angefragt'), 'angefragt')
+ assert.equal(filterStatus('bestaetigt'), 'bestaetigt')
+ assert.equal(filterStatus('abgesagt'), 'abgesagt,abgelehnt,verfallen')
+ assert.equal(filterStatus('gibtsnicht'), '')
+})
+
+test('anfragenText: Einzahl und Mehrzahl', () => {
+ assert.equal(anfragenText(0), '')
+ assert.equal(anfragenText(1), '1 Anfrage wartet')
+ assert.equal(anfragenText(2), '2 Anfragen warten')
+ assert.equal(anfragenText(17), '17 Anfragen warten')
+ assert.equal(anfragenText(undefined), '')
+})
+
+test('Absagegründe: Auswahl mit kurzen Texten (Server: höchstens 100 Zeichen)', () => {
+ assert.ok(ABSAGE_GRUENDE.length >= 4)
+ for (const g of ABSAGE_GRUENDE) assert.ok(g.length > 0 && [...g].length <= 100, g)
+ assert.equal(new Set(ABSAGE_GRUENDE).size, ABSAGE_GRUENDE.length)
+})
+
+test('ressourcenWort: Person, Platz oder beides', () => {
+ assert.equal(ressourcenWort([{ typ: 'person' }, { typ: 'person' }]), 'Person')
+ assert.equal(ressourcenWort([{ typ: 'platz' }]), 'Platz')
+ assert.equal(ressourcenWort([{ typ: 'person' }, { typ: 'platz' }]), 'Person/Platz')
+ assert.equal(ressourcenWort([]), 'Person/Platz')
+ assert.equal(ressourcenWort(undefined), 'Person/Platz')
+})
+
+test('fehltText: Pflichtangaben für Gäste in Worten', () => {
+ assert.equal(fehltText(['betrieb.telefon']), 'deine Telefonnummer')
+ assert.equal(fehltText(['betrieb.telefon', 'betrieb.adresse']), 'deine Telefonnummer und deine Anschrift')
+ assert.equal(fehltText(['betrieb.adresse']), 'deine Anschrift')
+ assert.equal(fehltText([]), '')
+ assert.equal(fehltText(['unbekannt']), '', 'Unbekanntes nie roh')
+ assert.equal(fehltText(undefined), '')
+})
+
+// ---- Verdrahtung (Portal-Ansicht #termine)
+const lies = (pfad) => readFileSync(new URL(`../${pfad}`, import.meta.url), 'utf8')
+
+test('Portal: Ansicht #termine ist verdrahtet, funktionen.js behält seinen einen absoluten Import', () => {
+ const app = lies('public/app.js')
+ assert.match(app, /VIEW_HASHES = \{[^}]*termine: 'termine'/)
+ assert.match(app, /APP_PARENT = \{ news: 'funktionen', analyse: 'funktionen', termine: 'funktionen'/)
+ assert.match(app, /FEATURE_LABELS = \{[^}]*termine: 'Terminplaner'/)
+ assert.match(app, /termine: \(\) => import\('\/termine\.js'\)\.then\(\(m\) => m\.initTermine\(document\.getElementById\('termine-body'\)\)\)/)
+ const html = lies('public/dashboard.html')
+ assert.match(html, /[\s\S]*Terminplaner<\/h1>[\s\S]*/)
+ assert.match(html, /
/)
+ // R4: funktionen.js lädt nichts Neues (die Kachel steht inline); test/funktionenTiles.test.js biegt genau diesen Import um
+ const fn = lies('public/funktionen.js')
+ assert.deepEqual(fn.match(/from '\/[^']+'/g), ["from '/analyse-view.js'"])
+ assert.doesNotMatch(fn, /import\s*\(/, 'auch kein dynamischer Import')
+})
+
+test('termine.js: Fremdtext nur als Text, keine Browser-Dialoge, Du-Form', () => {
+ const quelle = lies('public/termine.js').replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '') // ohne Kommentare
+ assert.doesNotMatch(quelle, /innerHTML|insertAdjacentHTML|outerHTML|document\.write/, 'Daten nie als HTML')
+ assert.doesNotMatch(quelle, /\bconfirm\(|\balert\(|\bprompt\(/, 'Rückfragen stehen in der Seite')
+ assert.doesNotMatch(quelle, /\bSie\b|\bIhr(e[nmrs]?)?\b/, 'Portaltexte in der Du-Form')
+ // Links nur auf die eigene Export-Schnittstelle, nie auf Kundentext
+ const hrefs = quelle.match(/href: `[^`]*`/g) || []
+ assert.deepEqual(hrefs, ['href: `/api/termine/export.csv?projectId=${encodeURIComponent(projectId)}`'])
+ assert.doesNotMatch(quelle, /href: [^`]|setAttribute\('href'/, 'sonst kein href')
+})
diff --git a/test/ui/termine-portal.ui.mjs b/test/ui/termine-portal.ui.mjs
new file mode 100644
index 0000000..96cbbdb
--- /dev/null
+++ b/test/ui/termine-portal.ui.mjs
@@ -0,0 +1,888 @@
+// Playwright-Checks der Portal-Ansicht Terminplaner (public/termine.js, termine-view.js, termine.css, kachel in funktionen.js) – Aufgabe 21a:
+// Kachel, Vorlagenauswahl, Reiter-Geruest und Reiter "Termine". Nicht Teil von npm test.
+// Aufruf: nice node test/ui/termine-portal.ui.mjs (vorher free -m; es laeuft nur ein Browser)
+// Screenshots: /root/reports/terminplaner-ui/portal-*.png (bei Fehlern fehler-portal-
.png). Exit-Code 1, wenn eine Pruefung scheitert.
+//
+// Zwei Arten von Pruefungen auf demselben UI-Testserver (test/ui/termineTestServer.mjs):
+// - "fx_*": Fixture. Alle /api/-Anfragen beantwortet page.route mit festen Daten; der Server liefert nur dashboard.html und die Dateien aus public/.
+// - "echt_*": die echten Router (/api/termine, /api/funktionen) hinter dem Nachbau von Appwrite; nur die uebrigen Portal-Schnittstellen
+// (/api/auth/me usw.) mockt page.route. Portal-Sitzung per Cookie (srv.portalCookie()).
+// NUR= fuehrt nur passende Pruefungen aus (zum Entwickeln).
+// Uhr: Server und Browser stehen auf Montag 2026-10-05 10:00 Berliner Zeit (context.clock.setFixedTime).
+import { mkdir, readdir, rm } from 'node:fs/promises'
+import path from 'node:path'
+import {
+ startTermineUiServer, salonEingabe,
+ R_ANNA, R_BEN, S_HERREN, S_DAMEN, S_ANFRAGE,
+} from './termineTestServer.mjs'
+
+const PW = process.env.PLAYWRIGHT_CORE || '/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core/index.mjs'
+const { chromium } = await import(PW)
+
+const SHOTS = '/root/reports/terminplaner-ui'
+await mkdir(SHOTS, { recursive: true })
+for (const datei of await readdir(SHOTS)) if (/^(portal|fehler-portal)-.*\.png$/.test(datei)) await rm(path.join(SHOTS, datei)) // alte Laeufe
+
+const srv = await startTermineUiServer()
+const KEY = srv.SITE_KEY
+const API = `${srv.url}/api/public/termine/v1`
+const JETZT = new Date('2026-10-05T08:00:00.000Z')
+
+// ---------------------------------------------------------------- Helfer
+
+function assert(cond, msg) { if (!cond) throw new Error(msg) }
+function eq(ist, soll, msg) {
+ const a = JSON.stringify(ist)
+ const b = JSON.stringify(soll)
+ if (a !== b) throw new Error(`${msg}: ${a} statt ${b}`)
+}
+
+/** Screenshot der ganzen Seite: das Portal scrollt im Dokument, fullPage zeigt dort nur die Kopfzeile; darum Fenster auf Seitenhoehe (hoechstens 3200 px). */
+async function shot(page, name) {
+ // Einblend-Animationen der Seite (kb-fade-in, kb-view-in) abwarten, sonst ist der Inhalt noch durchsichtig
+ await page.evaluate(() => Promise.all(document.getAnimations().filter((a) => a.effect.getComputedTiming().iterations !== Infinity).map((a) => a.finished.catch(() => {}))))
+ const fenster = page.viewportSize()
+ const hoehe = await page.evaluate(() => Math.min(Math.max(document.documentElement.scrollHeight, window.innerHeight), 3200))
+ await page.setViewportSize({ width: fenster.width, height: hoehe })
+ await page.screenshot({ path: `${SHOTS}/portal-${name}.png` })
+ await page.setViewportSize(fenster)
+}
+const knopf = (loc, name) => loc.getByRole('button', { name }) // Teiltreffer: Knoepfe in Zeilen tragen den Kontext im aria-label
+const reihe = (page, text) => page.locator('li.tm-eintrag', { hasText: text })
+const meldung = (page) => page.locator('.tm-meldung:not([hidden])')
+const pillen = (page) => page.locator('.tm-pille').allInnerTexts()
+const textVon = (loc) => loc.innerText()
+/** Kein Streifen rechts: die Seite passt in die Breite. */
+const passtInBreite = (page) => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
+
+// Berliner Wanduhr im Oktober 2026 (MESZ, bis 25.10.) als ISO-Zeit
+const utc = (tag, zeit) => new Date(`${tag}T${zeit}:00+02:00`).toISOString()
+
+/** Die uebrigen Portal-Schnittstellen, die dashboard.html beim Laden braucht (wie test/ui/funktionen.ui.mjs). */
+function portalHilfe(p, json) {
+ if (p === '/api/auth/me') return json({ authenticated: true, role: 'customer', customer: { id: 'c1', code: '1001', name: 'Silke Müller', email: 'silke@salon-mueller.example' } })
+ if (p === '/api/projects') return json({ projects: [{ id: 'p1', name: 'Salon Müller', status: 'live' }] })
+ if (p === '/api/features') return json({ features: [] })
+ if (p === '/api/contact') return json({ name: 'WEBklar-Team', responseTime: 'Antwort meist < 1 Werktag' })
+ if (p === '/api/chat/unread-count') return json({ unread: 0 })
+ return json({})
+}
+
+/** Fixture: Daten, die page.route statt der Schnittstellen /api/funktionen und /api/termine liefert. */
+function neueFixture() {
+ const fx = {
+ verfuegbar: true,
+ projekte: [{ projectId: 'p1', projectName: 'Salon Müller', liveUrl: 'https://salon-mueller.example', features: { news: { state: 'off' }, termine: { state: 'active' } } }],
+ einstellung: srv.einstellung(),
+ vorlagen: [
+ { key: 'salon', name: 'Friseur und Salon', beschreibung: 'Haarschnitt, Färben, Styling.' },
+ { key: 'kosmetik', name: 'Kosmetik und Wellness', beschreibung: 'Behandlungen und Massagen.' },
+ { key: 'beratung', name: 'Beratung und Coaching', beschreibung: 'Gespräche vor Ort oder online.' },
+ ],
+ fehlt: [],
+ anfragenOffen: 0,
+ buchungen: [],
+ aufrufe: [],
+ }
+ fx.daten = (state = fx.projekte[0].features.termine.state) => ({
+ state, aktivSeit: '2026-10-01T09:00:00.000Z', einstellung: fx.einstellung, version: fx.einstellung ? 3 : 0, vorlagen: fx.vorlagen, feeds: {}, siteKey: KEY,
+ einbau: { snippet: '' }, anfragenOffen: fx.buchungen.filter((b) => b.status === 'angefragt').length || fx.anfragenOffen, fehlt: fx.fehlt,
+ })
+ return fx
+}
+
+/** Buchung in der Form von GET /api/termine/buchungen. */
+function buchung(id, tag, zeit, dauer, o = {}) {
+ const startsAt = utc(tag, zeit)
+ const b = {
+ id, nummer: id.toUpperCase().padEnd(8, 'X').slice(0, 8), status: 'bestaetigt', quelle: 'online', startsAt, endsAt: new Date(new Date(startsAt).getTime() + dauer * 60000).toISOString(),
+ datum: tag, zeit, leistungId: S_DAMEN, leistung: 'Damenschnitt', dauer, titel: '', person: 'Anna', ressourceId: R_ANNA, gastName: 'Max Mustermann', gastEmail: 'max@example.com',
+ gastTelefon: '06301 555', gastNachricht: '', erinnerung: false, zustellFehler: false, angenommenAt: null, widerrufenAt: null, abgesagtVon: '', absageGrund: '', ...o,
+ }
+ b.titel = b.quelle === 'sperre' ? (o.titel || 'Gesperrt') : b.gastName ? `${b.leistung}: ${b.gastName}` : b.leistung
+ return b
+}
+
+/** Alle Status in einer Woche (Mo 2026-10-05 bis So 2026-10-11) plus eine Sperre. */
+function alleStatus() {
+ return [
+ buchung('bnichter', '2026-10-05', '07:00', 30, { status: 'nichterschienen', leistung: 'Herrenschnitt', person: 'Ben', gastName: 'Jonas Weber' }),
+ buchung('berledigt', '2026-10-05', '08:00', 45, { status: 'erledigt', gastName: 'Petra Klein' }),
+ buchung('bbegonnen', '2026-10-05', '09:00', 30, { leistung: 'Herrenschnitt', person: 'Ben', gastName: 'Karl Ohr' }),
+ buchung('bsperre', '2026-10-05', '12:00', 60, { quelle: 'sperre', status: 'bestaetigt', leistung: '', person: 'Ben', gastName: '', gastEmail: '', gastTelefon: '', titel: 'Mittagspause' }),
+ buchung('bkunft', '2026-10-05', '14:00', 45, { zustellFehler: true, gastName: 'Hans Gast' }),
+ buchung('banfrage', '2026-10-05', '15:00', 90, { status: 'angefragt', leistung: 'Brautfrisur', gastName: 'Lea Braun', gastNachricht: 'Hochzeit am 24.10., bitte mit Probetermin.' }),
+ buchung('bgehalten', '2026-10-05', '17:00', 30, { status: 'gehalten', leistung: 'Kinderschnitt', person: 'Ben', gastName: 'Tim Berg' }),
+ buchung('babgesagt', '2026-10-06', '10:00', 30, { status: 'abgesagt', abgesagtVon: 'betrieb', absageGrund: 'Krankheit', leistung: 'Herrenschnitt', person: 'Ben', gastName: 'Fritz Alt' }),
+ buchung('bwiderruf', '2026-10-06', '11:00', 45, { status: 'abgesagt', abgesagtVon: 'gast', widerrufenAt: utc('2026-10-04', '12:00'), absageGrund: 'Widerruf erklärt von: Eva Rot (eingegangen 04.10.2026 12:00)', gastName: 'Eva Rot' }),
+ buchung('babgelehnt', '2026-10-06', '14:00', 90, { status: 'abgelehnt', leistung: 'Brautfrisur', gastName: 'Uta Neu' }),
+ buchung('bverfallen', '2026-10-06', '15:00', 30, { status: 'verfallen', leistung: 'Kinderschnitt', person: 'Ben', gastName: 'Nils Spät' }),
+ buchung('bvermerk', '2026-10-06', '16:00', 45, { absageGrund: 'Widerruf nach Ablauf der Frist erklärt von: Anke Zu', gastName: 'Anke Zu' }),
+ buchung('bmanuell', '2026-10-08', '09:00', 30, { quelle: 'manuell', leistung: 'Herrenschnitt', person: 'Ben', gastName: 'Walter Laufkunde', gastEmail: '', gastTelefon: '' }),
+ ]
+}
+
+/** Antworten der Fixture auf /api/funktionen und /api/termine (Teil von page.route). */
+async function fixtureAntwort(fx, route, req, url, json) {
+ const p = url.pathname
+ const q = url.searchParams
+ const m = req.method()
+ if (p === '/api/funktionen') return json({ projects: fx.projekte, termine: { verfuegbar: fx.verfuegbar } })
+ if (p === '/api/termine/einstellungen' && m === 'GET') return json(fx.daten())
+ if (p === '/api/termine/einstellungen/vorlage' && m === 'POST') {
+ fx.aufrufe.push(['vorlage', req.postDataJSON()])
+ fx.einstellung = srv.einstellung()
+ fx.fehlt = ['betrieb.telefon', 'betrieb.adresse']
+ return json({ einstellung: fx.einstellung, version: 1, fehlt: fx.fehlt }, 201)
+ }
+ if (p === '/api/termine/buchungen' && m === 'GET') {
+ fx.aufrufe.push(['liste', Object.fromEntries(q)])
+ const status = q.get('status') ? q.get('status').split(',') : null
+ const liste = fx.buchungen.filter((b) => b.datum >= q.get('von') && b.datum <= q.get('bis') && (!status || status.includes(b.status)))
+ return json({ buchungen: liste.sort((a, b) => (a.startsAt < b.startsAt ? -1 : 1)) })
+ }
+ const aktion = /^\/api\/termine\/buchungen\/([^/]+)\/aktion$/.exec(p)
+ if (aktion && m === 'POST') {
+ const body = req.postDataJSON()
+ fx.aufrufe.push(['aktion', aktion[1], body])
+ const b = fx.buchungen.find((x) => x.id === aktion[1])
+ if (body.aktion === 'annehmen') { b.status = 'bestaetigt'; b.angenommenAt = JETZT.toISOString() }
+ if (body.aktion === 'absagen') Object.assign(b, { status: 'abgesagt', abgesagtVon: 'betrieb', absageGrund: body.grund })
+ return json({ status: b.status })
+ }
+ return json({})
+}
+
+// ---------------------------------------------------------------- Ablauf der Pruefungen
+
+const results = []
+let browser = null
+const hosts = new Set()
+
+/**
+ * Eine Pruefung in eigenem Browser-Kontext. optionen: viewport/context (Playwright), fixture (Objekt aus neueFixture() ⇒ alle
+ * Schnittstellen aus der Fixture; sonst die echten Router mit Portal-Cookie), reset (Optionen fuer srv.zuruecksetzen).
+ */
+async function check(name, fn, { context = {}, fixture = null, reset = {} } = {}) {
+ if (process.env.NUR && !name.includes(process.env.NUR)) return
+ await srv.zuruecksetzen(reset)
+ const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 }, ...context })
+ await ctx.clock.setFixedTime(JETZT)
+ if (!fixture) await ctx.addCookies([await srv.portalCookie()])
+ const page = await ctx.newPage()
+ page.fx = fixture
+ page.setDefaultTimeout(8000)
+ const fehler = []
+ page.on('pageerror', (e) => fehler.push(`JavaScript-Fehler: ${e.message}`))
+ page.on('dialog', (d) => { fehler.push(`Browser-Dialog ${d.type()}: ${d.message()}`); d.dismiss().catch(() => {}) })
+ page.on('request', (r) => { try { const u = new URL(r.url()); if (u.protocol.startsWith('http')) hosts.add(u.host) } catch { /* data: u. a. */ } })
+ await page.route('**/api/**', async (route) => {
+ const req = route.request()
+ const url = new URL(req.url())
+ const p = url.pathname
+ const json = (body, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
+ if (p.startsWith('/api/funktionen') || p.startsWith('/api/termine')) return fixture ? fixtureAntwort(fixture, route, req, url, json) : route.continue()
+ return portalHilfe(p, json)
+ })
+ try {
+ await fn(page, ctx)
+ assert(fehler.length === 0, fehler.join(' | '))
+ results.push([name, true])
+ console.log(`ok ${name}`)
+ } catch (e) {
+ results.push([name, false])
+ console.log(`FAIL ${name}: ${e.message.split('\n')[0]}`)
+ await page.screenshot({ path: `${SHOTS}/fehler-portal-${name}.png` }).catch(() => {})
+ } finally {
+ await ctx.close()
+ }
+}
+
+const oeffne = async (page, hash) => {
+ await page.goto(`${srv.url}/dashboard.html${hash}`)
+}
+const warteAnsicht = async (page) => {
+ await page.locator('#view-termine.active').waitFor()
+ await page.locator('#termine-body .tm-reiterleiste, #termine-body .tm-vorlagen, #termine-body .tm-info').first().waitFor()
+}
+
+// ---- Gaeste ueber die oeffentliche Schnittstelle buchen (echte Pruefungen): Link-Token aus der Mail
+const JSON_KOPF = { 'content-type': 'application/json', origin: srv.url }
+async function buche(o = {}) {
+ const v = { leistung: S_DAMEN, ressource: R_ANNA, tag: '2026-10-12', zeit: '10:00', name: 'Max Mustermann', email: 'max@example.com', ...o }
+ srv.limitsZuruecksetzen()
+ const conf = await (await fetch(`${API}/${KEY}/config`)).json()
+ const res = await fetch(`${API}/${KEY}/buchungen`, {
+ method: 'POST',
+ headers: JSON_KOPF,
+ body: JSON.stringify({
+ leistung: v.leistung, ressource: v.ressource, tag: v.tag, zeit: v.zeit, name: v.name, email: v.email, telefon: '', nachricht: '', zusatz: {},
+ erinnerung: false, zustimmungen: { storno: true, vorzeitig: true }, kontakt_url: '', formToken: conf.formToken,
+ }),
+ })
+ eq(res.status, 201, `Buchung ${v.tag} ${v.zeit} (${await res.clone().text()})`)
+ await srv.warteAufVersand()
+ const mails = srv.mails().filter((m) => m.to.some((t) => t.address === v.email)).reverse()
+ let token = null
+ for (const mail of mails) {
+ const treffer = /termin\.html#v=([A-Za-z0-9_-]{43})/.exec(`${mail.text}\n${mail.html}`)
+ if (treffer) { token = treffer[1]; break }
+ }
+ assert(token, `kein Bestaetigungslink fuer ${v.email}`)
+ const best = await fetch(`${API}/termin/bestaetigen`, { method: 'POST', headers: JSON_KOPF, body: JSON.stringify({ token }) })
+ eq(best.status, 200, 'Bestaetigung')
+ await srv.warteAufVersand()
+ return srv.buchungen().find((b) => b.gastEmail === v.email && gleichZeit(b.startsAt, utc(v.tag, v.zeit)))
+}
+
+const gleichZeit = (a, b) => new Date(a).getTime() === new Date(b).getTime() // der Nachbau speichert Datumswerte als +00:00
+const mailsAn = (adresse) => srv.mails().filter((m) => m.to.some((t) => t.address === adresse))
+const buchungMit = (id) => srv.buchungen().find((b) => b.$id === id)
+
+async function pruefungen() {
+ // ============================================================ Fixture
+ // ------------------------------------------------------------ Kachel in Funktionen
+ await check('fx_kachel_zustaende', async (page) => {
+ // Schalter aus ⇒ „Kommt bald“
+ await oeffne(page, '#funktionen')
+ await page.locator('.fn-tiles').waitFor()
+ const soon = page.locator('.fn-tile-soon', { hasText: 'Terminplaner' })
+ eq(await soon.count(), 1, 'Kommt-bald-Kachel')
+ assert((await textVon(soon)).includes('Kommt bald'), 'Pille Kommt bald')
+ eq(await page.locator('[data-fn-tile="termine"]').count(), 0, 'keine echte Kachel')
+ eq(await page.locator('[data-view="termine"]').count(), 0, 'kein Knopf zur Ansicht')
+ }, { fixture: Object.assign(neueFixture(), { verfuegbar: false }) })
+
+ await check('fx_kachel_aus_aktiv_abgeschaltet', async (page) => {
+ const fx = page.fx
+ // Aus
+ fx.projekte[0].features.termine = { state: 'off' }
+ await oeffne(page, '#funktionen')
+ let kachel = page.locator('[data-fn-tile="termine"]')
+ await kachel.waitFor()
+ let t = await textVon(kachel)
+ assert(t.includes('Aus') && t.includes('Wir schalten den Terminplaner für dich frei.'), `Aus: ${t}`)
+ await knopf(kachel, 'Chat öffnen').waitFor()
+ eq(await kachel.locator('[data-view="termine"]').count(), 0, 'Aus: kein Weg in die Ansicht')
+ // Aktiv mit zwei Anfragen
+ fx.projekte[0].features.termine = { state: 'active' }
+ fx.buchungen = alleStatus().map((b) => (b.id === 'bkunft' ? { ...b, status: 'angefragt' } : b))
+ await page.reload()
+ kachel = page.locator('[data-fn-tile="termine"]')
+ await kachel.waitFor()
+ await kachel.getByText('2 Anfragen warten').waitFor()
+ t = await textVon(kachel)
+ assert(t.includes('Aktiv') && t.includes('Termine öffnen'), `Aktiv: ${t}`)
+ await shot(page, 'kachel-aktiv')
+ // eine Anfrage ⇒ Einzahl
+ fx.buchungen = alleStatus()
+ await page.reload()
+ await page.locator('[data-fn-tile="termine"]').getByText('1 Anfrage wartet').waitFor()
+ // keine Anfrage ⇒ keine Zeile
+ fx.buchungen = []
+ await page.reload()
+ await page.locator('[data-fn-tile="termine"]').waitFor()
+ await page.waitForTimeout(150)
+ eq(await page.locator('[data-fn-tile="termine"] [data-termine-count]:not([hidden])').count(), 0, 'ohne Anfragen keine Zahl')
+ // Abgeschaltet
+ fx.projekte[0].features.termine = { state: 'disabled' }
+ await page.reload()
+ kachel = page.locator('[data-fn-tile="termine"]')
+ await kachel.waitFor()
+ t = await textVon(kachel)
+ assert(t.includes('Abgeschaltet') && t.includes('abgeschaltet') && t.includes('Termine ansehen'), `Abgeschaltet: ${t}`)
+ await shot(page, 'kachel-abgeschaltet')
+ }, { fixture: neueFixture() })
+
+ // ------------------------------------------------------------ Vorlage waehlen
+ await check('fx_vorlage_waehlen', async (page) => {
+ const fx = page.fx
+ await oeffne(page, '#termine')
+ await warteAnsicht(page)
+ // erstes Oeffnen ohne Einstellung: Auswahl statt Reiter
+ eq(await page.locator('.tm-reiterleiste').count(), 0, 'noch keine Reiter')
+ eq(await page.locator('input[name="termine-vorlage"]').count(), 3, 'Vorlagen aus der Antwort')
+ const start = knopf(page, 'Mit dieser Vorlage starten')
+ eq(await start.isDisabled(), true, 'Start erst nach der Wahl')
+ await shot(page, 'vorlage-waehlen')
+ await page.getByLabel('Friseur und Salon').check()
+ eq(await start.isDisabled(), false, 'Start nach der Wahl')
+ await start.click()
+ await page.locator('.tm-reiterleiste').waitFor()
+ eq(fx.aufrufe.filter((a) => a[0] === 'vorlage'), [['vorlage', { projectId: 'p1', vorlage: 'salon' }]], 'POST der Vorlage')
+ assert((await textVon(meldung(page))).includes('„Friseur und Salon“'), 'Meldung nennt die Vorlage')
+ // Pflichtangaben fehlen ⇒ Hinweis mit Weg zu den Einstellungen
+ const hinweis = await textVon(page.locator('.tm-box-warn'))
+ assert(hinweis.includes('Noch nicht buchbar') && hinweis.includes('deine Telefonnummer und deine Anschrift'), `Hinweis: ${hinweis}`)
+ await knopf(page.locator('.tm-box-warn'), 'Zu den Einstellungen').click()
+ eq(await page.locator('#tm-tab-einstellungen').getAttribute('aria-selected'), 'true', 'Reiter Einstellungen gewaehlt')
+ await shot(page, 'vorlage-angelegt')
+ }, { fixture: Object.assign(neueFixture(), { einstellung: null }) })
+
+ // ------------------------------------------------------------ Projektauswahl
+ await check('fx_projektwahl', async (page) => {
+ await oeffne(page, '#funktionen')
+ const kachel = page.locator('[data-fn-tile="termine"]')
+ await kachel.waitFor()
+ assert((await textVon(kachel)).includes('aktiv für 1 von 2 Websites'), 'Kachel zaehlt die Websites')
+ eq(await kachel.locator('[data-view="termine"]').getAttribute('data-project'), 'p2', 'Kachel zeigt auf die aktive Website')
+ await knopf(kachel, 'Termine öffnen').click()
+ await warteAnsicht(page)
+ await page.locator('.tm-reiterleiste').waitFor()
+ eq(await page.locator('#termine-project').inputValue(), 'p2', 'Auswahl: die Website der Kachel')
+ // andere Website ohne Terminplaner: Hinweis statt Reiter
+ await page.locator('#termine-project').selectOption('p1')
+ await page.getByText('noch nicht freigeschaltet').waitFor()
+ eq(await page.locator('.tm-reiterleiste').count(), 0, 'keine Reiter')
+ await knopf(page.locator('#termine-body'), 'Chat öffnen').waitFor()
+ await shot(page, 'projektwahl')
+ await page.locator('#termine-project').selectOption('p2')
+ await page.locator('.tm-reiterleiste').waitFor()
+ // Auswahl bleibt beim erneuten Oeffnen
+ await page.evaluate(() => { window.location.hash = '#funktionen' })
+ await page.locator('#view-funktionen.active').waitFor()
+ await page.evaluate(() => { window.location.hash = '#termine' })
+ await page.locator('.tm-reiterleiste').waitFor()
+ eq(await page.locator('#termine-project').inputValue(), 'p2', 'Auswahl bleibt')
+ }, {
+ fixture: (() => {
+ const fx = neueFixture()
+ fx.projekte = [
+ { projectId: 'p1', projectName: 'Alte Website', liveUrl: '', features: { news: { state: 'off' }, termine: { state: 'off' } } },
+ { projectId: 'p2', projectName: 'Salon Müller', liveUrl: '', features: { news: { state: 'off' }, termine: { state: 'active' } } },
+ ]
+ return fx
+ })(),
+ })
+
+ // ------------------------------------------------------------ Reiter-Geruest
+ await check('fx_reiter_geruest', async (page) => {
+ await oeffne(page, '#termine')
+ await warteAnsicht(page)
+ const tabs = page.getByRole('tab')
+ eq(await tabs.allInnerTexts(), ['Termine', 'Leistungen', 'Team & Plätze', 'Einstellungen'], 'vier Reiter')
+ eq(await page.getByRole('tab', { selected: true }).innerText(), 'Termine', 'Termine zuerst')
+ eq(await page.getByRole('tabpanel').getAttribute('aria-labelledby'), 'tm-tab-termine', 'Panel gehoert zum Reiter')
+ // Tastatur: Pfeil rechts wechselt (Platzhalter), Pos1 zurueck
+ await page.locator('#tm-tab-termine').focus()
+ await page.keyboard.press('ArrowRight')
+ eq(await page.getByRole('tab', { selected: true }).innerText(), 'Leistungen', 'Pfeil rechts')
+ assert((await textVon(page.getByRole('tabpanel'))).includes('Leistungen'), 'Platzhalter-Panel')
+ await page.keyboard.press('End')
+ eq(await page.getByRole('tab', { selected: true }).innerText(), 'Einstellungen', 'Ende')
+ await page.keyboard.press('ArrowRight')
+ eq(await page.getByRole('tab', { selected: true }).innerText(), 'Termine', 'Pfeil rechts vom letzten Reiter springt zum ersten')
+ // Auswahl bleibt beim Verlassen der Ansicht erhalten
+ await page.getByRole('tab', { name: 'Team & Plätze' }).click()
+ await page.evaluate(() => { window.location.hash = '#funktionen' })
+ await page.locator('#view-funktionen.active').waitFor()
+ await page.evaluate(() => { window.location.hash = '#termine' })
+ await page.locator('#view-termine.active').waitFor()
+ await page.locator('.tm-reiterleiste').waitFor()
+ eq(await page.getByRole('tab', { selected: true }).innerText(), 'Team & Plätze', 'Reiter bleibt')
+ }, { fixture: Object.assign(neueFixture(), { buchungen: alleStatus() }) })
+
+ // ------------------------------------------------------------ Liste mit allen Status (Fixture)
+ await check('fx_liste_alle_status', async (page) => {
+ const fx = page.fx
+ await oeffne(page, '#termine')
+ await warteAnsicht(page)
+ // Tag: heute, Montag 5. Oktober, in Zeitfolge
+ eq((await textVon(page.locator('.tm-zeitraum'))), 'Montag, 5. Oktober 2026', 'Titel Tag')
+ await page.locator('li.tm-eintrag').first().waitFor()
+ eq(await page.locator('li.tm-eintrag').evaluateAll((l) => l.map((e) => e.dataset.id)), ['bnichter', 'berledigt', 'bbegonnen', 'bsperre', 'bkunft', 'banfrage', 'bgehalten'], 'Tagesliste in Zeitfolge')
+ eq(fx.aufrufe.at(-1)[1], { projectId: 'p1', von: '2026-10-05', bis: '2026-10-05' }, 'Abfrage nur fuer heute')
+ // Woche: alle acht Status und die Sperre
+ await page.getByRole('button', { name: 'Woche', exact: true }).click()
+ await page.locator('li.tm-eintrag[data-id="babgesagt"]').waitFor()
+ eq(fx.aufrufe.at(-1)[1], { projectId: 'p1', von: '2026-10-05', bis: '2026-10-11' }, 'Abfrage fuer die Woche')
+ eq(await textVon(page.locator('.tm-zeitraum')), '5.–11. Oktober 2026', 'Titel Woche')
+ const alle = await pillen(page)
+ for (const status of ['Reserviert', 'Angefragt', 'Bestätigt', 'Erledigt', 'Nicht erschienen', 'Abgesagt', 'Abgelehnt', 'Verfallen', 'Gesperrt']) assert(alle.includes(status), `Pille ${status} fehlt: ${alle.join(', ')}`)
+ eq(await page.locator('.tm-tag').count(), 7, 'sieben Tage, auch leere')
+ const leer = await textVon(page.locator('.tm-tag[data-tag="2026-10-07"]'))
+ assert(leer.includes('Mittwoch, 7. Oktober') && leer.includes('Keine Termine'), `leerer Tag: ${leer}`)
+ assert((await textVon(page.locator('.tm-tag[data-tag="2026-10-05"] .tm-tag-kopf'))).startsWith('Heute · Montag, 5. Oktober'), 'Heute-Kopf')
+ assert((await textVon(page.locator('.tm-tag[data-tag="2026-10-06"] .tm-tag-kopf'))).startsWith('Morgen · Dienstag, 6. Oktober'), 'Morgen-Kopf')
+ // Sperre: Block „Gesperrt“ mit Titel, ohne Gastaktionen und ohne Details
+ const sperre = reihe(page, 'Mittagspause')
+ const st = await textVon(sperre)
+ assert(st.includes('Gesperrt') && st.includes('Ben') && st.includes('12:00–13:00'), `Sperre: ${st}`)
+ eq(await sperre.locator('[data-aktion]').allInnerTexts(), ['Sperre aufheben'], 'Sperre: nur aufheben')
+ eq(await sperre.locator('details').count(), 0, 'Sperre ohne Gastdetails')
+ // Zeilen: Uhrzeit mit Ende, Leistung, Person, Gast
+ const kunft = await textVon(reihe(page, 'Hans Gast'))
+ assert(kunft.includes('14:00–14:45') && kunft.includes('Damenschnitt') && kunft.includes('Anna · Hans Gast') && kunft.includes('Bestätigt'), `Zeile: ${kunft}`)
+ assert(kunft.includes('Bestätigung nicht zugestellt'), 'Hinweis Bestätigung nicht zugestellt')
+ eq(await page.locator('li.tm-eintrag:not([data-id="bkunft"]) .tm-warn').count(), 0, 'Hinweis nur an der betroffenen Zeile')
+ // absageGrund nur bei Status abgesagt; Vermerk an einem bestehenden Termin bleibt unsichtbar
+ const abgesagt = await textVon(reihe(page, 'Fritz Alt'))
+ assert(abgesagt.includes('Von dir abgesagt') && abgesagt.includes('Grund: Krankheit'), `Absage: ${abgesagt}`)
+ const widerruf = await textVon(reihe(page, 'Eva Rot'))
+ assert(widerruf.includes('Vom Gast widerrufen'), `Widerruf: ${widerruf}`)
+ const vermerk = await textVon(reihe(page, 'Anke Zu'))
+ assert(!vermerk.includes('Widerruf nach Ablauf') && !vermerk.includes('Grund'), `Vermerk darf nicht erscheinen: ${vermerk}`)
+ assert((await textVon(reihe(page, 'Walter Laufkunde'))).includes('von dir eingetragen'), 'Handeintrag gekennzeichnet')
+ // Knoepfe je Status
+ const aktionen = async (id) => page.locator(`li.tm-eintrag[data-id="${id}"] [data-aktion]`).evaluateAll((l) => l.map((e) => e.dataset.aktion))
+ eq(await aktionen('banfrage'), ['annehmen', 'ablehnen', 'verschieben', 'anonymisieren'], 'Anfrage')
+ eq(await aktionen('bkunft'), ['verschieben', 'absagen', 'anonymisieren'], 'bestaetigt in der Zukunft')
+ eq(await aktionen('bbegonnen'), ['nichterschienen', 'anonymisieren'], 'bestaetigt, begonnen')
+ eq(await aktionen('bgehalten'), [], 'Reservierung')
+ eq(await aktionen('bverfallen'), ['anonymisieren'], 'verfallen')
+ await shot(page, 'liste-woche-desktop')
+ }, { fixture: Object.assign(neueFixture(), { buchungen: alleStatus() }) })
+
+ // ------------------------------------------------------------ Filter, Navigation, Monat
+ await check('fx_filter_navigation', async (page) => {
+ const fx = page.fx
+ await oeffne(page, '#termine')
+ await warteAnsicht(page)
+ await page.getByRole('button', { name: 'Monat', exact: true }).click()
+ await page.locator('li.tm-eintrag[data-id="bmanuell"]').waitFor()
+ eq(fx.aufrufe.at(-1)[1], { projectId: 'p1', von: '2026-10-01', bis: '2026-10-31' }, 'Abfrage Monat')
+ eq(await textVon(page.locator('.tm-zeitraum')), 'Oktober 2026', 'Titel Monat')
+ eq(await page.locator('.tm-tag').count(), 3, 'Monat zeigt nur Tage mit Terminen')
+ // Filter ⇒ Statusliste an der Schnittstelle
+ const filter = async (name, status, erwartet) => {
+ await page.getByRole('button', { name, exact: true }).click()
+ await page.waitForFunction((n) => document.querySelector('.tm-chip[aria-pressed="true"]')?.textContent === n, name)
+ await page.waitForTimeout(100)
+ eq(fx.aufrufe.at(-1)[1].status, status, `Filter ${name}`)
+ eq((await pillen(page)).filter((p) => p !== 'Gesperrt').every((p) => erwartet.includes(p)), true, `Pillen unter ${name}: ${(await pillen(page)).join(', ')}`)
+ }
+ await filter('Angefragt', 'angefragt', ['Angefragt'])
+ await filter('Bestätigt', 'bestaetigt', ['Bestätigt'])
+ await filter('Abgesagt', 'abgesagt,abgelehnt,verfallen', ['Abgesagt', 'Abgelehnt', 'Verfallen'])
+ await filter('Offen', 'angefragt,bestaetigt', ['Angefragt', 'Bestätigt'])
+ await page.getByRole('button', { name: 'Alle', exact: true }).click()
+ await page.waitForTimeout(100)
+ assert(fx.aufrufe.at(-1)[1].status === undefined, 'Alle: keine Statusliste')
+ // Navigation: Monat vor, Heute, Datumssprung
+ await page.getByRole('button', { name: 'Weiter', exact: true }).click()
+ await page.locator('.tm-leer').waitFor()
+ eq(await textVon(page.locator('.tm-zeitraum')), 'November 2026', 'naechster Monat')
+ eq(await textVon(page.locator('.tm-leer')), 'Keine Termine in diesem Monat.', 'Leermeldung Monat')
+ await page.getByRole('button', { name: 'Heute', exact: true }).click()
+ await page.locator('li.tm-eintrag').first().waitFor()
+ eq(await textVon(page.locator('.tm-zeitraum')), 'Oktober 2026', 'Heute')
+ await page.getByRole('button', { name: 'Tag', exact: true }).click()
+ await page.locator('#tm-sprung').fill('2026-10-06')
+ await page.locator('li.tm-eintrag[data-id="babgesagt"]').waitFor()
+ eq(await textVon(page.locator('.tm-zeitraum')), 'Dienstag, 6. Oktober 2026', 'Datumssprung')
+ await page.getByRole('button', { name: 'Zurück', exact: true }).click()
+ await page.locator('li.tm-eintrag[data-id="bkunft"]').waitFor()
+ eq(await textVon(page.locator('.tm-zeitraum')), 'Montag, 5. Oktober 2026', 'ein Tag zurueck')
+ // Ansicht und Filter bleiben beim Wechsel der Ansicht gesetzt
+ eq(await page.getByRole('button', { name: 'Alle', exact: true }).getAttribute('aria-pressed'), 'true', 'Filter Alle gedrueckt')
+ }, { fixture: Object.assign(neueFixture(), { buchungen: alleStatus() }) })
+
+ // ------------------------------------------------------------ Anfrage annehmen (Fixture)
+ await check('fx_anfrage_annehmen', async (page) => {
+ const fx = page.fx
+ await oeffne(page, '#termine')
+ await warteAnsicht(page)
+ // Banner „1 Anfrage wartet“ und Weg zu allen Anfragen
+ const banner = page.locator('.tm-box-info')
+ await banner.getByText('1 Anfrage wartet').waitFor()
+ await knopf(banner, 'Anfragen ansehen').click()
+ await page.waitForFunction(() => document.querySelectorAll('li.tm-eintrag').length === 1) // die Liste der Tagesansicht steht noch bis die neue da ist
+ eq(await textVon(page.locator('.tm-zeitraum')), 'Offene Anfragen', 'Titel Anfragen')
+ eq(fx.aufrufe.at(-1)[1], { projectId: 'p1', von: '2026-10-05', bis: '2026-12-06', status: 'angefragt' }, 'Abfrage: ab heute, 62 Tage, nur Anfragen')
+ eq(await page.locator('li.tm-eintrag').count(), 1, 'nur die Anfrage')
+ const zeile = reihe(page, 'Lea Braun')
+ // Details zeigen Kontakt und Nachricht als Text
+ await zeile.locator('summary').click()
+ const details = await textVon(zeile.locator('dl'))
+ assert(details.includes('max@example.com') && details.includes('Hochzeit am 24.10., bitte mit Probetermin.') && details.includes('90 Minuten'), `Details: ${details}`)
+ await shot(page, 'anfrage-offen')
+ await knopf(zeile, 'Annehmen').click()
+ await meldung(page).waitFor()
+ assert((await textVon(meldung(page))).startsWith('Anfrage angenommen.'), 'Meldung nach dem Annehmen')
+ eq(fx.aufrufe.filter((a) => a[0] === 'aktion'), [['aktion', 'banfrage', { projectId: 'p1', aktion: 'annehmen' }]], 'POST annehmen')
+ // Liste neu geladen: keine Anfrage mehr, Banner weg
+ await page.getByText('Es warten keine Anfragen.').waitFor()
+ eq(await page.locator('.tm-box-info:not([hidden])').count(), 0, 'Banner weg')
+ await knopf(page, 'Zurück zur Übersicht').click()
+ await page.locator('li.tm-eintrag[data-id="banfrage"][data-status="bestaetigt"]').waitFor()
+ await shot(page, 'anfrage-angenommen')
+ }, { fixture: Object.assign(neueFixture(), { buchungen: alleStatus() }) })
+
+ // ------------------------------------------------------------ Rueckfrage in der Seite, Fremdtext nur als Text
+ await check('fx_rueckfrage_und_text', async (page) => {
+ const fx = page.fx
+ fx.buchungen = [
+ buchung('bxss', '2026-10-05', '14:00', 45, { gastName: '
Max', gastNachricht: 'fett & ', person: 'Anna', leistung: 'Schnitt 1' }),
+ buchung('bziel', '2026-10-05', '16:00', 30, { gastName: 'Zoe Ziel' }),
+ ]
+ await oeffne(page, '#termine')
+ await warteAnsicht(page)
+ await page.locator('li.tm-eintrag[data-id="bxss"]').waitFor()
+ eq(await page.evaluate(() => window.__xss), undefined, 'kein Skript ausgefuehrt')
+ eq(await page.locator('#termine-body img, #termine-body b, #termine-body i, #termine-body u, #termine-body script').count(), 0, 'Fremdtext erzeugt keine Elemente')
+ assert((await textVon(page.locator('li.tm-eintrag[data-id="bxss"]'))).includes('
Max'), 'Text sichtbar als Text')
+ // Absagen: Rueckfrage in der Seite (kein Browserdialog), Grund ist Pflicht
+ const zeile = page.locator('li.tm-eintrag[data-id="bziel"]')
+ await knopf(zeile, 'Absagen: Damenschnitt, 05.10.2026 um 16:00 Uhr').click()
+ const frage = zeile.locator('.tm-frage-box')
+ await frage.waitFor()
+ assert((await textVon(frage)).includes('Termin absagen?'), 'Rueckfrage')
+ eq(await page.evaluate(() => document.activeElement.tagName), 'SELECT', 'Fokus im Grund-Feld')
+ await knopf(frage, 'Termin absagen').click()
+ assert((await textVon(frage)).includes('Bitte wähle einen Grund aus.'), 'Grund ist Pflicht')
+ eq(fx.aufrufe.filter((a) => a[0] === 'aktion').length, 0, 'noch nichts gesendet')
+ await frage.getByLabel('Grund').selectOption('Krankheit')
+ await shot(page, 'rueckfrage-absagen')
+ // Abbrechen: Rueckfrage weg, Fokus zurueck am Knopf
+ await knopf(frage, 'Abbrechen').click()
+ eq(await zeile.locator('.tm-frage-box').count(), 0, 'Rueckfrage zu')
+ eq(await page.evaluate(() => document.activeElement.dataset.aktion), 'absagen', 'Fokus zurueck am Knopf')
+ // Nur eine Rueckfrage zugleich
+ await knopf(zeile, 'Absagen: Damenschnitt, 05.10.2026 um 16:00 Uhr').click()
+ await knopf(page.locator('li.tm-eintrag[data-id="bxss"]'), 'Absagen: Schnitt 1, 05.10.2026 um 14:00 Uhr').click()
+ eq(await page.locator('.tm-frage-box').count(), 1, 'eine Rueckfrage')
+ // Absagen mit Grund
+ await page.locator('li.tm-eintrag[data-id="bxss"] .tm-frage-box').getByLabel('Grund').selectOption('Betriebliche Gründe')
+ await page.locator('li.tm-eintrag[data-id="bxss"] .tm-frage-box').getByLabel('Nachricht an den Gast (optional)').fill('Tut uns leid.')
+ await knopf(page.locator('li.tm-eintrag[data-id="bxss"] .tm-frage-box'), 'Termin absagen').click()
+ await meldung(page).waitFor()
+ eq(fx.aufrufe.filter((a) => a[0] === 'aktion'), [['aktion', 'bxss', { projectId: 'p1', aktion: 'absagen', grund: 'Betriebliche Gründe', nachricht: 'Tut uns leid.' }]], 'POST absagen')
+ }, { fixture: neueFixture() })
+
+ // ------------------------------------------------------------ App-Ansicht 390 px
+ await check('fx_app_390', async (page) => {
+ await oeffne(page, '#funktionen')
+ const kachel = page.locator('[data-fn-tile="termine"]')
+ await kachel.waitFor()
+ await kachel.getByText('1 Anfrage wartet').waitFor()
+ eq(await passtInBreite(page), true, 'Funktionen: kein Streifen rechts')
+ await shot(page, '390-1-funktionen')
+ await knopf(kachel, 'Termine öffnen').click()
+ await warteAnsicht(page)
+ await page.locator('li.tm-eintrag').first().waitFor()
+ // Unterseite von Funktionen: Titel „Terminplaner“, Zurueck-Pfeil „Funktionen“, Tab Funktionen markiert
+ eq(await page.evaluate(() => window.location.hash), '#termine', 'Adresse #termine')
+ eq(await textVon(page.locator('#view-termine .kb-head h1')), 'Terminplaner', 'Ueberschrift')
+ eq(await textVon(page.locator('#kb-app-title')), 'Terminplaner', 'Titel der App-Leiste')
+ eq(await page.locator('#kb-back').isVisible(), true, 'Zurueck-Pfeil sichtbar')
+ eq(await textVon(page.locator('#kb-back span')), 'Funktionen', 'Zurueck zu Funktionen')
+ eq(await page.locator('.kb-nav-item.active').getAttribute('data-view'), 'funktionen', 'Tab Funktionen markiert')
+ eq(await page.evaluate(() => document.body.dataset.kbView), 'termine', 'Ansicht termine')
+ eq(await passtInBreite(page), true, 'Termine: kein Streifen rechts')
+ // Reiter als 2×2-Raster, grosse Touch-Ziele
+ const kleine = await page.evaluate(() => [...document.querySelectorAll('#view-termine button, #view-termine summary')]
+ .filter((e) => e.offsetParent !== null && !e.classList.contains('tm-link-knopf') && e.getBoundingClientRect().height < 43.5)
+ .map((e) => `${e.textContent.trim().slice(0, 24)} ${Math.round(e.getBoundingClientRect().height)}`))
+ eq(kleine, [], 'Knoepfe mindestens 44 px hoch')
+ const tabs = await page.locator('.tm-reiter').evaluateAll((l) => l.map((e) => Math.round(e.getBoundingClientRect().top)))
+ eq(tabs[0] === tabs[1] && tabs[2] === tabs[3] && tabs[2] > tabs[0], true, 'Reiter in zwei Zeilen')
+ await shot(page, '390-2-termine')
+ // Woche, Anfrage mit Rueckfrage
+ await page.getByRole('button', { name: 'Woche', exact: true }).click()
+ await page.locator('li.tm-eintrag[data-id="babgesagt"]').waitFor()
+ eq(await passtInBreite(page), true, 'Woche: kein Streifen rechts')
+ await shot(page, '390-3-woche')
+ const zeile = page.locator('li.tm-eintrag[data-id="banfrage"]')
+ await knopf(zeile, 'Ablehnen: Brautfrisur, 05.10.2026 um 15:00 Uhr').click()
+ await zeile.locator('.tm-frage-box').waitFor()
+ eq(await passtInBreite(page), true, 'Rueckfrage: kein Streifen rechts')
+ await zeile.scrollIntoViewIfNeeded()
+ await shot(page, '390-4-ablehnen')
+ // Zurueck-Pfeil fuehrt zu Funktionen
+ await page.locator('#kb-back').click()
+ await page.locator('#view-funktionen.active').waitFor()
+ eq(await page.evaluate(() => document.body.dataset.kbView), 'funktionen', 'zurueck in Funktionen')
+ }, { fixture: Object.assign(neueFixture(), { buchungen: alleStatus() }), context: { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true } })
+
+ // ============================================================ Echter Server
+ await check('echt_kachel_vorlage_und_liste', async (page) => {
+ // aktiv, aber noch ohne Einstellung
+ await oeffne(page, '#funktionen')
+ const kachel = page.locator('[data-fn-tile="termine"]')
+ await kachel.waitFor()
+ assert((await textVon(kachel)).includes('Aktiv') && (await textVon(kachel)).includes('Termine öffnen'), 'Kachel aktiv')
+ await knopf(kachel, 'Termine öffnen').click()
+ await warteAnsicht(page)
+ // neun Vorlagen des Servers
+ eq(await page.locator('input[name="termine-vorlage"]').count(), 9, 'neun Vorlagen')
+ await shot(page, 'echt-vorlage')
+ await page.locator('input[name="termine-vorlage"][value="salon"]').check()
+ await knopf(page, 'Mit dieser Vorlage starten').click()
+ await page.locator('.tm-reiterleiste').waitFor()
+ const e = srv.einstellung()
+ eq(e.vorlage, 'salon', 'Einstellung aus der Vorlage gespeichert')
+ assert(e.betrieb.name.includes('Silke') || e.betrieb.name !== '', 'Betrieb aus dem Kundenkonto')
+ const hinweis = await textVon(page.locator('.tm-box-warn'))
+ assert(hinweis.includes('Noch nicht buchbar'), `Hinweis fehlende Angaben: ${hinweis}`)
+ await shot(page, 'echt-nach-vorlage')
+ // zweites Oeffnen: keine Auswahl mehr
+ await page.reload()
+ await page.locator('.tm-reiterleiste').waitFor()
+ eq(await page.locator('.tm-vorlagen').count(), 0, 'Vorlage nur beim ersten Oeffnen')
+ }, { reset: { ohneEinstellung: true } })
+
+ await check('echt_anfragen_annehmen_ablehnen_absagen_verschieben', async (page) => {
+ // Gaeste buchen ueber die oeffentliche Schnittstelle (echte Daten, echte Mails)
+ const bestaetigt = await buche({ name: 'Max Mustermann', email: 'max@example.com', tag: '2026-10-12', zeit: '10:00' })
+ const zweiter = await buche({ name: 'Maria Muster', email: 'maria@example.com', tag: '2026-10-12', zeit: '11:00', leistung: S_HERREN, ressource: R_BEN })
+ const anfrage1 = await buche({ name: 'Lea Braun', email: 'lea@example.com', tag: '2026-10-13', zeit: '14:00', leistung: S_ANFRAGE })
+ const anfrage2 = await buche({ name: 'Uta Neu', email: 'uta@example.com', tag: '2026-10-14', zeit: '09:00', leistung: S_ANFRAGE })
+ eq([anfrage1.status, anfrage2.status, bestaetigt.status], ['angefragt', 'angefragt', 'bestaetigt'], 'Status nach dem Bestaetigen')
+ // eine Zeile mit gescheiterter Mail
+ srv.aw.seed('termineOutbox', { $id: 'o_fehler01', featureId: srv.FEATURE, bookingId: bestaetigt.$id, art: 'G02', an: 'max@example.com', status: 'fehler', versuche: 3, naechsterVersuch: JETZT.toISOString(), createdAt: JETZT.toISOString() })
+
+ await oeffne(page, '#termine')
+ await warteAnsicht(page)
+ // Banner mit der Zahl; alle Anfragen ab heute
+ await page.locator('.tm-box-info').getByText('2 Anfragen warten').waitFor()
+ await knopf(page, 'Anfragen ansehen').click()
+ await page.locator('li.tm-eintrag[data-status="angefragt"]').first().waitFor()
+ eq(await page.locator('li.tm-eintrag').count(), 2, 'zwei Anfragen')
+ eq(await page.locator('li.tm-eintrag').evaluateAll((l) => l.map((e) => e.dataset.id)), [anfrage1.$id, anfrage2.$id], 'Anfragen in Zeitfolge')
+ await shot(page, 'echt-anfragen')
+
+ // annehmen: G02 an die Anfragende
+ const mailsVorher = mailsAn('lea@example.com').length
+ await knopf(reihe(page, 'Lea Braun'), 'Annehmen: Brautfrisur, 13.10.2026 um 14:00 Uhr').click()
+ await meldung(page).waitFor()
+ assert((await textVon(meldung(page))).includes('Lea Braun bekommt eine Bestätigung per E-Mail.'), 'Meldung annehmen')
+ await srv.warteAufVersand()
+ eq(buchungMit(anfrage1.$id).status, 'bestaetigt', 'Server: angenommen')
+ assert(buchungMit(anfrage1.$id).angenommenAt, 'angenommenAt gesetzt')
+ eq(mailsAn('lea@example.com').length, mailsVorher + 1, 'G02 an den Gast')
+
+ // ablehnen mit Nachricht: G04
+ const zeile2 = reihe(page, 'Uta Neu')
+ await knopf(zeile2, 'Ablehnen: Brautfrisur, 14.10.2026 um 09:00 Uhr').click()
+ await zeile2.getByLabel('Nachricht an den Gast (optional)').fill('Leider ausgebucht.')
+ await knopf(zeile2, 'Anfrage ablehnen').click()
+ await page.getByText('Es warten keine Anfragen.').waitFor()
+ await srv.warteAufVersand()
+ eq(buchungMit(anfrage2.$id).status, 'abgelehnt', 'Server: abgelehnt')
+ const g04 = mailsAn('uta@example.com').at(-1)
+ assert(`${g04.text}${g04.html}`.includes('Leider ausgebucht.'), 'G04 mit Nachricht des Betriebs')
+ eq(await page.locator('.tm-box-info:not([hidden])').count(), 0, 'Banner weg')
+
+ // zurueck zur Uebersicht, Woche des 12. Oktober
+ await knopf(page, 'Zurück zur Übersicht').click()
+ await page.getByRole('button', { name: 'Woche', exact: true }).click()
+ await page.locator('#tm-sprung').fill('2026-10-12')
+ await page.locator(`li.tm-eintrag[data-id="${bestaetigt.$id}"]`).waitFor()
+ eq(await textVon(page.locator('.tm-zeitraum')), '12.–18. Oktober 2026', 'Woche ab 12.10.')
+ const t1 = await textVon(page.locator(`li.tm-eintrag[data-id="${bestaetigt.$id}"]`))
+ assert(t1.includes('10:00–10:45') && t1.includes('Damenschnitt') && t1.includes('Anna · Max Mustermann') && t1.includes('Bestätigt') && t1.includes('Bestätigung nicht zugestellt'), `Zeile Server: ${t1}`)
+ await shot(page, 'echt-woche')
+
+ // absagen mit Pflichtgrund: G06, Status, Grund in der Liste
+ const zeile3 = page.locator(`li.tm-eintrag[data-id="${zweiter.$id}"]`)
+ await knopf(zeile3, 'Absagen: Herrenschnitt, 12.10.2026 um 11:00 Uhr').click()
+ await zeile3.getByLabel('Grund').selectOption('Krankheit')
+ await knopf(zeile3, 'Termin absagen').click()
+ await meldung(page).waitFor()
+ await srv.warteAufVersand()
+ const abgesagt = buchungMit(zweiter.$id)
+ eq([abgesagt.status, abgesagt.abgesagtVon, abgesagt.absageGrund], ['abgesagt', 'betrieb', 'Krankheit'], 'Server: abgesagt')
+ const abTxt = await textVon(page.locator(`li.tm-eintrag[data-id="${zweiter.$id}"]`))
+ assert(abTxt.includes('Abgesagt') && abTxt.includes('Von dir abgesagt') && abTxt.includes('Grund: Krankheit'), `Zeile abgesagt: ${JSON.stringify(abTxt)}`)
+ assert(mailsAn('maria@example.com').some((m) => /Krankheit/.test(`${m.text}${m.html}`)), 'G06 nennt den Grund')
+ eq((await page.locator(`li.tm-eintrag[data-id="${zweiter.$id}"] [data-aktion]`).allTextContents()), ['Gastdaten löschen'], 'nach der Absage nur noch Gastdaten loeschen')
+
+ // verschieben: neuer Tag, freie Zeit der Schnittstelle
+ const zeile4 = page.locator(`li.tm-eintrag[data-id="${bestaetigt.$id}"]`)
+ await knopf(zeile4, 'Verschieben: Damenschnitt, 12.10.2026 um 10:00 Uhr').click()
+ const frage = zeile4.locator('.tm-frage-box')
+ await frage.getByLabel('Neuer Tag').fill('2026-10-14')
+ await frage.locator('.tm-zeit-knopf').first().waitFor()
+ const zeiten = await frage.locator('.tm-zeit-knopf').allInnerTexts()
+ assert(zeiten.includes('09:00') && zeiten.includes('16:45') && !zeiten.includes('12:30'), `freie Zeiten: ${zeiten.join(' ')}`)
+ await shot(page, 'echt-verschieben')
+ await knopf(frage, 'Verschieben').click() // ohne Zeit: Hinweis
+ assert((await textVon(frage)).includes('Bitte wähle eine Uhrzeit aus.'), 'Zeit ist Pflicht')
+ await frage.locator('.tm-zeit-knopf', { hasText: '15:00' }).click()
+ eq(await frage.locator('.tm-zeit-knopf[aria-pressed="true"]').allInnerTexts(), ['15:00'], 'eine Zeit gewaehlt')
+ await knopf(frage, 'Verschieben').click()
+ await meldung(page).waitFor()
+ assert((await textVon(meldung(page))).includes('Termin verschoben auf 14.10.2026, 15:00 Uhr.'), 'Meldung verschieben')
+ await srv.warteAufVersand()
+ assert(gleichZeit(buchungMit(bestaetigt.$id).startsAt, utc('2026-10-14', '15:00')), `Server: neue Zeit ${buchungMit(bestaetigt.$id).startsAt}`)
+ assert(mailsAn('max@example.com').length >= 3, 'G07 an den Gast')
+ // der verschobene Termin steht in der Woche
+ await page.locator(`li.tm-eintrag[data-id="${bestaetigt.$id}"]`).waitFor()
+ assert((await textVon(page.locator(`li.tm-eintrag[data-id="${bestaetigt.$id}"]`))).includes('15:00–15:45'), 'verschobene Zeit in der Liste')
+ })
+
+ await check('echt_termin_eintragen_sperre_nichterschienen', async (page) => {
+ // begonnener Termin (09:00, jetzt ist 10:00) und eine Anfrage direkt im Nachbau
+ const basis = (id, o) => ({
+ $id: id, featureId: srv.FEATURE, customerId: srv.KUNDE, projectId: srv.PROJEKT, leistungId: S_HERREN, leistungName: 'Herrenschnitt', ressourceId: R_BEN, ressourceName: 'Ben',
+ startsAt: utc('2026-10-05', '09:00'), endsAt: utc('2026-10-05', '09:30'), blockEndsAt: utc('2026-10-05', '09:35'), status: 'bestaetigt', quelle: 'online',
+ gastName: 'Karl Ohr', gastEmail: 'karl@example.com', gastTelefon: '', gastNachricht: '', gastZusatz: '', erinnerung: false, createdAt: JETZT.toISOString(), updatedAt: JETZT.toISOString(),
+ preisCent: 2800, preisArt: 'fest', buchungsart: 'sofort', verbraucher: true, zahlart: 'vorort', zahlStatus: 'keine', icsSequenz: 0, ...o,
+ })
+ srv.aw.seed('termineBookings', basis('b_begonnen', {}))
+ await oeffne(page, '#termine')
+ await warteAnsicht(page)
+ const begonnen = page.locator('li.tm-eintrag[data-id="b_begonnen"]')
+ await begonnen.waitFor()
+ eq(await begonnen.locator('[data-aktion]').evaluateAll((l) => l.map((e) => e.dataset.aktion)), ['nichterschienen', 'anonymisieren'], 'begonnener Termin')
+ await knopf(begonnen, 'Nicht erschienen: Herrenschnitt, 05.10.2026 um 09:00 Uhr').click()
+ await knopf(begonnen.locator('.tm-frage-box'), 'Ja, nicht erschienen').click()
+ await meldung(page).waitFor()
+ await page.locator('li.tm-eintrag[data-id="b_begonnen"][data-status="nichterschienen"]').waitFor()
+ eq(buchungMit('b_begonnen').status, 'nichterschienen', 'Server: nicht erschienen')
+ // Gastdaten loeschen
+ await begonnen.locator('summary').click()
+ await knopf(begonnen, 'Gastdaten löschen').click()
+ assert((await textVon(begonnen.locator('.tm-frage-box'))).includes('lässt sich nicht rückgängig machen'), 'Warnung')
+ await knopf(begonnen.locator('.tm-frage-box'), 'Gastdaten löschen').click()
+ await page.locator('li.tm-eintrag[data-id="b_begonnen"] .tm-meta', { hasText: 'Gelöscht' }).waitFor()
+ eq([buchungMit('b_begonnen').gastName, buchungMit('b_begonnen').gastEmail], ['Gelöscht', ''], 'Server: Gastdaten geloescht')
+
+ // + Termin eintragen: Pflichtfelder, Feldfehler am Feld, dann Erfolg
+ await knopf(page, '+ Termin eintragen').click()
+ const form = page.getByRole('form', { name: 'Termin eintragen' })
+ await form.waitFor()
+ await shot(page, 'echt-termin-eintragen')
+ await form.getByLabel('Leistung', { exact: true }).selectOption(S_HERREN)
+ await form.getByLabel('Tag', { exact: true }).fill('2026-10-06')
+ await form.locator('.tm-zeit-knopf').first().waitFor()
+ await form.locator('.tm-zeit-knopf', { hasText: '09:30' }).click()
+ await knopf(form, 'Termin eintragen').click() // ohne Namen
+ await form.locator('[data-fehler="name"]:not(:empty)').waitFor()
+ eq(await textVon(form.locator('[data-fehler="name"]')), 'Bitte einen Namen angeben.', 'Feldfehler am Namen')
+ eq(await form.getByLabel('Name des Gastes').getAttribute('aria-invalid'), 'true', 'Namensfeld markiert')
+ eq(srv.buchungen().filter((b) => b.quelle === 'manuell').length, 0, 'nichts gespeichert')
+ await form.getByLabel('Name des Gastes').fill('Walter Laufkunde')
+ eq(await form.getByLabel('Bestätigung an den Gast senden').isDisabled(), true, 'Haken nur mit E-Mail')
+ await form.getByLabel('Telefon (optional)').fill('06301 999')
+ await knopf(form, 'Termin eintragen').click()
+ await meldung(page).waitFor()
+ eq(await textVon(meldung(page)), 'Termin eingetragen.', 'Meldung eintragen')
+ const manuell = srv.buchungen().find((b) => b.quelle === 'manuell')
+ eq([manuell.gastName, manuell.status, manuell.leistungName], ['Walter Laufkunde', 'bestaetigt', 'Herrenschnitt'], 'Server: Handeintrag')
+ assert(gleichZeit(manuell.startsAt, utc('2026-10-06', '09:30')), `Server: Zeit des Handeintrags ${manuell.startsAt}`)
+ // Ansicht springt auf den Tag des neuen Termins
+ await page.locator(`li.tm-eintrag[data-id="${manuell.$id}"]`).waitFor()
+ eq(await textVon(page.locator('.tm-zeitraum')), 'Dienstag, 6. Oktober 2026', 'Tag des Eintrags')
+ eq(await page.locator('.tm-formular').count(), 0, 'Formular zu')
+ assert((await textVon(page.locator(`li.tm-eintrag[data-id="${manuell.$id}"]`))).includes('von dir eingetragen'), 'Handeintrag gekennzeichnet')
+
+ // Zeit sperren: Hinweis 500 Minuten, zu lang ⇒ Fehler am Feld, dann Erfolg
+ await knopf(page, 'Zeit sperren').click()
+ const sperre = page.getByRole('form', { name: 'Zeit sperren' })
+ await sperre.waitFor()
+ assert((await textVon(sperre)).includes('höchstens 8 Stunden 20 Minuten (500 Minuten)'), 'Hinweis 500 Minuten')
+ await sperre.getByLabel('Person', { exact: true }).selectOption(R_BEN)
+ await sperre.getByLabel('Tag', { exact: true }).fill('2026-10-06')
+ await sperre.getByLabel('Von (Uhrzeit)').fill('8:00')
+ await sperre.getByLabel('Bis (Uhrzeit)').fill('18:00')
+ await knopf(sperre, 'Zeit sperren').click()
+ await sperre.locator('[data-fehler="bis"]:not(:empty)').waitFor()
+ assert((await textVon(sperre.locator('[data-fehler="bis"]'))).includes('höchstens 8 Stunden 20 Minuten'), 'Fehler vom Server am Feld Bis')
+ await sperre.getByLabel('Von (Uhrzeit)').fill('abc')
+ await knopf(sperre, 'Zeit sperren').click()
+ await sperre.locator('[data-fehler="von"]:not(:empty)').waitFor()
+ await sperre.getByLabel('Von (Uhrzeit)').fill('12:00')
+ await sperre.getByLabel('Bis (Uhrzeit)').fill('13:00')
+ await sperre.getByLabel('Titel (optional)').fill('Mittagspause')
+ await knopf(sperre, 'Zeit sperren').click()
+ await meldung(page).waitFor()
+ eq(await textVon(meldung(page)), 'Zeit gesperrt.', 'Meldung sperren')
+ const sperreDoc = srv.buchungen().find((b) => b.quelle === 'sperre')
+ assert(sperreDoc.leistungName === 'Mittagspause' && gleichZeit(sperreDoc.startsAt, utc('2026-10-06', '12:00')) && gleichZeit(sperreDoc.endsAt, utc('2026-10-06', '13:00')), 'Server: Sperre')
+ const sperreZeile = page.locator(`li.tm-eintrag[data-id="${sperreDoc.$id}"]`)
+ await sperreZeile.waitFor()
+ assert((await textVon(sperreZeile)).includes('Gesperrt') && (await textVon(sperreZeile)).includes('Mittagspause'), 'Sperre in der Liste')
+ // ueberschneidende Sperre ⇒ belegt
+ await knopf(page, 'Zeit sperren').click()
+ const zweite = page.getByRole('form', { name: 'Zeit sperren' })
+ await zweite.getByLabel('Person', { exact: true }).selectOption(R_BEN)
+ await zweite.getByLabel('Tag', { exact: true }).fill('2026-10-06')
+ await zweite.getByLabel('Von (Uhrzeit)').fill('12:30')
+ await zweite.getByLabel('Bis (Uhrzeit)').fill('14:00')
+ await knopf(zweite, 'Zeit sperren').click()
+ await zweite.locator('[data-fehler="von"]:not(:empty)').waitFor()
+ assert((await textVon(zweite.locator('[data-fehler="von"]'))).includes('schon einen Termin oder eine Sperre'), 'belegte Zeit am Feld Von')
+ await knopf(zweite, 'Abbrechen').click()
+ // Sperre aufheben (Rueckfrage)
+ await knopf(sperreZeile, 'Sperre aufheben: Sperre, 06.10.2026 um 12:00 Uhr').click()
+ await knopf(sperreZeile.locator('.tm-frage-box'), 'Sperre aufheben').click()
+ await page.locator(`li.tm-eintrag[data-id="${sperreDoc.$id}"]`).waitFor({ state: 'detached' })
+ eq(srv.buchungen().filter((b) => b.quelle === 'sperre').length, 0, 'Server: Sperre geloescht')
+ await shot(page, 'echt-nach-sperre')
+ })
+
+ await check('echt_abgeschaltet_nur_lesen', async (page) => {
+ srv.aw.seed('termineBookings', {
+ $id: 'b_alt', featureId: srv.FEATURE, customerId: srv.KUNDE, projectId: srv.PROJEKT, leistungId: S_HERREN, leistungName: 'Herrenschnitt', ressourceId: R_BEN, ressourceName: 'Ben',
+ startsAt: utc('2026-10-06', '09:00'), endsAt: utc('2026-10-06', '09:30'), blockEndsAt: utc('2026-10-06', '09:35'), status: 'angefragt', quelle: 'online', gastName: 'Karl Ohr',
+ gastEmail: 'karl@example.com', gastTelefon: '', gastNachricht: '', gastZusatz: '', erinnerung: false, createdAt: JETZT.toISOString(), updatedAt: JETZT.toISOString(),
+ preisCent: 0, preisArt: 'keiner', buchungsart: 'anfrage', verbraucher: true, zahlart: 'vorort', zahlStatus: 'keine', icsSequenz: 0,
+ })
+ await srv.setzeFunktion('disabled')
+ await oeffne(page, '#funktionen')
+ const kachel = page.locator('[data-fn-tile="termine"]')
+ await kachel.waitFor()
+ assert((await textVon(kachel)).includes('Abgeschaltet'), 'Kachel abgeschaltet')
+ await knopf(kachel, 'Termine ansehen').click()
+ await warteAnsicht(page)
+ const hinweis = await textVon(page.locator('.tm-box-warn'))
+ assert(hinweis.includes('abgeschaltet') && hinweis.includes('nur noch ansehen'), `Banner: ${hinweis}`)
+ eq(await page.locator('.tm-box-warn a').getAttribute('href'), '/api/termine/export.csv?projectId=p1', 'Export-Link')
+ eq(await page.locator('[data-werkzeug]').count(), 0, 'keine Schreib-Knoepfe oben')
+ await page.getByRole('button', { name: 'Tag', exact: true }).click()
+ await page.getByRole('button', { name: 'Weiter', exact: true }).click()
+ await page.locator('li.tm-eintrag[data-id="b_alt"]').waitFor()
+ eq(await page.locator('li.tm-eintrag [data-aktion]').count(), 0, 'keine Aktionen an den Zeilen')
+ await shot(page, 'echt-abgeschaltet')
+ // die Schnittstelle verweigert Schreiben auch direkt
+ const r = await page.evaluate(async () => (await fetch('/api/termine/buchungen/b_alt/aktion', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ projectId: 'p1', aktion: 'annehmen' }) })).status)
+ eq(r, 409, 'Server: Schreiben bei abgeschalteter Funktion')
+ })
+
+ await check('echt_390_liste_und_formular', async (page) => {
+ srv.aw.seed('termineBookings', {
+ $id: 'b_390', featureId: srv.FEATURE, customerId: srv.KUNDE, projectId: srv.PROJEKT, leistungId: S_DAMEN, leistungName: 'Damenschnitt', ressourceId: R_ANNA, ressourceName: 'Anna',
+ startsAt: utc('2026-10-05', '14:00'), endsAt: utc('2026-10-05', '14:45'), blockEndsAt: utc('2026-10-05', '14:50'), status: 'angefragt', quelle: 'online', gastName: 'Katharina von und zu Hohenlohe-Langenburg',
+ gastEmail: 'katharina@example.com', gastTelefon: '+49 6301 123456', gastNachricht: 'Bitte nach Möglichkeit mit der Stylistin vom letzten Mal, Frau Schneider.', gastZusatz: '', erinnerung: true,
+ createdAt: JETZT.toISOString(), updatedAt: JETZT.toISOString(), preisCent: 0, preisArt: 'keiner', buchungsart: 'anfrage', verbraucher: true, zahlart: 'vorort', zahlStatus: 'keine', icsSequenz: 0,
+ })
+ await oeffne(page, '#funktionen')
+ await knopf(page.locator('[data-fn-tile="termine"]'), 'Termine öffnen').click()
+ await warteAnsicht(page)
+ await page.locator('li.tm-eintrag').waitFor()
+ eq(await passtInBreite(page), true, 'echt: Liste ohne Streifen')
+ await shot(page, '390-5-echt-liste')
+ await knopf(page, '+ Termin eintragen').click()
+ const form = page.getByRole('form', { name: 'Termin eintragen' })
+ await form.locator('.tm-zeit-knopf').first().waitFor()
+ eq(await passtInBreite(page), true, 'echt: Formular ohne Streifen')
+ await shot(page, '390-6-echt-termin-eintragen')
+ await knopf(form, 'Abbrechen').click()
+ await knopf(page, 'Zeit sperren').click()
+ await page.getByRole('form', { name: 'Zeit sperren' }).waitFor()
+ eq(await passtInBreite(page), true, 'echt: Sperre ohne Streifen')
+ await shot(page, '390-7-echt-zeit-sperren')
+ }, { context: { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true } })
+}
+
+// ---------------------------------------------------------------- Lauf
+
+let abbruch = null
+try {
+ browser = await chromium.launch()
+ await pruefungen()
+ const fremd = [...hosts].filter((h) => h !== new URL(srv.url).host)
+ results.push(['nur_eigene_herkunft', fremd.length === 0])
+ console.log(fremd.length ? `FAIL nur_eigene_herkunft: Anfragen an fremde Hosts: ${fremd.join(', ')}` : 'ok nur_eigene_herkunft')
+} catch (e) {
+ abbruch = e
+ console.log(`ABBRUCH: ${e.stack || e.message}`)
+} finally {
+ if (browser) await browser.close().catch(() => {})
+ await srv.stop().catch((e) => console.log(`Server beenden: ${e.message}`))
+}
+
+const fehlgeschlagen = results.filter(([, ok]) => !ok)
+console.log(`${results.length - fehlgeschlagen.length}/${results.length} Checks bestanden`)
+process.exit(abbruch || fehlgeschlagen.length ? 1 : 0)
diff --git a/test/ui/termineTestServer.mjs b/test/ui/termineTestServer.mjs
index 915e1a6..02ad472 100644
--- a/test/ui/termineTestServer.mjs
+++ b/test/ui/termineTestServer.mjs
@@ -17,6 +17,10 @@
// const srv = await startTermineUiServer()
// const { sessionCookie } = await import('../support/testApp.js')
//
+// Portal (Aufgaben 21a/21b): srv.portalCookie() liefert { name, value, url } fuer addCookies; srv.zuruecksetzen({ ohneEinstellung: true })
+// startet ohne Einstellung (Vorlagenauswahl); der Server stellt auch dashboard.html und /api/funktionen bereit, die uebrigen
+// Portal-Schnittstellen (/api/auth/me usw.) mockt das Skript per page.route.
+//
// Seitenherkunft: srv.url ist http://127.0.0.1:PORT, srv.pageOrigin ist http://localhost:PORT (derselbe Server, anderer Host).
// Mit { crossOrigin: true } gilt pageOrigin als Herkunft des Portals (config.publicBaseUrl): Seiten von pageOrigin mit
// data-api=srv.url sind dann echte Cross-Origin-Anfragen (Preflight fuer POST), die der Server erlaubt.
@@ -221,8 +225,19 @@ export async function startTermineUiServer({ jetzt = JETZT, crossOrigin = false,
resetKontextForTests()
},
- /** Alles auf Anfang: Dokumente loeschen, Zaehler und Zwischenspeicher leeren, neue Senke, Einstellung und Funktion neu anlegen. */
- async zuruecksetzen({ eingabe, uhrNeu = true } = {}) {
+ /** Cookie des Portal-Kunden (c1) fuer page.context().addCookies([...]): { name, value, url }. Das Portal (dashboard.html) liegt unter srv.url. */
+ async portalCookie({ email = 'silke@salon-mueller.example', name = 'Silke Müller' } = {}) {
+ const { sessionCookie } = await import('../support/testApp.js')
+ const roh = sessionCookie({ customerId: KUNDE, appwriteUserId: `u_${KUNDE}`, name, email })
+ const i = roh.indexOf('=')
+ return { name: roh.slice(0, i), value: roh.slice(i + 1), url: app.url }
+ },
+
+ /**
+ * Alles auf Anfang: Dokumente loeschen, Zaehler und Zwischenspeicher leeren, neue Senke, Einstellung und Funktion neu anlegen.
+ * ohneEinstellung: die Funktion ist aktiv, aber es gibt noch keine Einstellung (erstes Oeffnen im Portal: Vorlage waehlen).
+ */
+ async zuruecksetzen({ eingabe, uhrNeu = true, ohneEinstellung = false } = {}) {
await warteAufVersand()
for (const collection of ['portalFeatures', 'termineConfig', 'termineBookings', 'termineClaims', 'termineOutbox', 'customers', 'websiteProjects']) {
for (const id of [...aw.docs(collection).keys()]) await deleteDocument(collection, id)
@@ -246,7 +261,7 @@ export async function startTermineUiServer({ jetzt = JETZT, crossOrigin = false,
aw.seed('websiteProjects', { $id: PROJEKT, customerId: KUNDE, projectName: 'Salon Müller', subdomain: 'salon', liveDomain: 'salon-mueller.example' })
aw.seed('customers', { $id: KUNDE, name: 'Silke Müller', email: 'silke@salon-mueller.example' })
aw.seed('portalFeatures', srv.featureDoc())
- await srv.speichere(anpassen(eingabe ?? salonEingabe()))
+ if (!ohneEinstellung) await srv.speichere(anpassen(eingabe ?? salonEingabe()))
},
/** Beendet Server und Nachbauten (zuerst den Browser schliessen) und stellt config wieder her. */