205 lines
13 KiB
JavaScript
205 lines
13 KiB
JavaScript
/* 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)
|
||
}
|