Files
Webklar-Kundenbereich/public/zeiten-editor.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

135 lines
7.9 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: der Editor (ES-Modul, zeiten.js haengt ihn in .zt-editor ein; Spec 3.4). Server:
server/routes/zeiten.js. renderEditor(root, ctx) laedt GET /api/zeiten/<projectId> und zeichnet die Abschnitte; die Eingaben des Kunden
bleiben im Editor, solange der Platz (root) steht – zeiten.js baut ihn bei einem neuen Stand der Funktion nicht neu auf.
root.dataset.state = Zustand der Funktion (setup, active, disabled): disabled heisst nur lesen (alles gesperrt, PUT antwortet mit 403).
ctx von zeiten.js: { projectId, kundenansicht, neuZeichnen() } – neuZeichnen holt die Liste der Funktionen neu und zeichnet den Kopf der
Ansicht (Statuskarte); der Editor ruft es einmal nach dem ersten Speichern (zeitenLeer wechselt), nie sonst.
Der Editor-ctx t.ctx der Abschnitte (aus Aufgabe 29; Aufgabe 30 haengt ihre Abschnitte an ABSCHNITTE und die Live-Vorschau in den Kopf):
{ projectId, kundenansicht, nurLesen, stand, entwurfsplan, offen, hinweisFrage, aktuell(), neuLaden(), meldung(text, art), uebernimm(antwort, { erstes }),
leereDaten(), leer() }
stand = Antwort des Servers { state, daten (null vor dem ersten Speichern), version, kundenansicht, vorschau, feiertage, googleText }; sie wird bei jedem
Speichern und bei „Neu laden“ ersetzt. Ein Abschnitt ist eine Funktion (t) => Element mit t = { ctx, sp, zeichne, wurzel }.
Vor dem ersten Speichern (stand.daten null) gibt es nur den leeren Hauptplan; die anderen Abschnitte (mitDaten) erscheinen danach, denn der
Server verlangt mindestens einen Plan. Die Kontaktdaten (daten.kontakt) gehen unveraendert zurueck: der Editor setzt nie ein neues
kontakt.whatsapp.bestaetigtAt (R12, das tut Aufgabe 30 mit dem Haken des Kunden). */
import { fehlerKarte, h, request } from './termine-dom.js'
import { istTag } from './termine-view.js'
import { leererPlan } from './zeiten-view.js'
import { neuerZeitenSpeicher } from './zeiten-speichern.js'
import { plaene } from './zeiten-plaene.js'
import { ausnahmen, feiertage } from './zeiten-ausnahmen.js'
/** Abschnitte in der Reihenfolge der Seite (Spec 3.4). mitDaten: erst nach dem ersten Speichern. */
const ABSCHNITTE = [
{ key: 'plaene', bauen: plaene, mitDaten: false },
{ key: 'feiertage', bauen: feiertage, mitDaten: true },
{ key: 'ausnahmen', bauen: ausnahmen, mitDaten: true },
]
const NICHT_GELADEN = 'Deine Öffnungszeiten konnten gerade nicht geladen werden. Bitte versuch es noch einmal.'
const NUR_LESEN = 'Diese Funktion ist abgeschaltet. Du kannst deine Eingaben nur noch ansehen.'
const KUNDENANSICHT = 'Kundenansicht: Was du hier speicherst, ändert die Öffnungszeiten des Kunden. Jedes Speichern steht im Protokoll.'
const LEER = 'Trag deine Öffnungszeiten ein. Sobald du speicherst, kennt deine Website sie.'
const SPAETER = 'Feiertage, Ausnahmen und alles Weitere kannst du eintragen, sobald deine Öffnungszeiten gespeichert sind.'
// Welcher Aufruf von renderEditor gehoert zu einem Platz? Ein neuer Aufruf macht den alten ungueltig (seine Antworten fassen nichts mehr an).
const LAEUFE = new WeakMap()
/** Antwort des Servers pruefen und auf die Felder reduzieren, die der Editor braucht; wirft bei einer unbrauchbaren Antwort. */
function standAus(antwort) {
if (antwort === null || typeof antwort !== 'object' || !Number.isInteger(antwort.version)) throw new Error(NICHT_GELADEN)
const daten = antwort.daten !== null && typeof antwort.daten === 'object' && !Array.isArray(antwort.daten) ? antwort.daten : null
if (daten !== null && !Array.isArray(daten.plaene)) throw new Error(NICHT_GELADEN)
return {
state: typeof antwort.state === 'string' ? antwort.state : '',
// ohne Plaene ist nichts gespeichert (wie vor dem ersten Speichern)
daten: daten !== null && daten.plaene.length > 0 ? daten : null,
version: antwort.version,
kundenansicht: antwort.kundenansicht === true,
vorschau: antwort.vorschau ?? null,
feiertage: (Array.isArray(antwort.feiertage) ? antwort.feiertage : []).filter((f) => f && istTag(f.tag) && typeof f.name === 'string'),
googleText: typeof antwort.googleText === 'string' ? antwort.googleText : '',
}
}
export async function renderEditor(root, ctx = {}) {
const token = {}
LAEUFE.set(root, token)
const aktuell = () => root.isConnected && LAEUFE.get(root) === token
const url = `/api/zeiten/${encodeURIComponent(ctx.projectId)}`
const ladeStand = async () => standAus(await request(url))
root.replaceChildren(h('p', { class: 'kb-empty' }, 'Lade…'))
let stand
try {
stand = await ladeStand()
} catch (err) {
if (aktuell()) root.replaceChildren(fehlerKarte(err.message, () => renderEditor(root, ctx)))
return
}
if (!aktuell()) return
const nurLesen = ctx.nurLesen === true || root.dataset.state === 'disabled'
const meldungEl = h('div', { class: 'tm-meldung', 'data-meldung': 'editor', role: 'status', 'aria-live': 'polite', tabindex: '-1' })
const entwurfsplan = leererPlan({ name: 'Öffnungszeiten', kurz: 'oeffnungszeiten', haupt: true })
const ec = {
projectId: ctx.projectId,
kundenansicht: ctx.kundenansicht === true,
nurLesen,
stand,
entwurfsplan, // der leere Hauptplan vor dem ersten Speichern
offen: new Set([entwurfsplan.id, ...(stand.daten ? stand.daten.plaene.filter((p) => p?.haupt === true).map((p) => p.id) : [])]), // aufgeklappte Plaene
hinweisFrage: null, // { vorschlag } nach einem Urlaub ab 2 Tagen
aktuell,
leer: () => ec.stand.daten === null,
/** Daten fuer das erste Speichern: der leere Hauptplan; die Kontaktdaten setzt der Server auf seine Vorgabe. */
leereDaten: () => ({ land: 'RP', plaene: [ec.entwurfsplan], feiertage: {}, ausnahmen: [], hinweise: [] }),
/** Eine Meldung, die erst nach dem Wechsel der Website eintrifft, gehoert nicht mehr auf diese Seite. */
meldung(text, art = 'ok') {
if (!aktuell()) return
meldungEl.textContent = text || ''
meldungEl.className = `tm-meldung tm-meldung-${art}`
meldungEl.setAttribute('role', art === 'fehler' ? 'alert' : 'status')
meldungEl.setAttribute('aria-live', art === 'fehler' ? 'assertive' : 'polite')
if (!text) return
if (art === 'fehler') meldungEl.focus({ preventScroll: true })
meldungEl.scrollIntoView({ block: 'nearest' })
},
async neuLaden() {
const neu = await ladeStand()
if (aktuell()) ec.stand = neu
return neu
},
/** Antwort auf ein erfolgreiches Speichern. Das erste Speichern aendert, was die Huelle zeigt (zeitenLeer): sie zeichnet ihren Kopf neu. */
uebernimm(antwort, { erstes = false } = {}) {
ec.stand = standAus(antwort)
if (erstes && typeof ctx.neuZeichnen === 'function') {
try {
Promise.resolve(ctx.neuZeichnen()).catch(() => {}) // scheitert das, zeigt der Kopf den alten Stand bis zum naechsten Besuch
} catch { /* wie oben */ }
}
},
}
const sp = neuerZeitenSpeicher(ec, root, zeichne)
const t = { ctx: ec, sp, zeichne, wurzel: root }
function zeichne() {
const leer = ec.leer()
const teile = [meldungEl, sp.konflikt]
if (ec.kundenansicht || ec.stand.kundenansicht) teile.push(h('div', { class: 'tm-box tm-box-info', role: 'note', 'data-hinweis': 'kundenansicht' }, h('span', null, KUNDENANSICHT)))
if (nurLesen) teile.push(h('div', { class: 'tm-box tm-box-warn', role: 'note', 'data-hinweis': 'nurlesen' }, h('span', null, NUR_LESEN)))
else if (leer) teile.push(h('div', { class: 'tm-box tm-box-info', role: 'note', 'data-hinweis': 'leer' }, h('span', null, LEER)))
teile.push(h('div', { class: 'zt-kopfbereich' })) // Platz der Live-Vorschau (Aufgabe 30)
for (const abschnitt of ABSCHNITTE) {
if (abschnitt.mitDaten && leer) continue
const el = abschnitt.bauen(t)
el.dataset.abschnitt = abschnitt.key
teile.push(el)
}
if (leer && !nurLesen) teile.push(h('p', { class: 'tm-hinweis zt-spaeter' }, SPAETER))
root.replaceChildren(...teile)
}
zeichne()
}