Öffnungszeiten: Speichern zeichnet nur neu, was sich geändert hat

Ungespeicherte Eingaben in anderen Formularen bleiben stehen: nach dem Speichern
wird nur die Plankarte, die Planliste (mit den Eingaben der übrigen Karten),
Feiertage oder Ausnahmen neu gezeichnet, „Nein, danke“ entfernt nur die Frage.
Während des Speicherns sind auch die Eingabefelder gesperrt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 06:18:21 +00:00
1 parent 04a3525f79
commit f01ed15247
6 files changed
+409 -58

No files matched your search

+26 -9
View File
@@ -1,7 +1,9 @@
/* Oeffnungszeiten live im Kundenbereich: Editor, Bereiche 2 „Feiertage“ und 3 „Ausnahmen“ (Spec 3.4). Jeder Bereich ist eine Funktion
(t) => Element; t = { ctx, sp, zeichne, wurzel } kommt aus zeiten-editor.js (sp = Speicher des Editors, zeichne() zeichnet alle Abschnitte
neu). Jede Aenderung speichert die ganzen Daten (zeiten-speichern.js). Abgeschaltete Funktion (ctx.nurLesen): dieselben Felder gesperrt,
(t) => Element; t = { ctx, sp, zeichne, wurzel } kommt aus zeiten-editor.js (sp = Speicher des Editors, zeichne([…]) zeichnet einzelne
Abschnitte neu). Jede Aenderung speichert die ganzen Daten (zeiten-speichern.js). Abgeschaltete Funktion (ctx.nurLesen): dieselben Felder gesperrt,
ohne Knoepfe zum Aendern. Stil: termine-team-ausnahmen.js (Abwesenheiten, Feiertage, Sonderoeffnungen).
Nach einem Speichern wird nur neu gezeichnet, was sich geaendert hat (nachSpeichern mit neuZeichnen): ungespeicherte Eingaben in den anderen Abschnitten
und Formularen bleiben stehen.
Feiertage: Bundesland (ein eigenes Speichern; die Liste kommt vom Server und gilt danach fuer das neue Land) und die Feiertage der naechsten
12 Monate (ctx.stand.feiertage). Gespeichert werden nur Abweichungen von der Vorgabe (daten.feiertage): ein gesetzlicher Feiertag ist
„geschlossen“, ein „bitte prüfen“-Tag (Heiligabend, Silvester, nur ortsuebliche Feiertage) folgt dem Wochenplan.
@@ -22,6 +24,17 @@ const zeitraum = (a) => (a.von === a.bis ? datumKurz(a.von) : `${datumKurz(a.von
const FEIERTAG_WAHL = Object.freeze([['', 'geschlossen (Vorgabe)'], ...MODUS_WAHL.filter(([wert]) => wert !== 'zu')])
const PRUEFEN_WAHL = Object.freeze([['', 'wie im Wochenplan (Vorgabe)'], ...MODUS_WAHL])
// Pro Abschnitt „Feiertage“ die Tageseingaben je Datum: nach einem Wechsel des Bundeslands bekommen Tage, die es auch im neuen Land gibt, ihre Eingaben zurueck
const ZEILEN = new WeakMap()
/** Wird nach dem Neuzeichnen des Abschnitts (Bundesland gewechselt) mit dem alten und dem neuen Abschnitt gerufen: ungespeicherte Eingaben wandern mit. */
export function feiertageUebernehmen(alt, neu) {
const frueher = ZEILEN.get(alt)
const jetzt = ZEILEN.get(neu)
if (!frueher || !jetzt) return
for (const [tag, eingabe] of jetzt) if (frueher.has(tag)) eingabe.stelleWieder(frueher.get(tag).merke())
}
export function feiertage(t) {
const { ctx, sp } = t
const daten = ctx.stand.daten
@@ -39,7 +52,8 @@ export function feiertage(t) {
const ok = await sp.speichere((entwurf) => { entwurf.land = land.value }, { container: landForm })
if (!ok || !sp.nochHier()) return
const name = BUNDESLAENDER.find(([key]) => key === land.value)?.[1] ?? land.value
nachSpeichern(t, `Bundesland gespeichert: ${name}. Die Feiertage unten gelten jetzt dafür.`, 'form[aria-label="Bundesland"] button[type="submit"]')
// die Liste darunter gilt jetzt fuer das neue Land: nur dieser Abschnitt wird neu gezeichnet
nachSpeichern(t, `Bundesland gespeichert: ${name}. Die Feiertage unten gelten jetzt dafür.`, 'form[aria-label="Bundesland"] button[type="submit"]', () => t.zeichne(['feiertage']))
})
const zeilen = ctx.stand.feiertage.map((f) => {
@@ -92,14 +106,17 @@ export function feiertage(t) {
return true
}, { container: form })
if (!ok || !sp.nochHier()) return
nachSpeichern(t, 'Feiertage gespeichert.', 'form[aria-label="Feiertage"] button[type="submit"]')
// die Liste zeigt schon, was gespeichert ist; nichts neu zeichnen (das Bundesland darueber koennte noch ungespeichert gewaehlt sein)
nachSpeichern(t, 'Feiertage gespeichert.', 'form[aria-label="Feiertage"] button[type="submit"]', () => {})
})
return abschnitt('Feiertage',
const el = abschnitt('Feiertage',
'An gesetzlichen Feiertagen ist deine Website als „geschlossen“ eingetragen. Willst du an einem Feiertag öffnen, wähle eigene Zeiten. „Bitte prüfen“ sind Tage, die nicht überall oder nicht für jeden frei sind (zum Beispiel Heiligabend, Silvester oder Fronleichnam): Hier gilt als Vorgabe dein Wochenplan.',
landForm,
h('p', { class: 'tm-hinweis' }, 'Gemeinde-Feiertage sind nicht enthalten. Trage sie bei Bedarf unten als Sondertag ein.'),
form)
ZEILEN.set(el, new Map(zeilen.map(({ f, tag }) => [f.tag, tag])))
return el
}
/* -------------------------------------------------------------- Ausnahmen --- */
@@ -169,7 +186,7 @@ function hinweisFrage(t) {
h('button', { type: 'submit', class: 'kb-btn-primary', 'data-aktion': 'hinweis-ja' }, 'Ja, anlegen'),
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'hinweis-nein', onclick: () => {
ctx.hinweisFrage = null
t.zeichne()
form.remove() // nur die Frage verschwindet, alles andere in der Seite bleibt
t.wurzel.querySelector('[data-werkzeug="urlaub-neu"]')?.focus()
} }, 'Nein, danke')))
form.dataset.frage = 'hinweis'
@@ -179,7 +196,7 @@ function hinweisFrage(t) {
}, { container: form })
if (!ok || !sp.nochHier()) return
ctx.hinweisFrage = null
nachSpeichern(t, `Die Hinweis-Leiste „${v.text}“ ist angelegt.`, '[data-werkzeug="urlaub-neu"]')
nachSpeichern(t, `Die Hinweis-Leiste „${v.text}“ ist angelegt.`, '[data-werkzeug="urlaub-neu"]', () => t.zeichne(['ausnahmen']))
})
return form
}
@@ -212,7 +229,7 @@ function formular(t, { a, art, schliessen }) {
titel: `${wort} entfernen?`,
text: urlaub ? 'In dieser Zeit gilt danach wieder der Wochenplan.' : 'An diesem Tag gilt danach wieder der Wochenplan (und der Feiertag, falls einer ist).',
aendere: (entwurf) => { entwurf.ausnahmen = (entwurf.ausnahmen || []).filter((x) => x?.id !== alt.id) },
nachher: () => nachSpeichern(t, `${wort} entfernt.`, '[data-werkzeug="urlaub-neu"]'),
nachher: () => nachSpeichern(t, `${wort} entfernt.`, '[data-werkzeug="urlaub-neu"]', () => t.zeichne(['ausnahmen'])),
})
const speichern = h('button', { type: 'submit', class: 'kb-btn-primary', 'data-aktion': 'speichern' }, neu ? `${wort} eintragen` : 'Speichern')
const form = h('form', { class: 'kb-panel tm-formular', novalidate: true, 'aria-label': neu ? `${wort} eintragen` : `${wort} bearbeiten` },
@@ -274,7 +291,7 @@ function formular(t, { a, art, schliessen }) {
const geaendert = neu || alt.von !== eintrag.von || alt.bis !== eintrag.bis
ctx.hinweisFrage = vorschlag && geaendert && !gibtHinweis(ctx.stand.daten.hinweise, vorschlag) ? { vorschlag } : null
}
nachSpeichern(t, neu ? `${wort} eingetragen.` : `${wort} gespeichert.`, `li[data-id="${CSS.escape(alt.id)}"] [data-aktion="bearbeiten"]`)
nachSpeichern(t, neu ? `${wort} eingetragen.` : `${wort} gespeichert.`, `li[data-id="${CSS.escape(alt.id)}"] [data-aktion="bearbeiten"]`, () => t.zeichne(['ausnahmen']))
})
return h('div', { class: 'tm-editor' }, form, entfernen?.box)
}
+35 -7
View File
@@ -9,6 +9,8 @@
leereDaten(), leer() }
stand = Antwort des Servers { state, daten (null vor dem ersten Speichern), version, kundenansicht, vorschau, feiertage, googleText }; sie wird bei jedem
Speichern und bei „Neu laden“ ersetzt. Ein Abschnitt ist eine Funktion (t) => Element mit t = { ctx, sp, zeichne, wurzel }.
t.zeichne() zeichnet alles neu (nur nach „Neu laden“, wenn die Daten vom Server neu sind); t.zeichne(['ausnahmen', …]) zeichnet nur die genannten Abschnitte
neu und laesst alle anderen Formulare mit ihren ungespeicherten Eingaben stehen – das ist der Weg nach einem Speichern.
Vor dem ersten Speichern (stand.daten null) gibt es nur den leeren Hauptplan; die anderen Abschnitte (mitDaten) erscheinen danach, denn der
Server verlangt mindestens einen Plan. Die Kontaktdaten (daten.kontakt) gehen unveraendert zurueck: der Editor setzt nie ein neues
kontakt.whatsapp.bestaetigtAt (R12, das tut Aufgabe 30 mit dem Haken des Kunden). */
@@ -18,12 +20,15 @@ import { istTag } from './termine-view.js'
import { leererPlan } from './zeiten-view.js'
import { neuerZeitenSpeicher } from './zeiten-speichern.js'
import { plaene } from './zeiten-plaene.js'
import { ausnahmen, feiertage } from './zeiten-ausnahmen.js'
import { ausnahmen, feiertage, feiertageUebernehmen } from './zeiten-ausnahmen.js'
/** Abschnitte in der Reihenfolge der Seite (Spec 3.4). mitDaten: erst nach dem ersten Speichern. */
/**
* Abschnitte in der Reihenfolge der Seite (Spec 3.4). mitDaten: erst nach dem ersten Speichern. uebernehmen(alt, neu): wird gerufen, wenn der Abschnitt allein
* neu gezeichnet wird, und nimmt ungespeicherte Eingaben aus dem alten Abschnitt in den neuen mit (optional).
*/
const ABSCHNITTE = [
{ key: 'plaene', bauen: plaene, mitDaten: false },
{ key: 'feiertage', bauen: feiertage, mitDaten: true },
{ key: 'feiertage', bauen: feiertage, mitDaten: true, uebernehmen: feiertageUebernehmen },
{ key: 'ausnahmen', bauen: ausnahmen, mitDaten: true },
]
@@ -114,8 +119,33 @@ export async function renderEditor(root, ctx = {}) {
const sp = neuerZeitenSpeicher(ec, root, zeichne)
const t = { ctx: ec, sp, zeichne, wurzel: root }
function zeichne() {
let leerGezeichnet = null
const abschnittEl = new Map() // key => das gezeichnete Element des Abschnitts
function baueAbschnitt(abschnitt) {
const el = abschnitt.bauen(t)
el.dataset.abschnitt = abschnitt.key
abschnittEl.set(abschnitt.key, el)
return el
}
/** Alles neu (welche fehlt) oder nur die Abschnitte in welche (Liste von Schluesseln). Aenderte sich leer/nicht leer, muss alles neu. */
function zeichne(welche) {
const leer = ec.leer()
if (Array.isArray(welche) && welche.length && leer === leerGezeichnet) {
for (const key of welche) {
const abschnitt = ABSCHNITTE.find((a) => a.key === key)
const alt = abschnittEl.get(key)
if (!abschnitt || !alt?.parentNode) continue
const neu = baueAbschnitt(abschnitt)
abschnitt.uebernehmen?.(alt, neu)
alt.parentNode.insertBefore(neu, alt)
alt.remove()
}
return
}
leerGezeichnet = leer
abschnittEl.clear()
const teile = [meldungEl, sp.konflikt]
if (ec.kundenansicht || ec.stand.kundenansicht) teile.push(h('div', { class: 'tm-box tm-box-info', role: 'note', 'data-hinweis': 'kundenansicht' }, h('span', null, KUNDENANSICHT)))
if (nurLesen) teile.push(h('div', { class: 'tm-box tm-box-warn', role: 'note', 'data-hinweis': 'nurlesen' }, h('span', null, NUR_LESEN)))
@@ -123,9 +153,7 @@ export async function renderEditor(root, ctx = {}) {
teile.push(h('div', { class: 'zt-kopfbereich' })) // Platz der Live-Vorschau (Aufgabe 30)
for (const abschnitt of ABSCHNITTE) {
if (abschnitt.mitDaten && leer) continue
const el = abschnitt.bauen(t)
el.dataset.abschnitt = abschnitt.key
teile.push(el)
teile.push(baueAbschnitt(abschnitt))
}
if (leer && !nurLesen) teile.push(h('p', { class: 'tm-hinweis zt-spaeter' }, SPAETER))
root.replaceChildren(...teile)
+100 -9
View File
@@ -1,6 +1,9 @@
/* Oeffnungszeiten live im Kundenbereich: Editor, Bereich 1 „Zeitplaene“ (Spec 3.4) und die Eingabe eines Tages, die auch Feiertage und
Sondertage nutzen (tagFelder). plaene(t) ist ein Abschnitt des Editors; t = { ctx, sp, zeichne, wurzel } kommt aus zeiten-editor.js
(sp = Speicher des Editors, zeichne() zeichnet alle Abschnitte neu). Jede Aenderung speichert die ganzen Daten (zeiten-speichern.js).
(sp = Speicher des Editors, zeichne(['ausnahmen', …]) zeichnet einzelne Abschnitte neu). Jede Aenderung speichert die ganzen Daten (zeiten-speichern.js).
Nach einem Speichern wird nur neu gezeichnet, was sich geaendert hat: die Karte des gespeicherten Plans (ersetzePlanKarte), die Liste der Plaene bei
Hinzufuegen, Loeschen und Hauptplan (zeichnePlanListe, mit den ungespeicherten Eingaben der anderen Karten), die Ausnahmen bei neuem oder geloeschtem Plan
und bei einem neuen Namen. Alles andere in der Seite bleibt mit seinen ungespeicherten Eingaben stehen.
Ein Plan: Name, Kurzname (nur Anzeige: der Server vergibt ihn beim ersten Speichern endgueltig und nennt ihn in der Antwort, R33), je
Wochentag der Haken „geöffnet“ mit zwei Zeitpaaren (<input type="time" step="300">) und der Wahl „zu festen Zeiten“, „rund um die Uhr“,
„nach Vereinbarung“; Knoepfe Speichern, Zum Hauptplan machen, Plan löschen (nicht der einzige, nicht der Hauptplan).
@@ -32,6 +35,9 @@ const zeitWert = (wert) => (wert === '24:00' ? '00:00' : typeof wert === 'string
let idZaehler = 0
/** Gibt es in der Auswahl einen Wert wert? (Eine Eingabe aus einer anderen Liste, z. B. nach einem Wechsel des Bundeslands, passt eventuell nicht mehr.) */
const hatOption = (auswahl, wert) => [...auswahl.querySelectorAll('option')].some((o) => o.value === wert)
/**
* Eingabe eines Tages (Wochentag, Feiertag, Sondertag): Betriebsart und bis zu zwei Zeitpaare.
* haken: Wochentag – Haken „geöffnet“ (ohne Haken: geschlossen) und daneben die Wahl der Art; sonst eine Auswahl der Betriebsart (wahl: Liste
@@ -39,8 +45,9 @@ let idZaehler = 0
* pfad: Name des Feldes (data-feld) und des Fehlers (data-fehler) – so findet ihn die Antwort des Servers; fensterPfad: Pfad des Fehlers bei den
* Zeitfenstern, wenn der Server fuer Betriebsart und Fenster getrennte Pfade nennt (Sondertag: …modus und …fenster).
* sichtbar: die Beschriftung der Auswahl steht in der Seite (sonst nur fuer Screenreader).
* => { el, lies }: lies() liefert { modus, fenster } oder { fehler: { [pfad]: Text } } (ein Paar nur halb ausgefuellt). Die Regeln der Fenster pruefen
* der Aufrufer (tagFehler/wochenFehler) und der Server.
* => { el, lies, merke, stelleWieder }: lies() liefert { modus, fenster } oder { fehler: { [pfad]: Text } } (ein Paar nur halb ausgefuellt). Die Regeln der
* Fenster pruefen der Aufrufer (tagFehler/wochenFehler) und der Server. merke() haelt die Eingaben so fest, wie sie im Formular stehen (auch Halbfertiges),
* stelleWieder(merke()) setzt sie in einem neu gebauten Feld wieder ein: so gehen ungespeicherte Eingaben nicht verloren, wenn die Karte neu gezeichnet wird.
*/
export function tagFelder({ pfad, fensterPfad = pfad, label, modus = 'zu', fenster = [], haken = false, wahl = MODUS_WAHL, sichtbar = false }) {
const start = typeof modus === 'string' && (MODI.includes(modus) || modus === '') ? modus : 'zu'
@@ -56,6 +63,7 @@ export function tagFelder({ pfad, fensterPfad = pfad, label, modus = 'zu', fenst
let felder
let modusWert
let zeige
let setzeModus
if (haken) {
const geoeffnet = h('input', { type: 'checkbox', checked: start !== 'zu' && start !== '' })
geoeffnet.dataset.feld = pfad
@@ -63,6 +71,10 @@ export function tagFelder({ pfad, fensterPfad = pfad, label, modus = 'zu', fenst
kopf = h('div', { class: 'tm-feld tm-feld-haken' }, h('label', { class: 'tm-haken' }, geoeffnet, h('span', null, h('strong', null, label), ' geöffnet')))
felder = h('div', { class: 'zt-tag-felder' }, art, zeiten)
modusWert = () => (geoeffnet.checked ? art.value : 'zu')
setzeModus = (wert) => {
geoeffnet.checked = wert.an === true
if (hatOption(art, wert.art)) art.value = wert.art
}
zeige = () => {
felder.hidden = !geoeffnet.checked
zeiten.hidden = !(geoeffnet.checked && art.value === 'zeiten')
@@ -76,6 +88,7 @@ export function tagFelder({ pfad, fensterPfad = pfad, label, modus = 'zu', fenst
kopf = h('div', { class: 'tm-feld' }, h('label', { for: id, class: sichtbar ? 'tm-label' : 'tm-sr' }, sichtbar ? 'Öffnung' : `${label}: Öffnung`), auswahl)
felder = h('div', { class: 'zt-tag-felder' }, zeiten)
modusWert = () => auswahl.value
setzeModus = (wert) => { if (hatOption(auswahl, wert.wahl)) auswahl.value = wert.wahl }
zeige = () => { zeiten.hidden = auswahl.value !== 'zeiten' }
auswahl.addEventListener('change', zeige)
}
@@ -98,7 +111,22 @@ export function tagFelder({ pfad, fensterPfad = pfad, label, modus = 'zu', fenst
}
return { modus: art, fenster: gelesen }
}
return { el, lies }
const merke = () => ({
an: haken ? modusWert() !== 'zu' : undefined,
art: haken ? (el.querySelector('select')?.value ?? '') : undefined,
wahl: haken ? undefined : modusWert(),
zeiten: paare.flatMap((paar) => [paar.von.value, paar.bis.value]),
})
const stelleWieder = (gemerkt) => {
if (!gemerkt) return
setzeModus(gemerkt)
paare.forEach((paar, i) => {
paar.von.value = gemerkt.zeiten?.[2 * i] ?? ''
paar.bis.value = gemerkt.zeiten?.[2 * i + 1] ?? ''
})
zeige()
}
return { el, lies, merke, stelleWieder }
}
/* ----------------------------------------------------------------- Zeitplaene --- */
@@ -107,6 +135,12 @@ const LOESCHEN_WARNUNG = 'Steht dieser Plan auf deiner Website, zeigt sie dort w
/** Der Plan mit dieser ID im aktuellen Stand (nach dem Speichern: mit dem Kurznamen des Servers). */
const planMitId = (ctx, id) => (ctx.stand.daten?.plaene || []).find((p) => p?.id === id)
const planSelektor = (id, rest) => `details[data-plan="${CSS.escape(id)}"] ${rest}`
const grenzText = `Mehr als ${ZEITEN_LIMITS.plaene} Zeitpläne sind nicht möglich.`
// Pro Karte, wie man ihre Eingaben festhaelt und wieder einsetzt (merke/stelleWieder): die Liste der Plaene wird bei „Plan hinzufügen“, „Löschen“
// und „Zum Hauptplan machen“ neu gezeichnet; was in den anderen Karten ungespeichert steht, geht dabei nicht verloren.
const KARTEN = new WeakMap()
const kartenIn = (t) => [...(t.wurzel.querySelector('.zt-plaene')?.children ?? [])]
export function plaene(t) {
const { ctx } = t
@@ -114,16 +148,51 @@ export function plaene(t) {
const liste = daten ? daten.plaene : [ctx.entwurfsplan]
const neu = daten ? neuerEintrag({
nurLesen: ctx.nurLesen, text: '+ Plan hinzufügen', werkzeug: 'plan-neu',
voll: liste.length >= ZEITEN_LIMITS.plaene, grenzText: `Mehr als ${ZEITEN_LIMITS.plaene} Zeitpläne sind nicht möglich.`,
baue: (schliessen) => neuFormular(t, { schliessen }),
}) : null
const voll = liste.length >= ZEITEN_LIMITS.plaene
const knopf = neu?.leiste?.querySelector('button')
if (knopf) knopf.disabled = voll
return abschnitt('Zeitpläne',
'Wann hast du geöffnet? Der Hauptplan steuert den Live-Status und die Leisten auf deiner Website. Weitere Pläne (zum Beispiel für Küche oder Lieferdienst) bekommen einen eigenen Kurznamen, unter dem das Team sie auf deiner Website einbauen kann.',
neu?.leiste,
neu ? h('p', { class: 'tm-hinweis zt-grenze', 'data-grenze': '' }, voll ? grenzText : '') : null,
neu?.box,
h('div', { class: 'zt-plaene' }, liste.map((plan) => planKarte(t, plan))))
}
/** Nach dem Speichern eines Plans: nur seine Karte neu zeichnen (aus dem Stand des Servers); alle anderen Formulare bleiben, wie sie sind. */
function ersetzePlanKarte(t, id) {
const plan = planMitId(t.ctx, id)
const alt = kartenIn(t).find((karte) => karte.getAttribute('data-plan') === id)
if (!plan || !alt) return
alt.parentNode.insertBefore(planKarte(t, plan), alt)
alt.remove()
}
/**
* Nach „Plan hinzufügen“, „Plan löschen“ und „Zum Hauptplan machen“: die Karten neu zeichnen (Hauptplan-Marke, Knoepfe, neue und entfernte Plaene)
* und den Knopf „+ Plan hinzufügen“ auf den Stand bringen. Die Eingaben der Karten, die es noch gibt, werden wieder eingesetzt (ungespeichert, wie sie waren);
* das Formular „Plan hinzufügen“ (neben der Liste) bleibt unberuehrt.
*/
function zeichnePlanListe(t) {
const { ctx } = t
const behaelter = t.wurzel.querySelector('.zt-plaene')
if (!behaelter || !ctx.stand.daten) return t.zeichne(['plaene'])
const gemerkt = new Map(kartenIn(t).map((karte) => [karte.getAttribute('data-plan'), KARTEN.get(karte)?.merke()]))
const karten = ctx.stand.daten.plaene.map((plan) => {
const karte = planKarte(t, plan)
if (gemerkt.get(plan.id)) KARTEN.get(karte)?.stelleWieder(gemerkt.get(plan.id))
return karte
})
behaelter.replaceChildren(...karten)
const voll = ctx.stand.daten.plaene.length >= ZEITEN_LIMITS.plaene
const knopf = t.wurzel.querySelector('[data-werkzeug="plan-neu"]')
if (knopf) knopf.disabled = voll
const grenze = t.wurzel.querySelector('[data-grenze]')
if (grenze) grenze.textContent = voll ? grenzText : ''
}
/** Formular „Plan hinzufügen“: Name und die Vorschau des Kurznamens (wie ihn der Server bildet; endgueltig ist der Wert aus der Antwort). */
function neuFormular(t, { schliessen }) {
const { ctx, sp } = t
@@ -156,7 +225,11 @@ function neuFormular(t, { schliessen }) {
if (!ok || !sp.nochHier()) return
ctx.offen.add(neuerPlan.id)
const gespeichert = planMitId(ctx, neuerPlan.id) // Name und Kurzname gelten so, wie der Server sie gespeichert hat
nachSpeichern(t, `Der Zeitplan „${gespeichert?.name ?? gewaehlt}“ ist angelegt, sein Kurzname ist „${gespeichert?.kurz ?? neuerPlan.kurz}“. Trage jetzt seine Zeiten ein.`, planSelektor(neuerPlan.id, 'summary'))
nachSpeichern(t, `Der Zeitplan „${gespeichert?.name ?? gewaehlt}“ ist angelegt, sein Kurzname ist „${gespeichert?.kurz ?? neuerPlan.kurz}“. Trage jetzt seine Zeiten ein.`, planSelektor(neuerPlan.id, 'summary'), () => {
zeichnePlanListe(t)
schliessen() // das Formular hat seine Arbeit getan
t.zeichne(['ausnahmen']) // „Gilt für“ kennt den neuen Plan
})
})
return form
}
@@ -219,7 +292,10 @@ function planKarte(t, plan) {
},
nachher: () => {
ctx.offen.delete(plan.id)
nachSpeichern(t, `Der Zeitplan „${plan.name}“ ist gelöscht.`, '[data-werkzeug="plan-neu"]')
nachSpeichern(t, `Der Zeitplan „${plan.name}“ ist gelöscht.`, '[data-werkzeug="plan-neu"]', () => {
zeichnePlanListe(t)
t.zeichne(['ausnahmen']) // die Ausnahmen dieses Plans sind mit weg, „Gilt für“ kennt den Plan nicht mehr
})
},
}) : null
@@ -251,7 +327,15 @@ function planKarte(t, plan) {
return true
}, { container: form })
if (!ok || !sp.nochHier()) return
nachSpeichern(t, `Die Zeiten von „${planMitId(ctx, plan.id)?.name ?? plan.name}“ sind gespeichert.`, planSelektor(plan.id, '[data-aktion="speichern"]'))
const gespeichert = planMitId(ctx, plan.id)
nachSpeichern(t, `Die Zeiten von „${gespeichert?.name ?? plan.name}“ sind gespeichert.`, planSelektor(plan.id, '[data-aktion="speichern"]'), () => {
if (!daten) { // das erste Speichern: jetzt gibt es auch Feiertage und Ausnahmen, der ganze Editor ist neu (es gibt sonst noch kein Formular)
t.zeichne()
return
}
ersetzePlanKarte(t, plan.id)
if (gespeichert && gespeichert.name !== plan.name) t.zeichne(['ausnahmen']) // die Liste der Ausnahmen nennt den Plan beim Namen
})
})
hauptKnopf?.addEventListener('click', async () => {
@@ -261,9 +345,16 @@ function planKarte(t, plan) {
return true
}, { container: form })
if (!ok || !sp.nochHier()) return
nachSpeichern(t, `Der Zeitplan „${planMitId(ctx, plan.id)?.name ?? plan.name}“ ist jetzt der Hauptplan. Er steuert den Live-Status und die Leisten auf deiner Website.`, planSelektor(plan.id, 'summary'))
nachSpeichern(t, `Der Zeitplan „${planMitId(ctx, plan.id)?.name ?? plan.name}“ ist jetzt der Hauptplan. Er steuert den Live-Status und die Leisten auf deiner Website.`, planSelektor(plan.id, 'summary'), () => zeichnePlanListe(t))
})
KARTEN.set(details, {
merke: () => ({ name: name.value, tage: tage.map(({ tag }) => tag.merke()) }),
stelleWieder: (gemerkt) => {
name.value = gemerkt.name
tage.forEach(({ tag }, i) => tag.stelleWieder(gemerkt.tage[i]))
},
})
details.append(
h('summary', { class: 'zt-plan-kopf' },
h('strong', { class: 'tm-name' }, plan.name),
+32 -33
View File
@@ -8,8 +8,11 @@
'feiertage.2026-12-25'); Pfade ohne Platz im container erscheinen allgemein, mit dem Namen des Eintrags davor. Rueckgabe false.
- 409 (ZEITEN_VERALTET, jemand hat inzwischen gespeichert): Hinweis mit Knopf „Neu laden“ oben im Editor. Rueckgabe false.
- alles andere (429 zu viele Speichervorgaenge, 403, 500, kein Netz): Meldung ueber dem Editor (ctx.meldung). Rueckgabe false.
- Es laeuft immer nur ein Speichern (PUT und das Uebernehmen): solange es laeuft, ist der Editor 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 zu gehen.
- Es laeuft immer nur ein Speichern (PUT und das Uebernehmen): solange der PUT laeuft, ist der Editor aria-busy, alle seine Knoepfe UND Eingabefelder
sind gesperrt (waehrenddessen Getipptes ginge sonst verloren), und ein weiterer Aufruf von speichere (z. B. ein Enter im Formular) wird abgewiesen
(Rueckgabe false), statt mit einer veralteten Version zu gehen. Der Fokus geht danach zum ersten Feld mit Fehler, sonst dorthin zurueck, wo er war.
- Nach einem Erfolg zeichnet der Abschnitt nur neu, was das Speichern geaendert hat (nachSpeichern mit neuZeichnen): ungespeicherte Eingaben in den
anderen Formularen bleiben stehen. Nur „Neu laden“ nach einem 409 zeichnet den ganzen Editor neu.
Der Abschnitt ruft speichere nur, wenn ctx.nurLesen nicht gesetzt ist. Texte des Servers kommen nur als Text in die Seite (h, textContent).
ctx (baut zeiten-editor.js): { projectId, stand, aktuell(), neuLaden(), meldung(text, art), uebernimm(antwort, { erstes }) }. */
@@ -64,7 +67,7 @@ export function beschrifteFehler(pfad, text, quellen) {
}
}
/** Fehler des Servers am Feld; was im container keinen Platz hat, steht allgemein (mit dem Namen des Eintrags davor). */
/** Fehler des Servers am Feld; was im container keinen Platz hat, steht allgemein (mit dem Namen des Eintrags davor). Den Fokus setzt speichere nach dem Entsperren. */
function zeigeFelder(container, fehler, quellen) {
const amFeld = {}
const rest = []
@@ -73,13 +76,12 @@ function zeigeFelder(container, fehler, quellen) {
else rest.push(beschrifteFehler(pfad, text, quellen))
}
setzeFehler(container, rest.length ? { ...amFeld, _: rest.join(' ') } : amFeld)
container.querySelector('[aria-invalid="true"]')?.focus()
}
/**
* Speicher des Editors. wurzel = das Element des Editors (verschwindet beim Wechsel der Website: danach fasst nichts mehr die Seite an),
* neuZeichnen() zeichnet den Editor aus ctx.stand neu (nach „Neu laden“).
* => { konflikt, speichere, exklusiv, nochHier }: konflikt ist das Hinweis-Element (der Editor setzt es an den Anfang), nochHier() sagt, ob der
* => { konflikt, speichere, nochHier }: konflikt ist das Hinweis-Element (der Editor setzt es an den Anfang), nochHier() sagt, ob der
* Editor noch angezeigt wird (vor jedem Anfassen der Seite nach einem await pruefen).
*/
export function neuerZeitenSpeicher(ctx, wurzel, neuZeichnen) {
@@ -102,44 +104,36 @@ export function neuerZeitenSpeicher(ctx, wurzel, neuZeichnen) {
}
})
// Ein Speichern zur Zeit: waehrenddessen aria-busy am Editor und alle Knoepfe darin gesperrt (danach wieder frei, der Fokus kehrt zurueck).
// Ein Speichern zur Zeit: waehrenddessen aria-busy am Editor und alle Knoepfe und Eingabefelder darin gesperrt (danach wieder frei, der Fokus kehrt zurueck).
let laeuft = false
function sperre(an, gesperrt) {
if (an) {
wurzel.setAttribute('aria-busy', 'true')
const knoepfe = [...wurzel.querySelectorAll('button:not(:disabled)')]
const steuerungen = [...wurzel.querySelectorAll('button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled)')]
const fokus = document.activeElement
for (const knopf of knoepfe) knopf.disabled = true
return { knoepfe, fokus }
for (const steuerung of steuerungen) steuerung.disabled = true
return { steuerungen, fokus }
}
wurzel.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 Abschnitt ihn inzwischen anders gesetzt (z. B. auf ein Feld mit Fehler), bleibt das so
for (const steuerung of gesperrt.steuerungen) steuerung.disabled = false
// Der Fokus war auf einer gesperrten Steuerung und ist weg: zurueck dorthin; hat der Abschnitt ihn inzwischen anders gesetzt (z. B. auf die Meldung), 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()
if (gesperrt.steuerungen.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 {
return await arbeit()
} finally {
laeuft = false
sperre(false, gesperrt)
}
}
async function speichere(aendere, { container }) {
const ergebnis = await exklusiv(async () => {
if (laeuft) return false // ein Speichern zur Zeit
laeuft = true
let gesperrt = null
let amFeld = false
try {
setzeFehler(container, {})
konflikt.hidden = true
const bisher = ctx.stand.daten
const entwurf = structuredClone(bisher ?? ctx.leereDaten())
if (aendere(entwurf) === false) return false // der Abschnitt hat selbst Fehler am Feld gezeigt
gesperrt = sperre(true)
try {
const antwort = await request(`/api/zeiten/${encodeURIComponent(ctx.projectId)}`, { method: 'PUT', body: { version: ctx.stand.version, daten: entwurf } })
if (!ctx.aktuell()) return false
@@ -153,16 +147,20 @@ export function neuerZeitenSpeicher(ctx, wurzel, neuZeichnen) {
konflikt.scrollIntoView({ block: 'nearest' })
} else if (err.status === 400 && Object.keys(err.fehler || {}).length) {
zeigeFelder(container, err.fehler, [bisher, entwurf])
amFeld = true
} else {
ctx.meldung(err.message, 'fehler')
}
return false
}
})
return ergebnis === true
} finally {
laeuft = false
if (gesperrt) sperre(false, gesperrt)
if (amFeld && nochHier()) container.querySelector('[aria-invalid="true"]')?.focus() // gesperrte Felder nehmen den Fokus nicht: erst nach dem Entsperren
}
}
return { konflikt, speichere, exklusiv, nochHier }
return { konflikt, speichere, nochHier }
}
/**
@@ -176,11 +174,12 @@ export function stelleVon(liste, id, form, was) {
}
/**
* Nach einem erfolgreichen Speichern: Abschnitte 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).
* Nach einem erfolgreichen Speichern: neu zeichnen, was das Speichern geaendert hat (neuZeichnen(); ohne Angabe der ganze Editor – die Abschnitte
* rufen es immer mit der kleinsten noetigen Aenderung auf, damit ungespeicherte Eingaben in anderen Formularen stehen bleiben), 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, meldung, selektor) {
t.zeichne()
export function nachSpeichern(t, meldung, selektor, neuZeichnen = () => t.zeichne()) {
neuZeichnen()
t.ctx.meldung(meldung)
t.wurzel.querySelector(selektor)?.focus()
}
+2
View File
@@ -39,6 +39,8 @@
.zt-plan > .tm-editor { margin: 0; padding: 0 16px 16px; }
.zt-plan-form .tm-frage-knoepfe { margin-top: 16px; }
.zt-plan-form > .tm-hinweis { margin-top: 10px; }
.zt-grenze { margin: 0 0 12px; }
.zt-grenze:empty { display: none; }
.zt-werkzeuge { display: flex; flex-wrap: wrap; gap: 0 10px; }
.zt-werkzeuge:empty { display: none; }
.zt-editor .tm-abschnitt > .tm-frage-box { margin: 12px 0 16px; }
+214
View File
@@ -626,6 +626,220 @@ test('Ausnahmen: bei 100 Einträgen sind beide Knöpfe gesperrt', mitDom({}, asy
assert.match(fx.text(abschnitt(fx, root, 'ausnahmen')), /Mehr als 100 Ausnahmen sind nicht möglich/)
}))
// ---------------------------------------------------------------------------------------------------------------- Nur neu zeichnen, was sich geändert hat
const kurzerUrlaub = (id, text = '') => ({ id, art: 'urlaub', von: tagPlus(HEUTE, 10), bis: tagPlus(HEUTE, 10), text, modus: 'zu', fenster: [], plan: null })
/** Einen eintägigen Betriebsurlaub über das Formular eintragen (ohne Frage nach der Hinweis-Leiste); die Antwort kommt als nächste aus der Warteschlange. */
async function urlaubEintragen(fx, root, antwortDaten, version) {
await fx.klick(fx.finde(root, '[data-werkzeug="urlaub-neu"]'))
const form = urlaubsformular(fx, root)
await fx.setze(feldIn(fx, form, 'von'), tagPlus(HEUTE, 10))
await fx.setze(feldIn(fx, form, 'bis'), tagPlus(HEUTE, 10))
fx.antworten.push(antwort(antwortDaten, version))
await fx.klick(fx.finde(form, 'button[type="submit"]'))
}
test('Plan speichern zeichnet nur diese Karte: ungespeicherte Eingaben in einem anderen Plan und in anderen Abschnitten bleiben stehen', mitDom({}, async (fx) => {
const zwei = daten({ plaene: [plan(P1), plan(P2)] })
const { root } = await oeffne(fx, { start: stand(zwei) })
const karteA = planKarte(fx, root, P1)
const karteB = planKarte(fx, root, P2)
const nameB = fx.finde(karteB, `[data-feld="plaene.${P2}.name"]`)
await fx.setze(nameB, 'Küche Entwurf')
await fx.klick(haken(fx, tagZeile(fx, root, P2, '7'))) // Sonntag im zweiten Plan geöffnet, nicht gespeichert
await fx.setze(fx.finde(root, 'select[data-feld="land"]'), 'BY') // Bundesland gewählt, nicht gespeichert
await fx.setze(fx.finde(fx.finde(root, '[data-feiertag="2026-12-25"]'), 'select'), 'rund')
await fx.setze(zeitFelder(fx, tagZeile(fx, root, P1, '2'))[0], '08:15') // Dienstag in A: wird gleich gespeichert
fx.antworten.push(antwort(daten({ plaene: [plan(P1, { tage: { ...werktags(), 2: { modus: 'zeiten', fenster: [['08:15', '18:00']] } } }), plan(P2)] }), 4))
await fx.klick(speichernKnopf(fx, root, P1))
const gesendet = puts(fx)[0].body.daten
assert.deepEqual(gesendet.plaene[0].tage['2'].fenster, [['08:15', '18:00']])
assert.equal(gesendet.plaene[1].name, 'Küche', 'der Entwurf des anderen Plans wird nicht mitgespeichert')
assert.equal(gesendet.plaene[1].tage['7'].modus, 'zu')
assert.notEqual(planKarte(fx, root, P1), karteA, 'die gespeicherte Karte ist neu gezeichnet')
assert.equal(planKarte(fx, root, P2), karteB, 'die andere Karte ist dieselbe geblieben')
assert.equal(fx.finde(planKarte(fx, root, P2), `[data-feld="plaene.${P2}.name"]`), nameB)
assert.equal(nameB.value, 'Küche Entwurf')
assert.equal(haken(fx, tagZeile(fx, root, P2, '7')).checked, true)
assert.equal(fx.finde(root, 'select[data-feld="land"]').value, 'BY', 'das ungespeicherte Bundesland bleibt')
assert.equal(fx.finde(fx.finde(root, '[data-feiertag="2026-12-25"]'), 'select').value, 'rund')
assert.deepEqual(zeitFelder(fx, tagZeile(fx, root, P1, '2')).slice(0, 2).map((f) => f.value), ['08:15', '18:00'], 'die gespeicherte Karte zeigt den gespeicherten Stand')
}))
test('Betriebsurlaub speichern zeichnet nur „Ausnahmen“: eine ungespeicherte Änderung am Wochenplan bleibt', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
const montag = tagZeile(fx, root, P1, '1')
const feld = zeitFelder(fx, montag)[0]
await fx.setze(feld, '07:45')
const karte = planKarte(fx, root, P1)
const feiertage = abschnitt(fx, root, 'feiertage')
const ausnahmenVorher = abschnitt(fx, root, 'ausnahmen')
await urlaubEintragen(fx, root, daten({ ausnahmen: [kurzerUrlaub('x_urlaub01')] }), 4)
assert.equal(planKarte(fx, root, P1), karte, 'dieselbe Karte')
assert.equal(zeitFelder(fx, tagZeile(fx, root, P1, '1'))[0], feld)
assert.equal(feld.value, '07:45', 'die ungespeicherte Eingabe steht noch da')
assert.equal(abschnitt(fx, root, 'feiertage'), feiertage, 'Feiertage unberührt')
assert.notEqual(abschnitt(fx, root, 'ausnahmen'), ausnahmenVorher, 'Ausnahmen neu gezeichnet')
assert.ok(fx.finde(root, 'li[data-id="x_urlaub01"]'))
assert.match(meldung(fx, root), /eingetragen/)
assert.equal(puts(fx)[0].body.daten.plaene[0].tage['1'].fenster[0][0], '09:00', 'der Entwurf ging nicht mit hinaus')
// dasselbe beim Entfernen
const zeile = fx.finde(root, 'li[data-id="x_urlaub01"]')
await fx.klick(fx.finde(zeile, '[data-aktion="bearbeiten"]'))
await fx.klick(fx.finde(zeile, '[data-aktion="entfernen"]'))
fx.antworten.push(antwort(daten(), 5))
await fx.klick(fx.finde(fx.finde(zeile, '.tm-frage-box'), 'button[type="submit"]'))
assert.equal(feld.value, '07:45')
assert.equal(fx.finde(root, 'li[data-id="x_urlaub01"]'), null)
}))
test('„Nein, danke“ entfernt nur die Frage: andere ungespeicherte Eingaben und offene Formulare bleiben', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
await fx.klick(fx.finde(root, '[data-werkzeug="urlaub-neu"]'))
const form = urlaubsformular(fx, root)
const von = tagPlus(HEUTE, 20)
const bis = tagPlus(HEUTE, 25)
await fx.setze(feldIn(fx, form, 'von'), von)
await fx.setze(feldIn(fx, form, 'bis'), bis)
fx.antworten.push(antwort(daten({ ausnahmen: [{ ...kurzerUrlaub('x_urlaub01'), von, bis }] }), 4))
await fx.klick(fx.finde(form, 'button[type="submit"]'))
assert.ok(fx.finde(root, '[data-frage="hinweis"]'))
// jetzt nebenher etwas eingeben: ein Wochentag, ein offenes Sondertag-Formular mit Text
const feld = zeitFelder(fx, tagZeile(fx, root, P1, '3'))[1]
await fx.setze(feld, '17:20')
await fx.klick(fx.finde(root, '[data-werkzeug="sondertag-neu"]'))
const sonder = fx.finde(root, 'form[aria-label="Sondertag eintragen"]')
await fx.setze(feldIn(fx, sonder, 'text'), 'Tag der offenen Tür')
await fx.klick(fx.finde(fx.finde(root, '[data-frage="hinweis"]'), '[data-aktion="hinweis-nein"]'))
assert.equal(fx.finde(root, '[data-frage="hinweis"]'), null, 'die Frage ist weg')
assert.equal(feld.value, '17:20')
assert.equal(fx.finde(root, 'form[aria-label="Sondertag eintragen"]'), sonder, 'das offene Formular ist dasselbe')
assert.equal(feldIn(fx, sonder, 'text').value, 'Tag der offenen Tür')
assert.equal(puts(fx).length, 1)
}))
test('Feiertage speichern lässt ein noch ungespeichertes Bundesland stehen; Bundesland speichern nimmt Eingaben für Tage mit ins neue Land', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
const zeile = (tag) => fx.finde(root, `[data-feiertag="${tag}"]`)
await fx.setze(fx.finde(root, 'select[data-feld="land"]'), 'BY')
await fx.setze(fx.finde(zeile('2026-12-25'), 'select'), 'vereinbarung')
fx.antworten.push(antwort(daten({ feiertage: { '2026-12-25': { modus: 'vereinbarung', fenster: [] } } }), 4))
await fx.klick(fx.finde(root, 'form[aria-label="Feiertage"] button[type="submit"]'))
assert.equal(puts(fx)[0].body.daten.land, 'RP', 'das Bundesland ging nicht mit')
assert.equal(fx.finde(root, 'select[data-feld="land"]').value, 'BY', 'und steht noch da')
assert.match(meldung(fx, root), /Feiertage gespeichert/)
// jetzt das Bundesland speichern: ein Wochenplan-Entwurf bleibt, die Eingabe zu Neujahr (gibt es auch in Bayern) wandert mit
await fx.setze(fx.finde(zeile('2027-01-01'), 'select'), 'rund')
const feld = zeitFelder(fx, tagZeile(fx, root, P1, '4'))[0]
await fx.setze(feld, '10:10')
const bayern = [{ tag: '2026-12-25', name: 'Erster Weihnachtstag', art: 'feiertag' }, { tag: '2027-01-01', name: 'Neujahr', art: 'feiertag' }, { tag: '2027-01-06', name: 'Heilige Drei Könige', art: 'feiertag' }]
fx.antworten.push(antwort(daten({ land: 'BY', feiertage: { '2026-12-25': { modus: 'vereinbarung', fenster: [] } } }), 5, { feiertage: bayern }))
await fx.klick(fx.finde(root, 'form[aria-label="Bundesland"] button[type="submit"]'))
assert.deepEqual(fx.alle(root, '[data-feiertag]').map((z) => z.getAttribute('data-feiertag')), ['2026-12-25', '2027-01-01', '2027-01-06'])
assert.equal(fx.finde(zeile('2027-01-01'), 'select').value, 'rund', 'die Eingabe zu Neujahr ist erhalten')
assert.equal(fx.finde(zeile('2026-12-25'), 'select').value, 'vereinbarung', 'der gespeicherte Stand')
assert.equal(fx.finde(zeile('2027-01-06'), 'select').value, '')
assert.equal(feld.value, '10:10', 'der Wochenplan-Entwurf bleibt')
}))
test('Plan hinzufügen, Hauptplan wechseln und Plan löschen zeichnen die Planliste neu, behalten aber die ungespeicherten Eingaben der übrigen Karten und das offene „Plan hinzufügen“', mitDom({}, async (fx) => {
const P3 = 'p_dritter3'
const d3 = daten({
plaene: [plan(P1), plan(P2), plan(P3, { kurz: 'bar', name: 'Bar', haupt: false })],
ausnahmen: [{ ...kurzerUrlaub('x_kueche01', 'Nur Küche'), plan: P2 }, kurzerUrlaub('x_alle0001', 'Alle')],
})
const { root } = await oeffne(fx, { start: stand(d3) })
const name3 = fx.finde(planKarte(fx, root, P3), `[data-feld="plaene.${P3}.name"]`)
await fx.setze(name3, 'Bar Entwurf')
const montag = zeitFelder(fx, tagZeile(fx, root, P1, '1'))[0]
await fx.setze(montag, '06:30')
await fx.klick(fx.finde(root, '[data-werkzeug="plan-neu"]'))
const neuForm = fx.finde(root, 'form[aria-label="Plan hinzufügen"]')
await fx.setze(fx.finde(neuForm, 'input[type="text"]'), 'Halbfertig')
const ausnahmenVorher = abschnitt(fx, root, 'ausnahmen')
// Hauptplan wechseln (P2): Pillen und Knöpfe aller Karten ändern sich, die Eingaben bleiben
fx.antworten.push(antwort(daten({ ...d3, plaene: [plan(P1, { haupt: false }), plan(P2, { haupt: true }), plan(P3, { kurz: 'bar', name: 'Bar', haupt: false })] }), 4))
await fx.klick(fx.finde(planKarte(fx, root, P2), '[data-aktion="haupt"]'))
assert.match(fx.text(fx.finde(planKarte(fx, root, P2), 'summary')), /Hauptplan/)
assert.doesNotMatch(fx.text(fx.finde(planKarte(fx, root, P1), 'summary')), /Hauptplan/)
assert.ok(fx.finde(planKarte(fx, root, P1), '[data-aktion="haupt"]'), 'der frühere Hauptplan hat jetzt den Knopf')
assert.equal(fx.finde(planKarte(fx, root, P3), `[data-feld="plaene.${P3}.name"]`).value, 'Bar Entwurf')
assert.equal(zeitFelder(fx, tagZeile(fx, root, P1, '1'))[0].value, '06:30')
assert.equal(fx.finde(root, 'form[aria-label="Plan hinzufügen"]'), neuForm, 'das offene Formular bleibt')
assert.equal(fx.finde(neuForm, 'input[type="text"]').value, 'Halbfertig')
assert.equal(abschnitt(fx, root, 'ausnahmen'), ausnahmenVorher, 'die Ausnahmen kennen die Pläne nur beim Namen: nichts zu tun')
// Plan löschen (P3, jetzt weder Haupt- noch letzter Plan): P1 und P2 behalten ihre Eingaben, Ausnahmen werden neu gezeichnet
const montag2 = zeitFelder(fx, tagZeile(fx, root, P1, '1'))[0]
await fx.klick(fx.finde(planKarte(fx, root, P3), '[data-aktion="entfernen"]'))
fx.antworten.push(antwort(daten({ ...d3, plaene: [plan(P1, { haupt: false }), plan(P2, { haupt: true })] }), 5))
await fx.klick(fx.finde(fx.finde(planKarte(fx, root, P3), '.tm-frage-box'), 'button[type="submit"]'))
assert.equal(planKarte(fx, root, P3), null)
assert.equal(zeitFelder(fx, tagZeile(fx, root, P1, '1'))[0].value, '06:30', 'Eingabe in P1 bleibt')
assert.notEqual(zeitFelder(fx, tagZeile(fx, root, P1, '1'))[0], montag2, '(die Karte wurde mit ihren Werten neu gebaut)')
assert.equal(fx.finde(root, 'form[aria-label="Plan hinzufügen"]'), neuForm)
assert.notEqual(abschnitt(fx, root, 'ausnahmen'), ausnahmenVorher, 'Ausnahmen neu gezeichnet')
// Plan hinzufügen: neue Karte, Formular zu, P1 behält die Eingabe, „Gilt für“ in neuen Formularen kennt den neuen Plan
await fx.setze(fx.finde(neuForm, 'input[type="text"]'), 'Lieferdienst')
fx.antworten.push((aufruf) => antwort(daten({ ...d3, plaene: [plan(P1, { haupt: false }), plan(P2, { haupt: true }), plan(aufruf.body.daten.plaene[2].id, { kurz: 'lieferdienst', name: 'Lieferdienst', haupt: false, tage: wochenplan() })] }), 6))
await fx.klick(fx.finde(neuForm, 'button[type="submit"]'))
const neueKarte = fx.alle(root, 'details[data-plan]').find((k) => /Lieferdienst/.test(fx.text(fx.finde(k, 'summary'))))
assert.ok(neueKarte, 'die neue Karte steht in der Liste')
assert.equal(fx.alle(root, 'details[data-plan]').length, 3)
assert.equal(fx.finde(root, 'form[aria-label="Plan hinzufügen"]'), null, 'das Formular ist zu')
assert.equal(zeitFelder(fx, tagZeile(fx, root, P1, '1'))[0].value, '06:30', 'auch das Hinzufügen lässt die Eingaben der anderen Karten stehen')
await fx.klick(fx.finde(root, '[data-werkzeug="urlaub-neu"]'))
assert.match(fx.text(feldIn(fx, urlaubsformular(fx, root), 'plan')), /Lieferdienst/)
}))
test('Plan umbenennen: die Liste der Ausnahmen nennt den neuen Namen, sonst bleibt sie stehen', mitDom({}, async (fx) => {
const ausnahme = { ...kurzerUrlaub('x_kueche01', 'Nur Küche'), plan: P2 }
const { root } = await oeffne(fx, { start: stand(daten({ plaene: [plan(P1), plan(P2)], ausnahmen: [ausnahme] })) })
const vorher = abschnitt(fx, root, 'ausnahmen')
assert.match(fx.text(fx.finde(root, 'li[data-id="x_kueche01"]')), /nur Küche/)
// erst gespeichert ohne neuen Namen: Ausnahmen bleiben
fx.antworten.push(antwort(daten({ plaene: [plan(P1), plan(P2)], ausnahmen: [ausnahme] }), 4))
await fx.klick(speichernKnopf(fx, root, P2))
assert.equal(abschnitt(fx, root, 'ausnahmen'), vorher)
await fx.setze(fx.finde(planKarte(fx, root, P2), `[data-feld="plaene.${P2}.name"]`), 'Restaurant')
fx.antworten.push(antwort(daten({ plaene: [plan(P1), plan(P2, { name: 'Restaurant' })], ausnahmen: [ausnahme] }), 5))
await fx.klick(speichernKnopf(fx, root, P2))
assert.notEqual(abschnitt(fx, root, 'ausnahmen'), vorher)
assert.match(fx.text(fx.finde(root, 'li[data-id="x_kueche01"]')), /nur Restaurant/)
}))
test('Während ein Speichern läuft, sind Knöpfe und Eingabefelder gesperrt (nichts geht beim Tippen verloren); danach sind sie wieder frei', mitDom({}, async (fx) => {
const { root } = await oeffne(fx, { start: stand(daten({ plaene: [plan(P1), plan(P2)] })) })
const echtes = globalThis.fetch
let freigeben
const wartet = new Promise((resolve) => { freigeben = resolve })
globalThis.fetch = async (url, optionen) => {
if (optionen?.method === 'PUT') await wartet
return echtes(url, optionen)
}
try {
const ausserhalb = zeitFelder(fx, tagZeile(fx, root, P2, '1'))[0]
const selbst = zeitFelder(fx, tagZeile(fx, root, P1, '1'))[0]
fx.antworten.push(antwort(daten({ plaene: [plan(P1), plan(P2)] }), 4))
const lauf = fx.klick(speichernKnopf(fx, root, P1))
await fx.ruhe()
assert.equal(root.getAttribute('aria-busy'), 'true')
for (const steuerung of fx.alle(root, 'input, select, textarea, button')) assert.equal(steuerung.disabled, true, `${steuerung.tagName} ist gesperrt`)
await fx.setze(ausserhalb, '05:55')
assert.notEqual(ausserhalb.value, '05:55', 'in ein gesperrtes Feld lässt sich nichts tippen')
freigeben()
await lauf
assert.equal(root.hasAttribute('aria-busy'), false)
assert.equal(ausserhalb.disabled, false, 'die Karte, die stehen blieb, ist wieder frei')
assert.equal(fx.alle(root, 'input:disabled, select:disabled, button:disabled').length, 0)
void selbst
} finally {
globalThis.fetch = echtes
}
}))
// ---------------------------------------------------------------------------------------------------------------- Fehler des Servers
test('409 beim Speichern: Meldung „Inzwischen geändert – bitte neu laden.“ und Neu-laden-Knopf; der Knopf lädt und zeichnet neu', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)