Files
Webklar-Kundenbereich/public/zeiten-ausnahmen.js
T
ClaudeandClaude Opus 5.5 04a3525f79 Öffnungszeiten: Editor für Pläne, Feiertage und Ausnahmen
Speichern über PUT /api/zeiten (Version, 409, Feldfehler per ID-Pfad), Zeitpläne mit
Wochentagen, Hauptplan wechseln und Löschen (R10, R33), Feiertage je Bundesland,
Betriebsurlaub und Sondertage mit Hinweis-Leisten-Frage. Die Hülle behält den Editor
bei einem neuen Stand und zeichnet nur den Kopf neu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 06:02:31 +00:00

281 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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,
ohne Knoepfe zum Aendern. Stil: termine-team-ausnahmen.js (Abwesenheiten, Feiertage, Sonderoeffnungen).
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.
Ausnahmen: Betriebsurlaub (von–bis, Text) und Sondertag (Datum, geschlossen oder Zeiten), Geltung „alle Pläne“ oder ein Plan. Nach dem Speichern
eines Urlaubs ab 2 Tagen fragt der Editor „Hinweis-Leiste dazu anlegen?“ (ctx.hinweisFrage); „Ja“ legt den Vorschlag (hinweisVorschlag) in
daten.hinweise an und speichert. */
import { BUNDESLAENDER, datumKurz, datumLang, heuteBerlin, istTag } from './termine-view.js'
import { abschnitt, allgemeinerFehler, editorZeile, feld, frage, gesperrt, h, neuerEintrag, zeigeFehler } from './termine-dom.js'
import { MODI, ZEITEN_LIMITS, ZEITEN_MAX, hinweisVorschlag, neueZeitenId, tagFehler } from './zeiten-view.js'
import { entfernenKnopf, nachSpeichern, stelleVon } from './zeiten-speichern.js'
import { MODUS_WAHL, tagFelder, tagText } from './zeiten-plaene.js'
const nachBeginn = (x, y) => (x.von < y.von ? -1 : x.von > y.von ? 1 : x.bis < y.bis ? -1 : x.bis > y.bis ? 1 : 0)
const zeitraum = (a) => (a.von === a.bis ? datumKurz(a.von) : `${datumKurz(a.von)} – ${datumKurz(a.bis)}`)
/* -------------------------------------------------------------- Feiertage --- */
const FEIERTAG_WAHL = Object.freeze([['', 'geschlossen (Vorgabe)'], ...MODUS_WAHL.filter(([wert]) => wert !== 'zu')])
const PRUEFEN_WAHL = Object.freeze([['', 'wie im Wochenplan (Vorgabe)'], ...MODUS_WAHL])
export function feiertage(t) {
const { ctx, sp } = t
const daten = ctx.stand.daten
const nurLesen = ctx.nurLesen
const gespeichert = daten.feiertage !== null && typeof daten.feiertage === 'object' ? daten.feiertage : {}
// Bundesland: eigenes Formular, denn die Liste darunter kommt nach dem Speichern vom Server neu
const land = h('select', null, BUNDESLAENDER.map(([key, name]) => h('option', { value: key, selected: (daten.land || 'RP') === key }, name)))
const landForm = h('form', { novalidate: true, 'aria-label': 'Bundesland' },
gesperrt(nurLesen, feld('land', 'Bundesland', land, 'Danach richten sich die Feiertage unten.')),
allgemeinerFehler(),
nurLesen ? null : h('div', { class: 'tm-frage-knoepfe' }, h('button', { type: 'submit', class: 'kb-btn-outline', 'data-aktion': 'land-speichern' }, 'Bundesland speichern')))
landForm.addEventListener('submit', async (ev) => {
ev.preventDefault()
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"]')
})
const zeilen = ctx.stand.feiertage.map((f) => {
const pruefen = f.art === 'pruefen'
const abweichung = Object.hasOwn(gespeichert, f.tag) ? gespeichert[f.tag] : null
// ein gespeichertes „geschlossen“ an einem gesetzlichen Feiertag ist die Vorgabe
const modus = abweichung && MODI.includes(abweichung.modus) && !(!pruefen && abweichung.modus === 'zu') ? abweichung.modus : ''
const tag = tagFelder({ pfad: `feiertage.${f.tag}`, label: `${f.name}, ${datumKurz(f.tag)}`, wahl: pruefen ? PRUEFEN_WAHL : FEIERTAG_WAHL, modus, fenster: abweichung?.fenster })
const el = h('li', { class: 'tm-eintrag zt-feiertag', 'data-feiertag': f.tag },
h('div', { class: 'tm-kopf' },
h('div', { class: 'tm-haupt' }, h('strong', { class: 'tm-name' }, datumLang(f.tag)), h('span', { class: 'tm-meta' }, f.name)),
pruefen ? h('span', { class: 'tm-pille tm-pille-wait' }, 'bitte prüfen') : null),
tag.el)
return { f, tag, el }
})
const speichern = h('button', { type: 'submit', class: 'kb-btn-primary', 'data-aktion': 'feiertage-speichern' }, 'Feiertage speichern')
const form = h('form', { novalidate: true, 'aria-label': 'Feiertage' },
zeilen.length
? gesperrt(nurLesen, h('ul', { class: 'tm-eintraege', 'aria-label': 'Feiertage der nächsten 12 Monate' }, zeilen.map(({ el }) => el)))
: h('p', { class: 'tm-leer' }, 'Für dieses Bundesland gibt es in den nächsten 12 Monaten keine Feiertage.'),
allgemeinerFehler(),
nurLesen || !zeilen.length ? null : h('div', { class: 'tm-frage-knoepfe' }, speichern))
form.addEventListener('submit', async (ev) => {
ev.preventDefault()
const ok = await sp.speichere((entwurf) => {
// nur Abweichungen von der Vorgabe: alles andere (auch Tage ausserhalb der Liste) bleibt, wie es war
const neu = { ...(entwurf.feiertage || {}) }
const fehler = {}
for (const { f, tag } of zeilen) {
const r = tag.lies()
if (r.fehler) {
Object.assign(fehler, r.fehler)
continue
}
if (r.modus === '' || (f.art !== 'pruefen' && r.modus === 'zu')) {
delete neu[f.tag]
continue
}
const eintrag = { modus: r.modus, fenster: r.fenster }
const meldung = tagFehler(eintrag)
if (meldung) fehler[`feiertage.${f.tag}`] = meldung
neu[f.tag] = eintrag
}
if (Object.keys(fehler).length) {
zeigeFehler(form, { fehler })
return false
}
entwurf.feiertage = neu
return true
}, { container: form })
if (!ok || !sp.nochHier()) return
nachSpeichern(t, 'Feiertage gespeichert.', 'form[aria-label="Feiertage"] button[type="submit"]')
})
return 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)
}
/* -------------------------------------------------------------- Ausnahmen --- */
const gibtHinweis = (hinweise, v) => (Array.isArray(hinweise) ? hinweise : []).some((x) => x?.text === v.text && x.von === v.von && x.bis === v.bis)
const planName = (daten, id) => (daten.plaene || []).find((p) => p?.id === id)?.name
export function ausnahmen(t) {
const { ctx } = t
const daten = ctx.stand.daten
const alle = Array.isArray(daten.ausnahmen) ? daten.ausnahmen : []
const heute = heuteBerlin()
const kommend = alle.filter((a) => a.bis >= heute).sort(nachBeginn)
const vorbei = alle.filter((a) => a.bis < heute).sort(nachBeginn).reverse()
const voll = alle.length >= ZEITEN_LIMITS.ausnahmen
const grenzText = `Mehr als ${ZEITEN_LIMITS.ausnahmen} Ausnahmen sind nicht möglich.`
const urlaubNeu = neuerEintrag({
nurLesen: ctx.nurLesen, text: '+ Betriebsurlaub eintragen', werkzeug: 'urlaub-neu', voll, grenzText,
baue: (schliessen) => formular(t, { a: null, art: 'urlaub', schliessen }),
})
const sonderNeu = neuerEintrag({
nurLesen: ctx.nurLesen, text: '+ Sondertag eintragen', werkzeug: 'sondertag-neu', voll, grenzText: '',
baue: (schliessen) => formular(t, { a: null, art: 'sondertag', schliessen }),
})
const reihe = (a) => {
const art = a.art === 'sondertag' ? 'Sondertag' : 'Betriebsurlaub'
const plan = a.plan ? `nur ${planName(daten, a.plan) || 'ein gelöschter Plan'}` : ''
return editorZeile({
attrs: { 'data-id': a.id },
nurLesen: ctx.nurLesen,
label: `${art} ${datumKurz(a.von)}`,
haupt: [
h('strong', { class: 'tm-name' }, zeitraum(a)),
h('span', { class: 'tm-meta' }, [art, a.art === 'sondertag' ? tagText(a) : '', a.text, plan].filter(Boolean).join(' · ')),
],
baue: (schliessen) => formular(t, { a, art: a.art === 'sondertag' ? 'sondertag' : 'urlaub', schliessen }),
})
}
return abschnitt('Ausnahmen',
'Betriebsurlaub und einzelne Sondertage gehen dem Wochenplan und den Feiertagen vor. Jede Ausnahme gilt für alle Pläne oder nur für einen.',
hinweisFrage(t),
h('div', { class: 'zt-werkzeuge' }, urlaubNeu.leiste, sonderNeu.leiste),
urlaubNeu.box,
sonderNeu.box,
kommend.length
? h('ul', { class: 'tm-eintraege', 'aria-label': 'Kommende Ausnahmen' }, kommend.map(reihe))
: h('p', { class: 'tm-leer' }, 'Keine kommenden Ausnahmen eingetragen.'),
vorbei.length
? h('details', { class: 'tm-details' }, h('summary', null, `Vergangene Ausnahmen (${vorbei.length})`), h('ul', { class: 'tm-eintraege' }, vorbei.map(reihe)))
: null)
}
/**
* „Hinweis-Leiste dazu anlegen?“ nach einem Betriebsurlaub ab 2 Tagen (ctx.hinweisFrage = { vorschlag }): Text und Zeitraum sind vorbefuellt
* (hinweisVorschlag: sichtbar ab 7 Tage vor Beginn bis zum Urlaubsende). „Ja“ legt den Hinweis in daten.hinweise an und speichert; „Nein“ laesst es.
*/
function hinweisFrage(t) {
const { ctx, sp } = t
const f = ctx.hinweisFrage
if (!f || ctx.nurLesen) return null
const v = f.vorschlag
const form = frage('Hinweis-Leiste dazu anlegen?',
h('p', null, `„${v.text}“ – sichtbar vom ${datumKurz(v.von)} bis ${datumKurz(v.bis)}, oben auf deiner Website.`),
allgemeinerFehler(),
h('div', { class: 'tm-frage-knoepfe' },
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()
t.wurzel.querySelector('[data-werkzeug="urlaub-neu"]')?.focus()
} }, 'Nein, danke')))
form.dataset.frage = 'hinweis'
form.addEventListener('submit', async () => {
const ok = await sp.speichere((entwurf) => {
entwurf.hinweise = [...(Array.isArray(entwurf.hinweise) ? entwurf.hinweise : []), { id: neueZeitenId('h'), art: v.art, text: v.text, knopf: null, von: v.von, bis: v.bis }]
}, { container: form })
if (!ok || !sp.nochHier()) return
ctx.hinweisFrage = null
nachSpeichern(t, `Die Hinweis-Leiste „${v.text}“ ist angelegt.`, '[data-werkzeug="urlaub-neu"]')
})
return form
}
/**
* Formular einer neuen (a null) oder bestehenden Ausnahme der Art 'urlaub' (von, bis) oder 'sondertag' (ein Datum, Betriebsart, Zeiten).
* Die Felder heissen nach dem Pfad der Fehler des Servers (ausnahmen.<ID>.von …); Absenden sucht die Ausnahme per ID.
*/
function formular(t, { a, art, schliessen }) {
const { ctx, sp } = t
const daten = ctx.stand.daten
const neu = a === null
const alt = a || { id: neueZeitenId('x'), art, von: heuteBerlin(), bis: heuteBerlin(), text: '', modus: 'zu', fenster: [], plan: null }
const p = `ausnahmen.${alt.id}`
const urlaub = art === 'urlaub'
const wort = urlaub ? 'Betriebsurlaub' : 'Sondertag'
const von = h('input', { type: 'date', value: alt.von })
const bis = h('input', { type: 'date', value: alt.bis })
const text = h('input', { type: 'text', maxlength: ZEITEN_MAX.ausnahmeText, autocomplete: 'off', placeholder: urlaub ? 'z. B. Sommerpause' : 'z. B. Tag der offenen Tür', value: alt.text })
const tag = urlaub ? null : tagFelder({ pfad: `${p}.modus`, fensterPfad: `${p}.fenster`, label: 'Sondertag', wahl: MODUS_WAHL, sichtbar: true, modus: alt.modus, fenster: alt.fenster })
const plaene = Array.isArray(daten.plaene) ? daten.plaene : []
const geltung = plaene.length > 1 || alt.plan
? h('select', null,
h('option', { value: '', selected: !alt.plan }, 'Alle Pläne'),
plaene.map((x) => h('option', { value: x.id, selected: alt.plan === x.id }, x.name)))
: null
const entfernen = neu || ctx.nurLesen ? null : entfernenKnopf(sp, {
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"]'),
})
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` },
h('h3', null, neu ? (urlaub ? 'Neuer Betriebsurlaub' : 'Neuer Sondertag') : wort),
gesperrt(ctx.nurLesen, [
urlaub
? h('div', { class: 'tm-raster' }, feld(`${p}.von`, 'Von (erster Tag)', von), feld(`${p}.bis`, 'Bis (letzter Tag)', bis))
: feld(`${p}.von`, 'Datum', von),
tag?.el,
feld(`${p}.text`, 'Text (optional)', text, 'Höchstens 60 Zeichen.'),
geltung ? feld(`${p}.plan`, 'Gilt für', geltung) : null,
]),
allgemeinerFehler(),
h('div', { class: 'tm-frage-knoepfe' },
ctx.nurLesen ? null : speichern,
h('button', { type: 'button', class: 'kb-btn-outline', onclick: schliessen }, ctx.nurLesen ? 'Schließen' : 'Abbrechen'),
entfernen?.knopf))
form.addEventListener('submit', async (ev) => {
ev.preventDefault()
let eintrag = null
const ok = await sp.speichere((entwurf) => {
const fehler = {}
if (!istTag(von.value)) fehler[`${p}.von`] = 'Bitte gib ein gültiges Datum an.'
let ende = von.value
if (urlaub) {
ende = bis.value
if (!istTag(bis.value)) fehler[`${p}.bis`] = 'Bitte gib ein gültiges Datum an.'
else if (istTag(von.value) && bis.value < von.value) fehler[`${p}.bis`] = 'Das Ende darf nicht vor dem Beginn liegen.'
}
let betrieb = { modus: 'zu', fenster: [] }
if (tag) {
const r = tag.lies()
if (r.fehler) Object.assign(fehler, r.fehler)
else {
const meldung = tagFehler(r)
if (meldung) fehler[`${p}.fenster`] = meldung
betrieb = r
}
}
if (Object.keys(fehler).length) {
zeigeFehler(form, { fehler })
return false
}
eintrag = { ...alt, art, von: von.value, bis: ende, text: text.value.trim(), modus: betrieb.modus, fenster: betrieb.fenster, plan: geltung ? (geltung.value || null) : alt.plan ?? null }
if (neu) {
entwurf.ausnahmen = [...(Array.isArray(entwurf.ausnahmen) ? entwurf.ausnahmen : []), eintrag]
return true
}
const stelle = stelleVon(entwurf.ausnahmen, alt.id, form, `Dieser ${wort}`)
if (stelle < 0) return false
entwurf.ausnahmen[stelle] = eintrag
return true
}, { container: form })
if (!ok || !sp.nochHier()) return
// Urlaub ab 2 Tagen: die Hinweis-Leiste dazu anbieten (nicht noch einmal, wenn es sie schon gibt oder sich der Zeitraum nicht geaendert hat)
if (urlaub) {
const vorschlag = hinweisVorschlag(eintrag)
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"]`)
})
return h('div', { class: 'tm-editor' }, form, entfernen?.box)
}