Terminplaner: Portal-Ansicht, Kachel, Termine

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>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-03 08:17:44 +00:00
1 parent 70940e02c8
commit 43e4826ae3
10 files changed
+2552 -8

No files matched your search

+4 -3
View File
@@ -139,7 +139,7 @@ function projectState(project) {
return ['kb-state-off', 'Noch keine Website']
}
const FEATURE_LABELS = { preview: 'Vorschau', chat: 'Chat', invoices: 'Rechnungen', news: 'News-Pinnwand' }
const FEATURE_LABELS = { preview: 'Vorschau', chat: 'Chat', invoices: 'Rechnungen', news: 'News-Pinnwand', termine: 'Terminplaner' }
function renderProjectCard(project, features) {
const li = document.createElement('li')
@@ -1910,7 +1910,7 @@ function initPasswordView() {
// "projekt" (Projektdetails als eigene Seite) gibt es ebenfalls nur in der App-Ansicht.
// "passwort" oeffnet das Konto-Menue (Profilbild). #fortschritt fuehrt zu Projekte und scrollt zum Abschnitt
// "Was wir zuletzt erledigt haben" (#pj-activity); hinter dem Hash darf ein Query stehen (#news?bestellt=1, Rueckkehr von Stripe).
const VIEW_HASHES = { projects: '', funktionen: 'funktionen', news: 'news', analyse: 'analyse', invoices: 'rechnungen', chat: 'chat', vertraege: 'vertraege', stunden: 'stunden', 'bestellen-hosting': 'bestellen-hosting', 'bestellen-wartung': 'bestellen-wartung', wechsel: 'wechsel', takeover: 'gutschrift', projekt: 'projekt', passwort: 'passwort' }
const VIEW_HASHES = { projects: '', funktionen: 'funktionen', news: 'news', analyse: 'analyse', termine: 'termine', invoices: 'rechnungen', chat: 'chat', vertraege: 'vertraege', stunden: 'stunden', 'bestellen-hosting': 'bestellen-hosting', 'bestellen-wartung': 'bestellen-wartung', wechsel: 'wechsel', takeover: 'gutschrift', projekt: 'projekt', passwort: 'passwort' }
const HASH_ALIASES = { wartung: 'vertraege', abo: 'vertraege' }
const viewLoaders = { chat: initChat }
// Funktionen und News laden bei jedem Oeffnen neu (Zustand aendert sich durch Bestellung und Freischaltung); Modul erst dann laden
@@ -1918,6 +1918,7 @@ const viewRefreshers = {
funktionen: () => import('/funktionen.js').then((m) => m.initFunktionen(document.getElementById('funktionen-body'))),
news: () => import('/funktionen.js').then((m) => m.initNews(document.getElementById('news-body'))),
analyse: () => import('/funktionen.js').then((m) => m.initAnalyse(document.getElementById('analyse-body'))),
termine: () => import('/termine.js').then((m) => m.initTermine(document.getElementById('termine-body'))),
}
const loadedViews = new Set()
@@ -2110,7 +2111,7 @@ const appLayout = window.matchMedia('(max-width: 880px), (pointer: coarse) and (
const coarsePointer = window.matchMedia('(pointer: coarse)')
// Unterseite -> Tab, zu dem sie gehoert (und Text neben dem Zurueck-Pfeil).
// "passwort" setzt onAppViewShown: die Ansicht, aus der das Konto-Menue geoeffnet wurde.
const APP_PARENT = { news: 'funktionen', analyse: 'funktionen', stunden: 'vertraege', 'bestellen-hosting': 'vertraege', 'bestellen-wartung': 'vertraege', wechsel: 'vertraege', takeover: 'vertraege', projekt: 'projects' }
const APP_PARENT = { news: 'funktionen', analyse: 'funktionen', termine: 'funktionen', stunden: 'vertraege', 'bestellen-hosting': 'vertraege', 'bestellen-wartung': 'vertraege', wechsel: 'vertraege', takeover: 'vertraege', projekt: 'projects' }
const APP_PARENT_LABEL = { vertraege: 'Verträge', projects: 'Projekte', funktionen: 'Funktionen', invoices: 'Rechnungen', chat: 'Chat' }
const appShell = { view: 'projects', scroll: {}, fromHistory: false, sideCards: [], installPrompt: null }
const TEXT_INPUT = 'textarea, input:not([type=checkbox]):not([type=radio]):not([type=file]):not([type=date]):not([type=button]):not([type=submit]):not([type=range]):not([type=color])'
+10
View File
@@ -22,6 +22,7 @@
<link rel="stylesheet" href="/kundenbereich.css">
<link rel="stylesheet" href="/chat-composer.css">
<link rel="stylesheet" href="/funktionen.css">
<link rel="stylesheet" href="/termine.css">
</head>
<body class="portal-page dashboard-page kb-page">
<div class="dot-background" aria-hidden="true">
@@ -185,6 +186,15 @@
<div id="analyse-body"><p class="kb-empty">Lade…</p></div>
</section>
<!-- Terminplaner: Vorlage, Termine, Leistungen, Team & Plätze, Einstellungen (termine.js, initTermine); Unterseite von Funktionen -->
<section class="kb-view" id="view-termine">
<header class="kb-head">
<h1>Terminplaner</h1>
<p>Termine, die deine Kundschaft über deine Website bucht – und was du selbst einträgst.</p>
</header>
<div id="termine-body"><p class="kb-empty">Lade…</p></div>
</section>
<!-- Rechnungen -->
<section class="kb-view" id="view-invoices">
<header class="kb-head">
+60 -2
View File
@@ -136,6 +136,58 @@ function analyseTile(projects) {
</article>`
}
/* Terminplaner: Kachel nur, wenn der Schalter es erlaubt (termine.verfuegbar), sonst "Kommt bald". Die Ansicht selbst
(termine.js) wird von app.js geladen; hier steht nur die Kachel, damit funktionen.js keinen weiteren Import braucht. */
const TERMINE_TEXT = 'Deine Kundschaft bucht Termine direkt über deine Website.'
const termineOf = (project) => project.features?.termine || { state: 'off' }
const anfragenText = (n) => (n === 1 ? '1 Anfrage wartet' : `${n} Anfragen warten`)
function pickTermineProject(projects) {
return projects.find((p) => termineOf(p).state === 'active') || projects.find((p) => termineOf(p).state === 'disabled') || projects[0]
}
function termineTile(projects) {
const states = projects.map((p) => termineOf(p).state)
const active = states.filter((s) => s === 'active').length
const target = pickTermineProject(projects)
const disabled = !active && states.length > 0 && states.every((s) => s === 'disabled')
const pill = active ? ['Aktiv', 'on'] : disabled ? ['Abgeschaltet', 'off'] : ['Aus', 'off']
let extra
let action
if (active) {
extra = '<p class="fn-count" data-termine-count hidden></p>'
action = `<button type="button" class="kb-btn-primary fn-tile-cta" data-view="termine" data-project="${esc(target.projectId)}">Termine öffnen</button>`
} else if (disabled) {
extra = '<p>Der Terminplaner ist für diese Website abgeschaltet. Deine Termine kannst du noch ansehen und exportieren.</p>'
action = `<button type="button" class="kb-btn-primary fn-tile-cta" data-view="termine" data-project="${esc(target.projectId)}">Termine ansehen</button>`
} else {
extra = '<p>Wir schalten den Terminplaner für dich frei.</p>'
action = '<button type="button" class="kb-btn-primary fn-tile-cta" data-view="chat">Chat öffnen</button>'
}
const count = projects.length > 1 && active ? `<p class="fn-count">aktiv für ${active} von ${projects.length} Websites</p>` : ''
return `
<article class="fn-tile" data-fn-tile="termine">
<div class="fn-tile-head">${svg('calendar')}<span class="fn-pill fn-pill-${pill[1]}">${pill[0]}</span></div>
<h3>Terminplaner</h3>
<p>${TERMINE_TEXT}</p>
${extra}
${count}
${action}
</article>`
}
// "{n} Anfragen warten" aus anfragenOffen der aktiven Websites; ein Fehler laesst die Kachel einfach ohne Zahl stehen
async function showTermineCount(root, projects) {
const el = root.querySelector('[data-termine-count]')
const active = projects.filter((p) => termineOf(p).state === 'active')
if (!el || !active.length) return
const counts = await Promise.all(active.map((p) => request(`/api/termine/einstellungen?projectId=${encodeURIComponent(p.projectId)}`).then((d) => Number(d.anfragenOffen) || 0, () => 0)))
const open = counts.reduce((sum, n) => sum + n, 0)
if (open < 1) return
el.textContent = anfragenText(open)
el.hidden = false
}
const soonTile = (icon, title, text) => `
<article class="fn-tile fn-tile-soon">
<div class="fn-tile-head">${svg(icon)}<span class="fn-pill fn-pill-soon">Kommt bald</span></div>
@@ -145,15 +197,21 @@ const soonTile = (icon, title, text) => `
export async function initFunktionen(root) {
if (!root) return
const { projects = [] } = await request('/api/funktionen')
const { projects = [], termine } = await request('/api/funktionen')
root.innerHTML = `
<div class="fn-tiles">
${newsTile(projects)}
${soonTile('calendar', 'Terminplaner', 'Deine Kundschaft bucht Termine direkt über deine Website.')}
${termine?.verfuegbar ? termineTile(projects) : soonTile('calendar', 'Terminplaner', TERMINE_TEXT)}
${analyseTile(projects)}
</div>`
root.querySelector('[data-fn-tile="news"] .fn-tile-cta')?.addEventListener('click', (ev) => { selection.projectId = ev.currentTarget.dataset.project })
root.querySelector('[data-fn-tile="analyse"] .fn-tile-cta[data-project]')?.addEventListener('click', (ev) => { analyseSel.projectId = ev.currentTarget.dataset.project })
// Die Terminplaner-Ansicht liest das gewaehlte Projekt aus ihrem Behaelter (termine.js, initTermine)
root.querySelector('[data-fn-tile="termine"] .fn-tile-cta[data-project]')?.addEventListener('click', (ev) => {
const body = document.getElementById('termine-body')
if (body) body.dataset.wunschProjekt = ev.currentTarget.dataset.project
})
if (termine?.verfuegbar) await showTermineCount(root, projects)
}
/* ---------------------------------------------------------------- News --- */
+247
View File
@@ -0,0 +1,247 @@
/* 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
}
+154
View File
@@ -0,0 +1,154 @@
/* Terminplaner im Kundenbereich (termine.js): Projektauswahl, Vorlagen, Reiter, Terminliste, Rueckfragen und Formulare.
Farben und Abstaende aus den Tokens von kundenbereich.css (.kb-page); nur Klassen mit tm-. Handy: bis 880px wie die App-Ansicht. */
#view-termine .kb-panel { max-width: none; }
#view-termine .fn-project { margin-bottom: 16px; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding: 14px 18px; }
#view-termine button:focus-visible, #view-termine input:focus-visible, #view-termine select:focus-visible, #view-termine textarea:focus-visible,
#view-termine summary:focus-visible, #view-termine a:focus-visible { outline: 3px solid #d4b4ff; outline-offset: 2px; }
.tm-sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
/* -------------------------------------------------- Hinweise und Meldungen --- */
.tm-box { display: flex; align-items: center; justify-content: space-between; gap: 10px 16px; flex-wrap: wrap; margin-bottom: 16px; padding: 12px 16px; border-radius: 12px; font-size: 14px; line-height: 1.5; }
.tm-box[hidden] { display: none; }
.tm-box-warn { background: rgba(251, 191, 36, 0.12); border: 1px solid rgba(251, 191, 36, 0.45); }
.tm-box-info { background: var(--kb-purple-soft); border: 1px solid var(--kb-purple-line); }
.tm-meldung { margin: 0 0 16px; padding: 12px 16px; border-radius: 12px; font-size: 14px; line-height: 1.5; }
.tm-meldung[hidden] { display: none; }
.tm-meldung-ok { background: rgba(74, 222, 128, 0.1); border: 1px solid rgba(74, 222, 128, 0.4); }
.tm-meldung-fehler { background: rgba(248, 113, 113, 0.1); border: 1px solid rgba(248, 113, 113, 0.5); color: #fecaca; }
.tm-info p { margin: 0 0 12px; line-height: 1.55; color: var(--kb-muted); }
.tm-info p:last-child { margin-bottom: 0; }
/* ----------------------------------------------------------------- Reiter --- */
.tm-reiterleiste { display: grid; grid-template-columns: repeat(4, auto); justify-content: start; gap: 6px; margin-bottom: 18px; padding-bottom: 12px; border-bottom: 1px solid var(--kb-border); }
.tm-reiter {
padding: 10px 16px; border-radius: 999px; border: 1px solid transparent; background: transparent; color: var(--kb-muted);
font: inherit; font-size: 14px; font-weight: 700; white-space: nowrap; cursor: pointer;
}
.tm-reiter:hover { color: #fff; background: rgba(255, 255, 255, 0.06); }
.tm-reiter[aria-selected='true'] { color: #fff; background: var(--kb-purple-soft); border-color: var(--kb-purple-line); }
.tm-panel:focus { outline: none; }
/* ------------------------------------------------------------- Werkzeugleiste --- */
.tm-leiste { display: flex; align-items: center; gap: 10px 14px; flex-wrap: wrap; }
.tm-leiste [hidden], .tm-filter[hidden] { display: none; }
.tm-segment { display: inline-flex; border: 1px solid var(--kb-border-strong); border-radius: 999px; overflow: hidden; }
.tm-seg { padding: 9px 18px; border: none; background: transparent; color: inherit; font: inherit; font-size: 14px; font-weight: 700; cursor: pointer; }
.tm-seg + .tm-seg { border-left: 1px solid var(--kb-border-strong); }
.tm-seg[aria-pressed='true'] { background: var(--kb-purple); color: #fff; }
.tm-nav { display: inline-flex; align-items: center; gap: 6px; }
.tm-pfeil {
display: grid; place-items: center; width: 40px; height: 40px; padding: 0; border-radius: 50%; border: 1px solid var(--kb-border-strong);
background: transparent; color: #fff; font: inherit; font-size: 22px; line-height: 1; cursor: pointer;
}
.tm-pfeil:hover { background: rgba(255, 255, 255, 0.07); }
.tm-sprung { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; color: var(--kb-muted); }
.tm-zeitraum { margin: 18px 0 10px; font-size: 19px; }
.tm-filter { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; }
.tm-chip {
padding: 8px 15px; border-radius: 999px; border: 1px solid var(--kb-border-strong); background: transparent; color: inherit;
font: inherit; font-size: 13.5px; font-weight: 600; cursor: pointer;
}
.tm-chip:hover { background: rgba(255, 255, 255, 0.07); }
.tm-chip[aria-pressed='true'] { background: var(--kb-purple-soft); border-color: var(--kb-purple-line); color: #fff; }
.tm-werkzeug-zeile { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 16px; }
.tm-werkzeuge { display: contents; }
.tm-werkzeuge button[aria-expanded='true'] { box-shadow: 0 0 0 3px var(--kb-purple-soft); }
#view-termine .kb-btn-outline, #view-termine .kb-btn-primary { min-height: 40px; }
/* ------------------------------------------------------------------- Liste --- */
.tm-tag-kopf { margin: 22px 0 8px; font-size: 14px; font-weight: 700; color: var(--kb-muted); }
.tm-tag:first-child .tm-tag-kopf { margin-top: 6px; }
.tm-liste[aria-busy='true'] { opacity: 0.55; }
.tm-leer { margin: 0 0 8px; padding: 14px 4px; font-size: 14px; color: var(--kb-faint); }
.tm-eintraege { display: grid; gap: 10px; margin: 0; padding: 0; list-style: none; }
.tm-eintrag { padding: 14px 16px; border-radius: 14px; background: var(--kb-surface); border: 1px solid var(--kb-border); }
.tm-eintrag.tm-offen { border-color: var(--kb-purple-line); }
.tm-sperre {
border-style: dashed; border-color: var(--kb-border-strong);
background: repeating-linear-gradient(135deg, rgba(255, 255, 255, 0.035) 0 10px, transparent 10px 20px), var(--kb-surface);
}
.tm-zeile { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 4px 14px; align-items: start; }
.tm-zeit { font-weight: 700; font-variant-numeric: tabular-nums; white-space: nowrap; padding-top: 1px; }
.tm-haupt { min-width: 0; }
.tm-name, .tm-meta { display: block; overflow-wrap: anywhere; }
.tm-meta { margin-top: 2px; font-size: 13.5px; color: var(--kb-muted); }
.tm-pille { display: inline-block; padding: 3px 11px; border-radius: 999px; font-size: 12px; font-weight: 700; white-space: nowrap; }
.tm-pille-ok { color: #86efac; background: rgba(74, 222, 128, 0.1); border: 1px solid rgba(74, 222, 128, 0.4); }
.tm-pille-wait { color: #fcd34d; background: rgba(251, 191, 36, 0.1); border: 1px solid rgba(251, 191, 36, 0.4); }
.tm-pille-off { color: var(--kb-muted); background: rgba(255, 255, 255, 0.05); border: 1px solid var(--kb-border-strong); }
.tm-pille-bad { color: #fca5a5; background: rgba(248, 113, 113, 0.1); border: 1px solid rgba(248, 113, 113, 0.45); }
.tm-hinweis-zeile { margin: 8px 0 0; font-size: 13.5px; line-height: 1.5; color: var(--kb-muted); overflow-wrap: anywhere; }
.tm-hinweis-zeile.tm-warn { color: #fcd34d; }
.tm-aktionen { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
.tm-frage:empty { display: none; }
.tm-frage-box {
display: grid; gap: 10px; margin-top: 12px; padding: 14px; border-radius: 12px; background: var(--kb-purple-soft); border: 1px solid var(--kb-purple-line);
}
.tm-frage-box:focus { outline: none; }
.tm-frage-box p { margin: 0; font-size: 14px; line-height: 1.5; }
.tm-frage-knoepfe { display: flex; flex-wrap: wrap; gap: 10px; }
.tm-details { margin-top: 10px; font-size: 14px; }
.tm-details summary { display: inline-block; padding: 4px 0; color: var(--kb-lilac); font-size: 13.5px; cursor: pointer; }
.tm-fakten { display: grid; gap: 8px; margin: 8px 0 0; }
.tm-fakten > div { display: grid; grid-template-columns: 9.5rem minmax(0, 1fr); gap: 2px 12px; }
.tm-fakten dt { color: var(--kb-muted); font-size: 13px; }
.tm-fakten dd { margin: 0; overflow-wrap: anywhere; white-space: pre-wrap; }
.tm-link-knopf { margin-top: 10px; padding: 4px 0; border: none; background: none; color: #fca5a5; font: inherit; font-size: 13.5px; text-decoration: underline; cursor: pointer; }
/* ------------------------------------------------------------------ Formulare --- */
.tm-formular { margin-bottom: 16px; }
.tm-formular h3 { margin-bottom: 6px; }
.tm-raster { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 0 14px; }
.tm-feld { display: grid; gap: 6px; margin: 12px 0 0; min-width: 0; }
.tm-feld > label, .tm-label { font-size: 13px; font-weight: 600; }
.tm-feld input:not([type=checkbox]):not([type=radio]), .tm-feld select, .tm-feld textarea {
width: 100%; box-sizing: border-box; padding: 11px 12px; border-radius: 12px; font: inherit; font-size: 16px; resize: vertical;
border: 1px solid var(--kb-border-strong); background: rgba(255, 255, 255, 0.05); color: inherit;
}
.tm-feld select option { color: #111; }
.tm-feld [aria-invalid='true'] { border-color: #f87171; }
.tm-feld input:focus, .tm-feld select:focus, .tm-feld textarea:focus { border-color: var(--kb-purple-line); box-shadow: 0 0 0 3px var(--kb-purple-soft); }
.tm-hinweis { margin: 0; font-size: 12.5px; color: var(--kb-muted); }
.tm-fehler { margin: 0; font-size: 13px; color: #fca5a5; }
.tm-fehler:empty { display: none; }
.tm-zeiten { display: flex; flex-wrap: wrap; gap: 8px; }
.tm-haken { display: flex; align-items: flex-start; gap: 10px; margin: 14px 0 6px; font-size: 14px; line-height: 1.5; cursor: pointer; }
.tm-haken input { flex: none; width: 18px; height: 18px; margin-top: 2px; accent-color: var(--kb-purple); }
.tm-formular .tm-frage-knoepfe { margin-top: 16px; }
/* ------------------------------------------------------------- Vorlagenauswahl --- */
.tm-vorlagen > p { margin: 0 0 14px; line-height: 1.55; color: var(--kb-muted); }
.tm-vorlagen-liste { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; margin: 0 0 16px; padding: 0; border: 0; min-width: 0; }
.tm-vorlage { display: flex; align-items: flex-start; gap: 12px; padding: 14px; border-radius: 14px; border: 2px solid var(--kb-border-strong); cursor: pointer; }
.tm-vorlage:hover { background: rgba(255, 255, 255, 0.04); }
.tm-vorlage:has(input:checked) { border-color: var(--kb-purple); background: var(--kb-purple-soft); }
.tm-vorlage input { flex: none; width: 18px; height: 18px; margin-top: 2px; accent-color: var(--kb-purple); }
.tm-vorlage strong, .tm-vorlage small { display: block; }
.tm-vorlage small { margin-top: 3px; font-size: 13px; line-height: 1.45; color: var(--kb-muted); }
/* ----------------------------------------------------------------- Handy --- */
@media (max-width: 880px), (pointer: coarse) {
#view-termine button:not(.tm-link-knopf), #view-termine .kb-btn-outline, #view-termine .kb-btn-primary { min-height: 44px; }
.tm-pfeil { width: 44px; height: 44px; }
.tm-link-knopf { padding: 11px 0; }
.tm-details summary { display: flex; align-items: center; min-height: 44px; padding: 0; }
}
@media (max-width: 600px) {
.tm-reiterleiste { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.tm-reiter { text-align: center; }
.tm-leiste { gap: 10px; }
.tm-segment { width: 100%; }
.tm-seg { flex: 1; padding-left: 8px; padding-right: 8px; }
.tm-nav { width: 100%; }
.tm-nav .tm-heute { flex: 1; }
.tm-sprung { width: 100%; }
.tm-sprung input { flex: 1; min-width: 0; }
.tm-zeile { grid-template-columns: minmax(0, 1fr) auto; }
.tm-haupt { grid-column: 1 / -1; grid-row: 2; }
.tm-fakten > div { grid-template-columns: minmax(0, 1fr); }
.tm-werkzeug-zeile > *, .tm-werkzeuge > * { flex: 1 1 calc(50% - 5px); }
.tm-werkzeuge > .kb-btn-primary { flex-basis: 100%; }
.tm-aktionen > * { flex: 1 1 auto; }
}
@media (prefers-reduced-motion: reduce) { #view-termine * { transition: none !important; animation: none !important; } }
+832
View File
@@ -0,0 +1,832 @@
/* Terminplaner im Kundenbereich (ES-Modul; app.js laedt es erst beim Oeffnen der Ansicht #termine).
initTermine(rootEl, { projectId }): Projektauswahl, Vorlagenauswahl beim ersten Oeffnen, Reiter-Geruest und der Reiter "Termine".
Die Reiter stehen in REITER: jeder ist eine Funktion (panel, ctx) => void | Promise. Leistungen, Team & Plaetze und Einstellungen
(Aufgabe 21b) ersetzen dort nur ihren Platzhalter. ctx = { s, projectId, daten, nurLesen, api, meldung, zumReiter, neuLaden }:
daten ist die Antwort von GET /api/termine/einstellungen, nurLesen gilt fuer abgeschaltete Funktionen.
Kundentext kommt nur ueber textContent in die Seite (Hilfsfunktion h), nie ueber innerHTML. Server: server/routes/termine.js.
Tage sind Berliner Kalendertage 'JJJJ-MM-TT'; gerechnet wird in termine-view.js. Styles: termine.css. */
import {
ABSAGE_GRUENDE, FILTER, anfragenText, bereichFuer, datumKurz, endeZeit, fehltText, filterStatus, gruppiereNachTag, heuteBerlin, istSperre,
moeglicheAktionen, ressourcenWort, statusLabel, statusTon, tagPlus, tagUeberschrift, verschiebeTag, zeitNormal, zeitraumTitel,
} from './termine-view.js'
const NAME_MAX = 100
const TELEFON_MAX = 40
const NACHRICHT_MAX = 500
const TITEL_MAX = 120
// Auswahl bleibt beim Wechsel zwischen Funktionen und Terminplaner erhalten (der Tag nicht: er beginnt immer bei heute)
const auswahl = { projectId: '', reiter: 'termine', ansicht: 'tag', filter: 'alle' }
let S = null // aktuelle Sitzung der Ansicht; eine alte Antwort darf eine neuere nicht ueberschreiben (S !== s)
/* ------------------------------------------------------------ Helfer --- */
/** Element bauen: Texte nur als Textknoten, Attribute mit on… als Ereignis, true setzt ein Attribut ohne Wert, false/null/undefined lassen es weg. */
function h(tag, attrs, ...kinder) {
const el = document.createElement(tag)
for (const [name, wert] of Object.entries(attrs || {})) {
if (wert === undefined || wert === null || wert === false) continue
if (name === 'class') el.className = wert
else if (name.startsWith('on')) el.addEventListener(name.slice(2), wert)
else el.setAttribute(name, wert === true ? '' : wert)
}
for (const kind of kinder.flat()) if (kind !== undefined && kind !== null && kind !== false) el.append(kind)
return el
}
async function request(path, { method = 'GET', body } = {}) {
const response = await fetch(path, {
method,
credentials: 'same-origin',
headers: body ? { 'Content-Type': 'application/json' } : {},
body: body ? JSON.stringify(body) : undefined,
})
const data = await response.json().catch(() => ({}))
if (!response.ok) {
const err = new Error(data.error || (response.status === 502 ? 'Server nicht erreichbar. Bitte kurz warten und noch einmal versuchen.' : `Fehler ${response.status}`))
err.status = response.status
err.fehler = data.fehler || {}
err.code = data.code
throw err
}
return data
}
let idZaehler = 0
const neueId = () => `tm-f${++idZaehler}`
/** Formularfeld mit Beschriftung, Hinweis und Platz fuer den Fehler (data-feld = Feldname der Schnittstelle). */
function feld(name, label, steuer, hinweis) {
const id = steuer.id || (steuer.id = neueId())
steuer.dataset.feld = name
return h('div', { class: 'tm-feld' },
h('label', { for: id }, label),
steuer,
hinweis ? h('p', { class: 'tm-hinweis' }, hinweis) : null,
h('p', { class: 'tm-fehler', 'data-fehler': name, role: 'alert' }))
}
function setzeFehler(form, fehler) {
for (const el of form.querySelectorAll('[data-fehler]')) el.textContent = ''
for (const el of form.querySelectorAll('[aria-invalid]')) el.removeAttribute('aria-invalid')
const rest = []
for (const [name, text] of Object.entries(fehler || {})) {
const ziel = form.querySelector(`[data-fehler="${CSS.escape(name)}"]`)
if (ziel) {
ziel.textContent = text
form.querySelector(`[data-feld="${CSS.escape(name)}"]`)?.setAttribute('aria-invalid', 'true')
} else {
rest.push(text)
}
}
const allgemein = form.querySelector('[data-fehler="_"]')
if (allgemein) allgemein.textContent = rest.join(' ')
}
/** Fehler der Schnittstelle am Formular zeigen (Feldfehler am Feld, sonst allgemein). */
function zeigeFehler(form, err) {
const fehler = err.fehler && Object.keys(err.fehler).length ? err.fehler : { _: err.message }
setzeFehler(form, fehler)
form.querySelector('[aria-invalid]')?.focus()
}
const infoKarte = (titel, text, { chat = false } = {}) => h('div', { class: 'kb-panel tm-info' },
h('h3', null, titel),
text ? h('p', null, text) : null,
chat ? h('button', { type: 'button', class: 'kb-btn-outline', 'data-view': 'chat' }, 'Chat öffnen') : null)
const fehlerKarte = (text, nochmal) => h('div', { class: 'kb-panel tm-info', role: 'alert' },
h('p', null, text),
nochmal ? h('button', { type: 'button', class: 'kb-btn-outline', onclick: nochmal }, 'Noch einmal versuchen') : null)
/* --------------------------------------------------------- Einstieg --- */
export async function initTermine(root, { projectId } = {}) {
if (!root) return
const wunsch = projectId || root.dataset.wunschProjekt || '' // die Kachel in Funktionen legt ihr Projekt hier ab
delete root.dataset.wunschProjekt
if (wunsch) auswahl.projectId = wunsch
const { projects = [], termine } = await request('/api/funktionen')
const s = { root, projects, projekt: null, daten: null, tag: heuteBerlin(), nurLesen: false, buehne: null, meldungEl: null }
S = s
if (!termine?.verfuegbar) {
root.replaceChildren(infoKarte('Der Terminplaner ist noch nicht verfügbar', 'Sobald er für dich bereit ist, findest du ihn hier.'))
return
}
if (!projects.length) {
root.replaceChildren(h('div', { class: 'kb-panel kb-empty' }, 'Noch keine Website-Projekte zugewiesen.'))
return
}
const zustand = (p) => p.features?.termine?.state
s.projekt = projects.find((p) => p.projectId === auswahl.projectId)
|| projects.find((p) => zustand(p) === 'active') || projects.find((p) => zustand(p) === 'disabled') || projects[0]
auswahl.projectId = s.projekt.projectId
s.buehne = h('div', { id: 'termine-buehne' })
root.replaceChildren(...(projects.length > 1 ? [projektwahl(s)] : []), s.buehne)
await zeigeProjekt(s)
}
function projektwahl(s) {
const select = h('select', { id: 'termine-project' }, s.projects.map((p) => {
const option = h('option', { value: p.projectId }, p.projectName)
option.selected = p.projectId === s.projekt.projectId
return option
}))
select.addEventListener('change', () => {
auswahl.projectId = select.value
s.projekt = s.projects.find((p) => p.projectId === select.value)
s.tag = heuteBerlin()
zeigeProjekt(s)
})
return h('div', { class: 'fn-project kb-panel' }, h('label', { for: 'termine-project' }, 'Website'), select)
}
const ladeDaten = (s) => request(`/api/termine/einstellungen?projectId=${encodeURIComponent(s.projekt.projectId)}`)
/** Je nach Zustand der Funktion: nicht freigeschaltet, Vorlage waehlen oder die Reiter. */
async function zeigeProjekt(s) {
const zustand = s.projekt.features?.termine?.state || 'off'
s.nurLesen = zustand !== 'active'
if (zustand !== 'active' && zustand !== 'disabled') {
s.buehne.replaceChildren(infoKarte('Der Terminplaner ist für diese Website noch nicht freigeschaltet', 'Wir schalten den Terminplaner für dich frei.', { chat: true }))
return
}
s.buehne.replaceChildren(h('p', { class: 'kb-empty' }, 'Lade…'))
try {
s.daten = await ladeDaten(s)
} catch (err) {
if (S === s) s.buehne.replaceChildren(fehlerKarte(err.message, () => zeigeProjekt(s)))
return
}
if (S !== s) return
if (s.daten.einstellung) zeigeReiter(s)
else if (s.nurLesen) s.buehne.replaceChildren(infoKarte('Der Terminplaner ist für diese Website abgeschaltet', 'Es wurden keine Termine eingerichtet.', { chat: true }))
else zeigeVorlagen(s)
}
/* ---------------------------------------------------- Vorlage wählen --- */
function zeigeVorlagen(s) {
const projektId = s.projekt.projectId
const fehler = h('p', { class: 'tm-fehler', role: 'alert' })
const start = h('button', { type: 'button', class: 'kb-btn-primary', disabled: true }, 'Mit dieser Vorlage starten')
let gewaehlt = null
const karten = (s.daten.vorlagen || []).map((v) => {
const radio = h('input', { type: 'radio', name: 'termine-vorlage', value: v.key })
radio.addEventListener('change', () => { gewaehlt = v; start.disabled = false; fehler.textContent = '' })
return h('label', { class: 'tm-vorlage' }, radio, h('span', null, h('strong', null, v.name), h('small', null, v.beschreibung)))
})
start.addEventListener('click', async () => {
if (!gewaehlt) return
start.disabled = true
fehler.textContent = ''
try {
await request('/api/termine/einstellungen/vorlage', { method: 'POST', body: { projectId: projektId, vorlage: gewaehlt.key } })
s.daten = await ladeDaten(s)
if (S !== s) return
zeigeReiter(s)
zeigeMeldung(s, `Die Vorlage „${gewaehlt.name}“ ist angelegt. Alles darin ist ein Vorschlag, den du anpassen kannst.`)
} catch (err) {
fehler.textContent = err.message
start.disabled = false
}
})
s.buehne.replaceChildren(h('div', { class: 'kb-panel tm-vorlagen' },
h('h3', null, 'Womit möchtest du starten?'),
h('p', null, 'Such dir die Vorlage aus, die am besten zu deinem Betrieb passt. Die Vorlage legt Leistungen, Zeiten und Regeln als Vorschlag an. Du kannst danach alles ändern.'),
h('fieldset', { class: 'tm-vorlagen-liste' }, h('legend', { class: 'tm-sr' }, 'Vorlage'), karten),
fehler,
h('div', { class: 'abo-actions' }, start)))
}
/* -------------------------------------------------------- Reiter-Gerüst --- */
const platzhalter = (titel, text) => (panel) => {
panel.replaceChildren(h('div', { class: 'kb-panel tm-info' }, h('h3', null, titel), h('p', null, text)))
}
/** key, Beschriftung, Funktion (panel, ctx). Aufgabe 21b ersetzt die Platzhalter, sonst bleibt alles wie es ist. */
const REITER = [
{ key: 'termine', label: 'Termine', render: renderTermine },
{ key: 'leistungen', label: 'Leistungen', render: platzhalter('Leistungen', 'Hier legst du bald fest, was deine Kundschaft buchen kann – mit Dauer, Preis und Reihenfolge.') },
{ key: 'team', label: 'Team & Plätze', render: platzhalter('Team & Plätze', 'Hier pflegst du bald Personen und Plätze mit ihren Zeiten, Urlaub und Kalender-Links.') },
{ key: 'einstellungen', label: 'Einstellungen', render: platzhalter('Einstellungen', 'Hier ändern wir bald Betriebsangaben, Regeln und Texte für deine Gäste.') },
]
/** Meldung ueber der Reiterleiste: art 'ok' (Standard) oder 'fehler'; leerer Text blendet sie aus. */
function zeigeMeldung(s, text, art = 'ok') {
const el = s.meldungEl
if (!el) return
el.textContent = text || ''
el.hidden = !text
el.className = `tm-meldung tm-meldung-${art}`
el.setAttribute('role', art === 'fehler' ? 'alert' : 'status')
}
function zeigeReiter(s) {
const projectId = s.projekt.projectId
const nurLesen = s.nurLesen
s.meldungEl = h('div', { class: 'tm-meldung', hidden: true })
const ctx = {
s,
projectId,
daten: s.daten,
nurLesen,
api: {
get: (pfad, query = {}) => request(`/api/termine${pfad}?${new URLSearchParams({ projectId, ...query })}`),
send: (method, pfad, body = {}) => (method === 'DELETE'
? request(`/api/termine${pfad}?${new URLSearchParams({ projectId })}`, { method })
: request(`/api/termine${pfad}`, { method, body: { projectId, ...body } })),
},
meldung: (text, art) => zeigeMeldung(s, text, art),
zumReiter: (key) => waehleReiter(key),
neuLaden: async () => {
s.daten = await ladeDaten(s)
ctx.daten = s.daten
return s.daten
},
}
const hinweise = []
if (nurLesen) {
hinweise.push(h('div', { class: 'tm-box tm-box-warn', role: 'note' },
'Der Terminplaner ist für diese Website abgeschaltet. Du kannst Termine nur noch ansehen und ',
h('a', { href: `/api/termine/export.csv?projectId=${encodeURIComponent(projectId)}`, download: 'termine.csv' }, 'exportieren'),
'.'))
} else if ((s.daten.fehlt || []).length) {
hinweise.push(h('div', { class: 'tm-box tm-box-warn', role: 'note' },
h('span', null, `Noch nicht buchbar: Es fehlt ${fehltText(s.daten.fehlt) || 'eine Angabe zu deinem Betrieb'}. Solange das fehlt, können Gäste über deine Website nicht buchen.`),
h('button', { type: 'button', class: 'kb-btn-outline', onclick: () => waehleReiter('einstellungen') }, 'Zu den Einstellungen')))
}
const panel = h('div', { class: 'tm-panel', role: 'tabpanel', id: 'tm-panel', tabindex: '0' })
const knoepfe = REITER.map((r) => h('button', {
type: 'button', role: 'tab', class: 'tm-reiter', id: `tm-tab-${r.key}`, 'aria-controls': 'tm-panel', 'data-reiter': r.key,
onclick: () => waehleReiter(r.key),
}, r.label))
const leiste = h('div', { class: 'tm-reiterleiste', role: 'tablist', 'aria-label': 'Bereiche des Terminplaners' }, knoepfe)
leiste.addEventListener('keydown', (ev) => {
const i = REITER.findIndex((r) => r.key === auswahl.reiter)
const ziel = { ArrowRight: (i + 1) % REITER.length, ArrowLeft: (i + REITER.length - 1) % REITER.length, Home: 0, End: REITER.length - 1 }[ev.key]
if (ziel === undefined) return
ev.preventDefault()
waehleReiter(REITER[ziel].key)
knoepfe[ziel].focus()
})
function waehleReiter(key) {
const reiter = REITER.find((r) => r.key === key) || REITER[0]
auswahl.reiter = reiter.key
for (const k of knoepfe) {
const an = k.dataset.reiter === reiter.key
k.setAttribute('aria-selected', String(an))
k.tabIndex = an ? 0 : -1
}
panel.setAttribute('aria-labelledby', `tm-tab-${reiter.key}`)
zeigeMeldung(s, '')
panel.replaceChildren()
Promise.resolve()
.then(() => reiter.render(panel, ctx))
.catch((err) => { if (S === s) panel.replaceChildren(fehlerKarte(err.message, () => waehleReiter(reiter.key))) })
}
s.buehne.replaceChildren(...hinweise, s.meldungEl, leiste, panel)
waehleReiter(auswahl.reiter)
}
/* -------------------------------------------------------- Reiter Termine --- */
const AKTION_TEXT = {
annehmen: 'Annehmen', ablehnen: 'Ablehnen', verschieben: 'Verschieben', absagen: 'Absagen', nichterschienen: 'Nicht erschienen',
anonymisieren: 'Gastdaten löschen', entsperren: 'Sperre aufheben',
}
const ABSENDER = { gast: 'Vom Gast abgesagt', betrieb: 'Von dir abgesagt', system: 'Automatisch abgelehnt (Termin vorbei)' }
function renderTermine(panel, ctx) {
const t = { ctx, panel, nr: 0, buchungen: [], offen: null, formular: null }
const { s } = ctx
// Kopf: Ansicht, Navigation, Titel, Filter, Werkzeuge
const ansichtKnoepfe = [['tag', 'Tag'], ['woche', 'Woche'], ['monat', 'Monat']].map(([key, text]) =>
h('button', { type: 'button', class: 'tm-seg', 'data-ansicht': key, onclick: () => setze(t, { ansicht: key }) }, text))
t.zurueck = h('button', { type: 'button', class: 'kb-btn-outline', hidden: true, onclick: () => setze(t, { ansicht: 'tag' }) }, 'Zurück zur Übersicht')
t.nav = h('div', { class: 'tm-nav' },
h('button', { type: 'button', class: 'tm-pfeil', 'aria-label': 'Zurück', onclick: () => setze(t, { tag: verschiebeTag(auswahl.ansicht, s.tag, -1) }) }, h('span', { 'aria-hidden': 'true' }, '‹')),
h('button', { type: 'button', class: 'kb-btn-outline tm-heute', onclick: () => setze(t, { tag: heuteBerlin() }) }, 'Heute'),
h('button', { type: 'button', class: 'tm-pfeil', 'aria-label': 'Weiter', onclick: () => setze(t, { tag: verschiebeTag(auswahl.ansicht, s.tag, 1) }) }, h('span', { 'aria-hidden': 'true' }, '›')))
t.datumFeld = h('input', { type: 'date', id: 'tm-sprung', 'aria-label': 'Zu einem Datum springen' })
t.datumFeld.addEventListener('change', () => { if (t.datumFeld.value) setze(t, { tag: t.datumFeld.value }) })
t.titel = h('h3', { class: 'tm-zeitraum', 'aria-live': 'polite' })
const filterKnoepfe = FILTER.map((f) => h('button', { type: 'button', class: 'tm-chip', 'data-filter': f.key, onclick: () => setze(t, { filter: f.key }) }, f.label))
t.filterBox = h('div', { class: 'tm-filter', role: 'group', 'aria-label': 'Nach Status filtern' }, filterKnoepfe)
t.ansichtBox = h('div', { class: 'tm-segment', role: 'group', 'aria-label': 'Ansicht' }, ansichtKnoepfe)
t.werkzeuge = ctx.nurLesen ? null : h('div', { class: 'tm-werkzeuge' },
h('button', { type: 'button', class: 'kb-btn-primary', 'data-werkzeug': 'manuell', onclick: () => oeffneFormular(t, 'manuell') }, '+ Termin eintragen'),
h('button', { type: 'button', class: 'kb-btn-outline', 'data-werkzeug': 'sperre', onclick: () => oeffneFormular(t, 'sperre') }, 'Zeit sperren'))
t.neuBtn = h('button', { type: 'button', class: 'kb-btn-outline', onclick: () => ladeListe(t, { auchZahl: true }) }, 'Aktualisieren')
t.anfragenBox = h('div', { class: 'tm-box tm-box-info', role: 'status', hidden: true })
t.formularBox = h('div', { class: 'tm-formular-box' })
t.liste = h('div', { class: 'tm-liste', 'aria-live': 'polite' })
panel.replaceChildren(
h('div', { class: 'tm-leiste' }, t.ansichtBox, t.nav, h('label', { class: 'tm-sprung', for: 'tm-sprung' }, h('span', null, 'Datum'), t.datumFeld), t.zurueck),
t.titel,
t.filterBox,
h('div', { class: 'tm-werkzeug-zeile' }, t.werkzeuge, t.neuBtn),
t.anfragenBox,
t.formularBox,
t.liste)
aktualisiereKopf(t)
ladeListe(t)
}
/** Auswahl aendern (Ansicht, Filter, Tag) und die Liste neu laden. */
function setze(t, aenderung) {
const { s } = t.ctx
if (aenderung.ansicht) auswahl.ansicht = aenderung.ansicht
if (aenderung.filter) auswahl.filter = aenderung.filter
if (aenderung.tag) s.tag = aenderung.tag
t.ctx.meldung('')
schliesseFrage(t)
aktualisiereKopf(t)
ladeListe(t)
}
function aktualisiereKopf(t) {
const { s, daten } = t.ctx
const anfragen = auswahl.ansicht === 'anfragen'
for (const k of t.ansichtBox.children) k.setAttribute('aria-pressed', String(!anfragen && k.dataset.ansicht === auswahl.ansicht))
for (const k of t.filterBox.children) k.setAttribute('aria-pressed', String(!anfragen && k.dataset.filter === auswahl.filter))
t.ansichtBox.hidden = anfragen
t.nav.hidden = anfragen
t.filterBox.hidden = anfragen
t.datumFeld.parentElement.hidden = anfragen
t.zurueck.hidden = !anfragen
t.titel.textContent = zeitraumTitel(auswahl.ansicht, s.tag)
t.datumFeld.value = s.tag
const n = daten.anfragenOffen
t.anfragenBox.hidden = anfragen || !(n > 0)
if (!t.anfragenBox.hidden) {
t.anfragenBox.replaceChildren(
h('span', null, anfragenText(n)),
h('button', { type: 'button', class: 'kb-btn-outline', onclick: () => setze(t, { ansicht: 'anfragen' }) }, 'Anfragen ansehen'))
}
}
async function ladeListe(t, { auchZahl = false } = {}) {
const { ctx } = t
const { s } = ctx
const nr = ++t.nr
const anfragen = auswahl.ansicht === 'anfragen'
const { von, bis } = bereichFuer(auswahl.ansicht, s.tag, heuteBerlin())
const status = anfragen ? 'angefragt' : filterStatus(auswahl.filter)
t.liste.setAttribute('aria-busy', 'true')
try {
const [antwort] = await Promise.all([
ctx.api.get('/buchungen', { von, bis, ...(status ? { status } : {}) }),
auchZahl ? ctx.neuLaden().then(() => aktualisiereKopf(t), () => {}) : null, // die Zahl der Anfragen ist Beiwerk: ein Fehler dabei kostet nicht die Liste
])
if (nr !== t.nr || S !== s || !t.liste.isConnected) return
t.buchungen = antwort.buchungen || []
zeichneListe(t)
} catch (err) {
if (nr !== t.nr || S !== s) return
t.liste.replaceChildren(fehlerKarte(err.message, () => ladeListe(t)))
} finally {
if (nr === t.nr) t.liste.removeAttribute('aria-busy')
}
}
/** Nach einer Aenderung: Liste und Zahl der Anfragen neu holen. */
const nachAenderung = (t) => ladeListe(t, { auchZahl: true })
function leerText() {
if (auswahl.ansicht === 'anfragen') return 'Es warten keine Anfragen.'
const wo = auswahl.ansicht === 'monat' ? 'in diesem Monat' : 'an diesem Tag'
return auswahl.filter === 'alle' ? `Keine Termine ${wo}.` : `Keine Termine mit diesem Filter ${wo}.`
}
function zeichneListe(t) {
const { s } = t.ctx
const heute = heuteBerlin()
const gruppen = gruppiereNachTag(t.buchungen)
let tage = gruppen
// Tag und Woche zeigen jeden Tag des Zeitraums, auch leere; Monat und Anfragen nur Tage mit Terminen
if (auswahl.ansicht === 'tag' || auswahl.ansicht === 'woche') {
const { von, bis } = bereichFuer(auswahl.ansicht, s.tag, heute)
tage = []
for (let tag = von; tag <= bis; tag = tagPlus(tag, 1)) tage.push(gruppen.find((g) => g.tag === tag) || { tag, buchungen: [] })
}
t.offen = null
if (!gruppen.length && auswahl.ansicht !== 'woche') {
t.liste.replaceChildren(h('p', { class: 'tm-leer' }, leerText()))
return
}
t.liste.replaceChildren(...tage.map((g) => h('section', { class: 'tm-tag', 'data-tag': g.tag },
auswahl.ansicht === 'tag' ? null : h('h4', { class: 'tm-tag-kopf' }, tagUeberschrift(g.tag, heute)),
g.buchungen.length
? h('ul', { class: 'tm-eintraege' }, g.buchungen.map((b) => eintrag(t, b)))
: h('p', { class: 'tm-leer' }, 'Keine Termine'))))
}
function eintrag(t, b) {
const { ctx } = t
const sperre = istSperre(b)
const aktionen = ctx.nurLesen ? [] : moeglicheAktionen(b, Date.now())
const kontext = `${sperre ? 'Sperre' : b.leistung}, ${datumKurz(b.datum)} um ${b.zeit} Uhr`
const li = h('li', { class: `tm-eintrag${sperre ? ' tm-sperre' : ''}`, 'data-id': b.id, 'data-status': b.status, 'data-quelle': b.quelle })
const titelBlock = sperre
? [h('strong', { class: 'tm-name' }, 'Gesperrt'), b.titel && b.titel !== 'Gesperrt' ? h('span', { class: 'tm-meta' }, b.titel) : null, b.person ? h('span', { class: 'tm-meta' }, b.person) : null]
: [h('strong', { class: 'tm-name' }, b.leistung || 'Termin'),
h('span', { class: 'tm-meta' }, [b.person, b.gastName || 'Ohne Namen', b.quelle === 'manuell' ? 'von dir eingetragen' : ''].filter(Boolean).join(' · '))]
li.append(h('div', { class: 'tm-zeile' },
h('span', { class: 'tm-zeit' }, `${b.zeit}–${endeZeit(b)}`),
h('div', { class: 'tm-haupt' }, titelBlock),
sperre ? h('span', { class: 'tm-pille tm-pille-off' }, 'Gesperrt') : h('span', { class: `tm-pille tm-pille-${statusTon(b.status)}` }, statusLabel(b.status))))
const hinweise = []
if (b.zustellFehler) hinweise.push(h('p', { class: 'tm-hinweis-zeile tm-warn' }, h('strong', null, 'Bestätigung nicht zugestellt'), ' – die E-Mail an den Gast ist nicht angekommen. Melde dich am besten telefonisch.'))
if (b.status === 'abgesagt') {
// absageGrund steht auch bei Vermerken an noch bestehenden Terminen (z. B. spaeter Widerruf): als Grund nur bei einer Absage zeigen
if (b.widerrufenAt) hinweise.push(h('p', { class: 'tm-hinweis-zeile' }, 'Vom Gast widerrufen'))
else if (ABSENDER[b.abgesagtVon]) hinweise.push(h('p', { class: 'tm-hinweis-zeile' }, ABSENDER[b.abgesagtVon]))
if (b.absageGrund) hinweise.push(h('p', { class: 'tm-hinweis-zeile' }, `${b.abgesagtVon === 'betrieb' ? 'Grund' : 'Vermerk'}: ${b.absageGrund}`))
}
if (b.status === 'abgelehnt' && b.abgesagtVon === 'system') hinweise.push(h('p', { class: 'tm-hinweis-zeile' }, ABSENDER.system))
li.append(...hinweise)
const knoepfe = aktionen.filter((a) => a !== 'anonymisieren')
if (knoepfe.length) {
li.append(h('div', { class: 'tm-aktionen' }, knoepfe.map((a) => h('button', {
type: 'button',
class: a === 'annehmen' ? 'kb-btn-primary tm-knopf' : 'kb-btn-outline tm-knopf',
'data-aktion': a,
'aria-label': `${AKTION_TEXT[a]}: ${kontext}`,
onclick: (ev) => starteAktion(t, li, b, a, ev.currentTarget),
}, AKTION_TEXT[a]))))
}
li.append(h('div', { class: 'tm-frage', 'data-frage': '' }))
if (!sperre) {
const fakten = [
['Buchungsnummer', b.nummer], ['Dauer', `${b.dauer} Minuten`], ['Name', b.gastName], ['E-Mail', b.gastEmail], ['Telefon', b.gastTelefon],
['Nachricht', b.gastNachricht], ['Erinnerung', b.erinnerung ? 'Per E-Mail gewünscht' : ''],
].filter(([, wert]) => wert)
li.append(h('details', { class: 'tm-details' },
h('summary', { 'aria-label': `Details: ${kontext}` }, 'Details'),
h('dl', { class: 'tm-fakten' }, fakten.map(([name, wert]) => h('div', null, h('dt', null, name), h('dd', null, wert)))),
aktionen.includes('anonymisieren')
? h('button', { type: 'button', class: 'tm-link-knopf', 'data-aktion': 'anonymisieren', onclick: (ev) => starteAktion(t, li, b, 'anonymisieren', ev.currentTarget) }, 'Gastdaten löschen')
: null))
}
return li
}
/* ------------------------------------------------- Aktionen mit Rückfrage --- */
function schliesseFrage(t, fokus = false) {
if (!t.offen) return
const { li, knopf } = t.offen
li.querySelector('[data-frage]')?.replaceChildren()
li.classList.remove('tm-offen')
t.offen = null
if (fokus && knopf?.isConnected) knopf.focus()
}
function starteAktion(t, li, b, art, knopf) {
t.ctx.meldung('')
if (art === 'annehmen') {
schliesseFrage(t)
fuehreAus(t, b, { aktion: 'annehmen' }, b.gastEmail ? `Anfrage angenommen. ${b.gastName} bekommt eine Bestätigung per E-Mail.` : 'Anfrage angenommen.', null, knopf)
return
}
schliesseFrage(t)
const zu = () => schliesseFrage(t, true)
const fragen = { ablehnen: frageAblehnen, absagen: frageAbsagen, verschieben: frageVerschieben, nichterschienen: frageNichtErschienen, anonymisieren: frageAnonymisieren, entsperren: frageEntsperren }
const box = fragen[art](t, b, zu)
li.querySelector('[data-frage]').replaceChildren(box)
li.classList.add('tm-offen')
t.offen = { li, knopf, art }
// Fokus ins erste Eingabefeld; ohne Eingabefeld auf die Rueckfrage selbst, damit ein Enter nichts bestaetigt
const erster = box.querySelector('select, textarea, input') || box
erster.focus()
}
/**
* Aktion an die Schnittstelle schicken. erfolg = Text der Meldung. form = Rueckfrage-Formular fuer Fehler (400 und belegte Zeit
* bleiben dort), sonst Meldung ueber den Reitern; bei Fehlern, die auf einen geaenderten Stand deuten (404, 409), wird die Liste neu geladen.
*/
async function fuehreAus(t, b, body, erfolg, form, knopf) {
const { ctx } = t
knopf.disabled = true
try {
const entsperren = body.aktion === 'entsperren'
const antwort = entsperren
? await ctx.api.send('DELETE', `/buchungen/${encodeURIComponent(b.id)}`)
: await ctx.api.send('POST', `/buchungen/${encodeURIComponent(b.id)}/aktion`, body)
await nachAenderung(t) // erst die Liste, dann die Meldung: wer die Meldung sieht, sieht auch den neuen Stand
ctx.meldung(antwort.schon ? 'Das war schon so erledigt.' : erfolg)
} catch (err) {
const imFormular = form && (err.status === 400 || err.code === 'SLOT_VERGEBEN')
if (imFormular) {
zeigeFehler(form, err)
knopf.disabled = false
return
}
ctx.meldung(err.message, 'fehler')
if (err.status === 404 || err.status === 409) await nachAenderung(t)
else knopf.disabled = false
}
}
const mailHinweis = (b, text) => (b.gastEmail ? text : 'Der Gast bekommt keine E-Mail, weil keine Adresse hinterlegt ist.')
function frage(titel, ...inhalt) {
return h('form', { class: 'tm-frage-box', novalidate: true, tabindex: '-1', 'aria-label': titel, onsubmit: (ev) => ev.preventDefault() }, h('strong', null, titel), ...inhalt)
}
/** Knopfzeile einer Rueckfrage: Bestaetigen (ok, schickt das Formular ab) und Abbrechen. */
function knopfZeile(bestaetigen, zu) {
const ok = h('button', { type: 'submit', class: 'kb-btn-primary' }, bestaetigen)
return { ok, zeile: h('div', { class: 'tm-frage-knoepfe' }, ok, h('button', { type: 'button', class: 'kb-btn-outline', onclick: zu }, 'Abbrechen')) }
}
const allgemeinerFehler = () => h('p', { class: 'tm-fehler', 'data-fehler': '_', role: 'alert' })
function frageAblehnen(t, b, zu) {
const nachricht = h('textarea', { rows: '3', maxlength: String(NACHRICHT_MAX) })
const { ok, zeile } = knopfZeile('Anfrage ablehnen', zu)
const form = frage('Anfrage ablehnen?',
h('p', null, mailHinweis(b, 'Der Gast bekommt eine E-Mail, dass der Termin nicht möglich ist. Die Zeit wird wieder frei.')),
feld('nachricht', 'Nachricht an den Gast (optional)', nachricht),
allgemeinerFehler(),
zeile)
form.addEventListener('submit', () => fuehreAus(t, b, { aktion: 'ablehnen', nachricht: nachricht.value }, 'Anfrage abgelehnt.', form, ok))
return form
}
function frageAbsagen(t, b, zu) {
const grund = h('select', null, h('option', { value: '' }, 'Bitte wählen'), ABSAGE_GRUENDE.map((g) => h('option', { value: g }, g)))
const nachricht = h('textarea', { rows: '3', maxlength: String(NACHRICHT_MAX) })
const { ok, zeile } = knopfZeile('Termin absagen', zu)
const form = frage('Termin absagen?',
h('p', null, mailHinweis(b, 'Der Gast bekommt eine E-Mail mit dem Grund. Die Zeit wird wieder frei.')),
feld('grund', 'Grund', grund),
feld('nachricht', 'Nachricht an den Gast (optional)', nachricht),
allgemeinerFehler(),
zeile)
form.addEventListener('submit', () => {
if (!grund.value) {
setzeFehler(form, { grund: 'Bitte wähle einen Grund aus.' })
grund.focus()
return
}
fuehreAus(t, b, { aktion: 'absagen', grund: grund.value, nachricht: nachricht.value }, 'Termin abgesagt.', form, ok)
})
return form
}
function frageNichtErschienen(t, b, zu) {
const { ok, zeile } = knopfZeile('Ja, nicht erschienen', zu)
const form = frage('Als „nicht erschienen“ markieren?',
h('p', null, 'Der Termin bleibt in der Liste und wird als „Nicht erschienen“ geführt. Der Gast bekommt keine E-Mail.'),
allgemeinerFehler(),
zeile)
form.addEventListener('submit', () => fuehreAus(t, b, { aktion: 'nichterschienen' }, 'Als „nicht erschienen“ markiert.', form, ok))
return form
}
function frageAnonymisieren(t, b, zu) {
const { ok, zeile } = knopfZeile('Gastdaten löschen', zu)
const form = frage('Gastdaten löschen?',
h('p', null, 'Name, E-Mail, Telefon und Nachricht werden gelöscht. Das lässt sich nicht rückgängig machen. Der Termin bleibt ohne Namen bestehen, die Links in alten E-Mails funktionieren danach nicht mehr.'),
allgemeinerFehler(),
zeile)
form.addEventListener('submit', () => fuehreAus(t, b, { aktion: 'anonymisieren' }, 'Gastdaten gelöscht.', form, ok))
return form
}
function frageEntsperren(t, b, zu) {
const { ok, zeile } = knopfZeile('Sperre aufheben', zu)
const form = frage('Sperre aufheben?',
h('p', null, 'Die Zeit ist danach wieder buchbar.'),
allgemeinerFehler(),
zeile)
form.addEventListener('submit', () => fuehreAus(t, b, { aktion: 'entsperren' }, 'Sperre aufgehoben.', form, ok))
return form
}
function frageVerschieben(t, b, zu) {
const { ctx } = t
const datum = h('input', { type: 'date', value: b.datum, min: heuteBerlin() })
const zeiten = zeitWahl(ctx, () => ({ ohne: b.id, tag: datum.value }))
const { ok, zeile } = knopfZeile('Verschieben', zu)
const form = frage('Termin verschieben',
h('p', null, mailHinweis(b, 'Der Gast bekommt eine E-Mail mit der neuen Zeit. Es gelten keine Fristen und kein Vorlauf.')),
feld('tag', 'Neuer Tag', datum),
zeiten.el,
allgemeinerFehler(),
zeile)
datum.addEventListener('change', () => zeiten.laden())
zeiten.laden()
form.addEventListener('submit', () => {
if (!zeiten.wert()) {
setzeFehler(form, { zeit: 'Bitte wähle eine Uhrzeit aus.' })
return
}
fuehreAus(t, b, { aktion: 'verschieben', tag: datum.value, zeit: zeiten.wert() }, `Termin verschoben auf ${datumKurz(datum.value)}, ${zeiten.wert()} Uhr.`, form, ok)
})
return form
}
/** Freie Uhrzeiten als Auswahl-Knoepfe: laden() holt sie fuer query() (leistung/ressource/tag oder ohne/tag), wert() ist die gewaehlte. */
function zeitWahl(ctx, query, { onWahl } = {}) {
let gewaehlt = ''
let nr = 0
const status = h('p', { class: 'tm-hinweis', 'aria-live': 'polite' })
const knoepfe = h('div', { class: 'tm-zeiten', role: 'group', 'aria-label': 'Freie Uhrzeiten' })
const fehler = h('p', { class: 'tm-fehler', 'data-fehler': 'zeit', role: 'alert' })
const el = h('div', { class: 'tm-feld', 'data-feld': 'zeit' }, h('span', { class: 'tm-label' }, 'Uhrzeit'), status, knoepfe, fehler)
async function laden() {
const mein = ++nr
gewaehlt = ''
knoepfe.replaceChildren()
fehler.textContent = ''
const q = query()
if (!q || !q.tag) { status.textContent = 'Wähle zuerst einen Tag.'; return }
status.textContent = 'Freie Zeiten werden geladen …'
try {
const { zeiten } = await ctx.api.get('/zeiten', q)
if (mein !== nr) return
status.textContent = zeiten.length ? 'Wähle eine Uhrzeit:' : 'An diesem Tag ist nichts frei.'
knoepfe.replaceChildren(...zeiten.map((z) => h('button', {
type: 'button', class: 'tm-chip tm-zeit-knopf', 'aria-pressed': 'false', 'data-zeit': z,
onclick: (ev) => {
gewaehlt = z
for (const k of knoepfe.children) k.setAttribute('aria-pressed', String(k === ev.currentTarget))
fehler.textContent = ''
onWahl?.(z)
},
}, z)))
} catch (err) {
if (mein === nr) status.textContent = err.message
}
}
return { el, laden, wert: () => gewaehlt }
}
/* ---------------------------------------- Termin eintragen / Zeit sperren --- */
function oeffneFormular(t, art) {
schliesseFrage(t)
t.ctx.meldung('')
if (t.formular === art) { schliesseFormular(t); return }
t.formular = art
for (const k of t.werkzeuge.children) k.setAttribute('aria-expanded', String(k.dataset.werkzeug === art))
const form = art === 'manuell' ? formularTermin(t) : formularSperre(t)
t.formularBox.replaceChildren(form)
form.querySelector('select, input')?.focus()
}
function schliesseFormular(t) {
t.formular = null
t.formularBox.replaceChildren()
for (const k of t.werkzeuge?.children || []) k.setAttribute('aria-expanded', 'false')
}
/** Startwert fuer Tag-Felder: der gewaehlte Tag, wenn er nicht in der Vergangenheit liegt, sonst heute. */
const startTag = (t) => (t.ctx.s.tag >= heuteBerlin() ? t.ctx.s.tag : heuteBerlin())
async function nachErfolg(t, tag, text) {
schliesseFormular(t)
t.ctx.s.tag = tag
if (auswahl.ansicht === 'anfragen') auswahl.ansicht = 'tag'
aktualisiereKopf(t)
await nachAenderung(t)
t.ctx.meldung(text)
}
function formularTermin(t) {
const { ctx } = t
const e = ctx.daten.einstellung
const aktiveRessourcen = (e.ressourcen || []).filter((r) => r.aktiv)
const leistungen = (e.leistungen || []).filter((l) => l.aktiv && (l.ressourcen || []).some((id) => aktiveRessourcen.some((r) => r.id === id)))
if (!leistungen.length) {
return h('div', { class: 'kb-panel tm-formular' }, h('p', null, 'Lege zuerst im Reiter „Leistungen“ eine Leistung an, die du buchen kannst.'),
h('button', { type: 'button', class: 'kb-btn-outline', onclick: () => ctx.zumReiter('leistungen') }, 'Zu den Leistungen'))
}
const wort = ressourcenWort(aktiveRessourcen)
const leistung = h('select', null, leistungen.map((l) => h('option', { value: l.id }, `${l.name} (${l.dauer} Min.)`)))
const person = h('select')
const tag = h('input', { type: 'date', value: startTag(t), min: heuteBerlin() })
const name = h('input', { type: 'text', maxlength: String(NAME_MAX), autocomplete: 'off' })
const email = h('input', { type: 'email', maxlength: '254', autocomplete: 'off' })
const telefon = h('input', { type: 'tel', maxlength: String(TELEFON_MAX), autocomplete: 'off' })
const nachricht = h('textarea', { rows: '3', maxlength: String(NACHRICHT_MAX) })
const senden = h('input', { type: 'checkbox', id: 'tm-senden' })
const sendenHinweis = h('span', { class: 'tm-hinweis' })
const zeiten = zeitWahl(ctx, () => ({ leistung: leistung.value, ressource: person.value, tag: tag.value }))
function ressourcenOptionen() {
const l = leistungen.find((x) => x.id === leistung.value)
const infrage = aktiveRessourcen.filter((r) => (l.ressourcen || []).includes(r.id))
person.replaceChildren(...(infrage.length > 1 ? [h('option', { value: 'egal' }, 'Egal (erste freie)')] : []), ...infrage.map((r) => h('option', { value: r.id }, r.name)))
person.disabled = infrage.length === 1
}
ressourcenOptionen()
leistung.addEventListener('change', () => { ressourcenOptionen(); zeiten.laden() })
person.addEventListener('change', () => zeiten.laden())
tag.addEventListener('change', () => zeiten.laden())
const mailAn = () => { senden.disabled = email.value.trim() === ''; if (senden.disabled) senden.checked = false; sendenHinweis.textContent = senden.disabled ? ' (nur mit E-Mail-Adresse)' : '' }
email.addEventListener('input', mailAn)
mailAn()
const ok = h('button', { type: 'submit', class: 'kb-btn-primary' }, 'Termin eintragen')
const form = h('form', { class: 'kb-panel tm-formular', novalidate: true, 'aria-label': 'Termin eintragen' },
h('h3', null, 'Termin eintragen'),
h('p', { class: 'tm-hinweis' }, 'Für Anrufe und Laufkundschaft. Der Termin ist sofort bestätigt. Fristen und Vorlauf gelten dafür nicht.'),
h('div', { class: 'tm-raster' },
feld('leistung', 'Leistung', leistung),
feld('ressource', wort, person),
feld('tag', 'Tag', tag)),
zeiten.el,
h('div', { class: 'tm-raster' },
feld('name', 'Name des Gastes', name),
feld('email', 'E-Mail (optional)', email),
feld('telefon', 'Telefon (optional)', telefon)),
feld('nachricht', 'Notiz (optional)', nachricht, 'Bitte keine Gesundheitsangaben.'),
h('label', { class: 'tm-haken', for: 'tm-senden' }, senden, h('span', null, 'Bestätigung an den Gast senden', sendenHinweis)),
h('p', { class: 'tm-fehler', 'data-fehler': '_', role: 'alert' }),
h('div', { class: 'tm-frage-knoepfe' }, ok, h('button', { type: 'button', class: 'kb-btn-outline', onclick: () => { schliesseFormular(t); t.werkzeuge.querySelector('[data-werkzeug="manuell"]').focus() } }, 'Abbrechen')))
zeiten.laden()
form.addEventListener('submit', async (ev) => {
ev.preventDefault()
setzeFehler(form, {})
if (!zeiten.wert()) {
setzeFehler(form, { zeit: 'Bitte wähle eine Uhrzeit aus.' })
return
}
ok.disabled = true
try {
const gewaehlt = tag.value
await ctx.api.send('POST', '/buchungen', {
art: 'manuell', leistung: leistung.value, ressource: person.value, tag: gewaehlt, zeit: zeiten.wert(), name: name.value,
email: email.value, telefon: telefon.value, nachricht: nachricht.value, bestaetigungSenden: senden.checked,
})
await nachErfolg(t, gewaehlt, 'Termin eingetragen.')
} catch (err) {
ok.disabled = false
if (err.status === 400 || err.code === 'SLOT_VERGEBEN') {
zeigeFehler(form, err)
if (err.code === 'SLOT_VERGEBEN') zeiten.laden()
} else {
ctx.meldung(err.message, 'fehler')
}
}
})
return form
}
function formularSperre(t) {
const { ctx } = t
const e = ctx.daten.einstellung
const ressourcen = (e.ressourcen || []).filter((r) => r.aktiv)
const wort = ressourcenWort(ressourcen)
const person = h('select', null, ressourcen.map((r) => h('option', { value: r.id }, r.name)))
const tag = h('input', { type: 'date', value: startTag(t), min: heuteBerlin() })
const von = h('input', { type: 'text', inputmode: 'numeric', maxlength: '5', placeholder: '12:00', autocomplete: 'off' })
const bis = h('input', { type: 'text', inputmode: 'numeric', maxlength: '5', placeholder: '13:00', autocomplete: 'off' })
const titel = h('input', { type: 'text', maxlength: String(TITEL_MAX), placeholder: 'z. B. Mittagspause', autocomplete: 'off' })
const ok = h('button', { type: 'submit', class: 'kb-btn-primary' }, 'Zeit sperren')
const form = h('form', { class: 'kb-panel tm-formular', novalidate: true, 'aria-label': 'Zeit sperren' },
h('h3', null, 'Zeit sperren'),
h('p', { class: 'tm-hinweis' }, 'In dieser Zeit kann niemand buchen. Eine Sperre kann höchstens 8 Stunden 20 Minuten (500 Minuten) dauern. Für ganze Tage oder Urlaub nutze die Abwesenheiten im Reiter „Team & Plätze“.'),
h('div', { class: 'tm-raster' },
feld('ressource', wort, person),
feld('tag', 'Tag', tag)),
h('div', { class: 'tm-raster' },
feld('von', 'Von (Uhrzeit)', von, 'In 5-Minuten-Schritten, z. B. 12:00.'),
feld('bis', 'Bis (Uhrzeit)', bis, 'Auch 24:00 ist möglich.')),
feld('titel', 'Titel (optional)', titel, 'Nur für dich sichtbar.'),
h('p', { class: 'tm-fehler', 'data-fehler': '_', role: 'alert' }),
h('div', { class: 'tm-frage-knoepfe' }, ok, h('button', { type: 'button', class: 'kb-btn-outline', onclick: () => { schliesseFormular(t); t.werkzeuge.querySelector('[data-werkzeug="sperre"]').focus() } }, 'Abbrechen')))
form.addEventListener('submit', async (ev) => {
ev.preventDefault()
setzeFehler(form, {})
const vonZeit = zeitNormal(von.value)
const bisZeit = zeitNormal(bis.value, { ende: true })
const fehler = {}
if (!vonZeit) fehler.von = 'Bitte gib die Uhrzeit so an: 12:00.'
if (!bisZeit) fehler.bis = 'Bitte gib die Uhrzeit so an: 13:00.'
if (Object.keys(fehler).length) {
zeigeFehler(form, { fehler })
return
}
ok.disabled = true
try {
const gewaehlt = tag.value
await ctx.api.send('POST', '/buchungen', { art: 'sperre', ressource: person.value, tag: gewaehlt, von: vonZeit, bis: bisZeit, titel: titel.value })
await nachErfolg(t, gewaehlt, 'Zeit gesperrt.')
} catch (err) {
ok.disabled = false
if (err.status === 400 || err.code === 'SLOT_VERGEBEN') zeigeFehler(form, err)
else ctx.meldung(err.message, 'fehler')
}
})
return form
}