448 lines
21 KiB
JavaScript
448 lines
21 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. */
|
||
|
||
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
|
||
}
|
||
|
||
/** '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))}`
|
||
}
|