Kachel in Funktionen (Kommt bald / Aus / Aktiv mit Anfragen-Zahl / Abgeschaltet), Ansicht #termine als Unterseite von Funktionen: Projektauswahl, Vorlagenauswahl beim ersten Oeffnen, Reiter-Geruest (Platzhalter fuer Leistungen, Team & Plaetze, Einstellungen) und der Reiter Termine mit Tag/Woche/Monat, Filtern, Aktionen mit Rueckfragen in der Seite, Termin eintragen und Zeit sperren. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
248 lines
11 KiB
JavaScript
248 lines
11 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'
|
||
|
||
/* ------------------------------------------------------------ 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 fuer die Abfrage der Buchungen. ansicht: 'tag' | 'woche' | 'monat' | 'anfragen' (ab heute, so weit die
|
||
* Schnittstelle reicht).
|
||
*/
|
||
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, MAX_TAGE) }
|
||
return { von: tag, bis: 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); '' wenn kein Zeitpunkt. */
|
||
export function endeZeit(buchung) {
|
||
const ende = new Date(buchung?.endsAt)
|
||
return Number.isNaN(ende.getTime()) ? '' : ENDE_FORMAT.format(ende)
|
||
}
|
||
|
||
/* ------------------------------------------------------------- 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 ''. */
|
||
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
|
||
}
|