245 lines
12 KiB
JavaScript
245 lines
12 KiB
JavaScript
/* 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-tab-termine.js, termine-tab-leistungen.js,
|
|
termine-tab-team.js, termine-tab-einstellungen.js; die drei letzten speichern ueber termine-speichern.js).
|
|
ctx = { s, projectId, daten, nurLesen, auswahl, aktuell(), api, meldung, hinweise, 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
|
|
(die Reiter, die speichern, halten daten.einstellung und daten.version aktuell), nurLesen gilt fuer abgeschaltete Funktionen,
|
|
hinweise() zeichnet die Hinweisleiste ueber den Reitern neu (z. B. wenn Telefon und Anschrift nach dem Speichern nicht mehr fehlen). 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 { exportLink, fehlerKarte, h, infoKarte, request } from './termine-dom.js'
|
|
import { render as renderTermine } from './termine-tab-termine.js'
|
|
import { render as renderLeistungen } from './termine-tab-leistungen.js'
|
|
import { render as renderTeam } from './termine-tab-team.js'
|
|
import { render as renderEinstellungen } from './termine-tab-einstellungen.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, lauf: 0, 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 = (projectId) => request(`/api/termine/einstellungen?projectId=${encodeURIComponent(projectId)}`)
|
|
|
|
/** Hinweis fuer abgeschaltete Funktionen: nur noch ansehen und exportieren. */
|
|
const hinweisAbgeschaltet = (projectId) => 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 ',
|
|
exportLink(projectId, 'exportieren'),
|
|
'.')
|
|
|
|
/**
|
|
* Je nach Zustand der Funktion: nicht freigeschaltet, Vorlage waehlen oder die Reiter. Jeder Aufruf zaehlt s.lauf hoch: die Antwort
|
|
* einer frueheren Website (langsam, dann zur anderen gewechselt) wird verworfen und erscheint nie unter der neuen.
|
|
*/
|
|
async function zeigeProjekt(s) {
|
|
const projekt = s.projekt
|
|
const lauf = ++s.lauf
|
|
const aktuell = () => S === s && s.lauf === lauf
|
|
const zustand = 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…'))
|
|
let daten
|
|
try {
|
|
daten = await ladeDaten(projekt.projectId)
|
|
} catch (err) {
|
|
if (aktuell()) s.buehne.replaceChildren(fehlerKarte(err.message, () => zeigeProjekt(s)))
|
|
return
|
|
}
|
|
if (!aktuell()) return
|
|
s.daten = daten
|
|
if (daten.einstellung) zeigeReiter(s, { projekt, aktuell })
|
|
else if (s.nurLesen) {
|
|
s.buehne.replaceChildren(
|
|
hinweisAbgeschaltet(projekt.projectId),
|
|
infoKarte('Keine Termine eingerichtet', 'Für diese Website wurden keine Einstellungen und Termine angelegt.', { chat: true }))
|
|
} else zeigeVorlagen(s, { projekt, aktuell })
|
|
}
|
|
|
|
/* ---------------------------------------------------- Vorlage wählen --- */
|
|
function zeigeVorlagen(s, { projekt, aktuell }) {
|
|
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: projekt.projectId, vorlage: gewaehlt.key } })
|
|
const daten = await ladeDaten(projekt.projectId)
|
|
if (!aktuell()) return
|
|
s.daten = daten
|
|
zeigeReiter(s, { projekt, aktuell })
|
|
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 --- */
|
|
/** key, Beschriftung, Funktion (panel, ctx). */
|
|
const REITER = [
|
|
{ key: 'termine', label: 'Termine', render: renderTermine },
|
|
{ key: 'leistungen', label: 'Leistungen', render: renderLeistungen },
|
|
{ key: 'team', label: 'Team & Plätze', render: renderTeam },
|
|
{ key: 'einstellungen', label: 'Einstellungen', render: renderEinstellungen },
|
|
]
|
|
|
|
/**
|
|
* Meldung ueber der Reiterleiste: art 'ok' (Standard) oder 'fehler'; leerer Text blendet sie aus. Die Meldung ist die einzige Live-Region
|
|
* der Ansicht. Sie erscheint dort, wo man hinschaut: liegt sie ausserhalb des Bildes, wird sie hingescrollt (block: 'nearest'), eine
|
|
* Fehlermeldung bekommt zusaetzlich den Fokus.
|
|
*/
|
|
function zeigeMeldung(s, text, art = 'ok') {
|
|
const el = s.meldungEl
|
|
if (!el) return
|
|
el.textContent = text || ''
|
|
el.className = `tm-meldung tm-meldung-${art}`
|
|
el.setAttribute('role', art === 'fehler' ? 'alert' : 'status')
|
|
el.setAttribute('aria-live', art === 'fehler' ? 'assertive' : 'polite')
|
|
if (!text) return
|
|
if (art === 'fehler') el.focus({ preventScroll: true })
|
|
el.scrollIntoView({ block: 'nearest' })
|
|
}
|
|
|
|
function zeigeReiter(s, { projekt, aktuell }) {
|
|
const projectId = projekt.projectId
|
|
const nurLesen = s.nurLesen
|
|
s.meldungEl = h('div', { class: 'tm-meldung', role: 'status', 'aria-live': 'polite', tabindex: '-1' })
|
|
const ctx = {
|
|
s,
|
|
projectId,
|
|
daten: s.daten,
|
|
nurLesen,
|
|
auswahl,
|
|
aktuell,
|
|
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 } })),
|
|
},
|
|
// eine Meldung, die erst nach dem Wechsel der Website oder der Ansicht eintrifft, gehoert nicht mehr auf diese Seite
|
|
meldung: (text, art) => { if (aktuell()) zeigeMeldung(s, text, art) },
|
|
hinweise: () => { if (aktuell()) zeichneHinweise() },
|
|
zumReiter: (key) => waehleReiter(key),
|
|
neuLaden: async () => {
|
|
const daten = await ladeDaten(projectId)
|
|
if (aktuell()) {
|
|
s.daten = daten
|
|
ctx.daten = daten
|
|
zeichneHinweise()
|
|
}
|
|
return daten
|
|
},
|
|
}
|
|
|
|
// Hinweisleiste: abgeschaltet, oder Pflichtangaben fuer Gaeste fehlen (GET /einstellungen: fehlt). Sie folgt s.daten.
|
|
const hinweisBox = h('div', { class: 'tm-hinweise' })
|
|
function zeichneHinweise() {
|
|
const hinweise = []
|
|
if (nurLesen) {
|
|
hinweise.push(hinweisAbgeschaltet(projectId))
|
|
} 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')))
|
|
}
|
|
hinweisBox.replaceChildren(...hinweise)
|
|
}
|
|
zeichneHinweise()
|
|
|
|
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 (aktuell()) panel.replaceChildren(fehlerKarte(err.message, () => waehleReiter(reiter.key))) })
|
|
}
|
|
|
|
s.buehne.replaceChildren(hinweisBox, s.meldungEl, leiste, panel)
|
|
waehleReiter(auswahl.reiter)
|
|
}
|
|
|