Files
Webklar-Kundenbereich/public/termine.js
T
ClaudeandClaude Sonnet 5.5 9c07f6a8c5 Terminplaner: Portal-Ansicht – Nachbesserungen (1/2: Module getrennt)
public/termine.js in Huelle und Module zerlegt, Verhalten unveraendert (R48):
termine-dom.js (Helfer ohne Zustand), termine-tab-termine.js (Liste, Kopf, Zeilen),
termine-aktionen.js (Aktionen mit Rueckfrage), termine-formulare.js (Uhrzeiten,
Termin eintragen, Zeit sperren). Reiter bekommen Zustand und aktuell() nur ueber
ctx; die Huelle haengt sie ueber die Registry REITER ein. Statiktest prueft alle
public/termine*.js.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:44 +00:00

212 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Terminplaner im Kundenbereich (ES-Modul; app.js laedt es erst beim Oeffnen der Ansicht #termine). Das ist die Huelle:
initTermine(rootEl, { projectId }): Projektauswahl, Vorlagenauswahl beim ersten Oeffnen, Reiter-Geruest und die Meldung ueber den Reitern.
Die Reiter stehen in REITER: jeder ist ein Modul mit render(panel, ctx) => void | Promise (Termine: termine-tab-termine.js; Leistungen,
Team & Plaetze und Einstellungen (Aufgabe 21b) ersetzen dort nur ihren Platzhalter).
ctx = { s, projectId, daten, nurLesen, auswahl, aktuell(), api, meldung, zumReiter, neuLaden }: Zustand und Pruefung "bin ich noch
aktuell?" bekommen die Reiter nur ueber ctx, nie ueber Modulvariablen. daten ist die Antwort von GET /api/termine/einstellungen,
nurLesen gilt fuer abgeschaltete Funktionen. Server: server/routes/termine.js. Tage sind Berliner Kalendertage 'JJJJ-MM-TT';
gerechnet wird in termine-view.js. Helfer ohne Zustand: termine-dom.js. Styles: termine.css. */
import { fehltText, heuteBerlin } from './termine-view.js'
import { exportUrl, fehlerKarte, h, infoKarte, request } from './termine-dom.js'
import { render as renderTermine } from './termine-tab-termine.js'
// 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)
/* --------------------------------------------------------- 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,
auswahl,
aktuell: () => S === s,
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: exportUrl(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)
}