/* 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) }