/* 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(['ausnahmen', …]) zeichnet einzelne Abschnitte neu). Jede Aenderung speichert die ganzen Daten (zeiten-speichern.js). Nach einem Speichern wird nur neu gezeichnet, was sich geaendert hat: die Karte des gespeicherten Plans (ersetzePlanKarte), die Liste der Plaene bei Hinzufuegen, Loeschen und Hauptplan (zeichnePlanListe, mit den ungespeicherten Eingaben der anderen Karten), die Ausnahmen bei neuem oder geloeschtem Plan und bei einem neuen Namen. Alles andere in der Seite bleibt mit seinen ungespeicherten Eingaben stehen. Ein Plan: Name, Kurzname (nur Anzeige: der Server vergibt ihn beim ersten Speichern endgueltig und nennt ihn in der Antwort, R33), je Wochentag der Haken „geöffnet“ mit zwei Zeitpaaren () 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' } /** 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 /** Gibt es in der Auswahl einen Wert wert? (Eine Eingabe aus einer anderen Liste, z. B. nach einem Wechsel des Bundeslands, passt eventuell nicht mehr.) */ const hatOption = (auswahl, wert) => [...auswahl.querySelectorAll('option')].some((o) => o.value === wert) /** * Eingabe eines Tages (Wochentag, Feiertag, Sondertag): Betriebsart und bis zu zwei Zeitpaare. * haken: Wochentag – Haken „geöffnet“ (ohne Haken: geschlossen) und daneben die Wahl der Art; sonst eine Auswahl der Betriebsart (wahl: Liste * [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, merke, stelleWieder }: lies() liefert { modus, fenster } oder { fehler: { [pfad]: Text } } (ein Paar nur halb ausgefuellt). Die Regeln der * Fenster pruefen der Aufrufer (tagFehler/wochenFehler) und der Server. merke() haelt die Eingaben so fest, wie sie im Formular stehen (auch Halbfertiges), * stelleWieder(merke()) setzt sie in einem neu gebauten Feld wieder ein: so gehen ungespeicherte Eingaben nicht verloren, wenn die Karte neu gezeichnet wird. */ export function tagFelder({ pfad, fensterPfad = pfad, label, modus = 'zu', fenster = [], haken = false, wahl = MODUS_WAHL, sichtbar = false }) { const start = typeof modus === 'string' && (MODI.includes(modus) || modus === '') ? modus : 'zu' 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 let setzeModus 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') setzeModus = (wert) => { geoeffnet.checked = wert.an === true if (hatOption(art, wert.art)) art.value = wert.art } zeige = () => { felder.hidden = !geoeffnet.checked zeiten.hidden = !(geoeffnet.checked && art.value === 'zeiten') } 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 setzeModus = (wert) => { if (hatOption(auswahl, wert.wahl)) auswahl.value = wert.wahl } 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 } } 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 --- */ 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}` const grenzText = `Mehr als ${ZEITEN_LIMITS.plaene} Zeitpläne sind nicht möglich.` // Pro Karte, wie man ihre Eingaben festhaelt und wieder einsetzt (merke/stelleWieder): die Liste der Plaene wird bei „Plan hinzufügen“, „Löschen“ // und „Zum Hauptplan machen“ neu gezeichnet; was in den anderen Karten ungespeichert steht, geht dabei nicht verloren. const KARTEN = new WeakMap() const kartenIn = (t) => [...(t.wurzel.querySelector('.zt-plaene')?.children ?? [])] export function plaene(t) { const { ctx } = t 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', baue: (schliessen) => neuFormular(t, { schliessen }), }) : null const voll = liste.length >= ZEITEN_LIMITS.plaene const knopf = neu?.leiste?.querySelector('button') if (knopf) knopf.disabled = voll return abschnitt('Zeitpläne', 'Wann hast du geöffnet? Der Hauptplan steuert den Live-Status und die Leisten auf deiner Website. Weitere Pläne (zum Beispiel für Küche oder Lieferdienst) bekommen einen eigenen Kurznamen, unter dem das Team sie auf deiner Website einbauen kann.', neu?.leiste, neu ? h('p', { class: 'tm-hinweis zt-grenze', 'data-grenze': '' }, voll ? grenzText : '') : null, neu?.box, h('div', { class: 'zt-plaene' }, liste.map((plan) => planKarte(t, plan)))) } /** Nach dem Speichern eines Plans: nur seine Karte neu zeichnen (aus dem Stand des Servers); alle anderen Formulare bleiben, wie sie sind. */ function ersetzePlanKarte(t, id) { const plan = planMitId(t.ctx, id) const alt = kartenIn(t).find((karte) => karte.getAttribute('data-plan') === id) if (!plan || !alt) return alt.parentNode.insertBefore(planKarte(t, plan), alt) alt.remove() } /** * Nach „Plan hinzufügen“, „Plan löschen“ und „Zum Hauptplan machen“: die Karten neu zeichnen (Hauptplan-Marke, Knoepfe, neue und entfernte Plaene) * und den Knopf „+ Plan hinzufügen“ auf den Stand bringen. Die Eingaben der Karten, die es noch gibt, werden wieder eingesetzt (ungespeichert, wie sie waren); * das Formular „Plan hinzufügen“ (neben der Liste) bleibt unberuehrt. */ function zeichnePlanListe(t) { const { ctx } = t const behaelter = t.wurzel.querySelector('.zt-plaene') if (!behaelter || !ctx.stand.daten) return t.zeichne(['plaene']) const gemerkt = new Map(kartenIn(t).map((karte) => [karte.getAttribute('data-plan'), KARTEN.get(karte)?.merke()])) const karten = ctx.stand.daten.plaene.map((plan) => { const karte = planKarte(t, plan) if (gemerkt.get(plan.id)) KARTEN.get(karte)?.stelleWieder(gemerkt.get(plan.id)) return karte }) behaelter.replaceChildren(...karten) const voll = ctx.stand.daten.plaene.length >= ZEITEN_LIMITS.plaene const knopf = t.wurzel.querySelector('[data-werkzeug="plan-neu"]') if (knopf) knopf.disabled = voll const grenze = t.wurzel.querySelector('[data-grenze]') if (grenze) grenze.textContent = voll ? grenzText : '' } /** Formular „Plan hinzufügen“: Name und die Vorschau des Kurznamens (wie ihn der Server bildet; endgueltig ist der Wert aus der Antwort). */ function neuFormular(t, { schliessen }) { const { ctx, sp } = t 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'), () => { zeichnePlanListe(t) schliessen() // das Formular hat seine Arbeit getan t.zeichne(['ausnahmen']) // „Gilt für“ kennt den neuen Plan }) }) 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 // Wie viele Ausnahmen nur fuer diesen Plan gelten, steht erst beim Oeffnen der Rueckfrage fest: Ausnahmen zeichnen die Karte nicht neu const mitAusnahmen = () => (Array.isArray(ctx.stand.daten?.ausnahmen) ? ctx.stand.daten.ausnahmen : []).filter((a) => a?.plan === plan.id).length const loeschenText = () => { const n = mitAusnahmen() return [ LOESCHEN_WARNUNG, n === 0 ? '' : n === 1 ? 'Die eine Ausnahme, die nur für diesen Plan gilt, wird mit gelöscht.' : `Die ${n} Ausnahmen, die nur für diesen Plan gelten, werden mit gelöscht.`, ] } const entfernen = daten && !nurLesen && grund === null ? entfernenKnopf(sp, { knopfText: 'Plan löschen', ja: 'Ja, löschen', titel: `Zeitplan „${plan.name}“ löschen?`, text: loeschenText, 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"]', () => { zeichnePlanListe(t) t.zeichne(['ausnahmen']) // die Ausnahmen dieses Plans sind mit weg, „Gilt für“ kennt den Plan nicht mehr }) }, }) : 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 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 () => { 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'), () => zeichnePlanListe(t)) }) KARTEN.set(details, { merke: () => ({ name: name.value, tage: tage.map(({ tag }) => tag.merke()) }), stelleWieder: (gemerkt) => { name.value = gemerkt.name tage.forEach(({ tag }, i) => tag.stelleWieder(gemerkt.tage[i])) }, }) details.append( h('summary', { class: 'zt-plan-kopf' }, h('strong', { class: 'tm-name' }, plan.name), 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 }