diff --git a/public/termine-aktionen.js b/public/termine-aktionen.js new file mode 100644 index 0000000..af12964 --- /dev/null +++ b/public/termine-aktionen.js @@ -0,0 +1,167 @@ +/* Terminplaner im Kundenbereich: Aktionen auf Terminen mit Rueckfrage in der Seite (kein confirm()). + starteAktion(t, li, b, art, knopf) oeffnet die Rueckfrage unter dem Termin, fuehreAus schickt sie an /api/termine ab. + t ist der Zustand des Reiters "Termine": { ctx, offen, nachAenderung() } – nachAenderung laedt Liste und Zahl der Anfragen neu + (der Reiter setzt sie; so kennt dieses Modul den Reiter nicht). Fehler: 400 und belegte Zeit bleiben in der Rueckfrage, + alles andere erscheint als Meldung ueber den Reitern. */ + +import { MAX, ABSAGE_GRUENDE, datumKurz, heuteBerlin } from './termine-view.js' +import { allgemeinerFehler, feld, h, setzeFehler, zeigeFehler } from './termine-dom.js' +import { zeitWahl } from './termine-formulare.js' + +export const AKTION_TEXT = { + annehmen: 'Annehmen', ablehnen: 'Ablehnen', verschieben: 'Verschieben', absagen: 'Absagen', nichterschienen: 'Nicht erschienen', + anonymisieren: 'Gastdaten löschen', entsperren: 'Sperre aufheben', +} + +export 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() +} + +export 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 t.nachAenderung() // 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 t.nachAenderung() + 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')) } +} + +function frageAblehnen(t, b, zu) { + const nachricht = h('textarea', { rows: '3', maxlength: String(MAX.nachricht) }) + 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(MAX.nachricht) }) + 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 +} diff --git a/public/termine-dom.js b/public/termine-dom.js new file mode 100644 index 0000000..dee20b3 --- /dev/null +++ b/public/termine-dom.js @@ -0,0 +1,87 @@ +/* Terminplaner im Kundenbereich: Helfer ohne Zustand (Elemente bauen, Schnittstelle, Formularfelder, Hinweiskarten). + Alle Module der Ansicht (termine.js, termine-tab-*.js, termine-aktionen.js, termine-formulare.js) bauen die Seite nur hiermit: + Kundentext kommt ausschliesslich ueber textContent in die Seite (h), nie ueber innerHTML. */ + +/** Element bauen: Texte nur als Textknoten, Attribute mit on… als Ereignis, true setzt ein Attribut ohne Wert, false/null/undefined lassen es weg. */ +export 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 +} + +export 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 +} + +/** Adresse des CSV-Exports (die einzige Adresse, die die Ansicht als Link setzt). */ +export const exportUrl = (projectId) => `/api/termine/export.csv?projectId=${encodeURIComponent(projectId)}` + +let idZaehler = 0 +const neueId = () => `tm-f${++idZaehler}` + +/** Formularfeld mit Beschriftung, Hinweis und Platz fuer den Fehler (data-feld = Feldname der Schnittstelle). */ +export 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' })) +} + +/** Platz fuer Fehler, die zu keinem Feld gehoeren (Feldname „_“). */ +export const allgemeinerFehler = () => h('p', { class: 'tm-fehler', 'data-fehler': '_', role: 'alert' }) + +export 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). */ +export 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() +} + +export 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) + +export 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) diff --git a/public/termine-formulare.js b/public/termine-formulare.js new file mode 100644 index 0000000..5ab76e2 --- /dev/null +++ b/public/termine-formulare.js @@ -0,0 +1,170 @@ +/* Terminplaner im Kundenbereich: Formulare des Reiters "Termine" – freie Uhrzeiten waehlen, Termin von Hand eintragen, Zeit sperren. + Die Formulare kennen den Reiter nicht: sie bekommen ctx (siehe termine.js) und hooks = { startTag, erfolg(tag, text), abbrechen() }. + erfolg wird nach dem Speichern aufgerufen (der Reiter zeigt dann den Tag und laedt die Liste neu), abbrechen schliesst das Formular. */ + +import { MAX, heuteBerlin, ressourcenWort, zeitNormal } from './termine-view.js' +import { allgemeinerFehler, feld, h, setzeFehler, zeigeFehler } from './termine-dom.js' + +/** Freie Uhrzeiten als Auswahl-Knoepfe: laden() holt sie fuer query() (leistung/ressource/tag oder ohne/tag), wert() ist die gewaehlte. */ +export 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 } +} + +export function formularTermin(ctx, hooks) { + 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: hooks.startTag, min: heuteBerlin() }) + const name = h('input', { type: 'text', maxlength: String(MAX.name), autocomplete: 'off' }) + const email = h('input', { type: 'email', maxlength: '254', autocomplete: 'off' }) + const telefon = h('input', { type: 'tel', maxlength: String(MAX.telefon), autocomplete: 'off' }) + const nachricht = h('textarea', { rows: '3', maxlength: String(MAX.nachricht) }) + 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)), + allgemeinerFehler(), + h('div', { class: 'tm-frage-knoepfe' }, ok, h('button', { type: 'button', class: 'kb-btn-outline', onclick: hooks.abbrechen }, '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 hooks.erfolg(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 +} + +export function formularSperre(ctx, hooks) { + 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: hooks.startTag, 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(MAX.titel), 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.'), + allgemeinerFehler(), + h('div', { class: 'tm-frage-knoepfe' }, ok, h('button', { type: 'button', class: 'kb-btn-outline', onclick: hooks.abbrechen }, '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 hooks.erfolg(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 +} diff --git a/public/termine-tab-termine.js b/public/termine-tab-termine.js new file mode 100644 index 0000000..c37b9dc --- /dev/null +++ b/public/termine-tab-termine.js @@ -0,0 +1,228 @@ +/* Terminplaner im Kundenbereich: Reiter "Termine" – Liste nach Tagen (Tag, Woche, Monat, Offene Anfragen), Filter, Zeilen mit Aktionen, + "+ Termin eintragen" und "Zeit sperren". render(panel, ctx) ist der Einstieg (Registry REITER in termine.js). + ctx kommt aus termine.js: { s, projectId, daten, nurLesen, auswahl, aktuell(), api, meldung, zumReiter, neuLaden }. Die Aktionen mit + Rueckfrage stehen in termine-aktionen.js, die Formulare in termine-formulare.js. */ + +import { + FILTER, anfragenText, bereichFuer, datumKurz, endeZeit, filterStatus, gruppiereNachTag, heuteBerlin, istSperre, + moeglicheAktionen, statusLabel, statusTon, tagPlus, tagUeberschrift, verschiebeTag, zeitraumTitel, +} from './termine-view.js' +import { fehlerKarte, h } from './termine-dom.js' +import { AKTION_TEXT, schliesseFrage, starteAktion } from './termine-aktionen.js' +import { formularSperre, formularTermin } from './termine-formulare.js' + +const ABSENDER = { gast: 'Vom Gast abgesagt', betrieb: 'Von dir abgesagt', system: 'Automatisch abgelehnt (Termin vorbei)' } + +export function render(panel, ctx) { + const t = { ctx, auswahl: ctx.auswahl, panel, nr: 0, buchungen: [], offen: null, formular: null } + const { s } = ctx + t.nachAenderung = () => ladeListe(t, { auchZahl: true }) // Liste und Zahl der Anfragen nach einer Aenderung neu holen + + // 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(t.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(t.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) t.auswahl.ansicht = aenderung.ansicht + if (aenderung.filter) t.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 { ansicht, filter } = t.auswahl + const anfragen = ansicht === 'anfragen' + for (const k of t.ansichtBox.children) k.setAttribute('aria-pressed', String(!anfragen && k.dataset.ansicht === ansicht)) + for (const k of t.filterBox.children) k.setAttribute('aria-pressed', String(!anfragen && k.dataset.filter === 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(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 = t.auswahl.ansicht === 'anfragen' + const { von, bis } = bereichFuer(t.auswahl.ansicht, s.tag, heuteBerlin()) + const status = anfragen ? 'angefragt' : filterStatus(t.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 || !ctx.aktuell() || !t.liste.isConnected) return + t.buchungen = antwort.buchungen || [] + zeichneListe(t) + } catch (err) { + if (nr !== t.nr || !ctx.aktuell()) return + t.liste.replaceChildren(fehlerKarte(err.message, () => ladeListe(t))) + } finally { + if (nr === t.nr) t.liste.removeAttribute('aria-busy') + } +} + +function leerText(auswahl) { + 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 { ansicht } = t.auswahl + 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 (ansicht === 'tag' || ansicht === 'woche') { + const { von, bis } = bereichFuer(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 && ansicht !== 'woche') { + t.liste.replaceChildren(h('p', { class: 'tm-leer' }, leerText(t.auswahl))) + return + } + t.liste.replaceChildren(...tage.map((g) => h('section', { class: 'tm-tag', 'data-tag': g.tag }, + 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 +} + +/* ---------------------------------------- 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 hooks = { + // Startwert fuer Tag-Felder: der gewaehlte Tag, wenn er nicht in der Vergangenheit liegt, sonst heute + startTag: t.ctx.s.tag >= heuteBerlin() ? t.ctx.s.tag : heuteBerlin(), + erfolg: (tag, text) => nachErfolg(t, tag, text), + abbrechen: () => { schliesseFormular(t); t.werkzeuge.querySelector(`[data-werkzeug="${art}"]`).focus() }, + } + const form = art === 'manuell' ? formularTermin(t.ctx, hooks) : formularSperre(t.ctx, hooks) + 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') +} + +async function nachErfolg(t, tag, text) { + schliesseFormular(t) + t.ctx.s.tag = tag + if (t.auswahl.ansicht === 'anfragen') t.auswahl.ansicht = 'tag' + aktualisiereKopf(t) + await ladeListe(t, { auchZahl: true }) + t.ctx.meldung(text) +} diff --git a/public/termine-view.js b/public/termine-view.js index 575d4fc..a2bbf8a 100644 --- a/public/termine-view.js +++ b/public/termine-view.js @@ -10,6 +10,9 @@ const TAG_MS = 24 * 60 * 60 * 1000 const MAX_TAGE = 62 const GELOESCHT = 'Gelöscht' +/** Laengen der Eingaben, wie der Server sie pruefen wird (ablauf.js, ablaufBetrieb.js). */ +export const MAX = { name: 100, telefon: 40, nachricht: 500, titel: 120 } + /* ------------------------------------------------------------ Kalender --- */ function utc(tag) { const m = TAG_RE.exec(String(tag)) diff --git a/public/termine.js b/public/termine.js index c837fdc..e7c09ca 100644 --- a/public/termine.js +++ b/public/termine.js @@ -1,104 +1,20 @@ -/* 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. */ +/* 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 { - 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 +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) -/* ------------------------------------------------------------ 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 @@ -229,6 +145,8 @@ function zeigeReiter(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' @@ -248,7 +166,7 @@ function zeigeReiter(s) { 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'), + 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' }, @@ -291,542 +209,3 @@ function zeigeReiter(s) { 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 -} diff --git a/test/termineView.test.js b/test/termineView.test.js index e227fa7..8f5a427 100644 --- a/test/termineView.test.js +++ b/test/termineView.test.js @@ -1,6 +1,6 @@ import { test } from 'node:test' import assert from 'node:assert/strict' -import { readFileSync } from 'node:fs' +import { readFileSync, readdirSync } from 'node:fs' import { FILTER, ABSAGE_GRUENDE, anfragenText, bereichFuer, datumKurz, datumLang, endeZeit, fensterParsen, fensterText, filterStatus, gruppiereNachTag, fehltText, heuteBerlin, istSperre, monatVon, moeglicheAktionen, ressourcenWort, statusLabel, statusTon, tagPlus, tagUeberschrift, verschiebeTag, wocheVon, zeitNormal, zeitraumTitel, @@ -230,13 +230,31 @@ test('Portal: Ansicht #termine ist verdrahtet, funktionen.js behält seinen eine assert.doesNotMatch(fn, /import\s*\(/, 'auch kein dynamischer Import') }) -test('termine.js: Fremdtext nur als Text, keine Browser-Dialoge, Du-Form', () => { - const quelle = lies('public/termine.js').replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '') // ohne Kommentare - assert.doesNotMatch(quelle, /innerHTML|insertAdjacentHTML|outerHTML|document\.write/, 'Daten nie als HTML') - assert.doesNotMatch(quelle, /\bconfirm\(|\balert\(|\bprompt\(/, 'Rückfragen stehen in der Seite') - assert.doesNotMatch(quelle, /\bSie\b|\bIhr(e[nmrs]?)?\b/, 'Portaltexte in der Du-Form') - // Links nur auf die eigene Export-Schnittstelle, nie auf Kundentext - const hrefs = quelle.match(/href: `[^`]*`/g) || [] - assert.deepEqual(hrefs, ['href: `/api/termine/export.csv?projectId=${encodeURIComponent(projectId)}`']) - assert.doesNotMatch(quelle, /href: [^`]|setAttribute\('href'/, 'sonst kein href') +test('Ansicht-Module (public/termine*.js): Fremdtext nur als Text, keine Browser-Dialoge, Du-Form, ein einziger Link', () => { + const dateien = readdirSync(new URL('../public/', import.meta.url)).filter((f) => /^termine.*\.js$/.test(f)).sort() + assert.deepEqual(dateien, ['termine-aktionen.js', 'termine-dom.js', 'termine-formulare.js', 'termine-tab-termine.js', 'termine-view.js', 'termine.js']) + const ohneKommentare = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '') + const quellen = dateien.map((f) => [f, ohneKommentare(lies(`public/${f}`))]) + for (const [datei, quelle] of quellen) { + assert.doesNotMatch(quelle, /innerHTML|insertAdjacentHTML|outerHTML|document\.write/, `${datei}: Daten nie als HTML`) + assert.doesNotMatch(quelle, /\bconfirm\(|\balert\(|\bprompt\(/, `${datei}: Rückfragen stehen in der Seite`) + assert.doesNotMatch(quelle, /\bSie\b|\bIhr(e[nmrs]?)?\b/, `${datei}: Portaltexte in der Du-Form`) + assert.doesNotMatch(quelle, /localStorage|sessionStorage|document\.cookie/, `${datei}: kein Browserspeicher`) + } + // Links nur auf die eigene Export-Schnittstelle (exportUrl), nie auf Kundentext + const hrefs = quellen.flatMap(([datei, quelle]) => (quelle.match(/href: [^,}]+|setAttribute\('href'/g) || []).map((m) => `${datei}: ${m}`)) + assert.deepEqual(hrefs, ['termine.js: href: exportUrl(projectId)']) + const dom = quellen.find(([f]) => f === 'termine-dom.js')[1] + assert.ok(dom.includes('`/api/termine/export.csv?projectId=${encodeURIComponent(projectId)}`'), 'exportUrl codiert die Projekt-ID') +}) + +test('Ansicht-Module: Reiter sind Module mit render(panel, ctx), die Huelle kennt sie nur ueber REITER', () => { + const huelle = lies('public/termine.js') + assert.match(huelle, /import \{ render as renderTermine \} from '\.\/termine-tab-termine\.js'/) + assert.match(huelle, /const REITER = \[/) + assert.match(lies('public/termine-tab-termine.js'), /export function render\(panel, ctx\)/) + // Reiter lesen Zustand nur aus ctx, nicht aus der Huelle + for (const datei of ['termine-tab-termine.js', 'termine-aktionen.js', 'termine-formulare.js']) { + assert.doesNotMatch(lies(`public/${datei}`), /from '\.\/termine\.js'/, `${datei} importiert die Huelle nicht`) + } })