Terminplaner: Portal-Ansicht Leistungen, Team, Einstellungen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
650919b022
commit
0b75b31009
15 files changed
+2479
-79
No files matched your search
+176
-10
@@ -10,8 +10,16 @@ const TAG_MS = 24 * 60 * 60 * 1000
|
||||
const MAX_TAGE = 62
|
||||
const GELOESCHT = 'Gelöscht'
|
||||
|
||||
/** Laengen der Eingaben, wie der Server sie pruefen wird (ablauf.js, ablaufBetrieb.js). */
|
||||
export const MAX = { name: 100, telefon: 40, nachricht: 500, titel: 120 }
|
||||
/**
|
||||
* 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) {
|
||||
@@ -40,17 +48,14 @@ export function monatVon(tag) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeitraum der Liste. ansicht: 'tag' | 'woche' | 'monat' | 'anfragen' (ab heute bis zum Ende des Buchungszeitraums horizontTage
|
||||
* der Einstellung, Vorgabe 60, hoechstens 365). Fuer 'anfragen' ist der Zeitraum laenger als ein Aufruf der Schnittstelle:
|
||||
* fensterAufteilen macht daraus die einzelnen Abfragen.
|
||||
* 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, horizontTage) {
|
||||
export function bereichFuer(ansicht, tag, heute) {
|
||||
if (ansicht === 'woche') return wocheVon(tag)
|
||||
if (ansicht === 'monat') return monatVon(tag)
|
||||
if (ansicht === 'anfragen') {
|
||||
const horizont = Number.isInteger(horizontTage) && horizontTage >= 1 ? Math.min(horizontTage, 365) : 60
|
||||
return { von: heute, bis: tagPlus(heute, horizont) }
|
||||
}
|
||||
if (ansicht === 'anfragen') return { von: heute, bis: tagPlus(heute, 365) }
|
||||
return { von: tag, bis: tag }
|
||||
}
|
||||
|
||||
@@ -279,3 +284,164 @@ export function moeglicheAktionen(buchung, jetzt) {
|
||||
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))}`
|
||||
}
|
||||
Reference in new issue
Block a user