Terminplaner: Portal-Ansicht, Kachel, Termine

Kachel in Funktionen (Kommt bald / Aus / Aktiv mit Anfragen-Zahl / Abgeschaltet),
Ansicht #termine als Unterseite von Funktionen: Projektauswahl, Vorlagenauswahl
beim ersten Oeffnen, Reiter-Geruest (Platzhalter fuer Leistungen, Team & Plaetze,
Einstellungen) und der Reiter Termine mit Tag/Woche/Monat, Filtern, Aktionen mit
Rueckfragen in der Seite, Termin eintragen und Zeit sperren.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-03 08:17:44 +00:00
1 parent 70940e02c8
commit 43e4826ae3
10 files changed
+2552 -8

No files matched your search

+4 -3
View File
@@ -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])'
+10
View File
@@ -22,6 +22,7 @@
<link rel="stylesheet" href="/kundenbereich.css">
<link rel="stylesheet" href="/chat-composer.css">
<link rel="stylesheet" href="/funktionen.css">
<link rel="stylesheet" href="/termine.css">
</head>
<body class="portal-page dashboard-page kb-page">
<div class="dot-background" aria-hidden="true">
@@ -185,6 +186,15 @@
<div id="analyse-body"><p class="kb-empty">Lade…</p></div>
</section>
<!-- Terminplaner: Vorlage, Termine, Leistungen, Team & Plätze, Einstellungen (termine.js, initTermine); Unterseite von Funktionen -->
<section class="kb-view" id="view-termine">
<header class="kb-head">
<h1>Terminplaner</h1>
<p>Termine, die deine Kundschaft über deine Website bucht – und was du selbst einträgst.</p>
</header>
<div id="termine-body"><p class="kb-empty">Lade…</p></div>
</section>
<!-- Rechnungen -->
<section class="kb-view" id="view-invoices">
<header class="kb-head">
+60 -2
View File
@@ -136,6 +136,58 @@ function analyseTile(projects) {
</article>`
}
/* 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 = '<p class="fn-count" data-termine-count hidden></p>'
action = `<button type="button" class="kb-btn-primary fn-tile-cta" data-view="termine" data-project="${esc(target.projectId)}">Termine öffnen</button>`
} else if (disabled) {
extra = '<p>Der Terminplaner ist für diese Website abgeschaltet. Deine Termine kannst du noch ansehen und exportieren.</p>'
action = `<button type="button" class="kb-btn-primary fn-tile-cta" data-view="termine" data-project="${esc(target.projectId)}">Termine ansehen</button>`
} else {
extra = '<p>Wir schalten den Terminplaner für dich frei.</p>'
action = '<button type="button" class="kb-btn-primary fn-tile-cta" data-view="chat">Chat öffnen</button>'
}
const count = projects.length > 1 && active ? `<p class="fn-count">aktiv für ${active} von ${projects.length} Websites</p>` : ''
return `
<article class="fn-tile" data-fn-tile="termine">
<div class="fn-tile-head">${svg('calendar')}<span class="fn-pill fn-pill-${pill[1]}">${pill[0]}</span></div>
<h3>Terminplaner</h3>
<p>${TERMINE_TEXT}</p>
${extra}
${count}
${action}
</article>`
}
// "{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) => `
<article class="fn-tile fn-tile-soon">
<div class="fn-tile-head">${svg(icon)}<span class="fn-pill fn-pill-soon">Kommt bald</span></div>
@@ -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 = `
<div class="fn-tiles">
${newsTile(projects)}
${soonTile('calendar', 'Terminplaner', 'Deine Kundschaft bucht Termine direkt über deine Website.')}
${termine?.verfuegbar ? termineTile(projects) : soonTile('calendar', 'Terminplaner', TERMINE_TEXT)}
${analyseTile(projects)}
</div>`
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 --- */
+247
View File
@@ -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
}
+154
View File
@@ -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; } }
+832
View File
@@ -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
}
+97
View File
@@ -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<b>', 'active'), projectName: '<img src=x onerror=1>' }], { verfuegbar: true })
assert.doesNotMatch(html, /<img/)
assert.match(tile(html, 'termine'), /data-project="a&lt;b&gt;"/)
})
// ---- Analyse-Einschalten und Ausschalten (ohne DOM: Attrappen, die innerHTML und Klick-Handler festhalten)
function fakeEl() {
const handlers = {}
+242
View File
@@ -0,0 +1,242 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import {
FILTER, ABSAGE_GRUENDE, anfragenText, bereichFuer, datumKurz, datumLang, endeZeit, fensterParsen, fensterText, filterStatus, gruppiereNachTag,
fehltText, heuteBerlin, istSperre, monatVon, moeglicheAktionen, ressourcenWort, statusLabel, statusTon, tagPlus, tagUeberschrift, verschiebeTag, wocheVon, zeitNormal, zeitraumTitel,
} from '../public/termine-view.js'
// Reine Helfer der Portal-Ansicht Terminplaner (public/termine-view.js): Tage sind Berliner Kalendertage 'JJJJ-MM-TT', gerechnet wird
// nur mit dem Kalender (kein Browser-Datum), die Uhrzeit des Endes kommt aus endsAt in Berliner Zeit.
test('fensterParsen: Zeitfenster aus Text, normalisiert', () => {
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<b>'), '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, /<section class="kb-view" id="view-termine">[\s\S]*<h1>Terminplaner<\/h1>[\s\S]*<div id="termine-body">/)
assert.match(html, /<link rel="stylesheet" href="\/termine\.css">/)
// 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')
})
+888
View File
@@ -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-<Pruefung>.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=<Teil des Namens> 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: '<img src=x onerror=window.__xss=1>Max', gastNachricht: '<b>fett</b> & <script>window.__xss=2</script>', person: '<i>Anna</i>', leistung: 'Schnitt <u>1</u>' }),
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('<img src=x onerror=window.__xss=1>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 <u>1</u>, 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)
+18 -3
View File
@@ -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. */