Ö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>
This commit is contained in:
1 parent
cee44d6852
commit
04a3525f79
8 files changed
+1974
-21
No files matched your search
@@ -0,0 +1,274 @@
|
||||
/* 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
|
||||
}
|
||||
Reference in new issue
Block a user