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>
229 lines
12 KiB
JavaScript
229 lines
12 KiB
JavaScript
/* 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)
|
||
}
|