726 lines
38 KiB
JavaScript
726 lines
38 KiB
JavaScript
/* 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}`)
|
||
|
||
// 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] }))
|
||
}
|