Ö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,134 @@
|
||||
/* 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()
|
||||
}
|
||||
Reference in new issue
Block a user