Files
ClaudeandClaude Sonnet 5.5 5f99f08a39 TP2 Abschlussprüfung: Fragebogen und Bestellseite (veralteter Tab, Abo-Hinweis, Zurück aus Stripe)
- PUT /fragebogen: ein Tab mit veralteter Branche bekommt 409 EINSTELLUNG_VERALTET (die Branche wechselt nur die Vorlage), bestaetigt wird mit
  dem gespeicherten Stand vereinigt; die ungeprüften Vorlagenpreise können nicht mehr als geprüft gelten
- GET /einstellungen liefert in off aboHinweis (aboMeldung, auch bei nicht lesbarem Abo); die Hinweiskarte zeigt ihn immer und hat „Neu laden“
- Bestellseite und Statuskarte laden bei pageshow (persisted) neu, wenn der Browser sie nach der Zahlungsseite aus dem bfcache holt
- renderBestellen zeichnet „Ändern“ und „Zum Schritt“ nur mit zumSchritt; nach einem 409 bekommt die Meldung den Fokus
- Zusammenfassung nennt „Kleinunternehmer: ja/nein“

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 06:35:42 +00:00

727 lines
38 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Terminplaner im Kundenbereich: reine Helfer ohne DOM (ES-Modul; termine.js nutzt sie, test/termineView.test.js prueft sie).
Tage sind Berliner Kalendertage 'JJJJ-MM-TT' (so liefert und erwartet sie /api/termine). Gerechnet wird nur mit dem Kalender
(UTC-Mitternacht), nie mit der Zeitzone des Browsers; nur heuteBerlin() und endeZeit() fragen die Uhr bzw. Zeitzone Berlin. */
import { ANREDE_WAHL } from './termine-gaestetexte.js'
const TAG_RE = /^(\d{4})-(\d{2})-(\d{2})$/
const MONATE = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']
const WOCHENTAGE = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag']
const TAG_MS = 24 * 60 * 60 * 1000
/** Die Schnittstelle liefert Buchungen fuer hoechstens 62 Tage nach dem Beginn des Zeitraums. */
const MAX_TAGE = 62
const GELOESCHT = 'Gelöscht'
/**
* 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) {
const m = TAG_RE.exec(String(tag))
return Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]))
}
const tagVon = (ms) => new Date(ms).toISOString().slice(0, 10)
/** Der Tag n Kalendertage nach (oder vor, n < 0) dem Tag. */
export function tagPlus(tag, n) {
return tagVon(utc(tag) + n * TAG_MS)
}
/** Montag bis Sonntag der Woche, in der der Tag liegt. */
export function wocheVon(tag) {
const wochentag = new Date(utc(tag)).getUTCDay() // 0 = Sonntag
const montag = tagPlus(tag, -((wochentag + 6) % 7))
return { von: montag, bis: tagPlus(montag, 6) }
}
/** Erster bis letzter Tag des Monats, in dem der Tag liegt. */
export function monatVon(tag) {
const [jahr, monat] = String(tag).split('-').map(Number)
return { von: `${tag.slice(0, 7)}-01`, bis: tagVon(Date.UTC(jahr, monat, 0)) }
}
/**
* Zeitraum 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) {
if (ansicht === 'woche') return wocheVon(tag)
if (ansicht === 'monat') return monatVon(tag)
if (ansicht === 'anfragen') return { von: heute, bis: tagPlus(heute, 365) }
return { von: tag, bis: tag }
}
/**
* Zerlegt von..bis (beide Tage eingeschlossen) in lueckenlose Fenster, die die Schnittstelle einzeln annimmt:
* bis hoechstens 62 Tage nach von. 365 Tage ab heute sind sechs Aufrufe.
*/
export function fensterAufteilen(von, bis) {
const fenster = []
for (let anfang = von; anfang <= bis; anfang = tagPlus(fenster.at(-1).bis, 1)) {
const ende = tagPlus(anfang, MAX_TAGE)
fenster.push({ von: anfang, bis: ende < bis ? ende : bis })
}
return fenster
}
/** Ein echter Kalendertag 'JJJJ-MM-TT' aus den Jahren 2000 bis 2100 (ein Datumsfeld mit fuenfstelligem Jahr liefert anderes). */
export function istTag(text) {
const m = typeof text === 'string' ? TAG_RE.exec(text) : null
if (!m) return false
const jahr = Number(m[1])
const monat = Number(m[2])
const tag = Number(m[3])
if (jahr < 2000 || jahr > 2100) return false
const datum = new Date(Date.UTC(jahr, monat - 1, tag))
return datum.getUTCFullYear() === jahr && datum.getUTCMonth() === monat - 1 && datum.getUTCDate() === tag
}
/** Naechster (richtung 1) oder vorheriger (-1) Zeitraum: Tag +-1, Woche +-7, Monat auf den 1. des Nachbarmonats. */
export function verschiebeTag(ansicht, tag, richtung) {
if (ansicht === 'woche') return tagPlus(tag, 7 * richtung)
if (ansicht === 'monat') {
const [jahr, monat] = tag.split('-').map(Number)
return tagVon(Date.UTC(jahr, monat - 1 + richtung, 1))
}
return tagPlus(tag, richtung)
}
/** Heutiger Kalendertag in Berlin (nicht der des Browsers). */
export function heuteBerlin(jetzt = new Date()) {
return jetzt.toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' })
}
/* --------------------------------------------------------------- Datum --- */
const tagOhneJahr = (tag) => `${Number(tag.slice(8))}. ${MONATE[Number(tag.slice(5, 7)) - 1]}`
/** 'Montag, 5. Oktober 2026' */
export function datumLang(tag) {
return `${WOCHENTAGE[new Date(utc(tag)).getUTCDay()]}, ${tagOhneJahr(tag)} ${tag.slice(0, 4)}`
}
/** '05.10.2026' */
export function datumKurz(tag) {
return `${tag.slice(8)}.${tag.slice(5, 7)}.${tag.slice(0, 4)}`
}
/** Ueberschrift eines Tages in der Liste: 'Heute · Montag, 5. Oktober'; das Jahr nur, wenn es nicht das heutige ist. */
export function tagUeberschrift(tag, heute) {
const rel = tag === heute ? 'Heute · ' : tag === tagPlus(heute, 1) ? 'Morgen · ' : tag === tagPlus(heute, -1) ? 'Gestern · ' : ''
const jahr = tag.slice(0, 4) === heute.slice(0, 4) ? '' : ` ${tag.slice(0, 4)}`
return `${rel}${WOCHENTAGE[new Date(utc(tag)).getUTCDay()]}, ${tagOhneJahr(tag)}${jahr}`
}
/** Titel des gewaehlten Zeitraums ueber der Liste: '5.–11. Oktober 2026', 'Oktober 2026', 'Montag, 5. Oktober 2026'. */
export function zeitraumTitel(ansicht, tag) {
if (ansicht === 'anfragen') return 'Offene Anfragen'
if (ansicht === 'monat') return `${MONATE[Number(tag.slice(5, 7)) - 1]} ${tag.slice(0, 4)}`
if (ansicht === 'woche') {
const { von, bis } = wocheVon(tag)
if (von.slice(0, 7) === bis.slice(0, 7)) return `${Number(von.slice(8))}.–${tagOhneJahr(bis)} ${bis.slice(0, 4)}`
if (von.slice(0, 4) === bis.slice(0, 4)) return `${tagOhneJahr(von)} – ${tagOhneJahr(bis)} ${bis.slice(0, 4)}`
return `${tagOhneJahr(von)} ${von.slice(0, 4)} – ${tagOhneJahr(bis)} ${bis.slice(0, 4)}`
}
return datumLang(tag)
}
const ENDE_FORMAT = new Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })
/** Ende des Termins als Berliner Wanduhr 'HH:MM' (aus endsAt), Mitternacht als '24:00' (22:00–24:00); '' wenn kein Zeitpunkt. */
export function endeZeit(buchung) {
const ende = new Date(buchung?.endsAt)
if (Number.isNaN(ende.getTime())) return ''
const zeit = ENDE_FORMAT.format(ende)
return zeit === '00:00' ? '24:00' : zeit
}
/* ------------------------------------------------------------- Uhrzeit --- */
/** 'H:MM', 'HH:MM' oder 'HH.MM' ⇒ 'HH:MM'; ende erlaubt 24:00. Das 5-Minuten-Raster prueft der Server (und fensterParsen). */
export function zeitNormal(text, { ende = false } = {}) {
const m = typeof text === 'string' ? /^(\d{1,2})[:.](\d{2})$/.exec(text.trim()) : null
if (!m) return null
const stunde = Number(m[1])
const minute = Number(m[2])
if (minute > 59 || stunde > 24 || (stunde === 24 && (!ende || minute !== 0))) return null
return `${String(stunde).padStart(2, '0')}:${m[2]}`
}
const inMinuten = (zeit) => Number(zeit.slice(0, 2)) * 60 + Number(zeit.slice(3))
/**
* Zeitfenster eines Wochentags aus Text: '09:00–12:30, 13:30–18:00' ⇒ [['09:00','12:30'], ['13:30','18:00']].
* Getrennt durch Komma, Strichpunkt, Zeilenumbruch oder „und“; Bis-Strich: -, –, — oder „bis“. Leerer Text ⇒ [] (geschlossen).
* Ungueltig ⇒ null: keine Uhrzeit, kein 5-Minuten-Raster, Ende nicht nach dem Beginn, nicht aufsteigend, Ueberlappung.
*/
export function fensterParsen(text) {
if (typeof text !== 'string') return null
if (text.trim() === '') return []
const fenster = []
let vorher = null
for (const teil of text.split(/[,;\n]|\bund\b/)) {
const m = /^\s*(\S+?)\s*(?:[-–—]|\bbis\b)\s*(\S+)\s*$/.exec(teil)
const von = m ? zeitNormal(m[1]) : null
const bis = m ? zeitNormal(m[2], { ende: true }) : null
if (von === null || bis === null || inMinuten(von) % 5 !== 0 || inMinuten(bis) % 5 !== 0) return null
if (inMinuten(bis) <= inMinuten(von) || (vorher !== null && inMinuten(von) < inMinuten(vorher))) return null
vorher = bis
fenster.push([von, bis])
}
return fenster
}
/** [['09:00','12:30'], ['13:30','18:00']] ⇒ '09:00–12:30, 13:30–18:00' (Umkehrung von fensterParsen). */
export function fensterText(fenster) {
return (Array.isArray(fenster) ? fenster : []).map(([von, bis]) => `${von}–${bis}`).join(', ')
}
/* -------------------------------------------------------------- Status --- */
const STATUS = {
gehalten: ['Reserviert', 'wait'],
angefragt: ['Angefragt', 'wait'],
bestaetigt: ['Bestätigt', 'ok'],
erledigt: ['Erledigt', 'off'],
nichterschienen: ['Nicht erschienen', 'bad'],
abgesagt: ['Abgesagt', 'off'],
abgelehnt: ['Abgelehnt', 'off'],
verfallen: ['Verfallen', 'off'],
}
/** Anzeigetext eines Status; Unbekanntes nie roh. */
export function statusLabel(status) {
return Object.hasOwn(STATUS, status) ? STATUS[status][0] : 'Unbekannt'
}
/** Farbton der Pille: ok | wait | off | bad. */
export function statusTon(status) {
return Object.hasOwn(STATUS, status) ? STATUS[status][1] : 'off'
}
/** Filter der Terminliste; status = Liste fuer ?status= der Schnittstelle (null = alle). */
export const FILTER = [
{ key: 'alle', label: 'Alle', status: null },
{ key: 'offen', label: 'Offen', status: ['angefragt', 'bestaetigt'] },
{ key: 'angefragt', label: 'Angefragt', status: ['angefragt'] },
{ key: 'bestaetigt', label: 'Bestätigt', status: ['bestaetigt'] },
{ key: 'abgesagt', label: 'Abgesagt', status: ['abgesagt', 'abgelehnt', 'verfallen'] },
]
/** Wert fuer ?status= ('' = alle Status). */
export function filterStatus(key) {
return FILTER.find((f) => f.key === key)?.status?.join(',') ?? ''
}
/** „3 Anfragen warten“ / „1 Anfrage wartet“; bei 0 oder ohne Zahl ''. Gleicher Text steht als anfragenText in funktionen.js (Kachel) – beide gemeinsam aendern. */
export function anfragenText(n) {
if (!Number.isInteger(n) || n < 1) return ''
return n === 1 ? '1 Anfrage wartet' : `${n} Anfragen warten`
}
/** Gruende fuer die Absage durch den Betrieb (Pflichtauswahl; der Gast liest sie in der Absage-Mail). */
export const ABSAGE_GRUENDE = ['Krankheit', 'Betriebliche Gründe', 'Urlaub oder Betriebsferien', 'Auf Wunsch des Gastes', 'Sonstiges']
/* ----------------------------------------------------------- Einstellung --- */
/** „Person“, „Platz“ oder „Person/Platz“, je nachdem, was der Betrieb als Ressourcen hat. */
export function ressourcenWort(ressourcen) {
const typen = new Set((Array.isArray(ressourcen) ? ressourcen : []).map((r) => r?.typ))
if (typen.size === 1 && typen.has('platz')) return 'Platz'
if (typen.size === 1 && typen.has('person')) return 'Person'
return 'Person/Platz'
}
const FEHLT = { 'betrieb.telefon': 'deine Telefonnummer', 'betrieb.adresse': 'deine Anschrift' }
/** Was fuer die Buchbarkeit noch fehlt (fehlt = Pfade aus GET /einstellungen): „deine Telefonnummer und deine Anschrift“; '' wenn nichts. */
export function fehltText(fehlt) {
const teile = (Array.isArray(fehlt) ? fehlt : []).map((pfad) => FEHLT[pfad]).filter(Boolean)
return teile.join(' und ')
}
/* ------------------------------------------------------------- Buchungen --- */
export const istSperre = (buchung) => buchung?.quelle === 'sperre'
/** Buchungen nach Berliner Tag gruppiert (datum), Tage und Termine aufsteigend: [{ tag, buchungen }]. */
export function gruppiereNachTag(buchungen) {
const nachTag = new Map()
for (const b of [...(Array.isArray(buchungen) ? buchungen : [])].sort((a, c) => (a.startsAt < c.startsAt ? -1 : a.startsAt > c.startsAt ? 1 : 0))) {
if (!nachTag.has(b.datum)) nachTag.set(b.datum, [])
nachTag.get(b.datum).push(b)
}
return [...nachTag.keys()].sort().map((tag) => ({ tag, buchungen: nachTag.get(tag) }))
}
const hatGastdaten = (b) => Boolean((b.gastName && b.gastName !== GELOESCHT) || b.gastEmail || b.gastTelefon || b.gastNachricht)
/**
* Aktionen, die der Betrieb fuer die Buchung anbieten darf (jetzt = ms), in der Reihenfolge der Knoepfe:
* annehmen | ablehnen | verschieben | absagen | nichterschienen | anonymisieren | entsperren.
* Der Server prueft alles noch einmal; das hier haelt nur sinnlose Knoepfe weg.
*/
export function moeglicheAktionen(buchung, jetzt) {
const b = buchung || {}
const beginn = Date.parse(b.startsAt)
const ende = Date.parse(b.endsAt)
if (istSperre(b)) return ende > jetzt ? ['entsperren'] : []
const aktionen = []
const begonnen = !(beginn > jetzt)
if (b.status === 'angefragt') {
if (!begonnen) aktionen.push('annehmen')
aktionen.push('ablehnen')
if (!begonnen) aktionen.push('verschieben')
} else if (b.status === 'bestaetigt') {
if (!begonnen) aktionen.push('verschieben', 'absagen')
else aktionen.push('nichterschienen')
} else if (b.status === 'erledigt') {
aktionen.push('nichterschienen')
}
if (b.status !== 'gehalten' && hatGastdaten(b)) aktionen.push('anonymisieren')
return aktionen
}
/* ------------------------------------------- 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
}
/**
* Eintraege nach der Reihenfolge der IDs: Kopie der Liste; unbekannte IDs werden uebergangen, Eintraege ohne ID in der Liste der IDs
* (z. B. inzwischen neu) folgen hinten in ihrer bisherigen Reihenfolge. So wirkt eine gemerkte Reihenfolge auf den aktuellen Stand der Liste.
*/
export function ordneNachIds(liste, ids) {
const eintraege = Array.isArray(liste) ? liste : []
const reihenfolge = new Map((Array.isArray(ids) ? ids : []).map((id, stelle) => [id, stelle]))
const stelle = (eintrag) => (reihenfolge.has(eintrag?.id) ? reihenfolge.get(eintrag.id) : Infinity)
return eintraege.map((eintrag, alt) => [eintrag, alt]).sort(([a, altA], [b, altB]) => (stelle(a) === stelle(b) ? altA - altB : stelle(a) < stelle(b) ? -1 : 1)).map(([eintrag]) => eintrag)
}
/** '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))}`
}
/**
* Text des Banners einer abgeschalteten Funktion nach Abschaltgrund. abgeschaltet = { grund, seit, loeschungAm } aus GET /api/termine/einstellungen
* (grund: 'abo_ende' | 'abo_ende_vor_einrichtung' | 'team' | 'kunde_geloescht' | null, loeschungAm: 'JJJJ-MM-TT' oder null; null/fehlend = bisheriger Satz).
* Liefert { davor, danach, mitExport }: mit Export steht zwischen den Teilen der Export-Link ([davor, Link 'exportieren', danach]), ohne
* Export (nur wenn das Abo vor der Einrichtung endete: es gibt nichts zu exportieren) folgen die Teile direkt aufeinander.
* Das Loeschdatum steht nur da, wenn loeschungAm ein gueltiger Kalendertag ist. Du-Form; der Wortlaut ist ein Vorschlag fuer die Rechtsrunde.
*/
export function abschaltText(abgeschaltet) {
const { grund, loeschungAm } = abgeschaltet || {}
const loeschen = istTag(loeschungAm) ? ` Ab dem ${datumKurz(loeschungAm)} löschen wir die Daten.` : ''
if (grund === 'abo_ende_vor_einrichtung') {
return { davor: 'Der Terminplaner ist abgeschaltet, weil dein Wartungs-Abo endete, bevor der Terminplaner eingerichtet war.', danach: loeschen, mitExport: false }
}
const davor = grund === 'abo_ende'
? 'Der Terminplaner ist abgeschaltet, weil dein Wartungs-Abo beendet ist. Du kannst Termine nur noch ansehen und '
: 'Der Terminplaner ist für diese Website abgeschaltet. Du kannst Termine nur noch ansehen und '
return { davor, danach: `.${loeschen}`, mitExport: true }
}
/* --------------------------------------------------------- Fragebogen (TP2) --- */
/**
* Die acht Schritte des Fragebogens als [Schluessel, Titel], in der Reihenfolge des Servers. Spiegel von SCHRITTE und SCHRITT_TITEL in
* server/services/termine/fragebogen.js (test/termineView.test.js vergleicht beide; der Titel steht auch in „Bitte bestaetige den Schritt …“).
* Der neunte Schritt „Pruefen und bestellen“ ist kein Fragebogen-Schritt und wird nie bestaetigt.
*/
export const FRAGEBOGEN_SCHRITTE = [
['branche', 'Branche und Betrieb'],
['wer', 'Wer wird gebucht'],
['leistungen', 'Leistungen'],
['zeiten', 'Zeiten'],
['regeln', 'Regeln'],
['gastfelder', 'Gastfelder'],
['benachrichtigung', 'Benachrichtigung'],
['website', 'Website'],
]
/** Stand der Schritte aus fragebogen.bestaetigt: [{ key, titel, erledigt }] in Schrittfolge. Ohne Fragebogen ist nichts erledigt. */
export function schrittStatus(fragebogen) {
const bestaetigt = Array.isArray(fragebogen?.bestaetigt) ? fragebogen.bestaetigt : []
return FRAGEBOGEN_SCHRITTE.map(([key, titel]) => ({ key, titel, erledigt: bestaetigt.includes(key) }))
}
/** Schluessel des ersten Schritts ohne Haken (die erste Luecke, nicht die Zahl der Haken); sind alle bestaetigt: 'bestellen'. */
export function ersterOffenerSchritt(fragebogen) {
return schrittStatus(fragebogen).find((schritt) => !schritt.erledigt)?.key ?? 'bestellen'
}
const leerzeichenEins = (wert) => wert.replace(/\s+/g, ' ').trim()
const nameSchluessel = (name) => leerzeichenEins(name).normalize('NFC').toLowerCase()
/**
* Personen oder Plaetze aus den Namen im Schritt „Wer wird gebucht“. bisher = ressourcen der Einstellung, namen = Liste von Texten,
* typ = 'person' | 'platz' (gilt fuer alle, auch fuer behaltene Eintraege; sonst Person), neueId(belegt) = Erzeuger einer freien ID
* (belegt: Set aller schon vergebenen IDs inklusive der alten, auch der wegfallenden; Vorgabe eindeutigeId('r', belegt); eine belegte ID wirft).
* Namen: getrimmt, Leerraum zusammengefasst, auf MAX.ressourcenName gekuerzt, ohne Leere und ohne Doppelte (Gross-/Kleinschreibung zaehlt
* nicht, die erste Schreibweise gilt), hoechstens LIMITS.ressourcen. Zuordnung: gleiche Namen behalten ID und Plan; uebrige alte Eintraege
* werden in ihrer Reihenfolge den uebrigen Namen zugeteilt (umbenannt, ID und Plan bleiben); weitere Namen bekommen eine neue ID und eine
* Kopie des Plans des ersten behaltenen oder umbenannten Eintrags, gleich wo der neue Name steht (ohne solchen Eintrag: leerer Plan). Alle Eintraege sind aktiv. Ergebnis in der Reihenfolge der Namen:
* { ressourcen, entfernt: IDs der weggefallenen Eintraege, neu: IDs der neuen }. Die Eintraege tragen nur Felder, die der Server aus dem
* Client uebernimmt (kein feedToken, den vergibt und behaelt der Server). Die Zuordnung der Leistungen setzt der Aufrufer.
*/
export function ressourcenAusNamen({ bisher, typ, namen, neueId: erzeugeId = (belegt) => eindeutigeId('r', belegt) } = {}) {
const art = typ === 'platz' ? 'platz' : 'person'
const alt = (Array.isArray(bisher) ? bisher : []).filter((r) => r !== null && typeof r === 'object' && typeof r.id === 'string')
const sauber = []
const gesehen = new Set()
for (const roh of Array.isArray(namen) ? namen : []) {
if (typeof roh !== 'string') continue
const name = leerzeichenEins([...leerzeichenEins(roh)].slice(0, MAX.ressourcenName).join(''))
if (name === '' || gesehen.has(nameSchluessel(name))) continue
gesehen.add(nameSchluessel(name))
sauber.push(name)
if (sauber.length === LIMITS.ressourcen) break
}
// 1. gleiche Namen behalten ihren alten Eintrag, 2. die uebrigen alten Eintraege gehen der Reihe nach an die uebrigen Namen
const frei = new Set(alt)
const zugeteilt = sauber.map((name) => {
const treffer = alt.find((r) => frei.has(r) && typeof r.name === 'string' && nameSchluessel(r.name) === nameSchluessel(name))
if (treffer) frei.delete(treffer)
return treffer ?? null
})
const uebrige = alt.filter((r) => frei.has(r))
sauber.forEach((name, i) => { if (zugeteilt[i] === null && uebrige.length > 0) zugeteilt[i] = uebrige.shift() })
// Vorlage fuer neue Namen: Plan des ersten behaltenen oder umbenannten Eintrags (unabhaengig davon, wo ein neuer Name in der Liste steht)
const vorlagePlan = zugeteilt.find(Boolean)?.plan
const belegt = new Set(alt.map((r) => r.id))
const ressourcen = []
const neu = []
sauber.forEach((name, i) => {
const alter = zugeteilt[i]
let id
let plan
if (alter) {
id = alter.id
plan = alter.plan
} else {
id = erzeugeId(new Set(belegt))
if (typeof id !== 'string' || belegt.has(id)) throw new Error(`neueId lieferte keine freie ID: ${String(id).slice(0, 40)}`)
belegt.add(id)
neu.push(id)
plan = vorlagePlan
}
ressourcen.push({ id, typ: art, name, aktiv: true, plan: structuredClone(plan !== null && typeof plan === 'object' ? plan : {}) })
})
return { ressourcen, entfernt: uebrige.map((r) => r.id), neu }
}
const BUCHUNGSARTEN = ['sofort', 'anfrage']
/** Kopie der Leistungen, in der jede (auch eine inaktive) die Buchungsart art hat; sonst aendert sich nichts. Unbekannte Art: unveraenderte Kopie. */
export function buchungsartFuerAlle(leistungen, art) {
return (Array.isArray(leistungen) ? leistungen : []).map((leistung) => (BUCHUNGSARTEN.includes(art) ? { ...leistung, buchungsart: art } : { ...leistung }))
}
/** 'sofort' | 'anfrage', wenn alle Leistungen gleich sind (auch ohne Leistungen und bei fehlender Angabe: sofort, wie beim Server), sonst 'gemischt'. */
export function buchungsartLage(leistungen) {
const arten = new Set((Array.isArray(leistungen) ? leistungen : []).map((leistung) => (leistung?.buchungsart === 'anfrage' ? 'anfrage' : 'sofort')))
if (arten.size > 1) return 'gemischt'
return arten.has('anfrage') ? 'anfrage' : 'sofort'
}
// ---- Zusammenfassung auf der Bestellseite
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
const istText = (wert) => typeof wert === 'string' && wert.trim() !== ''
const einheit = (n, einzahl, mehrzahl) => `${n} ${n === 1 ? einzahl : mehrzahl}`
const wahlName = (wahl, key) => wahl.find(([k]) => k === key)?.[1]
/** Kundentext als eine Zeile: Umbrueche werden Leerzeichen, ueber max Zeichen gekuerzt (mit „…“). */
function kurzText(wert, max) {
const zeichen = [...leerzeichenEins(wert)]
return zeichen.length > max ? `${zeichen.slice(0, max - 1).join('').trimEnd()}…` : zeichen.join('')
}
/** 0 ⇒ 'keine', ganze Tage, ganze Stunden, sonst Minuten. */
function zeitspanne(minuten) {
if (minuten === 0) return 'keine'
if (minuten % 1440 === 0) return einheit(minuten / 1440, 'Tag', 'Tage')
if (minuten % 60 === 0) return einheit(minuten / 60, 'Stunde', 'Stunden')
return einheit(minuten, 'Minute', 'Minuten')
}
/** Wochenplan einer Ressource als Zeilen: aufeinanderfolgende Tage mit gleichen Zeiten zusammengefasst, danach 'Geschlossen: …'. */
function wochenplanZeilen(plan, vorsatz) {
const texte = planTexte(plan)
const tage = WOCHENTAGE_PLAN
const bereich = (von, bis) => (von === bis ? tage[von][1] : `${tage[von][1]}–${tage[bis][1]}`)
const offen = []
const zu = []
for (let i = 0; i < tage.length;) {
const text = texte[tage[i][0]]
let bis = i
while (bis + 1 < tage.length && texte[tage[bis + 1][0]] === text) bis += 1
if (text === '') zu.push(bereich(i, bis))
else offen.push(`${bereich(i, bis)}: ${text}`)
i = bis + 1
}
if (zu.length) offen.push(`Geschlossen: ${zu.join(', ')}`)
return offen.map((zeile) => `${vorsatz}${zeile}`)
}
const GAESTE_SIND = { verbraucher: 'Privatpersonen', unternehmer: 'Unternehmen', gemischt: 'Privatpersonen und Unternehmen' }
const BUCHUNGSART_TEXT = { sofort: 'sofort buchbar', anfrage: 'auf Anfrage', gemischt: 'gemischt, je Leistung' }
/**
* Zusammenfassung der Angaben fuer „Pruefen und bestellen“: acht Gruppen in der Reihenfolge der Schritte, jede { schritt, titel, zeilen },
* die Zeilen nur Text (der Aufrufer setzt sie per textContent; Kundentext bleibt unveraendert, lange Wuensche sind gekuerzt). Es kommt nur vor,
* was angegeben ist; Fehlendes nennt bestellpruefung des Servers. Das Bundesland steht nur, wenn der Kunde es ausdruecklich gewaehlt hat
* (fragebogen.bundeslandGewaehlt: der Serverstandard RP ist keine Wahl). Nie feedToken oder IDs. branchen = Liste { key, name } aus GET /einstellungen.
*/
export function zusammenfassung(einstellung, fragebogen, branchen) {
const e = istObjekt(einstellung) ? einstellung : {}
const f = istObjekt(fragebogen) ? fragebogen : {}
const betrieb = istObjekt(e.betrieb) ? e.betrieb : {}
const ressourcen = (Array.isArray(e.ressourcen) ? e.ressourcen : []).filter(istObjekt)
const leistungen = (Array.isArray(e.leistungen) ? e.leistungen : []).filter(istObjekt)
const regeln = istObjekt(e.regeln) ? e.regeln : {}
const gastFelder = istObjekt(e.gastFelder) ? e.gastFelder : {}
const erinnerung = istObjekt(e.erinnerung) ? e.erinnerung : {}
const recht = istObjekt(e.recht) ? e.recht : {}
const einbau = istObjekt(f.einbau) ? f.einbau : {}
const gruppen = Object.fromEntries(FRAGEBOGEN_SCHRITTE.map(([key]) => [key, []]))
// zeile(schritt, text): nur ein nicht-leerer Text kommt in die Gruppe (so fehlt eine Zeile, wenn `angabe && …` falsch ist)
const zeile = (schritt, text) => { if (typeof text === 'string' && text !== '') gruppen[schritt].push(text) }
const bundesland = f.bundeslandGewaehlt === true ? wahlName(BUNDESLAENDER, e.bundesland) : undefined
const guteWahl = (wahl, key) => wahlName(wahl, key) !== undefined
// 1. Branche und Betrieb
const branche = (Array.isArray(branchen) ? branchen : []).find((b) => istObjekt(b) && b.key === f.branche)
zeile('branche', istText(branche?.name) && `Branche: ${branche.name}`)
zeile('branche', istText(betrieb.name) && `Betrieb: ${betrieb.name}`)
zeile('branche', istText(betrieb.adresse) && `Anschrift: ${betrieb.adresse}`)
zeile('branche', typeof betrieb.kleinunternehmer === 'boolean' && `Kleinunternehmer: ${betrieb.kleinunternehmer ? 'ja' : 'nein'}`)
// 2. Wer wird gebucht: Personen und Plaetze getrennt, in der Reihenfolge ihres ersten Auftretens
const nachTyp = new Map()
for (const r of ressourcen.filter((x) => istText(x.name))) {
const wort = r.typ === 'platz' ? 'Plätze' : 'Personen'
if (!nachTyp.has(wort)) nachTyp.set(wort, [])
nachTyp.get(wort).push(`${r.name}${r.aktiv === false ? ' (nicht aktiv)' : ''}`)
}
for (const [wort, namen] of nachTyp) zeile('wer', `${wort}: ${namen.join(', ')}`)
// 3. Leistungen
for (const l of leistungen.filter((x) => istText(x.name))) {
zeile('leistungen', `${l.name}: ${Number.isInteger(l.dauer) ? `${l.dauer} Min., ` : ''}${preisAnzeige(l)}${l.aktiv === false ? ' (nicht aktiv)' : ''}`)
}
zeile('leistungen', leistungen.some((l) => preisAnzeige(l) !== 'ohne Preis') && `Preise geprüft: ${f.preiseGeprueft === true ? 'ja' : 'nein'}`)
// 4. Zeiten
const aktive = ressourcen.filter((r) => r.aktiv !== false)
for (const r of aktive) for (const text of wochenplanZeilen(r.plan, aktive.length > 1 && istText(r.name) ? `${r.name}: ` : '')) zeile('zeiten', text)
zeile('zeiten', typeof e.feiertageGeschlossen === 'boolean' && `Feiertage: ${e.feiertageGeschlossen ? 'geschlossen' : 'geöffnet'}`)
zeile('zeiten', bundesland !== undefined && `Bundesland: ${bundesland}`)
const abwesend = Array.isArray(e.abwesenheiten) ? e.abwesenheiten.length : 0
zeile('zeiten', abwesend > 0 && `Abwesenheiten: ${abwesend} eingetragen`)
const sonder = istObjekt(e.sonderoeffnungen) ? Object.keys(e.sonderoeffnungen).length : 0
zeile('zeiten', sonder > 0 && `Besondere Öffnungstage: ${sonder}`)
// 5. Regeln
zeile('regeln', leistungen.length > 0 && `Buchungsart: ${BUCHUNGSART_TEXT[buchungsartLage(leistungen)]}`)
zeile('regeln', Number.isInteger(regeln.raster) && `Raster: ${regeln.raster} Minuten`)
zeile('regeln', Number.isInteger(regeln.vorlaufMin) && regeln.vorlaufMin >= 0 && `Vorlaufzeit: ${zeitspanne(regeln.vorlaufMin)}`)
zeile('regeln', Number.isInteger(regeln.horizontTage) && `Buchungszeitraum: ${einheit(regeln.horizontTage, 'Tag', 'Tage')} im Voraus`)
zeile('regeln', Number.isInteger(regeln.absageBisStd) && `Absage durch Gäste: bis ${einheit(regeln.absageBisStd, 'Stunde', 'Stunden')} vorher`)
zeile('regeln', Number.isInteger(regeln.maxOffeneJeEmail) && `Offene Termine je E-Mail-Adresse: höchstens ${regeln.maxOffeneJeEmail}`)
zeile('regeln', typeof regeln.personWaehlbar === 'boolean' && `${ressourcenWort(ressourcen)} wählbar: ${regeln.personWaehlbar ? 'ja' : 'nein'}`)
zeile('regeln', Object.hasOwn(GAESTE_SIND, recht.gaesteSind) && `Deine Gäste sind: ${GAESTE_SIND[recht.gaesteSind]}`)
zeile('regeln', recht.freizeit === true && 'Leistungen sind Freizeitangebote mit festem Termin')
const arten = (istObjekt(e.zahlung) && Array.isArray(e.zahlung.vorOrtArten) ? e.zahlung.vorOrtArten : []).map((a) => wahlName(ZAHLARTEN, a)).filter(Boolean)
zeile('regeln', arten.length > 0 && `Zahlung: vor Ort (${arten.join(', ')})`)
zeile('regeln', istObjekt(e.storno) && `Stornobedingungen: ${istText(e.storno.text) ? 'Text hinterlegt' : 'kein Text'}`)
// 6. Gastfelder
zeile('gastfelder', guteWahl(GAST_FELD_WAHL, gastFelder.telefon) && `Telefonnummer des Gastes: ${wahlName(GAST_FELD_WAHL, gastFelder.telefon)}`)
zeile('gastfelder', guteWahl(GAST_FELD_WAHL, gastFelder.nachricht) && `Nachricht des Gastes: ${wahlName(GAST_FELD_WAHL, gastFelder.nachricht)}`)
for (const z of (Array.isArray(gastFelder.zusatz) ? gastFelder.zusatz : []).filter((x) => istObjekt(x) && istText(x.label))) {
zeile('gastfelder', `Zusatzfeld „${z.label}“: ${z.pflicht === true ? 'Pflicht' : 'Optional'}${z.typ === 'auswahl' ? ' (Auswahl)' : ''}`)
}
// 7. Benachrichtigung
zeile('benachrichtigung', istText(betrieb.email) && `Benachrichtigungen an: ${betrieb.email}`)
zeile('benachrichtigung', guteWahl(ANREDE_WAHL, e.anrede) && `Anrede der Gäste: ${wahlName(ANREDE_WAHL, e.anrede)}`)
if (typeof erinnerung.anbieten === 'boolean') {
zeile('benachrichtigung', erinnerung.anbieten ? `E-Mail-Erinnerung für Gäste: angeboten, ${einheit(erinnerung.stundenVorher, 'Stunde', 'Stunden')} vorher` : 'E-Mail-Erinnerung für Gäste: aus')
}
// 8. Website und Einbau
zeile('website', istText(betrieb.telefon) && `Telefon: ${betrieb.telefon}`)
zeile('website', istText(betrieb.impressumUrl) && `Impressum: ${betrieb.impressumUrl}`)
zeile('website', istText(betrieb.datenschutzUrl) && `Datenschutzerklärung: ${betrieb.datenschutzUrl}`)
zeile('website', istText(betrieb.agbUrl) && `AGB: ${betrieb.agbUrl}`)
zeile('website', istText(betrieb.websiteUrl) && `Website: ${betrieb.websiteUrl}`)
zeile('website', einbau.modus === 'inline' && 'Darstellung: direkt auf deiner Seite')
zeile('website', einbau.modus === 'knopf' && `Darstellung: hinter einem Knopf${istText(einbau.knopfText) ? ` „${einbau.knopfText}“` : ''}`)
for (const [label, wert] of [['Stelle', einbau.stelle], ['Gestaltungswunsch', einbau.gestaltung], ['Bemerkung', einbau.bemerkung]]) {
zeile('website', istText(wert) && `${label}: ${kurzText(wert, 200)}`)
}
return FRAGEBOGEN_SCHRITTE.map(([schritt, titel]) => ({ schritt, titel, zeilen: gruppen[schritt] }))
}