Ö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 /* 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 (t) => Element; t = { ctx, sp, zeichne, wurzel } kommt aus zeiten-editor.js (sp = Speicher des Editors, zeichne([…]) zeichnet einzelne
neu). Jede Aenderung speichert die ganzen Daten (zeiten-speichern.js). Abgeschaltete Funktion (ctx.nurLesen): dieselben Felder gesperrt, 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). 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 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 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. „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 FEIERTAG_WAHL = Object.freeze([['', 'geschlossen (Vorgabe)'], ...MODUS_WAHL.filter(([wert]) => wert !== 'zu')])
const PRUEFEN_WAHL = Object.freeze([['', 'wie im Wochenplan (Vorgabe)'], ...MODUS_WAHL]) 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) { export function feiertage(t) {
const { ctx, sp } = t const { ctx, sp } = t
const daten = ctx.stand.daten 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 }) const ok = await sp.speichere((entwurf) => { entwurf.land = land.value }, { container: landForm })
if (!ok || !sp.nochHier()) return if (!ok || !sp.nochHier()) return
const name = BUNDESLAENDER.find(([key]) => key === land.value)?.[1] ?? land.value 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) => { const zeilen = ctx.stand.feiertage.map((f) => {
@@ -92,14 +106,17 @@ export function feiertage(t) {
return true return true
}, { container: form }) }, { container: form })
if (!ok || !sp.nochHier()) return 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.', '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, landForm,
h('p', { class: 'tm-hinweis' }, 'Gemeinde-Feiertage sind nicht enthalten. Trage sie bei Bedarf unten als Sondertag ein.'), h('p', { class: 'tm-hinweis' }, 'Gemeinde-Feiertage sind nicht enthalten. Trage sie bei Bedarf unten als Sondertag ein.'),
form) form)
ZEILEN.set(el, new Map(zeilen.map(({ f, tag }) => [f.tag, tag])))
return el
} }
/* -------------------------------------------------------------- Ausnahmen --- */ /* -------------------------------------------------------------- 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: 'submit', class: 'kb-btn-primary', 'data-aktion': 'hinweis-ja' }, 'Ja, anlegen'),
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'hinweis-nein', onclick: () => { h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'hinweis-nein', onclick: () => {
ctx.hinweisFrage = null 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() t.wurzel.querySelector('[data-werkzeug="urlaub-neu"]')?.focus()
} }, 'Nein, danke'))) } }, 'Nein, danke')))
form.dataset.frage = 'hinweis' form.dataset.frage = 'hinweis'
@@ -179,7 +196,7 @@ function hinweisFrage(t) {
}, { container: form }) }, { container: form })
if (!ok || !sp.nochHier()) return if (!ok || !sp.nochHier()) return
ctx.hinweisFrage = null 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 return form
} }
@@ -212,7 +229,7 @@ function formular(t, { a, art, schliessen }) {
titel: `${wort} entfernen?`, 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).', 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) }, 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 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` }, 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 const geaendert = neu || alt.von !== eintrag.von || alt.bis !== eintrag.bis
ctx.hinweisFrage = vorschlag && geaendert && !gibtHinweis(ctx.stand.daten.hinweise, vorschlag) ? { vorschlag } : null 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) return h('div', { class: 'tm-editor' }, form, entfernen?.box)
} }
+35 -7
View File
@@ -9,6 +9,8 @@
leereDaten(), leer() } leereDaten(), leer() }
stand = Antwort des Servers { state, daten (null vor dem ersten Speichern), version, kundenansicht, vorschau, feiertage, googleText }; sie wird bei jedem 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 }. 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 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 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). */ 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 { leererPlan } from './zeiten-view.js'
import { neuerZeitenSpeicher } from './zeiten-speichern.js' import { neuerZeitenSpeicher } from './zeiten-speichern.js'
import { plaene } from './zeiten-plaene.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 = [ const ABSCHNITTE = [
{ key: 'plaene', bauen: plaene, mitDaten: false }, { 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 }, { key: 'ausnahmen', bauen: ausnahmen, mitDaten: true },
] ]
@@ -114,8 +119,33 @@ export async function renderEditor(root, ctx = {}) {
const sp = neuerZeitenSpeicher(ec, root, zeichne) const sp = neuerZeitenSpeicher(ec, root, zeichne)
const t = { ctx: ec, sp, zeichne, wurzel: root } 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() 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] 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 (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))) 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) teile.push(h('div', { class: 'zt-kopfbereich' })) // Platz der Live-Vorschau (Aufgabe 30)
for (const abschnitt of ABSCHNITTE) { for (const abschnitt of ABSCHNITTE) {
if (abschnitt.mitDaten && leer) continue if (abschnitt.mitDaten && leer) continue
const el = abschnitt.bauen(t) teile.push(baueAbschnitt(abschnitt))
el.dataset.abschnitt = abschnitt.key
teile.push(el)
} }
if (leer && !nurLesen) teile.push(h('p', { class: 'tm-hinweis zt-spaeter' }, SPAETER)) if (leer && !nurLesen) teile.push(h('p', { class: 'tm-hinweis zt-spaeter' }, SPAETER))
root.replaceChildren(...teile) 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 /* 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 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 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“, 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). „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 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. * 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 * 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 * 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). * 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). * 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 * => { el, lies, merke, stelleWieder }: lies() liefert { modus, fenster } oder { fehler: { [pfad]: Text } } (ein Paar nur halb ausgefuellt). Die Regeln der
* der Aufrufer (tagFehler/wochenFehler) und der Server. * 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 }) { 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' 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 felder
let modusWert let modusWert
let zeige let zeige
let setzeModus
if (haken) { if (haken) {
const geoeffnet = h('input', { type: 'checkbox', checked: start !== 'zu' && start !== '' }) const geoeffnet = h('input', { type: 'checkbox', checked: start !== 'zu' && start !== '' })
geoeffnet.dataset.feld = pfad 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'))) 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) felder = h('div', { class: 'zt-tag-felder' }, art, zeiten)
modusWert = () => (geoeffnet.checked ? art.value : 'zu') modusWert = () => (geoeffnet.checked ? art.value : 'zu')
setzeModus = (wert) => {
geoeffnet.checked = wert.an === true
if (hatOption(art, wert.art)) art.value = wert.art
}
zeige = () => { zeige = () => {
felder.hidden = !geoeffnet.checked felder.hidden = !geoeffnet.checked
zeiten.hidden = !(geoeffnet.checked && art.value === 'zeiten') 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) 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) felder = h('div', { class: 'zt-tag-felder' }, zeiten)
modusWert = () => auswahl.value modusWert = () => auswahl.value
setzeModus = (wert) => { if (hatOption(auswahl, wert.wahl)) auswahl.value = wert.wahl }
zeige = () => { zeiten.hidden = auswahl.value !== 'zeiten' } zeige = () => { zeiten.hidden = auswahl.value !== 'zeiten' }
auswahl.addEventListener('change', zeige) auswahl.addEventListener('change', zeige)
} }
@@ -98,7 +111,22 @@ export function tagFelder({ pfad, fensterPfad = pfad, label, modus = 'zu', fenst
} }
return { modus: art, fenster: gelesen } 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 --- */ /* ----------------------------------------------------------------- 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). */ /** 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 planMitId = (ctx, id) => (ctx.stand.daten?.plaene || []).find((p) => p?.id === id)
const planSelektor = (id, rest) => `details[data-plan="${CSS.escape(id)}"] ${rest}` 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) { export function plaene(t) {
const { ctx } = t const { ctx } = t
@@ -114,16 +148,51 @@ export function plaene(t) {
const liste = daten ? daten.plaene : [ctx.entwurfsplan] const liste = daten ? daten.plaene : [ctx.entwurfsplan]
const neu = daten ? neuerEintrag({ const neu = daten ? neuerEintrag({
nurLesen: ctx.nurLesen, text: '+ Plan hinzufügen', werkzeug: 'plan-neu', 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 }), baue: (schliessen) => neuFormular(t, { schliessen }),
}) : null }) : null
const voll = liste.length >= ZEITEN_LIMITS.plaene
const knopf = neu?.leiste?.querySelector('button')
if (knopf) knopf.disabled = voll
return abschnitt('Zeitpläne', 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.', '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?.leiste,
neu ? h('p', { class: 'tm-hinweis zt-grenze', 'data-grenze': '' }, voll ? grenzText : '') : null,
neu?.box, neu?.box,
h('div', { class: 'zt-plaene' }, liste.map((plan) => planKarte(t, plan)))) 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). */ /** 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 }) { function neuFormular(t, { schliessen }) {
const { ctx, sp } = t const { ctx, sp } = t
@@ -156,7 +225,11 @@ function neuFormular(t, { schliessen }) {
if (!ok || !sp.nochHier()) return if (!ok || !sp.nochHier()) return
ctx.offen.add(neuerPlan.id) ctx.offen.add(neuerPlan.id)
const gespeichert = planMitId(ctx, neuerPlan.id) // Name und Kurzname gelten so, wie der Server sie gespeichert hat 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 return form
} }
@@ -219,7 +292,10 @@ function planKarte(t, plan) {
}, },
nachher: () => { nachher: () => {
ctx.offen.delete(plan.id) 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 }) : null
@@ -251,7 +327,15 @@ function planKarte(t, plan) {
return true return true
}, { container: form }) }, { container: form })
if (!ok || !sp.nochHier()) return 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 () => { hauptKnopf?.addEventListener('click', async () => {
@@ -261,9 +345,16 @@ function planKarte(t, plan) {
return true return true
}, { container: form }) }, { container: form })
if (!ok || !sp.nochHier()) return 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( details.append(
h('summary', { class: 'zt-plan-kopf' }, h('summary', { class: 'zt-plan-kopf' },
h('strong', { class: 'tm-name' }, plan.name), 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. '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. - 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. - 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 - Es laeuft immer nur ein Speichern (PUT und das Uebernehmen): solange der PUT laeuft, ist der Editor aria-busy, alle seine Knoepfe UND Eingabefelder
ein weiterer Aufruf von speichere (z. B. ein Enter im Formular) wird abgewiesen (Rueckgabe false), statt mit einer veralteten Version zu gehen. 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). 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 }) }. */ 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) { function zeigeFelder(container, fehler, quellen) {
const amFeld = {} const amFeld = {}
const rest = [] const rest = []
@@ -73,13 +76,12 @@ function zeigeFelder(container, fehler, quellen) {
else rest.push(beschrifteFehler(pfad, text, quellen)) else rest.push(beschrifteFehler(pfad, text, quellen))
} }
setzeFehler(container, rest.length ? { ...amFeld, _: rest.join(' ') } : amFeld) 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), * 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“). * 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). * Editor noch angezeigt wird (vor jedem Anfassen der Seite nach einem await pruefen).
*/ */
export function neuerZeitenSpeicher(ctx, wurzel, neuZeichnen) { 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 let laeuft = false
function sperre(an, gesperrt) { function sperre(an, gesperrt) {
if (an) { if (an) {
wurzel.setAttribute('aria-busy', 'true') 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 const fokus = document.activeElement
for (const knopf of knoepfe) knopf.disabled = true for (const steuerung of steuerungen) steuerung.disabled = true
return { knoepfe, fokus } return { steuerungen, fokus }
} }
wurzel.removeAttribute('aria-busy') wurzel.removeAttribute('aria-busy')
for (const knopf of gesperrt.knoepfe) knopf.disabled = false for (const steuerung of gesperrt.steuerungen) steuerung.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 // 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 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 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 }) { 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, {}) setzeFehler(container, {})
konflikt.hidden = true konflikt.hidden = true
const bisher = ctx.stand.daten const bisher = ctx.stand.daten
const entwurf = structuredClone(bisher ?? ctx.leereDaten()) const entwurf = structuredClone(bisher ?? ctx.leereDaten())
if (aendere(entwurf) === false) return false // der Abschnitt hat selbst Fehler am Feld gezeigt if (aendere(entwurf) === false) return false // der Abschnitt hat selbst Fehler am Feld gezeigt
gesperrt = sperre(true)
try { try {
const antwort = await request(`/api/zeiten/${encodeURIComponent(ctx.projectId)}`, { method: 'PUT', body: { version: ctx.stand.version, daten: entwurf } }) const antwort = await request(`/api/zeiten/${encodeURIComponent(ctx.projectId)}`, { method: 'PUT', body: { version: ctx.stand.version, daten: entwurf } })
if (!ctx.aktuell()) return false if (!ctx.aktuell()) return false
@@ -153,16 +147,20 @@ export function neuerZeitenSpeicher(ctx, wurzel, neuZeichnen) {
konflikt.scrollIntoView({ block: 'nearest' }) konflikt.scrollIntoView({ block: 'nearest' })
} else if (err.status === 400 && Object.keys(err.fehler || {}).length) { } else if (err.status === 400 && Object.keys(err.fehler || {}).length) {
zeigeFelder(container, err.fehler, [bisher, entwurf]) zeigeFelder(container, err.fehler, [bisher, entwurf])
amFeld = true
} else { } else {
ctx.meldung(err.message, 'fehler') ctx.meldung(err.message, 'fehler')
} }
return false return false
} }
}) } finally {
return ergebnis === true 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 * Nach einem erfolgreichen Speichern: neu zeichnen, was das Speichern geaendert hat (neuZeichnen(); ohne Angabe der ganze Editor – die Abschnitte
* auf den Knopf der gespeicherten Zeile, nach dem Entfernen auf den „+“-Knopf der Liste). * 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) { export function nachSpeichern(t, meldung, selektor, neuZeichnen = () => t.zeichne()) {
t.zeichne() neuZeichnen()
t.ctx.meldung(meldung) t.ctx.meldung(meldung)
t.wurzel.querySelector(selektor)?.focus() t.wurzel.querySelector(selektor)?.focus()
} }
+2
View File
@@ -39,6 +39,8 @@
.zt-plan > .tm-editor { margin: 0; padding: 0 16px 16px; } .zt-plan > .tm-editor { margin: 0; padding: 0 16px 16px; }
.zt-plan-form .tm-frage-knoepfe { margin-top: 16px; } .zt-plan-form .tm-frage-knoepfe { margin-top: 16px; }
.zt-plan-form > .tm-hinweis { margin-top: 10px; } .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 { display: flex; flex-wrap: wrap; gap: 0 10px; }
.zt-werkzeuge:empty { display: none; } .zt-werkzeuge:empty { display: none; }
.zt-editor .tm-abschnitt > .tm-frage-box { margin: 12px 0 16px; } .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/) 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 // ---------------------------------------------------------------------------------------------------------------- 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) => { 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) const { root } = await oeffne(fx)