Files
Webklar-Kundenbereich/public/termine-tab-leistungen.js
T

205 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Terminplaner im Kundenbereich: Reiter "Leistungen" – Liste in der Reihenfolge, in der Gaeste sie sehen, Anlegen, Bearbeiten (Felder wie in
einstellungen.js: Name, Beschreibung, Dauer, Pause danach, Preis, Buchungsart, wer sie durchfuehrt, aktiv), Entfernen, Reihenfolge.
render(panel, ctx) ist der Einstieg (Registry REITER in termine.js). Jede Aktion ist ein Speichern der ganzen Einstellung
(termine-speichern.js); Pruefen und Begrenzen bleibt Sache des Servers, hier steht nur, was ein Feld ohne Antwort des Servers wissen kann.
Abgeschaltete Funktion (ctx.nurLesen): dieselben Felder, aber gesperrt und ohne Knoepfe zum Aendern. */
import { GESUNDHEIT_HINWEIS, LIMITS, MAX, centZuEuro, eindeutigeId, euroZuCent, ganzzahl, preisAnzeige, verschiebeEintrag } from './termine-view.js'
import { allgemeinerFehler, feld, gesperrt, h, hakenFeld, zeigeFehler } from './termine-dom.js'
import { entfernenFrage, neuerSpeicher } from './termine-speichern.js'
const BUCHUNGSART = [['sofort', 'Sofort buchbar'], ['anfrage', 'Nur als Anfrage (du nimmst jeden Termin an)']]
const PREISART = [['keiner', 'Ohne Preis'], ['fest', 'Festpreis'], ['ab', 'Preis ab']]
const option = (wert, text, gewaehlt) => h('option', { value: wert, selected: gewaehlt }, text)
export function render(panel, ctx) {
const wurzel = h('div', { class: 'tm-tab' })
panel.replaceChildren(wurzel)
const sp = neuerSpeicher(ctx, wurzel, zeichne)
const t = { ctx, sp, zeichne, wurzel }
function zeichne() {
const e = ctx.daten.einstellung
const leistungen = e.leistungen || []
const kopfFehler = h('div', null, allgemeinerFehler())
const neuBox = h('div', { class: 'tm-formular-box' })
const voll = leistungen.length >= LIMITS.leistungen
const neuKnopf = ctx.nurLesen ? null : h('button', {
type: 'button', class: 'kb-btn-primary', 'data-werkzeug': 'leistung-neu', 'aria-expanded': 'false', disabled: voll,
onclick: (ev) => {
if (neuBox.firstChild) { neuBox.replaceChildren(); ev.currentTarget.setAttribute('aria-expanded', 'false'); return }
ev.currentTarget.setAttribute('aria-expanded', 'true')
const form = formular(t, { index: leistungen.length, leistung: null, schliessen: () => { neuBox.replaceChildren(); neuKnopf.setAttribute('aria-expanded', 'false'); neuKnopf.focus() } })
neuBox.replaceChildren(form)
form.querySelector('input')?.focus()
},
}, '+ Leistung anlegen')
wurzel.replaceChildren(
sp.konflikt,
h('div', { class: 'tm-box tm-box-info', role: 'note' },
h('span', null, h('strong', null, GESUNDHEIT_HINWEIS), ' Namen und Beschreibungen deiner Leistungen liest jeder, der auf deiner Website bucht. Schreib dort zum Beispiel keine Krankheiten oder Beschwerden hinein.')),
ctx.nurLesen ? null : h('div', { class: 'tm-werkzeug-zeile' }, neuKnopf, voll ? h('p', { class: 'tm-hinweis' }, `Mehr als ${LIMITS.leistungen} Leistungen sind nicht möglich.`) : null),
neuBox,
kopfFehler,
leistungen.length
? h('ul', { class: 'tm-eintraege', 'aria-label': 'Leistungen in der Reihenfolge, in der Gäste sie sehen' }, leistungen.map((l, i) => zeile(t, l, i, leistungen.length, kopfFehler)))
: h('p', { class: 'tm-leer' }, 'Noch keine Leistungen. Ohne Leistung kann niemand buchen.'))
}
zeichne()
}
/** Zeile einer Leistung: Kurzfassung, Aktiv-Pille, Reihenfolge (hoch/runter) und der Knopf, der das Formular unter der Zeile oeffnet. */
function zeile(t, l, index, anzahl, kopfFehler) {
const { ctx, sp } = t
const e = ctx.daten.einstellung
const namen = (l.ressourcen || []).map((id) => (e.ressourcen || []).find((r) => r.id === id)?.name).filter(Boolean)
const li = h('li', { class: 'tm-eintrag', 'data-id': l.id })
const editor = h('div', { class: 'tm-editor-box' })
const meta = [`${l.dauer} Min.`, l.pufferNach ? `${l.pufferNach} Min. Pause danach` : '', preisAnzeige(l), l.buchungsart === 'anfrage' ? 'Nur als Anfrage' : 'Sofort buchbar', namen.join(', ') || 'niemand zugeordnet']
const oeffnen = h('button', {
type: 'button', class: 'kb-btn-outline tm-knopf', 'data-aktion': 'bearbeiten', 'aria-expanded': 'false', 'aria-label': `${ctx.nurLesen ? 'Ansehen' : 'Bearbeiten'}: ${l.name}`,
onclick: () => {
if (editor.firstChild) { editor.replaceChildren(); oeffnen.setAttribute('aria-expanded', 'false'); return }
oeffnen.setAttribute('aria-expanded', 'true')
const form = formular(t, { index, leistung: l, schliessen: () => { editor.replaceChildren(); oeffnen.setAttribute('aria-expanded', 'false'); oeffnen.focus() } })
editor.replaceChildren(form)
form.querySelector('input')?.focus()
},
}, ctx.nurLesen ? 'Ansehen' : 'Bearbeiten')
async function verschiebe(richtung, knopf) {
const ok = await sp.speichere((entwurf) => { entwurf.leistungen = verschiebeEintrag(entwurf.leistungen, index, richtung) }, { container: kopfFehler, knopf })
if (!ok || !sp.nochHier()) return
t.zeichne()
ctx.meldung(`„${l.name}“ steht jetzt ${richtung < 0 ? 'weiter oben' : 'weiter unten'}.`)
const neu = t.wurzel.querySelector(`li[data-id="${l.id}"]`)
;(neu?.querySelector(`[data-aktion="${richtung < 0 ? 'hoch' : 'runter'}"]:not([disabled])`) || neu?.querySelector('[data-aktion="bearbeiten"]'))?.focus()
}
const pfeil = (aktion, richtung, zeichen, text, aus) => h('button', {
type: 'button', class: 'tm-pfeil', 'data-aktion': aktion, 'aria-label': `${text}: ${l.name}`, disabled: aus,
onclick: (ev) => verschiebe(richtung, ev.currentTarget),
}, h('span', { 'aria-hidden': 'true' }, zeichen))
li.append(
h('div', { class: 'tm-kopf' },
h('div', { class: 'tm-haupt' },
h('strong', { class: 'tm-name' }, l.name),
h('span', { class: 'tm-meta' }, meta.filter(Boolean).join(' · ')),
l.beschreibung ? h('span', { class: 'tm-meta' }, l.beschreibung) : null),
h('span', { class: `tm-pille tm-pille-${l.aktiv ? 'ok' : 'off'}` }, l.aktiv ? 'Aktiv' : 'Inaktiv')),
h('div', { class: 'tm-aktionen' },
ctx.nurLesen ? null : [pfeil('hoch', -1, '↑', 'Nach oben', index === 0), pfeil('runter', 1, '↓', 'Nach unten', index === anzahl - 1)],
oeffnen),
editor)
return li
}
/** Formular einer neuen (leistung null) oder bestehenden Leistung; index = ihre Stelle in der Liste (so heissen die Pfade der Fehler des Servers). */
function formular(t, { index, leistung, schliessen }) {
const { ctx, sp, zeichne } = t
const e = ctx.daten.einstellung
const neu = leistung === null
const l = leistung || { id: eindeutigeId('s', (e.leistungen || []).map((x) => x.id)), name: '', beschreibung: '', dauer: 30, pufferNach: 0, preisArt: 'keiner', preisCent: 0, buchungsart: 'sofort', ressourcen: [], aktiv: true }
const p = `leistungen.${index}`
const name = h('input', { type: 'text', maxlength: MAX.leistungName, autocomplete: 'off', value: l.name })
const beschreibung = h('textarea', { rows: '2', maxlength: MAX.beschreibung }, l.beschreibung)
const dauer = h('input', { type: 'number', inputmode: 'numeric', min: '5', max: '480', step: '5', value: l.dauer })
const puffer = h('input', { type: 'number', inputmode: 'numeric', min: '0', max: '120', step: '5', value: l.pufferNach ?? 0 })
const preisArt = h('select', null, PREISART.map(([wert, text]) => option(wert, text, l.preisArt === wert)))
const preis = h('input', { type: 'text', inputmode: 'decimal', autocomplete: 'off', value: l.preisArt === 'keiner' ? '' : centZuEuro(l.preisCent) })
const preisSteuern = () => { preis.disabled = preisArt.value === 'keiner'; if (preis.disabled) preis.value = '' }
preisArt.addEventListener('change', preisSteuern)
preisSteuern()
const art = h('select', null, BUCHUNGSART.map(([wert, text]) => option(wert, text, l.buchungsart === wert)))
const aktiv = h('input', { type: 'checkbox', checked: l.aktiv })
const boxen = (e.ressourcen || []).map((r) => ({ r, input: h('input', { type: 'checkbox', value: r.id, checked: (l.ressourcen || []).includes(r.id) }) }))
const wer = h('fieldset', { class: 'tm-feld tm-gruppe-felder', 'data-feld': `${p}.ressourcen`, tabindex: '-1' },
h('legend', { class: 'tm-label' }, 'Wer führt die Leistung durch?'),
boxen.length
? boxen.map(({ r, input }) => h('label', { class: 'tm-haken' }, input, h('span', null, `${r.name} · ${r.typ === 'platz' ? 'Platz' : 'Person'}${r.aktiv ? '' : ' (inaktiv)'}`)))
: h('p', { class: 'tm-hinweis' }, 'Lege zuerst im Reiter „Team & Plätze“ eine Person oder einen Platz an.'),
h('p', { class: 'tm-fehler', 'data-fehler': `${p}.ressourcen`, role: 'alert' }))
const felder = [
feld(`${p}.name`, 'Name', name, 'So steht die Leistung beim Buchen.'),
feld(`${p}.beschreibung`, 'Beschreibung (optional)', beschreibung, `Ein bis zwei Sätze für deine Gäste. ${GESUNDHEIT_HINWEIS}`),
h('div', { class: 'tm-raster' },
feld(`${p}.dauer`, 'Dauer (Minuten)', dauer, '5 bis 480, in 5-Minuten-Schritten.'),
feld(`${p}.pufferNach`, 'Pause danach (Minuten)', puffer, 'Zeit danach, in der niemand gebucht werden kann. Gäste sehen sie nicht. Dauer und Pause zusammen höchstens 500 Minuten.')),
h('div', { class: 'tm-raster' },
feld(`${p}.preisArt`, 'Preis', preisArt),
feld(`${p}.preisCent`, 'Betrag in Euro', preis, 'Bei „Preis ab“ ist es der Mindestpreis.')),
feld(`${p}.buchungsart`, 'Buchung', art),
wer,
hakenFeld(`${p}.aktiv`, aktiv, 'Aktiv', 'Nur aktive Leistungen können Gäste buchen. Eine inaktive Leistung bleibt gespeichert.'),
]
const speichern = h('button', { type: 'submit', class: 'kb-btn-primary' }, neu ? 'Leistung anlegen' : 'Speichern')
const entfernenBox = h('div', { class: 'tm-frage' }) // die Rueckfrage steht neben dem Formular, nicht darin (ein submit steigt sonst ins aeussere Formular auf)
const entfernen = neu || ctx.nurLesen ? null : h('button', { type: 'button', class: 'kb-btn-outline tm-gefahr', onclick: () => frageEntfernen() }, 'Entfernen')
const form = h('form', { class: 'kb-panel tm-formular', novalidate: true, 'aria-label': neu ? 'Leistung anlegen' : `${ctx.nurLesen ? 'Leistung' : 'Leistung bearbeiten'}: ${l.name}` },
h('h3', null, neu ? 'Neue Leistung' : l.name),
gesperrt(ctx.nurLesen, felder),
allgemeinerFehler(),
h('div', { class: 'tm-frage-knoepfe' },
ctx.nurLesen ? null : speichern,
h('button', { type: 'button', class: 'kb-btn-outline', onclick: schliessen }, ctx.nurLesen ? 'Schließen' : 'Abbrechen'),
entfernen))
/** Leistung aus den Feldern; Eingaben, die kein Feld verstehen kann, werden hier am Feld gemeldet. */
function lese() {
const fehler = {}
const minuten = ganzzahl(dauer.value)
if (minuten === null) fehler[`${p}.dauer`] = 'Bitte gib die Dauer in ganzen Minuten an.'
const pause = puffer.value.trim() === '' ? 0 : ganzzahl(puffer.value)
if (pause === null) fehler[`${p}.pufferNach`] = 'Bitte gib die Pause in ganzen Minuten an (0 = keine Pause).'
let cent = 0
if (preisArt.value !== 'keiner') {
cent = euroZuCent(preis.value)
if (cent === null) fehler[`${p}.preisCent`] = 'Bitte gib den Preis in Euro an, zum Beispiel 45,00.'
}
if (Object.keys(fehler).length) return { fehler }
return {
leistung: {
...l, name: name.value, beschreibung: beschreibung.value, dauer: minuten, pufferNach: pause, preisArt: preisArt.value, preisCent: cent,
buchungsart: art.value, ressourcen: boxen.filter(({ input }) => input.checked).map(({ r }) => r.id), aktiv: aktiv.checked,
},
}
}
form.addEventListener('submit', async (ev) => {
ev.preventDefault()
const ok = await sp.speichere((entwurf) => {
const gelesen = lese()
if (gelesen.fehler) {
zeigeFehler(form, { fehler: gelesen.fehler })
return false
}
if (neu) entwurf.leistungen.push(gelesen.leistung)
else entwurf.leistungen[index] = gelesen.leistung
return true
}, { container: form, knopf: speichern })
if (!ok || !sp.nochHier()) return
zeichne()
ctx.meldung(neu ? 'Leistung angelegt.' : 'Leistung gespeichert.')
t.wurzel.querySelector(`li[data-id="${l.id}"] [data-aktion="bearbeiten"]`)?.focus()
})
function frageEntfernen() {
const box = entfernenFrage(sp, {
titel: `Leistung „${l.name}“ entfernen?`,
text: 'Das kann nicht rückgängig gemacht werden. Gibt es für die Leistung noch künftige Termine, geht das Entfernen nicht. Dann deaktiviere sie stattdessen.',
fehlerPfad: `leistungen.${l.id}`,
aendere: (entwurf) => { entwurf.leistungen = entwurf.leistungen.filter((x) => x.id !== l.id) },
nachher: () => { zeichne(); ctx.meldung(`Leistung „${l.name}“ entfernt.`) },
zurueck: () => { entfernenBox.replaceChildren(); entfernen.focus() },
})
entfernenBox.replaceChildren(box)
box.focus()
}
return h('div', { class: 'tm-editor' }, form, entfernenBox)
}