Terminplaner: Portal-Ansicht Leistungen, Team, Einstellungen

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 650919b022
commit 0b75b31009
15 files changed
+2472 -72

No files matched your search

+1 -11
View File
@@ -5,7 +5,7 @@
alles andere erscheint als Meldung ueber den Reitern. */
import { MAX, ABSAGE_GRUENDE, datumKurz, heuteBerlin } from './termine-view.js'
import { allgemeinerFehler, feld, h, setzeFehler, zeigeFehler } from './termine-dom.js'
import { allgemeinerFehler, feld, frage, h, knopfZeile, setzeFehler, zeigeFehler } from './termine-dom.js'
import { zeitWahl } from './termine-formulare.js'
export const AKTION_TEXT = {
@@ -65,16 +65,6 @@ async function fuehreAus(t, b, body, erfolg, form, knopf) {
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')) }
}
// Annehmen schliesst den Vertrag und verschickt die Bestaetigung (G02): wie jede Aktion erst nach einer Rueckfrage in der Seite
function frageAnnehmen(t, b, zu) {
const { ok, zeile } = knopfZeile('Annehmen', zu)
+33 -1
View File
@@ -56,6 +56,14 @@ export function feld(name, label, steuer, hinweis) {
h('p', { class: 'tm-fehler', 'data-fehler': name, role: 'alert' }))
}
/** Ankreuzfeld (Wert steuert der Aufrufer: input type=checkbox) mit Beschriftung, Hinweis und Platz fuer den Fehler. */
export function hakenFeld(name, steuer, text, hinweis) {
steuer.dataset.feld = name
return h('div', { class: 'tm-feld tm-feld-haken' },
h('label', { class: 'tm-haken' }, steuer, h('span', null, text, hinweis ? h('small', { class: 'tm-hinweis' }, hinweis) : null)),
h('p', { class: 'tm-fehler', 'data-fehler': name, role: 'alert' }))
}
/** Platz fuer Fehler, die zu keinem Feld gehoeren (Feldname „_“). */
export const allgemeinerFehler = () => h('p', { class: 'tm-fehler', 'data-fehler': '_', role: 'alert' })
@@ -64,7 +72,8 @@ export function setzeFehler(form, fehler) {
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)}"]`)
// „_“ und Namen ohne Platz im Formular werden allgemein gezeigt
const ziel = name === '_' ? null : form.querySelector(`[data-fehler="${CSS.escape(name)}"]`)
if (ziel) {
ziel.textContent = text
form.querySelector(`[data-feld="${CSS.escape(name)}"]`)?.setAttribute('aria-invalid', 'true')
@@ -83,6 +92,29 @@ export function zeigeFehler(form, err) {
form.querySelector('[aria-invalid]')?.focus()
}
/** Rueckfrage in der Seite (kein confirm()): Formular mit Titel; der Fokus gehoert dem Aufrufer. */
export 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 (schickt das Formular ab) und Abbrechen. */
export 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')) }
}
/** Abschnitt eines Reiters: Karte mit Ueberschrift und einem erklaerenden Satz (hinweis darf fehlen). */
export const abschnitt = (titel, hinweis, ...inhalt) => h('section', { class: 'kb-panel tm-abschnitt' },
h('h3', null, titel),
hinweis ? h('p', { class: 'tm-hinweis tm-abschnitt-text' }, hinweis) : null,
...inhalt)
/** Felder einer abgeschalteten Funktion: gesperrt (fieldset disabled sperrt alle Felder darin), sonst unveraendert. */
export const gesperrt = (nurLesen, felder) => (nurLesen ? h('fieldset', { class: 'tm-gruppe-felder', disabled: true }, felder) : felder)
/** Link zum CSV-Export der Termine. */
export const exportLink = (projectId, text) => h('a', { href: exportUrl(projectId), download: 'termine.csv' }, text)
export const infoKarte = (titel, text, { chat = false } = {}) => h('div', { class: 'kb-panel tm-info' },
h('h3', null, titel),
text ? h('p', null, text) : null,
+2 -2
View File
@@ -2,7 +2,7 @@
Die Formulare kennen den Reiter nicht: sie bekommen ctx (siehe termine.js) und hooks = { startTag, erfolg(tag, text), abbrechen() }.
erfolg wird nach dem Speichern aufgerufen (der Reiter zeigt dann den Tag und laedt die Liste neu), abbrechen schliesst das Formular. */
import { MAX, heuteBerlin, istTag, ressourcenWort, zeitNormal } from './termine-view.js'
import { GESUNDHEIT_HINWEIS, MAX, heuteBerlin, istTag, ressourcenWort, zeitNormal } from './termine-view.js'
import { allgemeinerFehler, feld, h, setzeFehler, zeigeFehler } from './termine-dom.js'
/** Freie Uhrzeiten als Auswahl-Knoepfe: laden() holt sie fuer query() (leistung/ressource/tag oder ohne/tag), wert() ist die gewaehlte. */
@@ -88,7 +88,7 @@ export function formularTermin(ctx, hooks) {
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.'),
feld('nachricht', 'Notiz (optional)', nachricht, GESUNDHEIT_HINWEIS),
h('label', { class: 'tm-haken', for: 'tm-senden' }, senden, h('span', null, 'Bestätigung an den Gast senden', sendenHinweis)),
allgemeinerFehler(),
h('div', { class: 'tm-frage-knoepfe' }, ok, h('button', { type: 'button', class: 'kb-btn-outline', onclick: hooks.abbrechen }, 'Abbrechen')))
+19
View File
@@ -0,0 +1,19 @@
/* Terminplaner im Kundenbereich: Texte, die GAESTE lesen, und die Anrede der Gaeste. Dieses Modul ist das einzige der Ansicht, in dem die
Sie-Form stehen darf (test/termineView.test.js prueft, dass alle anderen Portaltexte in der Du-Form sind). */
/** Anrede der Gaeste (anrede der Einstellung): Wert und Beschriftung. */
export const ANREDE_WAHL = [['sie', 'Sie (förmlich)'], ['du', 'Du (persönlich)']]
/**
* Mustertext fuer die Stornobedingungen, Zahlung vor Ort: derselbe Wortlaut wie in den Vorlagen des Servers
* (stornoText in server/services/termine/vorlagen.js, Stornobedingungen Variante 1) mit der Frist in Stunden.
* Ein Muster, kein fertiger Rechtstext: der Betrieb muss ihn pruefen und an sein Angebot anpassen. Eine unbrauchbare Frist (kleiner als 1,
* keine ganze Zahl) ergibt die Vorgabe 24 Stunden.
*/
export function stornoMuster(stunden) {
const frist = Number.isInteger(stunden) && stunden >= 1 ? stunden : 24
return `Sie können Ihren Termin bis ${frist} Stunden vorher kostenlos absagen oder verschieben (Link in der Bestätigungsmail). `
+ 'Danach oder bei Nichterscheinen berechnen wir für den freigehaltenen Termin 50 % des Preises. '
+ 'Dem Kunden bleibt der Nachweis vorbehalten, dass uns kein oder ein geringerer Aufwand entstanden ist. '
+ 'Ihr gesetzliches Widerrufsrecht bleibt unberührt.'
}
+128
View File
@@ -0,0 +1,128 @@
/* Terminplaner im Kundenbereich: Speichern der Einstellungen – gemeinsam fuer die Reiter Leistungen, Team & Plaetze und Einstellungen.
Jede Aenderung ist ein Speichern: PATCH /api/termine/einstellungen schickt die GANZE Einstellung mit der Version, auf der sie beruht
(server/routes/termine.js). Der Reiter gibt nur an, was sich aendert: speichere(aendere, { container, knopf }) holt eine Kopie des
aktuellen Standes (ctx.daten.einstellung), laesst aendere(entwurf) sie aendern und schickt sie ab.
- Erfolg: die zurueckgegebene Einstellung und Version gelten (ctx.daten), die Hinweisleiste der Huelle wird neu gezeichnet und die Daten
(Kalender-Links) werden neu geladen; Rueckgabe true.
- 400: Fehler stehen am Feld (data-fehler = Pfad wie 'leistungen.2.dauer' oder 'ressourcen.1.plan.3'); Pfade ohne Platz im container
erscheinen allgemein, mit dem Namen des Eintrags davor. Rueckgabe false.
- 409 EINSTELLUNG_VERALTET (jemand hat inzwischen gespeichert): Hinweis mit Knopf „Neu laden“ oben im Reiter. Rueckgabe false.
- alles andere: Meldung ueber den Reitern.
Der Reiter ruft speichere nur, wenn ctx.nurLesen nicht gesetzt ist. Die Tokens der Kalender-Links gehoeren dem Server und stehen nie in
der Einstellung des Browsers. */
import { allgemeinerFehler, frage, h, knopfZeile, setzeFehler } from './termine-dom.js'
export const VERALTET_TEXT = 'Die Einstellungen wurden inzwischen geändert. Bitte neu laden.'
const WORT = { leistungen: 'Leistung', abwesenheiten: 'Abwesenheit' }
/** „Leistung „Färben“: Text“ aus Pfad und Text eines Fehlers; Eintraege finden sich per Index oder per ID in den Einstellungen (quellen). */
export function beschrifteFehler(pfad, text, quellen) {
if (pfad.startsWith('betrieb.')) return `${text} Das änderst du im Reiter „Einstellungen“.`
const m = /^(leistungen|ressourcen|abwesenheiten)\.([^.]+)/.exec(pfad)
if (!m) return text
for (const quelle of quellen) {
const liste = Array.isArray(quelle?.[m[1]]) ? quelle[m[1]] : []
const eintrag = /^\d+$/.test(m[2]) ? liste[Number(m[2])] : liste.find((e) => e?.id === m[2])
const name = eintrag?.name || eintrag?.titel
if (!name) continue
const wort = m[1] === 'ressourcen' ? (eintrag.typ === 'platz' ? 'Platz' : 'Person') : WORT[m[1]]
return `${wort} „${name}“: ${text}`
}
return text
}
/**
* Speicher eines Reiters. wurzel = das Element des Reiters (verschwindet beim Wechsel des Reiters: danach fasst nichts mehr die Seite an),
* neuZeichnen() zeichnet den Reiter aus ctx.daten neu (nach „Neu laden“).
* => { konflikt, speichere, nochHier }: konflikt ist das Hinweis-Element (der Reiter setzt es an den Anfang), nochHier() sagt, ob der
* Reiter noch angezeigt wird (vor jedem Anfassen der Seite nach einem await pruefen).
*/
export function neuerSpeicher(ctx, wurzel, neuZeichnen) {
const nochHier = () => wurzel.isConnected && ctx.aktuell()
const text = h('span', null, VERALTET_TEXT)
const neuLadenKnopf = h('button', { type: 'button', class: 'kb-btn-outline' }, 'Neu laden')
const konflikt = h('div', { class: 'tm-box tm-box-warn', role: 'alert', hidden: true }, text, neuLadenKnopf)
neuLadenKnopf.addEventListener('click', async () => {
neuLadenKnopf.disabled = true
try {
await ctx.neuLaden()
if (!nochHier()) return
text.textContent = VERALTET_TEXT
konflikt.hidden = true
neuZeichnen()
} catch (err) {
if (nochHier()) text.textContent = err.message
} finally {
neuLadenKnopf.disabled = false
}
})
async function speichere(aendere, { container, knopf = null } = {}) {
setzeFehler(container, {})
konflikt.hidden = true
const bisher = ctx.daten.einstellung
const entwurf = structuredClone(bisher)
if (aendere(entwurf) === false) return false // der Reiter hat selbst Fehler am Feld gezeigt
if (knopf) knopf.disabled = true
try {
const antwort = await ctx.api.send('PATCH', '/einstellungen', { version: ctx.daten.version, einstellung: entwurf })
if (!ctx.aktuell()) return false
// Der Server hat streng geprueft: Telefon und Anschrift fehlen nach einem Erfolg nie mehr
Object.assign(ctx.daten, { einstellung: antwort.einstellung, version: antwort.version, fehlt: [] })
ctx.hinweise()
try {
await ctx.neuLaden() // neue Personen/Plaetze bekommen vom Server ihre Kalender-Links
} catch { /* dann zeigen neue Eintraege ihren Kalender-Link erst nach dem naechsten Laden */ }
return true
} catch (err) {
if (!nochHier()) return false
if (err.code === 'EINSTELLUNG_VERALTET') {
text.textContent = VERALTET_TEXT
konflikt.hidden = false
konflikt.scrollIntoView({ block: 'nearest' })
} else if (err.status === 400 && Object.keys(err.fehler || {}).length) {
zeigeFelder(container, err.fehler, [bisher, entwurf])
} else {
ctx.meldung(err.message, 'fehler')
}
return false
} finally {
if (knopf?.isConnected) knopf.disabled = false
}
}
return { konflikt, speichere, nochHier }
}
/** Fehler des Servers am Feld; was im container keinen Platz hat, steht allgemein (mit dem Namen des Eintrags davor). */
function zeigeFelder(container, fehler, quellen) {
const amFeld = {}
const rest = []
for (const [pfad, text] of Object.entries(fehler)) {
if (pfad !== '_' && container.querySelector(`[data-fehler="${CSS.escape(pfad)}"]`)) amFeld[pfad] = text
else rest.push(beschrifteFehler(pfad, text, quellen))
}
setzeFehler(container, rest.length ? { ...amFeld, _: rest.join(' ') } : amFeld)
container.querySelector('[aria-invalid="true"]')?.focus()
}
/**
* Rueckfrage „entfernen?“ in der Seite (kein confirm()): Titel, Erklaerung, Platz fuer den Fehler des Servers am Eintrag (fehlerPfad, z. B.
* 'leistungen.s_abcd1234': „Fuer diese Leistung gibt es noch 2 kuenftige Termine …“), Knoepfe. aendere(entwurf) entfernt den Eintrag,
* nachher() laeuft nach dem erfolgreichen Speichern (der Reiter zeichnet neu), zurueck() nach „Abbrechen“. Gibt das Formular zurueck;
* der Aufrufer setzt es in seine Box und gibt ihm den Fokus.
*/
export function entfernenFrage(sp, { titel, text, fehlerPfad, aendere, nachher, zurueck }) {
const { ok, zeile } = knopfZeile('Ja, entfernen', zurueck)
const box = frage(titel,
h('p', null, text),
fehlerPfad ? h('p', { class: 'tm-fehler', 'data-fehler': fehlerPfad, role: 'alert' }) : null,
allgemeinerFehler(),
zeile)
box.addEventListener('submit', async () => {
if (await sp.speichere(aendere, { container: box, knopf: ok }) && sp.nochHier()) nachher()
})
return box
}
+281
View File
@@ -0,0 +1,281 @@
/* Terminplaner im Kundenbereich: Reiter "Einstellungen" (Spezifikation Abschnitt 9, Punkt 4) – Betriebsangaben fuer Gaeste, Anrede und Farbe,
Regeln, Angaben von Gaesten (Zusatzfelder), Erinnerung, Stornobedingungen (mit Muster), Rechtliches, Bezahlung, Aufbewahrung,
darunter Export und Status. render(panel, ctx) ist der Einstieg (Registry REITER in termine.js).
Alles ist EIN Formular mit einem Speichern (termine-speichern.js). Jeder Abschnitt ist eine Funktion ctx => { el, lese(entwurf, fehler) }:
el die Karte, lese uebertraegt die Felder in den Entwurf und meldet Eingaben, die kein Feld verstehen kann, in fehler (Pfad ⇒ Text).
Pruefen und Begrenzen bleibt Sache des Servers. Abgeschaltete Funktion (ctx.nurLesen): Felder gesperrt, kein Speichern, Export bleibt. */
import {
ERINNERUNG_STUNDEN, GAST_FELD_WAHL, GESUNDHEIT_HINWEIS, LIMITS, MAX, RASTER_MIN, ZAHLARTEN, aktivSeitText, fehltText, ganzzahl, optionenAusText, optionenText, zusatzSchluessel,
} from './termine-view.js'
import { ANREDE_WAHL, stornoMuster } from './termine-gaestetexte.js'
import { abschnitt, allgemeinerFehler, exportLink, feld, gesperrt, h, hakenFeld, zeigeFehler } from './termine-dom.js'
import { neuerSpeicher } from './termine-speichern.js'
const wahl = (liste, aktuell) => liste.map(([wert, text]) => h('option', { value: wert, selected: String(aktuell) === String(wert) }, text))
const zahlenFeld = (name, label, wert, { min, max, schritt = 1, hinweis }) => {
const input = h('input', { type: 'number', inputmode: 'numeric', min, max, step: schritt, value: wert })
return { input, el: feld(name, label, input, hinweis) }
}
const liesZahl = (fehler, name, input) => {
const wert = ganzzahl(input.value)
if (wert === null) fehler[name] = 'Bitte gib eine ganze Zahl an.'
return wert
}
export function render(panel, ctx) {
const wurzel = h('div', { class: 'tm-tab' })
panel.replaceChildren(wurzel)
const sp = neuerSpeicher(ctx, wurzel, zeichne)
function zeichne() {
const e = ctx.daten.einstellung
const regelnAbschnitt = regeln(e)
const abschnitte = [betrieb(ctx, e), darstellung(e), regelnAbschnitt, gastFelder(ctx, e), erinnerung(e), storno(ctx, e, regelnAbschnitt.absage), rechtliches(e), zahlung(e), aufbewahrung(e)]
const speichern = h('button', { type: 'submit', class: 'kb-btn-primary' }, 'Einstellungen speichern')
const form = h('form', { novalidate: true, 'aria-label': 'Einstellungen' },
gesperrt(ctx.nurLesen, abschnitte.map((a) => a.el)),
allgemeinerFehler(),
ctx.nurLesen ? null : h('div', { class: 'tm-frage-knoepfe' }, speichern))
form.addEventListener('submit', async (ev) => {
ev.preventDefault()
const ok = await sp.speichere((entwurf) => {
const fehler = {}
for (const a of abschnitte) a.lese(entwurf, fehler)
if (Object.keys(fehler).length) {
zeigeFehler(form, { fehler })
return false
}
return true
}, { container: form, knopf: speichern })
if (!ok || !sp.nochHier()) return
zeichne()
ctx.meldung('Einstellungen gespeichert.')
})
wurzel.replaceChildren(sp.konflikt, form, exportUndStatus(ctx))
}
zeichne()
}
/* ----------------------------------------------------------------- Betrieb --- */
function betrieb(ctx, e) {
const b = e.betrieb || {}
const text = (wert, max, extra) => h('input', { type: 'text', maxlength: max, autocomplete: 'off', value: wert ?? '', ...extra })
const url = (wert) => text(wert, MAX.url, { inputmode: 'url', placeholder: 'https://' })
const f = {
name: text(b.name, MAX.betriebName), email: h('input', { type: 'email', maxlength: '254', autocomplete: 'off', value: b.email ?? '' }), telefon: text(b.telefon, MAX.telefon),
adresse: text(b.adresse, MAX.adresse), impressumUrl: url(b.impressumUrl), datenschutzUrl: url(b.datenschutzUrl), agbUrl: url(b.agbUrl), websiteUrl: url(b.websiteUrl),
kleinunternehmer: h('input', { type: 'checkbox', checked: b.kleinunternehmer === true }),
}
const fehlt = ctx.daten.fehlt || []
const warnung = fehlt.length
? h('div', { class: 'tm-box tm-box-warn', role: 'note' }, `Ohne Telefon/Anschrift ist der Terminplaner auf deiner Website nicht buchbar. Es fehlt noch: ${fehltText(fehlt) || 'eine Angabe zu deinem Betrieb'}.`)
: null
const el = abschnitt('Dein Betrieb',
'Diese Angaben sehen deine Gäste beim Buchen und in ihren E-Mails. Die meisten haben wir aus deinem Kundenkonto übernommen.',
warnung,
h('div', { class: 'tm-raster' },
feld('betrieb.name', 'Name des Betriebs', f.name),
feld('betrieb.email', 'E-Mail des Betriebs', f.email, 'Hierhin schicken wir dir Nachrichten zu Terminen. Gäste können auf ihre Mails hier antworten.')),
h('div', { class: 'tm-raster' },
feld('betrieb.telefon', 'Telefon (Pflicht)', f.telefon, 'Gäste sehen die Nummer als Ausweichweg und in ihren Mails.'),
feld('betrieb.adresse', 'Anschrift', f.adresse, 'Pflicht, wenn Privatpersonen eine Leistung sofort buchen: Die Anschrift steht in der Widerrufsbelehrung.')),
hakenFeld('betrieb.kleinunternehmer', f.kleinunternehmer, 'Ich bin Kleinunternehmer nach § 19 UStG', 'Dann steht bei den Preisen „kein Ausweis der USt nach § 19 UStG“ statt „inkl. USt“.'),
h('div', { class: 'tm-raster' },
feld('betrieb.impressumUrl', 'Link zum Impressum', f.impressumUrl, 'Vollständige Adresse mit https://'),
feld('betrieb.datenschutzUrl', 'Link zur Datenschutzerklärung', f.datenschutzUrl, 'Vollständige Adresse mit https://'),
feld('betrieb.agbUrl', 'Link zu deinen AGB (optional)', f.agbUrl, 'Vollständige Adresse mit https://'),
feld('betrieb.websiteUrl', 'Link zu deiner Website', f.websiteUrl, 'Vollständige Adresse mit https://')))
return {
el,
lese(entwurf) {
entwurf.betrieb = {
...entwurf.betrieb,
name: f.name.value, email: f.email.value, telefon: f.telefon.value, adresse: f.adresse.value, kleinunternehmer: f.kleinunternehmer.checked,
impressumUrl: f.impressumUrl.value, datenschutzUrl: f.datenschutzUrl.value, agbUrl: f.agbUrl.value, websiteUrl: f.websiteUrl.value,
}
},
}
}
/* -------------------------------------------------------- Anrede und Farbe --- */
function darstellung(e) {
const anrede = h('select', null, wahl(ANREDE_WAHL, e.anrede ?? 'sie'))
const farbe = h('input', { type: 'color', value: /^#[0-9a-f]{6}$/.test(e.farbe) ? e.farbe : '#7c3aed' })
const el = abschnitt('Anrede und Farbe', 'So treten die Buchungsseite und die E-Mails an deine Gäste auf.',
h('div', { class: 'tm-raster' },
feld('anrede', 'Wie sprichst du deine Gäste an?', anrede),
feld('farbe', 'Akzentfarbe', farbe, 'Für Knöpfe und Kopfzeile der Buchungsseite und der E-Mails.')))
return { el, lese(entwurf) { entwurf.anrede = anrede.value; entwurf.farbe = farbe.value } }
}
/* ------------------------------------------------------------------ Regeln --- */
function regeln(e) {
const r = e.regeln || {}
const raster = h('select', null, wahl(RASTER_MIN.map((n) => [n, `${n} Minuten`]), r.raster ?? 15))
const vorlauf = zahlenFeld('regeln.vorlaufMin', 'Mindestvorlauf (Minuten)', r.vorlaufMin, { min: 0, max: 20160, hinweis: 'Wie kurzfristig darf gebucht werden? 120 sind 2 Stunden, höchstens 20160 (14 Tage).' })
const horizont = zahlenFeld('regeln.horizontTage', 'Buchungszeitraum (Tage)', r.horizontTage, { min: 1, max: 365, hinweis: 'Wie weit im Voraus darf gebucht werden? 1 bis 365 Tage.' })
const absage = zahlenFeld('regeln.absageBisStd', 'Selbst absagen bis (Stunden vorher)', r.absageBisStd, { min: 0, max: 336, hinweis: 'Bis dahin dürfen Gäste ihren Termin selbst absagen oder verschieben. 0 bis 336.' })
const offene = zahlenFeld('regeln.maxOffeneJeEmail', 'Offene Termine je E-Mail-Adresse', r.maxOffeneJeEmail, { min: 1, max: 10, hinweis: 'So viele Termine darf eine Adresse gleichzeitig offen haben (1 bis 10). Das schützt vor Spaßbuchungen.' })
const wahlbar = h('input', { type: 'checkbox', checked: r.personWaehlbar !== false })
const el = abschnitt('Regeln fürs Buchen', null,
h('div', { class: 'tm-raster' }, feld('regeln.raster', 'Startzeiten alle', raster, 'In diesen Schritten bieten wir Uhrzeiten an.'), vorlauf.el, horizont.el, absage.el, offene.el),
hakenFeld('regeln.personWaehlbar', wahlbar, 'Gäste dürfen die Person oder den Platz selbst wählen', 'Sonst vergeben wir automatisch eine freie Person oder einen freien Platz.'))
return {
el,
lese(entwurf, fehler) {
entwurf.regeln = {
...entwurf.regeln,
raster: Number(raster.value), vorlaufMin: liesZahl(fehler, 'regeln.vorlaufMin', vorlauf.input), horizontTage: liesZahl(fehler, 'regeln.horizontTage', horizont.input),
absageBisStd: liesZahl(fehler, 'regeln.absageBisStd', absage.input), maxOffeneJeEmail: liesZahl(fehler, 'regeln.maxOffeneJeEmail', offene.input), personWaehlbar: wahlbar.checked,
}
},
absage: absage.input,
}
}
/* ------------------------------------------------- Angaben von Gaesten --- */
function gastFelder(ctx, e) {
const g = e.gastFelder || {}
const telefon = h('select', null, wahl(GAST_FELD_WAHL, g.telefon ?? 'optional'))
const nachricht = h('select', null, wahl(GAST_FELD_WAHL, g.nachricht ?? 'aus'))
// Zusatzfelder: der Zustand steht in zeilen, die Liste wird daraus gezeichnet (Namen der Fehlerfelder = Stelle in der Liste)
const zeilen = (g.zusatz || []).map((z) => ({ key: z.key, label: z.label, typ: z.typ, optionen: optionenText(z.optionen), pflicht: z.pflicht === true }))
const liste = h('div', { class: 'tm-zusatz' })
const neuKnopf = ctx.nurLesen ? null : h('button', {
type: 'button', class: 'kb-btn-outline', 'data-aktion': 'zusatz-neu',
onclick: () => { zeilen.push({ key: '', label: '', typ: 'text', optionen: '', pflicht: false }); zeichneListe(); liste.lastElementChild?.querySelector('input')?.focus() },
}, '+ Zusatzfeld')
const platz = h('p', { class: 'tm-fehler', 'data-fehler': 'gastFelder.zusatz', role: 'alert' })
function zeichneListe() {
if (neuKnopf) neuKnopf.disabled = zeilen.length >= LIMITS.zusatzfelder
liste.replaceChildren(...zeilen.map((z, i) => {
const p = `gastFelder.zusatz.${i}`
const label = h('input', { type: 'text', maxlength: MAX.zusatzLabel, autocomplete: 'off', value: z.label })
const typ = h('select', null, wahl([['text', 'Freier Text'], ['auswahl', 'Auswahl aus Liste']], z.typ))
const optionen = h('textarea', { rows: '3', maxlength: String(MAX.option * LIMITS.optionen + LIMITS.optionen) }, z.optionen)
const pflicht = h('input', { type: 'checkbox', checked: z.pflicht })
const optionenFeld = feld(`${p}.optionen`, 'Auswahlmöglichkeiten', optionen, `Eine je Zeile, höchstens ${LIMITS.optionen}.`)
optionenFeld.hidden = z.typ !== 'auswahl'
label.addEventListener('input', () => { z.label = label.value })
optionen.addEventListener('input', () => { z.optionen = optionen.value })
pflicht.addEventListener('change', () => { z.pflicht = pflicht.checked })
typ.addEventListener('change', () => { z.typ = typ.value; optionenFeld.hidden = z.typ !== 'auswahl' })
return h('div', { class: 'tm-zusatz-zeile', 'data-zusatz': String(i) },
h('div', { class: 'tm-raster' }, feld(`${p}.label`, 'Beschriftung', label), feld(`${p}.typ`, 'Art', typ)),
optionenFeld,
hakenFeld(`${p}.pflicht`, pflicht, 'Pflichtfeld'),
ctx.nurLesen ? null : h('button', {
type: 'button', class: 'kb-btn-outline tm-gefahr', 'data-aktion': 'zusatz-weg', 'aria-label': `Zusatzfeld entfernen: ${z.label || i + 1}`,
onclick: () => { zeilen.splice(i, 1); zeichneListe(); neuKnopf?.focus() },
}, 'Entfernen'))
}))
}
zeichneListe()
const el = abschnitt('Angaben von Gästen',
'Name und E-Mail brauchen wir immer. Alles Weitere entscheidest du.',
h('div', { class: 'tm-raster' },
feld('gastFelder.telefon', 'Telefonnummer des Gastes', telefon),
feld('gastFelder.nachricht', 'Nachricht des Gastes', nachricht, `Über dem Feld steht für deine Gäste: „${GESUNDHEIT_HINWEIS}“`)),
h('h4', null, 'Zusatzfelder'),
h('p', { class: 'tm-hinweis' }, `Eigene Fragen an deine Gäste, zum Beispiel nach der Haarlänge oder einer Wunschfarbe. Höchstens ${LIMITS.zusatzfelder}. Keine Gesundheitsangaben abfragen.`),
liste,
platz,
neuKnopf)
return {
el,
lese(entwurf) {
const belegt = zeilen.map((z) => z.key).filter(Boolean)
const zusatz = zeilen.map((z) => {
const key = z.key || zusatzSchluessel(z.label, belegt)
belegt.push(key)
return { key, label: z.label, typ: z.typ, optionen: z.typ === 'auswahl' ? optionenAusText(z.optionen) : [], pflicht: z.pflicht }
})
entwurf.gastFelder = { ...entwurf.gastFelder, telefon: telefon.value, nachricht: nachricht.value, zusatz }
},
}
}
/* -------------------------------------------------------------- Erinnerung --- */
function erinnerung(e) {
const r = e.erinnerung || {}
const anbieten = h('input', { type: 'checkbox', checked: r.anbieten !== false })
const stunden = h('select', null, wahl(ERINNERUNG_STUNDEN.map((n) => [n, `${n} Stunden vorher`]), r.stundenVorher ?? 24))
const el = abschnitt('Erinnerung', null,
hakenFeld('erinnerung.anbieten', anbieten, 'Gästen eine E-Mail-Erinnerung anbieten', 'Gäste entscheiden beim Buchen selbst (der Haken ist nie vorangekreuzt).'),
feld('erinnerung.stundenVorher', 'Die Erinnerung kommt', stunden))
return { el, lese(entwurf) { entwurf.erinnerung = { ...entwurf.erinnerung, anbieten: anbieten.checked, stundenVorher: Number(stunden.value) } } }
}
/* --------------------------------------------------------- Stornobedingungen --- */
function storno(ctx, e, fristFeld) {
const s = e.storno || {}
const textfeld = h('textarea', { rows: '6', maxlength: String(MAX.storno) }, s.text ?? '')
// das Muster setzt die Frist ein, die im Feld „Selbst absagen bis“ steht (auch ungespeichert)
const muster = h('blockquote', { class: 'tm-muster-text' }, stornoMuster(e.regeln?.absageBisStd))
fristFeld.addEventListener('input', () => { muster.textContent = stornoMuster(ganzzahl(fristFeld.value)) })
const einfuegen = ctx.nurLesen ? null : h('button', {
type: 'button', class: 'kb-btn-outline', 'data-aktion': 'muster-uebernehmen',
onclick: () => { textfeld.value = muster.textContent; textfeld.focus() },
}, 'Muster in das Textfeld übernehmen')
const el = abschnitt('Stornobedingungen',
'Gäste müssen diesen Text vor dem Buchen bestätigen. Lässt du das Feld leer, gibt es keine Stornobedingungen und keinen Haken dazu.',
feld('storno.text', 'Dein Text', textfeld, s.version ? `Aktuelle Fassung: ${s.version}. Ändert sich der Text, bekommt er beim Speichern eine neue Fassung, und Gäste stimmen immer der Fassung zu, die sie gesehen haben.` : 'Beim ersten Speichern bekommt der Text eine Fassungsnummer.'),
h('div', { class: 'tm-muster', role: 'group', 'aria-label': 'Mustertext' },
h('p', null, h('span', { class: 'tm-pille tm-pille-wait' }, 'Muster'), ' Ein Vorschlag, kein fertiger Rechtstext. Prüfe ihn und passe Frist, Prozentsatz und Anrede an deinen Betrieb an. Er ist in der förmlichen Anrede geschrieben. Das gesetzliche Widerrufsrecht deiner privaten Gäste bleibt unberührt.'),
muster,
einfuegen,
einfuegen ? h('p', { class: 'tm-hinweis' }, 'Das ersetzt den Text im Feld. Gespeichert wird erst mit „Einstellungen speichern“.') : null))
return { el, lese(entwurf) { entwurf.storno = { ...entwurf.storno, text: textfeld.value } } }
}
/* ------------------------------------------------------------- Rechtliches --- */
function rechtliches(e) {
const r = e.recht || {}
const gaeste = h('select', null, wahl([['verbraucher', 'Privatpersonen'], ['unternehmer', 'Unternehmen'], ['gemischt', 'Beides']], r.gaesteSind ?? 'verbraucher'))
const freizeit = h('input', { type: 'checkbox', checked: r.freizeit === true })
const el = abschnitt('Rechtliches', null,
h('div', { class: 'tm-box tm-box-info', role: 'note' }, h('span', null,
h('strong', null, 'Der Widerruf schlägt die Stornofrist. '),
'Privatpersonen können einen verbindlich gebuchten Termin bis 14 Tage nach der Buchung widerrufen, auch wenn deine kostenlose Stornofrist schon vorbei ist. Die Widerrufsbelehrung zeigt der Terminplaner deinen Gästen automatisch.')),
feld('recht.gaesteSind', 'Deine Gäste sind', gaeste, 'Bei „Unternehmen“ gibt es kein gesetzliches Widerrufsrecht.'),
hakenFeld('recht.freizeit', freizeit, 'Meine Leistungen sind Freizeitangebote mit festem Termin', 'Zum Beispiel ein Kurs oder ein Workshop. Dann gibt es kein gesetzliches Widerrufsrecht (§ 312g Abs. 2 Nr. 9 BGB). Setze das Häkchen nur, wenn du dir sicher bist oder dich bei uns rückversichert hast.'))
return { el, lese(entwurf) { entwurf.recht = { ...entwurf.recht, gaesteSind: gaeste.value, freizeit: freizeit.checked } } }
}
/* ----------------------------------------------------------------- Bezahlung --- */
function zahlung(e) {
const z = e.zahlung || {}
const arten = ZAHLARTEN.map(([key, name]) => ({ key, name, input: h('input', { type: 'checkbox', checked: (z.vorOrtArten || []).includes(key) }) }))
const el = abschnitt('Bezahlung', 'Bezahlt wird bei dir vor Ort. Welche Zahlarten nimmst du an? Gäste sehen das vor der Buchung.',
h('div', { class: 'tm-optionen', role: 'radiogroup', 'aria-label': 'Wo wird bezahlt?' },
h('label', { class: 'tm-haken' }, h('input', { type: 'radio', name: 'tm-zahlmodus', value: 'vorort', checked: true }), h('span', null, 'Vor Ort bezahlen')),
h('label', { class: 'tm-haken tm-aus' }, h('input', { type: 'radio', name: 'tm-zahlmodus', value: 'online', disabled: true }), h('span', null, 'Online bezahlen ', h('span', { class: 'tm-pille tm-pille-off' }, 'kommt bald')))),
h('fieldset', { class: 'tm-gruppe-felder', 'data-feld': 'zahlung.vorOrtArten', tabindex: '-1' },
h('legend', { class: 'tm-label' }, 'Zahlarten vor Ort'),
arten.map(({ name, input }) => h('label', { class: 'tm-haken' }, input, h('span', null, name))),
h('p', { class: 'tm-fehler', 'data-fehler': 'zahlung.vorOrtArten', role: 'alert' })))
return { el, lese(entwurf) { entwurf.zahlung = { modus: 'vorort', vorOrtArten: arten.filter(({ input }) => input.checked).map(({ key }) => key) } } }
}
/* ------------------------------------------------------------ Aufbewahrung --- */
function aufbewahrung(e) {
const tage = zahlenFeld('aufbewahrungTage', 'Aufbewahrung (Tage)', e.aufbewahrungTage, {
min: 14, max: 365,
hinweis: 'So lange nach dem Termin bleiben erledigte Termine und Termine, zu denen Gäste nicht erschienen sind, mit allen Angaben gespeichert. Danach löschen wir sie. Abgesagte Termine löschen wir nach 30 Tagen. 14 bis 365.',
})
return { el: abschnitt('Aufbewahrung', null, tage.el), lese(entwurf, fehler) { entwurf.aufbewahrungTage = liesZahl(fehler, 'aufbewahrungTage', tage.input) } }
}
/* ------------------------------------------------------------ Export, Status --- */
function exportUndStatus(ctx) {
const seit = aktivSeitText(ctx.daten.aktivSeit)
const abgeschaltet = ctx.daten.state === 'disabled'
return abschnitt('Export und Status', null,
h('p', null, exportLink(ctx.projectId, 'Alle Termine als CSV-Datei herunterladen'), ' (öffnet sich in Excel und Numbers).'),
h('p', { class: 'tm-status' },
h('span', { class: `tm-pille tm-pille-${abgeschaltet ? 'off' : 'ok'}` }, abgeschaltet ? 'Abgeschaltet' : 'Aktiv'),
seit ? ` ${seit}` : ''))
}
+204
View File
@@ -0,0 +1,204 @@
/* Terminplaner im Kundenbereich: Reiter "Leistungen" – Liste in der Reihenfolge, in der Gaeste sie sehen, Anlegen, Bearbeiten (Felder wie in
einstellungen.js: Name, Beschreibung, Dauer, Pause danach, Preis, Buchungsart, wer sie durchfuehrt, aktiv), Entfernen, Reihenfolge.
render(panel, ctx) ist der Einstieg (Registry REITER in termine.js). Jede Aktion ist ein Speichern der ganzen Einstellung
(termine-speichern.js); Pruefen und Begrenzen bleibt Sache des Servers, hier steht nur, was ein Feld ohne Antwort des Servers wissen kann.
Abgeschaltete Funktion (ctx.nurLesen): dieselben Felder, aber gesperrt und ohne Knoepfe zum Aendern. */
import { GESUNDHEIT_HINWEIS, LIMITS, MAX, centZuEuro, eindeutigeId, euroZuCent, ganzzahl, preisAnzeige, verschiebeEintrag } from './termine-view.js'
import { allgemeinerFehler, feld, gesperrt, h, hakenFeld, zeigeFehler } from './termine-dom.js'
import { entfernenFrage, neuerSpeicher } from './termine-speichern.js'
const BUCHUNGSART = [['sofort', 'Sofort buchbar'], ['anfrage', 'Nur als Anfrage (du nimmst jeden Termin an)']]
const PREISART = [['keiner', 'Ohne Preis'], ['fest', 'Festpreis'], ['ab', 'Preis ab']]
const option = (wert, text, gewaehlt) => h('option', { value: wert, selected: gewaehlt }, text)
export function render(panel, ctx) {
const wurzel = h('div', { class: 'tm-tab' })
panel.replaceChildren(wurzel)
const sp = neuerSpeicher(ctx, wurzel, zeichne)
const t = { ctx, sp, zeichne, wurzel }
function zeichne() {
const e = ctx.daten.einstellung
const leistungen = e.leistungen || []
const kopfFehler = h('div', null, allgemeinerFehler())
const neuBox = h('div', { class: 'tm-formular-box' })
const voll = leistungen.length >= LIMITS.leistungen
const neuKnopf = ctx.nurLesen ? null : h('button', {
type: 'button', class: 'kb-btn-primary', 'data-werkzeug': 'leistung-neu', 'aria-expanded': 'false', disabled: voll,
onclick: (ev) => {
if (neuBox.firstChild) { neuBox.replaceChildren(); ev.currentTarget.setAttribute('aria-expanded', 'false'); return }
ev.currentTarget.setAttribute('aria-expanded', 'true')
const form = formular(t, { index: leistungen.length, leistung: null, schliessen: () => { neuBox.replaceChildren(); neuKnopf.setAttribute('aria-expanded', 'false'); neuKnopf.focus() } })
neuBox.replaceChildren(form)
form.querySelector('input')?.focus()
},
}, '+ Leistung anlegen')
wurzel.replaceChildren(
sp.konflikt,
h('div', { class: 'tm-box tm-box-info', role: 'note' },
h('span', null, h('strong', null, GESUNDHEIT_HINWEIS), ' Namen und Beschreibungen deiner Leistungen liest jeder, der auf deiner Website bucht. Schreib dort zum Beispiel keine Krankheiten oder Beschwerden hinein.')),
ctx.nurLesen ? null : h('div', { class: 'tm-werkzeug-zeile' }, neuKnopf, voll ? h('p', { class: 'tm-hinweis' }, `Mehr als ${LIMITS.leistungen} Leistungen sind nicht möglich.`) : null),
neuBox,
kopfFehler,
leistungen.length
? h('ul', { class: 'tm-eintraege', 'aria-label': 'Leistungen in der Reihenfolge, in der Gäste sie sehen' }, leistungen.map((l, i) => zeile(t, l, i, leistungen.length, kopfFehler)))
: h('p', { class: 'tm-leer' }, 'Noch keine Leistungen. Ohne Leistung kann niemand buchen.'))
}
zeichne()
}
/** Zeile einer Leistung: Kurzfassung, Aktiv-Pille, Reihenfolge (hoch/runter) und der Knopf, der das Formular unter der Zeile oeffnet. */
function zeile(t, l, index, anzahl, kopfFehler) {
const { ctx, sp } = t
const e = ctx.daten.einstellung
const namen = (l.ressourcen || []).map((id) => (e.ressourcen || []).find((r) => r.id === id)?.name).filter(Boolean)
const li = h('li', { class: 'tm-eintrag', 'data-id': l.id })
const editor = h('div', { class: 'tm-editor-box' })
const meta = [`${l.dauer} Min.`, l.pufferNach ? `${l.pufferNach} Min. Pause danach` : '', preisAnzeige(l), l.buchungsart === 'anfrage' ? 'Nur als Anfrage' : 'Sofort buchbar', namen.join(', ') || 'niemand zugeordnet']
const oeffnen = h('button', {
type: 'button', class: 'kb-btn-outline tm-knopf', 'data-aktion': 'bearbeiten', 'aria-expanded': 'false', 'aria-label': `${ctx.nurLesen ? 'Ansehen' : 'Bearbeiten'}: ${l.name}`,
onclick: () => {
if (editor.firstChild) { editor.replaceChildren(); oeffnen.setAttribute('aria-expanded', 'false'); return }
oeffnen.setAttribute('aria-expanded', 'true')
const form = formular(t, { index, leistung: l, schliessen: () => { editor.replaceChildren(); oeffnen.setAttribute('aria-expanded', 'false'); oeffnen.focus() } })
editor.replaceChildren(form)
form.querySelector('input')?.focus()
},
}, ctx.nurLesen ? 'Ansehen' : 'Bearbeiten')
async function verschiebe(richtung, knopf) {
const ok = await sp.speichere((entwurf) => { entwurf.leistungen = verschiebeEintrag(entwurf.leistungen, index, richtung) }, { container: kopfFehler, knopf })
if (!ok || !sp.nochHier()) return
t.zeichne()
ctx.meldung(`„${l.name}“ steht jetzt ${richtung < 0 ? 'weiter oben' : 'weiter unten'}.`)
const neu = t.wurzel.querySelector(`li[data-id="${l.id}"]`)
;(neu?.querySelector(`[data-aktion="${richtung < 0 ? 'hoch' : 'runter'}"]:not([disabled])`) || neu?.querySelector('[data-aktion="bearbeiten"]'))?.focus()
}
const pfeil = (aktion, richtung, zeichen, text, aus) => h('button', {
type: 'button', class: 'tm-pfeil', 'data-aktion': aktion, 'aria-label': `${text}: ${l.name}`, disabled: aus,
onclick: (ev) => verschiebe(richtung, ev.currentTarget),
}, h('span', { 'aria-hidden': 'true' }, zeichen))
li.append(
h('div', { class: 'tm-kopf' },
h('div', { class: 'tm-haupt' },
h('strong', { class: 'tm-name' }, l.name),
h('span', { class: 'tm-meta' }, meta.filter(Boolean).join(' · ')),
l.beschreibung ? h('span', { class: 'tm-meta' }, l.beschreibung) : null),
h('span', { class: `tm-pille tm-pille-${l.aktiv ? 'ok' : 'off'}` }, l.aktiv ? 'Aktiv' : 'Inaktiv')),
h('div', { class: 'tm-aktionen' },
ctx.nurLesen ? null : [pfeil('hoch', -1, '↑', 'Nach oben', index === 0), pfeil('runter', 1, '↓', 'Nach unten', index === anzahl - 1)],
oeffnen),
editor)
return li
}
/** Formular einer neuen (leistung null) oder bestehenden Leistung; index = ihre Stelle in der Liste (so heissen die Pfade der Fehler des Servers). */
function formular(t, { index, leistung, schliessen }) {
const { ctx, sp, zeichne } = t
const e = ctx.daten.einstellung
const neu = leistung === null
const l = leistung || { id: eindeutigeId('s', (e.leistungen || []).map((x) => x.id)), name: '', beschreibung: '', dauer: 30, pufferNach: 0, preisArt: 'keiner', preisCent: 0, buchungsart: 'sofort', ressourcen: [], aktiv: true }
const p = `leistungen.${index}`
const name = h('input', { type: 'text', maxlength: MAX.leistungName, autocomplete: 'off', value: l.name })
const beschreibung = h('textarea', { rows: '2', maxlength: MAX.beschreibung }, l.beschreibung)
const dauer = h('input', { type: 'number', inputmode: 'numeric', min: '5', max: '480', step: '5', value: l.dauer })
const puffer = h('input', { type: 'number', inputmode: 'numeric', min: '0', max: '120', step: '5', value: l.pufferNach ?? 0 })
const preisArt = h('select', null, PREISART.map(([wert, text]) => option(wert, text, l.preisArt === wert)))
const preis = h('input', { type: 'text', inputmode: 'decimal', autocomplete: 'off', value: l.preisArt === 'keiner' ? '' : centZuEuro(l.preisCent) })
const preisSteuern = () => { preis.disabled = preisArt.value === 'keiner'; if (preis.disabled) preis.value = '' }
preisArt.addEventListener('change', preisSteuern)
preisSteuern()
const art = h('select', null, BUCHUNGSART.map(([wert, text]) => option(wert, text, l.buchungsart === wert)))
const aktiv = h('input', { type: 'checkbox', checked: l.aktiv })
const boxen = (e.ressourcen || []).map((r) => ({ r, input: h('input', { type: 'checkbox', value: r.id, checked: (l.ressourcen || []).includes(r.id) }) }))
const wer = h('fieldset', { class: 'tm-feld tm-gruppe-felder', 'data-feld': `${p}.ressourcen`, tabindex: '-1' },
h('legend', { class: 'tm-label' }, 'Wer führt die Leistung durch?'),
boxen.length
? boxen.map(({ r, input }) => h('label', { class: 'tm-haken' }, input, h('span', null, `${r.name} · ${r.typ === 'platz' ? 'Platz' : 'Person'}${r.aktiv ? '' : ' (inaktiv)'}`)))
: h('p', { class: 'tm-hinweis' }, 'Lege zuerst im Reiter „Team & Plätze“ eine Person oder einen Platz an.'),
h('p', { class: 'tm-fehler', 'data-fehler': `${p}.ressourcen`, role: 'alert' }))
const felder = [
feld(`${p}.name`, 'Name', name, 'So steht die Leistung beim Buchen.'),
feld(`${p}.beschreibung`, 'Beschreibung (optional)', beschreibung, `Ein bis zwei Sätze für deine Gäste. ${GESUNDHEIT_HINWEIS}`),
h('div', { class: 'tm-raster' },
feld(`${p}.dauer`, 'Dauer (Minuten)', dauer, '5 bis 480, in 5-Minuten-Schritten.'),
feld(`${p}.pufferNach`, 'Pause danach (Minuten)', puffer, 'Zeit danach, in der niemand gebucht werden kann. Gäste sehen sie nicht. Dauer und Pause zusammen höchstens 500 Minuten.')),
h('div', { class: 'tm-raster' },
feld(`${p}.preisArt`, 'Preis', preisArt),
feld(`${p}.preisCent`, 'Betrag in Euro', preis, 'Bei „Preis ab“ ist es der Mindestpreis.')),
feld(`${p}.buchungsart`, 'Buchung', art),
wer,
hakenFeld(`${p}.aktiv`, aktiv, 'Aktiv', 'Nur aktive Leistungen können Gäste buchen. Eine inaktive Leistung bleibt gespeichert.'),
]
const speichern = h('button', { type: 'submit', class: 'kb-btn-primary' }, neu ? 'Leistung anlegen' : 'Speichern')
const entfernenBox = h('div', { class: 'tm-frage' }) // die Rueckfrage steht neben dem Formular, nicht darin (ein submit steigt sonst ins aeussere Formular auf)
const entfernen = neu || ctx.nurLesen ? null : h('button', { type: 'button', class: 'kb-btn-outline tm-gefahr', onclick: () => frageEntfernen() }, 'Entfernen')
const form = h('form', { class: 'kb-panel tm-formular', novalidate: true, 'aria-label': neu ? 'Leistung anlegen' : `${ctx.nurLesen ? 'Leistung' : 'Leistung bearbeiten'}: ${l.name}` },
h('h3', null, neu ? 'Neue Leistung' : l.name),
gesperrt(ctx.nurLesen, felder),
allgemeinerFehler(),
h('div', { class: 'tm-frage-knoepfe' },
ctx.nurLesen ? null : speichern,
h('button', { type: 'button', class: 'kb-btn-outline', onclick: schliessen }, ctx.nurLesen ? 'Schließen' : 'Abbrechen'),
entfernen))
/** Leistung aus den Feldern; Eingaben, die kein Feld verstehen kann, werden hier am Feld gemeldet. */
function lese() {
const fehler = {}
const minuten = ganzzahl(dauer.value)
if (minuten === null) fehler[`${p}.dauer`] = 'Bitte gib die Dauer in ganzen Minuten an.'
const pause = puffer.value.trim() === '' ? 0 : ganzzahl(puffer.value)
if (pause === null) fehler[`${p}.pufferNach`] = 'Bitte gib die Pause in ganzen Minuten an (0 = keine Pause).'
let cent = 0
if (preisArt.value !== 'keiner') {
cent = euroZuCent(preis.value)
if (cent === null) fehler[`${p}.preisCent`] = 'Bitte gib den Preis in Euro an, zum Beispiel 45,00.'
}
if (Object.keys(fehler).length) return { fehler }
return {
leistung: {
...l, name: name.value, beschreibung: beschreibung.value, dauer: minuten, pufferNach: pause, preisArt: preisArt.value, preisCent: cent,
buchungsart: art.value, ressourcen: boxen.filter(({ input }) => input.checked).map(({ r }) => r.id), aktiv: aktiv.checked,
},
}
}
form.addEventListener('submit', async (ev) => {
ev.preventDefault()
const ok = await sp.speichere((entwurf) => {
const gelesen = lese()
if (gelesen.fehler) {
zeigeFehler(form, { fehler: gelesen.fehler })
return false
}
if (neu) entwurf.leistungen.push(gelesen.leistung)
else entwurf.leistungen[index] = gelesen.leistung
return true
}, { container: form, knopf: speichern })
if (!ok || !sp.nochHier()) return
zeichne()
ctx.meldung(neu ? 'Leistung angelegt.' : 'Leistung gespeichert.')
t.wurzel.querySelector(`li[data-id="${l.id}"] [data-aktion="bearbeiten"]`)?.focus()
})
function frageEntfernen() {
const box = entfernenFrage(sp, {
titel: `Leistung „${l.name}“ entfernen?`,
text: 'Das kann nicht rückgängig gemacht werden. Gibt es für die Leistung noch künftige Termine, geht das Entfernen nicht. Dann deaktiviere sie stattdessen.',
fehlerPfad: `leistungen.${l.id}`,
aendere: (entwurf) => { entwurf.leistungen = entwurf.leistungen.filter((x) => x.id !== l.id) },
nachher: () => { zeichne(); ctx.meldung(`Leistung „${l.name}“ entfernt.`) },
zurueck: () => { entfernenBox.replaceChildren(); entfernen.focus() },
})
entfernenBox.replaceChildren(box)
box.focus()
}
return h('div', { class: 'tm-editor' }, form, entfernenBox)
}
+239
View File
@@ -0,0 +1,239 @@
/* Terminplaner im Kundenbereich: Reiter "Team & Plaetze" – Personen und Plaetze mit Wochenplan (je Tag mehrere Zeitfenster, Text wie
„09:00–12:30, 13:30–18:00“, fensterParsen), Zuordnung der Leistungen, Kalender-Abo-Link (kopieren, neu erzeugen); darunter die Ausnahmen
(Abwesenheiten, Feiertage, Sonderoeffnungen: termine-team-ausnahmen.js). render(panel, ctx) ist der Einstieg (Registry REITER in termine.js).
Jede Aenderung speichert die ganze Einstellung (termine-speichern.js); die Zuordnung steht in der Einstellung bei den Leistungen
(leistungen[].ressourcen), eine neue Person und ihre Zuordnung gehen deshalb in EINEM Speichern raus. Tokens der Kalender-Links vergibt nur
der Server. Abgeschaltete Funktion (ctx.nurLesen): Felder gesperrt, keine Knoepfe zum Aendern, keine Links. */
import { LIMITS, MAX, WOCHENTAGE_PLAN, eindeutigeId, planAusTexten, planTexte } from './termine-view.js'
import { abschnitt, allgemeinerFehler, feld, frage, gesperrt, h, hakenFeld, knopfZeile, zeigeFehler } from './termine-dom.js'
import { entfernenFrage, neuerSpeicher } from './termine-speichern.js'
import { abwesenheiten, feiertage, sonderoeffnungen } from './termine-team-ausnahmen.js'
const TYPEN = [['person', 'Person'], ['platz', 'Platz']]
const zaehle = (n, eins, viele) => `${n} ${n === 1 ? eins : viele}`
export function render(panel, ctx) {
const wurzel = h('div', { class: 'tm-tab' })
panel.replaceChildren(wurzel)
const sp = neuerSpeicher(ctx, wurzel, zeichne)
const t = { ctx, sp, zeichne, wurzel }
function zeichne() {
wurzel.replaceChildren(sp.konflikt, personen(t), abwesenheiten(t), feiertage(t), sonderoeffnungen(t))
}
zeichne()
}
/* ----------------------------------------------------- Personen und Plaetze --- */
function personen(t) {
const { ctx } = t
const e = ctx.daten.einstellung
const liste = e.ressourcen || []
const voll = liste.length >= LIMITS.ressourcen
const neuBox = h('div', { class: 'tm-formular-box' })
const neuKnopf = ctx.nurLesen ? null : h('button', {
type: 'button', class: 'kb-btn-primary', 'data-werkzeug': 'ressource-neu', 'aria-expanded': 'false', disabled: voll,
onclick: () => {
if (neuBox.firstChild) { neuBox.replaceChildren(); neuKnopf.setAttribute('aria-expanded', 'false'); return }
neuKnopf.setAttribute('aria-expanded', 'true')
const editor = formular(t, { index: liste.length, r: null, schliessen: () => { neuBox.replaceChildren(); neuKnopf.setAttribute('aria-expanded', 'false'); neuKnopf.focus() } })
neuBox.replaceChildren(editor)
editor.querySelector('input')?.focus()
},
}, '+ Person oder Platz hinzufügen')
const reihe = (r, index) => {
const li = h('li', { class: 'tm-eintrag', 'data-id': r.id })
const editor = h('div', { class: 'tm-editor-box' })
const leistungen = (e.leistungen || []).filter((l) => (l.ressourcen || []).includes(r.id)).length
const tage = Object.keys(r.plan || {}).length
const oeffnen = h('button', {
type: 'button', class: 'kb-btn-outline tm-knopf', 'data-aktion': 'bearbeiten', 'aria-expanded': 'false', 'aria-label': `${ctx.nurLesen ? 'Ansehen' : 'Bearbeiten'}: ${r.name}`,
onclick: () => {
if (editor.firstChild) { editor.replaceChildren(); oeffnen.setAttribute('aria-expanded', 'false'); return }
oeffnen.setAttribute('aria-expanded', 'true')
const neuerEditor = formular(t, { index, r, schliessen: () => { editor.replaceChildren(); oeffnen.setAttribute('aria-expanded', 'false'); oeffnen.focus() } })
editor.replaceChildren(neuerEditor)
neuerEditor.querySelector('input')?.focus()
},
}, ctx.nurLesen ? 'Ansehen' : 'Bearbeiten')
li.append(
h('div', { class: 'tm-kopf' },
h('div', { class: 'tm-haupt' },
h('strong', { class: 'tm-name' }, r.name),
h('span', { class: 'tm-meta' }, [r.typ === 'platz' ? 'Platz' : 'Person', tage ? `Zeiten an ${zaehle(tage, 'Tag', 'Tagen')}` : 'keine Zeiten', leistungen ? zaehle(leistungen, 'Leistung', 'Leistungen') : 'keine Leistung'].join(' · '))),
h('span', { class: `tm-pille tm-pille-${r.aktiv ? 'ok' : 'off'}` }, r.aktiv ? 'Aktiv' : 'Inaktiv')),
h('div', { class: 'tm-aktionen' }, oeffnen),
editor)
return li
}
return abschnitt('Personen und Plätze',
'Wer oder was kann gebucht werden? Jede Person und jeder Platz hat eigene Zeiten und bietet bestimmte Leistungen an. Die Leistungen selbst legst du im Reiter „Leistungen“ an.',
ctx.nurLesen ? null : h('div', { class: 'tm-werkzeug-zeile' }, neuKnopf, voll ? h('p', { class: 'tm-hinweis' }, `Mehr als ${LIMITS.ressourcen} Personen oder Plätze sind nicht möglich.`) : null),
neuBox,
liste.length
? h('ul', { class: 'tm-eintraege', 'aria-label': 'Personen und Plätze' }, liste.map(reihe))
: h('p', { class: 'tm-leer' }, 'Noch keine Person und kein Platz. Ohne sie kann niemand buchen.'))
}
/**
* Editor einer neuen (r null) oder bestehenden Person/eines Platzes: Formular, darunter (nur bestehend) Kalender-Link und die Rueckfrage zum
* Entfernen. Die Rueckfragen stehen neben dem Formular, nicht darin: ein submit der Rueckfrage steigt sonst ins aeussere Formular auf.
* index = Stelle in ressourcen (so heissen die Pfade der Fehler des Servers).
*/
function formular(t, { index, r, schliessen }) {
const { ctx, sp, zeichne } = t
const e = ctx.daten.einstellung
const neu = r === null
const alt = r || { id: eindeutigeId('r', (e.ressourcen || []).map((x) => x.id)), typ: 'person', name: '', aktiv: true, plan: {} }
const p = `ressourcen.${index}`
const leistungen = e.leistungen || []
const name = h('input', { type: 'text', maxlength: MAX.ressourcenName, autocomplete: 'off', value: alt.name })
const typ = h('select', null, TYPEN.map(([wert, text]) => h('option', { value: wert, selected: alt.typ === wert }, text)))
const aktiv = h('input', { type: 'checkbox', checked: alt.aktiv })
const texte = planTexte(alt.plan)
const tage = WOCHENTAGE_PLAN.map(([nr, tag]) => ({
nr, name: `${p}.plan.${nr}`,
input: h('input', { type: 'text', autocomplete: 'off', placeholder: 'geschlossen', value: texte[nr] }),
tag,
}))
const zuordnung = leistungen.map((l, k) => ({ id: l.id, input: h('input', { type: 'checkbox', checked: (l.ressourcen || []).includes(alt.id) }), name: `leistungen.${k}.ressourcen`, label: `${l.name}${l.aktiv ? '' : ' (inaktiv)'}` }))
const speichern = h('button', { type: 'submit', class: 'kb-btn-primary' }, neu ? 'Hinzufügen' : 'Speichern')
const frageBox = h('div', { class: 'tm-frage' })
const entfernen = neu || ctx.nurLesen ? null : h('button', { type: 'button', class: 'kb-btn-outline tm-gefahr', onclick: () => fragen() }, 'Entfernen')
const form = h('form', { class: 'kb-panel tm-formular', novalidate: true, 'aria-label': neu ? 'Person oder Platz hinzufügen' : `${ctx.nurLesen ? 'Person oder Platz' : 'Bearbeiten'}: ${alt.name}` },
h('h3', null, neu ? 'Neue Person oder neuer Platz' : alt.name),
gesperrt(ctx.nurLesen, [
h('div', { class: 'tm-raster' },
feld(`${p}.name`, 'Name', name, 'So steht es beim Buchen, wenn Gäste eine Person wählen dürfen.'),
feld(`${p}.typ`, 'Art', typ)),
hakenFeld(`${p}.aktiv`, aktiv, 'Aktiv', 'Nur aktive Personen und Plätze können gebucht werden. Termine, die schon gebucht sind, bleiben bei „Inaktiv“ bestehen.'),
h('fieldset', { class: 'tm-gruppe-felder tm-wochenplan' },
h('legend', { class: 'tm-label' }, 'Wochenplan'),
h('p', { class: 'tm-hinweis' }, 'Wann kann gebucht werden? Mehrere Zeitfenster trennst du mit Komma, zum Beispiel 09:00–12:30, 13:30–18:00. Leer lassen: an diesem Tag geschlossen. Uhrzeiten in 5-Minuten-Schritten, das Ende bis 24:00.'),
h('div', { class: 'tm-raster' }, tage.map(({ name: feldName, input, tag }) => feld(feldName, tag, input)))),
h('fieldset', { class: 'tm-gruppe-felder', tabindex: '-1' },
h('legend', { class: 'tm-label' }, 'Diese Leistungen bietet sie an'),
zuordnung.length
? zuordnung.map(({ input, name: feldName, label }) => hakenFeld(feldName, input, label))
: h('p', { class: 'tm-hinweis' }, 'Es gibt noch keine Leistung. Lege sie im Reiter „Leistungen“ an und ordne sie danach hier zu.')),
]),
allgemeinerFehler(),
h('div', { class: 'tm-frage-knoepfe' },
ctx.nurLesen ? null : speichern,
h('button', { type: 'button', class: 'kb-btn-outline', onclick: schliessen }, ctx.nurLesen ? 'Schließen' : 'Abbrechen'),
entfernen))
form.addEventListener('submit', async (ev) => {
ev.preventDefault()
const ok = await sp.speichere((entwurf) => {
const gelesen = planAusTexten(Object.fromEntries(tage.map(({ nr, input }) => [nr, input.value])))
if (gelesen.fehler) {
zeigeFehler(form, { fehler: Object.fromEntries(Object.entries(gelesen.fehler).map(([nr, text]) => [`${p}.plan.${nr}`, text])) })
return false
}
const ressource = { ...alt, name: name.value, typ: typ.value, aktiv: aktiv.checked, plan: gelesen.plan }
if (neu) entwurf.ressourcen.push(ressource)
else entwurf.ressourcen[index] = ressource
// die Zuordnung steht bei den Leistungen: genau diese Person/diesen Platz bei jeder Leistung ein- oder austragen
for (const { id, input } of zuordnung) {
const leistung = entwurf.leistungen.find((l) => l.id === id)
if (!leistung) continue
const ids = (leistung.ressourcen || []).filter((x) => x !== alt.id)
leistung.ressourcen = input.checked ? [...ids, alt.id] : ids
}
return true
}, { container: form, knopf: speichern })
if (!ok || !sp.nochHier()) return
zeichne()
ctx.meldung(neu ? `„${name.value.trim()}“ ist hinzugefügt.` : 'Gespeichert.')
t.wurzel.querySelector(`li[data-id="${alt.id}"] [data-aktion="bearbeiten"]`)?.focus()
})
function fragen() {
const box = entfernenFrage(sp, {
titel: `„${alt.name}“ entfernen?`,
text: 'Die Zeiten, die Zuordnung zu den Leistungen und die Abwesenheiten gehen mit verloren. Gibt es noch künftige Termine, geht das Entfernen nicht. Dann setze sie auf „Inaktiv“.',
fehlerPfad: `ressourcen.${alt.id}`,
aendere: (entwurf) => {
entwurf.ressourcen = entwurf.ressourcen.filter((x) => x.id !== alt.id)
for (const l of entwurf.leistungen) l.ressourcen = (l.ressourcen || []).filter((id) => id !== alt.id)
entwurf.abwesenheiten = (entwurf.abwesenheiten || []).filter((a) => a.ressourceId !== alt.id)
},
nachher: () => { zeichne(); ctx.meldung(`„${alt.name}“ entfernt.`) },
zurueck: () => { frageBox.replaceChildren(); entfernen.focus() },
})
frageBox.replaceChildren(box)
box.focus()
}
return h('div', { class: 'tm-editor' }, form, neu || ctx.nurLesen ? null : kalenderLink(t, alt), frageBox)
}
/* ---------------------------------------------------------- Kalender-Abo --- */
/** Kalender-Link einer gespeicherten Person/eines Platzes: anzeigen, kopieren, neu erzeugen (der Server vergibt das Token). */
function kalenderLink(t, r) {
const { ctx } = t
const url = ctx.daten.feeds?.[r.id]
const kopf = [
h('h4', null, 'Kalender-Abo'),
h('p', { class: 'tm-hinweis' }, 'Mit diesem Link zeigt dein Kalender (Google, Apple, Outlook) die bestätigten Termine automatisch an. Füge ihn dort als Kalender-Abo ein („Kalender abonnieren“ oder „Per URL hinzufügen“), nicht als Datei. Google aktualisiert abonnierte Kalender nur alle paar Stunden, neue Termine erscheinen dort deshalb mit Verspätung.'),
]
if (!url) return h('div', { class: 'tm-feed' }, kopf, h('p', { class: 'tm-hinweis' }, 'Der Link erscheint, sobald die Seite neu geladen ist.'))
const eingabe = h('input', { type: 'text', readonly: true, class: 'tm-link-feld', value: url, 'aria-label': `Kalender-Link für ${r.name}`, spellcheck: 'false' })
eingabe.addEventListener('focus', () => eingabe.select())
const status = h('p', { class: 'tm-hinweis', role: 'status' })
const frageBox = h('div', { class: 'tm-frage' })
async function kopiere() {
try {
await navigator.clipboard.writeText(eingabe.value)
status.textContent = 'Der Link ist kopiert.'
} catch {
// kein Zugriff auf die Zwischenablage (Browser, Einstellung): der Link bleibt sichtbar und markiert zum Kopieren von Hand
eingabe.focus()
eingabe.select()
status.textContent = 'Das Kopieren hat nicht geklappt. Der Link ist markiert: drücke Strg+C (Mac: Cmd+C), am Handy halte ihn gedrückt und wähle „Kopieren“.'
}
}
function fragen(ausloeser) {
const { ok, zeile } = knopfZeile('Neuen Link erzeugen', () => { frageBox.replaceChildren(); ausloeser.focus() })
const box = frage('Neuen Kalender-Link erzeugen?',
h('p', null, 'Der bisherige Link funktioniert danach nicht mehr. Kalender, die ihn abonniert haben, zeigen keine neuen Termine mehr, bis du dort den neuen Link einträgst. Tu das, wenn der Link in falsche Hände geraten ist.'),
allgemeinerFehler(),
zeile)
box.addEventListener('submit', async () => {
ok.disabled = true
try {
const antwort = await ctx.api.send('POST', `/ressourcen/${encodeURIComponent(r.id)}/feed`)
if (!ctx.aktuell() || !eingabe.isConnected) return
ctx.daten.version = antwort.version // das Erzeugen zaehlt die Version hoch: das naechste Speichern baut darauf auf
ctx.daten.feeds = { ...ctx.daten.feeds, [r.id]: antwort.url }
eingabe.value = antwort.url
frageBox.replaceChildren()
status.textContent = 'Der neue Link ist da. Der alte funktioniert nicht mehr.'
ctx.meldung(`Neuer Kalender-Link für „${r.name}“ erzeugt.`)
ausloeser.focus()
} catch (err) {
if (!ctx.aktuell() || !eingabe.isConnected) return
ok.disabled = false
zeigeFehler(box, err)
}
})
frageBox.replaceChildren(box)
box.focus()
}
const neuKnopf = h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'link-neu', onclick: (ev) => fragen(ev.currentTarget) }, 'Neuen Link erzeugen')
return h('div', { class: 'tm-feed' },
kopf,
h('p', { class: 'tm-hinweis' }, 'Der Link ist geheim: Wer ihn kennt, sieht die bestätigten Termine mit den Namen der Gäste. Gib ihn nicht weiter.'),
eingabe,
h('div', { class: 'tm-frage-knoepfe' }, h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'link-kopieren', onclick: kopiere }, 'Link kopieren'), neuKnopf),
status,
frageBox)
}
+2 -2
View File
@@ -92,8 +92,8 @@ async function ladeListe(t, { auchZahl = false } = {}) {
const { s } = ctx
const nr = ++t.nr
const anfragen = t.auswahl.ansicht === 'anfragen'
// Offene Anfragen: alle bis zum Ende des Buchungszeitraums; die Schnittstelle liefert hoechstens 62 Tage je Aufruf, darum mehrere Fenster
const { von, bis } = bereichFuer(t.auswahl.ansicht, s.tag, heuteBerlin(), ctx.daten.einstellung?.regeln?.horizontTage)
// Offene Anfragen: ab heute 365 Tage (Ruling R50); die Schnittstelle liefert hoechstens 62 Tage je Aufruf, darum mehrere Fenster
const { von, bis } = bereichFuer(t.auswahl.ansicht, s.tag, heuteBerlin())
const fenster = anfragen ? fensterAufteilen(von, bis) : [{ von, bis }]
const status = anfragen ? 'angefragt' : filterStatus(t.auswahl.filter)
t.liste.setAttribute('aria-busy', 'true')
+249
View File
@@ -0,0 +1,249 @@
/* Terminplaner im Kundenbereich: Reiter "Team & Plaetze", die Abschnitte fuer Ausnahmen vom Wochenplan – Abwesenheiten und Urlaub,
Feiertage (Bundesland) und Sonderoeffnungen. Jeder Abschnitt ist eine Funktion (t) => Element; t = { ctx, sp, zeichne, wurzel } kommt
aus termine-tab-team.js (sp = Speicher des Reiters, zeichne() zeichnet den Reiter neu). Jede Aenderung speichert die ganze Einstellung
(termine-speichern.js). Abgeschaltete Funktion (ctx.nurLesen): dieselben Felder gesperrt, ohne Knoepfe zum Aendern. */
import { BUNDESLAENDER, FENSTER_FEHLER, LIMITS, MAX, datumKurz, datumLang, eindeutigeId, fensterParsen, fensterText, heuteBerlin, istTag } from './termine-view.js'
import { abschnitt, allgemeinerFehler, feld, gesperrt, h, hakenFeld, zeigeFehler } from './termine-dom.js'
import { entfernenFrage } from './termine-speichern.js'
const nachBeginn = (x, y) => (x.von < y.von ? -1 : x.von > y.von ? 1 : x.bis < y.bis ? -1 : x.bis > y.bis ? 1 : 0)
/* ------------------------------------------------- Abwesenheiten, Urlaub --- */
export function abwesenheiten(t) {
const { ctx } = t
const e = ctx.daten.einstellung
const heute = heuteBerlin()
const alle = (e.abwesenheiten || []).map((a, index) => ({ ...a, index }))
const kommend = alle.filter((a) => a.bis >= heute).sort(nachBeginn)
const vorbei = alle.filter((a) => a.bis < heute).sort(nachBeginn).reverse()
const neuBox = h('div', { class: 'tm-formular-box' })
const voll = alle.length >= LIMITS.abwesenheiten
const neuKnopf = ctx.nurLesen ? null : h('button', {
type: 'button', class: 'kb-btn-outline', 'data-werkzeug': 'abwesenheit-neu', 'aria-expanded': 'false', disabled: voll,
onclick: () => {
if (neuBox.firstChild) { neuBox.replaceChildren(); neuKnopf.setAttribute('aria-expanded', 'false'); return }
neuKnopf.setAttribute('aria-expanded', 'true')
const form = formular(t, { index: alle.length, a: null, schliessen: () => { neuBox.replaceChildren(); neuKnopf.setAttribute('aria-expanded', 'false'); neuKnopf.focus() } })
neuBox.replaceChildren(form)
form.querySelector('select, input')?.focus()
},
}, '+ Abwesenheit eintragen')
const wer = (a) => (a.ressourceId ? (e.ressourcen || []).find((r) => r.id === a.ressourceId)?.name || 'Unbekannt' : 'Ganzer Betrieb')
const reihe = (a) => {
const li = h('li', { class: 'tm-eintrag', 'data-id': a.id })
const editor = h('div', { class: 'tm-editor-box' })
const oeffnen = h('button', {
type: 'button', class: 'kb-btn-outline tm-knopf', 'data-aktion': 'bearbeiten', 'aria-expanded': 'false',
'aria-label': `${ctx.nurLesen ? 'Ansehen' : 'Bearbeiten'}: ${a.titel || 'Abwesenheit'}, ${datumKurz(a.von)}`,
onclick: () => {
if (editor.firstChild) { editor.replaceChildren(); oeffnen.setAttribute('aria-expanded', 'false'); return }
oeffnen.setAttribute('aria-expanded', 'true')
const form = formular(t, { index: a.index, a, schliessen: () => { editor.replaceChildren(); oeffnen.setAttribute('aria-expanded', 'false'); oeffnen.focus() } })
editor.replaceChildren(form)
form.querySelector('select, input')?.focus()
},
}, ctx.nurLesen ? 'Ansehen' : 'Bearbeiten')
li.append(
h('div', { class: 'tm-kopf' },
h('div', { class: 'tm-haupt' },
h('strong', { class: 'tm-name' }, a.von === a.bis ? datumKurz(a.von) : `${datumKurz(a.von)} – ${datumKurz(a.bis)}`),
h('span', { class: 'tm-meta' }, [wer(a), a.titel].filter(Boolean).join(' · ')))),
h('div', { class: 'tm-aktionen' }, oeffnen),
editor)
return li
}
return abschnitt('Abwesenheiten und Urlaub',
'In dieser Zeit kann niemand neu buchen, für eine Person, einen Platz oder den ganzen Betrieb. Termine, die schon gebucht sind, bleiben bestehen. Sag sie bei Bedarf selbst ab.',
ctx.nurLesen ? null : h('div', { class: 'tm-werkzeug-zeile' }, neuKnopf, voll ? h('p', { class: 'tm-hinweis' }, `Mehr als ${LIMITS.abwesenheiten} Abwesenheiten sind nicht möglich.`) : null),
neuBox,
kommend.length ? h('ul', { class: 'tm-eintraege', 'aria-label': 'Abwesenheiten' }, kommend.map(reihe)) : h('p', { class: 'tm-leer' }, 'Keine Abwesenheiten eingetragen.'),
vorbei.length
? h('details', { class: 'tm-details' }, h('summary', null, `Vergangene Abwesenheiten (${vorbei.length})`), h('ul', { class: 'tm-eintraege' }, vorbei.map(reihe)))
: null)
}
function formular(t, { index, a, schliessen }) {
const { ctx, sp, zeichne } = t
const e = ctx.daten.einstellung
const neu = a === null
const alt = a || { id: eindeutigeId('a', (e.abwesenheiten || []).map((x) => x.id)), ressourceId: null, von: heuteBerlin(), bis: heuteBerlin(), titel: '' }
const p = `abwesenheiten.${index}`
const wer = h('select', null,
h('option', { value: '', selected: !alt.ressourceId }, 'Ganzer Betrieb'),
(e.ressourcen || []).map((r) => h('option', { value: r.id, selected: alt.ressourceId === r.id }, r.name)))
const von = h('input', { type: 'date', value: alt.von })
const bis = h('input', { type: 'date', value: alt.bis })
const titel = h('input', { type: 'text', maxlength: MAX.abwesenheitTitel, autocomplete: 'off', placeholder: 'z. B. Betriebsurlaub', value: alt.titel })
const speichern = h('button', { type: 'submit', class: 'kb-btn-primary' }, neu ? 'Abwesenheit eintragen' : 'Speichern')
const frageBox = h('div', { class: 'tm-frage' })
const entfernen = neu || ctx.nurLesen ? null : h('button', { type: 'button', class: 'kb-btn-outline tm-gefahr', onclick: () => fragen() }, 'Entfernen')
const form = h('form', { class: 'kb-panel tm-formular', novalidate: true, 'aria-label': neu ? 'Abwesenheit eintragen' : 'Abwesenheit bearbeiten' },
h('h3', null, neu ? 'Neue Abwesenheit' : 'Abwesenheit'),
gesperrt(ctx.nurLesen, [
feld(`${p}.ressourceId`, 'Wer ist weg?', wer),
h('div', { class: 'tm-raster' }, feld(`${p}.von`, 'Von (erster Tag)', von), feld(`${p}.bis`, 'Bis (letzter Tag)', bis)),
feld(`${p}.titel`, 'Titel (optional)', titel, 'Nur für dich sichtbar.'),
]),
allgemeinerFehler(),
h('div', { class: 'tm-frage-knoepfe' },
ctx.nurLesen ? null : speichern,
h('button', { type: 'button', class: 'kb-btn-outline', onclick: schliessen }, ctx.nurLesen ? 'Schließen' : 'Abbrechen'),
entfernen))
form.addEventListener('submit', async (ev) => {
ev.preventDefault()
const ok = await sp.speichere((entwurf) => {
const eintrag = { ...alt, ressourceId: wer.value || null, von: von.value, bis: bis.value, titel: titel.value }
if (neu) entwurf.abwesenheiten = [...(entwurf.abwesenheiten || []), eintrag]
else entwurf.abwesenheiten[index] = eintrag
}, { container: form, knopf: speichern })
if (!ok || !sp.nochHier()) return
zeichne()
ctx.meldung(neu ? 'Abwesenheit eingetragen.' : 'Abwesenheit gespeichert.')
})
function fragen() {
const box = entfernenFrage(sp, {
titel: 'Abwesenheit entfernen?',
text: 'In dieser Zeit kann danach wieder gebucht werden.',
aendere: (entwurf) => { entwurf.abwesenheiten = entwurf.abwesenheiten.filter((x) => x.id !== alt.id) },
nachher: () => { zeichne(); ctx.meldung('Abwesenheit entfernt.') },
zurueck: () => { frageBox.replaceChildren(); entfernen.focus() },
})
frageBox.replaceChildren(box)
box.focus()
}
return h('div', { class: 'tm-editor' }, form, frageBox)
}
/* -------------------------------------------------------------- Feiertage --- */
export function feiertage(t) {
const { ctx, sp, zeichne } = t
const e = ctx.daten.einstellung
const land = h('select', null, BUNDESLAENDER.map(([key, name]) => h('option', { value: key, selected: e.bundesland === key }, name)))
const zu = h('input', { type: 'checkbox', checked: e.feiertageGeschlossen !== false })
const speichern = h('button', { type: 'submit', class: 'kb-btn-primary' }, 'Speichern')
const form = h('form', { novalidate: true, 'aria-label': 'Feiertage' },
gesperrt(ctx.nurLesen, [
feld('bundesland', 'Bundesland', land, 'Danach richten sich die gesetzlichen Feiertage.'),
hakenFeld('feiertageGeschlossen', zu, 'An gesetzlichen Feiertagen geschlossen', 'Dann kann an Feiertagen niemand buchen. Eine Sonderöffnung (unten) geht vor.'),
]),
allgemeinerFehler(),
ctx.nurLesen ? null : h('div', { class: 'tm-frage-knoepfe' }, speichern))
form.addEventListener('submit', async (ev) => {
ev.preventDefault()
const ok = await sp.speichere((entwurf) => { entwurf.bundesland = land.value; entwurf.feiertageGeschlossen = zu.checked }, { container: form, knopf: speichern })
if (!ok || !sp.nochHier()) return
zeichne()
ctx.meldung('Feiertage gespeichert.')
})
return abschnitt('Feiertage', null, form)
}
/* ------------------------------------------------------- Sonderöffnungen --- */
export function sonderoeffnungen(t) {
const { ctx, sp, zeichne } = t
const e = ctx.daten.einstellung
const tage = Object.keys(e.sonderoeffnungen || {}).sort()
const neuBox = h('div', { class: 'tm-formular-box' })
const voll = tage.length >= LIMITS.sonderoeffnungen
const neuKnopf = ctx.nurLesen ? null : h('button', {
type: 'button', class: 'kb-btn-outline', 'data-werkzeug': 'sonder-neu', 'aria-expanded': 'false', disabled: voll,
onclick: () => {
if (neuBox.firstChild) { neuBox.replaceChildren(); neuKnopf.setAttribute('aria-expanded', 'false'); return }
neuKnopf.setAttribute('aria-expanded', 'true')
const form = sonderFormular(t, { tag: null, schliessen: () => { neuBox.replaceChildren(); neuKnopf.setAttribute('aria-expanded', 'false'); neuKnopf.focus() } })
neuBox.replaceChildren(form)
form.querySelector('input')?.focus()
},
}, '+ Sonderöffnung eintragen')
const reihe = (tag) => {
const li = h('li', { class: 'tm-eintrag', 'data-tag': tag })
const editor = h('div', { class: 'tm-editor-box' })
const zeiten = fensterText(e.sonderoeffnungen[tag])
const oeffnen = h('button', {
type: 'button', class: 'kb-btn-outline tm-knopf', 'data-aktion': 'bearbeiten', 'aria-expanded': 'false',
'aria-label': `${ctx.nurLesen ? 'Ansehen' : 'Bearbeiten'}: Sonderöffnung ${datumKurz(tag)}`,
onclick: () => {
if (editor.firstChild) { editor.replaceChildren(); oeffnen.setAttribute('aria-expanded', 'false'); return }
oeffnen.setAttribute('aria-expanded', 'true')
const form = sonderFormular(t, { tag, schliessen: () => { editor.replaceChildren(); oeffnen.setAttribute('aria-expanded', 'false'); oeffnen.focus() } })
editor.replaceChildren(form)
form.querySelector('input')?.focus()
},
}, ctx.nurLesen ? 'Ansehen' : 'Bearbeiten')
li.append(
h('div', { class: 'tm-kopf' }, h('div', { class: 'tm-haupt' },
h('strong', { class: 'tm-name' }, datumLang(tag)),
h('span', { class: 'tm-meta' }, zeiten || 'Geschlossen'))),
h('div', { class: 'tm-aktionen' }, oeffnen),
editor)
return li
}
return abschnitt('Sonderöffnungen',
'Für einzelne Tage, an denen es anders ist als im Wochenplan: Heiligabend nur bis 13 Uhr, ein Sonntag mit Öffnung oder ein Brückentag mit geschlossener Tür. Das gilt für alle Personen und Plätze und gehen dem Wochenplan und den Feiertagen vor.',
ctx.nurLesen ? null : h('div', { class: 'tm-werkzeug-zeile' }, neuKnopf, voll ? h('p', { class: 'tm-hinweis' }, `Mehr als ${LIMITS.sonderoeffnungen} Sonderöffnungen sind nicht möglich.`) : null),
neuBox,
tage.length ? h('ul', { class: 'tm-eintraege', 'aria-label': 'Sonderöffnungen' }, tage.map(reihe)) : h('p', { class: 'tm-leer' }, 'Keine Sonderöffnungen eingetragen.'))
}
/** Formular fuer einen neuen (tag null: mit Datum) oder bestehenden Tag (Datum steht fest; wer den Tag aendern will, entfernt ihn und legt einen neuen an). */
function sonderFormular(t, { tag, schliessen }) {
const { ctx, sp, zeichne } = t
const e = ctx.daten.einstellung
const neu = tag === null
const datum = h('input', { type: 'date', value: heuteBerlin() })
const zeiten = h('input', { type: 'text', autocomplete: 'off', placeholder: '09:00–13:00', value: neu ? '' : fensterText(e.sonderoeffnungen[tag]) })
const speichern = h('button', { type: 'submit', class: 'kb-btn-primary' }, neu ? 'Sonderöffnung eintragen' : 'Speichern')
const frageBox = h('div', { class: 'tm-frage' })
const entfernen = neu || ctx.nurLesen ? null : h('button', { type: 'button', class: 'kb-btn-outline tm-gefahr', onclick: () => fragen() }, 'Entfernen')
const datumFeld = feld('datum', 'Tag', datum)
const zeitenFeld = feld('zeiten', 'Geöffnet von – bis', zeiten, 'Mehrere Zeitfenster mit Komma trennen: 09:00–12:00, 14:00–18:00. Leer lassen: an diesem Tag geschlossen.')
const zeitenFehler = zeitenFeld.querySelector('[data-fehler]')
// Fehler des Servers heissen 'sonderoeffnungen.<Tag>': beim Absenden nimmt das Feld der Zeiten diesen Namen an
const zeitenName = (name) => { zeitenFehler.dataset.fehler = name; zeiten.dataset.feld = name }
const form = h('form', { class: 'kb-panel tm-formular', novalidate: true, 'aria-label': neu ? 'Sonderöffnung eintragen' : `Sonderöffnung ${datumKurz(tag)}` },
h('h3', null, neu ? 'Neue Sonderöffnung' : datumLang(tag)),
gesperrt(ctx.nurLesen, [neu ? datumFeld : null, zeitenFeld]),
allgemeinerFehler(),
h('div', { class: 'tm-frage-knoepfe' },
ctx.nurLesen ? null : speichern,
h('button', { type: 'button', class: 'kb-btn-outline', onclick: schliessen }, ctx.nurLesen ? 'Schließen' : 'Abbrechen'),
entfernen))
form.addEventListener('submit', async (ev) => {
ev.preventDefault()
const ok = await sp.speichere((entwurf) => {
const tagWert = neu ? datum.value : tag
zeitenName('zeiten')
const fehler = {}
if (!istTag(tagWert)) fehler.datum = 'Bitte wähle einen Tag.'
else if (neu && Object.hasOwn(entwurf.sonderoeffnungen || {}, tagWert)) fehler.datum = 'Für diesen Tag gibt es schon eine Sonderöffnung. Bearbeite sie in der Liste.'
const fenster = fensterParsen(zeiten.value)
if (fenster === null) fehler.zeiten = FENSTER_FEHLER
if (Object.keys(fehler).length) {
zeigeFehler(form, { fehler })
return false
}
zeitenName(`sonderoeffnungen.${tagWert}`)
entwurf.sonderoeffnungen = { ...(entwurf.sonderoeffnungen || {}), [tagWert]: fenster }
return true
}, { container: form, knopf: speichern })
if (!ok || !sp.nochHier()) return
zeichne()
ctx.meldung(neu ? 'Sonderöffnung eingetragen.' : 'Sonderöffnung gespeichert.')
})
function fragen() {
const box = entfernenFrage(sp, {
titel: `Sonderöffnung am ${datumKurz(tag)} entfernen?`,
text: 'Danach gilt an diesem Tag wieder der Wochenplan (und der Feiertag, falls einer ist).',
aendere: (entwurf) => { const rest = { ...entwurf.sonderoeffnungen }; delete rest[tag]; entwurf.sonderoeffnungen = rest },
nachher: () => { zeichne(); ctx.meldung('Sonderöffnung entfernt.') },
zurueck: () => { frageBox.replaceChildren(); entfernen.focus() },
})
frageBox.replaceChildren(box)
box.focus()
}
return h('div', { class: 'tm-editor' }, form, frageBox)
}
+176 -10
View File
@@ -10,8 +10,16 @@ const TAG_MS = 24 * 60 * 60 * 1000
const MAX_TAGE = 62
const GELOESCHT = 'Gelöscht'
/** Laengen der Eingaben, wie der Server sie pruefen wird (ablauf.js, ablaufBetrieb.js). */
export const MAX = { name: 100, telefon: 40, nachricht: 500, titel: 120 }
/**
* Laengen der Eingaben (maxlength der Felder): name/nachricht/titel/telefon fuer Gaeste und Sperren (ablauf.js, ablaufBetrieb.js),
* der Rest fuer die Einstellungen (einstellungen.js kuerzt laengere Texte stillschweigend; url ist eine Obergrenze mit Fehler).
* telefon gilt auch fuer das Telefon des Betriebs. test/termineView.test.js vergleicht sie mit dem Server.
*/
export const MAX = {
name: 100, telefon: 40, nachricht: 500, titel: 120,
betriebName: 80, adresse: 200, url: 300, leistungName: 100, beschreibung: 300, ressourcenName: 80, abwesenheitTitel: 80,
zusatzLabel: 60, option: 60, storno: 2000,
}
/* ------------------------------------------------------------ Kalender --- */
function utc(tag) {
@@ -40,17 +48,14 @@ export function monatVon(tag) {
}
/**
* Zeitraum der Liste. ansicht: 'tag' | 'woche' | 'monat' | 'anfragen' (ab heute bis zum Ende des Buchungszeitraums horizontTage
* der Einstellung, Vorgabe 60, hoechstens 365). Fuer 'anfragen' ist der Zeitraum laenger als ein Aufruf der Schnittstelle:
* fensterAufteilen macht daraus die einzelnen Abfragen.
* Zeitraum der Liste. ansicht: 'tag' | 'woche' | 'monat' | 'anfragen' (ab heute 365 Tage, unabhaengig vom Buchungszeitraum der
* Einstellung: auch eine Anfrage, die vor einer spaeteren Verkuerzung des Zeitraums kam, soll in der Liste stehen). Fuer 'anfragen'
* ist der Zeitraum laenger als ein Aufruf der Schnittstelle: fensterAufteilen macht daraus die einzelnen Abfragen (hoechstens 6).
*/
export function bereichFuer(ansicht, tag, heute, horizontTage) {
export function bereichFuer(ansicht, tag, heute) {
if (ansicht === 'woche') return wocheVon(tag)
if (ansicht === 'monat') return monatVon(tag)
if (ansicht === 'anfragen') {
const horizont = Number.isInteger(horizontTage) && horizontTage >= 1 ? Math.min(horizontTage, 365) : 60
return { von: heute, bis: tagPlus(heute, horizont) }
}
if (ansicht === 'anfragen') return { von: heute, bis: tagPlus(heute, 365) }
return { von: tag, bis: tag }
}
@@ -279,3 +284,164 @@ export function moeglicheAktionen(buchung, jetzt) {
if (b.status !== 'gehalten' && hatGastdaten(b)) aktionen.push('anonymisieren')
return aktionen
}
/* ------------------------------------------- Einstellungen bearbeiten (21b) --- */
/** Hinweis ueber Namensfeldern, die Gaeste lesen (Wortlaut GESUNDHEIT_HINWEIS in server/services/termine/rechtstexte.js). */
export const GESUNDHEIT_HINWEIS = 'Bitte keine Gesundheitsangaben.'
/** Obergrenzen der Listen (GRENZEN in einstellungen.js); der Server prueft sie noch einmal. */
export const LIMITS = { ressourcen: 20, leistungen: 50, abwesenheiten: 100, sonderoeffnungen: 200, zusatzfelder: 5, optionen: 10 }
export const RASTER_MIN = [5, 10, 15, 20, 30, 60]
export const ERINNERUNG_STUNDEN = [2, 12, 24, 48]
export const GAST_FELD_WAHL = [['aus', 'Aus'], ['optional', 'Optional'], ['pflicht', 'Pflicht']]
export const ZAHLARTEN = [['bar', 'bar'], ['ec', 'EC-Karte'], ['kreditkarte', 'Kreditkarte'], ['ueberweisung', 'Überweisung'], ['paypal', 'PayPal']]
export const BUNDESLAENDER = [
['BW', 'Baden-Württemberg'], ['BY', 'Bayern'], ['BE', 'Berlin'], ['BB', 'Brandenburg'], ['HB', 'Bremen'], ['HH', 'Hamburg'], ['HE', 'Hessen'],
['MV', 'Mecklenburg-Vorpommern'], ['NI', 'Niedersachsen'], ['NW', 'Nordrhein-Westfalen'], ['RP', 'Rheinland-Pfalz'], ['SL', 'Saarland'],
['SN', 'Sachsen'], ['ST', 'Sachsen-Anhalt'], ['SH', 'Schleswig-Holstein'], ['TH', 'Thüringen'],
]
/* ----------------------------------------------------------- Wochenplan --- */
/** ISO-Wochentage wie in plan der Einstellung: '1' = Montag … '7' = Sonntag. */
export const WOCHENTAGE_PLAN = [['1', 'Montag'], ['2', 'Dienstag'], ['3', 'Mittwoch'], ['4', 'Donnerstag'], ['5', 'Freitag'], ['6', 'Samstag'], ['7', 'Sonntag']]
export const FENSTER_FEHLER = 'Bitte so schreiben: 09:00–12:30, 13:30–18:00 (5-Minuten-Schritte, aufsteigend, ohne Überlappung, Ende bis 24:00).'
/** plan { '1': [['09:00','12:30']], … } ⇒ Text je Wochentag { '1': '09:00–12:30', …, '7': '' } (leer = geschlossen). */
export function planTexte(plan) {
return Object.fromEntries(WOCHENTAGE_PLAN.map(([nr]) => [nr, fensterText(plan?.[nr])]))
}
/** Umkehrung: Texte je Wochentag ⇒ { plan } (nur Tage mit Zeiten) oder { fehler: { '3': FENSTER_FEHLER } } fuer Tage, die fensterParsen ablehnt. */
export function planAusTexten(texte) {
const plan = {}
const fehler = {}
for (const [nr] of WOCHENTAGE_PLAN) {
const fenster = fensterParsen(texte?.[nr] ?? '')
if (fenster === null) fehler[nr] = FENSTER_FEHLER
else if (fenster.length) plan[nr] = fenster
}
return Object.keys(fehler).length ? { fehler } : { plan }
}
/* ------------------------------------------------------------------ IDs --- */
const ID_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789'
const zufallsBytes = (n) => crypto.getRandomValues(new Uint8Array(n))
/**
* Neue ID einer Person/eines Platzes ('r'), einer Leistung ('s') oder einer Abwesenheit ('a'): Praefix_ + 8 Zeichen a-z0-9 (Ruling R8,
* dieselbe Form wie neueId in einstellungen.js; so bleibt ein Speichern atomar, auch wenn neue Eintraege aufeinander verweisen).
* bytes(n) liefert n Zufallsbytes (Vorgabe crypto.getRandomValues; nur fuer Tests austauschbar). Bytes ab 252 werden verworfen
* (252 = 7 * 36): so ist jedes Zeichen gleich wahrscheinlich.
*/
export function neueId(praefix, bytes = zufallsBytes) {
if (!['r', 's', 'a'].includes(praefix)) throw new Error(`Unbekanntes ID-Präfix: ${praefix}`)
let zeichen = ''
while (zeichen.length < 8) {
for (const byte of bytes(16)) {
if (byte < 252 && zeichen.length < 8) zeichen += ID_ALPHABET[byte % ID_ALPHABET.length]
}
}
return `${praefix}_${zeichen}`
}
/** Neue ID, die in vorhandene (Liste oder Set von IDs) nicht vorkommt. */
export function eindeutigeId(praefix, vorhandene, bytes) {
const belegt = new Set(vorhandene)
let id
do { id = neueId(praefix, bytes) } while (belegt.has(id))
return id
}
/* ----------------------------------------------------------- Zahlen, Preis --- */
/** Ganze Zahl aus einem Feld ('45', ' 45 '), sonst null (leer, Komma, Buchstaben, mehr als 9 Stellen). */
export function ganzzahl(text) {
return typeof text === 'string' && /^\s*-?\d{1,9}\s*$/.test(text) ? Number(text) : null
}
/**
* Euro aus einem Feld in Cent. Deutsche Schreibweise: ',' trennt die Cent, '.' die Tausender ('1.234,50'); '45.50' und '12.5'
* gelten als Euro und Cent. null bei allem anderen (leer, Buchstaben, mehr als zwei Nachkommastellen, kein Betrag vor dem Komma).
* Die Grenze (0 bis 10.000 Euro) prueft der Server.
*/
export function euroZuCent(text) {
if (typeof text !== 'string') return null
const s = text.replace(/€/g, '').trim()
if (!/^[\d.,]+$/.test(s)) return null
let ganz = s
let rest = ''
const komma = s.indexOf(',')
if (komma >= 0) {
ganz = s.slice(0, komma)
rest = s.slice(komma + 1)
if (/[.,]/.test(rest)) return null
if (ganz.includes('.') && !/^\d{1,3}(\.\d{3})+$/.test(ganz)) return null
ganz = ganz.replaceAll('.', '')
} else if (/^\d{1,3}(\.\d{3})+$/.test(s)) {
ganz = s.replaceAll('.', '')
} else if (s.includes('.')) {
const teile = s.split('.')
if (teile.length !== 2) return null
;[ganz, rest] = teile
}
if (!/^\d{1,9}$/.test(ganz) || !/^\d{0,2}$/.test(rest)) return null
return Number(ganz) * 100 + Number(rest.padEnd(2, '0'))
}
/** 4500 ⇒ '45,00' (Feldwert); unbrauchbar ⇒ ''. */
export function centZuEuro(cent) {
return Number.isInteger(cent) && cent >= 0 ? `${Math.floor(cent / 100)},${String(cent % 100).padStart(2, '0')}` : ''
}
/** '45,00 €' | 'ab 45,00 €' | 'ohne Preis' (wie preisText in einstellungen.js, dort '' statt „ohne Preis“). */
export function preisAnzeige(leistung) {
const l = leistung || {}
if (l.preisArt === 'keiner' || !Number.isInteger(l.preisCent) || l.preisCent <= 0) return 'ohne Preis'
const euro = String(Math.floor(l.preisCent / 100)).replace(/\B(?=(\d{3})+(?!\d))/g, '.')
return `${l.preisArt === 'ab' ? 'ab ' : ''}${euro},${String(l.preisCent % 100).padStart(2, '0')} €`
}
/* ------------------------------------------------------- Zusatzfelder, Listen --- */
const RESERVIERT = ['__proto__', 'constructor', 'prototype']
/**
* Schluessel eines Zusatzfelds aus der Beschriftung ('Größe in cm' ⇒ 'groesse_in_cm'): 1 bis 20 Zeichen a-z0-9_, nicht in vorhandene
* (Liste) und nicht reserviert; sonst wird _2, _3 … angehaengt. Der Kunde sieht den Schluessel nie.
*/
export function zusatzSchluessel(label, vorhandene) {
const belegt = new Set(vorhandene)
const grund = String(label ?? '').toLowerCase()
.replaceAll('ä', 'ae').replaceAll('ö', 'oe').replaceAll('ü', 'ue').replaceAll('ß', 'ss')
.normalize('NFKD').replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 20).replace(/_+$/, '') || 'feld'
let key = grund
for (let n = 2; belegt.has(key) || RESERVIERT.includes(key); n += 1) {
const endung = `_${n}`
key = grund.slice(0, 20 - endung.length) + endung
}
return key
}
/** Auswahlmoeglichkeiten eines Zusatzfelds: eine je Zeile ⇔ Liste. */
export function optionenAusText(text) {
return typeof text === 'string' ? text.split(/\r?\n/).map((zeile) => zeile.trim()).filter(Boolean) : []
}
export function optionenText(optionen) {
return Array.isArray(optionen) ? optionen.join('\n') : ''
}
/** Kopie der Liste, in der der Eintrag an Stelle index um richtung (-1 hoch, +1 runter) verschoben ist; ausserhalb: unveraendert. */
export function verschiebeEintrag(liste, index, richtung) {
const kopie = [...liste]
const ziel = index + richtung
if (!Number.isInteger(index) || index < 0 || index >= kopie.length || ziel < 0 || ziel >= kopie.length) return kopie
;[kopie[index], kopie[ziel]] = [kopie[ziel], kopie[index]]
return kopie
}
/** 'Aktiv seit 01.10.2026' aus aktivSeit (ISO-Zeit) nach Berliner Kalender; '' ohne brauchbare Zeit. */
export function aktivSeitText(iso) {
const zeit = typeof iso === 'string' ? new Date(iso) : null
if (!zeit || Number.isNaN(zeit.getTime())) return ''
return `Aktiv seit ${datumKurz(heuteBerlin(zeit))}`
}
+59 -1
View File
@@ -103,7 +103,7 @@
/* ------------------------------------------------------------------ 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-raster { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 0 14px; align-items: start; }
.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 {
@@ -131,12 +131,66 @@
.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); }
/* ----------------------------------- Leistungen, Team & Plaetze, Einstellungen --- */
.tm-tab > * + * { margin-top: 16px; }
.tm-tab > .tm-box { margin-bottom: 0; }
.tm-hinweise:empty { display: none; }
.tm-feld[hidden] { display: none; }
.tm-gruppe-felder { display: grid; gap: 0; min-width: 0; margin: 0; padding: 0; border: 0; }
.tm-gruppe-felder > legend { padding: 0; }
.tm-feld.tm-gruppe-felder { margin-top: 14px; }
.tm-abschnitt h3 { margin-bottom: 4px; }
.tm-abschnitt h4 { margin: 18px 0 4px; font-size: 14.5px; }
.tm-abschnitt-text { margin: 0 0 14px; line-height: 1.55; }
.tm-abschnitt .tm-box { margin: 0 0 14px; }
.tm-abschnitt .tm-werkzeug-zeile { align-items: center; margin-bottom: 12px; }
.tm-abschnitt .tm-eintraege { margin-top: 4px; }
.tm-kopf { display: flex; align-items: flex-start; justify-content: space-between; gap: 6px 14px; }
.tm-kopf .tm-pille { flex: none; }
.tm-eintrag .tm-meta + .tm-meta { margin-top: 0; }
.tm-editor-box:empty, .tm-frage:empty { display: none; }
.tm-editor { margin-top: 12px; }
.tm-eintrag .tm-formular { margin-bottom: 0; padding: 14px 0 0; border: 0; border-top: 1px solid var(--kb-border); border-radius: 0; background: transparent; }
.tm-eintrag .tm-formular h3 { display: none; }
.tm-feld-haken { margin-top: 4px; }
.tm-feld-haken .tm-haken { margin: 6px 0 0; }
.tm-haken > span { font-weight: 600; }
.tm-haken small { display: block; margin-top: 2px; font-weight: 400; }
.tm-abschnitt form > .tm-frage-knoepfe, .tm-tab > form > .tm-frage-knoepfe { margin-top: 16px; }
.tm-feld input[type=color] { height: 46px; padding: 4px; cursor: pointer; }
.tm-gefahr { color: #fca5a5; border-color: rgba(248, 113, 113, 0.45); }
.tm-gefahr:hover { background: rgba(248, 113, 113, 0.1); }
.tm-wochenplan .tm-raster { gap: 0 14px; }
.tm-feed { margin-top: 14px; padding: 14px 16px; border-radius: 12px; background: rgba(255, 255, 255, 0.03); border: 1px solid var(--kb-border); }
.tm-feed h4 { margin: 0 0 6px; font-size: 14.5px; }
.tm-feed .tm-hinweis { margin: 0 0 8px; line-height: 1.5; }
.tm-feed .tm-frage-knoepfe { margin: 10px 0 0; }
.tm-link-feld {
width: 100%; box-sizing: border-box; padding: 10px 12px; border-radius: 12px; border: 1px solid var(--kb-border-strong);
background: rgba(255, 255, 255, 0.05); color: inherit; font: inherit; font-size: 13px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; text-overflow: ellipsis;
}
.tm-muster { margin-top: 14px; padding: 14px 16px; border-radius: 12px; border: 1px dashed var(--kb-border-strong); }
.tm-muster > p { margin: 0 0 10px; font-size: 13.5px; line-height: 1.55; color: var(--kb-muted); }
.tm-muster-text { margin: 0 0 12px; padding: 0 0 0 12px; border-left: 3px solid var(--kb-border-strong); font-size: 13.5px; line-height: 1.6; color: var(--kb-muted); font-style: italic; }
.tm-zusatz { display: grid; gap: 12px; margin: 10px 0; }
.tm-zusatz:empty { display: none; }
.tm-zusatz-zeile { padding: 12px 14px 14px; border-radius: 12px; border: 1px solid var(--kb-border); }
.tm-zusatz-zeile .tm-gefahr { margin-top: 10px; }
.tm-optionen { display: grid; gap: 4px; margin: 4px 0 8px; }
.tm-optionen .tm-haken { margin: 6px 0; }
.tm-aus { opacity: 0.6; cursor: not-allowed; }
.tm-status { margin: 0; }
.tm-abschnitt details.tm-details { margin-top: 12px; }
/* ----------------------------------------------------------------- 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; }
.tm-haken { align-items: center; min-height: 44px; margin-top: 0; margin-bottom: 0; }
.tm-haken input { margin-top: 0; }
.tm-link-feld { min-height: 44px; font-size: 16px; } /* 16 px: iOS zoomt beim Antippen kleinerer Felder */
}
@media (max-width: 600px) {
.tm-reiterleiste { grid-template-columns: repeat(2, minmax(0, 1fr)); }
@@ -154,5 +208,9 @@
.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; }
.tm-aktionen > .tm-pfeil { flex: 0 0 44px; }
.tm-kopf { flex-wrap: wrap; }
.tm-feed { padding: 12px; }
.tm-frage-knoepfe > * { flex: 1 1 auto; }
}
@media (prefers-reduced-motion: reduce) { #view-termine * { transition: none !important; animation: none !important; } }
+26 -17
View File
@@ -1,15 +1,19 @@
/* Terminplaner im Kundenbereich (ES-Modul; app.js laedt es erst beim Oeffnen der Ansicht #termine). Das ist die Huelle:
initTermine(rootEl, { projectId }): Projektauswahl, Vorlagenauswahl beim ersten Oeffnen, Reiter-Geruest und die Meldung ueber den Reitern.
Die Reiter stehen in REITER: jeder ist ein Modul mit render(panel, ctx) => void | Promise (Termine: termine-tab-termine.js; Leistungen,
Team & Plaetze und Einstellungen (Aufgabe 21b) ersetzen dort nur ihren Platzhalter).
ctx = { s, projectId, daten, nurLesen, auswahl, aktuell(), api, meldung, zumReiter, neuLaden }: Zustand und Pruefung "bin ich noch
aktuell?" bekommen die Reiter nur ueber ctx, nie ueber Modulvariablen. daten ist die Antwort von GET /api/termine/einstellungen,
nurLesen gilt fuer abgeschaltete Funktionen. Server: server/routes/termine.js. Tage sind Berliner Kalendertage 'JJJJ-MM-TT';
Die Reiter stehen in REITER: jeder ist ein Modul mit render(panel, ctx) => void | Promise (termine-tab-termine.js, termine-tab-leistungen.js,
termine-tab-team.js, termine-tab-einstellungen.js; die drei letzten speichern ueber termine-speichern.js).
ctx = { s, projectId, daten, nurLesen, auswahl, aktuell(), api, meldung, hinweise, zumReiter, neuLaden }: Zustand und Pruefung "bin ich noch
aktuell?" bekommen die Reiter nur ueber ctx, nie ueber Modulvariablen. daten ist die Antwort von GET /api/termine/einstellungen
(die Reiter, die speichern, halten daten.einstellung und daten.version aktuell), nurLesen gilt fuer abgeschaltete Funktionen,
hinweise() zeichnet die Hinweisleiste ueber den Reitern neu (z. B. wenn Telefon und Anschrift nach dem Speichern nicht mehr fehlen). Server: server/routes/termine.js. Tage sind Berliner Kalendertage 'JJJJ-MM-TT';
gerechnet wird in termine-view.js. Helfer ohne Zustand: termine-dom.js. Styles: termine.css. */
import { fehltText, heuteBerlin } from './termine-view.js'
import { exportUrl, fehlerKarte, h, infoKarte, request } from './termine-dom.js'
import { exportLink, fehlerKarte, h, infoKarte, request } from './termine-dom.js'
import { render as renderTermine } from './termine-tab-termine.js'
import { render as renderLeistungen } from './termine-tab-leistungen.js'
import { render as renderTeam } from './termine-tab-team.js'
import { render as renderEinstellungen } from './termine-tab-einstellungen.js'
// 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' }
@@ -61,7 +65,7 @@ const ladeDaten = (projectId) => request(`/api/termine/einstellungen?projectId=$
/** Hinweis fuer abgeschaltete Funktionen: nur noch ansehen und exportieren. */
const hinweisAbgeschaltet = (projectId) => 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: exportUrl(projectId), download: 'termine.csv' }, 'exportieren'),
exportLink(projectId, 'exportieren'),
'.')
/**
@@ -131,16 +135,12 @@ function zeigeVorlagen(s, { projekt, aktuell }) {
}
/* -------------------------------------------------------- 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. */
/** key, Beschriftung, Funktion (panel, ctx). */
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.') },
{ key: 'leistungen', label: 'Leistungen', render: renderLeistungen },
{ key: 'team', label: 'Team & Plätze', render: renderTeam },
{ key: 'einstellungen', label: 'Einstellungen', render: renderEinstellungen },
]
/**
@@ -177,18 +177,24 @@ function zeigeReiter(s, { projekt, aktuell }) {
? request(`/api/termine${pfad}?${new URLSearchParams({ projectId })}`, { method })
: request(`/api/termine${pfad}`, { method, body: { projectId, ...body } })),
},
meldung: (text, art) => zeigeMeldung(s, text, art),
// eine Meldung, die erst nach dem Wechsel der Website oder der Ansicht eintrifft, gehoert nicht mehr auf diese Seite
meldung: (text, art) => { if (aktuell()) zeigeMeldung(s, text, art) },
hinweise: () => { if (aktuell()) zeichneHinweise() },
zumReiter: (key) => waehleReiter(key),
neuLaden: async () => {
const daten = await ladeDaten(projectId)
if (aktuell()) {
s.daten = daten
ctx.daten = daten
zeichneHinweise()
}
return daten
},
}
// Hinweisleiste: abgeschaltet, oder Pflichtangaben fuer Gaeste fehlen (GET /einstellungen: fehlt). Sie folgt s.daten.
const hinweisBox = h('div', { class: 'tm-hinweise' })
function zeichneHinweise() {
const hinweise = []
if (nurLesen) {
hinweise.push(hinweisAbgeschaltet(projectId))
@@ -197,6 +203,9 @@ function zeigeReiter(s, { projekt, aktuell }) {
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')))
}
hinweisBox.replaceChildren(...hinweise)
}
zeichneHinweise()
const panel = h('div', { class: 'tm-panel', role: 'tabpanel', id: 'tm-panel', tabindex: '0' })
const knoepfe = REITER.map((r) => h('button', {
@@ -229,7 +238,7 @@ function zeigeReiter(s, { projekt, aktuell }) {
.catch((err) => { if (aktuell()) panel.replaceChildren(fehlerKarte(err.message, () => waehleReiter(reiter.key))) })
}
s.buehne.replaceChildren(...hinweise, s.meldungEl, leiste, panel)
s.buehne.replaceChildren(hinweisBox, s.meldungEl, leiste, panel)
waehleReiter(auswahl.reiter)
}
+278 -11
View File
@@ -5,7 +5,18 @@ import {
FILTER, ABSAGE_GRUENDE, anfragenText, bereichFuer, datumKurz, datumLang, endeZeit, fensterParsen, fensterText, filterStatus, gruppiereNachTag,
fehltText, fensterAufteilen, heuteBerlin, istSperre, istTag, monatVon, moeglicheAktionen, ressourcenWort, statusLabel, statusTon, tagPlus, tagUeberschrift, verschiebeTag, wocheVon, zeitNormal, zeitraumTitel,
} from '../public/termine-view.js'
import {
MAX, BUNDESLAENDER, ERINNERUNG_STUNDEN, FENSTER_FEHLER, GAST_FELD_WAHL, GESUNDHEIT_HINWEIS, LIMITS, RASTER_MIN, WOCHENTAGE_PLAN, ZAHLARTEN,
aktivSeitText, centZuEuro, eindeutigeId, euroZuCent, ganzzahl, neueId, optionenAusText, optionenText, planAusTexten, planTexte, preisAnzeige, verschiebeEintrag, zusatzSchluessel,
} from '../public/termine-view.js'
import { ANREDE_WAHL, stornoMuster } from '../public/termine-gaestetexte.js'
import { exportUrl, request } from '../public/termine-dom.js'
import { beschrifteFehler } from '../public/termine-speichern.js'
import { BUNDESLAENDER as SERVER_BUNDESLAENDER } from '../server/services/termine/feiertage.js'
import { normalisiereEinstellung, preisText, zahlungText } from '../server/services/termine/einstellungen.js'
import { VORLAGEN, ausVorlage } from '../server/services/termine/vorlagen.js'
const JETZT_SERVER = new Date('2026-10-05T08:00:00.000Z') // feste Uhr für Aufrufe der Server-Prüfung
// 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.
@@ -81,11 +92,10 @@ 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-04' }, 'ab heute bis zum Ende des Buchungszeitraums (Vorgabe 60 Tage)')
assert.deepEqual(bereichFuer('anfragen', '2026-10-07', '2026-10-05', 180), { von: '2026-10-05', bis: '2027-04-03' }, 'Buchungszeitraum der Einstellung')
assert.deepEqual(bereichFuer('anfragen', '2026-10-07', '2026-10-05', 365), { von: '2026-10-05', bis: '2027-10-05' })
assert.equal(bereichFuer('anfragen', '2026-10-07', '2026-10-05', 9999).bis, '2027-10-05', 'höchstens 365 Tage')
for (const kaputt of [0, -5, NaN, 'x', null, undefined]) assert.equal(bereichFuer('anfragen', '2026-10-07', '2026-10-05', kaputt).bis, '2026-12-04', `ungültiger Zeitraum ${String(kaputt)} ⇒ Vorgabe`)
// Ruling R50: Offene Anfragen immer ab heute 365 Tage (so lange kann eine Anfrage nicht in der Zukunft liegen), unabhängig vom Buchungszeitraum
assert.deepEqual(bereichFuer('anfragen', '2026-10-07', '2026-10-05'), { von: '2026-10-05', bis: '2027-10-05' }, 'ab heute 365 Tage')
assert.deepEqual(bereichFuer('anfragen', '2026-10-07', '2026-10-05', 30), { von: '2026-10-05', bis: '2027-10-05' }, 'ein kurzer Buchungszeitraum begrenzt die Liste nicht')
assert.deepEqual(bereichFuer('anfragen', '2026-10-07', '2028-02-28'), { von: '2028-02-28', bis: '2029-02-27' }, 'Schaltjahr: 365 Kalendertage')
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')
@@ -261,33 +271,65 @@ test('Portal: Ansicht #termine ist verdrahtet, funktionen.js behält seinen eine
test('Ansicht-Module (public/termine*.js): Fremdtext nur als Text, keine Browser-Dialoge, Du-Form, ein einziger Link', () => {
const dateien = readdirSync(new URL('../public/', import.meta.url)).filter((f) => /^termine.*\.js$/.test(f)).sort()
assert.deepEqual(dateien, ['termine-aktionen.js', 'termine-dom.js', 'termine-formulare.js', 'termine-tab-termine.js', 'termine-view.js', 'termine.js'])
assert.deepEqual(dateien, [
'termine-aktionen.js', 'termine-dom.js', 'termine-formulare.js', 'termine-gaestetexte.js', 'termine-speichern.js', 'termine-tab-einstellungen.js',
'termine-tab-leistungen.js', 'termine-tab-team.js', 'termine-tab-termine.js', 'termine-team-ausnahmen.js', 'termine-view.js', 'termine.js',
])
const ohneKommentare = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '')
const quellen = dateien.map((f) => [f, ohneKommentare(lies(`public/${f}`))])
// Nur termine-gaestetexte.js schreibt für Gäste (Mustertext, Auswahl der Anrede) und darf deshalb die Sie-Form enthalten
const GAESTETEXTE = 'termine-gaestetexte.js'
for (const [datei, quelle] of quellen) {
assert.doesNotMatch(quelle, /innerHTML|insertAdjacentHTML|outerHTML|document\.write/, `${datei}: Daten nie als HTML`)
assert.doesNotMatch(quelle, /\bconfirm\(|\balert\(|\bprompt\(/, `${datei}: Rückfragen stehen in der Seite`)
assert.doesNotMatch(quelle, /\bSie\b|\bIhr(e[nmrs]?)?\b/, `${datei}: Portaltexte in der Du-Form`)
if (datei !== GAESTETEXTE) assert.doesNotMatch(quelle, /\bSie\b|\bIhr(e[nmrs]?)?\b/, `${datei}: Portaltexte in der Du-Form`)
assert.doesNotMatch(quelle, /localStorage|sessionStorage|document\.cookie/, `${datei}: kein Browserspeicher`)
}
assert.match(quellen.find(([f]) => f === GAESTETEXTE)[1], /\bSie\b/, 'die Ausnahme hat einen Grund')
// Links nur auf die eigene Export-Schnittstelle (exportUrl), nie auf Kundentext
const hrefs = quellen.flatMap(([datei, quelle]) => (quelle.match(/href: [^,}]+|setAttribute\('href'/g) || []).map((m) => `${datei}: ${m}`))
assert.deepEqual(hrefs, ['termine.js: href: exportUrl(projectId)'])
assert.deepEqual(hrefs, ['termine-dom.js: href: exportUrl(projectId)'])
const dom = quellen.find(([f]) => f === 'termine-dom.js')[1]
assert.ok(dom.includes('`/api/termine/export.csv?projectId=${encodeURIComponent(projectId)}`'), 'exportUrl codiert die Projekt-ID')
// Speichern der Einstellungen gibt es genau einmal (termine-speichern.js), nicht in jedem Reiter
assert.deepEqual(quellen.filter(([, quelle]) => /'PATCH'/.test(quelle)).map(([datei]) => datei), ['termine-speichern.js'])
})
test('Ansicht-Module: Reiter sind Module mit render(panel, ctx), die Huelle kennt sie nur ueber REITER', () => {
const huelle = lies('public/termine.js')
assert.match(huelle, /import \{ render as renderTermine \} from '\.\/termine-tab-termine\.js'/)
for (const tab of ['termine', 'leistungen', 'team', 'einstellungen']) {
assert.match(huelle, new RegExp(`import \\{ render as render${tab[0].toUpperCase()}${tab.slice(1)} \\} from '\\./termine-tab-${tab}\\.js'`))
assert.match(lies(`public/termine-tab-${tab}.js`), /export function render\(panel, ctx\)/)
}
assert.match(huelle, /const REITER = \[/)
assert.match(lies('public/termine-tab-termine.js'), /export function render\(panel, ctx\)/)
assert.deepEqual([...huelle.matchAll(/\{ key: '(\w+)', label: '([^']+)', render: (\w+) \}/g)].map((m) => [m[1], m[2], m[3]]), [
['termine', 'Termine', 'renderTermine'], ['leistungen', 'Leistungen', 'renderLeistungen'], ['team', 'Team & Plätze', 'renderTeam'], ['einstellungen', 'Einstellungen', 'renderEinstellungen'],
])
// Reiter lesen Zustand nur aus ctx, nicht aus der Huelle
for (const datei of ['termine-tab-termine.js', 'termine-aktionen.js', 'termine-formulare.js']) {
for (const datei of ['termine-tab-termine.js', 'termine-tab-leistungen.js', 'termine-tab-team.js', 'termine-tab-einstellungen.js', 'termine-team-ausnahmen.js', 'termine-speichern.js', 'termine-aktionen.js', 'termine-formulare.js']) {
assert.doesNotMatch(lies(`public/${datei}`), /from '\.\/termine\.js'/, `${datei} importiert die Huelle nicht`)
}
})
test('beschrifteFehler: Fehler ohne Platz am Feld nennen den Eintrag', () => {
const quellen = [{
leistungen: [{ id: 's_aaaaaaaa', name: 'Herrenschnitt' }, { id: 's_bbbbbbbb', name: 'Färben' }],
ressourcen: [{ id: 'r_aaaaaaaa', name: 'Anna', typ: 'person' }, { id: 'r_bbbbbbbb', name: 'Kabine 1', typ: 'platz' }],
abwesenheiten: [{ id: 'a_aaaaaaaa', titel: 'Betriebsurlaub' }],
}]
assert.equal(beschrifteFehler('leistungen.1.ressourcen', 'Eine aktive Leistung braucht …', quellen), 'Leistung „Färben“: Eine aktive Leistung braucht …', 'nach Stelle')
assert.equal(beschrifteFehler('leistungen.s_aaaaaaaa', 'Für diese Leistung gibt es noch 1 künftigen Termin.', quellen), 'Leistung „Herrenschnitt“: Für diese Leistung gibt es noch 1 künftigen Termin.', 'nach ID')
assert.equal(beschrifteFehler('ressourcen.r_bbbbbbbb', 'Für diesen Platz …', quellen), 'Platz „Kabine 1“: Für diesen Platz …')
assert.equal(beschrifteFehler('ressourcen.0.plan.3', 'Zeitfenster überlappen sich.', quellen), 'Person „Anna“: Zeitfenster überlappen sich.')
assert.equal(beschrifteFehler('abwesenheiten.0.bis', 'Das Ende …', quellen), 'Abwesenheit „Betriebsurlaub“: Das Ende …')
assert.equal(beschrifteFehler('leistungen.7.name', 'Bitte gib einen Namen an.', quellen), 'Bitte gib einen Namen an.', 'unbekannter Eintrag: Text unverändert')
assert.equal(beschrifteFehler('regeln.raster', 'Das Raster …', quellen), 'Das Raster …')
assert.equal(beschrifteFehler('betrieb.adresse', 'Bitte gib die Anschrift an.', quellen), 'Bitte gib die Anschrift an. Das änderst du im Reiter „Einstellungen“.', 'Betriebsangaben stehen woanders')
assert.equal(beschrifteFehler('_', 'Zu umfangreich.', quellen), 'Zu umfangreich.')
// gesucht wird in allen Quellen (der alte Stand kennt Einträge, die der Entwurf nicht mehr hat)
assert.equal(beschrifteFehler('leistungen.s_cccccccc', 'x', [{ leistungen: [] }, { leistungen: [{ id: 's_cccccccc', name: 'Alt' }] }]), 'Leistung „Alt“: x')
})
test('request: Netzwerkfehler auf Deutsch, Fehler der Schnittstelle mit Feldern und Code', async () => {
const vorher = globalThis.fetch
try {
@@ -306,3 +348,228 @@ test('exportUrl: Projekt-ID wird codiert', () => {
assert.equal(exportUrl('p1'), '/api/termine/export.csv?projectId=p1')
assert.equal(exportUrl('a&b=c d'), '/api/termine/export.csv?projectId=a%26b%3Dc%20d')
})
// ---- Einstellungen bearbeiten (Aufgabe 21b): Wochenplan, IDs, Preise, Spiegel der Server-Grenzen
test('Wochenplan: Text je Wochentag ⇔ Plan der Einstellung', () => {
const plan = { 1: [['09:00', '12:30'], ['13:30', '18:00']], 6: [['09:00', '13:00']] }
assert.deepEqual(WOCHENTAGE_PLAN.map(([nr, name]) => `${nr}${name}`), ['1Montag', '2Dienstag', '3Mittwoch', '4Donnerstag', '5Freitag', '6Samstag', '7Sonntag'])
assert.deepEqual(planTexte(plan), { 1: '09:00–12:30, 13:30–18:00', 2: '', 3: '', 4: '', 5: '', 6: '09:00–13:00', 7: '' })
assert.deepEqual(planTexte(undefined), { 1: '', 2: '', 3: '', 4: '', 5: '', 6: '', 7: '' })
assert.deepEqual(planAusTexten(planTexte(plan)), { plan }, 'Umkehrung')
assert.deepEqual(planAusTexten({ 1: ' ', 2: '8:00-12:00 und 14:00 bis 24:00' }), { plan: { 2: [['08:00', '12:00'], ['14:00', '24:00']] } }, 'leer = geschlossen, Schreibweisen werden vereinheitlicht')
assert.deepEqual(planAusTexten({}), { plan: {} })
assert.deepEqual(planAusTexten(undefined), { plan: {} })
assert.deepEqual(planAusTexten({ 3: '9-12', 5: '09:00–09:00', 1: '09:00–10:00' }), { fehler: { 3: FENSTER_FEHLER, 5: FENSTER_FEHLER } }, 'Fehler je Tag, kein Teilplan')
assert.match(FENSTER_FEHLER, /09:00–12:30, 13:30–18:00/)
})
test('Wochenplan aus dem Formular nimmt der Server an (5-Minuten-Raster, aufsteigend, bis 24:00)', () => {
const e = ausVorlage('salon', { name: 'Salon', email: 'a@b.de', telefon: '1', adresse: 'Str. 1' }, JETZT_SERVER)
e.ressourcen[0].plan = planAusTexten({ 1: '07:05–12:00, 13:00–24:00', 7: '10:00–11:00' }).plan
const erg = normalisiereEinstellung(e, { now: JETZT_SERVER })
assert.equal(erg.fehler, undefined, JSON.stringify(erg.fehler))
assert.deepEqual(erg.einstellung.ressourcen[0].plan, { 1: [['07:05', '12:00'], ['13:00', '24:00']], 7: [['10:00', '11:00']] })
})
test('neueId: Präfix + 8 Zeichen a–z0–9, mit crypto.getRandomValues (Ruling R8)', () => {
for (const p of ['r', 's', 'a']) for (let i = 0; i < 50; i += 1) assert.match(neueId(p), new RegExp(`^${p}_[a-z0-9]{8}$`))
assert.equal(new Set(Array.from({ length: 2000 }, () => neueId('s'))).size, 2000, 'Zufall, keine Wiederholung')
assert.throws(() => neueId('x'), /Präfix/)
// Bytes ab 252 werden verworfen (252 = 7 · 36: sonst wären die ersten Zeichen häufiger); der Rest wird modulo 36 abgebildet
assert.equal(neueId('r', () => Uint8Array.from([255, 252, 253, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12])), 'r_abcdefgh')
assert.equal(neueId('s', () => Uint8Array.from([35, 36, 71, 251, 250, 249, 248, 247, 0, 0, 0, 0, 0, 0, 0, 0])), 's_9a998765')
// zu wenige brauchbare Bytes ⇒ es wird nachgezogen
let aufrufe = 0
const nurSpaeterBrauchbar = () => {
aufrufe += 1
return Uint8Array.from({ length: 16 }, (_, i) => (aufrufe === 1 ? 255 : i))
}
assert.equal(neueId('a', nurSpaeterBrauchbar), 'a_abcdefgh')
assert.equal(aufrufe, 2)
})
test('eindeutigeId: nie eine ID, die es in der Liste schon gibt', () => {
const folgen = [Array.from({ length: 16 }, (_, i) => i), Array.from({ length: 16 }, (_, i) => i + 8)]
let k = 0
const bytes = () => Uint8Array.from(folgen[k++])
assert.equal(eindeutigeId('r', ['r_abcdefgh'], bytes), 'r_ijklmnop')
assert.equal(k, 2)
assert.match(eindeutigeId('s', []), /^s_[a-z0-9]{8}$/)
})
test('Preis: Euro-Text ⇔ Cent (deutsche Schreibweise), Anzeige wie beim Server', () => {
const gut = { '45': 4500, '45,00': 4500, '45,5': 4550, '45.50': 4550, '1.234,50': 123450, '1.234': 123400, '1234,5': 123450, ' 19,99 € ': 1999, '0': 0, '0,05': 5, '10000': 1000000, '10000,01': 1000001, '12.5': 1250 }
for (const [text, cent] of Object.entries(gut)) assert.equal(euroZuCent(text), cent, text)
for (const schlecht of ['', ' ', 'abc', ',50', '12,345', '1,2,3', '-5', '1.2.3', '1e3', '99999999999', '4 5', null, undefined, 45]) assert.equal(euroZuCent(schlecht), null, String(schlecht))
assert.equal(centZuEuro(4500), '45,00')
assert.equal(centZuEuro(5), '0,05')
assert.equal(centZuEuro(123450), '1234,50')
assert.equal(centZuEuro(0), '0,00')
assert.equal(centZuEuro(-1), '')
assert.equal(centZuEuro(1.5), '')
assert.equal(centZuEuro(undefined), '')
assert.equal(euroZuCent(centZuEuro(123456)), 123456, 'Hin und zurück')
for (const l of [{ preisArt: 'fest', preisCent: 4500 }, { preisArt: 'ab', preisCent: 4500 }, { preisArt: 'fest', preisCent: 123450 }, { preisArt: 'ab', preisCent: 5 }]) {
assert.equal(preisAnzeige(l), preisText(l), `wie preisText des Servers: ${JSON.stringify(l)}`)
}
assert.equal(preisAnzeige({ preisArt: 'keiner', preisCent: 0 }), 'ohne Preis')
assert.equal(preisAnzeige({ preisArt: 'fest', preisCent: 0 }), 'ohne Preis')
assert.equal(preisAnzeige(undefined), 'ohne Preis')
})
test('ganzzahl: nur ganze Zahlen aus Feldern, sonst null', () => {
assert.equal(ganzzahl('45'), 45)
assert.equal(ganzzahl(' 45 '), 45)
assert.equal(ganzzahl('0'), 0)
assert.equal(ganzzahl('-5'), -5)
for (const schlecht of ['', ' ', '4.5', '4,5', 'abc', '1e3', '9999999999', null, undefined, 5]) assert.equal(ganzzahl(schlecht), null, String(schlecht))
})
test('zusatzSchluessel: Schlüssel aus der Beschriftung, eindeutig, vom Server erlaubt', () => {
assert.equal(zusatzSchluessel('Wunschfarbe', []), 'wunschfarbe')
assert.equal(zusatzSchluessel('Größe in cm', []), 'groesse_in_cm')
assert.equal(zusatzSchluessel(' Haar-Länge (Straße) ', []), 'haar_laenge_strasse')
assert.equal(zusatzSchluessel('Haarlänge', ['haarlaenge']), 'haarlaenge_2')
assert.equal(zusatzSchluessel('Haarlänge', ['haarlaenge', 'haarlaenge_2']), 'haarlaenge_3')
assert.equal(zusatzSchluessel('!!!', []), 'feld')
assert.equal(zusatzSchluessel('', ['feld']), 'feld_2')
const lang = zusatzSchluessel('Eine sehr lange Beschriftung für das Feld', [])
assert.match(lang, /^[a-z0-9_]{1,20}$/)
const langZwei = zusatzSchluessel('Eine sehr lange Beschriftung für das Feld', [lang])
assert.match(langZwei, /^[a-z0-9_]{1,20}$/)
assert.notEqual(langZwei, lang)
for (const reserviert of ['constructor', 'prototype', '__proto__']) {
const key = zusatzSchluessel(reserviert, [])
assert.match(key, /^[a-z0-9_]{1,20}$/)
assert.ok(!['__proto__', 'constructor', 'prototype'].includes(key), `${reserviert} ⇒ ${key}`)
}
})
test('Auswahlmöglichkeiten: eine je Zeile', () => {
assert.deepEqual(optionenAusText('Rot\n Blau \r\n\nGrün\n'), ['Rot', 'Blau', 'Grün'])
assert.deepEqual(optionenAusText(''), [])
assert.deepEqual(optionenAusText(undefined), [])
assert.equal(optionenText(['Rot', 'Blau']), 'Rot\nBlau')
assert.equal(optionenText(undefined), '')
})
test('verschiebeEintrag: Reihenfolge ändern, ohne die Liste zu verändern', () => {
const liste = ['a', 'b', 'c']
assert.deepEqual(verschiebeEintrag(liste, 1, -1), ['b', 'a', 'c'])
assert.deepEqual(verschiebeEintrag(liste, 0, 1), ['b', 'a', 'c'])
assert.deepEqual(verschiebeEintrag(liste, 2, 1), ['a', 'b', 'c'], 'am Ende nach unten: unverändert')
assert.deepEqual(verschiebeEintrag(liste, 0, -1), ['a', 'b', 'c'], 'am Anfang nach oben: unverändert')
assert.deepEqual(verschiebeEintrag(liste, 7, 1), ['a', 'b', 'c'], 'unbekannte Stelle')
assert.deepEqual(liste, ['a', 'b', 'c'])
assert.notEqual(verschiebeEintrag(liste, 0, -1), liste, 'immer eine Kopie')
})
test('aktivSeitText: Datum nach Berliner Kalender', () => {
assert.equal(aktivSeitText('2026-10-01T09:00:00.000Z'), 'Aktiv seit 01.10.2026')
assert.equal(aktivSeitText('2026-09-30T22:30:00.000Z'), 'Aktiv seit 01.10.2026', 'nach Mitternacht in Berlin')
assert.equal(aktivSeitText('2026-09-30T21:30:00.000Z'), 'Aktiv seit 30.09.2026')
for (const schlecht of ['', 'gestern', null, undefined, 0]) assert.equal(aktivSeitText(schlecht), '', String(schlecht))
})
test('Hinweis zu Gesundheitsangaben: Wortlaut wie bei den Gästen', () => {
assert.equal(GESUNDHEIT_HINWEIS, 'Bitte keine Gesundheitsangaben.')
})
// ---- Spiegel der Auswahl und Grenzen des Servers (einstellungen.js): driftet eine Seite, wird dieser Test rot
const basisEinstellung = () => ausVorlage('salon', { name: 'Salon', email: 'a@b.de', telefon: '06301 1', adresse: 'Hauptstr. 1' }, JETZT_SERVER)
/** Fehler des Servers für eine geänderte Einstellung ({} = gültig). */
const serverFehler = (aendere) => {
const e = basisEinstellung()
aendere(e)
return normalisiereEinstellung(e, { now: JETZT_SERVER }).fehler || {}
}
test('Spiegel: Auswahllisten sind die des Servers', () => {
assert.deepEqual(RASTER_MIN, [5, 10, 15, 20, 30, 60])
for (const r of RASTER_MIN) assert.equal(serverFehler((e) => { e.regeln.raster = r })['regeln.raster'], undefined, `Raster ${r}`)
for (const r of [0, 7, 45, 90]) assert.ok(serverFehler((e) => { e.regeln.raster = r })['regeln.raster'], `Raster ${r} gibt es nicht`)
assert.deepEqual(ERINNERUNG_STUNDEN, [2, 12, 24, 48])
for (const n of ERINNERUNG_STUNDEN) assert.equal(serverFehler((e) => { e.erinnerung.stundenVorher = n })['erinnerung.stundenVorher'], undefined, `${n} Stunden`)
assert.ok(serverFehler((e) => { e.erinnerung.stundenVorher = 3 })['erinnerung.stundenVorher'])
assert.deepEqual(GAST_FELD_WAHL.map(([key]) => key), ['aus', 'optional', 'pflicht'])
for (const [key] of GAST_FELD_WAHL) {
assert.deepEqual(serverFehler((e) => { e.gastFelder.telefon = key; e.gastFelder.nachricht = key }), {}, key)
}
assert.deepEqual(new Set(BUNDESLAENDER.map(([key]) => key)), new Set(SERVER_BUNDESLAENDER), 'alle 16 Bundesländer, keines zu viel')
assert.equal(BUNDESLAENDER.length, 16)
assert.deepEqual(BUNDESLAENDER.map(([, name]) => name), [...BUNDESLAENDER.map(([, name]) => name)].sort((a, b) => a.localeCompare(b, 'de')), 'nach Namen sortiert')
for (const [key] of BUNDESLAENDER) assert.deepEqual(serverFehler((e) => { e.bundesland = key }), {}, key)
assert.deepEqual(ANREDE_WAHL.map(([key]) => key), ['sie', 'du'])
for (const [key] of ANREDE_WAHL) assert.deepEqual(serverFehler((e) => { e.anrede = key }), {}, key)
})
test('Spiegel: Zahlarten und ihre Namen sind die des Servers', () => {
const keys = ZAHLARTEN.map(([key]) => key)
assert.deepEqual(keys, ['bar', 'ec', 'kreditkarte', 'ueberweisung', 'paypal'])
const e = basisEinstellung()
e.zahlung.vorOrtArten = keys
const erg = normalisiereEinstellung(e, { now: JETZT_SERVER })
assert.equal(erg.fehler, undefined)
assert.deepEqual(erg.einstellung.zahlung.vorOrtArten, keys, 'Reihenfolge wie beim Server')
assert.equal(zahlungText(erg.einstellung), `Bezahlung vor Ort: ${ZAHLARTEN.map(([, name]) => name).join(', ')}`)
assert.ok(serverFehler((x) => { x.zahlung.vorOrtArten = ['bitcoin'] })['zahlung.vorOrtArten'])
})
test('Spiegel: Obergrenzen der Listen sind die des Servers', () => {
assert.deepEqual(LIMITS, { ressourcen: 20, leistungen: 50, abwesenheiten: 100, sonderoeffnungen: 200, zusatzfelder: 5, optionen: 10 })
const tag = (i) => new Date(Date.UTC(2027, 0, 1 + i)).toISOString().slice(0, 10)
const faelle = {
ressourcen: [LIMITS.ressourcen, 'ressourcen', (e, n) => { e.leistungen = []; e.abwesenheiten = []; e.ressourcen = Array.from({ length: n }, (_, i) => ({ id: neueId('r'), typ: 'person', name: `P${i}`, aktiv: true, plan: {} })) }],
leistungen: [LIMITS.leistungen, 'leistungen', (e, n) => { e.leistungen = Array.from({ length: n }, (_, i) => ({ ...e.leistungen[0], id: neueId('s'), name: `L${i}` })) }],
abwesenheiten: [LIMITS.abwesenheiten, 'abwesenheiten', (e, n) => { e.abwesenheiten = Array.from({ length: n }, (_, i) => ({ id: neueId('a'), ressourceId: null, von: tag(i), bis: tag(i), titel: '' })) }],
sonderoeffnungen: [LIMITS.sonderoeffnungen, 'sonderoeffnungen', (e, n) => { e.sonderoeffnungen = Object.fromEntries(Array.from({ length: n }, (_, i) => [tag(i), []])) }],
zusatzfelder: [LIMITS.zusatzfelder, 'gastFelder.zusatz', (e, n) => { e.gastFelder.zusatz = Array.from({ length: n }, (_, i) => ({ key: `f${i}`, label: `F${i}`, typ: 'text', optionen: [], pflicht: false })) }],
optionen: [LIMITS.optionen, 'gastFelder.zusatz.0.optionen', (e, n) => { e.gastFelder.zusatz = [{ key: 'wahl', label: 'Wahl', typ: 'auswahl', optionen: Array.from({ length: n }, (_, i) => `O${i}`), pflicht: false }] }],
}
for (const [name, [grenze, pfad, baue]] of Object.entries(faelle)) {
assert.equal(serverFehler((e) => baue(e, grenze))[pfad], undefined, `${name}: ${grenze} sind erlaubt`)
assert.ok(serverFehler((e) => baue(e, grenze + 1))[pfad], `${name}: ${grenze + 1} sind zu viele`)
}
})
test('Spiegel: Längen der Eingabefelder (maxlength) kürzt der Server nicht mehr', () => {
const lang = (n) => 'x'.repeat(n)
const lies = (e, was) => was(normalisiereEinstellung(e, { now: JETZT_SERVER }).einstellung)
const faelle = [
['betriebName', (e, t) => { e.betrieb.name = t }, (x) => x.betrieb.name],
['telefon', (e, t) => { e.betrieb.telefon = t }, (x) => x.betrieb.telefon],
['adresse', (e, t) => { e.betrieb.adresse = t }, (x) => x.betrieb.adresse],
['leistungName', (e, t) => { e.leistungen[0].name = t }, (x) => x.leistungen[0].name],
['beschreibung', (e, t) => { e.leistungen[0].beschreibung = t }, (x) => x.leistungen[0].beschreibung],
['ressourcenName', (e, t) => { e.ressourcen[0].name = t }, (x) => x.ressourcen[0].name],
['abwesenheitTitel', (e, t) => { e.abwesenheiten = [{ id: 'a_abcd1234', ressourceId: null, von: '2027-01-01', bis: '2027-01-02', titel: t }] }, (x) => x.abwesenheiten[0].titel],
['zusatzLabel', (e, t) => { e.gastFelder.zusatz = [{ key: 'k', label: t, typ: 'text', optionen: [], pflicht: false }] }, (x) => x.gastFelder.zusatz[0].label],
['option', (e, t) => { e.gastFelder.zusatz = [{ key: 'k', label: 'K', typ: 'auswahl', optionen: [t], pflicht: false }] }, (x) => x.gastFelder.zusatz[0].optionen[0]],
['storno', (e, t) => { e.storno = { text: t } }, (x) => x.storno.text],
]
for (const [name, setze, lese] of faelle) {
const grenze = MAX[name === 'telefon' ? 'telefon' : name]
assert.ok(Number.isInteger(grenze), `MAX.${name}`)
const e = basisEinstellung()
setze(e, lang(grenze))
assert.equal(lies(e, lese).length, grenze, `${name}: ${grenze} Zeichen bleiben ganz`)
const zuLang = basisEinstellung()
setze(zuLang, lang(grenze + 1))
assert.equal(lies(zuLang, lese).length, grenze, `${name}: mehr als ${grenze} würden gekürzt`)
}
assert.equal(MAX.url, 300)
const url = (n) => `https://example.org/${'a'.repeat(n - 'https://example.org/'.length)}`
assert.equal(serverFehler((e) => { e.betrieb.impressumUrl = url(300) })['betrieb.impressumUrl'], undefined)
assert.ok(serverFehler((e) => { e.betrieb.impressumUrl = url(301) })['betrieb.impressumUrl'])
})
test('Mustertext Storno (Gästetext): wie die Vorlagen des Servers, Frist einsetzbar', () => {
assert.equal(stornoMuster(24), VORLAGEN.salon.storno, 'derselbe Wortlaut wie in den Vorlagen')
assert.ok(stornoMuster(48).includes('bis 48 Stunden vorher'))
assert.ok(stornoMuster(48).includes('50 % des Preises'))
for (const unbrauchbar of [0, -1, 1.5, NaN, 'x', null, undefined]) assert.equal(stornoMuster(unbrauchbar), stornoMuster(24), String(unbrauchbar))
const e = basisEinstellung()
e.storno = { text: stornoMuster(36) }
assert.equal(normalisiereEinstellung(e, { now: JETZT_SERVER }).einstellung.storno.text, stornoMuster(36), 'der Server ändert den Text nicht')
assert.deepEqual(ANREDE_WAHL.map(([, name]) => name), ['Sie (förmlich)', 'Du (persönlich)'])
})
+775 -17
View File
@@ -7,13 +7,13 @@
// - "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).
// NUR=<Teil des Namens> fuehrt nur passende Pruefungen aus (zum Entwickeln); STACK=1 zeigt bei Fehlern den Aufrufweg.
// 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,
R_ANNA, R_BEN, S_HERREN, S_DAMEN, S_KINDER, S_ANFRAGE,
} from './termineTestServer.mjs'
const PW = process.env.PLAYWRIGHT_CORE || '/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core/index.mjs'
@@ -151,6 +151,7 @@ async function fixtureAntwort(fx, route, req, url, json) {
if (aktion && m === 'POST') {
const body = req.postDataJSON()
fx.aufrufe.push(['aktion', aktion[1], body])
if (fx.aktionVerzoegerung) await new Promise((fertig) => { setTimeout(fertig, fx.aktionVerzoegerung) })
const b = fx.buchungen.find((x) => x.id === aktion[1])
if (b.gastName === 'Konflikt Test') return json({ error: 'Dieser Termin ist nicht mehr offen.', code: 'nicht_moeglich', fehler: { _: 'Dieser Termin ist nicht mehr offen.' } }, 409)
if (body.aktion === 'annehmen') { b.status = 'bestaetigt'; b.angenommenAt = JETZT.toISOString() }
@@ -176,14 +177,15 @@ async function check(name, fn, { context = {}, fixture = null, reset = {}, jetzt
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)
ctx.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) => {
// jede Seite des Kontexts (auch ein zweiter Tab, ctx.newPage()) wird beobachtet und beantwortet die Schnittstellen gleich
ctx.on('page', (seite) => {
seite.on('pageerror', (e) => fehler.push(`JavaScript-Fehler: ${e.message}`))
seite.on('dialog', (d) => { fehler.push(`Browser-Dialog ${d.type()}: ${d.message()}`); d.dismiss().catch(() => {}) })
seite.on('request', (r) => { try { const u = new URL(r.url()); if (u.protocol.startsWith('http')) hosts.add(u.host) } catch { /* data: u. a. */ } })
})
await ctx.route('**/api/**', async (route) => {
const req = route.request()
const url = new URL(req.url())
const p = url.pathname
@@ -191,6 +193,8 @@ async function check(name, fn, { context = {}, fixture = null, reset = {}, jetzt
if (p.startsWith('/api/funktionen') || p.startsWith('/api/termine')) return fixture ? fixtureAntwort(fixture, route, req, url, json) : route.continue()
return portalHilfe(p, json)
})
const page = await ctx.newPage()
page.fx = fixture
try {
await fn(page, ctx)
assert(fehler.length === 0, fehler.join(' | '))
@@ -199,6 +203,7 @@ async function check(name, fn, { context = {}, fixture = null, reset = {}, jetzt
} catch (e) {
results.push([name, false])
console.log(`FAIL ${name}: ${e.message.split('\n')[0]}`)
if (process.env.STACK) console.log(e.stack)
await page.screenshot({ path: `${SHOTS}/fehler-portal-${name}.png` }).catch(() => {})
} finally {
await ctx.close()
@@ -208,6 +213,29 @@ async function check(name, fn, { context = {}, fixture = null, reset = {}, jetzt
const oeffne = async (page, hash) => {
await page.goto(`${srv.url}/dashboard.html${hash}`)
}
/** Ansicht öffnen und zum Reiter wechseln ('leistungen' | 'team' | 'einstellungen' | 'termine'). */
const oeffneReiter = async (page, key) => {
await oeffne(page, '#termine')
await warteAnsicht(page)
await page.locator(`[data-reiter="${key}"]`).click()
await page.locator(`#tm-tab-${key}[aria-selected="true"]`).waitFor()
await page.locator('#tm-panel .tm-tab').waitFor()
}
/** Gespeicherte Einstellung und Version im Nachbau von Appwrite. */
const gespeichert = () => srv.einstellung()
const version = () => srv.aw.docs('termineConfig').get(srv.FEATURE).version
/** Wartet (bis 5 s), bis eine Bedingung im Nachbau des Servers stimmt (z. B. ein Speichern angekommen ist). */
async function warteBis(bedingung, text) {
for (let i = 0; i < 100; i += 1) {
if (bedingung()) return
await new Promise((fertig) => { setTimeout(fertig, 50) })
}
throw new Error(`Zeit abgelaufen: ${text}`)
}
const zeileMitId = (page, id) => page.locator(`#tm-panel li[data-id="${id}"]`)
const eintrag = (page, text) => page.locator('#tm-panel li.tm-eintrag', { hasText: text })
const formular = (page, name) => page.getByRole('form', { name })
const fehlerAm = (loc, pfad) => loc.locator(`[data-fehler="${pfad}"]`)
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()
@@ -496,7 +524,10 @@ async function pruefungen() {
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-04', status: 'angefragt' }, 'Abfrage: ab heute bis Ende des Buchungszeitraums (60 Tage), nur Anfragen')
// Ruling R50: Offene Anfragen immer ab heute 365 Tage (sechs Aufrufe zu hoechstens 62 Tagen), nur Anfragen
const anfragenAufrufe = fx.aufrufe.filter((a) => a[0] === 'liste' && a[1].status === 'angefragt').map((a) => a[1]).sort((a, b) => (a.von < b.von ? -1 : 1))
eq(anfragenAufrufe.length, 6, 'sechs Abfragefenster')
eq([anfragenAufrufe[0].von, anfragenAufrufe.at(-1).bis], ['2026-10-05', '2027-10-05'], 'ab heute bis heute + 365 Tage')
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
@@ -621,18 +652,18 @@ async function pruefungen() {
await oeffne(page, '#termine')
await warteAnsicht(page)
const banner = page.locator('.tm-box-info')
await banner.getByText('3 Anfragen warten').waitFor()
await banner.getByText('4 Anfragen warten').waitFor()
await knopf(banner, 'Anfragen ansehen').click()
await page.waitForFunction(() => document.querySelectorAll('li.tm-eintrag').length === 3)
// Zahl und Liste stimmen ueberein; die ferne Anfrage (107 Tage) steht dabei
eq(await page.locator('li.tm-eintrag').evaluateAll((l) => l.map((e) => e.dataset.id)), ['banfrage', 'bfern1', 'bfern2'], 'alle drei Anfragen in Zeitfolge')
await page.waitForFunction(() => document.querySelectorAll('li.tm-eintrag').length === 4)
// Zahl und Liste stimmen ueberein; die ferne Anfrage (107 Tage) steht dabei, auch die jenseits des Buchungszeitraums von 180 Tagen (330 Tage)
eq(await page.locator('li.tm-eintrag').evaluateAll((l) => l.map((e) => e.dataset.id)), ['banfrage', 'bfern1', 'bfern2', 'bweit'], 'alle vier Anfragen in Zeitfolge')
assert((await textVon(reihe(page, 'Fern Eins'))).includes('Angefragt'), 'ferne Anfrage mit Status')
// Abfragen: lueckenlose Fenster ab heute bis heute + Buchungszeitraum (180 Tage), jedes hoechstens 62 Tage nach seinem Beginn, nur Anfragen
// Abfragen (Ruling R50): lueckenlose Fenster ab heute bis heute + 365 Tage, unabhaengig vom Buchungszeitraum (hier 180 Tage), jedes hoechstens 62 Tage nach seinem Beginn, nur Anfragen
const tageZwischen = (a, b) => Math.round((Date.parse(`${b}T00:00:00Z`) - Date.parse(`${a}T00:00:00Z`)) / 86400000)
const fenster = fx.aufrufe.filter((a) => a[0] === 'liste' && a[1].status === 'angefragt').map((a) => a[1]).sort((a, b) => (a.von < b.von ? -1 : 1))
eq(fenster.length, 3, 'drei Aufrufe')
eq(fenster.length, 6, 'sechs Aufrufe')
eq(fenster[0].von, '2026-10-05', 'beginnt heute')
eq(fenster.at(-1).bis, '2027-04-03', 'endet nach 180 Tagen')
eq(fenster.at(-1).bis, '2027-10-05', 'endet nach 365 Tagen')
fenster.forEach((f, i) => {
assert(tageZwischen(f.von, f.bis) <= 62, `Fenster ${i} zu lang: ${f.von}..${f.bis}`)
if (i > 0) eq(tageZwischen(fenster[i - 1].bis, f.von), 1, `Fenster ${i} schließt lückenlos an`)
@@ -646,6 +677,7 @@ async function pruefungen() {
buchung('banfrage', '2026-10-05', '15:00', 90, { status: 'angefragt', leistung: 'Brautfrisur', gastName: 'Lea Braun' }),
buchung('bfern1', '2027-01-20', '09:00', 90, { status: 'angefragt', leistung: 'Brautfrisur', gastName: 'Fern Eins' }),
buchung('bfern2', '2027-03-10', '14:00', 90, { status: 'angefragt', leistung: 'Brautfrisur', gastName: 'Fern Zwei' }),
buchung('bweit', '2027-09-01', '11:00', 90, { status: 'angefragt', leistung: 'Brautfrisur', gastName: 'Weit Weg' }),
]
return fx
})(),
@@ -1071,6 +1103,732 @@ async function pruefungen() {
await shot(page, name === 'echt_zeitzone_new_york' ? 'echt-new-york' : 'echt-berlin')
}, { context: { timezoneId: zone }, jetzt: new Date('2026-10-05T23:30:00Z') })
}
// ============================================================ Aufgabe 21b: Leistungen, Team & Plätze, Einstellungen (echte Router)
// ------------------------------------------------------------ Leistungen: anlegen, bearbeiten, Reihenfolge
await check('echt_leistung_anlegen_bearbeiten_reihenfolge', async (page) => {
await oeffneReiter(page, 'leistungen')
assert((await textVon(page.locator('#tm-panel .tm-box-info'))).includes('Bitte keine Gesundheitsangaben.'), 'Hinweis zu Gesundheitsangaben')
eq(await page.locator('#tm-panel li.tm-eintrag .tm-name').allInnerTexts(), ['Herrenschnitt', 'Damenschnitt', 'Kinderschnitt', 'Färben', 'Kennenlern-Beratung', 'Brautfrisur'], 'Reihenfolge wie gespeichert')
const meta = await textVon(eintrag(page, 'Herrenschnitt').locator('.tm-meta').first())
assert(['30 Min.', '5 Min. Pause danach', '28,00 €', 'Sofort buchbar', 'Anna, Ben'].every((t) => meta.includes(t)), `Kurzfassung: ${meta}`)
assert((await textVon(eintrag(page, 'Brautfrisur'))).includes('Nur als Anfrage') && (await textVon(eintrag(page, 'Kennenlern-Beratung'))).includes('ohne Preis'), 'Anfrage und ohne Preis')
await shot(page, 'leistungen')
// Leistung anlegen: erst Fehler (Dauer leer: im Feld erkannt; ohne Name und Person: vom Server), dann ein gueltiger Eintrag
const v0 = version()
await knopf(page, '+ Leistung anlegen').click()
const neu = formular(page, 'Leistung anlegen')
await neu.getByLabel('Dauer (Minuten)', { exact: true }).fill('')
await knopf(neu, 'Leistung anlegen').click()
eq(await textVon(fehlerAm(neu, 'leistungen.6.dauer')), 'Bitte gib die Dauer in ganzen Minuten an.', 'Dauer leer')
eq(version(), v0, 'nichts gespeichert')
await neu.getByLabel('Dauer (Minuten)', { exact: true }).fill('20')
await knopf(neu, 'Leistung anlegen').click()
await fehlerAm(neu, 'leistungen.6.name').filter({ hasText: 'Bitte gib einen Namen an.' }).waitFor()
assert((await textVon(fehlerAm(neu, 'leistungen.6.ressourcen'))).includes('mindestens eine aktive Person'), 'Zuordnung fehlt (Server)')
eq(await neu.getByLabel('Name', { exact: true }).getAttribute('aria-invalid'), 'true', 'Feld markiert')
eq(version(), v0, 'auch der Server hat nichts gespeichert')
await neu.getByLabel('Name', { exact: true }).fill('Bartpflege')
await neu.getByLabel('Beschreibung (optional)').fill('Trimmen und Konturen.')
await neu.getByLabel('Pause danach (Minuten)').fill('5')
await neu.getByLabel('Preis', { exact: true }).selectOption('fest')
await neu.getByLabel('Betrag in Euro').fill('19,90')
await neu.getByLabel('Anna · Person').check()
await shot(page, 'leistung-anlegen')
await knopf(neu, 'Leistung anlegen').click()
await eintrag(page, 'Bartpflege').waitFor()
assert((await textVon(meldung(page))).includes('Leistung angelegt.'), 'Meldung')
eq(version(), v0 + 1, 'ein Speichern')
const angelegt = gespeichert().leistungen.at(-1)
assert(/^s_[a-z0-9]{8}$/.test(angelegt.id), `ID im Muster s_ + 8: ${angelegt.id}`)
eq({ ...angelegt, id: 'x' }, { id: 'x', name: 'Bartpflege', beschreibung: 'Trimmen und Konturen.', dauer: 20, pufferNach: 5, preisArt: 'fest', preisCent: 1990, buchungsart: 'sofort', ressourcen: [R_ANNA], aktiv: true, reihenfolge: 7 }, 'gespeicherte Leistung')
const meta2 = await textVon(eintrag(page, 'Bartpflege').locator('.tm-meta').first())
assert(meta2.includes('19,90 €') && meta2.includes('Anna') && !meta2.includes('Ben'), `Zeile: ${meta2}`)
// Gaeste sehen sie sofort
const conf = await (await fetch(`${API}/${KEY}/config`)).json()
const gast = conf.leistungen.find((l) => l.name === 'Bartpflege')
assert(gast && gast.preis === '19,90 €' && gast.id === angelegt.id, 'oeffentliche Config nennt die neue Leistung')
// bearbeiten, zweimal hintereinander ohne Neuladen (die Version wird mitgefuehrt)
for (const dauer of ['35', '40']) {
await knopf(eintrag(page, 'Herrenschnitt'), 'Bearbeiten: Herrenschnitt').click()
const form = formular(page, 'Leistung bearbeiten: Herrenschnitt')
await form.getByLabel('Dauer (Minuten)', { exact: true }).fill(dauer)
await knopf(form, 'Speichern').click()
await page.waitForFunction((d) => document.querySelector('#tm-panel li[data-id="s_herren01"] .tm-meta')?.textContent.includes(`${d} Min.`), dauer)
eq(gespeichert().leistungen[0].dauer, Number(dauer), `Dauer ${dauer} gespeichert`)
}
eq(await page.locator('#tm-panel .tm-box-warn:not([hidden])').count(), 0, 'kein Konflikt mit sich selbst')
assert((await textVon(meldung(page))).includes('Leistung gespeichert.'), 'Meldung Speichern')
// Reihenfolge: Damenschnitt nach oben
await knopf(page, 'Nach oben: Damenschnitt').click()
await page.waitForFunction(() => document.querySelector('#tm-panel li.tm-eintrag .tm-name')?.textContent === 'Damenschnitt')
eq(gespeichert().leistungen.map((l) => [l.name, l.reihenfolge]).slice(0, 2), [['Damenschnitt', 1], ['Herrenschnitt', 2]], 'Reihenfolge gespeichert')
eq(await knopf(page, 'Nach oben: Damenschnitt').isDisabled(), true, 'ganz oben: nicht weiter hoch')
eq(await knopf(page, 'Nach unten: Bartpflege').isDisabled(), true, 'ganz unten: nicht weiter runter')
// der Fokus bleibt in der verschobenen Zeile (kein Verlust nach dem Neuzeichnen)
eq(await page.evaluate(() => document.activeElement?.closest('li')?.dataset.id), 's_damen001', 'Fokus in der verschobenen Zeile')
// Deaktivieren (Leistung bleibt gespeichert), Gaeste sehen sie nicht mehr
await knopf(eintrag(page, 'Kinderschnitt'), 'Bearbeiten: Kinderschnitt').click()
const kind = formular(page, 'Leistung bearbeiten: Kinderschnitt')
await kind.getByLabel(/^Aktiv/).uncheck()
await knopf(kind, 'Speichern').click()
await eintrag(page, 'Kinderschnitt').locator('.tm-pille', { hasText: 'Inaktiv' }).waitFor()
assert(!(await (await fetch(`${API}/${KEY}/config`)).json()).leistungen.some((l) => l.name === 'Kinderschnitt'), 'inaktive Leistung nicht oeffentlich')
await shot(page, 'leistungen-nach-aenderung')
})
// ------------------------------------------------------------ Leistung mit kuenftigem Termin entfernen ⇒ Anzahl am Eintrag
await check('echt_leistung_entfernen_mit_termin', async (page) => {
await buche({ leistung: S_HERREN, tag: '2026-10-12', zeit: '10:00', email: 'a@example.com' })
await buche({ leistung: S_HERREN, tag: '2026-10-13', zeit: '11:00', email: 'b@example.com' })
await buche({ leistung: S_DAMEN, tag: '2026-10-14', zeit: '09:00', email: 'c@example.com', name: 'Dora Damen' })
await oeffneReiter(page, 'leistungen')
const v0 = version()
// zwei kuenftige Termine ⇒ Mehrzahl, die Leistung bleibt
await knopf(eintrag(page, 'Herrenschnitt'), 'Bearbeiten: Herrenschnitt').click()
const form = formular(page, 'Leistung bearbeiten: Herrenschnitt')
await knopf(form, 'Entfernen').click()
const frage = page.getByRole('form', { name: 'Leistung „Herrenschnitt“ entfernen?' })
await frage.waitFor()
assert((await textVon(frage)).includes('Das kann nicht rückgängig gemacht werden.'), 'Rueckfrage in der Seite')
await knopf(frage, 'Ja, entfernen').click()
await frage.locator('.tm-fehler:not(:empty)').waitFor()
eq(await textVon(frage.locator('.tm-fehler:not(:empty)')), 'Für diese Leistung gibt es noch 2 künftige Termine. Deaktiviere sie stattdessen.', 'Anzahl am Eintrag')
eq(gespeichert().leistungen.some((l) => l.id === S_HERREN), true, 'Leistung bleibt')
eq(version(), v0, 'nichts gespeichert')
await shot(page, 'leistung-entfernen-mit-termin')
// Abbrechen schliesst die Rueckfrage
await knopf(frage, 'Abbrechen').click()
eq(await frage.count(), 0, 'Rueckfrage zu')
// ein Termin ⇒ Einzahl
await knopf(eintrag(page, 'Damenschnitt'), 'Bearbeiten: Damenschnitt').click()
await knopf(formular(page, 'Leistung bearbeiten: Damenschnitt'), 'Entfernen').click()
const frageDamen = page.getByRole('form', { name: 'Leistung „Damenschnitt“ entfernen?' })
await knopf(frageDamen, 'Ja, entfernen').click()
await frageDamen.locator('.tm-fehler:not(:empty)').waitFor()
eq(await textVon(frageDamen.locator('.tm-fehler:not(:empty)')), 'Für diese Leistung gibt es noch 1 künftigen Termin. Deaktiviere sie stattdessen.', 'Einzahl')
// Deaktivieren geht, die Termine bleiben
await knopf(frageDamen, 'Abbrechen').click()
await formular(page, 'Leistung bearbeiten: Damenschnitt').getByLabel(/^Aktiv/).uncheck()
await knopf(formular(page, 'Leistung bearbeiten: Damenschnitt'), 'Speichern').click()
await eintrag(page, 'Damenschnitt').locator('.tm-pille', { hasText: 'Inaktiv' }).waitFor()
eq(srv.buchungen().filter((b) => b.leistungId === S_DAMEN && b.status === 'bestaetigt').length, 1, 'Termin der inaktiven Leistung bleibt')
// ohne Termin ⇒ weg
await knopf(eintrag(page, 'Kinderschnitt'), 'Bearbeiten: Kinderschnitt').click()
await knopf(formular(page, 'Leistung bearbeiten: Kinderschnitt'), 'Entfernen').click()
await knopf(page.getByRole('form', { name: 'Leistung „Kinderschnitt“ entfernen?' }), 'Ja, entfernen').click()
await eintrag(page, 'Kinderschnitt').waitFor({ state: 'detached' })
eq(gespeichert().leistungen.some((l) => l.id === S_KINDER), false, 'Kinderschnitt entfernt')
assert((await textVon(meldung(page))).includes('Leistung „Kinderschnitt“ entfernt.'), 'Meldung')
})
// ------------------------------------------------------------ Team & Plätze: Wochenplan ändern, neuer Platz mit Zuordnung
await check('echt_team_wochenplan_und_neuer_platz', async (page) => {
await oeffneReiter(page, 'team')
const kopfzeile = await page.locator('#tm-panel li.tm-eintrag .tm-meta').allInnerTexts()
assert(kopfzeile[0].includes('Person') && kopfzeile[0].includes('Zeiten an 6 Tagen') && kopfzeile[0].includes('6 Leistungen'), `Kurzfassung: ${kopfzeile[0]}`)
await shot(page, 'team')
// Wochenplan von Anna: Montag ändern
await knopf(zeileMitId(page, R_ANNA), 'Bearbeiten: Anna').click()
const form = formular(page, 'Bearbeiten: Anna')
const montag = form.getByLabel('Montag', { exact: true })
eq(await montag.inputValue(), '09:00–12:30, 13:30–18:00', 'Wochenplan als Text')
eq(await form.getByLabel('Sonntag', { exact: true }).inputValue(), '', 'Sonntag geschlossen')
await shot(page, 'team-bearbeiten')
// ungueltige Eingaben ⇒ Fehler am Tag, nichts wird gesendet
const v0 = version()
await montag.fill('10-25')
await form.getByLabel('Dienstag', { exact: true }).fill('09:00–12:00, 11:00–13:00')
await knopf(form, 'Speichern').click()
assert((await textVon(fehlerAm(form, 'ressourcen.0.plan.1'))).includes('09:00–12:30, 13:30–18:00'), 'Format-Hinweis am Montag')
assert((await textVon(fehlerAm(form, 'ressourcen.0.plan.2'))).includes('ohne Überlappung'), 'Überlappung am Dienstag')
eq(await montag.getAttribute('aria-invalid'), 'true', 'Montag markiert')
eq(version(), v0, 'nichts gespeichert')
// gueltig: Montag 10:00–14:00 (Schreibweise wird vereinheitlicht), Dienstag und Sonntag
await montag.fill('10:00 - 14:00')
await form.getByLabel('Dienstag', { exact: true }).fill('9:00–12:00 und 14:00 bis 24:00')
await form.getByLabel('Sonntag', { exact: true }).fill('10:00–11:00')
await knopf(form, 'Speichern').click()
await page.waitForFunction(() => document.querySelector('#tm-panel li[data-id="r_anna0001"] .tm-meta')?.textContent.includes('Zeiten an 7 Tagen'))
eq(version(), v0 + 1, 'ein Speichern')
const plan = gespeichert().ressourcen[0].plan
eq([plan['1'], plan['2'], plan['7'], plan['3']], [[['10:00', '14:00']], [['09:00', '12:00'], ['14:00', '24:00']], [['10:00', '11:00']], [['09:00', '12:30'], ['13:30', '18:00']]], 'Plan gespeichert')
// Gaeste sehen die neuen Zeiten: Montag 2026-10-12 hat bei Anna nichts vor 10:00
const tage = await (await fetch(`${API}/${KEY}/zeiten?leistung=${S_KINDER}&ressource=${R_ANNA}&tag=2026-10-12`)).json()
eq([tage.zeiten?.[0], tage.zeiten?.at(-1)], ['10:00', '13:15'], 'erste und letzte Startzeit am Montag (15-Minuten-Raster, 30 Min. + 5 Pause bis 14:00)')
// neuer Platz mit Zeiten und Zuordnung in EINEM Speichern
const v1 = version()
await knopf(page, '+ Person oder Platz hinzufügen').click()
const neu = formular(page, 'Person oder Platz hinzufügen')
await neu.getByLabel('Name', { exact: true }).fill('Kabine 1')
await neu.getByLabel('Art', { exact: true }).selectOption('platz')
await neu.getByLabel('Montag', { exact: true }).fill('09:00–12:00')
await neu.getByLabel('Herrenschnitt').check()
await neu.getByLabel('Färben').check()
await knopf(neu, 'Hinzufügen').click()
await eintrag(page, 'Kabine 1').waitFor()
eq(version(), v1 + 1, 'ein Speichern für Platz und Zuordnung')
const e = gespeichert()
const platz = e.ressourcen.at(-1)
assert(/^r_[a-z0-9]{8}$/.test(platz.id) && platz.typ === 'platz' && platz.aktiv === true, `Platz: ${JSON.stringify(platz)}`)
assert(/^[A-Za-z0-9_-]{43}$/.test(platz.feedToken), 'Kalender-Token vergibt der Server')
eq(e.leistungen.filter((l) => l.ressourcen.includes(platz.id)).map((l) => l.name), ['Herrenschnitt', 'Färben'], 'Zuordnung bei den Leistungen')
eq(await textVon(eintrag(page, 'Kabine 1').locator('.tm-meta').first()), 'Platz · Zeiten an 1 Tag · 2 Leistungen', 'Kurzfassung')
// der neue Platz zeigt seinen Kalender-Link, sobald die Daten neu geladen sind (kein Neuladen der Seite noetig)
await knopf(eintrag(page, 'Kabine 1'), 'Bearbeiten: Kabine 1').click()
await page.locator('#tm-panel li[data-id="' + platz.id + '"] .tm-link-feld').waitFor()
assert((await page.locator('#tm-panel li[data-id="' + platz.id + '"] .tm-link-feld').inputValue()).endsWith(`/${platz.feedToken}.ics`), 'Link mit dem Token des Servers')
// Person entfernen: Zeiten, Zuordnung und Abwesenheiten gehen mit
await knopf(zeileMitId(page, R_BEN), 'Bearbeiten: Ben').click()
await knopf(formular(page, 'Bearbeiten: Ben'), 'Entfernen').click()
await knopf(page.getByRole('form', { name: '„Ben“ entfernen?' }), 'Ja, entfernen').click()
await zeileMitId(page, R_BEN).waitFor({ state: 'detached' })
const nach = gespeichert()
eq(nach.ressourcen.map((r) => r.name), ['Anna', 'Kabine 1'], 'Ben entfernt')
eq(nach.leistungen.every((l) => !l.ressourcen.includes(R_BEN)), true, 'aus allen Zuordnungen entfernt')
})
// ------------------------------------------------------------ Team: Fehler des Servers stehen an der Leistung (nicht in der Person)
await check('echt_team_fehler_zuordnung', async (page) => {
await oeffneReiter(page, 'team')
await knopf(zeileMitId(page, R_ANNA), 'Bearbeiten: Anna').click()
const form = formular(page, 'Bearbeiten: Anna')
// Anna und Ben bieten alles an; zuerst bei Ben alles abwaehlen ⇒ dann haengt jede Leistung nur an Anna
await knopf(zeileMitId(page, R_BEN), 'Bearbeiten: Ben').click()
const ben = formular(page, 'Bearbeiten: Ben')
for (const kasten of await ben.locator('fieldset input[type=checkbox]').all()) await kasten.uncheck()
await knopf(ben, 'Speichern').click()
await page.waitForFunction(() => document.querySelector('#tm-panel li[data-id="r_ben00002"] .tm-meta')?.textContent.includes('keine Leistung'))
// jetzt Anna auf „Inaktiv“: alle aktiven Leistungen haben dann niemanden
await knopf(zeileMitId(page, R_ANNA), 'Bearbeiten: Anna').click()
const anna = formular(page, 'Bearbeiten: Anna')
await anna.getByLabel(/^Aktiv/).first().uncheck()
const v0 = version()
await knopf(anna, 'Speichern').click()
const slot = fehlerAm(anna, 'leistungen.0.ressourcen')
await slot.filter({ hasText: 'mindestens eine aktive Person' }).waitFor()
eq(await anna.locator('[data-fehler^="leistungen."]:not(:empty)').count(), 6, 'ein Fehler an jeder der sechs Leistungen')
eq(await anna.locator('input[aria-invalid="true"]').count(), 6, 'sechs Haken markiert')
eq(version(), v0, 'nichts gespeichert')
await shot(page, 'team-fehler-zuordnung')
})
// ------------------------------------------------------------ Team: Abwesenheiten, Feiertage, Sonderöffnungen
await check('echt_team_ausnahmen', async (page) => {
await oeffneReiter(page, 'team')
// Abwesenheit
const v0 = version()
await knopf(page, '+ Abwesenheit eintragen').click()
const neu = formular(page, 'Abwesenheit eintragen')
await neu.getByLabel('Von (erster Tag)').fill('2026-11-06')
await neu.getByLabel('Bis (letzter Tag)').fill('2026-11-02')
await knopf(neu, 'Abwesenheit eintragen').click()
await fehlerAm(neu, 'abwesenheiten.0.bis').filter({ hasText: 'Das Ende darf nicht vor dem Beginn liegen.' }).waitFor() // Fehler des Servers am Feld
eq(version(), v0, 'nichts gespeichert')
await neu.getByLabel('Von (erster Tag)').fill('2026-11-02')
await neu.getByLabel('Bis (letzter Tag)').fill('2026-11-06')
await neu.getByLabel('Titel (optional)').fill('Betriebsurlaub')
await knopf(neu, 'Abwesenheit eintragen').click()
await page.locator('#tm-panel [aria-label="Abwesenheiten"] li', { hasText: 'Betriebsurlaub' }).waitFor()
const a = gespeichert().abwesenheiten[0]
assert(/^a_[a-z0-9]{8}$/.test(a.id), `ID a_ + 8: ${a.id}`)
eq({ ...a, id: 'x' }, { id: 'x', ressourceId: null, von: '2026-11-02', bis: '2026-11-06', titel: 'Betriebsurlaub' }, 'Abwesenheit gespeichert')
eq(await textVon(page.locator('#tm-panel [aria-label="Abwesenheiten"] li .tm-name')), '02.11.2026 – 06.11.2026', 'Zeitraum')
// ... und fuer Anna allein, vergangene stehen eingeklappt
await knopf(page, '+ Abwesenheit eintragen').click()
const anna = formular(page, 'Abwesenheit eintragen')
await anna.getByLabel('Wer ist weg?').selectOption(R_ANNA)
await anna.getByLabel('Von (erster Tag)').fill('2026-09-01')
await anna.getByLabel('Bis (letzter Tag)').fill('2026-09-05')
await knopf(anna, 'Abwesenheit eintragen').click()
await page.locator('#tm-panel summary', { hasText: 'Vergangene Abwesenheiten (1)' }).waitFor()
eq(gespeichert().abwesenheiten.length, 2, 'zwei Abwesenheiten')
// Gaeste: am 28.12. ist niemand buchbar
const tag = await (await fetch(`${API}/${KEY}/zeiten?leistung=${S_KINDER}&ressource=egal&tag=2026-11-04`)).json()
eq(tag.zeiten, [], 'in der Abwesenheit keine Zeiten')
// bearbeiten und entfernen
await knopf(page.locator('#tm-panel [aria-label="Abwesenheiten"] li'), /Bearbeiten: Betriebsurlaub/).click()
const bearbeiten = formular(page, 'Abwesenheit bearbeiten')
await bearbeiten.getByLabel('Titel (optional)').fill('Winterpause')
await knopf(bearbeiten, 'Speichern').click()
await page.locator('#tm-panel [aria-label="Abwesenheiten"] li', { hasText: 'Winterpause' }).waitFor()
await knopf(page.locator('#tm-panel [aria-label="Abwesenheiten"] li'), /Bearbeiten: Winterpause/).click()
await knopf(formular(page, 'Abwesenheit bearbeiten'), 'Entfernen').click()
await knopf(page.getByRole('form', { name: 'Abwesenheit entfernen?' }), 'Ja, entfernen').click()
await page.locator('#tm-panel [aria-label="Abwesenheiten"] li', { hasText: 'Winterpause' }).waitFor({ state: 'detached' })
eq(gespeichert().abwesenheiten.map((x) => x.titel), [''], 'nur die vergangene bleibt')
// Feiertage
const feiertage = page.getByRole('form', { name: 'Feiertage' })
eq(await feiertage.getByLabel('Bundesland').inputValue(), 'RP', 'Bundesland')
eq(await feiertage.getByLabel(/An gesetzlichen Feiertagen geschlossen/).isChecked(), false, 'Haken wie gespeichert (Testdaten: aus)')
await feiertage.getByLabel('Bundesland').selectOption('BY')
await feiertage.getByLabel(/An gesetzlichen Feiertagen geschlossen/).check()
await knopf(feiertage, 'Speichern').click()
await page.waitForFunction(() => document.body.textContent.includes('Feiertage gespeichert.'))
eq([gespeichert().bundesland, gespeichert().feiertageGeschlossen], ['BY', true], 'Feiertage gespeichert')
// Sonderöffnungen
await knopf(page, '+ Sonderöffnung eintragen').click()
const sonder = formular(page, 'Sonderöffnung eintragen')
await sonder.getByLabel('Tag', { exact: true }).fill('2026-10-18')
await sonder.getByLabel(/^Geöffnet von/).fill('09:00 bis 13')
await knopf(sonder, 'Sonderöffnung eintragen').click()
assert((await textVon(fehlerAm(sonder, 'zeiten'))).includes('09:00–12:30, 13:30–18:00'), 'Zeiten ungültig (am Feld)')
await sonder.getByLabel(/^Geöffnet von/).fill('09:00–13:00')
await knopf(sonder, 'Sonderöffnung eintragen').click()
await page.locator('#tm-panel li[data-tag="2026-10-18"]').waitFor()
eq(gespeichert().sonderoeffnungen['2026-10-18'], [['09:00', '13:00']], 'Sonderöffnung gespeichert')
eq(await textVon(page.locator('#tm-panel li[data-tag="2026-10-18"] .tm-name')), 'Sonntag, 18. Oktober 2026', 'Tag')
// ein Tag ohne Zeiten = geschlossen; doppelt ⇒ Fehler
await knopf(page, '+ Sonderöffnung eintragen').click()
const zu = formular(page, 'Sonderöffnung eintragen')
await zu.getByLabel('Tag', { exact: true }).fill('2026-10-19')
await knopf(zu, 'Sonderöffnung eintragen').click()
await page.locator('#tm-panel li[data-tag="2026-10-19"]').waitFor()
eq(gespeichert().sonderoeffnungen['2026-10-19'], [], 'geschlossen = leere Liste')
eq(await textVon(page.locator('#tm-panel li[data-tag="2026-10-19"] .tm-meta')), 'Geschlossen', 'Anzeige')
await knopf(page, '+ Sonderöffnung eintragen').click()
const doppelt = formular(page, 'Sonderöffnung eintragen')
await doppelt.getByLabel('Tag', { exact: true }).fill('2026-10-18')
await knopf(doppelt, 'Sonderöffnung eintragen').click()
assert((await textVon(fehlerAm(doppelt, 'datum'))).includes('schon eine Sonderöffnung'), 'doppelter Tag')
await knopf(doppelt, 'Abbrechen').click()
// Gaeste: der Sonntag nur bis 13 Uhr, 25.12. zu (Feiertag-Haken + Sonderöffnung)
const hs = await (await fetch(`${API}/${KEY}/zeiten?leistung=${S_KINDER}&ressource=${R_ANNA}&tag=2026-10-18`)).json()
eq([hs.zeiten[0], hs.zeiten.at(-1)], ['09:00', '12:25'].map((x, i) => (i === 1 ? hs.zeiten.at(-1) : x)), 'der Sonntag ab 09:00')
assert(hs.zeiten.at(-1) < '13:00', `der Sonntag endet vor 13 Uhr: ${hs.zeiten.at(-1)}`)
// Sonderöffnung bearbeiten und entfernen
await knopf(page.locator('#tm-panel li[data-tag="2026-10-18"]'), /Bearbeiten: Sonderöffnung 18\.10\.2026/).click()
const sb = formular(page, 'Sonderöffnung 18.10.2026')
await sb.getByLabel(/^Geöffnet von/).fill('10:00–12:00')
await knopf(sb, 'Speichern').click()
await page.waitForFunction(() => document.querySelector('#tm-panel li[data-tag="2026-10-18"] .tm-meta')?.textContent === '10:00–12:00')
await knopf(page.locator('#tm-panel li[data-tag="2026-10-18"]'), /Bearbeiten: Sonderöffnung 18\.10\.2026/).click()
await knopf(formular(page, 'Sonderöffnung 18.10.2026'), 'Entfernen').click()
await knopf(page.getByRole('form', { name: 'Sonderöffnung am 18.10.2026 entfernen?' }), 'Ja, entfernen').click()
await page.locator('#tm-panel li[data-tag="2026-10-18"]').waitFor({ state: 'detached' })
eq(Object.keys(gespeichert().sonderoeffnungen), ['2026-10-19'], 'nur der geschlossene Tag bleibt')
await shot(page, 'team-ausnahmen')
})
// ------------------------------------------------------------ Kalender-Abo-Link: kopieren, neu erzeugen
await check('echt_feed_link_kopieren_und_neu', async (page, ctx) => {
await ctx.grantPermissions(['clipboard-read', 'clipboard-write'], { origin: srv.url })
await oeffneReiter(page, 'team')
await knopf(zeileMitId(page, R_ANNA), 'Bearbeiten: Anna').click()
const feed = zeileMitId(page, R_ANNA).locator('.tm-feed')
const link = feed.locator('.tm-link-feld')
const token0 = gespeichert().ressourcen[0].feedToken
const url0 = `${srv.url}/api/public/termine/v1/kalender/${KEY}/${token0}.ics`
eq(await link.inputValue(), url0, 'Link des Servers')
eq(await link.getAttribute('readonly'), '', 'nur lesbar')
const hinweis = await textVon(feed)
assert(hinweis.includes('alle paar Stunden') && hinweis.includes('Der Link ist geheim'), `Hinweise: ${hinweis}`)
await shot(page, 'team-kalender-link')
const r0 = await fetch(url0)
assert(r0.status === 200 && (r0.headers.get('content-type') || '').startsWith('text/calendar'), `Link liefert den Kalender (${r0.status})`)
// kopieren
await knopf(feed, 'Link kopieren').click()
await feed.getByText('Der Link ist kopiert.').waitFor()
eq(await page.evaluate(() => navigator.clipboard.readText()), url0, 'Zwischenablage')
// neu erzeugen: erst Rueckfrage in der Seite, Abbrechen aendert nichts
const v0 = version()
await knopf(feed, 'Neuen Link erzeugen').click()
const frage = page.getByRole('form', { name: 'Neuen Kalender-Link erzeugen?' })
assert((await textVon(frage)).includes('Der bisherige Link funktioniert danach nicht mehr.'), 'Rückfrage nennt die Folge')
await knopf(frage, 'Abbrechen').click()
eq([gespeichert().ressourcen[0].feedToken, version()], [token0, v0], 'Abbrechen ändert nichts')
await knopf(feed, 'Neuen Link erzeugen').click()
await shot(page, 'team-kalender-link-frage')
await knopf(frage, 'Neuen Link erzeugen').click()
await page.waitForFunction((alt) => document.querySelector('#tm-panel .tm-link-feld')?.value !== alt, url0)
const token1 = gespeichert().ressourcen[0].feedToken
assert(token1 !== token0 && /^[A-Za-z0-9_-]{43}$/.test(token1), 'neues Token vom Server')
const url1 = `${srv.url}/api/public/termine/v1/kalender/${KEY}/${token1}.ics`
eq(await link.inputValue(), url1, 'neuer Link steht im Feld')
eq(version(), v0 + 1, 'das Erzeugen zählt die Version hoch')
eq((await fetch(url0)).status, 404, 'der alte Link gilt nicht mehr')
eq((await fetch(url1)).status, 200, 'der neue Link gilt')
assert((await textVon(feed)).includes('Der neue Link ist da. Der alte funktioniert nicht mehr.'), 'Hinweis am Link')
eq(gespeichert().ressourcen[1].feedToken.length, 43, 'Ben behält seinen Link')
// ein Speichern direkt danach kollidiert nicht mit dem Erzeugen (Version aus der Antwort)
const form = formular(page, 'Bearbeiten: Anna')
await form.getByLabel('Name', { exact: true }).fill('Anna M.')
await knopf(form, 'Speichern').click()
await eintrag(page, 'Anna M.').waitFor()
eq(await page.locator('#tm-panel .tm-box-warn:not([hidden])').count(), 0, 'kein Konflikt')
eq([gespeichert().ressourcen[0].name, gespeichert().ressourcen[0].feedToken], ['Anna M.', token1], 'Name gespeichert, Link bleibt der neue')
})
await check('echt_feed_link_ohne_zwischenablage', async (page, ctx) => {
await ctx.addInitScript(() => Object.defineProperty(navigator, 'clipboard', { value: undefined, configurable: true })) // kein Zugriff auf die Zwischenablage
await oeffneReiter(page, 'team')
await knopf(zeileMitId(page, R_ANNA), 'Bearbeiten: Anna').click()
const feed = zeileMitId(page, R_ANNA).locator('.tm-feed')
await knopf(feed, 'Link kopieren').click()
const status = feed.locator('[role="status"]')
await status.filter({ hasText: 'Das Kopieren hat nicht geklappt.' }).waitFor()
assert((await textVon(status)).includes('markiert'), 'Hinweis: Link ist markiert')
const auswahl = await page.evaluate(() => {
const el = document.activeElement
return { link: el?.classList.contains('tm-link-feld'), von: el?.selectionStart, bis: el?.selectionEnd, laenge: el?.value.length }
})
eq(auswahl.link, true, 'Fokus im Link-Feld')
eq([auswahl.von, auswahl.bis], [0, auswahl.laenge], 'der ganze Link ist markiert')
})
// ------------------------------------------------------------ Konflikt mit zweitem Tab
await check('echt_konflikt_zweiter_tab', async (page, ctx) => {
await oeffneReiter(page, 'einstellungen')
const zweiter = await ctx.newPage()
await oeffneReiter(zweiter, 'einstellungen')
// der zweite Tab speichert zuerst
await zweiter.getByLabel('Telefon (Pflicht)').fill('06301 9999')
await knopf(zweiter, 'Einstellungen speichern').click()
await zweiter.getByText('Einstellungen gespeichert.').waitFor()
eq(gespeichert().betrieb.telefon, '06301 9999', 'zweiter Tab gespeichert')
// der erste hat noch die alte Version
const v1 = version()
await page.getByLabel('Name des Betriebs', { exact: true }).fill('Salon Müller & Söhne')
await knopf(page, 'Einstellungen speichern').click()
const box = page.locator('#tm-panel .tm-box-warn')
await box.waitFor()
assert((await textVon(box)).includes('Die Einstellungen wurden inzwischen geändert. Bitte neu laden.'), `Konflikt-Text: ${await textVon(box)}`)
eq(await box.getAttribute('role'), 'alert', 'als Hinweis ausgezeichnet')
eq([gespeichert().betrieb.name, version()], ['Salon Müller', v1], 'nichts überschrieben')
await shot(page, 'konflikt')
// neu laden holt den Stand des anderen Tabs, danach geht das Speichern
await knopf(box, 'Neu laden').click()
await box.waitFor({ state: 'hidden' })
eq(await page.getByLabel('Telefon (Pflicht)').inputValue(), '06301 9999', 'Stand des anderen Tabs')
eq(await page.getByLabel('Name des Betriebs', { exact: true }).inputValue(), 'Salon Müller', 'eigene ungespeicherte Änderung ist weg')
await page.getByLabel('Name des Betriebs', { exact: true }).fill('Salon Müller & Söhne')
await knopf(page, 'Einstellungen speichern').click()
await page.getByText('Einstellungen gespeichert.').waitFor()
eq([gespeichert().betrieb.name, gespeichert().betrieb.telefon], ['Salon Müller & Söhne', '06301 9999'], 'beide Änderungen da')
// dasselbe im Reiter Leistungen: der zweite Tab benennt eine Leistung um, der erste hat die alte Version
await page.locator('[data-reiter="leistungen"]').click()
await page.locator('#tm-panel li[data-id="s_herren01"]').waitFor()
await zweiter.reload() // der zweite Tab ist seit dem Speichern des ersten veraltet: frisch laden
await oeffneReiter(zweiter, 'leistungen')
await knopf(zweiter.locator('#tm-panel li[data-id="s_herren01"]'), 'Bearbeiten: Herrenschnitt').click()
const f2 = zweiter.getByRole('form', { name: 'Leistung bearbeiten: Herrenschnitt' })
await f2.getByLabel('Name', { exact: true }).fill('Herrenschnitt klassisch')
await knopf(f2, 'Speichern').click()
await zweiter.locator('#tm-panel li[data-id="s_herren01"]', { hasText: 'Herrenschnitt klassisch' }).waitFor()
await knopf(zeileMitId(page, S_KINDER), 'Bearbeiten: Kinderschnitt').click()
const f1 = formular(page, 'Leistung bearbeiten: Kinderschnitt')
await f1.getByLabel('Dauer (Minuten)', { exact: true }).fill('25')
await knopf(f1, 'Speichern').click()
await page.locator('#tm-panel .tm-box-warn:not([hidden])').waitFor()
eq(gespeichert().leistungen.find((l) => l.id === S_KINDER).dauer, 30, 'Kinderschnitt unverändert')
await knopf(page.locator('#tm-panel .tm-box-warn'), 'Neu laden').click()
await page.locator('#tm-panel li[data-id="s_herren01"]', { hasText: 'Herrenschnitt klassisch' }).waitFor()
eq(await page.locator('#tm-panel .tm-box-warn:not([hidden])').count(), 0, 'Hinweis weg')
})
// ------------------------------------------------------------ Feldfehler am Feld (javascript:-URL, Zahl, Name)
await check('echt_einstellungen_feldfehler', async (page) => {
await oeffneReiter(page, 'einstellungen')
const form = page.getByRole('form', { name: 'Einstellungen' })
const url = form.getByLabel('Link zum Impressum', { exact: true })
const v0 = version()
await url.fill('javascript:alert(1)')
await form.getByLabel('Aufbewahrung (Tage)', { exact: true }).fill('5')
await form.getByLabel('Name des Betriebs', { exact: true }).fill('')
await knopf(form, 'Einstellungen speichern').click()
const fehlerUrl = fehlerAm(form, 'betrieb.impressumUrl')
await fehlerUrl.filter({ hasText: 'Bitte gib eine vollständige Adresse mit https:// an' }).waitFor()
eq(await textVon(fehlerAm(form, 'aufbewahrungTage')), 'Die Aufbewahrung muss zwischen 14 und 365 Tagen liegen.', 'Zahl außerhalb: Text des Servers am Feld')
eq(await textVon(fehlerAm(form, 'betrieb.name')), 'Bitte gib den Namen deines Betriebs an.', 'Name fehlt (Server)')
eq(await url.getAttribute('aria-invalid'), 'true', 'URL-Feld markiert')
eq(await page.evaluate(() => document.activeElement?.dataset.feld), 'betrieb.name', 'Fokus auf dem ersten fehlerhaften Feld (Reihenfolge der Seite)')
eq([version(), gespeichert().betrieb.impressumUrl], [v0, 'https://salon-mueller.example/impressum'], 'nichts gespeichert')
await shot(page, 'einstellungen-feldfehler')
// die Adresse darf nie als Link oder Skript landen: gespeichert ist sie auch nicht
eq(await page.locator('#tm-panel a[href^="javascript:"]').count(), 0, 'kein javascript:-Link in der Seite')
// eine leere Zahl wird schon im Feld erkannt
await form.getByLabel('Aufbewahrung (Tage)', { exact: true }).fill('')
await knopf(form, 'Einstellungen speichern').click()
eq(await textVon(fehlerAm(form, 'aufbewahrungTage')), 'Bitte gib eine ganze Zahl an.', 'leere Zahl')
// alles korrigieren: die Fehler verschwinden mit dem Speichern
await url.fill('https://salon-mueller.example/impressum-neu')
await form.getByLabel('Aufbewahrung (Tage)', { exact: true }).fill('120')
await form.getByLabel('Name des Betriebs', { exact: true }).fill('Salon Müller')
await knopf(form, 'Einstellungen speichern').click()
await page.getByText('Einstellungen gespeichert.').waitFor()
eq(await page.locator('#tm-panel .tm-fehler:not(:empty)').count(), 0, 'keine Fehlertexte mehr')
eq([gespeichert().betrieb.impressumUrl, gespeichert().aufbewahrungTage], ['https://salon-mueller.example/impressum-neu', 120], 'gespeichert')
})
// ------------------------------------------------------------ Einstellungen: alle Abschnitte
await check('echt_einstellungen_speichern', async (page) => {
await oeffneReiter(page, 'einstellungen')
const form = page.getByRole('form', { name: 'Einstellungen' })
const feld = (label) => form.getByLabel(label, { exact: true })
// vorbefuellt aus der gespeicherten Einstellung
eq([await feld('Name des Betriebs').inputValue(), await feld('E-Mail des Betriebs').inputValue(), await feld('Telefon (Pflicht)').inputValue()], ['Salon Müller', 'info@salon-mueller.example', '06301 1234'], 'Betrieb vorbefüllt')
eq(await feld('Akzentfarbe').inputValue(), '#0f766e', 'Farbe')
eq(await feld('Selbst absagen bis (Stunden vorher)').inputValue(), '24', 'Regeln')
// Hinweise der Spezifikation
const text = await textVon(page.locator('#tm-panel'))
assert(text.includes('Der Widerruf schlägt die Stornofrist.'), 'Hinweis: Widerruf schlägt Stornofrist')
assert(text.includes('kein fertiger Rechtstext') && await page.locator('.tm-muster .tm-pille', { hasText: 'Muster' }).count() === 1, 'Stornotext-Muster als Muster gekennzeichnet')
assert(text.includes('Bitte keine Gesundheitsangaben.'), 'Hinweis am Nachrichtenfeld')
assert(text.includes('Aktiv seit 01.10.2026'), 'aktiv seit')
const online = form.getByRole('radio', { name: /Online bezahlen/ })
eq(await online.isDisabled(), true, 'Online bezahlen ausgegraut')
assert((await textVon(page.locator('.tm-optionen'))).includes('kommt bald'), '„kommt bald“')
eq(await form.getByRole('radio', { name: /Vor Ort bezahlen/ }).isChecked(), true, 'vor Ort gewählt')
eq(await page.getByRole('link', { name: /CSV/ }).getAttribute('href'), '/api/termine/export.csv?projectId=p1', 'Export-Link')
const csv = await page.evaluate(async () => { const r = await fetch('/api/termine/export.csv?projectId=p1'); return { status: r.status, typ: r.headers.get('content-type') } })
assert(csv.status === 200 && csv.typ.startsWith('text/csv'), `Export liefert CSV: ${JSON.stringify(csv)}`)
await shot(page, 'einstellungen')
// aendern
const stornoVorher = gespeichert().storno.version
await feld('Telefon (Pflicht)').fill('06301 9999')
await feld('Wie sprichst du deine Gäste an?').selectOption('du')
await feld('Akzentfarbe').fill('#112233')
await feld('Startzeiten alle').selectOption('30')
await feld('Mindestvorlauf (Minuten)').fill('60')
await feld('Buchungszeitraum (Tage)').fill('90')
await feld('Selbst absagen bis (Stunden vorher)').fill('12')
await feld('Offene Termine je E-Mail-Adresse').fill('5')
await form.getByLabel(/Gäste dürfen die Person oder den Platz selbst wählen/).uncheck()
await feld('Telefonnummer des Gastes').selectOption('pflicht')
await feld('Nachricht des Gastes').selectOption('optional')
await knopf(form, '+ Zusatzfeld').click()
await knopf(form, '+ Zusatzfeld').click()
const zeilen = form.locator('.tm-zusatz-zeile')
await zeilen.nth(0).getByLabel('Beschriftung', { exact: true }).fill('Wunschfarbe')
await zeilen.nth(1).getByLabel('Beschriftung', { exact: true }).fill('Haarlänge')
eq(await zeilen.nth(0).getByLabel('Auswahlmöglichkeiten').isVisible(), false, 'Auswahl nur bei „Auswahl aus Liste“')
await zeilen.nth(1).getByLabel('Art', { exact: true }).selectOption('auswahl')
await zeilen.nth(1).getByLabel('Auswahlmöglichkeiten').fill('kurz\nmittel\nlang')
await zeilen.nth(1).getByLabel('Pflichtfeld').check()
await feld('Die Erinnerung kommt').selectOption('12')
await knopf(form, 'Muster in das Textfeld übernehmen').click()
assert((await feld('Dein Text').inputValue()).includes('bis 12 Stunden vorher kostenlos absagen'), 'Muster mit der Frist aus dem Feld darüber')
await feld('Deine Gäste sind').selectOption('gemischt')
await form.getByLabel('Kreditkarte', { exact: true }).uncheck()
await form.getByLabel('PayPal', { exact: true }).check()
await feld('Aufbewahrung (Tage)').fill('120')
const v0 = version()
await knopf(form, 'Einstellungen speichern').click()
await page.getByText('Einstellungen gespeichert.').waitFor()
eq(version(), v0 + 1, 'ein Speichern')
const e = gespeichert()
eq(e.betrieb.telefon, '06301 9999', 'Telefon')
eq([e.anrede, e.farbe], ['du', '#112233'], 'Anrede, Farbe')
eq(e.regeln, { raster: 30, vorlaufMin: 60, horizontTage: 90, absageBisStd: 12, maxOffeneJeEmail: 5, personWaehlbar: false }, 'Regeln')
eq(e.gastFelder, { telefon: 'pflicht', nachricht: 'optional', zusatz: [
{ key: 'wunschfarbe', label: 'Wunschfarbe', typ: 'text', optionen: [], pflicht: false },
{ key: 'haarlaenge', label: 'Haarlänge', typ: 'auswahl', optionen: ['kurz', 'mittel', 'lang'], pflicht: true },
] }, 'Gastfelder')
eq(e.erinnerung, { anbieten: true, stundenVorher: 12 }, 'Erinnerung')
assert(e.storno.text.includes('bis 12 Stunden vorher') && e.storno.version !== stornoVorher, `Storno mit neuer Fassung: ${e.storno.version}`)
eq(e.recht, { gaesteSind: 'gemischt', freizeit: false }, 'Rechtliches')
eq(e.zahlung, { modus: 'vorort', vorOrtArten: ['bar', 'ec', 'paypal'] }, 'Zahlung')
eq(e.aufbewahrungTage, 120, 'Aufbewahrung')
// Gaeste sehen es
const conf = await (await fetch(`${API}/${KEY}/config`)).json()
eq([conf.anrede, conf.zahlung.text, conf.gastFelder.zusatz.length, conf.gastFelder.telefon], ['du', 'Bezahlung vor Ort: bar, EC-Karte, PayPal', 2, 'pflicht'], 'öffentliche Config')
// die Seite zeigt den gespeicherten Stand; ein Zusatzfeld bleibt bei einer Umbenennung unter demselben Schluessel
eq(await form.locator('.tm-zusatz-zeile').count(), 2, 'zwei Zusatzfelder')
await form.locator('.tm-zusatz-zeile').nth(0).getByLabel('Beschriftung', { exact: true }).fill('Wunschfarbe der Haare')
await knopf(form, 'Einstellungen speichern').click()
await page.waitForFunction(() => document.querySelector('[data-zusatz="0"] input')?.value === 'Wunschfarbe der Haare')
await page.getByText('Einstellungen gespeichert.').waitFor()
eq(gespeichert().gastFelder.zusatz.map((z) => [z.key, z.label]), [['wunschfarbe', 'Wunschfarbe der Haare'], ['haarlaenge', 'Haarlänge']], 'Schlüssel bleibt')
// Zusatzfeld entfernen
await knopf(form, 'Zusatzfeld entfernen: Wunschfarbe der Haare').click()
eq(gespeichert().gastFelder.zusatz.length, 2, 'bis zum Speichern bleibt alles')
await knopf(form, 'Einstellungen speichern').click()
await warteBis(() => gespeichert().gastFelder.zusatz.length === 1, 'Zusatzfeld entfernt')
eq(gespeichert().gastFelder.zusatz.map((z) => z.key), ['haarlaenge'], 'eins entfernt')
})
// ------------------------------------------------------------ Telefon und Anschrift fehlen (Entwurf aus Vorlage, Ruling R41)
await check('echt_pflichtangaben_aus_vorlage', async (page) => {
await oeffne(page, '#termine')
await warteAnsicht(page)
await page.getByLabel('Friseur und Salon').check()
await knopf(page, 'Mit dieser Vorlage starten').click()
await page.locator('.tm-reiterleiste').waitFor()
const leiste = page.locator('#termine-body .tm-hinweise .tm-box-warn')
assert((await textVon(leiste)).includes('Noch nicht buchbar'), 'Hinweisleiste')
// Speichern in Leistungen: der Server verlangt zuerst die Angaben, der Hinweis sagt wo
await page.locator('[data-reiter="leistungen"]').click()
const zeile = page.locator('#tm-panel li.tm-eintrag').first()
await knopf(zeile, /^Bearbeiten:/).click()
const form = page.locator('#tm-panel form').first()
await form.getByLabel('Dauer (Minuten)', { exact: true }).fill('40')
await knopf(form, 'Speichern').click()
const fehler = form.locator('[data-fehler="_"]')
await fehler.filter({ hasText: 'Telefonnummer' }).waitFor()
assert((await textVon(fehler)).includes('Das änderst du im Reiter „Einstellungen“.'), `Hinweis auf den Reiter: ${await textVon(fehler)}`)
// Einstellungen: Hinweis in der Karte und Felder fuellen
await page.locator('[data-reiter="einstellungen"]').click()
const einstellungen = page.getByRole('form', { name: 'Einstellungen' })
const warnung = page.locator('#tm-panel .tm-abschnitt .tm-box-warn')
eq(await textVon(warnung), 'Ohne Telefon/Anschrift ist der Terminplaner auf deiner Website nicht buchbar. Es fehlt noch: deine Telefonnummer und deine Anschrift.', 'Hinweis in den Einstellungen')
await shot(page, 'einstellungen-pflichtangaben')
await einstellungen.getByLabel('Telefon (Pflicht)').fill('06301 555')
await knopf(einstellungen, 'Einstellungen speichern').click()
await fehlerAm(einstellungen, 'betrieb.adresse').filter({ hasText: 'Bitte gib die Anschrift deines Betriebs an.' }).waitFor()
await einstellungen.getByLabel('Anschrift', { exact: true }).fill('Hauptstr. 1, 67722 Winnweiler')
await knopf(einstellungen, 'Einstellungen speichern').click()
await page.getByText('Einstellungen gespeichert.').waitFor()
// der Hinweis oben verschwindet sofort, ohne die Seite neu zu laden
eq(await page.locator('#termine-body .tm-hinweise .tm-box-warn').count(), 0, 'Hinweisleiste weg')
eq(await page.locator('#tm-panel .tm-abschnitt .tm-box-warn').count(), 0, 'Hinweis in der Karte weg')
eq([gespeichert().betrieb.telefon, gespeichert().betrieb.adresse], ['06301 555', 'Hauptstr. 1, 67722 Winnweiler'], 'gespeichert')
// jetzt geht das Speichern in Leistungen
await page.locator('[data-reiter="leistungen"]').click()
await knopf(page.locator('#tm-panel li.tm-eintrag').first(), /^Bearbeiten:/).click()
const form2 = page.locator('#tm-panel form').first()
await form2.getByLabel('Dauer (Minuten)', { exact: true }).fill('40')
await knopf(form2, 'Speichern').click()
await page.getByText('Leistung gespeichert.').waitFor()
eq(gespeichert().leistungen[0].dauer, 40, 'Dauer gespeichert')
}, { reset: { ohneEinstellung: true } })
// ------------------------------------------------------------ Abgeschaltet: alle neuen Reiter nur lesen
await check('echt_abgeschaltet_reiter_nur_lesen', async (page) => {
await srv.setzeFunktion('disabled')
const v0 = version()
const alleGesperrt = (loc) => loc.locator('input, select, textarea').evaluateAll((els) => els.length > 0 && els.every((e) => e.disabled || e.closest('fieldset[disabled]')))
// Leistungen
await oeffneReiter(page, 'leistungen')
eq(await knopf(page, '+ Leistung anlegen').count(), 0, 'Leistungen: kein Anlegen')
eq(await page.locator('#tm-panel [data-aktion="hoch"], #tm-panel [data-aktion="runter"]').count(), 0, 'Leistungen: keine Reihenfolge')
await knopf(eintrag(page, 'Herrenschnitt'), 'Ansehen: Herrenschnitt').click()
const leistung = formular(page, 'Leistung: Herrenschnitt')
eq(await alleGesperrt(leistung), true, 'Leistungen: Felder gesperrt')
eq(await leistung.getByLabel('Name', { exact: true }).inputValue(), 'Herrenschnitt', 'Leistungen: Werte stehen da')
eq(await leistung.getByRole('button').allInnerTexts(), ['Schließen'], 'Leistungen: nur Schließen')
await shot(page, 'abgeschaltet-leistungen')
await knopf(leistung, 'Schließen').click()
// Team
await page.locator('[data-reiter="team"]').click()
await page.locator('#tm-panel li[data-id="r_anna0001"]').waitFor()
for (const knopfText of ['+ Person oder Platz hinzufügen', '+ Abwesenheit eintragen', '+ Sonderöffnung eintragen']) eq(await knopf(page, knopfText).count(), 0, `Team: ${knopfText} fehlt`)
eq(await alleGesperrt(page.getByRole('form', { name: 'Feiertage' })), true, 'Team: Feiertage gesperrt')
eq(await knopf(page.getByRole('form', { name: 'Feiertage' }), 'Speichern').count(), 0, 'Team: kein Speichern bei den Feiertagen')
await knopf(zeileMitId(page, R_ANNA), 'Ansehen: Anna').click()
const anna = formular(page, 'Person oder Platz: Anna')
eq(await alleGesperrt(anna), true, 'Team: Person gesperrt')
eq(await anna.getByLabel('Montag', { exact: true }).inputValue(), '09:00–12:30, 13:30–18:00', 'Team: Wochenplan sichtbar')
eq(await anna.getByRole('button').allInnerTexts(), ['Schließen'], 'Team: nur Schließen')
eq(await page.locator('#tm-panel .tm-feed').count(), 0, 'Team: kein Kalender-Link (Server liefert keine)')
await shot(page, 'abgeschaltet-team')
// Einstellungen
await page.locator('[data-reiter="einstellungen"]').click()
const form = page.getByRole('form', { name: 'Einstellungen' })
await form.waitFor()
eq(await alleGesperrt(form), true, 'Einstellungen: alle Felder gesperrt')
eq(await knopf(page, 'Einstellungen speichern').count(), 0, 'Einstellungen: kein Speichern')
eq(await knopf(form, 'Muster in das Textfeld übernehmen').count() + await knopf(form, '+ Zusatzfeld').count(), 0, 'Einstellungen: keine Änderungsknöpfe')
eq(await page.getByRole('link', { name: /CSV/ }).count(), 1, 'Einstellungen: Export bleibt')
assert((await textVon(page.locator('#tm-panel .tm-status'))).includes('Abgeschaltet'), 'Einstellungen: Status')
await shot(page, 'abgeschaltet-einstellungen')
// die Schnittstelle verweigert Schreiben auch direkt
const status = await page.evaluate(async ([projectId, v, einstellung]) => (await fetch('/api/termine/einstellungen', {
method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ projectId, version: v, einstellung }),
})).status, ['p1', v0, gespeichert()])
eq(status, 409, 'Server: Speichern bei abgeschalteter Funktion')
eq(version(), v0, 'nichts gespeichert')
})
// ------------------------------------------------------------ App-Ansicht 390 px: die drei neuen Reiter
await check('echt_390_leistungen_team_einstellungen', async (page) => {
await oeffne(page, '#funktionen')
await knopf(page.locator('[data-fn-tile="termine"]'), 'Termine öffnen').click()
await warteAnsicht(page)
// jeder Knopf, jedes Auswahlfeld und jede Zeile zum Ankreuzen ist mindestens 44 px hoch (Touch)
const kleineZiele = () => page.evaluate(() => [...document.querySelectorAll('#tm-panel button, #tm-panel select, #tm-panel textarea, #tm-panel label.tm-haken, #tm-panel input:not([type=checkbox]):not([type=radio])')]
.filter((e) => e.offsetParent !== null)
.map((e) => [e.tagName.toLowerCase(), (e.textContent.trim() || e.getAttribute('aria-label') || e.className).slice(0, 40), Math.round(e.getBoundingClientRect().height)])
.filter(([, , hoehe]) => hoehe < 44))
const pruefe = async (was) => {
eq(await passtInBreite(page), true, `${was}: kein Streifen rechts`)
eq(await kleineZiele(), [], `${was}: Touch-Ziele mindestens 44 px`)
}
// Leistungen
await page.locator('[data-reiter="leistungen"]').click()
await page.locator('#tm-panel li.tm-eintrag').first().waitFor()
await pruefe('Leistungen')
eq(await page.getByRole('tab', { selected: true }).innerText(), 'Leistungen', 'Reiter markiert')
await shot(page, '390-8-leistungen')
await knopf(eintrag(page, 'Herrenschnitt'), 'Bearbeiten: Herrenschnitt').click()
await formular(page, 'Leistung bearbeiten: Herrenschnitt').waitFor()
await pruefe('Leistung bearbeiten')
await zeileMitId(page, S_HERREN).scrollIntoViewIfNeeded()
await shot(page, '390-9-leistung-bearbeiten')
// Team & Plaetze
await page.locator('[data-reiter="team"]').click()
await page.locator('#tm-panel li[data-id="r_anna0001"]').waitFor()
await pruefe('Team')
await shot(page, '390-10-team')
await knopf(zeileMitId(page, R_ANNA), 'Bearbeiten: Anna').click()
await zeileMitId(page, R_ANNA).locator('.tm-feed').waitFor()
await pruefe('Person bearbeiten mit Kalender-Link')
await knopf(zeileMitId(page, R_ANNA), 'Neuen Link erzeugen').click()
await page.getByRole('form', { name: 'Neuen Kalender-Link erzeugen?' }).waitFor()
await pruefe('Rückfrage zum Kalender-Link')
await zeileMitId(page, R_ANNA).scrollIntoViewIfNeeded()
await shot(page, '390-11-team-bearbeiten')
await knopf(page, '+ Sonderöffnung eintragen').click()
await formular(page, 'Sonderöffnung eintragen').waitFor()
await knopf(page, '+ Abwesenheit eintragen').click()
await formular(page, 'Abwesenheit eintragen').waitFor()
await pruefe('Formulare für Ausnahmen')
// Einstellungen
await page.locator('[data-reiter="einstellungen"]').click()
await page.getByRole('form', { name: 'Einstellungen' }).waitFor()
await pruefe('Einstellungen')
await knopf(page, '+ Zusatzfeld').click()
await pruefe('Einstellungen mit Zusatzfeld')
await shot(page, '390-12-einstellungen')
eq(await page.evaluate(() => document.body.dataset.kbView), 'termine', 'App-Ansicht Terminplaner')
}, { context: { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true } })
// ------------------------------------------------------------ Meldung kommt nach dem Wechsel der Website (Fixture, Ruling R50)
await check('fx_meldung_nach_projektwechsel', async (page) => {
const fx = page.fx
await oeffne(page, '#termine')
await warteAnsicht(page)
const zeile = reihe(page, 'Lea Braun')
await knopf(zeile, 'Annehmen').click()
await knopf(zeile.locator('.tm-frage-box'), 'Annehmen').click()
// die Antwort braucht 1,2 s; in der Zwischenzeit zur anderen Website wechseln
await page.locator('#termine-project').selectOption('p2')
await page.locator('.tm-reiterleiste').waitFor()
await page.waitForFunction(() => document.querySelector('#termine-project')?.value === 'p2')
await page.waitForTimeout(2000)
eq(fx.aufrufe.filter((a) => a[0] === 'aktion').length, 1, 'die Aktion wurde abgeschickt')
eq(await meldung(page).count(), 0, 'keine Meldung der alten Website unter der neuen')
eq(await page.locator('#termine-project').inputValue(), 'p2', 'Auswahl p2')
}, {
fixture: (() => {
const fx = neueFixture()
fx.projekte = [
{ projectId: 'p1', projectName: 'Erste Website', liveUrl: '', features: { news: { state: 'off' }, termine: { state: 'active' } } },
{ projectId: 'p2', projectName: 'Zweite Website', liveUrl: '', features: { news: { state: 'off' }, termine: { state: 'active' } } },
]
fx.buchungen = alleStatus()
fx.aktionVerzoegerung = 1200
return fx
})(),
})
}
// ---------------------------------------------------------------- Lauf