Terminplaner: Portal-Ansicht Einstellungen – Nachbesserungen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0b75b31009
commit
3ad056f261
10 files changed
+568
-342
No files matched your search
@@ -112,6 +112,51 @@ export const abschnitt = (titel, hinweis, ...inhalt) => h('section', { class: 'k
|
||||
/** 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)
|
||||
|
||||
/**
|
||||
* Klappt unter einem Knopf einen Editor auf und zu (Bearbeiten, „+ Neu“). baue(schliessen) liefert den Editor (Element), der in box
|
||||
* gesetzt wird. Fokus-Regel fuer alle Editoren: beim Oeffnen ins erste Feld, beim Schliessen ueber „Abbrechen“/„Schliessen“ (schliessen())
|
||||
* zurueck auf den Knopf, beim erneuten Klick auf den Knopf bleibt er dort.
|
||||
*/
|
||||
export function editorUmschalter({ knopf, box, baue }) {
|
||||
const zu = () => { box.replaceChildren(); knopf.setAttribute('aria-expanded', 'false') }
|
||||
knopf.addEventListener('click', () => {
|
||||
if (box.firstChild) { zu(); return }
|
||||
knopf.setAttribute('aria-expanded', 'true')
|
||||
const editor = baue(() => { zu(); knopf.focus() })
|
||||
box.replaceChildren(editor)
|
||||
editor.querySelector('input:not(:disabled), select:not(:disabled), textarea:not(:disabled)')?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeile einer Liste mit Editor darunter (Leistung, Person/Platz, Abwesenheit, Sonderoeffnung): Kurzfassung (haupt) mit Pille, Knoepfe
|
||||
* (vorKnopf, z. B. Pfeile) und der Knopf „Bearbeiten“ (bei abgeschalteter Funktion „Ansehen“), der den Editor unter der Zeile oeffnet.
|
||||
* attrs: Attribute der Zeile (data-id / data-tag, so findet der Reiter sie nach dem Neuzeichnen), label: Name fuer den Screenreader.
|
||||
*/
|
||||
export function editorZeile({ attrs, haupt, pille = null, vorKnopf = [], nurLesen, label, baue }) {
|
||||
const box = h('div', { class: 'tm-editor-box' })
|
||||
const knopf = h('button', {
|
||||
type: 'button', class: 'kb-btn-outline tm-knopf', 'data-aktion': 'bearbeiten', 'aria-expanded': 'false', 'aria-label': `${nurLesen ? 'Ansehen' : 'Bearbeiten'}: ${label}`,
|
||||
}, nurLesen ? 'Ansehen' : 'Bearbeiten')
|
||||
editorUmschalter({ knopf, box, baue })
|
||||
return h('li', { class: 'tm-eintrag', ...attrs },
|
||||
h('div', { class: 'tm-kopf' }, h('div', { class: 'tm-haupt' }, haupt), pille),
|
||||
h('div', { class: 'tm-aktionen' }, vorKnopf, knopf),
|
||||
box)
|
||||
}
|
||||
|
||||
/**
|
||||
* „+ Neu“-Knopf mit Editor darunter. voll: die Obergrenze der Liste ist erreicht (Knopf gesperrt, grenzText erklaert es).
|
||||
* => { leiste, box }: leiste (Knopf und Hinweis; null bei abgeschalteter Funktion) und box (Platz fuer den Editor) setzt der Aufrufer in den Reiter.
|
||||
*/
|
||||
export function neuerEintrag({ nurLesen, text, werkzeug, primaer = false, voll = false, grenzText = '', baue }) {
|
||||
const box = h('div', { class: 'tm-formular-box' })
|
||||
if (nurLesen) return { leiste: null, box }
|
||||
const knopf = h('button', { type: 'button', class: primaer ? 'kb-btn-primary' : 'kb-btn-outline', 'data-werkzeug': werkzeug, 'aria-expanded': 'false', disabled: voll }, text)
|
||||
editorUmschalter({ knopf, box, baue })
|
||||
return { leiste: h('div', { class: 'tm-werkzeug-zeile' }, knopf, voll ? h('p', { class: 'tm-hinweis' }, grenzText) : null), box }
|
||||
}
|
||||
|
||||
/** Link zum CSV-Export der Termine. */
|
||||
export const exportLink = (projectId, text) => h('a', { href: exportUrl(projectId), download: 'termine.csv' }, text)
|
||||
|
||||
|
||||
+107
-45
@@ -8,10 +8,13 @@
|
||||
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.
|
||||
- Es laeuft immer nur ein Speichern je Reiter (PATCH und das Nachladen danach): solange es laeuft, ist der Reiter aria-busy, alle seine
|
||||
Knoepfe sind gesperrt, und ein weiterer Aufruf von speichere (z. B. ein Enter im Formular) wird abgewiesen (Rueckgabe false), statt mit
|
||||
einer veralteten Version gesendet zu werden. Dasselbe gilt fuer das Erzeugen eines Kalender-Links (exklusiv), das die Version ebenfalls aendert.
|
||||
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'
|
||||
import { allgemeinerFehler, frage, h, knopfZeile, setzeFehler, zeigeFehler } from './termine-dom.js'
|
||||
|
||||
export const VERALTET_TEXT = 'Die Einstellungen wurden inzwischen geändert. Bitte neu laden.'
|
||||
|
||||
@@ -59,41 +62,93 @@ export function neuerSpeicher(ctx, wurzel, neuZeichnen) {
|
||||
}
|
||||
})
|
||||
|
||||
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
|
||||
// Ein Speichern zur Zeit: waehrenddessen aria-busy am Reiter und alle Knoepfe darin gesperrt (danach wieder frei, der Fokus kehrt zurueck)
|
||||
let laeuft = false
|
||||
function sperre(an, gesperrt) {
|
||||
const tafel = wurzel.closest('[role="tabpanel"]') || wurzel
|
||||
if (an) {
|
||||
tafel.setAttribute('aria-busy', 'true')
|
||||
const knoepfe = [...wurzel.querySelectorAll('button:not(:disabled)')]
|
||||
const fokus = document.activeElement
|
||||
for (const knopf of knoepfe) knopf.disabled = true
|
||||
return { knoepfe, fokus }
|
||||
}
|
||||
tafel.removeAttribute('aria-busy')
|
||||
for (const knopf of gesperrt.knoepfe) knopf.disabled = false
|
||||
// Der Fokus war auf einem gesperrten Knopf und ist weg: zurueck dorthin; hat der Reiter ihn inzwischen anders gesetzt (z. B. auf ein Feld mit Fehler), bleibt das so
|
||||
const jetzt = document.activeElement
|
||||
if (gesperrt.knoepfe.includes(gesperrt.fokus) && gesperrt.fokus.isConnected && (!jetzt || jetzt === document.body || jetzt === gesperrt.fokus)) gesperrt.fokus.focus()
|
||||
return null
|
||||
}
|
||||
|
||||
/** arbeit() exklusiv ausfuehren; laeuft schon etwas, passiert nichts (Rueckgabe undefined). */
|
||||
async function exklusiv(arbeit) {
|
||||
if (laeuft) return undefined
|
||||
laeuft = true
|
||||
const gesperrt = sperre(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
|
||||
return await arbeit()
|
||||
} finally {
|
||||
if (knopf?.isConnected) knopf.disabled = false
|
||||
laeuft = false
|
||||
sperre(false, gesperrt)
|
||||
}
|
||||
}
|
||||
|
||||
return { konflikt, speichere, nochHier }
|
||||
async function speichere(aendere, { container }) {
|
||||
const ergebnis = await exklusiv(async () => {
|
||||
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
|
||||
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
|
||||
}
|
||||
})
|
||||
return ergebnis === true
|
||||
}
|
||||
|
||||
return { konflikt, speichere, exklusiv, nochHier }
|
||||
}
|
||||
|
||||
/**
|
||||
* Stelle des Eintrags mit dieser ID in der Liste des Entwurfs. Editoren merken sich keine Stelle, sondern die ID und suchen sie beim
|
||||
* Absenden (die Liste kann sich inzwischen anders sortiert haben). Fehlt der Eintrag, steht am Formular eine Meldung und die Rueckgabe ist -1.
|
||||
*/
|
||||
export function stelleVon(liste, id, form, was) {
|
||||
const stelle = (Array.isArray(liste) ? liste : []).findIndex((eintrag) => eintrag?.id === id)
|
||||
if (stelle < 0) zeigeFehler(form, { fehler: { _: `${was} gibt es nicht mehr. Bitte lade die Seite neu.` } })
|
||||
return stelle
|
||||
}
|
||||
|
||||
/**
|
||||
* Nach einem erfolgreichen Speichern: Reiter neu zeichnen, Meldung zeigen, Fokus auf das Element mit selektor (Regel: nach dem Speichern
|
||||
* auf den Knopf der gespeicherten Zeile, nach dem Entfernen auf den „+“-Knopf der Liste).
|
||||
*/
|
||||
export function nachSpeichern(t, text, selektor) {
|
||||
t.zeichne()
|
||||
t.ctx.meldung(text)
|
||||
t.wurzel.querySelector(selektor)?.focus()
|
||||
}
|
||||
|
||||
/** Fehler des Servers am Feld; was im container keinen Platz hat, steht allgemein (mit dem Namen des Eintrags davor). */
|
||||
@@ -109,20 +164,27 @@ function zeigeFelder(container, fehler, quellen) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* „Entfernen“-Knopf mit Rueckfrage 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). Abbrechen schliesst die Rueckfrage und der Fokus
|
||||
* geht zum Knopf zurueck. => { knopf, box }: der Aufrufer setzt beide NEBEN sein Formular, nicht hinein (ein submit der Rueckfrage
|
||||
* stiege sonst ins aeussere Formular auf).
|
||||
*/
|
||||
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()
|
||||
export function entfernenKnopf(sp, { titel, text, fehlerPfad, aendere, nachher }) {
|
||||
const box = h('div', { class: 'tm-frage' })
|
||||
const knopf = h('button', { type: 'button', class: 'kb-btn-outline tm-gefahr', 'data-aktion': 'entfernen' }, 'Entfernen')
|
||||
knopf.addEventListener('click', () => {
|
||||
const { zeile } = knopfZeile('Ja, entfernen', () => { box.replaceChildren(); knopf.focus() })
|
||||
const rueckfrage = frage(titel,
|
||||
h('p', null, text),
|
||||
fehlerPfad ? h('p', { class: 'tm-fehler', 'data-fehler': fehlerPfad, role: 'alert' }) : null,
|
||||
allgemeinerFehler(),
|
||||
zeile)
|
||||
rueckfrage.addEventListener('submit', async () => {
|
||||
if (await sp.speichere(aendere, { container: rueckfrage }) && sp.nochHier()) nachher()
|
||||
})
|
||||
box.replaceChildren(rueckfrage)
|
||||
rueckfrage.focus()
|
||||
})
|
||||
return box
|
||||
return { knopf, box }
|
||||
}
|
||||
@@ -10,7 +10,17 @@ import {
|
||||
} 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'
|
||||
import { nachSpeichern, neuerSpeicher } from './termine-speichern.js'
|
||||
|
||||
// Rechtliche Hinweise (Ruling R52): Wortlaut nicht aendern ohne Ruling
|
||||
const WIDERRUF_HINWEIS = 'Privatpersonen können eine Buchung innerhalb von 14 Tagen nach Vertragsschluss widerrufen (bei Anfragen zählt deine Annahme). '
|
||||
+ 'Das gesetzliche Widerrufsrecht geht deiner Stornoregel vor: In dieser Zeit fällt bei einem Widerruf keine Stornogebühr an, auch wenn deine Stornofrist schon abgelaufen ist. '
|
||||
+ 'Liegt der Termin innerhalb dieser 14 Tage, fragt der Terminplaner deine Gäste, ob du vorher beginnen darfst. '
|
||||
+ 'Belehrung und Knopf „Vertrag widerrufen“ zeigt der Terminplaner automatisch, wenn deine Gäste Privatpersonen sind und du eine Leistung mit sofortiger Buchung anbietest.'
|
||||
const FREIZEIT_HINWEIS = 'Nur ankreuzen, wenn deine Termine Freizeitveranstaltungen zu einem festen Termin sind (§ 312g Abs. 2 Nr. 9 BGB), zum Beispiel ein einzelner Tanzabend oder ein Escape Room. '
|
||||
+ 'Friseur, Kosmetik, Massage, Tattoo, Beratung, Fotoshootings und Weiterbildungen sind in der Regel keine Freizeitleistung. '
|
||||
+ 'Kreuzt du es an, zeigt der Terminplaner keine Widerrufsbelehrung. Kreuzt du es zu Unrecht an, können deine Gäste noch bis zu 12 Monate und 14 Tage widerrufen. '
|
||||
+ 'Im Zweifel nicht ankreuzen oder dich rechtlich beraten lassen.'
|
||||
|
||||
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 }) => {
|
||||
@@ -27,6 +37,7 @@ export function render(panel, ctx) {
|
||||
const wurzel = h('div', { class: 'tm-tab' })
|
||||
panel.replaceChildren(wurzel)
|
||||
const sp = neuerSpeicher(ctx, wurzel, zeichne)
|
||||
const t = { ctx, zeichne, wurzel }
|
||||
|
||||
function zeichne() {
|
||||
const e = ctx.daten.einstellung
|
||||
@@ -47,10 +58,9 @@ export function render(panel, ctx) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}, { container: form, knopf: speichern })
|
||||
}, { container: form })
|
||||
if (!ok || !sp.nochHier()) return
|
||||
zeichne()
|
||||
ctx.meldung('Einstellungen gespeichert.')
|
||||
nachSpeichern(t, 'Einstellungen gespeichert.', 'form[aria-label="Einstellungen"] button[type="submit"]')
|
||||
})
|
||||
wurzel.replaceChildren(sp.konflikt, form, exportUndStatus(ctx))
|
||||
}
|
||||
@@ -237,11 +247,9 @@ function rechtliches(e) {
|
||||
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.')),
|
||||
h('div', { class: 'tm-box tm-box-info', role: 'note' }, h('span', null, WIDERRUF_HINWEIS)),
|
||||
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.'))
|
||||
hakenFeld('recht.freizeit', freizeit, 'Meine Leistungen sind Freizeitangebote mit festem Termin', FREIZEIT_HINWEIS))
|
||||
return { el, lese(entwurf) { entwurf.recht = { ...entwurf.recht, gaesteSind: gaeste.value, freizeit: freizeit.checked } } }
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
(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'
|
||||
import {
|
||||
GESUNDHEIT_HINWEIS, LIMITS, MAX, centZuEuro, eindeutigeId, euroZuCent, ganzzahl, ordneNachIds, preisAnzeige, verschiebeEintrag,
|
||||
} from './termine-view.js'
|
||||
import { allgemeinerFehler, editorZeile, feld, gesperrt, h, hakenFeld, neuerEintrag, zeigeFehler } from './termine-dom.js'
|
||||
import { entfernenKnopf, nachSpeichern, neuerSpeicher, stelleVon } from './termine-speichern.js'
|
||||
|
||||
const BUCHUNGSART = [['sofort', 'Sofort buchbar'], ['anfrage', 'Nur als Anfrage (du nimmst jeden Termin an)']]
|
||||
const PREISART = [['keiner', 'Ohne Preis'], ['fest', 'Festpreis'], ['ab', 'Preis ab']]
|
||||
@@ -18,88 +20,124 @@ export function render(panel, ctx) {
|
||||
panel.replaceChildren(wurzel)
|
||||
const sp = neuerSpeicher(ctx, wurzel, zeichne)
|
||||
const t = { ctx, sp, zeichne, wurzel }
|
||||
// Reihenfolge (Ruling R51): die Pfeile aendern nur diese gemerkte Reihenfolge (IDs), gespeichert wird sie mit „Reihenfolge speichern“ in einem
|
||||
// Speichern; null = wie gespeichert. Sie bleibt beim Neuzeichnen (z. B. nach dem Speichern einer Leistung) erhalten.
|
||||
let ordnung = null
|
||||
|
||||
/** Anzeigereihenfolge (IDs): die gemerkte, auf den aktuellen Stand der Liste angewandt; ist sie die gespeicherte, gilt keine gemerkte mehr. */
|
||||
function anzeigeIds() {
|
||||
const liste = ctx.daten.einstellung.leistungen || []
|
||||
const gespeichert = liste.map((l) => l.id)
|
||||
const ids = ordnung ? ordneNachIds(liste, ordnung).map((l) => l.id) : gespeichert
|
||||
ordnung = ids.join('|') === gespeichert.join('|') ? null : ids
|
||||
return ids
|
||||
}
|
||||
|
||||
function zeichne() {
|
||||
const e = ctx.daten.einstellung
|
||||
const leistungen = e.leistungen || []
|
||||
const kopfFehler = h('div', null, allgemeinerFehler())
|
||||
const 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')
|
||||
const neu = neuerEintrag({
|
||||
nurLesen: ctx.nurLesen, text: '+ Leistung anlegen', werkzeug: 'leistung-neu', primaer: true,
|
||||
voll: leistungen.length >= LIMITS.leistungen, grenzText: `Mehr als ${LIMITS.leistungen} Leistungen sind nicht möglich.`,
|
||||
baue: (schliessen) => formular(t, { leistung: null, schliessen }),
|
||||
})
|
||||
const zeilen = new Map(leistungen.map((l) => [l.id, zeile(t, l, verschiebe)]))
|
||||
const liste = h('ul', { class: 'tm-eintraege', 'aria-label': 'Leistungen in der Reihenfolge, in der Gäste sie sehen' })
|
||||
const status = h('span', { role: 'status' })
|
||||
const speichern = h('button', { type: 'button', class: 'kb-btn-primary', 'data-aktion': 'reihenfolge-speichern' }, 'Reihenfolge speichern')
|
||||
const verwerfen = h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'reihenfolge-verwerfen' }, 'Verwerfen')
|
||||
const leiste = h('div', { class: 'tm-box tm-box-info tm-reihenfolge', hidden: true }, status, h('div', { class: 'tm-frage-knoepfe' }, speichern, verwerfen))
|
||||
|
||||
/** Zeilen in Anzeigereihenfolge bringen (vorhandene Zeilen werden nur verschoben: offene Editoren und ihre Eingaben bleiben), Pfeile und Leiste nachziehen. */
|
||||
function aktualisiere() {
|
||||
const ids = anzeigeIds()
|
||||
liste.replaceChildren(...ids.map((id) => zeilen.get(id)))
|
||||
ids.forEach((id, i) => {
|
||||
const li = zeilen.get(id)
|
||||
const hoch = li.querySelector('[data-aktion="hoch"]')
|
||||
if (hoch) hoch.disabled = i === 0
|
||||
const runter = li.querySelector('[data-aktion="runter"]')
|
||||
if (runter) runter.disabled = i === ids.length - 1
|
||||
})
|
||||
leiste.hidden = !ordnung
|
||||
}
|
||||
function verschiebe(id, richtung, aktion) {
|
||||
const ids = anzeigeIds()
|
||||
ordnung = verschiebeEintrag(ids, ids.indexOf(id), richtung)
|
||||
aktualisiere()
|
||||
const stelle = anzeigeIds().indexOf(id) + 1
|
||||
status.textContent = ordnung
|
||||
? `„${leistungen.find((l) => l.id === id).name}“ steht jetzt an Stelle ${stelle} von ${ids.length}. Die neue Reihenfolge ist noch nicht gespeichert.`
|
||||
: 'Die Reihenfolge ist wieder wie gespeichert.'
|
||||
const li = zeilen.get(id)
|
||||
;(li.querySelector(`[data-aktion="${aktion}"]:not(:disabled)`) || li.querySelector('[data-aktion="bearbeiten"]'))?.focus()
|
||||
}
|
||||
const fokusErsteZeile = () => liste.querySelector('li [data-aktion="bearbeiten"]')?.focus()
|
||||
verwerfen.addEventListener('click', () => {
|
||||
ordnung = null
|
||||
aktualisiere()
|
||||
fokusErsteZeile()
|
||||
ctx.meldung('Die gemerkte Reihenfolge ist verworfen.')
|
||||
})
|
||||
speichern.addEventListener('click', async () => {
|
||||
const gewuenscht = [...(ordnung || [])]
|
||||
// die gemerkte Reihenfolge wirkt auf eine frische Kopie des gespeicherten Stands, nach IDs (nicht nach Stellen)
|
||||
const ok = await sp.speichere((entwurf) => { entwurf.leistungen = ordneNachIds(entwurf.leistungen, gewuenscht) }, { container: kopfFehler })
|
||||
if (!ok || !sp.nochHier()) return
|
||||
ordnung = null
|
||||
zeichne()
|
||||
ctx.meldung('Reihenfolge gespeichert.')
|
||||
fokusErsteZeile()
|
||||
})
|
||||
|
||||
aktualisiere()
|
||||
wurzel.replaceChildren(
|
||||
sp.konflikt,
|
||||
h('div', { class: 'tm-box tm-box-info', role: 'note' },
|
||||
h('span', null, h('strong', null, GESUNDHEIT_HINWEIS), ' Namen und Beschreibungen deiner Leistungen liest jeder, der auf deiner Website bucht. Schreib dort zum Beispiel keine Krankheiten oder Beschwerden hinein.')),
|
||||
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,
|
||||
neu.leiste,
|
||||
neu.box,
|
||||
leiste,
|
||||
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.'))
|
||||
leistungen.length ? liste : 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
|
||||
/** Zeile einer Leistung: Kurzfassung, Aktiv-Pille, Pfeile fuer die Reihenfolge und der Knopf, der das Formular unter der Zeile oeffnet. */
|
||||
function zeile(t, l, verschiebe) {
|
||||
const { ctx } = t
|
||||
const e = ctx.daten.einstellung
|
||||
const namen = (l.ressourcen || []).map((id) => (e.ressourcen || []).find((r) => r.id === id)?.name).filter(Boolean)
|
||||
const 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),
|
||||
const pfeil = (aktion, richtung, zeichen, text) => h('button', {
|
||||
type: 'button', class: 'tm-pfeil', 'data-aktion': aktion, 'aria-label': `${text}: ${l.name}`, onclick: () => verschiebe(l.id, richtung, aktion),
|
||||
}, h('span', { 'aria-hidden': 'true' }, zeichen))
|
||||
|
||||
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
|
||||
return editorZeile({
|
||||
attrs: { 'data-id': l.id },
|
||||
nurLesen: ctx.nurLesen,
|
||||
label: l.name,
|
||||
haupt: [
|
||||
h('strong', { class: 'tm-name' }, l.name),
|
||||
h('span', { class: 'tm-meta' }, meta.filter(Boolean).join(' · ')),
|
||||
l.beschreibung ? h('span', { class: 'tm-meta' }, l.beschreibung) : null,
|
||||
],
|
||||
pille: h('span', { class: `tm-pille tm-pille-${l.aktiv ? 'ok' : 'off'}` }, l.aktiv ? 'Aktiv' : 'Inaktiv'),
|
||||
vorKnopf: ctx.nurLesen ? [] : [pfeil('hoch', -1, '↑', 'Nach oben'), pfeil('runter', 1, '↓', 'Nach unten')],
|
||||
baue: (schliessen) => formular(t, { leistung: l, schliessen }),
|
||||
})
|
||||
}
|
||||
|
||||
/** Formular einer neuen (leistung null) oder bestehenden Leistung; 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
|
||||
/**
|
||||
* Formular einer neuen (leistung null) oder bestehenden Leistung. Die Pfade der Fehler des Servers nennen die Stelle in der gespeicherten
|
||||
* Liste (leistungen.2.dauer); die Felder heissen danach. Beim Absenden wird die Leistung per ID gesucht, nicht per Stelle.
|
||||
*/
|
||||
function formular(t, { leistung, schliessen }) {
|
||||
const { ctx, sp } = t
|
||||
const e = ctx.daten.einstellung
|
||||
const neu = leistung === null
|
||||
const index = neu ? (e.leistungen || []).length : (e.leistungen || []).findIndex((x) => x.id === leistung.id)
|
||||
const l = leistung || { id: eindeutigeId('s', (e.leistungen || []).map((x) => x.id)), name: '', beschreibung: '', dauer: 30, pufferNach: 0, preisArt: 'keiner', preisCent: 0, buchungsart: 'sofort', ressourcen: [], aktiv: true }
|
||||
const p = `leistungen.${index}`
|
||||
|
||||
@@ -138,8 +176,13 @@ function formular(t, { index, leistung, schliessen }) {
|
||||
]
|
||||
|
||||
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 entfernen = neu || ctx.nurLesen ? null : entfernenKnopf(sp, {
|
||||
titel: `Leistung „${l.name}“ entfernen?`,
|
||||
text: 'Das kann nicht rückgängig gemacht werden. Gibt es für die Leistung noch künftige Termine, geht das Entfernen nicht. Dann deaktiviere sie stattdessen.',
|
||||
fehlerPfad: `leistungen.${l.id}`,
|
||||
aendere: (entwurf) => { entwurf.leistungen = entwurf.leistungen.filter((x) => x.id !== l.id) },
|
||||
nachher: () => nachSpeichern(t, `Leistung „${l.name}“ entfernt.`, '[data-werkzeug="leistung-neu"]'),
|
||||
})
|
||||
const form = h('form', { class: 'kb-panel tm-formular', novalidate: true, 'aria-label': neu ? 'Leistung anlegen' : `${ctx.nurLesen ? 'Leistung' : 'Leistung bearbeiten'}: ${l.name}` },
|
||||
h('h3', null, neu ? 'Neue Leistung' : l.name),
|
||||
gesperrt(ctx.nurLesen, felder),
|
||||
@@ -147,7 +190,7 @@ function formular(t, { index, leistung, schliessen }) {
|
||||
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))
|
||||
entfernen?.knopf))
|
||||
|
||||
/** Leistung aus den Feldern; Eingaben, die kein Feld verstehen kann, werden hier am Feld gemeldet. */
|
||||
function lese() {
|
||||
@@ -178,27 +221,18 @@ function formular(t, { index, leistung, schliessen }) {
|
||||
zeigeFehler(form, { fehler: gelesen.fehler })
|
||||
return false
|
||||
}
|
||||
if (neu) entwurf.leistungen.push(gelesen.leistung)
|
||||
else entwurf.leistungen[index] = gelesen.leistung
|
||||
if (neu) {
|
||||
entwurf.leistungen.push(gelesen.leistung)
|
||||
return true
|
||||
}
|
||||
const stelle = stelleVon(entwurf.leistungen, l.id, form, 'Diese Leistung')
|
||||
if (stelle < 0) return false
|
||||
entwurf.leistungen[stelle] = gelesen.leistung
|
||||
return true
|
||||
}, { container: form, knopf: speichern })
|
||||
}, { container: form })
|
||||
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()
|
||||
nachSpeichern(t, neu ? 'Leistung angelegt.' : 'Leistung gespeichert.', `li[data-id="${l.id}"] [data-aktion="bearbeiten"]`)
|
||||
})
|
||||
|
||||
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)
|
||||
return h('div', { class: 'tm-editor' }, form, entfernen?.box)
|
||||
}
|
||||
+52
-72
@@ -6,8 +6,8 @@
|
||||
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 { abschnitt, allgemeinerFehler, editorZeile, feld, frage, gesperrt, h, hakenFeld, knopfZeile, neuerEintrag, zeigeFehler } from './termine-dom.js'
|
||||
import { entfernenKnopf, nachSpeichern, neuerSpeicher, stelleVon } from './termine-speichern.js'
|
||||
import { abwesenheiten, feiertage, sonderoeffnungen } from './termine-team-ausnahmen.js'
|
||||
|
||||
const TYPEN = [['person', 'Person'], ['platz', 'Platz']]
|
||||
@@ -29,49 +29,32 @@ 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 neu = neuerEintrag({
|
||||
nurLesen: ctx.nurLesen, text: '+ Person oder Platz hinzufügen', werkzeug: 'ressource-neu', primaer: true,
|
||||
voll: liste.length >= LIMITS.ressourcen, grenzText: `Mehr als ${LIMITS.ressourcen} Personen oder Plätze sind nicht möglich.`,
|
||||
baue: (schliessen) => formular(t, { r: null, schliessen }),
|
||||
})
|
||||
|
||||
const reihe = (r, index) => {
|
||||
const li = h('li', { class: 'tm-eintrag', 'data-id': r.id })
|
||||
const editor = h('div', { class: 'tm-editor-box' })
|
||||
const reihe = (r) => {
|
||||
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 editorZeile({
|
||||
attrs: { 'data-id': r.id },
|
||||
nurLesen: ctx.nurLesen,
|
||||
label: r.name,
|
||||
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(' · ')),
|
||||
],
|
||||
pille: h('span', { class: `tm-pille tm-pille-${r.aktiv ? 'ok' : 'off'}` }, r.aktiv ? 'Aktiv' : 'Inaktiv'),
|
||||
baue: (schliessen) => formular(t, { r, schliessen }),
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
neu.leiste,
|
||||
neu.box,
|
||||
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.'))
|
||||
@@ -80,12 +63,14 @@ function personen(t) {
|
||||
/**
|
||||
* 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).
|
||||
* Die Pfade der Fehler des Servers nennen die Stelle in der gespeicherten Liste (ressourcen.1.plan.3); die Felder heissen danach. Beim
|
||||
* Absenden wird die Person/der Platz per ID gesucht, nicht per Stelle.
|
||||
*/
|
||||
function formular(t, { index, r, schliessen }) {
|
||||
const { ctx, sp, zeichne } = t
|
||||
function formular(t, { r, schliessen }) {
|
||||
const { ctx, sp } = t
|
||||
const e = ctx.daten.einstellung
|
||||
const neu = r === null
|
||||
const index = neu ? (e.ressourcen || []).length : (e.ressourcen || []).findIndex((x) => x.id === r.id)
|
||||
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 || []
|
||||
@@ -102,8 +87,17 @@ function formular(t, { index, r, schliessen }) {
|
||||
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 entfernen = neu || ctx.nurLesen ? null : entfernenKnopf(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: () => nachSpeichern(t, `„${alt.name}“ entfernt.`, '[data-werkzeug="ressource-neu"]'),
|
||||
})
|
||||
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, [
|
||||
@@ -125,7 +119,7 @@ function formular(t, { index, r, schliessen }) {
|
||||
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))
|
||||
entfernen?.knopf))
|
||||
|
||||
form.addEventListener('submit', async (ev) => {
|
||||
ev.preventDefault()
|
||||
@@ -136,8 +130,13 @@ function formular(t, { index, r, schliessen }) {
|
||||
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
|
||||
if (neu) {
|
||||
entwurf.ressourcen.push(ressource)
|
||||
} else {
|
||||
const stelle = stelleVon(entwurf.ressourcen, alt.id, form, 'Diese Person oder dieser Platz')
|
||||
if (stelle < 0) return false
|
||||
entwurf.ressourcen[stelle] = 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)
|
||||
@@ -146,30 +145,12 @@ function formular(t, { index, r, schliessen }) {
|
||||
leistung.ressourcen = input.checked ? [...ids, alt.id] : ids
|
||||
}
|
||||
return true
|
||||
}, { container: form, knopf: speichern })
|
||||
}, { container: form })
|
||||
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()
|
||||
nachSpeichern(t, neu ? `„${name.value.trim()}“ ist hinzugefügt.` : 'Gespeichert.', `li[data-id="${alt.id}"] [data-aktion="bearbeiten"]`)
|
||||
})
|
||||
|
||||
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)
|
||||
return h('div', { class: 'tm-editor' }, form, neu || ctx.nurLesen ? null : kalenderLink(t, alt), entfernen?.box)
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- Kalender-Abo --- */
|
||||
@@ -201,13 +182,13 @@ function kalenderLink(t, r) {
|
||||
}
|
||||
|
||||
function fragen(ausloeser) {
|
||||
const { ok, zeile } = knopfZeile('Neuen Link erzeugen', () => { frageBox.replaceChildren(); ausloeser.focus() })
|
||||
const { 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
|
||||
// exklusiv wie ein Speichern: das Erzeugen aendert die Version, ein gleichzeitiges Speichern bekaeme sonst die alte
|
||||
box.addEventListener('submit', () => t.sp.exklusiv(async () => {
|
||||
try {
|
||||
const antwort = await ctx.api.send('POST', `/ressourcen/${encodeURIComponent(r.id)}/feed`)
|
||||
if (!ctx.aktuell() || !eingabe.isConnected) return
|
||||
@@ -220,10 +201,9 @@ function kalenderLink(t, r) {
|
||||
ausloeser.focus()
|
||||
} catch (err) {
|
||||
if (!ctx.aktuell() || !eingabe.isConnected) return
|
||||
ok.disabled = false
|
||||
zeigeFehler(box, err)
|
||||
}
|
||||
})
|
||||
}))
|
||||
frageBox.replaceChildren(box)
|
||||
box.focus()
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
(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'
|
||||
import { abschnitt, allgemeinerFehler, editorZeile, feld, gesperrt, h, hakenFeld, neuerEintrag, zeigeFehler } from './termine-dom.js'
|
||||
import { entfernenKnopf, nachSpeichern, stelleVon } 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)
|
||||
|
||||
@@ -14,61 +14,43 @@ 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 alle = e.abwesenheiten || []
|
||||
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 neu = neuerEintrag({
|
||||
nurLesen: ctx.nurLesen, text: '+ Abwesenheit eintragen', werkzeug: 'abwesenheit-neu',
|
||||
voll: alle.length >= LIMITS.abwesenheiten, grenzText: `Mehr als ${LIMITS.abwesenheiten} Abwesenheiten sind nicht möglich.`,
|
||||
baue: (schliessen) => formular(t, { a: null, schliessen }),
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
const reihe = (a) => editorZeile({
|
||||
attrs: { 'data-id': a.id },
|
||||
nurLesen: ctx.nurLesen,
|
||||
label: `${a.titel || 'Abwesenheit'}, ${datumKurz(a.von)}`,
|
||||
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(' · ')),
|
||||
],
|
||||
baue: (schliessen) => formular(t, { a, schliessen }),
|
||||
})
|
||||
|
||||
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,
|
||||
neu.leiste,
|
||||
neu.box,
|
||||
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
|
||||
/** Formular einer neuen (a null) oder bestehenden Abwesenheit; Felder nach der Stelle in der gespeicherten Liste, Absenden sucht per ID. */
|
||||
function formular(t, { a, schliessen }) {
|
||||
const { ctx, sp } = t
|
||||
const e = ctx.daten.einstellung
|
||||
const neu = a === null
|
||||
const index = neu ? (e.abwesenheiten || []).length : (e.abwesenheiten || []).findIndex((x) => x.id === a.id)
|
||||
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,
|
||||
@@ -78,8 +60,12 @@ function formular(t, { index, a, schliessen }) {
|
||||
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 entfernen = neu || ctx.nurLesen ? null : entfernenKnopf(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: () => nachSpeichern(t, 'Abwesenheit entfernt.', '[data-werkzeug="abwesenheit-neu"]'),
|
||||
})
|
||||
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, [
|
||||
@@ -91,35 +77,29 @@ function formular(t, { index, a, schliessen }) {
|
||||
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))
|
||||
entfernen?.knopf))
|
||||
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 (neu) {
|
||||
entwurf.abwesenheiten = [...(entwurf.abwesenheiten || []), eintrag]
|
||||
return true
|
||||
}
|
||||
const stelle = stelleVon(entwurf.abwesenheiten, alt.id, form, 'Diese Abwesenheit')
|
||||
if (stelle < 0) return false
|
||||
entwurf.abwesenheiten[stelle] = eintrag
|
||||
return true
|
||||
}, { container: form })
|
||||
if (!ok || !sp.nochHier()) return
|
||||
zeichne()
|
||||
ctx.meldung(neu ? 'Abwesenheit eingetragen.' : 'Abwesenheit gespeichert.')
|
||||
nachSpeichern(t, neu ? 'Abwesenheit eingetragen.' : 'Abwesenheit gespeichert.', `li[data-id="${alt.id}"] [data-aktion="bearbeiten"]`)
|
||||
})
|
||||
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)
|
||||
return h('div', { class: 'tm-editor' }, form, entfernen?.box)
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- Feiertage --- */
|
||||
export function feiertage(t) {
|
||||
const { ctx, sp, zeichne } = t
|
||||
const { ctx, sp } = 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 })
|
||||
@@ -133,72 +113,54 @@ export function feiertage(t) {
|
||||
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 })
|
||||
const ok = await sp.speichere((entwurf) => { entwurf.bundesland = land.value; entwurf.feiertageGeschlossen = zu.checked }, { container: form })
|
||||
if (!ok || !sp.nochHier()) return
|
||||
zeichne()
|
||||
ctx.meldung('Feiertage gespeichert.')
|
||||
nachSpeichern(t, 'Feiertage gespeichert.', 'form[aria-label="Feiertage"] button[type="submit"]')
|
||||
})
|
||||
return abschnitt('Feiertage', null, form)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------- Sonderöffnungen --- */
|
||||
export function sonderoeffnungen(t) {
|
||||
const { ctx, sp, zeichne } = t
|
||||
const { ctx } = 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
|
||||
}
|
||||
const neu = neuerEintrag({
|
||||
nurLesen: ctx.nurLesen, text: '+ Sonderöffnung eintragen', werkzeug: 'sonder-neu',
|
||||
voll: tage.length >= LIMITS.sonderoeffnungen, grenzText: `Mehr als ${LIMITS.sonderoeffnungen} Sonderöffnungen sind nicht möglich.`,
|
||||
baue: (schliessen) => sonderFormular(t, { tag: null, schliessen }),
|
||||
})
|
||||
const reihe = (tag) => editorZeile({
|
||||
attrs: { 'data-tag': tag },
|
||||
nurLesen: ctx.nurLesen,
|
||||
label: `Sonderöffnung ${datumKurz(tag)}`,
|
||||
haupt: [
|
||||
h('strong', { class: 'tm-name' }, datumLang(tag)),
|
||||
h('span', { class: 'tm-meta' }, fensterText(e.sonderoeffnungen[tag]) || 'Geschlossen'),
|
||||
],
|
||||
baue: (schliessen) => sonderFormular(t, { tag, schliessen }),
|
||||
})
|
||||
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,
|
||||
'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 geht dem Wochenplan und den Feiertagen vor.',
|
||||
neu.leiste,
|
||||
neu.box,
|
||||
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 { ctx, sp } = 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 entfernen = neu || ctx.nurLesen ? null : entfernenKnopf(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: () => nachSpeichern(t, 'Sonderöffnung entfernt.', '[data-werkzeug="sonder-neu"]'),
|
||||
})
|
||||
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]')
|
||||
@@ -211,7 +173,7 @@ function sonderFormular(t, { tag, schliessen }) {
|
||||
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))
|
||||
entfernen?.knopf))
|
||||
form.addEventListener('submit', async (ev) => {
|
||||
ev.preventDefault()
|
||||
const ok = await sp.speichere((entwurf) => {
|
||||
@@ -229,21 +191,9 @@ function sonderFormular(t, { tag, schliessen }) {
|
||||
zeitenName(`sonderoeffnungen.${tagWert}`)
|
||||
entwurf.sonderoeffnungen = { ...(entwurf.sonderoeffnungen || {}), [tagWert]: fenster }
|
||||
return true
|
||||
}, { container: form, knopf: speichern })
|
||||
}, { container: form })
|
||||
if (!ok || !sp.nochHier()) return
|
||||
zeichne()
|
||||
ctx.meldung(neu ? 'Sonderöffnung eingetragen.' : 'Sonderöffnung gespeichert.')
|
||||
nachSpeichern(t, neu ? 'Sonderöffnung eingetragen.' : 'Sonderöffnung gespeichert.', `li[data-tag="${neu ? datum.value : tag}"] [data-aktion="bearbeiten"]`)
|
||||
})
|
||||
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)
|
||||
return h('div', { class: 'tm-editor' }, form, entfernen?.box)
|
||||
}
|
||||
@@ -439,6 +439,17 @@ export function verschiebeEintrag(liste, index, richtung) {
|
||||
return kopie
|
||||
}
|
||||
|
||||
/**
|
||||
* Eintraege nach der Reihenfolge der IDs: Kopie der Liste; unbekannte IDs werden uebergangen, Eintraege ohne ID in der Liste der IDs
|
||||
* (z. B. inzwischen neu) folgen hinten in ihrer bisherigen Reihenfolge. So wirkt eine gemerkte Reihenfolge auf den aktuellen Stand der Liste.
|
||||
*/
|
||||
export function ordneNachIds(liste, ids) {
|
||||
const eintraege = Array.isArray(liste) ? liste : []
|
||||
const reihenfolge = new Map((Array.isArray(ids) ? ids : []).map((id, stelle) => [id, stelle]))
|
||||
const stelle = (eintrag) => (reihenfolge.has(eintrag?.id) ? reihenfolge.get(eintrag.id) : Infinity)
|
||||
return eintraege.map((eintrag, alt) => [eintrag, alt]).sort(([a, altA], [b, altB]) => (stelle(a) === stelle(b) ? altA - altB : stelle(a) < stelle(b) ? -1 : 1)).map(([eintrag]) => eintrag)
|
||||
}
|
||||
|
||||
/** '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
|
||||
|
||||
@@ -136,6 +136,7 @@
|
||||
.tm-tab > .tm-box { margin-bottom: 0; }
|
||||
.tm-hinweise:empty { display: none; }
|
||||
.tm-feld[hidden] { display: none; }
|
||||
.tm-reihenfolge .tm-frage-knoepfe { margin: 0; }
|
||||
.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; }
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from '../public/termine-view.js'
|
||||
import {
|
||||
MAX, BUNDESLAENDER, ERINNERUNG_STUNDEN, FENSTER_FEHLER, GAST_FELD_WAHL, GESUNDHEIT_HINWEIS, LIMITS, RASTER_MIN, WOCHENTAGE_PLAN, ZAHLARTEN,
|
||||
aktivSeitText, centZuEuro, eindeutigeId, euroZuCent, ganzzahl, neueId, optionenAusText, optionenText, planAusTexten, planTexte, preisAnzeige, verschiebeEintrag, zusatzSchluessel,
|
||||
aktivSeitText, centZuEuro, eindeutigeId, euroZuCent, ganzzahl, neueId, optionenAusText, optionenText, ordneNachIds, planAusTexten, planTexte, preisAnzeige, verschiebeEintrag, zusatzSchluessel,
|
||||
} from '../public/termine-view.js'
|
||||
import { ANREDE_WAHL, stornoMuster } from '../public/termine-gaestetexte.js'
|
||||
import { exportUrl, request } from '../public/termine-dom.js'
|
||||
@@ -293,6 +293,12 @@ test('Ansicht-Module (public/termine*.js): Fremdtext nur als Text, keine Browser
|
||||
assert.ok(dom.includes('`/api/termine/export.csv?projectId=${encodeURIComponent(projectId)}`'), 'exportUrl codiert die Projekt-ID')
|
||||
// Speichern der Einstellungen gibt es genau einmal (termine-speichern.js), nicht in jedem Reiter
|
||||
assert.deepEqual(quellen.filter(([, quelle]) => /'PATCH'/.test(quelle)).map(([datei]) => datei), ['termine-speichern.js'])
|
||||
// Editoren klappen ueber einen einzigen Baustein auf (editorUmschalter in termine-dom.js), nicht jeder mit eigenem Code
|
||||
assert.deepEqual(quellen.filter(([, quelle]) => /'aria-expanded', 'true'|setAttribute\('aria-expanded', 'true'\)/.test(quelle)).map(([datei]) => datei), ['termine-dom.js'])
|
||||
// Editoren merken sich keine Stelle in der Liste (sie kann veraltet sein), sondern suchen den Eintrag per ID
|
||||
for (const [datei, quelle] of quellen.filter(([f]) => /^termine-(tab-(leistungen|team|einstellungen)|team-ausnahmen)\.js$/.test(f))) {
|
||||
assert.doesNotMatch(quelle, /entwurf\.\w+\[(index|i)\]/, `${datei}: Eintrag per ID suchen, nicht per Stelle`)
|
||||
}
|
||||
})
|
||||
|
||||
test('Ansicht-Module: Reiter sind Module mit render(panel, ctx), die Huelle kennt sie nur ueber REITER', () => {
|
||||
@@ -464,6 +470,21 @@ test('verschiebeEintrag: Reihenfolge ändern, ohne die Liste zu verändern', ()
|
||||
assert.notEqual(verschiebeEintrag(liste, 0, -1), liste, 'immer eine Kopie')
|
||||
})
|
||||
|
||||
test('ordneNachIds: gemerkte Reihenfolge (IDs) auf den aktuellen Stand der Liste anwenden', () => {
|
||||
const liste = [{ id: 'a', n: 1 }, { id: 'b', n: 2 }, { id: 'c', n: 3 }, { id: 'd', n: 4 }]
|
||||
assert.deepEqual(ordneNachIds(liste, ['c', 'a', 'b', 'd']).map((x) => x.id), ['c', 'a', 'b', 'd'])
|
||||
assert.deepEqual(ordneNachIds(liste, ['b', 'a']).map((x) => x.id), ['b', 'a', 'c', 'd'], 'nicht genannte Eintraege folgen in alter Reihenfolge')
|
||||
assert.deepEqual(ordneNachIds(liste, ['x', 'd', 'y', 'a']).map((x) => x.id), ['d', 'a', 'b', 'c'], 'unbekannte IDs werden uebergangen')
|
||||
assert.deepEqual(ordneNachIds(liste, []).map((x) => x.id), ['a', 'b', 'c', 'd'])
|
||||
assert.deepEqual(ordneNachIds(liste, undefined).map((x) => x.id), ['a', 'b', 'c', 'd'])
|
||||
assert.deepEqual(ordneNachIds(undefined, ['a']), [])
|
||||
assert.equal(ordneNachIds(liste, ['d'])[0], liste[3], 'dieselben Objekte, keine Kopien der Eintraege')
|
||||
assert.deepEqual(liste.map((x) => x.id), ['a', 'b', 'c', 'd'], 'Liste bleibt unveraendert')
|
||||
assert.notEqual(ordneNachIds(liste, ['a', 'b', 'c', 'd']), liste, 'immer eine neue Liste')
|
||||
// Zusammenspiel mit verschiebeEintrag: IDs verschieben, dann auf den Stand anwenden
|
||||
assert.deepEqual(ordneNachIds(liste, verschiebeEintrag(liste.map((x) => x.id), 3, -1)).map((x) => x.id), ['a', 'b', 'd', 'c'])
|
||||
})
|
||||
|
||||
test('aktivSeitText: Datum nach Berliner Kalender', () => {
|
||||
assert.equal(aktivSeitText('2026-10-01T09:00:00.000Z'), 'Aktiv seit 01.10.2026')
|
||||
assert.equal(aktivSeitText('2026-09-30T22:30:00.000Z'), 'Aktiv seit 01.10.2026', 'nach Mitternacht in Berlin')
|
||||
|
||||
@@ -232,6 +232,12 @@ async function warteBis(bedingung, text) {
|
||||
}
|
||||
throw new Error(`Zeit abgelaufen: ${text}`)
|
||||
}
|
||||
/** Zaehlt die PATCH-Aufrufe der Seite auf die Einstellungen (Liste der Adressen). */
|
||||
const zaehlePatches = (seite) => {
|
||||
const liste = []
|
||||
seite.on('request', (r) => { if (r.method() === 'PATCH' && r.url().includes('/api/termine/einstellungen')) liste.push(r.url()) })
|
||||
return liste
|
||||
}
|
||||
const zeileMitId = (page, id) => page.locator(`#tm-panel li[data-id="${id}"]`)
|
||||
const eintrag = (page, text) => page.locator('#tm-panel li.tm-eintrag', { hasText: text })
|
||||
const formular = (page, name) => page.getByRole('form', { name })
|
||||
@@ -1107,8 +1113,9 @@ async function pruefungen() {
|
||||
// ============================================================ Aufgabe 21b: Leistungen, Team & Plätze, Einstellungen (echte Router)
|
||||
// ------------------------------------------------------------ Leistungen: anlegen, bearbeiten, Reihenfolge
|
||||
await check('echt_leistung_anlegen_bearbeiten_reihenfolge', async (page) => {
|
||||
const patches = zaehlePatches(page)
|
||||
await oeffneReiter(page, 'leistungen')
|
||||
assert((await textVon(page.locator('#tm-panel .tm-box-info'))).includes('Bitte keine Gesundheitsangaben.'), 'Hinweis zu Gesundheitsangaben')
|
||||
assert((await textVon(page.locator('#tm-panel .tm-box-info[role="note"]'))).includes('Bitte keine Gesundheitsangaben.'), 'Hinweis zu Gesundheitsangaben')
|
||||
eq(await page.locator('#tm-panel li.tm-eintrag .tm-name').allInnerTexts(), ['Herrenschnitt', 'Damenschnitt', 'Kinderschnitt', 'Färben', 'Kennenlern-Beratung', 'Brautfrisur'], 'Reihenfolge wie gespeichert')
|
||||
const meta = await textVon(eintrag(page, 'Herrenschnitt').locator('.tm-meta').first())
|
||||
assert(['30 Min.', '5 Min. Pause danach', '28,00 €', 'Sofort buchbar', 'Anna, Ben'].every((t) => meta.includes(t)), `Kurzfassung: ${meta}`)
|
||||
@@ -1162,14 +1169,36 @@ async function pruefungen() {
|
||||
eq(await page.locator('#tm-panel .tm-box-warn:not([hidden])').count(), 0, 'kein Konflikt mit sich selbst')
|
||||
assert((await textVon(meldung(page))).includes('Leistung gespeichert.'), 'Meldung Speichern')
|
||||
|
||||
// Reihenfolge: Damenschnitt nach oben
|
||||
// Reihenfolge (Ruling R51): die Pfeile aendern nur eine gemerkte Reihenfolge, gespeichert wird sie in EINEM Speichern
|
||||
const leiste = page.locator('#tm-panel .tm-reihenfolge')
|
||||
eq(await leiste.isHidden(), true, 'ohne Aenderung keine Leiste')
|
||||
const patchesVorher = patches.length
|
||||
const vR = version()
|
||||
await knopf(page, 'Nach oben: Damenschnitt').click()
|
||||
await page.waitForFunction(() => document.querySelector('#tm-panel li.tm-eintrag .tm-name')?.textContent === 'Damenschnitt')
|
||||
eq(gespeichert().leistungen.map((l) => [l.name, l.reihenfolge]).slice(0, 2), [['Damenschnitt', 1], ['Herrenschnitt', 2]], 'Reihenfolge gespeichert')
|
||||
await leiste.waitFor()
|
||||
eq(await page.locator('#tm-panel li.tm-eintrag .tm-name').first().innerText(), 'Damenschnitt', 'Anzeige geändert')
|
||||
const stand = await textVon(leiste.locator('[role="status"]'))
|
||||
assert(stand.includes('Stelle 1 von 7') && stand.includes('noch nicht gespeichert'), `Stand der Reihenfolge: ${stand}`)
|
||||
eq([gespeichert().leistungen[0].name, version(), patches.length], ['Herrenschnitt', vR, patchesVorher], 'kein Speichern durch den Pfeil')
|
||||
eq(await knopf(page, 'Nach oben: Damenschnitt').isDisabled(), true, 'ganz oben: nicht weiter hoch')
|
||||
eq(await knopf(page, 'Nach unten: Bartpflege').isDisabled(), true, 'ganz unten: nicht weiter runter')
|
||||
// der Fokus bleibt in der verschobenen Zeile (kein Verlust nach dem Neuzeichnen)
|
||||
// der Fokus bleibt in der verschobenen Zeile
|
||||
eq(await page.evaluate(() => document.activeElement?.closest('li')?.dataset.id), 's_damen001', 'Fokus in der verschobenen Zeile')
|
||||
await shot(page, 'leistungen-reihenfolge')
|
||||
// verwerfen: alles wie gespeichert
|
||||
await knopf(leiste, 'Verwerfen').click()
|
||||
await leiste.waitFor({ state: 'hidden' })
|
||||
eq(await page.locator('#tm-panel li.tm-eintrag .tm-name').first().innerText(), 'Herrenschnitt', 'verworfen')
|
||||
eq([version(), patches.length], [vR, patchesVorher], 'verwerfen speichert nichts')
|
||||
// zwei Aenderungen der Reihenfolge, ein Speichern
|
||||
await knopf(page, 'Nach oben: Damenschnitt').click()
|
||||
await knopf(page, 'Nach oben: Kinderschnitt').click()
|
||||
eq(await page.locator('#tm-panel li.tm-eintrag .tm-name').allInnerTexts().then((l) => l.slice(0, 3)), ['Damenschnitt', 'Kinderschnitt', 'Herrenschnitt'], 'Anzeige')
|
||||
await knopf(leiste, 'Reihenfolge speichern').click()
|
||||
await leiste.waitFor({ state: 'hidden' })
|
||||
eq(gespeichert().leistungen.map((l) => [l.name, l.reihenfolge]).slice(0, 3), [['Damenschnitt', 1], ['Kinderschnitt', 2], ['Herrenschnitt', 3]], 'Reihenfolge gespeichert')
|
||||
eq([version(), patches.length], [vR + 1, patchesVorher + 1], 'ein Speichern für beide Änderungen')
|
||||
assert((await textVon(meldung(page))).includes('Reihenfolge gespeichert.'), 'Meldung')
|
||||
// Deaktivieren (Leistung bleibt gespeichert), Gaeste sehen sie nicht mehr
|
||||
await knopf(eintrag(page, 'Kinderschnitt'), 'Bearbeiten: Kinderschnitt').click()
|
||||
const kind = formular(page, 'Leistung bearbeiten: Kinderschnitt')
|
||||
@@ -1225,6 +1254,83 @@ async function pruefungen() {
|
||||
assert((await textVon(meldung(page))).includes('Leistung „Kinderschnitt“ entfernt.'), 'Meldung')
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------ Reihenfolge-Entwurf und Bearbeiten: Eintraege per ID, nicht per Stelle (R51)
|
||||
await check('echt_reihenfolge_entwurf_und_bearbeiten', async (page) => {
|
||||
const patches = zaehlePatches(page)
|
||||
await oeffneReiter(page, 'leistungen')
|
||||
// ein offener Editor mit ungespeicherter Eingabe ueberlebt eine Aenderung der Reihenfolge
|
||||
await knopf(zeileMitId(page, S_KINDER), 'Bearbeiten: Kinderschnitt').click()
|
||||
const kind = formular(page, 'Leistung bearbeiten: Kinderschnitt')
|
||||
await kind.getByLabel('Dauer (Minuten)', { exact: true }).fill('25')
|
||||
await knopf(page, 'Nach oben: Damenschnitt').click()
|
||||
eq(await kind.getByLabel('Dauer (Minuten)', { exact: true }).inputValue(), '25', 'offener Editor bleibt mit seiner Eingabe')
|
||||
await knopf(kind, 'Abbrechen').click()
|
||||
// Anzeige: Damenschnitt, Herrenschnitt, ... ; gespeichert ist Herrenschnitt zuerst (Stelle 0), Damenschnitt an Stelle 1
|
||||
const herren = formular(page, 'Leistung bearbeiten: Herrenschnitt')
|
||||
await knopf(zeileMitId(page, S_HERREN), 'Bearbeiten: Herrenschnitt').click()
|
||||
await herren.getByLabel('Name', { exact: true }).fill('')
|
||||
const vorher = version()
|
||||
await knopf(herren, 'Speichern').click()
|
||||
// Fehler des Servers heissen nach der gespeicherten Stelle (leistungen.0) und stehen im Formular der richtigen Leistung
|
||||
await fehlerAm(herren, 'leistungen.0.name').filter({ hasText: 'Bitte gib einen Namen an.' }).waitFor()
|
||||
eq([patches.length, version()], [1, vorher], 'abgelehnt, nichts gespeichert')
|
||||
await herren.getByLabel('Name', { exact: true }).fill('Herrenschnitt neu')
|
||||
await herren.getByLabel('Dauer (Minuten)', { exact: true }).fill('35')
|
||||
await knopf(herren, 'Speichern').click()
|
||||
await warteBis(() => gespeichert().leistungen[0].name === 'Herrenschnitt neu', 'Herrenschnitt gespeichert')
|
||||
const e = gespeichert()
|
||||
eq([e.leistungen[0].name, e.leistungen[0].dauer, e.leistungen[1].name, e.leistungen[1].dauer], ['Herrenschnitt neu', 35, 'Damenschnitt', 45], 'die richtige Leistung wurde geändert, die Reihenfolge ist unverändert')
|
||||
// die gemerkte Reihenfolge ueberlebt das Speichern einer Leistung (Anzeige und Leiste)
|
||||
await page.locator('#tm-panel .tm-reihenfolge').waitFor()
|
||||
eq(await page.locator('#tm-panel li.tm-eintrag .tm-name').allInnerTexts().then((l) => l.slice(0, 2)), ['Damenschnitt', 'Herrenschnitt neu'], 'Anzeige bleibt')
|
||||
await knopf(page, 'Reihenfolge speichern').click()
|
||||
await page.locator('#tm-panel .tm-reihenfolge').waitFor({ state: 'hidden' })
|
||||
eq(gespeichert().leistungen.map((l) => l.name).slice(0, 2), ['Damenschnitt', 'Herrenschnitt neu'], 'Reihenfolge nach IDs auf den aktuellen Stand angewendet')
|
||||
eq(gespeichert().leistungen.find((l) => l.id === S_HERREN).dauer, 35, 'die Änderung ging nicht verloren')
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------ Nur ein Speichern gleichzeitig
|
||||
await check('echt_speichern_nur_ein_vorgang', async (page) => {
|
||||
const patches = zaehlePatches(page)
|
||||
// jede Antwort auf ein Speichern braucht 0,9 s: so lange ist der Reiter beschaeftigt
|
||||
await page.route('**/api/termine/einstellungen', async (route) => {
|
||||
if (route.request().method() === 'PATCH') await new Promise((fertig) => { setTimeout(fertig, 900) })
|
||||
await route.fallback()
|
||||
})
|
||||
await oeffneReiter(page, 'leistungen')
|
||||
const tafel = page.locator('#tm-panel')
|
||||
const freieKnoepfe = () => page.locator('#tm-panel .tm-tab button:not(:disabled)').count()
|
||||
const v0 = version()
|
||||
await knopf(zeileMitId(page, S_HERREN), 'Bearbeiten: Herrenschnitt').click()
|
||||
const form = formular(page, 'Leistung bearbeiten: Herrenschnitt')
|
||||
await form.getByLabel('Dauer (Minuten)', { exact: true }).fill('35')
|
||||
await knopf(form, 'Speichern').click()
|
||||
await tafel.and(page.locator('[aria-busy="true"]')).waitFor()
|
||||
eq(await freieKnoepfe(), 0, 'beschäftigt: alle Knöpfe des Reiters gesperrt')
|
||||
await shot(page, 'leistungen-beschaeftigt')
|
||||
// ein zweites Absenden (z. B. Enter im Formular) wird abgewiesen, nicht mit alter Version gesendet
|
||||
await form.evaluate((f) => f.requestSubmit())
|
||||
await form.evaluate((f) => f.requestSubmit())
|
||||
await page.waitForFunction(() => !document.querySelector('#tm-panel').hasAttribute('aria-busy'))
|
||||
eq(patches.length, 1, 'genau ein PATCH')
|
||||
eq(version(), v0 + 1, 'genau ein Speichern')
|
||||
eq(gespeichert().leistungen[0].dauer, 35, 'Wirkung da')
|
||||
eq(await page.locator('#tm-panel .tm-box-warn:not([hidden])').count(), 0, 'kein Konflikt')
|
||||
assert((await freieKnoepfe()) > 5, 'danach sind die Knöpfe wieder frei')
|
||||
eq(await knopf(page, 'Nach oben: Damenschnitt').isDisabled(), false, 'frei wie vorher (Pfeil nicht am Rand)')
|
||||
eq(await knopf(page, 'Nach oben: Herrenschnitt').isDisabled(), true, 'am Rand bleibt gesperrt')
|
||||
// Doppelklick auf Speichern: ein Speichern, kein Konflikt
|
||||
await knopf(zeileMitId(page, S_HERREN), 'Bearbeiten: Herrenschnitt').click()
|
||||
const form2 = formular(page, 'Leistung bearbeiten: Herrenschnitt')
|
||||
await form2.getByLabel('Dauer (Minuten)', { exact: true }).fill('40')
|
||||
await knopf(form2, 'Speichern').click({ clickCount: 2, delay: 20 })
|
||||
await page.waitForFunction(() => !document.querySelector('#tm-panel').hasAttribute('aria-busy'))
|
||||
await warteBis(() => gespeichert().leistungen[0].dauer === 40, 'zweites Speichern')
|
||||
eq([patches.length, version()], [2, v0 + 2], 'der Doppelklick sendet einmal')
|
||||
eq(await page.locator('#tm-panel .tm-box-warn:not([hidden])').count(), 0, 'kein Konflikt')
|
||||
assert((await textVon(eintrag(page, 'Herrenschnitt').locator('.tm-meta').first())).includes('40 Min.'), 'Wirkung sichtbar')
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------ Team & Plätze: Wochenplan ändern, neuer Platz mit Zuordnung
|
||||
await check('echt_team_wochenplan_und_neuer_platz', async (page) => {
|
||||
await oeffneReiter(page, 'team')
|
||||
@@ -1374,6 +1480,7 @@ async function pruefungen() {
|
||||
eq([gespeichert().bundesland, gespeichert().feiertageGeschlossen], ['BY', true], 'Feiertage gespeichert')
|
||||
|
||||
// Sonderöffnungen
|
||||
assert((await textVon(page.locator('#tm-panel .tm-abschnitt', { hasText: 'Sonderöffnungen' }).locator('.tm-abschnitt-text'))).endsWith('und geht dem Wochenplan und den Feiertagen vor.'), 'Satz: „geht … vor“')
|
||||
await knopf(page, '+ Sonderöffnung eintragen').click()
|
||||
const sonder = formular(page, 'Sonderöffnung eintragen')
|
||||
await sonder.getByLabel('Tag', { exact: true }).fill('2026-10-18')
|
||||
@@ -1579,7 +1686,9 @@ async function pruefungen() {
|
||||
eq(await feld('Selbst absagen bis (Stunden vorher)').inputValue(), '24', 'Regeln')
|
||||
// Hinweise der Spezifikation
|
||||
const text = await textVon(page.locator('#tm-panel'))
|
||||
assert(text.includes('Der Widerruf schlägt die Stornofrist.'), 'Hinweis: Widerruf schlägt Stornofrist')
|
||||
// Ruling R52: Wortlaut der rechtlichen Hinweise
|
||||
eq(await textVon(page.locator('#tm-panel .tm-box-info', { hasText: 'Privatpersonen können eine Buchung' })), 'Privatpersonen können eine Buchung innerhalb von 14 Tagen nach Vertragsschluss widerrufen (bei Anfragen zählt deine Annahme). Das gesetzliche Widerrufsrecht geht deiner Stornoregel vor: In dieser Zeit fällt bei einem Widerruf keine Stornogebühr an, auch wenn deine Stornofrist schon abgelaufen ist. Liegt der Termin innerhalb dieser 14 Tage, fragt der Terminplaner deine Gäste, ob du vorher beginnen darfst. Belehrung und Knopf „Vertrag widerrufen“ zeigt der Terminplaner automatisch, wenn deine Gäste Privatpersonen sind und du eine Leistung mit sofortiger Buchung anbietest.', 'Hinweis: Widerruf schlägt Stornoregel')
|
||||
eq(await textVon(page.locator('#tm-panel label.tm-haken small', { hasText: 'Nur ankreuzen, wenn' })), 'Nur ankreuzen, wenn deine Termine Freizeitveranstaltungen zu einem festen Termin sind (§ 312g Abs. 2 Nr. 9 BGB), zum Beispiel ein einzelner Tanzabend oder ein Escape Room. Friseur, Kosmetik, Massage, Tattoo, Beratung, Fotoshootings und Weiterbildungen sind in der Regel keine Freizeitleistung. Kreuzt du es an, zeigt der Terminplaner keine Widerrufsbelehrung. Kreuzt du es zu Unrecht an, können deine Gäste noch bis zu 12 Monate und 14 Tage widerrufen. Im Zweifel nicht ankreuzen oder dich rechtlich beraten lassen.', 'Hinweis: Freizeit')
|
||||
assert(text.includes('kein fertiger Rechtstext') && await page.locator('.tm-muster .tm-pille', { hasText: 'Muster' }).count() === 1, 'Stornotext-Muster als Muster gekennzeichnet')
|
||||
assert(text.includes('Bitte keine Gesundheitsangaben.'), 'Hinweis am Nachrichtenfeld')
|
||||
assert(text.includes('Aktiv seit 01.10.2026'), 'aktiv seit')
|
||||
@@ -1768,6 +1877,11 @@ async function pruefungen() {
|
||||
await pruefe('Leistungen')
|
||||
eq(await page.getByRole('tab', { selected: true }).innerText(), 'Leistungen', 'Reiter markiert')
|
||||
await shot(page, '390-8-leistungen')
|
||||
await knopf(page, 'Nach oben: Damenschnitt').click()
|
||||
await page.locator('#tm-panel .tm-reihenfolge').waitFor()
|
||||
await pruefe('Leistungen mit Reihenfolge-Leiste')
|
||||
await shot(page, '390-8b-leistungen-reihenfolge')
|
||||
await knopf(page.locator('#tm-panel .tm-reihenfolge'), 'Verwerfen').click()
|
||||
await knopf(eintrag(page, 'Herrenschnitt'), 'Bearbeiten: Herrenschnitt').click()
|
||||
await formular(page, 'Leistung bearbeiten: Herrenschnitt').waitFor()
|
||||
await pruefe('Leistung bearbeiten')
|
||||
|
||||
Reference in new issue
Block a user