239 lines
14 KiB
JavaScript
239 lines
14 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, ordneNachIds, preisAnzeige, verschiebeEintrag,
|
||
} from './termine-view.js'
|
||
import { allgemeinerFehler, editorZeile, feld, gesperrt, h, hakenFeld, neuerEintrag, zeigeFehler } from './termine-dom.js'
|
||
import { entfernenKnopf, nachSpeichern, neuerSpeicher, stelleVon } 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 }
|
||
// Reihenfolge (Ruling R51): die Pfeile aendern nur diese gemerkte Reihenfolge (IDs), gespeichert wird sie mit „Reihenfolge speichern“ in einem
|
||
// Speichern; null = wie gespeichert. Sie bleibt beim Neuzeichnen (z. B. nach dem Speichern einer Leistung) erhalten.
|
||
let ordnung = null
|
||
|
||
/** Anzeigereihenfolge (IDs): die gemerkte, auf den aktuellen Stand der Liste angewandt; ist sie die gespeicherte, gilt keine gemerkte mehr. */
|
||
function anzeigeIds() {
|
||
const liste = ctx.daten.einstellung.leistungen || []
|
||
const gespeichert = liste.map((l) => l.id)
|
||
const ids = ordnung ? ordneNachIds(liste, ordnung).map((l) => l.id) : gespeichert
|
||
ordnung = ids.join('|') === gespeichert.join('|') ? null : ids
|
||
return ids
|
||
}
|
||
|
||
function zeichne() {
|
||
const e = ctx.daten.einstellung
|
||
const leistungen = e.leistungen || []
|
||
const kopfFehler = h('div', null, allgemeinerFehler())
|
||
const neu = neuerEintrag({
|
||
nurLesen: ctx.nurLesen, text: '+ Leistung anlegen', werkzeug: 'leistung-neu', primaer: true,
|
||
voll: leistungen.length >= LIMITS.leistungen, grenzText: `Mehr als ${LIMITS.leistungen} Leistungen sind nicht möglich.`,
|
||
baue: (schliessen) => formular(t, { leistung: null, schliessen }),
|
||
})
|
||
const zeilen = new Map(leistungen.map((l) => [l.id, zeile(t, l, verschiebe)]))
|
||
const liste = h('ul', { class: 'tm-eintraege', 'aria-label': 'Leistungen in der Reihenfolge, in der Gäste sie sehen' })
|
||
const status = h('span', { role: 'status' })
|
||
const speichern = h('button', { type: 'button', class: 'kb-btn-primary', 'data-aktion': 'reihenfolge-speichern' }, 'Reihenfolge speichern')
|
||
const verwerfen = h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'reihenfolge-verwerfen' }, 'Verwerfen')
|
||
const leiste = h('div', { class: 'tm-box tm-box-info tm-reihenfolge', hidden: true }, status, h('div', { class: 'tm-frage-knoepfe' }, speichern, verwerfen))
|
||
|
||
/** Zeilen in Anzeigereihenfolge bringen (vorhandene Zeilen werden nur verschoben: offene Editoren und ihre Eingaben bleiben), Pfeile und Leiste nachziehen. */
|
||
function aktualisiere() {
|
||
const ids = anzeigeIds()
|
||
liste.replaceChildren(...ids.map((id) => zeilen.get(id)))
|
||
ids.forEach((id, i) => {
|
||
const li = zeilen.get(id)
|
||
const hoch = li.querySelector('[data-aktion="hoch"]')
|
||
if (hoch) hoch.disabled = i === 0
|
||
const runter = li.querySelector('[data-aktion="runter"]')
|
||
if (runter) runter.disabled = i === ids.length - 1
|
||
})
|
||
leiste.hidden = !ordnung
|
||
}
|
||
function verschiebe(id, richtung, aktion) {
|
||
const ids = anzeigeIds()
|
||
ordnung = verschiebeEintrag(ids, ids.indexOf(id), richtung)
|
||
aktualisiere()
|
||
const stelle = anzeigeIds().indexOf(id) + 1
|
||
status.textContent = ordnung
|
||
? `„${leistungen.find((l) => l.id === id).name}“ steht jetzt an Stelle ${stelle} von ${ids.length}. Die neue Reihenfolge ist noch nicht gespeichert.`
|
||
: 'Die Reihenfolge ist wieder wie gespeichert.'
|
||
const li = zeilen.get(id)
|
||
;(li.querySelector(`[data-aktion="${aktion}"]:not(:disabled)`) || li.querySelector('[data-aktion="bearbeiten"]'))?.focus()
|
||
}
|
||
const fokusErsteZeile = () => liste.querySelector('li [data-aktion="bearbeiten"]')?.focus()
|
||
verwerfen.addEventListener('click', () => {
|
||
ordnung = null
|
||
aktualisiere()
|
||
fokusErsteZeile()
|
||
ctx.meldung('Die gemerkte Reihenfolge ist verworfen.')
|
||
})
|
||
speichern.addEventListener('click', async () => {
|
||
const gewuenscht = [...(ordnung || [])]
|
||
// die gemerkte Reihenfolge wirkt auf eine frische Kopie des gespeicherten Stands, nach IDs (nicht nach Stellen)
|
||
const ok = await sp.speichere((entwurf) => { entwurf.leistungen = ordneNachIds(entwurf.leistungen, gewuenscht) }, { container: kopfFehler })
|
||
if (!ok || !sp.nochHier()) return
|
||
ordnung = null
|
||
zeichne()
|
||
ctx.meldung('Reihenfolge gespeichert.')
|
||
fokusErsteZeile()
|
||
})
|
||
|
||
aktualisiere()
|
||
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.')),
|
||
neu.leiste,
|
||
neu.box,
|
||
leiste,
|
||
kopfFehler,
|
||
leistungen.length ? liste : h('p', { class: 'tm-leer' }, 'Noch keine Leistungen. Ohne Leistung kann niemand buchen.'))
|
||
}
|
||
zeichne()
|
||
}
|
||
|
||
/** Zeile einer Leistung: Kurzfassung, Aktiv-Pille, Pfeile fuer die Reihenfolge und der Knopf, der das Formular unter der Zeile oeffnet. */
|
||
function zeile(t, l, verschiebe) {
|
||
const { ctx } = t
|
||
const e = ctx.daten.einstellung
|
||
const namen = (l.ressourcen || []).map((id) => (e.ressourcen || []).find((r) => r.id === id)?.name).filter(Boolean)
|
||
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 pfeil = (aktion, richtung, zeichen, text) => h('button', {
|
||
type: 'button', class: 'tm-pfeil', 'data-aktion': aktion, 'aria-label': `${text}: ${l.name}`, onclick: () => verschiebe(l.id, richtung, aktion),
|
||
}, h('span', { 'aria-hidden': 'true' }, zeichen))
|
||
return editorZeile({
|
||
attrs: { 'data-id': l.id },
|
||
nurLesen: ctx.nurLesen,
|
||
label: l.name,
|
||
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,
|
||
],
|
||
pille: h('span', { class: `tm-pille tm-pille-${l.aktiv ? 'ok' : 'off'}` }, l.aktiv ? 'Aktiv' : 'Inaktiv'),
|
||
vorKnopf: ctx.nurLesen ? [] : [pfeil('hoch', -1, '↑', 'Nach oben'), pfeil('runter', 1, '↓', 'Nach unten')],
|
||
baue: (schliessen) => formular(t, { leistung: l, schliessen }),
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Formular einer neuen (leistung null) oder bestehenden Leistung. Die Pfade der Fehler des Servers nennen die Stelle in der gespeicherten
|
||
* Liste (leistungen.2.dauer); die Felder heissen danach. Beim Absenden wird die Leistung per ID gesucht, nicht per Stelle.
|
||
*/
|
||
function formular(t, { leistung, schliessen }) {
|
||
const { ctx, sp } = t
|
||
const e = ctx.daten.einstellung
|
||
const neu = leistung === null
|
||
const index = neu ? (e.leistungen || []).length : (e.leistungen || []).findIndex((x) => x.id === leistung.id)
|
||
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 entfernen = neu || ctx.nurLesen ? null : entfernenKnopf(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: () => nachSpeichern(t, `Leistung „${l.name}“ entfernt.`, '[data-werkzeug="leistung-neu"]'),
|
||
})
|
||
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?.knopf))
|
||
|
||
/** 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)
|
||
return true
|
||
}
|
||
const stelle = stelleVon(entwurf.leistungen, l.id, form, 'Diese Leistung')
|
||
if (stelle < 0) return false
|
||
entwurf.leistungen[stelle] = gelesen.leistung
|
||
return true
|
||
}, { container: form })
|
||
if (!ok || !sp.nochHier()) return
|
||
nachSpeichern(t, neu ? 'Leistung angelegt.' : 'Leistung gespeichert.', `li[data-id="${l.id}"] [data-aktion="bearbeiten"]`)
|
||
})
|
||
|
||
return h('div', { class: 'tm-editor' }, form, entfernen?.box)
|
||
}
|