Ungespeicherte Eingaben in anderen Formularen bleiben stehen: nach dem Speichern wird nur die Plankarte, die Planliste (mit den Eingaben der übrigen Karten), Feiertage oder Ausnahmen neu gezeichnet, „Nein, danke“ entfernt nur die Frage. Während des Speicherns sind auch die Eingabefelder gesperrt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
163 lines
9.2 KiB
JavaScript
163 lines
9.2 KiB
JavaScript
/* 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 }.
|
||
t.zeichne() zeichnet alles neu (nur nach „Neu laden“, wenn die Daten vom Server neu sind); t.zeichne(['ausnahmen', …]) zeichnet nur die genannten Abschnitte
|
||
neu und laesst alle anderen Formulare mit ihren ungespeicherten Eingaben stehen – das ist der Weg nach einem Speichern.
|
||
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, feiertageUebernehmen } from './zeiten-ausnahmen.js'
|
||
|
||
/**
|
||
* Abschnitte in der Reihenfolge der Seite (Spec 3.4). mitDaten: erst nach dem ersten Speichern. uebernehmen(alt, neu): wird gerufen, wenn der Abschnitt allein
|
||
* neu gezeichnet wird, und nimmt ungespeicherte Eingaben aus dem alten Abschnitt in den neuen mit (optional).
|
||
*/
|
||
const ABSCHNITTE = [
|
||
{ key: 'plaene', bauen: plaene, mitDaten: false },
|
||
{ key: 'feiertage', bauen: feiertage, mitDaten: true, uebernehmen: feiertageUebernehmen },
|
||
{ 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 }
|
||
|
||
let leerGezeichnet = null
|
||
const abschnittEl = new Map() // key => das gezeichnete Element des Abschnitts
|
||
|
||
function baueAbschnitt(abschnitt) {
|
||
const el = abschnitt.bauen(t)
|
||
el.dataset.abschnitt = abschnitt.key
|
||
abschnittEl.set(abschnitt.key, el)
|
||
return el
|
||
}
|
||
|
||
/** Alles neu (welche fehlt) oder nur die Abschnitte in welche (Liste von Schluesseln). Aenderte sich leer/nicht leer, muss alles neu. */
|
||
function zeichne(welche) {
|
||
const leer = ec.leer()
|
||
if (Array.isArray(welche) && welche.length && leer === leerGezeichnet) {
|
||
for (const key of welche) {
|
||
const abschnitt = ABSCHNITTE.find((a) => a.key === key)
|
||
const alt = abschnittEl.get(key)
|
||
if (!abschnitt || !alt?.parentNode) continue
|
||
const neu = baueAbschnitt(abschnitt)
|
||
abschnitt.uebernehmen?.(alt, neu)
|
||
alt.parentNode.insertBefore(neu, alt)
|
||
alt.remove()
|
||
}
|
||
return
|
||
}
|
||
leerGezeichnet = leer
|
||
abschnittEl.clear()
|
||
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
|
||
teile.push(baueAbschnitt(abschnitt))
|
||
}
|
||
if (leer && !nurLesen) teile.push(h('p', { class: 'tm-hinweis zt-spaeter' }, SPAETER))
|
||
root.replaceChildren(...teile)
|
||
}
|
||
zeichne()
|
||
}
|