Terminplaner: Portal-Ansicht – Nachbesserungen (1/2: Module getrennt)
public/termine.js in Huelle und Module zerlegt, Verhalten unveraendert (R48): termine-dom.js (Helfer ohne Zustand), termine-tab-termine.js (Liste, Kopf, Zeilen), termine-aktionen.js (Aktionen mit Rueckfrage), termine-formulare.js (Uhrzeiten, Termin eintragen, Zeit sperren). Reiter bekommen Zustand und aktuell() nur ueber ctx; die Huelle haengt sie ueber die Registry REITER ein. Statiktest prueft alle public/termine*.js. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
43e4826ae3
commit
9c07f6a8c5
7 files changed
+697
-645
No files matched your search
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -10,6 +10,9 @@ const TAG_MS = 24 * 60 * 60 * 1000
|
|||||||
const MAX_TAGE = 62
|
const MAX_TAGE = 62
|
||||||
const GELOESCHT = 'Gelöscht'
|
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 --- */
|
/* ------------------------------------------------------------ Kalender --- */
|
||||||
function utc(tag) {
|
function utc(tag) {
|
||||||
const m = TAG_RE.exec(String(tag))
|
const m = TAG_RE.exec(String(tag))
|
||||||
|
|||||||
+14
-635
@@ -1,104 +1,20 @@
|
|||||||
/* Terminplaner im Kundenbereich (ES-Modul; app.js laedt es erst beim Oeffnen der Ansicht #termine).
|
/* 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 der Reiter "Termine".
|
initTermine(rootEl, { projectId }): Projektauswahl, Vorlagenauswahl beim ersten Oeffnen, Reiter-Geruest und die Meldung ueber den Reitern.
|
||||||
Die Reiter stehen in REITER: jeder ist eine Funktion (panel, ctx) => void | Promise. Leistungen, Team & Plaetze und Einstellungen
|
Die Reiter stehen in REITER: jeder ist ein Modul mit render(panel, ctx) => void | Promise (Termine: termine-tab-termine.js; Leistungen,
|
||||||
(Aufgabe 21b) ersetzen dort nur ihren Platzhalter. ctx = { s, projectId, daten, nurLesen, api, meldung, zumReiter, neuLaden }:
|
Team & Plaetze und Einstellungen (Aufgabe 21b) ersetzen dort nur ihren Platzhalter).
|
||||||
daten ist die Antwort von GET /api/termine/einstellungen, nurLesen gilt fuer abgeschaltete Funktionen.
|
ctx = { s, projectId, daten, nurLesen, auswahl, aktuell(), api, meldung, zumReiter, neuLaden }: Zustand und Pruefung "bin ich noch
|
||||||
Kundentext kommt nur ueber textContent in die Seite (Hilfsfunktion h), nie ueber innerHTML. Server: server/routes/termine.js.
|
aktuell?" bekommen die Reiter nur ueber ctx, nie ueber Modulvariablen. daten ist die Antwort von GET /api/termine/einstellungen,
|
||||||
Tage sind Berliner Kalendertage 'JJJJ-MM-TT'; gerechnet wird in termine-view.js. Styles: termine.css. */
|
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 {
|
import { fehltText, heuteBerlin } from './termine-view.js'
|
||||||
ABSAGE_GRUENDE, FILTER, anfragenText, bereichFuer, datumKurz, endeZeit, fehltText, filterStatus, gruppiereNachTag, heuteBerlin, istSperre,
|
import { exportUrl, fehlerKarte, h, infoKarte, request } from './termine-dom.js'
|
||||||
moeglicheAktionen, ressourcenWort, statusLabel, statusTon, tagPlus, tagUeberschrift, verschiebeTag, zeitNormal, zeitraumTitel,
|
import { render as renderTermine } from './termine-tab-termine.js'
|
||||||
} from './termine-view.js'
|
|
||||||
|
|
||||||
const NAME_MAX = 100
|
|
||||||
const TELEFON_MAX = 40
|
|
||||||
const NACHRICHT_MAX = 500
|
|
||||||
const TITEL_MAX = 120
|
|
||||||
|
|
||||||
// Auswahl bleibt beim Wechsel zwischen Funktionen und Terminplaner erhalten (der Tag nicht: er beginnt immer bei heute)
|
// 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' }
|
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)
|
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 --- */
|
/* --------------------------------------------------------- Einstieg --- */
|
||||||
export async function initTermine(root, { projectId } = {}) {
|
export async function initTermine(root, { projectId } = {}) {
|
||||||
if (!root) return
|
if (!root) return
|
||||||
@@ -229,6 +145,8 @@ function zeigeReiter(s) {
|
|||||||
projectId,
|
projectId,
|
||||||
daten: s.daten,
|
daten: s.daten,
|
||||||
nurLesen,
|
nurLesen,
|
||||||
|
auswahl,
|
||||||
|
aktuell: () => S === s,
|
||||||
api: {
|
api: {
|
||||||
get: (pfad, query = {}) => request(`/api/termine${pfad}?${new URLSearchParams({ projectId, ...query })}`),
|
get: (pfad, query = {}) => request(`/api/termine${pfad}?${new URLSearchParams({ projectId, ...query })}`),
|
||||||
send: (method, pfad, body = {}) => (method === 'DELETE'
|
send: (method, pfad, body = {}) => (method === 'DELETE'
|
||||||
@@ -248,7 +166,7 @@ function zeigeReiter(s) {
|
|||||||
if (nurLesen) {
|
if (nurLesen) {
|
||||||
hinweise.push(h('div', { class: 'tm-box tm-box-warn', role: 'note' },
|
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 ',
|
'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) {
|
} else if ((s.daten.fehlt || []).length) {
|
||||||
hinweise.push(h('div', { class: 'tm-box tm-box-warn', role: 'note' },
|
hinweise.push(h('div', { class: 'tm-box tm-box-warn', role: 'note' },
|
||||||
@@ -291,542 +209,3 @@ function zeigeReiter(s) {
|
|||||||
waehleReiter(auswahl.reiter)
|
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
|
|
||||||
}
|
|
||||||
+28
-10
@@ -1,6 +1,6 @@
|
|||||||
import { test } from 'node:test'
|
import { test } from 'node:test'
|
||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import { readFileSync } from 'node:fs'
|
import { readFileSync, readdirSync } from 'node:fs'
|
||||||
import {
|
import {
|
||||||
FILTER, ABSAGE_GRUENDE, anfragenText, bereichFuer, datumKurz, datumLang, endeZeit, fensterParsen, fensterText, filterStatus, gruppiereNachTag,
|
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,
|
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')
|
assert.doesNotMatch(fn, /import\s*\(/, 'auch kein dynamischer Import')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('termine.js: Fremdtext nur als Text, keine Browser-Dialoge, Du-Form', () => {
|
test('Ansicht-Module (public/termine*.js): Fremdtext nur als Text, keine Browser-Dialoge, Du-Form, ein einziger Link', () => {
|
||||||
const quelle = lies('public/termine.js').replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '') // ohne Kommentare
|
const dateien = readdirSync(new URL('../public/', import.meta.url)).filter((f) => /^termine.*\.js$/.test(f)).sort()
|
||||||
assert.doesNotMatch(quelle, /innerHTML|insertAdjacentHTML|outerHTML|document\.write/, 'Daten nie als HTML')
|
assert.deepEqual(dateien, ['termine-aktionen.js', 'termine-dom.js', 'termine-formulare.js', 'termine-tab-termine.js', 'termine-view.js', 'termine.js'])
|
||||||
assert.doesNotMatch(quelle, /\bconfirm\(|\balert\(|\bprompt\(/, 'Rückfragen stehen in der Seite')
|
const ohneKommentare = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '')
|
||||||
assert.doesNotMatch(quelle, /\bSie\b|\bIhr(e[nmrs]?)?\b/, 'Portaltexte in der Du-Form')
|
const quellen = dateien.map((f) => [f, ohneKommentare(lies(`public/${f}`))])
|
||||||
// Links nur auf die eigene Export-Schnittstelle, nie auf Kundentext
|
for (const [datei, quelle] of quellen) {
|
||||||
const hrefs = quelle.match(/href: `[^`]*`/g) || []
|
assert.doesNotMatch(quelle, /innerHTML|insertAdjacentHTML|outerHTML|document\.write/, `${datei}: Daten nie als HTML`)
|
||||||
assert.deepEqual(hrefs, ['href: `/api/termine/export.csv?projectId=${encodeURIComponent(projectId)}`'])
|
assert.doesNotMatch(quelle, /\bconfirm\(|\balert\(|\bprompt\(/, `${datei}: Rückfragen stehen in der Seite`)
|
||||||
assert.doesNotMatch(quelle, /href: [^`]|setAttribute\('href'/, 'sonst kein href')
|
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`)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
Reference in new issue
Block a user