Terminplaner: Portal-Ansicht Leistungen, Team, Einstellungen

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-03 08:17:44 +00:00
1 parent 650919b022
commit 0b75b31009
15 files changed
+2479 -79

No files matched your search

+1 -11
View File
@@ -5,7 +5,7 @@
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 { allgemeinerFehler, feld, frage, h, knopfZeile, setzeFehler, zeigeFehler } from './termine-dom.js'
import { zeitWahl } from './termine-formulare.js'
export const AKTION_TEXT = {
@@ -65,16 +65,6 @@ async function fuehreAus(t, b, body, erfolg, form, knopf) {
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')) }
}
// Annehmen schliesst den Vertrag und verschickt die Bestaetigung (G02): wie jede Aktion erst nach einer Rueckfrage in der Seite
function frageAnnehmen(t, b, zu) {
const { ok, zeile } = knopfZeile('Annehmen', zu)
+33 -1
View File
@@ -56,6 +56,14 @@ export function feld(name, label, steuer, hinweis) {
h('p', { class: 'tm-fehler', 'data-fehler': name, role: 'alert' }))
}
/** Ankreuzfeld (Wert steuert der Aufrufer: input type=checkbox) mit Beschriftung, Hinweis und Platz fuer den Fehler. */
export function hakenFeld(name, steuer, text, hinweis) {
steuer.dataset.feld = name
return h('div', { class: 'tm-feld tm-feld-haken' },
h('label', { class: 'tm-haken' }, steuer, h('span', null, text, hinweis ? h('small', { 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' })
@@ -64,7 +72,8 @@ export function setzeFehler(form, fehler) {
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)}"]`)
// „_“ und Namen ohne Platz im Formular werden allgemein gezeigt
const ziel = name === '_' ? null : form.querySelector(`[data-fehler="${CSS.escape(name)}"]`)
if (ziel) {
ziel.textContent = text
form.querySelector(`[data-feld="${CSS.escape(name)}"]`)?.setAttribute('aria-invalid', 'true')
@@ -83,6 +92,29 @@ export function zeigeFehler(form, err) {
form.querySelector('[aria-invalid]')?.focus()
}
/** Rueckfrage in der Seite (kein confirm()): Formular mit Titel; der Fokus gehoert dem Aufrufer. */
export 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 (schickt das Formular ab) und Abbrechen. */
export 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')) }
}
/** Abschnitt eines Reiters: Karte mit Ueberschrift und einem erklaerenden Satz (hinweis darf fehlen). */
export const abschnitt = (titel, hinweis, ...inhalt) => h('section', { class: 'kb-panel tm-abschnitt' },
h('h3', null, titel),
hinweis ? h('p', { class: 'tm-hinweis tm-abschnitt-text' }, hinweis) : null,
...inhalt)
/** Felder einer abgeschalteten Funktion: gesperrt (fieldset disabled sperrt alle Felder darin), sonst unveraendert. */
export const gesperrt = (nurLesen, felder) => (nurLesen ? h('fieldset', { class: 'tm-gruppe-felder', disabled: true }, felder) : felder)
/** Link zum CSV-Export der Termine. */
export const exportLink = (projectId, text) => h('a', { href: exportUrl(projectId), download: 'termine.csv' }, text)
export const infoKarte = (titel, text, { chat = false } = {}) => h('div', { class: 'kb-panel tm-info' },
h('h3', null, titel),
text ? h('p', null, text) : null,
+2 -2
View File
@@ -2,7 +2,7 @@
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, istTag, ressourcenWort, zeitNormal } from './termine-view.js'
import { GESUNDHEIT_HINWEIS, MAX, heuteBerlin, istTag, 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. */
@@ -88,7 +88,7 @@ export function formularTermin(ctx, hooks) {
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.'),
feld('nachricht', 'Notiz (optional)', nachricht, GESUNDHEIT_HINWEIS),
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')))
+19
View File
@@ -0,0 +1,19 @@
/* Terminplaner im Kundenbereich: Texte, die GAESTE lesen, und die Anrede der Gaeste. Dieses Modul ist das einzige der Ansicht, in dem die
Sie-Form stehen darf (test/termineView.test.js prueft, dass alle anderen Portaltexte in der Du-Form sind). */
/** Anrede der Gaeste (anrede der Einstellung): Wert und Beschriftung. */
export const ANREDE_WAHL = [['sie', 'Sie (förmlich)'], ['du', 'Du (persönlich)']]
/**
* Mustertext fuer die Stornobedingungen, Zahlung vor Ort: derselbe Wortlaut wie in den Vorlagen des Servers
* (stornoText in server/services/termine/vorlagen.js, Stornobedingungen Variante 1) mit der Frist in Stunden.
* Ein Muster, kein fertiger Rechtstext: der Betrieb muss ihn pruefen und an sein Angebot anpassen. Eine unbrauchbare Frist (kleiner als 1,
* keine ganze Zahl) ergibt die Vorgabe 24 Stunden.
*/
export function stornoMuster(stunden) {
const frist = Number.isInteger(stunden) && stunden >= 1 ? stunden : 24
return `Sie können Ihren Termin bis ${frist} Stunden vorher kostenlos absagen oder verschieben (Link in der Bestätigungsmail). `
+ 'Danach oder bei Nichterscheinen berechnen wir für den freigehaltenen Termin 50 % des Preises. '
+ 'Dem Kunden bleibt der Nachweis vorbehalten, dass uns kein oder ein geringerer Aufwand entstanden ist. '
+ 'Ihr gesetzliches Widerrufsrecht bleibt unberührt.'
}
+128
View File
@@ -0,0 +1,128 @@
/* Terminplaner im Kundenbereich: Speichern der Einstellungen – gemeinsam fuer die Reiter Leistungen, Team & Plaetze und Einstellungen.
Jede Aenderung ist ein Speichern: PATCH /api/termine/einstellungen schickt die GANZE Einstellung mit der Version, auf der sie beruht
(server/routes/termine.js). Der Reiter gibt nur an, was sich aendert: speichere(aendere, { container, knopf }) holt eine Kopie des
aktuellen Standes (ctx.daten.einstellung), laesst aendere(entwurf) sie aendern und schickt sie ab.
- Erfolg: die zurueckgegebene Einstellung und Version gelten (ctx.daten), die Hinweisleiste der Huelle wird neu gezeichnet und die Daten
(Kalender-Links) werden neu geladen; Rueckgabe true.
- 400: Fehler stehen am Feld (data-fehler = Pfad wie 'leistungen.2.dauer' oder 'ressourcen.1.plan.3'); Pfade ohne Platz im container
erscheinen allgemein, mit dem Namen des Eintrags davor. Rueckgabe false.
- 409 EINSTELLUNG_VERALTET (jemand hat inzwischen gespeichert): Hinweis mit Knopf „Neu laden“ oben im Reiter. Rueckgabe false.
- alles andere: Meldung ueber den Reitern.
Der Reiter ruft speichere nur, wenn ctx.nurLesen nicht gesetzt ist. Die Tokens der Kalender-Links gehoeren dem Server und stehen nie in
der Einstellung des Browsers. */
import { allgemeinerFehler, frage, h, knopfZeile, setzeFehler } from './termine-dom.js'
export const VERALTET_TEXT = 'Die Einstellungen wurden inzwischen geändert. Bitte neu laden.'
const WORT = { leistungen: 'Leistung', abwesenheiten: 'Abwesenheit' }
/** „Leistung „Färben“: Text“ aus Pfad und Text eines Fehlers; Eintraege finden sich per Index oder per ID in den Einstellungen (quellen). */
export function beschrifteFehler(pfad, text, quellen) {
if (pfad.startsWith('betrieb.')) return `${text} Das änderst du im Reiter „Einstellungen“.`
const m = /^(leistungen|ressourcen|abwesenheiten)\.([^.]+)/.exec(pfad)
if (!m) return text
for (const quelle of quellen) {
const liste = Array.isArray(quelle?.[m[1]]) ? quelle[m[1]] : []
const eintrag = /^\d+$/.test(m[2]) ? liste[Number(m[2])] : liste.find((e) => e?.id === m[2])
const name = eintrag?.name || eintrag?.titel
if (!name) continue
const wort = m[1] === 'ressourcen' ? (eintrag.typ === 'platz' ? 'Platz' : 'Person') : WORT[m[1]]
return `${wort} „${name}“: ${text}`
}
return text
}
/**
* Speicher eines Reiters. wurzel = das Element des Reiters (verschwindet beim Wechsel des Reiters: danach fasst nichts mehr die Seite an),
* neuZeichnen() zeichnet den Reiter aus ctx.daten neu (nach „Neu laden“).
* => { konflikt, speichere, nochHier }: konflikt ist das Hinweis-Element (der Reiter setzt es an den Anfang), nochHier() sagt, ob der
* Reiter noch angezeigt wird (vor jedem Anfassen der Seite nach einem await pruefen).
*/
export function neuerSpeicher(ctx, wurzel, neuZeichnen) {
const nochHier = () => wurzel.isConnected && ctx.aktuell()
const text = h('span', null, VERALTET_TEXT)
const neuLadenKnopf = h('button', { type: 'button', class: 'kb-btn-outline' }, 'Neu laden')
const konflikt = h('div', { class: 'tm-box tm-box-warn', role: 'alert', hidden: true }, text, neuLadenKnopf)
neuLadenKnopf.addEventListener('click', async () => {
neuLadenKnopf.disabled = true
try {
await ctx.neuLaden()
if (!nochHier()) return
text.textContent = VERALTET_TEXT
konflikt.hidden = true
neuZeichnen()
} catch (err) {
if (nochHier()) text.textContent = err.message
} finally {
neuLadenKnopf.disabled = false
}
})
async function speichere(aendere, { container, knopf = null } = {}) {
setzeFehler(container, {})
konflikt.hidden = true
const bisher = ctx.daten.einstellung
const entwurf = structuredClone(bisher)
if (aendere(entwurf) === false) return false // der Reiter hat selbst Fehler am Feld gezeigt
if (knopf) knopf.disabled = true
try {
const antwort = await ctx.api.send('PATCH', '/einstellungen', { version: ctx.daten.version, einstellung: entwurf })
if (!ctx.aktuell()) return false
// Der Server hat streng geprueft: Telefon und Anschrift fehlen nach einem Erfolg nie mehr
Object.assign(ctx.daten, { einstellung: antwort.einstellung, version: antwort.version, fehlt: [] })
ctx.hinweise()
try {
await ctx.neuLaden() // neue Personen/Plaetze bekommen vom Server ihre Kalender-Links
} catch { /* dann zeigen neue Eintraege ihren Kalender-Link erst nach dem naechsten Laden */ }
return true
} catch (err) {
if (!nochHier()) return false
if (err.code === 'EINSTELLUNG_VERALTET') {
text.textContent = VERALTET_TEXT
konflikt.hidden = false
konflikt.scrollIntoView({ block: 'nearest' })
} else if (err.status === 400 && Object.keys(err.fehler || {}).length) {
zeigeFelder(container, err.fehler, [bisher, entwurf])
} else {
ctx.meldung(err.message, 'fehler')
}
return false
} finally {
if (knopf?.isConnected) knopf.disabled = false
}
}
return { konflikt, speichere, nochHier }
}
/** Fehler des Servers am Feld; was im container keinen Platz hat, steht allgemein (mit dem Namen des Eintrags davor). */
function zeigeFelder(container, fehler, quellen) {
const amFeld = {}
const rest = []
for (const [pfad, text] of Object.entries(fehler)) {
if (pfad !== '_' && container.querySelector(`[data-fehler="${CSS.escape(pfad)}"]`)) amFeld[pfad] = text
else rest.push(beschrifteFehler(pfad, text, quellen))
}
setzeFehler(container, rest.length ? { ...amFeld, _: rest.join(' ') } : amFeld)
container.querySelector('[aria-invalid="true"]')?.focus()
}
/**
* Rueckfrage „entfernen?“ in der Seite (kein confirm()): Titel, Erklaerung, Platz fuer den Fehler des Servers am Eintrag (fehlerPfad, z. B.
* 'leistungen.s_abcd1234': „Fuer diese Leistung gibt es noch 2 kuenftige Termine …“), Knoepfe. aendere(entwurf) entfernt den Eintrag,
* nachher() laeuft nach dem erfolgreichen Speichern (der Reiter zeichnet neu), zurueck() nach „Abbrechen“. Gibt das Formular zurueck;
* der Aufrufer setzt es in seine Box und gibt ihm den Fokus.
*/
export function entfernenFrage(sp, { titel, text, fehlerPfad, aendere, nachher, zurueck }) {
const { ok, zeile } = knopfZeile('Ja, entfernen', zurueck)
const box = frage(titel,
h('p', null, text),
fehlerPfad ? h('p', { class: 'tm-fehler', 'data-fehler': fehlerPfad, role: 'alert' }) : null,
allgemeinerFehler(),
zeile)
box.addEventListener('submit', async () => {
if (await sp.speichere(aendere, { container: box, knopf: ok }) && sp.nochHier()) nachher()
})
return box
}
+281
View File
@@ -0,0 +1,281 @@
/* Terminplaner im Kundenbereich: Reiter "Einstellungen" (Spezifikation Abschnitt 9, Punkt 4) – Betriebsangaben fuer Gaeste, Anrede und Farbe,
Regeln, Angaben von Gaesten (Zusatzfelder), Erinnerung, Stornobedingungen (mit Muster), Rechtliches, Bezahlung, Aufbewahrung,
darunter Export und Status. render(panel, ctx) ist der Einstieg (Registry REITER in termine.js).
Alles ist EIN Formular mit einem Speichern (termine-speichern.js). Jeder Abschnitt ist eine Funktion ctx => { el, lese(entwurf, fehler) }:
el die Karte, lese uebertraegt die Felder in den Entwurf und meldet Eingaben, die kein Feld verstehen kann, in fehler (Pfad ⇒ Text).
Pruefen und Begrenzen bleibt Sache des Servers. Abgeschaltete Funktion (ctx.nurLesen): Felder gesperrt, kein Speichern, Export bleibt. */
import {
ERINNERUNG_STUNDEN, GAST_FELD_WAHL, GESUNDHEIT_HINWEIS, LIMITS, MAX, RASTER_MIN, ZAHLARTEN, aktivSeitText, fehltText, ganzzahl, optionenAusText, optionenText, zusatzSchluessel,
} from './termine-view.js'
import { ANREDE_WAHL, stornoMuster } from './termine-gaestetexte.js'
import { abschnitt, allgemeinerFehler, exportLink, feld, gesperrt, h, hakenFeld, zeigeFehler } from './termine-dom.js'
import { neuerSpeicher } from './termine-speichern.js'
const wahl = (liste, aktuell) => liste.map(([wert, text]) => h('option', { value: wert, selected: String(aktuell) === String(wert) }, text))
const zahlenFeld = (name, label, wert, { min, max, schritt = 1, hinweis }) => {
const input = h('input', { type: 'number', inputmode: 'numeric', min, max, step: schritt, value: wert })
return { input, el: feld(name, label, input, hinweis) }
}
const liesZahl = (fehler, name, input) => {
const wert = ganzzahl(input.value)
if (wert === null) fehler[name] = 'Bitte gib eine ganze Zahl an.'
return wert
}
export function render(panel, ctx) {
const wurzel = h('div', { class: 'tm-tab' })
panel.replaceChildren(wurzel)
const sp = neuerSpeicher(ctx, wurzel, zeichne)
function zeichne() {
const e = ctx.daten.einstellung
const regelnAbschnitt = regeln(e)
const abschnitte = [betrieb(ctx, e), darstellung(e), regelnAbschnitt, gastFelder(ctx, e), erinnerung(e), storno(ctx, e, regelnAbschnitt.absage), rechtliches(e), zahlung(e), aufbewahrung(e)]
const speichern = h('button', { type: 'submit', class: 'kb-btn-primary' }, 'Einstellungen speichern')
const form = h('form', { novalidate: true, 'aria-label': 'Einstellungen' },
gesperrt(ctx.nurLesen, abschnitte.map((a) => a.el)),
allgemeinerFehler(),
ctx.nurLesen ? null : h('div', { class: 'tm-frage-knoepfe' }, speichern))
form.addEventListener('submit', async (ev) => {
ev.preventDefault()
const ok = await sp.speichere((entwurf) => {
const fehler = {}
for (const a of abschnitte) a.lese(entwurf, fehler)
if (Object.keys(fehler).length) {
zeigeFehler(form, { fehler })
return false
}
return true
}, { container: form, knopf: speichern })
if (!ok || !sp.nochHier()) return
zeichne()
ctx.meldung('Einstellungen gespeichert.')
})
wurzel.replaceChildren(sp.konflikt, form, exportUndStatus(ctx))
}
zeichne()
}
/* ----------------------------------------------------------------- Betrieb --- */
function betrieb(ctx, e) {
const b = e.betrieb || {}
const text = (wert, max, extra) => h('input', { type: 'text', maxlength: max, autocomplete: 'off', value: wert ?? '', ...extra })
const url = (wert) => text(wert, MAX.url, { inputmode: 'url', placeholder: 'https://' })
const f = {
name: text(b.name, MAX.betriebName), email: h('input', { type: 'email', maxlength: '254', autocomplete: 'off', value: b.email ?? '' }), telefon: text(b.telefon, MAX.telefon),
adresse: text(b.adresse, MAX.adresse), impressumUrl: url(b.impressumUrl), datenschutzUrl: url(b.datenschutzUrl), agbUrl: url(b.agbUrl), websiteUrl: url(b.websiteUrl),
kleinunternehmer: h('input', { type: 'checkbox', checked: b.kleinunternehmer === true }),
}
const fehlt = ctx.daten.fehlt || []
const warnung = fehlt.length
? h('div', { class: 'tm-box tm-box-warn', role: 'note' }, `Ohne Telefon/Anschrift ist der Terminplaner auf deiner Website nicht buchbar. Es fehlt noch: ${fehltText(fehlt) || 'eine Angabe zu deinem Betrieb'}.`)
: null
const el = abschnitt('Dein Betrieb',
'Diese Angaben sehen deine Gäste beim Buchen und in ihren E-Mails. Die meisten haben wir aus deinem Kundenkonto übernommen.',
warnung,
h('div', { class: 'tm-raster' },
feld('betrieb.name', 'Name des Betriebs', f.name),
feld('betrieb.email', 'E-Mail des Betriebs', f.email, 'Hierhin schicken wir dir Nachrichten zu Terminen. Gäste können auf ihre Mails hier antworten.')),
h('div', { class: 'tm-raster' },
feld('betrieb.telefon', 'Telefon (Pflicht)', f.telefon, 'Gäste sehen die Nummer als Ausweichweg und in ihren Mails.'),
feld('betrieb.adresse', 'Anschrift', f.adresse, 'Pflicht, wenn Privatpersonen eine Leistung sofort buchen: Die Anschrift steht in der Widerrufsbelehrung.')),
hakenFeld('betrieb.kleinunternehmer', f.kleinunternehmer, 'Ich bin Kleinunternehmer nach § 19 UStG', 'Dann steht bei den Preisen „kein Ausweis der USt nach § 19 UStG“ statt „inkl. USt“.'),
h('div', { class: 'tm-raster' },
feld('betrieb.impressumUrl', 'Link zum Impressum', f.impressumUrl, 'Vollständige Adresse mit https://'),
feld('betrieb.datenschutzUrl', 'Link zur Datenschutzerklärung', f.datenschutzUrl, 'Vollständige Adresse mit https://'),
feld('betrieb.agbUrl', 'Link zu deinen AGB (optional)', f.agbUrl, 'Vollständige Adresse mit https://'),
feld('betrieb.websiteUrl', 'Link zu deiner Website', f.websiteUrl, 'Vollständige Adresse mit https://')))
return {
el,
lese(entwurf) {
entwurf.betrieb = {
...entwurf.betrieb,
name: f.name.value, email: f.email.value, telefon: f.telefon.value, adresse: f.adresse.value, kleinunternehmer: f.kleinunternehmer.checked,
impressumUrl: f.impressumUrl.value, datenschutzUrl: f.datenschutzUrl.value, agbUrl: f.agbUrl.value, websiteUrl: f.websiteUrl.value,
}
},
}
}
/* -------------------------------------------------------- Anrede und Farbe --- */
function darstellung(e) {
const anrede = h('select', null, wahl(ANREDE_WAHL, e.anrede ?? 'sie'))
const farbe = h('input', { type: 'color', value: /^#[0-9a-f]{6}$/.test(e.farbe) ? e.farbe : '#7c3aed' })
const el = abschnitt('Anrede und Farbe', 'So treten die Buchungsseite und die E-Mails an deine Gäste auf.',
h('div', { class: 'tm-raster' },
feld('anrede', 'Wie sprichst du deine Gäste an?', anrede),
feld('farbe', 'Akzentfarbe', farbe, 'Für Knöpfe und Kopfzeile der Buchungsseite und der E-Mails.')))
return { el, lese(entwurf) { entwurf.anrede = anrede.value; entwurf.farbe = farbe.value } }
}
/* ------------------------------------------------------------------ Regeln --- */
function regeln(e) {
const r = e.regeln || {}
const raster = h('select', null, wahl(RASTER_MIN.map((n) => [n, `${n} Minuten`]), r.raster ?? 15))
const vorlauf = zahlenFeld('regeln.vorlaufMin', 'Mindestvorlauf (Minuten)', r.vorlaufMin, { min: 0, max: 20160, hinweis: 'Wie kurzfristig darf gebucht werden? 120 sind 2 Stunden, höchstens 20160 (14 Tage).' })
const horizont = zahlenFeld('regeln.horizontTage', 'Buchungszeitraum (Tage)', r.horizontTage, { min: 1, max: 365, hinweis: 'Wie weit im Voraus darf gebucht werden? 1 bis 365 Tage.' })
const absage = zahlenFeld('regeln.absageBisStd', 'Selbst absagen bis (Stunden vorher)', r.absageBisStd, { min: 0, max: 336, hinweis: 'Bis dahin dürfen Gäste ihren Termin selbst absagen oder verschieben. 0 bis 336.' })
const offene = zahlenFeld('regeln.maxOffeneJeEmail', 'Offene Termine je E-Mail-Adresse', r.maxOffeneJeEmail, { min: 1, max: 10, hinweis: 'So viele Termine darf eine Adresse gleichzeitig offen haben (1 bis 10). Das schützt vor Spaßbuchungen.' })
const wahlbar = h('input', { type: 'checkbox', checked: r.personWaehlbar !== false })
const el = abschnitt('Regeln fürs Buchen', null,
h('div', { class: 'tm-raster' }, feld('regeln.raster', 'Startzeiten alle', raster, 'In diesen Schritten bieten wir Uhrzeiten an.'), vorlauf.el, horizont.el, absage.el, offene.el),
hakenFeld('regeln.personWaehlbar', wahlbar, 'Gäste dürfen die Person oder den Platz selbst wählen', 'Sonst vergeben wir automatisch eine freie Person oder einen freien Platz.'))
return {
el,
lese(entwurf, fehler) {
entwurf.regeln = {
...entwurf.regeln,
raster: Number(raster.value), vorlaufMin: liesZahl(fehler, 'regeln.vorlaufMin', vorlauf.input), horizontTage: liesZahl(fehler, 'regeln.horizontTage', horizont.input),
absageBisStd: liesZahl(fehler, 'regeln.absageBisStd', absage.input), maxOffeneJeEmail: liesZahl(fehler, 'regeln.maxOffeneJeEmail', offene.input), personWaehlbar: wahlbar.checked,
}
},
absage: absage.input,
}
}
/* ------------------------------------------------- Angaben von Gaesten --- */
function gastFelder(ctx, e) {
const g = e.gastFelder || {}
const telefon = h('select', null, wahl(GAST_FELD_WAHL, g.telefon ?? 'optional'))
const nachricht = h('select', null, wahl(GAST_FELD_WAHL, g.nachricht ?? 'aus'))
// Zusatzfelder: der Zustand steht in zeilen, die Liste wird daraus gezeichnet (Namen der Fehlerfelder = Stelle in der Liste)
const zeilen = (g.zusatz || []).map((z) => ({ key: z.key, label: z.label, typ: z.typ, optionen: optionenText(z.optionen), pflicht: z.pflicht === true }))
const liste = h('div', { class: 'tm-zusatz' })
const neuKnopf = ctx.nurLesen ? null : h('button', {
type: 'button', class: 'kb-btn-outline', 'data-aktion': 'zusatz-neu',
onclick: () => { zeilen.push({ key: '', label: '', typ: 'text', optionen: '', pflicht: false }); zeichneListe(); liste.lastElementChild?.querySelector('input')?.focus() },
}, '+ Zusatzfeld')
const platz = h('p', { class: 'tm-fehler', 'data-fehler': 'gastFelder.zusatz', role: 'alert' })
function zeichneListe() {
if (neuKnopf) neuKnopf.disabled = zeilen.length >= LIMITS.zusatzfelder
liste.replaceChildren(...zeilen.map((z, i) => {
const p = `gastFelder.zusatz.${i}`
const label = h('input', { type: 'text', maxlength: MAX.zusatzLabel, autocomplete: 'off', value: z.label })
const typ = h('select', null, wahl([['text', 'Freier Text'], ['auswahl', 'Auswahl aus Liste']], z.typ))
const optionen = h('textarea', { rows: '3', maxlength: String(MAX.option * LIMITS.optionen + LIMITS.optionen) }, z.optionen)
const pflicht = h('input', { type: 'checkbox', checked: z.pflicht })
const optionenFeld = feld(`${p}.optionen`, 'Auswahlmöglichkeiten', optionen, `Eine je Zeile, höchstens ${LIMITS.optionen}.`)
optionenFeld.hidden = z.typ !== 'auswahl'
label.addEventListener('input', () => { z.label = label.value })
optionen.addEventListener('input', () => { z.optionen = optionen.value })
pflicht.addEventListener('change', () => { z.pflicht = pflicht.checked })
typ.addEventListener('change', () => { z.typ = typ.value; optionenFeld.hidden = z.typ !== 'auswahl' })
return h('div', { class: 'tm-zusatz-zeile', 'data-zusatz': String(i) },
h('div', { class: 'tm-raster' }, feld(`${p}.label`, 'Beschriftung', label), feld(`${p}.typ`, 'Art', typ)),
optionenFeld,
hakenFeld(`${p}.pflicht`, pflicht, 'Pflichtfeld'),
ctx.nurLesen ? null : h('button', {
type: 'button', class: 'kb-btn-outline tm-gefahr', 'data-aktion': 'zusatz-weg', 'aria-label': `Zusatzfeld entfernen: ${z.label || i + 1}`,
onclick: () => { zeilen.splice(i, 1); zeichneListe(); neuKnopf?.focus() },
}, 'Entfernen'))
}))
}
zeichneListe()
const el = abschnitt('Angaben von Gästen',
'Name und E-Mail brauchen wir immer. Alles Weitere entscheidest du.',
h('div', { class: 'tm-raster' },
feld('gastFelder.telefon', 'Telefonnummer des Gastes', telefon),
feld('gastFelder.nachricht', 'Nachricht des Gastes', nachricht, `Über dem Feld steht für deine Gäste: „${GESUNDHEIT_HINWEIS}“`)),
h('h4', null, 'Zusatzfelder'),
h('p', { class: 'tm-hinweis' }, `Eigene Fragen an deine Gäste, zum Beispiel nach der Haarlänge oder einer Wunschfarbe. Höchstens ${LIMITS.zusatzfelder}. Keine Gesundheitsangaben abfragen.`),
liste,
platz,
neuKnopf)
return {
el,
lese(entwurf) {
const belegt = zeilen.map((z) => z.key).filter(Boolean)
const zusatz = zeilen.map((z) => {
const key = z.key || zusatzSchluessel(z.label, belegt)
belegt.push(key)
return { key, label: z.label, typ: z.typ, optionen: z.typ === 'auswahl' ? optionenAusText(z.optionen) : [], pflicht: z.pflicht }
})
entwurf.gastFelder = { ...entwurf.gastFelder, telefon: telefon.value, nachricht: nachricht.value, zusatz }
},
}
}
/* -------------------------------------------------------------- Erinnerung --- */
function erinnerung(e) {
const r = e.erinnerung || {}
const anbieten = h('input', { type: 'checkbox', checked: r.anbieten !== false })
const stunden = h('select', null, wahl(ERINNERUNG_STUNDEN.map((n) => [n, `${n} Stunden vorher`]), r.stundenVorher ?? 24))
const el = abschnitt('Erinnerung', null,
hakenFeld('erinnerung.anbieten', anbieten, 'Gästen eine E-Mail-Erinnerung anbieten', 'Gäste entscheiden beim Buchen selbst (der Haken ist nie vorangekreuzt).'),
feld('erinnerung.stundenVorher', 'Die Erinnerung kommt', stunden))
return { el, lese(entwurf) { entwurf.erinnerung = { ...entwurf.erinnerung, anbieten: anbieten.checked, stundenVorher: Number(stunden.value) } } }
}
/* --------------------------------------------------------- Stornobedingungen --- */
function storno(ctx, e, fristFeld) {
const s = e.storno || {}
const textfeld = h('textarea', { rows: '6', maxlength: String(MAX.storno) }, s.text ?? '')
// das Muster setzt die Frist ein, die im Feld „Selbst absagen bis“ steht (auch ungespeichert)
const muster = h('blockquote', { class: 'tm-muster-text' }, stornoMuster(e.regeln?.absageBisStd))
fristFeld.addEventListener('input', () => { muster.textContent = stornoMuster(ganzzahl(fristFeld.value)) })
const einfuegen = ctx.nurLesen ? null : h('button', {
type: 'button', class: 'kb-btn-outline', 'data-aktion': 'muster-uebernehmen',
onclick: () => { textfeld.value = muster.textContent; textfeld.focus() },
}, 'Muster in das Textfeld übernehmen')
const el = abschnitt('Stornobedingungen',
'Gäste müssen diesen Text vor dem Buchen bestätigen. Lässt du das Feld leer, gibt es keine Stornobedingungen und keinen Haken dazu.',
feld('storno.text', 'Dein Text', textfeld, s.version ? `Aktuelle Fassung: ${s.version}. Ändert sich der Text, bekommt er beim Speichern eine neue Fassung, und Gäste stimmen immer der Fassung zu, die sie gesehen haben.` : 'Beim ersten Speichern bekommt der Text eine Fassungsnummer.'),
h('div', { class: 'tm-muster', role: 'group', 'aria-label': 'Mustertext' },
h('p', null, h('span', { class: 'tm-pille tm-pille-wait' }, 'Muster'), ' Ein Vorschlag, kein fertiger Rechtstext. Prüfe ihn und passe Frist, Prozentsatz und Anrede an deinen Betrieb an. Er ist in der förmlichen Anrede geschrieben. Das gesetzliche Widerrufsrecht deiner privaten Gäste bleibt unberührt.'),
muster,
einfuegen,
einfuegen ? h('p', { class: 'tm-hinweis' }, 'Das ersetzt den Text im Feld. Gespeichert wird erst mit „Einstellungen speichern“.') : null))
return { el, lese(entwurf) { entwurf.storno = { ...entwurf.storno, text: textfeld.value } } }
}
/* ------------------------------------------------------------- Rechtliches --- */
function rechtliches(e) {
const r = e.recht || {}
const gaeste = h('select', null, wahl([['verbraucher', 'Privatpersonen'], ['unternehmer', 'Unternehmen'], ['gemischt', 'Beides']], r.gaesteSind ?? 'verbraucher'))
const freizeit = h('input', { type: 'checkbox', checked: r.freizeit === true })
const el = abschnitt('Rechtliches', null,
h('div', { class: 'tm-box tm-box-info', role: 'note' }, h('span', null,
h('strong', null, 'Der Widerruf schlägt die Stornofrist. '),
'Privatpersonen können einen verbindlich gebuchten Termin bis 14 Tage nach der Buchung widerrufen, auch wenn deine kostenlose Stornofrist schon vorbei ist. Die Widerrufsbelehrung zeigt der Terminplaner deinen Gästen automatisch.')),
feld('recht.gaesteSind', 'Deine Gäste sind', gaeste, 'Bei „Unternehmen“ gibt es kein gesetzliches Widerrufsrecht.'),
hakenFeld('recht.freizeit', freizeit, 'Meine Leistungen sind Freizeitangebote mit festem Termin', 'Zum Beispiel ein Kurs oder ein Workshop. Dann gibt es kein gesetzliches Widerrufsrecht (§ 312g Abs. 2 Nr. 9 BGB). Setze das Häkchen nur, wenn du dir sicher bist oder dich bei uns rückversichert hast.'))
return { el, lese(entwurf) { entwurf.recht = { ...entwurf.recht, gaesteSind: gaeste.value, freizeit: freizeit.checked } } }
}
/* ----------------------------------------------------------------- Bezahlung --- */
function zahlung(e) {
const z = e.zahlung || {}
const arten = ZAHLARTEN.map(([key, name]) => ({ key, name, input: h('input', { type: 'checkbox', checked: (z.vorOrtArten || []).includes(key) }) }))
const el = abschnitt('Bezahlung', 'Bezahlt wird bei dir vor Ort. Welche Zahlarten nimmst du an? Gäste sehen das vor der Buchung.',
h('div', { class: 'tm-optionen', role: 'radiogroup', 'aria-label': 'Wo wird bezahlt?' },
h('label', { class: 'tm-haken' }, h('input', { type: 'radio', name: 'tm-zahlmodus', value: 'vorort', checked: true }), h('span', null, 'Vor Ort bezahlen')),
h('label', { class: 'tm-haken tm-aus' }, h('input', { type: 'radio', name: 'tm-zahlmodus', value: 'online', disabled: true }), h('span', null, 'Online bezahlen ', h('span', { class: 'tm-pille tm-pille-off' }, 'kommt bald')))),
h('fieldset', { class: 'tm-gruppe-felder', 'data-feld': 'zahlung.vorOrtArten', tabindex: '-1' },
h('legend', { class: 'tm-label' }, 'Zahlarten vor Ort'),
arten.map(({ name, input }) => h('label', { class: 'tm-haken' }, input, h('span', null, name))),
h('p', { class: 'tm-fehler', 'data-fehler': 'zahlung.vorOrtArten', role: 'alert' })))
return { el, lese(entwurf) { entwurf.zahlung = { modus: 'vorort', vorOrtArten: arten.filter(({ input }) => input.checked).map(({ key }) => key) } } }
}
/* ------------------------------------------------------------ Aufbewahrung --- */
function aufbewahrung(e) {
const tage = zahlenFeld('aufbewahrungTage', 'Aufbewahrung (Tage)', e.aufbewahrungTage, {
min: 14, max: 365,
hinweis: 'So lange nach dem Termin bleiben erledigte Termine und Termine, zu denen Gäste nicht erschienen sind, mit allen Angaben gespeichert. Danach löschen wir sie. Abgesagte Termine löschen wir nach 30 Tagen. 14 bis 365.',
})
return { el: abschnitt('Aufbewahrung', null, tage.el), lese(entwurf, fehler) { entwurf.aufbewahrungTage = liesZahl(fehler, 'aufbewahrungTage', tage.input) } }
}
/* ------------------------------------------------------------ Export, Status --- */
function exportUndStatus(ctx) {
const seit = aktivSeitText(ctx.daten.aktivSeit)
const abgeschaltet = ctx.daten.state === 'disabled'
return abschnitt('Export und Status', null,
h('p', null, exportLink(ctx.projectId, 'Alle Termine als CSV-Datei herunterladen'), ' (öffnet sich in Excel und Numbers).'),
h('p', { class: 'tm-status' },
h('span', { class: `tm-pille tm-pille-${abgeschaltet ? 'off' : 'ok'}` }, abgeschaltet ? 'Abgeschaltet' : 'Aktiv'),
seit ? ` ${seit}` : ''))
}
+204
View File
@@ -0,0 +1,204 @@
/* 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)
}
+239
View File
@@ -0,0 +1,239 @@
/* Terminplaner im Kundenbereich: Reiter "Team & Plaetze" – Personen und Plaetze mit Wochenplan (je Tag mehrere Zeitfenster, Text wie
„09:00–12:30, 13:30–18:00“, fensterParsen), Zuordnung der Leistungen, Kalender-Abo-Link (kopieren, neu erzeugen); darunter die Ausnahmen
(Abwesenheiten, Feiertage, Sonderoeffnungen: termine-team-ausnahmen.js). render(panel, ctx) ist der Einstieg (Registry REITER in termine.js).
Jede Aenderung speichert die ganze Einstellung (termine-speichern.js); die Zuordnung steht in der Einstellung bei den Leistungen
(leistungen[].ressourcen), eine neue Person und ihre Zuordnung gehen deshalb in EINEM Speichern raus. Tokens der Kalender-Links vergibt nur
der Server. Abgeschaltete Funktion (ctx.nurLesen): Felder gesperrt, keine Knoepfe zum Aendern, keine Links. */
import { LIMITS, MAX, WOCHENTAGE_PLAN, eindeutigeId, planAusTexten, planTexte } from './termine-view.js'
import { abschnitt, allgemeinerFehler, feld, frage, gesperrt, h, hakenFeld, knopfZeile, zeigeFehler } from './termine-dom.js'
import { entfernenFrage, neuerSpeicher } from './termine-speichern.js'
import { abwesenheiten, feiertage, sonderoeffnungen } from './termine-team-ausnahmen.js'
const TYPEN = [['person', 'Person'], ['platz', 'Platz']]
const zaehle = (n, eins, viele) => `${n} ${n === 1 ? eins : viele}`
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() {
wurzel.replaceChildren(sp.konflikt, personen(t), abwesenheiten(t), feiertage(t), sonderoeffnungen(t))
}
zeichne()
}
/* ----------------------------------------------------- Personen und Plaetze --- */
function personen(t) {
const { ctx } = t
const e = ctx.daten.einstellung
const liste = e.ressourcen || []
const voll = liste.length >= LIMITS.ressourcen
const neuBox = h('div', { class: 'tm-formular-box' })
const neuKnopf = ctx.nurLesen ? null : h('button', {
type: 'button', class: 'kb-btn-primary', 'data-werkzeug': 'ressource-neu', 'aria-expanded': 'false', disabled: voll,
onclick: () => {
if (neuBox.firstChild) { neuBox.replaceChildren(); neuKnopf.setAttribute('aria-expanded', 'false'); return }
neuKnopf.setAttribute('aria-expanded', 'true')
const editor = formular(t, { index: liste.length, r: null, schliessen: () => { neuBox.replaceChildren(); neuKnopf.setAttribute('aria-expanded', 'false'); neuKnopf.focus() } })
neuBox.replaceChildren(editor)
editor.querySelector('input')?.focus()
},
}, '+ Person oder Platz hinzufügen')
const reihe = (r, index) => {
const li = h('li', { class: 'tm-eintrag', 'data-id': r.id })
const editor = h('div', { class: 'tm-editor-box' })
const leistungen = (e.leistungen || []).filter((l) => (l.ressourcen || []).includes(r.id)).length
const tage = Object.keys(r.plan || {}).length
const oeffnen = h('button', {
type: 'button', class: 'kb-btn-outline tm-knopf', 'data-aktion': 'bearbeiten', 'aria-expanded': 'false', 'aria-label': `${ctx.nurLesen ? 'Ansehen' : 'Bearbeiten'}: ${r.name}`,
onclick: () => {
if (editor.firstChild) { editor.replaceChildren(); oeffnen.setAttribute('aria-expanded', 'false'); return }
oeffnen.setAttribute('aria-expanded', 'true')
const neuerEditor = formular(t, { index, r, schliessen: () => { editor.replaceChildren(); oeffnen.setAttribute('aria-expanded', 'false'); oeffnen.focus() } })
editor.replaceChildren(neuerEditor)
neuerEditor.querySelector('input')?.focus()
},
}, ctx.nurLesen ? 'Ansehen' : 'Bearbeiten')
li.append(
h('div', { class: 'tm-kopf' },
h('div', { class: 'tm-haupt' },
h('strong', { class: 'tm-name' }, r.name),
h('span', { class: 'tm-meta' }, [r.typ === 'platz' ? 'Platz' : 'Person', tage ? `Zeiten an ${zaehle(tage, 'Tag', 'Tagen')}` : 'keine Zeiten', leistungen ? zaehle(leistungen, 'Leistung', 'Leistungen') : 'keine Leistung'].join(' · '))),
h('span', { class: `tm-pille tm-pille-${r.aktiv ? 'ok' : 'off'}` }, r.aktiv ? 'Aktiv' : 'Inaktiv')),
h('div', { class: 'tm-aktionen' }, oeffnen),
editor)
return li
}
return abschnitt('Personen und Plätze',
'Wer oder was kann gebucht werden? Jede Person und jeder Platz hat eigene Zeiten und bietet bestimmte Leistungen an. Die Leistungen selbst legst du im Reiter „Leistungen“ an.',
ctx.nurLesen ? null : h('div', { class: 'tm-werkzeug-zeile' }, neuKnopf, voll ? h('p', { class: 'tm-hinweis' }, `Mehr als ${LIMITS.ressourcen} Personen oder Plätze sind nicht möglich.`) : null),
neuBox,
liste.length
? h('ul', { class: 'tm-eintraege', 'aria-label': 'Personen und Plätze' }, liste.map(reihe))
: h('p', { class: 'tm-leer' }, 'Noch keine Person und kein Platz. Ohne sie kann niemand buchen.'))
}
/**
* Editor einer neuen (r null) oder bestehenden Person/eines Platzes: Formular, darunter (nur bestehend) Kalender-Link und die Rueckfrage zum
* Entfernen. Die Rueckfragen stehen neben dem Formular, nicht darin: ein submit der Rueckfrage steigt sonst ins aeussere Formular auf.
* index = Stelle in ressourcen (so heissen die Pfade der Fehler des Servers).
*/
function formular(t, { index, r, schliessen }) {
const { ctx, sp, zeichne } = t
const e = ctx.daten.einstellung
const neu = r === null
const alt = r || { id: eindeutigeId('r', (e.ressourcen || []).map((x) => x.id)), typ: 'person', name: '', aktiv: true, plan: {} }
const p = `ressourcen.${index}`
const leistungen = e.leistungen || []
const name = h('input', { type: 'text', maxlength: MAX.ressourcenName, autocomplete: 'off', value: alt.name })
const typ = h('select', null, TYPEN.map(([wert, text]) => h('option', { value: wert, selected: alt.typ === wert }, text)))
const aktiv = h('input', { type: 'checkbox', checked: alt.aktiv })
const texte = planTexte(alt.plan)
const tage = WOCHENTAGE_PLAN.map(([nr, tag]) => ({
nr, name: `${p}.plan.${nr}`,
input: h('input', { type: 'text', autocomplete: 'off', placeholder: 'geschlossen', value: texte[nr] }),
tag,
}))
const zuordnung = leistungen.map((l, k) => ({ id: l.id, input: h('input', { type: 'checkbox', checked: (l.ressourcen || []).includes(alt.id) }), name: `leistungen.${k}.ressourcen`, label: `${l.name}${l.aktiv ? '' : ' (inaktiv)'}` }))
const speichern = h('button', { type: 'submit', class: 'kb-btn-primary' }, neu ? 'Hinzufügen' : 'Speichern')
const frageBox = h('div', { class: 'tm-frage' })
const entfernen = neu || ctx.nurLesen ? null : h('button', { type: 'button', class: 'kb-btn-outline tm-gefahr', onclick: () => fragen() }, 'Entfernen')
const form = h('form', { class: 'kb-panel tm-formular', novalidate: true, 'aria-label': neu ? 'Person oder Platz hinzufügen' : `${ctx.nurLesen ? 'Person oder Platz' : 'Bearbeiten'}: ${alt.name}` },
h('h3', null, neu ? 'Neue Person oder neuer Platz' : alt.name),
gesperrt(ctx.nurLesen, [
h('div', { class: 'tm-raster' },
feld(`${p}.name`, 'Name', name, 'So steht es beim Buchen, wenn Gäste eine Person wählen dürfen.'),
feld(`${p}.typ`, 'Art', typ)),
hakenFeld(`${p}.aktiv`, aktiv, 'Aktiv', 'Nur aktive Personen und Plätze können gebucht werden. Termine, die schon gebucht sind, bleiben bei „Inaktiv“ bestehen.'),
h('fieldset', { class: 'tm-gruppe-felder tm-wochenplan' },
h('legend', { class: 'tm-label' }, 'Wochenplan'),
h('p', { class: 'tm-hinweis' }, 'Wann kann gebucht werden? Mehrere Zeitfenster trennst du mit Komma, zum Beispiel 09:00–12:30, 13:30–18:00. Leer lassen: an diesem Tag geschlossen. Uhrzeiten in 5-Minuten-Schritten, das Ende bis 24:00.'),
h('div', { class: 'tm-raster' }, tage.map(({ name: feldName, input, tag }) => feld(feldName, tag, input)))),
h('fieldset', { class: 'tm-gruppe-felder', tabindex: '-1' },
h('legend', { class: 'tm-label' }, 'Diese Leistungen bietet sie an'),
zuordnung.length
? zuordnung.map(({ input, name: feldName, label }) => hakenFeld(feldName, input, label))
: h('p', { class: 'tm-hinweis' }, 'Es gibt noch keine Leistung. Lege sie im Reiter „Leistungen“ an und ordne sie danach hier zu.')),
]),
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))
form.addEventListener('submit', async (ev) => {
ev.preventDefault()
const ok = await sp.speichere((entwurf) => {
const gelesen = planAusTexten(Object.fromEntries(tage.map(({ nr, input }) => [nr, input.value])))
if (gelesen.fehler) {
zeigeFehler(form, { fehler: Object.fromEntries(Object.entries(gelesen.fehler).map(([nr, text]) => [`${p}.plan.${nr}`, text])) })
return false
}
const ressource = { ...alt, name: name.value, typ: typ.value, aktiv: aktiv.checked, plan: gelesen.plan }
if (neu) entwurf.ressourcen.push(ressource)
else entwurf.ressourcen[index] = ressource
// die Zuordnung steht bei den Leistungen: genau diese Person/diesen Platz bei jeder Leistung ein- oder austragen
for (const { id, input } of zuordnung) {
const leistung = entwurf.leistungen.find((l) => l.id === id)
if (!leistung) continue
const ids = (leistung.ressourcen || []).filter((x) => x !== alt.id)
leistung.ressourcen = input.checked ? [...ids, alt.id] : ids
}
return true
}, { container: form, knopf: speichern })
if (!ok || !sp.nochHier()) return
zeichne()
ctx.meldung(neu ? `„${name.value.trim()}“ ist hinzugefügt.` : 'Gespeichert.')
t.wurzel.querySelector(`li[data-id="${alt.id}"] [data-aktion="bearbeiten"]`)?.focus()
})
function fragen() {
const box = entfernenFrage(sp, {
titel: `„${alt.name}“ entfernen?`,
text: 'Die Zeiten, die Zuordnung zu den Leistungen und die Abwesenheiten gehen mit verloren. Gibt es noch künftige Termine, geht das Entfernen nicht. Dann setze sie auf „Inaktiv“.',
fehlerPfad: `ressourcen.${alt.id}`,
aendere: (entwurf) => {
entwurf.ressourcen = entwurf.ressourcen.filter((x) => x.id !== alt.id)
for (const l of entwurf.leistungen) l.ressourcen = (l.ressourcen || []).filter((id) => id !== alt.id)
entwurf.abwesenheiten = (entwurf.abwesenheiten || []).filter((a) => a.ressourceId !== alt.id)
},
nachher: () => { zeichne(); ctx.meldung(`„${alt.name}“ entfernt.`) },
zurueck: () => { frageBox.replaceChildren(); entfernen.focus() },
})
frageBox.replaceChildren(box)
box.focus()
}
return h('div', { class: 'tm-editor' }, form, neu || ctx.nurLesen ? null : kalenderLink(t, alt), frageBox)
}
/* ---------------------------------------------------------- Kalender-Abo --- */
/** Kalender-Link einer gespeicherten Person/eines Platzes: anzeigen, kopieren, neu erzeugen (der Server vergibt das Token). */
function kalenderLink(t, r) {
const { ctx } = t
const url = ctx.daten.feeds?.[r.id]
const kopf = [
h('h4', null, 'Kalender-Abo'),
h('p', { class: 'tm-hinweis' }, 'Mit diesem Link zeigt dein Kalender (Google, Apple, Outlook) die bestätigten Termine automatisch an. Füge ihn dort als Kalender-Abo ein („Kalender abonnieren“ oder „Per URL hinzufügen“), nicht als Datei. Google aktualisiert abonnierte Kalender nur alle paar Stunden, neue Termine erscheinen dort deshalb mit Verspätung.'),
]
if (!url) return h('div', { class: 'tm-feed' }, kopf, h('p', { class: 'tm-hinweis' }, 'Der Link erscheint, sobald die Seite neu geladen ist.'))
const eingabe = h('input', { type: 'text', readonly: true, class: 'tm-link-feld', value: url, 'aria-label': `Kalender-Link für ${r.name}`, spellcheck: 'false' })
eingabe.addEventListener('focus', () => eingabe.select())
const status = h('p', { class: 'tm-hinweis', role: 'status' })
const frageBox = h('div', { class: 'tm-frage' })
async function kopiere() {
try {
await navigator.clipboard.writeText(eingabe.value)
status.textContent = 'Der Link ist kopiert.'
} catch {
// kein Zugriff auf die Zwischenablage (Browser, Einstellung): der Link bleibt sichtbar und markiert zum Kopieren von Hand
eingabe.focus()
eingabe.select()
status.textContent = 'Das Kopieren hat nicht geklappt. Der Link ist markiert: drücke Strg+C (Mac: Cmd+C), am Handy halte ihn gedrückt und wähle „Kopieren“.'
}
}
function fragen(ausloeser) {
const { ok, zeile } = knopfZeile('Neuen Link erzeugen', () => { frageBox.replaceChildren(); ausloeser.focus() })
const box = frage('Neuen Kalender-Link erzeugen?',
h('p', null, 'Der bisherige Link funktioniert danach nicht mehr. Kalender, die ihn abonniert haben, zeigen keine neuen Termine mehr, bis du dort den neuen Link einträgst. Tu das, wenn der Link in falsche Hände geraten ist.'),
allgemeinerFehler(),
zeile)
box.addEventListener('submit', async () => {
ok.disabled = true
try {
const antwort = await ctx.api.send('POST', `/ressourcen/${encodeURIComponent(r.id)}/feed`)
if (!ctx.aktuell() || !eingabe.isConnected) return
ctx.daten.version = antwort.version // das Erzeugen zaehlt die Version hoch: das naechste Speichern baut darauf auf
ctx.daten.feeds = { ...ctx.daten.feeds, [r.id]: antwort.url }
eingabe.value = antwort.url
frageBox.replaceChildren()
status.textContent = 'Der neue Link ist da. Der alte funktioniert nicht mehr.'
ctx.meldung(`Neuer Kalender-Link für „${r.name}“ erzeugt.`)
ausloeser.focus()
} catch (err) {
if (!ctx.aktuell() || !eingabe.isConnected) return
ok.disabled = false
zeigeFehler(box, err)
}
})
frageBox.replaceChildren(box)
box.focus()
}
const neuKnopf = h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'link-neu', onclick: (ev) => fragen(ev.currentTarget) }, 'Neuen Link erzeugen')
return h('div', { class: 'tm-feed' },
kopf,
h('p', { class: 'tm-hinweis' }, 'Der Link ist geheim: Wer ihn kennt, sieht die bestätigten Termine mit den Namen der Gäste. Gib ihn nicht weiter.'),
eingabe,
h('div', { class: 'tm-frage-knoepfe' }, h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'link-kopieren', onclick: kopiere }, 'Link kopieren'), neuKnopf),
status,
frageBox)
}
+2 -2
View File
@@ -92,8 +92,8 @@ async function ladeListe(t, { auchZahl = false } = {}) {
const { s } = ctx
const nr = ++t.nr
const anfragen = t.auswahl.ansicht === 'anfragen'
// Offene Anfragen: alle bis zum Ende des Buchungszeitraums; die Schnittstelle liefert hoechstens 62 Tage je Aufruf, darum mehrere Fenster
const { von, bis } = bereichFuer(t.auswahl.ansicht, s.tag, heuteBerlin(), ctx.daten.einstellung?.regeln?.horizontTage)
// Offene Anfragen: ab heute 365 Tage (Ruling R50); die Schnittstelle liefert hoechstens 62 Tage je Aufruf, darum mehrere Fenster
const { von, bis } = bereichFuer(t.auswahl.ansicht, s.tag, heuteBerlin())
const fenster = anfragen ? fensterAufteilen(von, bis) : [{ von, bis }]
const status = anfragen ? 'angefragt' : filterStatus(t.auswahl.filter)
t.liste.setAttribute('aria-busy', 'true')
+249
View File
@@ -0,0 +1,249 @@
/* Terminplaner im Kundenbereich: Reiter "Team & Plaetze", die Abschnitte fuer Ausnahmen vom Wochenplan – Abwesenheiten und Urlaub,
Feiertage (Bundesland) und Sonderoeffnungen. Jeder Abschnitt ist eine Funktion (t) => Element; t = { ctx, sp, zeichne, wurzel } kommt
aus termine-tab-team.js (sp = Speicher des Reiters, zeichne() zeichnet den Reiter neu). Jede Aenderung speichert die ganze Einstellung
(termine-speichern.js). Abgeschaltete Funktion (ctx.nurLesen): dieselben Felder gesperrt, ohne Knoepfe zum Aendern. */
import { BUNDESLAENDER, FENSTER_FEHLER, LIMITS, MAX, datumKurz, datumLang, eindeutigeId, fensterParsen, fensterText, heuteBerlin, istTag } from './termine-view.js'
import { abschnitt, allgemeinerFehler, feld, gesperrt, h, hakenFeld, zeigeFehler } from './termine-dom.js'
import { entfernenFrage } from './termine-speichern.js'
const nachBeginn = (x, y) => (x.von < y.von ? -1 : x.von > y.von ? 1 : x.bis < y.bis ? -1 : x.bis > y.bis ? 1 : 0)
/* ------------------------------------------------- Abwesenheiten, Urlaub --- */
export function abwesenheiten(t) {
const { ctx } = t
const e = ctx.daten.einstellung
const heute = heuteBerlin()
const alle = (e.abwesenheiten || []).map((a, index) => ({ ...a, index }))
const kommend = alle.filter((a) => a.bis >= heute).sort(nachBeginn)
const vorbei = alle.filter((a) => a.bis < heute).sort(nachBeginn).reverse()
const neuBox = h('div', { class: 'tm-formular-box' })
const voll = alle.length >= LIMITS.abwesenheiten
const neuKnopf = ctx.nurLesen ? null : h('button', {
type: 'button', class: 'kb-btn-outline', 'data-werkzeug': 'abwesenheit-neu', 'aria-expanded': 'false', disabled: voll,
onclick: () => {
if (neuBox.firstChild) { neuBox.replaceChildren(); neuKnopf.setAttribute('aria-expanded', 'false'); return }
neuKnopf.setAttribute('aria-expanded', 'true')
const form = formular(t, { index: alle.length, a: null, schliessen: () => { neuBox.replaceChildren(); neuKnopf.setAttribute('aria-expanded', 'false'); neuKnopf.focus() } })
neuBox.replaceChildren(form)
form.querySelector('select, input')?.focus()
},
}, '+ Abwesenheit eintragen')
const wer = (a) => (a.ressourceId ? (e.ressourcen || []).find((r) => r.id === a.ressourceId)?.name || 'Unbekannt' : 'Ganzer Betrieb')
const reihe = (a) => {
const li = h('li', { class: 'tm-eintrag', 'data-id': a.id })
const editor = h('div', { class: 'tm-editor-box' })
const oeffnen = h('button', {
type: 'button', class: 'kb-btn-outline tm-knopf', 'data-aktion': 'bearbeiten', 'aria-expanded': 'false',
'aria-label': `${ctx.nurLesen ? 'Ansehen' : 'Bearbeiten'}: ${a.titel || 'Abwesenheit'}, ${datumKurz(a.von)}`,
onclick: () => {
if (editor.firstChild) { editor.replaceChildren(); oeffnen.setAttribute('aria-expanded', 'false'); return }
oeffnen.setAttribute('aria-expanded', 'true')
const form = formular(t, { index: a.index, a, schliessen: () => { editor.replaceChildren(); oeffnen.setAttribute('aria-expanded', 'false'); oeffnen.focus() } })
editor.replaceChildren(form)
form.querySelector('select, input')?.focus()
},
}, ctx.nurLesen ? 'Ansehen' : 'Bearbeiten')
li.append(
h('div', { class: 'tm-kopf' },
h('div', { class: 'tm-haupt' },
h('strong', { class: 'tm-name' }, a.von === a.bis ? datumKurz(a.von) : `${datumKurz(a.von)} – ${datumKurz(a.bis)}`),
h('span', { class: 'tm-meta' }, [wer(a), a.titel].filter(Boolean).join(' · ')))),
h('div', { class: 'tm-aktionen' }, oeffnen),
editor)
return li
}
return abschnitt('Abwesenheiten und Urlaub',
'In dieser Zeit kann niemand neu buchen, für eine Person, einen Platz oder den ganzen Betrieb. Termine, die schon gebucht sind, bleiben bestehen. Sag sie bei Bedarf selbst ab.',
ctx.nurLesen ? null : h('div', { class: 'tm-werkzeug-zeile' }, neuKnopf, voll ? h('p', { class: 'tm-hinweis' }, `Mehr als ${LIMITS.abwesenheiten} Abwesenheiten sind nicht möglich.`) : null),
neuBox,
kommend.length ? h('ul', { class: 'tm-eintraege', 'aria-label': 'Abwesenheiten' }, kommend.map(reihe)) : h('p', { class: 'tm-leer' }, 'Keine Abwesenheiten eingetragen.'),
vorbei.length
? h('details', { class: 'tm-details' }, h('summary', null, `Vergangene Abwesenheiten (${vorbei.length})`), h('ul', { class: 'tm-eintraege' }, vorbei.map(reihe)))
: null)
}
function formular(t, { index, a, schliessen }) {
const { ctx, sp, zeichne } = t
const e = ctx.daten.einstellung
const neu = a === null
const alt = a || { id: eindeutigeId('a', (e.abwesenheiten || []).map((x) => x.id)), ressourceId: null, von: heuteBerlin(), bis: heuteBerlin(), titel: '' }
const p = `abwesenheiten.${index}`
const wer = h('select', null,
h('option', { value: '', selected: !alt.ressourceId }, 'Ganzer Betrieb'),
(e.ressourcen || []).map((r) => h('option', { value: r.id, selected: alt.ressourceId === r.id }, r.name)))
const von = h('input', { type: 'date', value: alt.von })
const bis = h('input', { type: 'date', value: alt.bis })
const titel = h('input', { type: 'text', maxlength: MAX.abwesenheitTitel, autocomplete: 'off', placeholder: 'z. B. Betriebsurlaub', value: alt.titel })
const speichern = h('button', { type: 'submit', class: 'kb-btn-primary' }, neu ? 'Abwesenheit eintragen' : 'Speichern')
const frageBox = h('div', { class: 'tm-frage' })
const entfernen = neu || ctx.nurLesen ? null : h('button', { type: 'button', class: 'kb-btn-outline tm-gefahr', onclick: () => fragen() }, 'Entfernen')
const form = h('form', { class: 'kb-panel tm-formular', novalidate: true, 'aria-label': neu ? 'Abwesenheit eintragen' : 'Abwesenheit bearbeiten' },
h('h3', null, neu ? 'Neue Abwesenheit' : 'Abwesenheit'),
gesperrt(ctx.nurLesen, [
feld(`${p}.ressourceId`, 'Wer ist weg?', wer),
h('div', { class: 'tm-raster' }, feld(`${p}.von`, 'Von (erster Tag)', von), feld(`${p}.bis`, 'Bis (letzter Tag)', bis)),
feld(`${p}.titel`, 'Titel (optional)', titel, 'Nur für dich sichtbar.'),
]),
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))
form.addEventListener('submit', async (ev) => {
ev.preventDefault()
const ok = await sp.speichere((entwurf) => {
const eintrag = { ...alt, ressourceId: wer.value || null, von: von.value, bis: bis.value, titel: titel.value }
if (neu) entwurf.abwesenheiten = [...(entwurf.abwesenheiten || []), eintrag]
else entwurf.abwesenheiten[index] = eintrag
}, { container: form, knopf: speichern })
if (!ok || !sp.nochHier()) return
zeichne()
ctx.meldung(neu ? 'Abwesenheit eingetragen.' : 'Abwesenheit gespeichert.')
})
function fragen() {
const box = entfernenFrage(sp, {
titel: 'Abwesenheit entfernen?',
text: 'In dieser Zeit kann danach wieder gebucht werden.',
aendere: (entwurf) => { entwurf.abwesenheiten = entwurf.abwesenheiten.filter((x) => x.id !== alt.id) },
nachher: () => { zeichne(); ctx.meldung('Abwesenheit entfernt.') },
zurueck: () => { frageBox.replaceChildren(); entfernen.focus() },
})
frageBox.replaceChildren(box)
box.focus()
}
return h('div', { class: 'tm-editor' }, form, frageBox)
}
/* -------------------------------------------------------------- Feiertage --- */
export function feiertage(t) {
const { ctx, sp, zeichne } = t
const e = ctx.daten.einstellung
const land = h('select', null, BUNDESLAENDER.map(([key, name]) => h('option', { value: key, selected: e.bundesland === key }, name)))
const zu = h('input', { type: 'checkbox', checked: e.feiertageGeschlossen !== false })
const speichern = h('button', { type: 'submit', class: 'kb-btn-primary' }, 'Speichern')
const form = h('form', { novalidate: true, 'aria-label': 'Feiertage' },
gesperrt(ctx.nurLesen, [
feld('bundesland', 'Bundesland', land, 'Danach richten sich die gesetzlichen Feiertage.'),
hakenFeld('feiertageGeschlossen', zu, 'An gesetzlichen Feiertagen geschlossen', 'Dann kann an Feiertagen niemand buchen. Eine Sonderöffnung (unten) geht vor.'),
]),
allgemeinerFehler(),
ctx.nurLesen ? null : h('div', { class: 'tm-frage-knoepfe' }, speichern))
form.addEventListener('submit', async (ev) => {
ev.preventDefault()
const ok = await sp.speichere((entwurf) => { entwurf.bundesland = land.value; entwurf.feiertageGeschlossen = zu.checked }, { container: form, knopf: speichern })
if (!ok || !sp.nochHier()) return
zeichne()
ctx.meldung('Feiertage gespeichert.')
})
return abschnitt('Feiertage', null, form)
}
/* ------------------------------------------------------- Sonderöffnungen --- */
export function sonderoeffnungen(t) {
const { ctx, sp, zeichne } = t
const e = ctx.daten.einstellung
const tage = Object.keys(e.sonderoeffnungen || {}).sort()
const neuBox = h('div', { class: 'tm-formular-box' })
const voll = tage.length >= LIMITS.sonderoeffnungen
const neuKnopf = ctx.nurLesen ? null : h('button', {
type: 'button', class: 'kb-btn-outline', 'data-werkzeug': 'sonder-neu', 'aria-expanded': 'false', disabled: voll,
onclick: () => {
if (neuBox.firstChild) { neuBox.replaceChildren(); neuKnopf.setAttribute('aria-expanded', 'false'); return }
neuKnopf.setAttribute('aria-expanded', 'true')
const form = sonderFormular(t, { tag: null, schliessen: () => { neuBox.replaceChildren(); neuKnopf.setAttribute('aria-expanded', 'false'); neuKnopf.focus() } })
neuBox.replaceChildren(form)
form.querySelector('input')?.focus()
},
}, '+ Sonderöffnung eintragen')
const reihe = (tag) => {
const li = h('li', { class: 'tm-eintrag', 'data-tag': tag })
const editor = h('div', { class: 'tm-editor-box' })
const zeiten = fensterText(e.sonderoeffnungen[tag])
const oeffnen = h('button', {
type: 'button', class: 'kb-btn-outline tm-knopf', 'data-aktion': 'bearbeiten', 'aria-expanded': 'false',
'aria-label': `${ctx.nurLesen ? 'Ansehen' : 'Bearbeiten'}: Sonderöffnung ${datumKurz(tag)}`,
onclick: () => {
if (editor.firstChild) { editor.replaceChildren(); oeffnen.setAttribute('aria-expanded', 'false'); return }
oeffnen.setAttribute('aria-expanded', 'true')
const form = sonderFormular(t, { tag, schliessen: () => { editor.replaceChildren(); oeffnen.setAttribute('aria-expanded', 'false'); oeffnen.focus() } })
editor.replaceChildren(form)
form.querySelector('input')?.focus()
},
}, ctx.nurLesen ? 'Ansehen' : 'Bearbeiten')
li.append(
h('div', { class: 'tm-kopf' }, h('div', { class: 'tm-haupt' },
h('strong', { class: 'tm-name' }, datumLang(tag)),
h('span', { class: 'tm-meta' }, zeiten || 'Geschlossen'))),
h('div', { class: 'tm-aktionen' }, oeffnen),
editor)
return li
}
return abschnitt('Sonderöffnungen',
'Für einzelne Tage, an denen es anders ist als im Wochenplan: Heiligabend nur bis 13 Uhr, ein Sonntag mit Öffnung oder ein Brückentag mit geschlossener Tür. Das gilt für alle Personen und Plätze und gehen dem Wochenplan und den Feiertagen vor.',
ctx.nurLesen ? null : h('div', { class: 'tm-werkzeug-zeile' }, neuKnopf, voll ? h('p', { class: 'tm-hinweis' }, `Mehr als ${LIMITS.sonderoeffnungen} Sonderöffnungen sind nicht möglich.`) : null),
neuBox,
tage.length ? h('ul', { class: 'tm-eintraege', 'aria-label': 'Sonderöffnungen' }, tage.map(reihe)) : h('p', { class: 'tm-leer' }, 'Keine Sonderöffnungen eingetragen.'))
}
/** Formular fuer einen neuen (tag null: mit Datum) oder bestehenden Tag (Datum steht fest; wer den Tag aendern will, entfernt ihn und legt einen neuen an). */
function sonderFormular(t, { tag, schliessen }) {
const { ctx, sp, zeichne } = t
const e = ctx.daten.einstellung
const neu = tag === null
const datum = h('input', { type: 'date', value: heuteBerlin() })
const zeiten = h('input', { type: 'text', autocomplete: 'off', placeholder: '09:00–13:00', value: neu ? '' : fensterText(e.sonderoeffnungen[tag]) })
const speichern = h('button', { type: 'submit', class: 'kb-btn-primary' }, neu ? 'Sonderöffnung eintragen' : 'Speichern')
const frageBox = h('div', { class: 'tm-frage' })
const entfernen = neu || ctx.nurLesen ? null : h('button', { type: 'button', class: 'kb-btn-outline tm-gefahr', onclick: () => fragen() }, 'Entfernen')
const datumFeld = feld('datum', 'Tag', datum)
const zeitenFeld = feld('zeiten', 'Geöffnet von – bis', zeiten, 'Mehrere Zeitfenster mit Komma trennen: 09:00–12:00, 14:00–18:00. Leer lassen: an diesem Tag geschlossen.')
const zeitenFehler = zeitenFeld.querySelector('[data-fehler]')
// Fehler des Servers heissen 'sonderoeffnungen.<Tag>': beim Absenden nimmt das Feld der Zeiten diesen Namen an
const zeitenName = (name) => { zeitenFehler.dataset.fehler = name; zeiten.dataset.feld = name }
const form = h('form', { class: 'kb-panel tm-formular', novalidate: true, 'aria-label': neu ? 'Sonderöffnung eintragen' : `Sonderöffnung ${datumKurz(tag)}` },
h('h3', null, neu ? 'Neue Sonderöffnung' : datumLang(tag)),
gesperrt(ctx.nurLesen, [neu ? datumFeld : null, zeitenFeld]),
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))
form.addEventListener('submit', async (ev) => {
ev.preventDefault()
const ok = await sp.speichere((entwurf) => {
const tagWert = neu ? datum.value : tag
zeitenName('zeiten')
const fehler = {}
if (!istTag(tagWert)) fehler.datum = 'Bitte wähle einen Tag.'
else if (neu && Object.hasOwn(entwurf.sonderoeffnungen || {}, tagWert)) fehler.datum = 'Für diesen Tag gibt es schon eine Sonderöffnung. Bearbeite sie in der Liste.'
const fenster = fensterParsen(zeiten.value)
if (fenster === null) fehler.zeiten = FENSTER_FEHLER
if (Object.keys(fehler).length) {
zeigeFehler(form, { fehler })
return false
}
zeitenName(`sonderoeffnungen.${tagWert}`)
entwurf.sonderoeffnungen = { ...(entwurf.sonderoeffnungen || {}), [tagWert]: fenster }
return true
}, { container: form, knopf: speichern })
if (!ok || !sp.nochHier()) return
zeichne()
ctx.meldung(neu ? 'Sonderöffnung eingetragen.' : 'Sonderöffnung gespeichert.')
})
function fragen() {
const box = entfernenFrage(sp, {
titel: `Sonderöffnung am ${datumKurz(tag)} entfernen?`,
text: 'Danach gilt an diesem Tag wieder der Wochenplan (und der Feiertag, falls einer ist).',
aendere: (entwurf) => { const rest = { ...entwurf.sonderoeffnungen }; delete rest[tag]; entwurf.sonderoeffnungen = rest },
nachher: () => { zeichne(); ctx.meldung('Sonderöffnung entfernt.') },
zurueck: () => { frageBox.replaceChildren(); entfernen.focus() },
})
frageBox.replaceChildren(box)
box.focus()
}
return h('div', { class: 'tm-editor' }, form, frageBox)
}
+176 -10
View File
@@ -10,8 +10,16 @@ const TAG_MS = 24 * 60 * 60 * 1000
const MAX_TAGE = 62
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 }
/**
* Laengen der Eingaben (maxlength der Felder): name/nachricht/titel/telefon fuer Gaeste und Sperren (ablauf.js, ablaufBetrieb.js),
* der Rest fuer die Einstellungen (einstellungen.js kuerzt laengere Texte stillschweigend; url ist eine Obergrenze mit Fehler).
* telefon gilt auch fuer das Telefon des Betriebs. test/termineView.test.js vergleicht sie mit dem Server.
*/
export const MAX = {
name: 100, telefon: 40, nachricht: 500, titel: 120,
betriebName: 80, adresse: 200, url: 300, leistungName: 100, beschreibung: 300, ressourcenName: 80, abwesenheitTitel: 80,
zusatzLabel: 60, option: 60, storno: 2000,
}
/* ------------------------------------------------------------ Kalender --- */
function utc(tag) {
@@ -40,17 +48,14 @@ export function monatVon(tag) {
}
/**
* Zeitraum der Liste. ansicht: 'tag' | 'woche' | 'monat' | 'anfragen' (ab heute bis zum Ende des Buchungszeitraums horizontTage
* der Einstellung, Vorgabe 60, hoechstens 365). Fuer 'anfragen' ist der Zeitraum laenger als ein Aufruf der Schnittstelle:
* fensterAufteilen macht daraus die einzelnen Abfragen.
* Zeitraum der Liste. ansicht: 'tag' | 'woche' | 'monat' | 'anfragen' (ab heute 365 Tage, unabhaengig vom Buchungszeitraum der
* Einstellung: auch eine Anfrage, die vor einer spaeteren Verkuerzung des Zeitraums kam, soll in der Liste stehen). Fuer 'anfragen'
* ist der Zeitraum laenger als ein Aufruf der Schnittstelle: fensterAufteilen macht daraus die einzelnen Abfragen (hoechstens 6).
*/
export function bereichFuer(ansicht, tag, heute, horizontTage) {
export function bereichFuer(ansicht, tag, heute) {
if (ansicht === 'woche') return wocheVon(tag)
if (ansicht === 'monat') return monatVon(tag)
if (ansicht === 'anfragen') {
const horizont = Number.isInteger(horizontTage) && horizontTage >= 1 ? Math.min(horizontTage, 365) : 60
return { von: heute, bis: tagPlus(heute, horizont) }
}
if (ansicht === 'anfragen') return { von: heute, bis: tagPlus(heute, 365) }
return { von: tag, bis: tag }
}
@@ -279,3 +284,164 @@ export function moeglicheAktionen(buchung, jetzt) {
if (b.status !== 'gehalten' && hatGastdaten(b)) aktionen.push('anonymisieren')
return aktionen
}
/* ------------------------------------------- Einstellungen bearbeiten (21b) --- */
/** Hinweis ueber Namensfeldern, die Gaeste lesen (Wortlaut GESUNDHEIT_HINWEIS in server/services/termine/rechtstexte.js). */
export const GESUNDHEIT_HINWEIS = 'Bitte keine Gesundheitsangaben.'
/** Obergrenzen der Listen (GRENZEN in einstellungen.js); der Server prueft sie noch einmal. */
export const LIMITS = { ressourcen: 20, leistungen: 50, abwesenheiten: 100, sonderoeffnungen: 200, zusatzfelder: 5, optionen: 10 }
export const RASTER_MIN = [5, 10, 15, 20, 30, 60]
export const ERINNERUNG_STUNDEN = [2, 12, 24, 48]
export const GAST_FELD_WAHL = [['aus', 'Aus'], ['optional', 'Optional'], ['pflicht', 'Pflicht']]
export const ZAHLARTEN = [['bar', 'bar'], ['ec', 'EC-Karte'], ['kreditkarte', 'Kreditkarte'], ['ueberweisung', 'Überweisung'], ['paypal', 'PayPal']]
export const BUNDESLAENDER = [
['BW', 'Baden-Württemberg'], ['BY', 'Bayern'], ['BE', 'Berlin'], ['BB', 'Brandenburg'], ['HB', 'Bremen'], ['HH', 'Hamburg'], ['HE', 'Hessen'],
['MV', 'Mecklenburg-Vorpommern'], ['NI', 'Niedersachsen'], ['NW', 'Nordrhein-Westfalen'], ['RP', 'Rheinland-Pfalz'], ['SL', 'Saarland'],
['SN', 'Sachsen'], ['ST', 'Sachsen-Anhalt'], ['SH', 'Schleswig-Holstein'], ['TH', 'Thüringen'],
]
/* ----------------------------------------------------------- Wochenplan --- */
/** ISO-Wochentage wie in plan der Einstellung: '1' = Montag … '7' = Sonntag. */
export const WOCHENTAGE_PLAN = [['1', 'Montag'], ['2', 'Dienstag'], ['3', 'Mittwoch'], ['4', 'Donnerstag'], ['5', 'Freitag'], ['6', 'Samstag'], ['7', 'Sonntag']]
export const FENSTER_FEHLER = 'Bitte so schreiben: 09:00–12:30, 13:30–18:00 (5-Minuten-Schritte, aufsteigend, ohne Überlappung, Ende bis 24:00).'
/** plan { '1': [['09:00','12:30']], … } ⇒ Text je Wochentag { '1': '09:00–12:30', …, '7': '' } (leer = geschlossen). */
export function planTexte(plan) {
return Object.fromEntries(WOCHENTAGE_PLAN.map(([nr]) => [nr, fensterText(plan?.[nr])]))
}
/** Umkehrung: Texte je Wochentag ⇒ { plan } (nur Tage mit Zeiten) oder { fehler: { '3': FENSTER_FEHLER } } fuer Tage, die fensterParsen ablehnt. */
export function planAusTexten(texte) {
const plan = {}
const fehler = {}
for (const [nr] of WOCHENTAGE_PLAN) {
const fenster = fensterParsen(texte?.[nr] ?? '')
if (fenster === null) fehler[nr] = FENSTER_FEHLER
else if (fenster.length) plan[nr] = fenster
}
return Object.keys(fehler).length ? { fehler } : { plan }
}
/* ------------------------------------------------------------------ IDs --- */
const ID_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789'
const zufallsBytes = (n) => crypto.getRandomValues(new Uint8Array(n))
/**
* Neue ID einer Person/eines Platzes ('r'), einer Leistung ('s') oder einer Abwesenheit ('a'): Praefix_ + 8 Zeichen a-z0-9 (Ruling R8,
* dieselbe Form wie neueId in einstellungen.js; so bleibt ein Speichern atomar, auch wenn neue Eintraege aufeinander verweisen).
* bytes(n) liefert n Zufallsbytes (Vorgabe crypto.getRandomValues; nur fuer Tests austauschbar). Bytes ab 252 werden verworfen
* (252 = 7 * 36): so ist jedes Zeichen gleich wahrscheinlich.
*/
export function neueId(praefix, bytes = zufallsBytes) {
if (!['r', 's', 'a'].includes(praefix)) throw new Error(`Unbekanntes ID-Präfix: ${praefix}`)
let zeichen = ''
while (zeichen.length < 8) {
for (const byte of bytes(16)) {
if (byte < 252 && zeichen.length < 8) zeichen += ID_ALPHABET[byte % ID_ALPHABET.length]
}
}
return `${praefix}_${zeichen}`
}
/** Neue ID, die in vorhandene (Liste oder Set von IDs) nicht vorkommt. */
export function eindeutigeId(praefix, vorhandene, bytes) {
const belegt = new Set(vorhandene)
let id
do { id = neueId(praefix, bytes) } while (belegt.has(id))
return id
}
/* ----------------------------------------------------------- Zahlen, Preis --- */
/** Ganze Zahl aus einem Feld ('45', ' 45 '), sonst null (leer, Komma, Buchstaben, mehr als 9 Stellen). */
export function ganzzahl(text) {
return typeof text === 'string' && /^\s*-?\d{1,9}\s*$/.test(text) ? Number(text) : null
}
/**
* Euro aus einem Feld in Cent. Deutsche Schreibweise: ',' trennt die Cent, '.' die Tausender ('1.234,50'); '45.50' und '12.5'
* gelten als Euro und Cent. null bei allem anderen (leer, Buchstaben, mehr als zwei Nachkommastellen, kein Betrag vor dem Komma).
* Die Grenze (0 bis 10.000 Euro) prueft der Server.
*/
export function euroZuCent(text) {
if (typeof text !== 'string') return null
const s = text.replace(/€/g, '').trim()
if (!/^[\d.,]+$/.test(s)) return null
let ganz = s
let rest = ''
const komma = s.indexOf(',')
if (komma >= 0) {
ganz = s.slice(0, komma)
rest = s.slice(komma + 1)
if (/[.,]/.test(rest)) return null
if (ganz.includes('.') && !/^\d{1,3}(\.\d{3})+$/.test(ganz)) return null
ganz = ganz.replaceAll('.', '')
} else if (/^\d{1,3}(\.\d{3})+$/.test(s)) {
ganz = s.replaceAll('.', '')
} else if (s.includes('.')) {
const teile = s.split('.')
if (teile.length !== 2) return null
;[ganz, rest] = teile
}
if (!/^\d{1,9}$/.test(ganz) || !/^\d{0,2}$/.test(rest)) return null
return Number(ganz) * 100 + Number(rest.padEnd(2, '0'))
}
/** 4500 ⇒ '45,00' (Feldwert); unbrauchbar ⇒ ''. */
export function centZuEuro(cent) {
return Number.isInteger(cent) && cent >= 0 ? `${Math.floor(cent / 100)},${String(cent % 100).padStart(2, '0')}` : ''
}
/** '45,00 €' | 'ab 45,00 €' | 'ohne Preis' (wie preisText in einstellungen.js, dort '' statt „ohne Preis“). */
export function preisAnzeige(leistung) {
const l = leistung || {}
if (l.preisArt === 'keiner' || !Number.isInteger(l.preisCent) || l.preisCent <= 0) return 'ohne Preis'
const euro = String(Math.floor(l.preisCent / 100)).replace(/\B(?=(\d{3})+(?!\d))/g, '.')
return `${l.preisArt === 'ab' ? 'ab ' : ''}${euro},${String(l.preisCent % 100).padStart(2, '0')} €`
}
/* ------------------------------------------------------- Zusatzfelder, Listen --- */
const RESERVIERT = ['__proto__', 'constructor', 'prototype']
/**
* Schluessel eines Zusatzfelds aus der Beschriftung ('Größe in cm' ⇒ 'groesse_in_cm'): 1 bis 20 Zeichen a-z0-9_, nicht in vorhandene
* (Liste) und nicht reserviert; sonst wird _2, _3 … angehaengt. Der Kunde sieht den Schluessel nie.
*/
export function zusatzSchluessel(label, vorhandene) {
const belegt = new Set(vorhandene)
const grund = String(label ?? '').toLowerCase()
.replaceAll('ä', 'ae').replaceAll('ö', 'oe').replaceAll('ü', 'ue').replaceAll('ß', 'ss')
.normalize('NFKD').replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 20).replace(/_+$/, '') || 'feld'
let key = grund
for (let n = 2; belegt.has(key) || RESERVIERT.includes(key); n += 1) {
const endung = `_${n}`
key = grund.slice(0, 20 - endung.length) + endung
}
return key
}
/** Auswahlmoeglichkeiten eines Zusatzfelds: eine je Zeile ⇔ Liste. */
export function optionenAusText(text) {
return typeof text === 'string' ? text.split(/\r?\n/).map((zeile) => zeile.trim()).filter(Boolean) : []
}
export function optionenText(optionen) {
return Array.isArray(optionen) ? optionen.join('\n') : ''
}
/** Kopie der Liste, in der der Eintrag an Stelle index um richtung (-1 hoch, +1 runter) verschoben ist; ausserhalb: unveraendert. */
export function verschiebeEintrag(liste, index, richtung) {
const kopie = [...liste]
const ziel = index + richtung
if (!Number.isInteger(index) || index < 0 || index >= kopie.length || ziel < 0 || ziel >= kopie.length) return kopie
;[kopie[index], kopie[ziel]] = [kopie[ziel], kopie[index]]
return kopie
}
/** 'Aktiv seit 01.10.2026' aus aktivSeit (ISO-Zeit) nach Berliner Kalender; '' ohne brauchbare Zeit. */
export function aktivSeitText(iso) {
const zeit = typeof iso === 'string' ? new Date(iso) : null
if (!zeit || Number.isNaN(zeit.getTime())) return ''
return `Aktiv seit ${datumKurz(heuteBerlin(zeit))}`
}
+59 -1
View File
@@ -103,7 +103,7 @@
/* ------------------------------------------------------------------ Formulare --- */
.tm-formular { margin-bottom: 16px; }
.tm-formular h3 { margin-bottom: 6px; }
.tm-raster { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 0 14px; }
.tm-raster { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 0 14px; align-items: start; }
.tm-feld { display: grid; gap: 6px; margin: 12px 0 0; min-width: 0; }
.tm-feld > label, .tm-label { font-size: 13px; font-weight: 600; }
.tm-feld input:not([type=checkbox]):not([type=radio]), .tm-feld select, .tm-feld textarea {
@@ -131,12 +131,66 @@
.tm-vorlage strong, .tm-vorlage small { display: block; }
.tm-vorlage small { margin-top: 3px; font-size: 13px; line-height: 1.45; color: var(--kb-muted); }
/* ----------------------------------- Leistungen, Team & Plaetze, Einstellungen --- */
.tm-tab > * + * { margin-top: 16px; }
.tm-tab > .tm-box { margin-bottom: 0; }
.tm-hinweise:empty { display: none; }
.tm-feld[hidden] { display: none; }
.tm-gruppe-felder { display: grid; gap: 0; min-width: 0; margin: 0; padding: 0; border: 0; }
.tm-gruppe-felder > legend { padding: 0; }
.tm-feld.tm-gruppe-felder { margin-top: 14px; }
.tm-abschnitt h3 { margin-bottom: 4px; }
.tm-abschnitt h4 { margin: 18px 0 4px; font-size: 14.5px; }
.tm-abschnitt-text { margin: 0 0 14px; line-height: 1.55; }
.tm-abschnitt .tm-box { margin: 0 0 14px; }
.tm-abschnitt .tm-werkzeug-zeile { align-items: center; margin-bottom: 12px; }
.tm-abschnitt .tm-eintraege { margin-top: 4px; }
.tm-kopf { display: flex; align-items: flex-start; justify-content: space-between; gap: 6px 14px; }
.tm-kopf .tm-pille { flex: none; }
.tm-eintrag .tm-meta + .tm-meta { margin-top: 0; }
.tm-editor-box:empty, .tm-frage:empty { display: none; }
.tm-editor { margin-top: 12px; }
.tm-eintrag .tm-formular { margin-bottom: 0; padding: 14px 0 0; border: 0; border-top: 1px solid var(--kb-border); border-radius: 0; background: transparent; }
.tm-eintrag .tm-formular h3 { display: none; }
.tm-feld-haken { margin-top: 4px; }
.tm-feld-haken .tm-haken { margin: 6px 0 0; }
.tm-haken > span { font-weight: 600; }
.tm-haken small { display: block; margin-top: 2px; font-weight: 400; }
.tm-abschnitt form > .tm-frage-knoepfe, .tm-tab > form > .tm-frage-knoepfe { margin-top: 16px; }
.tm-feld input[type=color] { height: 46px; padding: 4px; cursor: pointer; }
.tm-gefahr { color: #fca5a5; border-color: rgba(248, 113, 113, 0.45); }
.tm-gefahr:hover { background: rgba(248, 113, 113, 0.1); }
.tm-wochenplan .tm-raster { gap: 0 14px; }
.tm-feed { margin-top: 14px; padding: 14px 16px; border-radius: 12px; background: rgba(255, 255, 255, 0.03); border: 1px solid var(--kb-border); }
.tm-feed h4 { margin: 0 0 6px; font-size: 14.5px; }
.tm-feed .tm-hinweis { margin: 0 0 8px; line-height: 1.5; }
.tm-feed .tm-frage-knoepfe { margin: 10px 0 0; }
.tm-link-feld {
width: 100%; box-sizing: border-box; padding: 10px 12px; border-radius: 12px; border: 1px solid var(--kb-border-strong);
background: rgba(255, 255, 255, 0.05); color: inherit; font: inherit; font-size: 13px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; text-overflow: ellipsis;
}
.tm-muster { margin-top: 14px; padding: 14px 16px; border-radius: 12px; border: 1px dashed var(--kb-border-strong); }
.tm-muster > p { margin: 0 0 10px; font-size: 13.5px; line-height: 1.55; color: var(--kb-muted); }
.tm-muster-text { margin: 0 0 12px; padding: 0 0 0 12px; border-left: 3px solid var(--kb-border-strong); font-size: 13.5px; line-height: 1.6; color: var(--kb-muted); font-style: italic; }
.tm-zusatz { display: grid; gap: 12px; margin: 10px 0; }
.tm-zusatz:empty { display: none; }
.tm-zusatz-zeile { padding: 12px 14px 14px; border-radius: 12px; border: 1px solid var(--kb-border); }
.tm-zusatz-zeile .tm-gefahr { margin-top: 10px; }
.tm-optionen { display: grid; gap: 4px; margin: 4px 0 8px; }
.tm-optionen .tm-haken { margin: 6px 0; }
.tm-aus { opacity: 0.6; cursor: not-allowed; }
.tm-status { margin: 0; }
.tm-abschnitt details.tm-details { margin-top: 12px; }
/* ----------------------------------------------------------------- Handy --- */
@media (max-width: 880px), (pointer: coarse) {
#view-termine button:not(.tm-link-knopf), #view-termine .kb-btn-outline, #view-termine .kb-btn-primary { min-height: 44px; }
.tm-pfeil { width: 44px; height: 44px; }
.tm-link-knopf { padding: 11px 0; }
.tm-details summary { display: flex; align-items: center; min-height: 44px; padding: 0; }
.tm-haken { align-items: center; min-height: 44px; margin-top: 0; margin-bottom: 0; }
.tm-haken input { margin-top: 0; }
.tm-link-feld { min-height: 44px; font-size: 16px; } /* 16 px: iOS zoomt beim Antippen kleinerer Felder */
}
@media (max-width: 600px) {
.tm-reiterleiste { grid-template-columns: repeat(2, minmax(0, 1fr)); }
@@ -154,5 +208,9 @@
.tm-werkzeug-zeile > *, .tm-werkzeuge > * { flex: 1 1 calc(50% - 5px); }
.tm-werkzeuge > .kb-btn-primary { flex-basis: 100%; }
.tm-aktionen > * { flex: 1 1 auto; }
.tm-aktionen > .tm-pfeil { flex: 0 0 44px; }
.tm-kopf { flex-wrap: wrap; }
.tm-feed { padding: 12px; }
.tm-frage-knoepfe > * { flex: 1 1 auto; }
}
@media (prefers-reduced-motion: reduce) { #view-termine * { transition: none !important; animation: none !important; } }
+33 -24
View File
@@ -1,15 +1,19 @@
/* 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 die Meldung ueber den Reitern.
Die Reiter stehen in REITER: jeder ist ein Modul mit render(panel, ctx) => void | Promise (Termine: termine-tab-termine.js; Leistungen,
Team & Plaetze und Einstellungen (Aufgabe 21b) ersetzen dort nur ihren Platzhalter).
ctx = { s, projectId, daten, nurLesen, auswahl, aktuell(), api, meldung, zumReiter, neuLaden }: Zustand und Pruefung "bin ich noch
aktuell?" bekommen die Reiter nur ueber ctx, nie ueber Modulvariablen. daten ist die Antwort von GET /api/termine/einstellungen,
nurLesen gilt fuer abgeschaltete Funktionen. Server: server/routes/termine.js. Tage sind Berliner Kalendertage 'JJJJ-MM-TT';
Die Reiter stehen in REITER: jeder ist ein Modul mit render(panel, ctx) => void | Promise (termine-tab-termine.js, termine-tab-leistungen.js,
termine-tab-team.js, termine-tab-einstellungen.js; die drei letzten speichern ueber termine-speichern.js).
ctx = { s, projectId, daten, nurLesen, auswahl, aktuell(), api, meldung, hinweise, zumReiter, neuLaden }: Zustand und Pruefung "bin ich noch
aktuell?" bekommen die Reiter nur ueber ctx, nie ueber Modulvariablen. daten ist die Antwort von GET /api/termine/einstellungen
(die Reiter, die speichern, halten daten.einstellung und daten.version aktuell), nurLesen gilt fuer abgeschaltete Funktionen,
hinweise() zeichnet die Hinweisleiste ueber den Reitern neu (z. B. wenn Telefon und Anschrift nach dem Speichern nicht mehr fehlen). 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 { fehltText, heuteBerlin } from './termine-view.js'
import { exportUrl, fehlerKarte, h, infoKarte, request } from './termine-dom.js'
import { exportLink, fehlerKarte, h, infoKarte, request } from './termine-dom.js'
import { render as renderTermine } from './termine-tab-termine.js'
import { render as renderLeistungen } from './termine-tab-leistungen.js'
import { render as renderTeam } from './termine-tab-team.js'
import { render as renderEinstellungen } from './termine-tab-einstellungen.js'
// 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' }
@@ -61,7 +65,7 @@ const ladeDaten = (projectId) => request(`/api/termine/einstellungen?projectId=$
/** Hinweis fuer abgeschaltete Funktionen: nur noch ansehen und exportieren. */
const hinweisAbgeschaltet = (projectId) => 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 ',
h('a', { href: exportUrl(projectId), download: 'termine.csv' }, 'exportieren'),
exportLink(projectId, 'exportieren'),
'.')
/**
@@ -131,16 +135,12 @@ function zeigeVorlagen(s, { projekt, aktuell }) {
}
/* -------------------------------------------------------- Reiter-Gerüst --- */
const platzhalter = (titel, text) => (panel) => {
panel.replaceChildren(h('div', { class: 'kb-panel tm-info' }, h('h3', null, titel), h('p', null, text)))
}
/** key, Beschriftung, Funktion (panel, ctx). Aufgabe 21b ersetzt die Platzhalter, sonst bleibt alles wie es ist. */
/** key, Beschriftung, Funktion (panel, ctx). */
const REITER = [
{ key: 'termine', label: 'Termine', render: renderTermine },
{ key: 'leistungen', label: 'Leistungen', render: platzhalter('Leistungen', 'Hier legst du bald fest, was deine Kundschaft buchen kann – mit Dauer, Preis und Reihenfolge.') },
{ key: 'team', label: 'Team & Plätze', render: platzhalter('Team & Plätze', 'Hier pflegst du bald Personen und Plätze mit ihren Zeiten, Urlaub und Kalender-Links.') },
{ key: 'einstellungen', label: 'Einstellungen', render: platzhalter('Einstellungen', 'Hier ändern wir bald Betriebsangaben, Regeln und Texte für deine Gäste.') },
{ key: 'leistungen', label: 'Leistungen', render: renderLeistungen },
{ key: 'team', label: 'Team & Plätze', render: renderTeam },
{ key: 'einstellungen', label: 'Einstellungen', render: renderEinstellungen },
]
/**
@@ -177,26 +177,35 @@ function zeigeReiter(s, { projekt, aktuell }) {
? request(`/api/termine${pfad}?${new URLSearchParams({ projectId })}`, { method })
: request(`/api/termine${pfad}`, { method, body: { projectId, ...body } })),
},
meldung: (text, art) => zeigeMeldung(s, text, art),
// eine Meldung, die erst nach dem Wechsel der Website oder der Ansicht eintrifft, gehoert nicht mehr auf diese Seite
meldung: (text, art) => { if (aktuell()) zeigeMeldung(s, text, art) },
hinweise: () => { if (aktuell()) zeichneHinweise() },
zumReiter: (key) => waehleReiter(key),
neuLaden: async () => {
const daten = await ladeDaten(projectId)
if (aktuell()) {
s.daten = daten
ctx.daten = daten
zeichneHinweise()
}
return daten
},
}
const hinweise = []
if (nurLesen) {
hinweise.push(hinweisAbgeschaltet(projectId))
} else if ((s.daten.fehlt || []).length) {
hinweise.push(h('div', { class: 'tm-box tm-box-warn', role: 'note' },
h('span', null, `Noch nicht buchbar: Es fehlt ${fehltText(s.daten.fehlt) || 'eine Angabe zu deinem Betrieb'}. Solange das fehlt, können Gäste über deine Website nicht buchen.`),
h('button', { type: 'button', class: 'kb-btn-outline', onclick: () => waehleReiter('einstellungen') }, 'Zu den Einstellungen')))
// Hinweisleiste: abgeschaltet, oder Pflichtangaben fuer Gaeste fehlen (GET /einstellungen: fehlt). Sie folgt s.daten.
const hinweisBox = h('div', { class: 'tm-hinweise' })
function zeichneHinweise() {
const hinweise = []
if (nurLesen) {
hinweise.push(hinweisAbgeschaltet(projectId))
} else if ((s.daten.fehlt || []).length) {
hinweise.push(h('div', { class: 'tm-box tm-box-warn', role: 'note' },
h('span', null, `Noch nicht buchbar: Es fehlt ${fehltText(s.daten.fehlt) || 'eine Angabe zu deinem Betrieb'}. Solange das fehlt, können Gäste über deine Website nicht buchen.`),
h('button', { type: 'button', class: 'kb-btn-outline', onclick: () => waehleReiter('einstellungen') }, 'Zu den Einstellungen')))
}
hinweisBox.replaceChildren(...hinweise)
}
zeichneHinweise()
const panel = h('div', { class: 'tm-panel', role: 'tabpanel', id: 'tm-panel', tabindex: '0' })
const knoepfe = REITER.map((r) => h('button', {
@@ -229,7 +238,7 @@ function zeigeReiter(s, { projekt, aktuell }) {
.catch((err) => { if (aktuell()) panel.replaceChildren(fehlerKarte(err.message, () => waehleReiter(reiter.key))) })
}
s.buehne.replaceChildren(...hinweise, s.meldungEl, leiste, panel)
s.buehne.replaceChildren(hinweisBox, s.meldungEl, leiste, panel)
waehleReiter(auswahl.reiter)
}