Files
Webklar-Kundenbereich/public/termine-view.js
T

459 lines
22 KiB
JavaScript
Raw 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. */
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))}`
}