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>
275 lines
16 KiB
JavaScript
275 lines
16 KiB
JavaScript
/* 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).
|
||
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).
|
||
Vor dem ersten Speichern (ctx.stand.daten null) gibt es nur den leeren Hauptplan „Öffnungszeiten“ (ctx.entwurfsplan); er gehoert zu
|
||
ctx.leereDaten(), mit denen das erste Speichern geht. Pruefungen im Browser (wochenFehler aus zeiten-view.js) zeigen Fehler nur frueher an,
|
||
entscheiden tut der Server. Abgeschaltete Funktion (ctx.nurLesen): dieselben Felder gesperrt, ohne Knoepfe zum Aendern. */
|
||
|
||
import { WOCHENTAGE_PLAN } from './termine-view.js'
|
||
import { abschnitt, allgemeinerFehler, feld, gesperrt, h, neuerEintrag, zeigeFehler } from './termine-dom.js'
|
||
import { MODI, ZEITEN_LIMITS, ZEITEN_MAX, hauptplanWechseln, kurzname, leererPlan, planLoeschenGrund, wochenFehler } from './zeiten-view.js'
|
||
import { entfernenKnopf, nachSpeichern, stelleVon } from './zeiten-speichern.js'
|
||
|
||
/** Betriebsart eines Tages, Ausnahme oder Feiertags: Wert und Beschriftung. */
|
||
export const MODUS_WAHL = Object.freeze([
|
||
['zu', 'geschlossen'], ['zeiten', 'geöffnet zu festen Zeiten'], ['rund', 'rund um die Uhr'], ['vereinbarung', 'nach Vereinbarung'],
|
||
])
|
||
const ART_WAHL = MODUS_WAHL.filter(([wert]) => wert !== 'zu') // Wochentag: „geöffnet“ ist der Haken, die Art steht daneben
|
||
|
||
/** Kurzfassung eines Tages: „geschlossen“, „09:00–12:30, 13:30–18:00“, „rund um die Uhr“, „nach Vereinbarung“. */
|
||
export function tagText(tag) {
|
||
if (tag?.modus === 'rund') return 'rund um die Uhr'
|
||
if (tag?.modus === 'vereinbarung') return 'nach Vereinbarung'
|
||
if (tag?.modus === 'zeiten' && Array.isArray(tag.fenster) && tag.fenster.length) return tag.fenster.map((f) => `${f[0]}–${f[1]}`).join(', ')
|
||
return 'geschlossen'
|
||
}
|
||
|
||
/** <input type="time"> kennt kein 24:00: ein gespeichertes Ende um 24:00 steht als 00:00 da (dasselbe Ende: „bis Mitternacht“). */
|
||
const zeitWert = (wert) => (wert === '24:00' ? '00:00' : typeof wert === 'string' ? wert : '')
|
||
|
||
let idZaehler = 0
|
||
|
||
/**
|
||
* 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
|
||
* [Wert, Text]; ein Wert '' steht fuer „Vorgabe“ und liest sich als modus '').
|
||
* 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.
|
||
*/
|
||
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 zeitFeld = (i, teil, wert) => h('input', { type: 'time', step: '300', value: zeitWert(wert), 'aria-label': `${label}, Zeitfenster ${i + 1}, ${teil}` })
|
||
const paare = [0, 1].map((i) => ({ von: zeitFeld(i, 'von', fenster?.[i]?.[0]), bis: zeitFeld(i, 'bis', fenster?.[i]?.[1]) }))
|
||
if (fensterPfad !== pfad) paare[0].von.dataset.feld = fensterPfad
|
||
const zeiten = h('div', { class: 'zt-zeiten' }, paare.map((paar, i) => h('div', { class: 'zt-fenster' },
|
||
h('span', { class: 'zt-fenster-name' }, i === 0 ? 'Zeitfenster' : '2. Zeitfenster (optional)'),
|
||
h('label', { class: 'zt-zeit' }, h('span', null, 'von'), paar.von),
|
||
h('label', { class: 'zt-zeit' }, h('span', null, 'bis'), paar.bis))))
|
||
|
||
let kopf
|
||
let felder
|
||
let modusWert
|
||
let zeige
|
||
if (haken) {
|
||
const geoeffnet = h('input', { type: 'checkbox', checked: start !== 'zu' && start !== '' })
|
||
geoeffnet.dataset.feld = pfad
|
||
const art = h('select', { 'aria-label': `${label}: Art der Öffnung` }, ART_WAHL.map(([wert, text]) => h('option', { value: wert, selected: wert === (start === 'zu' || start === '' ? 'zeiten' : start) }, text)))
|
||
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')
|
||
zeige = () => {
|
||
felder.hidden = !geoeffnet.checked
|
||
zeiten.hidden = !(geoeffnet.checked && art.value === 'zeiten')
|
||
}
|
||
geoeffnet.addEventListener('change', zeige)
|
||
art.addEventListener('change', zeige)
|
||
} else {
|
||
const id = `zt-t${++idZaehler}`
|
||
const auswahl = h('select', { id }, wahl.map(([wert, text]) => h('option', { value: wert, selected: wert === start }, text)))
|
||
auswahl.dataset.feld = pfad
|
||
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
|
||
zeige = () => { zeiten.hidden = auswahl.value !== 'zeiten' }
|
||
auswahl.addEventListener('change', zeige)
|
||
}
|
||
zeige()
|
||
|
||
const el = h('div', { class: 'zt-tag' }, kopf, felder,
|
||
h('p', { class: 'tm-fehler', 'data-fehler': pfad, role: 'alert' }),
|
||
fensterPfad !== pfad ? h('p', { class: 'tm-fehler', 'data-fehler': fensterPfad, role: 'alert' }) : null)
|
||
|
||
function lies() {
|
||
const art = modusWert()
|
||
if (art !== 'zeiten') return { modus: art, fenster: [] }
|
||
const gelesen = []
|
||
for (const paar of paare) {
|
||
const von = paar.von.value
|
||
const bis = paar.bis.value
|
||
if (!von && !bis) continue
|
||
if (!von || !bis) return { fehler: { [fensterPfad]: 'Bitte gib Beginn und Ende des Zeitfensters an.' } }
|
||
gelesen.push([von, bis])
|
||
}
|
||
return { modus: art, fenster: gelesen }
|
||
}
|
||
return { el, lies }
|
||
}
|
||
|
||
/* ----------------------------------------------------------------- Zeitplaene --- */
|
||
const LOESCHEN_WARNUNG = 'Steht dieser Plan auf deiner Website, zeigt sie dort wieder die feste Tabelle.'
|
||
|
||
/** 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}`
|
||
|
||
export function plaene(t) {
|
||
const { ctx } = t
|
||
const daten = ctx.stand.daten
|
||
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
|
||
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?.box,
|
||
h('div', { class: 'zt-plaene' }, liste.map((plan) => planKarte(t, plan))))
|
||
}
|
||
|
||
/** 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
|
||
const name = h('input', { type: 'text', maxlength: ZEITEN_MAX.name, autocomplete: 'off', placeholder: 'z. B. Küche oder Lieferdienst' })
|
||
const vorschau = h('code', { 'data-kurz-vorschau': '' }, '')
|
||
name.addEventListener('input', () => {
|
||
vorschau.textContent = name.value.trim() ? kurzname(name.value, ctx.stand.daten.plaene.map((p) => p.kurz)) : ''
|
||
})
|
||
const form = h('form', { class: 'kb-panel tm-formular', novalidate: true, 'aria-label': 'Plan hinzufügen' },
|
||
h('h3', null, 'Neuer Zeitplan'),
|
||
feld('name', 'Name des Zeitplans', name, 'Die Zeiten trägst du danach im neuen Plan ein.'),
|
||
h('p', { class: 'tm-hinweis' }, 'Kurzname: ', vorschau, ' – wird beim Speichern festgelegt und bleibt dann immer gleich.'),
|
||
allgemeinerFehler(),
|
||
h('div', { class: 'tm-frage-knoepfe' },
|
||
h('button', { type: 'submit', class: 'kb-btn-primary', 'data-aktion': 'plan-anlegen' }, 'Plan anlegen'),
|
||
h('button', { type: 'button', class: 'kb-btn-outline', onclick: schliessen }, 'Abbrechen')))
|
||
form.addEventListener('submit', async (ev) => {
|
||
ev.preventDefault()
|
||
const gewaehlt = name.value.trim()
|
||
let neuerPlan = null
|
||
const ok = await sp.speichere((entwurf) => {
|
||
if (!gewaehlt) {
|
||
zeigeFehler(form, { fehler: { name: 'Bitte gib dem Zeitplan einen Namen.' } })
|
||
return false
|
||
}
|
||
neuerPlan = leererPlan({ name: gewaehlt, kurz: kurzname(gewaehlt, entwurf.plaene.map((p) => p.kurz)) })
|
||
entwurf.plaene = [...entwurf.plaene, neuerPlan]
|
||
return true
|
||
}, { container: form })
|
||
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'))
|
||
})
|
||
return form
|
||
}
|
||
|
||
/** Ein Plan als aufklappbare Karte: Kopf (Name, Hauptplan, Kurzname) und das Formular mit Namen und Wochentagen. */
|
||
function planKarte(t, plan) {
|
||
const { ctx, sp } = t
|
||
const daten = ctx.stand.daten
|
||
const p = `plaene.${plan.id}`
|
||
const nurLesen = ctx.nurLesen
|
||
const details = h('details', { class: 'zt-plan', 'data-plan': plan.id, open: ctx.offen.has(plan.id) })
|
||
details.addEventListener('toggle', () => {
|
||
if (details.hasAttribute('open')) ctx.offen.add(plan.id)
|
||
else ctx.offen.delete(plan.id)
|
||
})
|
||
|
||
const name = h('input', { type: 'text', maxlength: ZEITEN_MAX.name, autocomplete: 'off', value: plan.name })
|
||
const kurzEl = h('code', { 'data-kurz': '' }, plan.kurz)
|
||
// Vor dem ersten Speichern bildet der Server den Kurznamen aus dem Namen von jetzt; danach bleibt er (der Name kann sich aendern, der Kurzname nicht)
|
||
if (!daten) name.addEventListener('input', () => { kurzEl.textContent = kurzname(name.value.trim() || plan.name) })
|
||
const tage = WOCHENTAGE_PLAN.map(([nr, tagName]) => ({
|
||
nr,
|
||
tag: tagFelder({ pfad: `${p}.tage.${nr}`, label: tagName, haken: true, modus: plan.tage?.[nr]?.modus, fenster: plan.tage?.[nr]?.fenster }),
|
||
}))
|
||
for (const { nr, tag } of tage) tag.el.dataset.tag = nr
|
||
|
||
/** Name und Tage aus dem Formular: { name, tage } oder { fehler } (Pfad => Text); wochenFehler zeigt, was der Server auch abweisen wuerde. */
|
||
function lies() {
|
||
const fehler = {}
|
||
const gewaehlt = name.value.trim()
|
||
if (!gewaehlt) fehler[`${p}.name`] = 'Bitte gib dem Zeitplan einen Namen.'
|
||
const gelesen = {}
|
||
for (const { nr, tag } of tage) {
|
||
const r = tag.lies()
|
||
if (r.fehler) Object.assign(fehler, r.fehler)
|
||
else gelesen[nr] = { modus: r.modus, fenster: r.fenster }
|
||
}
|
||
for (const [nr, text] of Object.entries(wochenFehler({ tage: gelesen }))) if (!Object.hasOwn(fehler, `${p}.tage.${nr}`)) fehler[`${p}.tage.${nr}`] = text
|
||
return Object.keys(fehler).length ? { fehler } : { name: gewaehlt, tage: gelesen }
|
||
}
|
||
|
||
const speichern = h('button', { type: 'submit', class: 'kb-btn-primary', 'data-aktion': 'speichern' }, 'Speichern')
|
||
const grund = daten && !nurLesen ? planLoeschenGrund(daten, plan.id) : null
|
||
const mitAusnahmen = daten ? (daten.ausnahmen || []).filter((a) => a?.plan === plan.id).length : 0
|
||
const entfernen = daten && !nurLesen && grund === null ? entfernenKnopf(sp, {
|
||
knopfText: 'Plan löschen',
|
||
ja: 'Ja, löschen',
|
||
titel: `Zeitplan „${plan.name}“ löschen?`,
|
||
text: [
|
||
LOESCHEN_WARNUNG,
|
||
mitAusnahmen === 0 ? '' : mitAusnahmen === 1
|
||
? 'Die eine Ausnahme, die nur für diesen Plan gilt, wird mit gelöscht.'
|
||
: `Die ${mitAusnahmen} Ausnahmen, die nur für diesen Plan gelten, werden mit gelöscht.`,
|
||
],
|
||
fehlerPfad: p,
|
||
// Ausnahmen, die nur fuer diesen Plan galten, haetten danach keinen Plan mehr (R10): sie gehen mit
|
||
aendere: (entwurf) => {
|
||
entwurf.plaene = entwurf.plaene.filter((x) => x.id !== plan.id)
|
||
entwurf.ausnahmen = (entwurf.ausnahmen || []).filter((a) => a?.plan !== plan.id)
|
||
},
|
||
nachher: () => {
|
||
ctx.offen.delete(plan.id)
|
||
nachSpeichern(t, `Der Zeitplan „${plan.name}“ ist gelöscht.`, '[data-werkzeug="plan-neu"]')
|
||
},
|
||
}) : null
|
||
|
||
const hauptKnopf = daten && !nurLesen && !plan.haupt ? h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'haupt' }, 'Zum Hauptplan machen') : null
|
||
|
||
const form = h('form', { class: 'zt-plan-form', novalidate: true, 'aria-label': `Zeitplan ${plan.name}` },
|
||
gesperrt(nurLesen, [
|
||
feld(`${p}.name`, 'Name des Zeitplans', name, daten
|
||
? 'Der Kurzname bleibt gleich, auch wenn du den Namen änderst.'
|
||
: 'Der Kurzname wird beim ersten Speichern aus dem Namen gebildet und bleibt dann immer gleich.'),
|
||
h('div', { class: 'zt-tage' }, tage.map(({ tag }) => tag.el)),
|
||
]),
|
||
allgemeinerFehler(),
|
||
nurLesen ? null : h('div', { class: 'tm-frage-knoepfe' }, speichern, hauptKnopf, entfernen?.knopf),
|
||
grund ? h('p', { class: 'tm-hinweis', 'data-hinweis': 'loeschen' }, grund) : null)
|
||
|
||
form.addEventListener('submit', async (ev) => {
|
||
ev.preventDefault()
|
||
const ok = await sp.speichere((entwurf) => {
|
||
const stelle = stelleVon(entwurf.plaene, plan.id, form, 'Dieser Zeitplan')
|
||
if (stelle < 0) return false
|
||
const gelesen = lies()
|
||
if (gelesen.fehler) {
|
||
zeigeFehler(form, { fehler: gelesen.fehler })
|
||
return false
|
||
}
|
||
const kurz = daten ? entwurf.plaene[stelle].kurz : kurzname(gelesen.name)
|
||
entwurf.plaene[stelle] = { ...entwurf.plaene[stelle], kurz, name: gelesen.name, tage: gelesen.tage }
|
||
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"]'))
|
||
})
|
||
|
||
hauptKnopf?.addEventListener('click', async () => {
|
||
const ok = await sp.speichere((entwurf) => {
|
||
if (stelleVon(entwurf.plaene, plan.id, form, 'Dieser Zeitplan') < 0) return false
|
||
entwurf.plaene = hauptplanWechseln(entwurf, plan.id).plaene
|
||
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'))
|
||
})
|
||
|
||
details.append(
|
||
h('summary', { class: 'zt-plan-kopf' },
|
||
h('strong', { class: 'tm-name' }, plan.name),
|
||
plan.haupt ? h('span', { class: 'tm-pille tm-pille-ok' }, 'Hauptplan') : null,
|
||
h('span', { class: 'tm-meta' }, 'Kurzname: ', kurzEl)),
|
||
h('div', { class: 'tm-editor' }, form, entfernen?.box))
|
||
return details
|
||
}
|