/* 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 () 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 /** * 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 }