Files
Webklar-Kundenbereich/public/termine-tab-leistungen.js
ClaudeandClaude Sonnet 5.5 87d267e51a Terminplaner TP2: Ansicht nach Zustand und Rueckkehr von Stripe
Die Ansicht #termine folgt dem Zustand aus GET /api/termine/einstellungen:
off mit laufendem Abo zeigt den Fragebogen samt Bestellseite, ohne Abo den
Hinweis des Servers (termine.hinweis, R111) und bei kein_abo den Link zu
Vertraege aus einer gemeinsamen Quelle (vertraege-link.js); ordered und setup
zeigen die Statuskarte und die Reiter Leistungen, Team & Plaetze und
Einstellungen nur zum Lesen (ohne Reiter Termine); active und disabled
bleiben wie bisher. Rueckkehr von Stripe: #termine?bestellt=1&projekt=<id>
und #termine?projekt=<id> waehlen das Projekt, "Danke!" nur mit bestellt=1,
die Adresse wird einmal gelesen und bereinigt.

R110: GET /einstellungen liefert bestellung und offen auch in ordered (ohne
Abo-Punkt, abo bleibt null); scheitert "Zahlung fortsetzen", zeichnet die
Huelle Liste und Daten neu (bei 409 zustimmung_fehlt mit dem
Bestellformular, sonst nach dem neuen Zustand, die Meldung bleibt am Knopf,
solange die Bestellung offen ist).

Dazu: "null" als Text im lesbaren Reiter Leistungen entfernt, Export und
Status der Einstellungen erst ab der Freischaltung.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 23:10:54 +00:00

241 lines
14 KiB
JavaScript
Raw Permalink 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, 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()
// neu.leiste ist null, solange die Funktion nur lesbar ist: replaceChildren wuerde sonst den Text "null" in die Seite schreiben
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.'),
].filter(Boolean))
}
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)
}