/* 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] })) }