Ö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,280 @@
|
||||
/* Oeffnungszeiten live im Kundenbereich: Editor, Bereiche 2 „Feiertage“ und 3 „Ausnahmen“ (Spec 3.4). Jeder Bereich ist eine Funktion
|
||||
(t) => Element; 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). Abgeschaltete Funktion (ctx.nurLesen): dieselben Felder gesperrt,
|
||||
ohne Knoepfe zum Aendern. Stil: termine-team-ausnahmen.js (Abwesenheiten, Feiertage, Sonderoeffnungen).
|
||||
Feiertage: Bundesland (ein eigenes Speichern; die Liste kommt vom Server und gilt danach fuer das neue Land) und die Feiertage der naechsten
|
||||
12 Monate (ctx.stand.feiertage). Gespeichert werden nur Abweichungen von der Vorgabe (daten.feiertage): ein gesetzlicher Feiertag ist
|
||||
„geschlossen“, ein „bitte prüfen“-Tag (Heiligabend, Silvester, nur ortsuebliche Feiertage) folgt dem Wochenplan.
|
||||
Ausnahmen: Betriebsurlaub (von–bis, Text) und Sondertag (Datum, geschlossen oder Zeiten), Geltung „alle Pläne“ oder ein Plan. Nach dem Speichern
|
||||
eines Urlaubs ab 2 Tagen fragt der Editor „Hinweis-Leiste dazu anlegen?“ (ctx.hinweisFrage); „Ja“ legt den Vorschlag (hinweisVorschlag) in
|
||||
daten.hinweise an und speichert. */
|
||||
|
||||
import { BUNDESLAENDER, datumKurz, datumLang, heuteBerlin, istTag } from './termine-view.js'
|
||||
import { abschnitt, allgemeinerFehler, editorZeile, feld, frage, gesperrt, h, neuerEintrag, zeigeFehler } from './termine-dom.js'
|
||||
import { MODI, ZEITEN_LIMITS, ZEITEN_MAX, hinweisVorschlag, neueZeitenId, tagFehler } from './zeiten-view.js'
|
||||
import { entfernenKnopf, nachSpeichern, stelleVon } from './zeiten-speichern.js'
|
||||
import { MODUS_WAHL, tagFelder, tagText } from './zeiten-plaene.js'
|
||||
|
||||
const nachBeginn = (x, y) => (x.von < y.von ? -1 : x.von > y.von ? 1 : x.bis < y.bis ? -1 : x.bis > y.bis ? 1 : 0)
|
||||
const zeitraum = (a) => (a.von === a.bis ? datumKurz(a.von) : `${datumKurz(a.von)} – ${datumKurz(a.bis)}`)
|
||||
|
||||
/* -------------------------------------------------------------- Feiertage --- */
|
||||
const FEIERTAG_WAHL = Object.freeze([['', 'geschlossen (Vorgabe)'], ...MODUS_WAHL.filter(([wert]) => wert !== 'zu')])
|
||||
const PRUEFEN_WAHL = Object.freeze([['', 'wie im Wochenplan (Vorgabe)'], ...MODUS_WAHL])
|
||||
|
||||
export function feiertage(t) {
|
||||
const { ctx, sp } = t
|
||||
const daten = ctx.stand.daten
|
||||
const nurLesen = ctx.nurLesen
|
||||
const gespeichert = daten.feiertage !== null && typeof daten.feiertage === 'object' ? daten.feiertage : {}
|
||||
|
||||
// Bundesland: eigenes Formular, denn die Liste darunter kommt nach dem Speichern vom Server neu
|
||||
const land = h('select', null, BUNDESLAENDER.map(([key, name]) => h('option', { value: key, selected: (daten.land || 'RP') === key }, name)))
|
||||
const landForm = h('form', { novalidate: true, 'aria-label': 'Bundesland' },
|
||||
gesperrt(nurLesen, feld('land', 'Bundesland', land, 'Danach richten sich die Feiertage unten.')),
|
||||
allgemeinerFehler(),
|
||||
nurLesen ? null : h('div', { class: 'tm-frage-knoepfe' }, h('button', { type: 'submit', class: 'kb-btn-outline', 'data-aktion': 'land-speichern' }, 'Bundesland speichern')))
|
||||
landForm.addEventListener('submit', async (ev) => {
|
||||
ev.preventDefault()
|
||||
const ok = await sp.speichere((entwurf) => { entwurf.land = land.value }, { container: landForm })
|
||||
if (!ok || !sp.nochHier()) return
|
||||
const name = BUNDESLAENDER.find(([key]) => key === land.value)?.[1] ?? land.value
|
||||
nachSpeichern(t, `Bundesland gespeichert: ${name}. Die Feiertage unten gelten jetzt dafür.`, 'form[aria-label="Bundesland"] button[type="submit"]')
|
||||
})
|
||||
|
||||
const zeilen = ctx.stand.feiertage.map((f) => {
|
||||
const pruefen = f.art === 'pruefen'
|
||||
const abweichung = Object.hasOwn(gespeichert, f.tag) ? gespeichert[f.tag] : null
|
||||
// ein gespeichertes „geschlossen“ an einem gesetzlichen Feiertag ist die Vorgabe
|
||||
const modus = abweichung && MODI.includes(abweichung.modus) && !(!pruefen && abweichung.modus === 'zu') ? abweichung.modus : ''
|
||||
const tag = tagFelder({ pfad: `feiertage.${f.tag}`, label: `${f.name}, ${datumKurz(f.tag)}`, wahl: pruefen ? PRUEFEN_WAHL : FEIERTAG_WAHL, modus, fenster: abweichung?.fenster })
|
||||
const el = h('li', { class: 'tm-eintrag zt-feiertag', 'data-feiertag': f.tag },
|
||||
h('div', { class: 'tm-kopf' },
|
||||
h('div', { class: 'tm-haupt' }, h('strong', { class: 'tm-name' }, datumLang(f.tag)), h('span', { class: 'tm-meta' }, f.name)),
|
||||
pruefen ? h('span', { class: 'tm-pille tm-pille-wait' }, 'bitte prüfen') : null),
|
||||
tag.el)
|
||||
return { f, tag, el }
|
||||
})
|
||||
|
||||
const speichern = h('button', { type: 'submit', class: 'kb-btn-primary', 'data-aktion': 'feiertage-speichern' }, 'Feiertage speichern')
|
||||
const form = h('form', { novalidate: true, 'aria-label': 'Feiertage' },
|
||||
zeilen.length
|
||||
? gesperrt(nurLesen, h('ul', { class: 'tm-eintraege', 'aria-label': 'Feiertage der nächsten 12 Monate' }, zeilen.map(({ el }) => el)))
|
||||
: h('p', { class: 'tm-leer' }, 'Für dieses Bundesland gibt es in den nächsten 12 Monaten keine Feiertage.'),
|
||||
allgemeinerFehler(),
|
||||
nurLesen || !zeilen.length ? null : h('div', { class: 'tm-frage-knoepfe' }, speichern))
|
||||
form.addEventListener('submit', async (ev) => {
|
||||
ev.preventDefault()
|
||||
const ok = await sp.speichere((entwurf) => {
|
||||
// nur Abweichungen von der Vorgabe: alles andere (auch Tage ausserhalb der Liste) bleibt, wie es war
|
||||
const neu = { ...(entwurf.feiertage || {}) }
|
||||
const fehler = {}
|
||||
for (const { f, tag } of zeilen) {
|
||||
const r = tag.lies()
|
||||
if (r.fehler) {
|
||||
Object.assign(fehler, r.fehler)
|
||||
continue
|
||||
}
|
||||
if (r.modus === '' || (f.art !== 'pruefen' && r.modus === 'zu')) {
|
||||
delete neu[f.tag]
|
||||
continue
|
||||
}
|
||||
const eintrag = { modus: r.modus, fenster: r.fenster }
|
||||
const meldung = tagFehler(eintrag)
|
||||
if (meldung) fehler[`feiertage.${f.tag}`] = meldung
|
||||
neu[f.tag] = eintrag
|
||||
}
|
||||
if (Object.keys(fehler).length) {
|
||||
zeigeFehler(form, { fehler })
|
||||
return false
|
||||
}
|
||||
entwurf.feiertage = neu
|
||||
return true
|
||||
}, { container: form })
|
||||
if (!ok || !sp.nochHier()) return
|
||||
nachSpeichern(t, 'Feiertage gespeichert.', 'form[aria-label="Feiertage"] button[type="submit"]')
|
||||
})
|
||||
|
||||
return abschnitt('Feiertage',
|
||||
'An gesetzlichen Feiertagen ist deine Website als „geschlossen“ eingetragen. Willst du an einem Feiertag öffnen, wähle eigene Zeiten. „Bitte prüfen“ sind Tage, die nicht überall oder nicht für jeden frei sind (zum Beispiel Heiligabend, Silvester oder Fronleichnam): Hier gilt als Vorgabe dein Wochenplan.',
|
||||
landForm,
|
||||
h('p', { class: 'tm-hinweis' }, 'Gemeinde-Feiertage sind nicht enthalten. Trage sie bei Bedarf unten als Sondertag ein.'),
|
||||
form)
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- Ausnahmen --- */
|
||||
const gibtHinweis = (hinweise, v) => (Array.isArray(hinweise) ? hinweise : []).some((x) => x?.text === v.text && x.von === v.von && x.bis === v.bis)
|
||||
const planName = (daten, id) => (daten.plaene || []).find((p) => p?.id === id)?.name
|
||||
|
||||
export function ausnahmen(t) {
|
||||
const { ctx } = t
|
||||
const daten = ctx.stand.daten
|
||||
const alle = Array.isArray(daten.ausnahmen) ? daten.ausnahmen : []
|
||||
const heute = heuteBerlin()
|
||||
const kommend = alle.filter((a) => a.bis >= heute).sort(nachBeginn)
|
||||
const vorbei = alle.filter((a) => a.bis < heute).sort(nachBeginn).reverse()
|
||||
const voll = alle.length >= ZEITEN_LIMITS.ausnahmen
|
||||
const grenzText = `Mehr als ${ZEITEN_LIMITS.ausnahmen} Ausnahmen sind nicht möglich.`
|
||||
const urlaubNeu = neuerEintrag({
|
||||
nurLesen: ctx.nurLesen, text: '+ Betriebsurlaub eintragen', werkzeug: 'urlaub-neu', voll, grenzText,
|
||||
baue: (schliessen) => formular(t, { a: null, art: 'urlaub', schliessen }),
|
||||
})
|
||||
const sonderNeu = neuerEintrag({
|
||||
nurLesen: ctx.nurLesen, text: '+ Sondertag eintragen', werkzeug: 'sondertag-neu', voll, grenzText: '',
|
||||
baue: (schliessen) => formular(t, { a: null, art: 'sondertag', schliessen }),
|
||||
})
|
||||
|
||||
const reihe = (a) => {
|
||||
const art = a.art === 'sondertag' ? 'Sondertag' : 'Betriebsurlaub'
|
||||
const plan = a.plan ? `nur ${planName(daten, a.plan) || 'ein gelöschter Plan'}` : ''
|
||||
return editorZeile({
|
||||
attrs: { 'data-id': a.id },
|
||||
nurLesen: ctx.nurLesen,
|
||||
label: `${art} ${datumKurz(a.von)}`,
|
||||
haupt: [
|
||||
h('strong', { class: 'tm-name' }, zeitraum(a)),
|
||||
h('span', { class: 'tm-meta' }, [art, a.art === 'sondertag' ? tagText(a) : '', a.text, plan].filter(Boolean).join(' · ')),
|
||||
],
|
||||
baue: (schliessen) => formular(t, { a, art: a.art === 'sondertag' ? 'sondertag' : 'urlaub', schliessen }),
|
||||
})
|
||||
}
|
||||
|
||||
return abschnitt('Ausnahmen',
|
||||
'Betriebsurlaub und einzelne Sondertage gehen dem Wochenplan und den Feiertagen vor. Jede Ausnahme gilt für alle Pläne oder nur für einen.',
|
||||
hinweisFrage(t),
|
||||
h('div', { class: 'zt-werkzeuge' }, urlaubNeu.leiste, sonderNeu.leiste),
|
||||
urlaubNeu.box,
|
||||
sonderNeu.box,
|
||||
kommend.length
|
||||
? h('ul', { class: 'tm-eintraege', 'aria-label': 'Kommende Ausnahmen' }, kommend.map(reihe))
|
||||
: h('p', { class: 'tm-leer' }, 'Keine kommenden Ausnahmen eingetragen.'),
|
||||
vorbei.length
|
||||
? h('details', { class: 'tm-details' }, h('summary', null, `Vergangene Ausnahmen (${vorbei.length})`), h('ul', { class: 'tm-eintraege' }, vorbei.map(reihe)))
|
||||
: null)
|
||||
}
|
||||
|
||||
/**
|
||||
* „Hinweis-Leiste dazu anlegen?“ nach einem Betriebsurlaub ab 2 Tagen (ctx.hinweisFrage = { vorschlag }): Text und Zeitraum sind vorbefuellt
|
||||
* (hinweisVorschlag: sichtbar ab 7 Tage vor Beginn bis zum Urlaubsende). „Ja“ legt den Hinweis in daten.hinweise an und speichert; „Nein“ laesst es.
|
||||
*/
|
||||
function hinweisFrage(t) {
|
||||
const { ctx, sp } = t
|
||||
const f = ctx.hinweisFrage
|
||||
if (!f || ctx.nurLesen) return null
|
||||
const v = f.vorschlag
|
||||
const form = frage('Hinweis-Leiste dazu anlegen?',
|
||||
h('p', null, `„${v.text}“ – sichtbar vom ${datumKurz(v.von)} bis ${datumKurz(v.bis)}, oben auf deiner Website.`),
|
||||
allgemeinerFehler(),
|
||||
h('div', { class: 'tm-frage-knoepfe' },
|
||||
h('button', { type: 'submit', class: 'kb-btn-primary', 'data-aktion': 'hinweis-ja' }, 'Ja, anlegen'),
|
||||
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'hinweis-nein', onclick: () => {
|
||||
ctx.hinweisFrage = null
|
||||
t.zeichne()
|
||||
t.wurzel.querySelector('[data-werkzeug="urlaub-neu"]')?.focus()
|
||||
} }, 'Nein, danke')))
|
||||
form.dataset.frage = 'hinweis'
|
||||
form.addEventListener('submit', async () => {
|
||||
const ok = await sp.speichere((entwurf) => {
|
||||
entwurf.hinweise = [...(Array.isArray(entwurf.hinweise) ? entwurf.hinweise : []), { id: neueZeitenId('h'), art: v.art, text: v.text, knopf: null, von: v.von, bis: v.bis }]
|
||||
}, { container: form })
|
||||
if (!ok || !sp.nochHier()) return
|
||||
ctx.hinweisFrage = null
|
||||
nachSpeichern(t, `Die Hinweis-Leiste „${v.text}“ ist angelegt.`, '[data-werkzeug="urlaub-neu"]')
|
||||
})
|
||||
return form
|
||||
}
|
||||
|
||||
/**
|
||||
* Formular einer neuen (a null) oder bestehenden Ausnahme der Art 'urlaub' (von, bis) oder 'sondertag' (ein Datum, Betriebsart, Zeiten).
|
||||
* Die Felder heissen nach dem Pfad der Fehler des Servers (ausnahmen.<ID>.von …); Absenden sucht die Ausnahme per ID.
|
||||
*/
|
||||
function formular(t, { a, art, schliessen }) {
|
||||
const { ctx, sp } = t
|
||||
const daten = ctx.stand.daten
|
||||
const neu = a === null
|
||||
const alt = a || { id: neueZeitenId('x'), art, von: heuteBerlin(), bis: heuteBerlin(), text: '', modus: 'zu', fenster: [], plan: null }
|
||||
const p = `ausnahmen.${alt.id}`
|
||||
const urlaub = art === 'urlaub'
|
||||
const wort = urlaub ? 'Betriebsurlaub' : 'Sondertag'
|
||||
|
||||
const von = h('input', { type: 'date', value: alt.von })
|
||||
const bis = h('input', { type: 'date', value: alt.bis })
|
||||
const text = h('input', { type: 'text', maxlength: ZEITEN_MAX.ausnahmeText, autocomplete: 'off', placeholder: urlaub ? 'z. B. Sommerpause' : 'z. B. Tag der offenen Tür', value: alt.text })
|
||||
const tag = urlaub ? null : tagFelder({ pfad: `${p}.modus`, fensterPfad: `${p}.fenster`, label: 'Sondertag', wahl: MODUS_WAHL, sichtbar: true, modus: alt.modus, fenster: alt.fenster })
|
||||
const plaene = Array.isArray(daten.plaene) ? daten.plaene : []
|
||||
const geltung = plaene.length > 1 || alt.plan
|
||||
? h('select', null,
|
||||
h('option', { value: '', selected: !alt.plan }, 'Alle Pläne'),
|
||||
plaene.map((x) => h('option', { value: x.id, selected: alt.plan === x.id }, x.name)))
|
||||
: null
|
||||
|
||||
const entfernen = neu || ctx.nurLesen ? null : entfernenKnopf(sp, {
|
||||
titel: `${wort} entfernen?`,
|
||||
text: urlaub ? 'In dieser Zeit gilt danach wieder der Wochenplan.' : 'An diesem Tag gilt danach wieder der Wochenplan (und der Feiertag, falls einer ist).',
|
||||
aendere: (entwurf) => { entwurf.ausnahmen = (entwurf.ausnahmen || []).filter((x) => x?.id !== alt.id) },
|
||||
nachher: () => nachSpeichern(t, `${wort} entfernt.`, '[data-werkzeug="urlaub-neu"]'),
|
||||
})
|
||||
const speichern = h('button', { type: 'submit', class: 'kb-btn-primary', 'data-aktion': 'speichern' }, neu ? `${wort} eintragen` : 'Speichern')
|
||||
const form = h('form', { class: 'kb-panel tm-formular', novalidate: true, 'aria-label': neu ? `${wort} eintragen` : `${wort} bearbeiten` },
|
||||
h('h3', null, neu ? (urlaub ? 'Neuer Betriebsurlaub' : 'Neuer Sondertag') : wort),
|
||||
gesperrt(ctx.nurLesen, [
|
||||
urlaub
|
||||
? h('div', { class: 'tm-raster' }, feld(`${p}.von`, 'Von (erster Tag)', von), feld(`${p}.bis`, 'Bis (letzter Tag)', bis))
|
||||
: feld(`${p}.von`, 'Datum', von),
|
||||
tag?.el,
|
||||
feld(`${p}.text`, 'Text (optional)', text, 'Höchstens 60 Zeichen.'),
|
||||
geltung ? feld(`${p}.plan`, 'Gilt für', geltung) : null,
|
||||
]),
|
||||
allgemeinerFehler(),
|
||||
h('div', { class: 'tm-frage-knoepfe' },
|
||||
ctx.nurLesen ? null : speichern,
|
||||
h('button', { type: 'button', class: 'kb-btn-outline', onclick: schliessen }, ctx.nurLesen ? 'Schließen' : 'Abbrechen'),
|
||||
entfernen?.knopf))
|
||||
|
||||
form.addEventListener('submit', async (ev) => {
|
||||
ev.preventDefault()
|
||||
let eintrag = null
|
||||
const ok = await sp.speichere((entwurf) => {
|
||||
const fehler = {}
|
||||
if (!istTag(von.value)) fehler[`${p}.von`] = 'Bitte gib ein gültiges Datum an.'
|
||||
let ende = von.value
|
||||
if (urlaub) {
|
||||
ende = bis.value
|
||||
if (!istTag(bis.value)) fehler[`${p}.bis`] = 'Bitte gib ein gültiges Datum an.'
|
||||
else if (istTag(von.value) && bis.value < von.value) fehler[`${p}.bis`] = 'Das Ende darf nicht vor dem Beginn liegen.'
|
||||
}
|
||||
let betrieb = { modus: 'zu', fenster: [] }
|
||||
if (tag) {
|
||||
const r = tag.lies()
|
||||
if (r.fehler) Object.assign(fehler, r.fehler)
|
||||
else {
|
||||
const meldung = tagFehler(r)
|
||||
if (meldung) fehler[`${p}.fenster`] = meldung
|
||||
betrieb = r
|
||||
}
|
||||
}
|
||||
if (Object.keys(fehler).length) {
|
||||
zeigeFehler(form, { fehler })
|
||||
return false
|
||||
}
|
||||
eintrag = { ...alt, art, von: von.value, bis: ende, text: text.value.trim(), modus: betrieb.modus, fenster: betrieb.fenster, plan: geltung ? (geltung.value || null) : alt.plan ?? null }
|
||||
if (neu) {
|
||||
entwurf.ausnahmen = [...(Array.isArray(entwurf.ausnahmen) ? entwurf.ausnahmen : []), eintrag]
|
||||
return true
|
||||
}
|
||||
const stelle = stelleVon(entwurf.ausnahmen, alt.id, form, `Dieser ${wort}`)
|
||||
if (stelle < 0) return false
|
||||
entwurf.ausnahmen[stelle] = eintrag
|
||||
return true
|
||||
}, { container: form })
|
||||
if (!ok || !sp.nochHier()) return
|
||||
// Urlaub ab 2 Tagen: die Hinweis-Leiste dazu anbieten (nicht noch einmal, wenn es sie schon gibt oder sich der Zeitraum nicht geaendert hat)
|
||||
if (urlaub) {
|
||||
const vorschlag = hinweisVorschlag(eintrag)
|
||||
const geaendert = neu || alt.von !== eintrag.von || alt.bis !== eintrag.bis
|
||||
ctx.hinweisFrage = vorschlag && geaendert && !gibtHinweis(ctx.stand.daten.hinweise, vorschlag) ? { vorschlag } : null
|
||||
}
|
||||
nachSpeichern(t, neu ? `${wort} eingetragen.` : `${wort} gespeichert.`, `li[data-id="${CSS.escape(alt.id)}"] [data-aktion="bearbeiten"]`)
|
||||
})
|
||||
return h('div', { class: 'tm-editor' }, form, entfernen?.box)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
/* 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 (<input type="time" step="300">) 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'
|
||||
}
|
||||
|
||||
/** <input type="time"> 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
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
/* Oeffnungszeiten live im Kundenbereich: Speichern des Editors – gemeinsam fuer alle Abschnitte (Zeitplaene, Feiertage, Ausnahmen und die
|
||||
Abschnitte der Aufgabe 30). Jede Aenderung ist ein Speichern: PUT /api/zeiten/<projectId> schickt ALLE Daten mit der Version, auf der sie
|
||||
beruhen (server/routes/zeiten.js). Der Abschnitt gibt nur an, was sich aendert: speichere(aendere, { container }) holt eine Kopie des
|
||||
aktuellen Stands (ctx.stand.daten, vor dem ersten Speichern ctx.leereDaten()), laesst aendere(entwurf) sie aendern und schickt sie ab.
|
||||
Muster: termine-speichern.js (neuerSpeicher), dort nichts geaendert.
|
||||
- Erfolg: ctx.uebernimm(antwort, { erstes }) setzt den neuen Stand (Antwort des Servers: daten, version, feiertage, vorschau …); Rueckgabe true.
|
||||
- 400: Fehler stehen am Feld (data-fehler = Pfad mit der ID des Eintrags, z. B. 'plaene.p_ab12cd34.tage.3', 'ausnahmen.x_ab12cd34.von',
|
||||
'feiertage.2026-12-25'); Pfade ohne Platz im container erscheinen allgemein, mit dem Namen des Eintrags davor. Rueckgabe false.
|
||||
- 409 (ZEITEN_VERALTET, jemand hat inzwischen gespeichert): Hinweis mit Knopf „Neu laden“ oben im Editor. Rueckgabe false.
|
||||
- alles andere (429 zu viele Speichervorgaenge, 403, 500, kein Netz): Meldung ueber dem Editor (ctx.meldung). Rueckgabe false.
|
||||
- Es laeuft immer nur ein Speichern (PUT und das Uebernehmen): solange es laeuft, ist der Editor aria-busy, alle seine Knoepfe sind gesperrt, und
|
||||
ein weiterer Aufruf von speichere (z. B. ein Enter im Formular) wird abgewiesen (Rueckgabe false), statt mit einer veralteten Version zu gehen.
|
||||
Der Abschnitt ruft speichere nur, wenn ctx.nurLesen nicht gesetzt ist. Texte des Servers kommen nur als Text in die Seite (h, textContent).
|
||||
ctx (baut zeiten-editor.js): { projectId, stand, aktuell(), neuLaden(), meldung(text, art), uebernimm(antwort, { erstes }) }. */
|
||||
|
||||
import { allgemeinerFehler, frage, h, knopfZeile, request, setzeFehler, zeigeFehler } from './termine-dom.js'
|
||||
import { datumKurz } from './termine-view.js'
|
||||
|
||||
export const VERALTET_TEXT = 'Inzwischen geändert – bitte neu laden.'
|
||||
|
||||
const WOCHENTAGE = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag']
|
||||
const istTagText = (tag) => typeof tag === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(tag)
|
||||
const finde = (liste, id) => (Array.isArray(liste) ? liste.find((eintrag) => eintrag?.id === id) : undefined)
|
||||
|
||||
/**
|
||||
* „Zeitplan „Küche“, Dienstag: Text“ aus Pfad und Text eines Fehlers. Eintraege finden sich per ID in den Daten (quellen: bisheriger Stand und
|
||||
* Entwurf); eine ID ist nie ein Schluessel eines Objekts (IDs wie „__proto__“ sind gewoehnliche Texte).
|
||||
*/
|
||||
export function beschrifteFehler(pfad, text, quellen) {
|
||||
const teile = pfad.split('.')
|
||||
const suche = (name, id) => {
|
||||
for (const quelle of quellen) {
|
||||
const eintrag = /^\d+$/.test(id) ? quelle?.[name]?.[Number(id)] : finde(quelle?.[name], id)
|
||||
if (eintrag) return eintrag
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
const mit = (wer) => `${wer}: ${text}`
|
||||
switch (teile[0]) {
|
||||
case 'plaene': {
|
||||
const plan = teile[1] ? suche('plaene', teile[1]) : undefined
|
||||
if (!plan?.name) return text
|
||||
const tag = teile[2] === 'tage' && /^[1-7]$/.test(teile[3] || '') ? `, ${WOCHENTAGE[Number(teile[3]) - 1]}` : ''
|
||||
return mit(`Zeitplan „${plan.name}“${tag}`)
|
||||
}
|
||||
case 'ausnahmen': {
|
||||
const ausnahme = teile[1] ? suche('ausnahmen', teile[1]) : undefined
|
||||
if (!ausnahme) return text
|
||||
const wann = istTagText(ausnahme.von) ? ` am ${datumKurz(ausnahme.von)}` : ''
|
||||
return mit(`${ausnahme.art === 'sondertag' ? 'Sondertag' : 'Betriebsurlaub'}${wann}${ausnahme.text ? ` („${ausnahme.text}“)` : ''}`)
|
||||
}
|
||||
case 'hinweise': {
|
||||
const hinweis = teile[1] ? suche('hinweise', teile[1]) : undefined
|
||||
return hinweis?.text ? mit(`Hinweis „${hinweis.text}“`) : text
|
||||
}
|
||||
case 'feiertage':
|
||||
return istTagText(teile[1]) ? mit(`Feiertag ${datumKurz(teile[1])}`) : text
|
||||
case 'kontakt':
|
||||
return mit('Kontakt-Leiste')
|
||||
case 'land':
|
||||
return mit('Bundesland')
|
||||
default:
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
/** Fehler des Servers am Feld; was im container keinen Platz hat, steht allgemein (mit dem Namen des Eintrags davor). */
|
||||
function zeigeFelder(container, fehler, quellen) {
|
||||
const amFeld = {}
|
||||
const rest = []
|
||||
for (const [pfad, text] of Object.entries(fehler)) {
|
||||
if (pfad !== '_' && container.querySelector(`[data-fehler="${CSS.escape(pfad)}"]`)) amFeld[pfad] = text
|
||||
else rest.push(beschrifteFehler(pfad, text, quellen))
|
||||
}
|
||||
setzeFehler(container, rest.length ? { ...amFeld, _: rest.join(' ') } : amFeld)
|
||||
container.querySelector('[aria-invalid="true"]')?.focus()
|
||||
}
|
||||
|
||||
/**
|
||||
* Speicher des Editors. wurzel = das Element des Editors (verschwindet beim Wechsel der Website: danach fasst nichts mehr die Seite an),
|
||||
* neuZeichnen() zeichnet den Editor aus ctx.stand neu (nach „Neu laden“).
|
||||
* => { konflikt, speichere, exklusiv, nochHier }: konflikt ist das Hinweis-Element (der Editor setzt es an den Anfang), nochHier() sagt, ob der
|
||||
* Editor noch angezeigt wird (vor jedem Anfassen der Seite nach einem await pruefen).
|
||||
*/
|
||||
export function neuerZeitenSpeicher(ctx, wurzel, neuZeichnen) {
|
||||
const nochHier = () => wurzel.isConnected && ctx.aktuell()
|
||||
const text = h('span', null, VERALTET_TEXT)
|
||||
const neuLadenKnopf = h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'neu-laden' }, 'Neu laden')
|
||||
const konflikt = h('div', { class: 'tm-box tm-box-warn', role: 'alert', hidden: true }, text, neuLadenKnopf)
|
||||
neuLadenKnopf.addEventListener('click', async () => {
|
||||
neuLadenKnopf.disabled = true
|
||||
try {
|
||||
await ctx.neuLaden()
|
||||
if (!nochHier()) return
|
||||
text.textContent = VERALTET_TEXT
|
||||
konflikt.hidden = true
|
||||
neuZeichnen()
|
||||
} catch (err) {
|
||||
if (nochHier()) text.textContent = err.message
|
||||
} finally {
|
||||
neuLadenKnopf.disabled = false
|
||||
}
|
||||
})
|
||||
|
||||
// Ein Speichern zur Zeit: waehrenddessen aria-busy am Editor und alle Knoepfe darin gesperrt (danach wieder frei, der Fokus kehrt zurueck).
|
||||
let laeuft = false
|
||||
function sperre(an, gesperrt) {
|
||||
if (an) {
|
||||
wurzel.setAttribute('aria-busy', 'true')
|
||||
const knoepfe = [...wurzel.querySelectorAll('button:not(:disabled)')]
|
||||
const fokus = document.activeElement
|
||||
for (const knopf of knoepfe) knopf.disabled = true
|
||||
return { knoepfe, fokus }
|
||||
}
|
||||
wurzel.removeAttribute('aria-busy')
|
||||
for (const knopf of gesperrt.knoepfe) knopf.disabled = false
|
||||
// Der Fokus war auf einem gesperrten Knopf und ist weg: zurueck dorthin; hat der Abschnitt ihn inzwischen anders gesetzt (z. B. auf ein Feld mit Fehler), bleibt das so
|
||||
const jetzt = document.activeElement
|
||||
if (gesperrt.knoepfe.includes(gesperrt.fokus) && gesperrt.fokus.isConnected && (!jetzt || jetzt === document.body || jetzt === gesperrt.fokus)) gesperrt.fokus.focus()
|
||||
return null
|
||||
}
|
||||
|
||||
/** arbeit() exklusiv ausfuehren; laeuft schon etwas, passiert nichts (Rueckgabe undefined). */
|
||||
async function exklusiv(arbeit) {
|
||||
if (laeuft) return undefined
|
||||
laeuft = true
|
||||
const gesperrt = sperre(true)
|
||||
try {
|
||||
return await arbeit()
|
||||
} finally {
|
||||
laeuft = false
|
||||
sperre(false, gesperrt)
|
||||
}
|
||||
}
|
||||
|
||||
async function speichere(aendere, { container }) {
|
||||
const ergebnis = await exklusiv(async () => {
|
||||
setzeFehler(container, {})
|
||||
konflikt.hidden = true
|
||||
const bisher = ctx.stand.daten
|
||||
const entwurf = structuredClone(bisher ?? ctx.leereDaten())
|
||||
if (aendere(entwurf) === false) return false // der Abschnitt hat selbst Fehler am Feld gezeigt
|
||||
try {
|
||||
const antwort = await request(`/api/zeiten/${encodeURIComponent(ctx.projectId)}`, { method: 'PUT', body: { version: ctx.stand.version, daten: entwurf } })
|
||||
if (!ctx.aktuell()) return false
|
||||
ctx.uebernimm(antwort, { erstes: bisher === null })
|
||||
return true
|
||||
} catch (err) {
|
||||
if (!nochHier()) return false
|
||||
if (err.code === 'ZEITEN_VERALTET') {
|
||||
text.textContent = VERALTET_TEXT
|
||||
konflikt.hidden = false
|
||||
konflikt.scrollIntoView({ block: 'nearest' })
|
||||
} else if (err.status === 400 && Object.keys(err.fehler || {}).length) {
|
||||
zeigeFelder(container, err.fehler, [bisher, entwurf])
|
||||
} else {
|
||||
ctx.meldung(err.message, 'fehler')
|
||||
}
|
||||
return false
|
||||
}
|
||||
})
|
||||
return ergebnis === true
|
||||
}
|
||||
|
||||
return { konflikt, speichere, exklusiv, nochHier }
|
||||
}
|
||||
|
||||
/**
|
||||
* Stelle des Eintrags mit dieser ID in der Liste des Entwurfs. Formulare merken sich keine Stelle, sondern die ID und suchen sie beim
|
||||
* Absenden. Fehlt der Eintrag, steht am Formular eine Meldung und die Rueckgabe ist -1.
|
||||
*/
|
||||
export function stelleVon(liste, id, form, was) {
|
||||
const stelle = (Array.isArray(liste) ? liste : []).findIndex((eintrag) => eintrag?.id === id)
|
||||
if (stelle < 0) zeigeFehler(form, { fehler: { _: `${was} gibt es nicht mehr. Bitte lade die Seite neu.` } })
|
||||
return stelle
|
||||
}
|
||||
|
||||
/**
|
||||
* Nach einem erfolgreichen Speichern: Abschnitte neu zeichnen, Meldung zeigen, Fokus auf das Element mit selektor (Regel: nach dem Speichern
|
||||
* auf den Knopf der gespeicherten Zeile, nach dem Entfernen auf den „+“-Knopf der Liste).
|
||||
*/
|
||||
export function nachSpeichern(t, meldung, selektor) {
|
||||
t.zeichne()
|
||||
t.ctx.meldung(meldung)
|
||||
t.wurzel.querySelector(selektor)?.focus()
|
||||
}
|
||||
|
||||
/**
|
||||
* Knopf mit Rueckfrage in der Seite (kein confirm()): Titel, Erklaerung (text, ein Text oder eine Liste von Absaetzen), Platz fuer den Fehler
|
||||
* des Servers, Knoepfe. aendere(entwurf) entfernt den Eintrag, nachher() laeuft nach dem erfolgreichen Speichern (der Abschnitt zeichnet neu).
|
||||
* Abbrechen schliesst die Rueckfrage und der Fokus geht zum Knopf zurueck. => { knopf, box }: der Aufrufer setzt beide NEBEN sein Formular, nicht
|
||||
* hinein (ein submit der Rueckfrage stiege sonst ins aeussere Formular auf).
|
||||
*/
|
||||
export function entfernenKnopf(sp, { knopfText = 'Entfernen', ja = 'Ja, entfernen', titel, text, fehlerPfad, aendere, nachher }) {
|
||||
const box = h('div', { class: 'tm-frage' })
|
||||
const knopf = h('button', { type: 'button', class: 'kb-btn-outline tm-gefahr', 'data-aktion': 'entfernen' }, knopfText)
|
||||
knopf.addEventListener('click', () => {
|
||||
const { zeile } = knopfZeile(ja, () => { box.replaceChildren(); knopf.focus() })
|
||||
const absaetze = (Array.isArray(text) ? text : [text]).filter(Boolean).map((t) => h('p', null, t))
|
||||
const rueckfrage = frage(titel,
|
||||
...absaetze,
|
||||
fehlerPfad ? h('p', { class: 'tm-fehler', 'data-fehler': fehlerPfad, role: 'alert' }) : null,
|
||||
allgemeinerFehler(),
|
||||
zeile)
|
||||
rueckfrage.addEventListener('submit', async () => {
|
||||
if (await sp.speichere(aendere, { container: rueckfrage }) && sp.nochHier()) nachher()
|
||||
})
|
||||
box.replaceChildren(rueckfrage)
|
||||
rueckfrage.focus()
|
||||
})
|
||||
return { knopf, box }
|
||||
}
|
||||
+48
-1
@@ -1,4 +1,4 @@
|
||||
/* Öffnungszeiten live im Kundenbereich (zeiten.js, zeiten-bestellen.js; der Editor kommt mit den Aufgaben 29 und 30 dazu).
|
||||
/* Öffnungszeiten live im Kundenbereich (zeiten.js, zeiten-bestellen.js und der Editor: zeiten-editor.js, zeiten-plaene.js, zeiten-ausnahmen.js).
|
||||
Farben und Abstände aus den Tokens von kundenbereich.css (.kb-page). Die Bausteine der Bestell- und Statusseiten (tm-bestellen, tm-statuskarte,
|
||||
tm-rechtstext, tm-haken, tm-pille, tm-box, tm-meldung, tm-info …) stehen in termine.css und gelten hier mit; neu sind nur Klassen mit zt-
|
||||
und die Regeln für #view-zeiten. Handy: bis 880px wie die App-Ansicht, Tippflächen mindestens 44px. */
|
||||
@@ -21,12 +21,59 @@
|
||||
.zt-status .tm-box { margin-bottom: 0; }
|
||||
.zt-status .tm-box .kb-btn-outline { flex: none; }
|
||||
|
||||
/* Editor: Zeitplaene, Feiertage, Ausnahmen (zeiten-editor.js, zeiten-plaene.js, zeiten-ausnahmen.js) */
|
||||
.zt-editor > * + * { margin-top: 16px; }
|
||||
.zt-editor > .tm-meldung:empty, .zt-editor > .tm-box[hidden] { margin: 0; }
|
||||
.zt-kopfbereich:empty { display: none; }
|
||||
.zt-spaeter { margin: 0 4px; line-height: 1.5; }
|
||||
.zt-plaene { display: grid; gap: 12px; }
|
||||
.zt-plan { border-radius: 14px; background: var(--kb-surface); border: 1px solid var(--kb-border); }
|
||||
.zt-plan[open] { border-color: var(--kb-purple-line); }
|
||||
.zt-plan-kopf { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; min-height: 44px; padding: 12px 16px; list-style: none; cursor: pointer; }
|
||||
.zt-plan-kopf::-webkit-details-marker { display: none; }
|
||||
.zt-plan-kopf::before { content: '▸'; flex: none; color: var(--kb-muted); transition: transform 0.15s; }
|
||||
.zt-plan[open] > .zt-plan-kopf::before { transform: rotate(90deg); }
|
||||
.zt-plan-kopf .tm-name { display: inline; }
|
||||
.zt-plan-kopf .tm-meta { display: inline; margin: 0; }
|
||||
.zt-plan-kopf code, .tm-formular code { padding: 1px 6px; border-radius: 6px; background: rgba(255, 255, 255, 0.07); font-size: 12.5px; }
|
||||
.zt-plan > .tm-editor { margin: 0; padding: 0 16px 16px; }
|
||||
.zt-plan-form .tm-frage-knoepfe { margin-top: 16px; }
|
||||
.zt-plan-form > .tm-hinweis { margin-top: 10px; }
|
||||
.zt-werkzeuge { display: flex; flex-wrap: wrap; gap: 0 10px; }
|
||||
.zt-werkzeuge:empty { display: none; }
|
||||
.zt-editor .tm-abschnitt > .tm-frage-box { margin: 12px 0 16px; }
|
||||
.zt-tage { display: grid; gap: 10px; margin-top: 14px; }
|
||||
.zt-tag { min-width: 0; padding: 10px 12px; border-radius: 12px; border: 1px solid var(--kb-border); background: rgba(255, 255, 255, 0.02); }
|
||||
.zt-tag > .tm-feld { margin-top: 0; }
|
||||
.zt-tag-felder { display: grid; gap: 10px; margin-top: 8px; }
|
||||
.zt-tag-felder[hidden], .zt-zeiten[hidden] { display: none; }
|
||||
.zt-tag select, .zt-feiertag select {
|
||||
width: 100%; max-width: 360px; box-sizing: border-box; padding: 10px 12px; border-radius: 12px; font: inherit; font-size: 16px;
|
||||
border: 1px solid var(--kb-border-strong); background: rgba(255, 255, 255, 0.05); color: inherit;
|
||||
}
|
||||
.zt-tag select option { color: #111; }
|
||||
.zt-zeiten { display: grid; gap: 8px; }
|
||||
.zt-fenster { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 14px; }
|
||||
.zt-fenster-name { min-width: 11rem; font-size: 13px; color: var(--kb-muted); }
|
||||
.zt-zeit { display: inline-flex; align-items: center; gap: 8px; font-size: 13.5px; }
|
||||
.zt-zeit input {
|
||||
width: 8.5rem; min-height: 40px; box-sizing: border-box; padding: 8px 10px; border-radius: 12px; font: inherit; font-size: 16px; font-variant-numeric: tabular-nums;
|
||||
border: 1px solid var(--kb-border-strong); background: rgba(255, 255, 255, 0.05); color: inherit;
|
||||
}
|
||||
.zt-tag [aria-invalid='true'] { border-color: #f87171; }
|
||||
.zt-tag > .tm-fehler { margin-top: 8px; }
|
||||
.zt-feiertag .zt-tag { margin-top: 10px; padding: 0; border: 0; background: transparent; }
|
||||
|
||||
@media (max-width: 880px), (pointer: coarse) {
|
||||
#view-zeiten button, #view-zeiten .kb-btn-outline, #view-zeiten .kb-btn-primary { min-height: 44px; }
|
||||
#view-zeiten .tm-haken { align-items: center; min-height: 44px; margin-top: 0; margin-bottom: 0; }
|
||||
#view-zeiten .tm-haken input { margin-top: 0; }
|
||||
#view-zeiten .zt-zeit input, #view-zeiten .zt-tag select, #view-zeiten .zt-feiertag select { min-height: 44px; }
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.zt-fenster-name { min-width: 100%; }
|
||||
.zt-zeit input { width: 7.75rem; }
|
||||
.zt-plan > .tm-editor { padding: 0 12px 14px; }
|
||||
.zt-bestellen .tm-bestellen-aktion { justify-items: stretch; }
|
||||
.zt-status .tm-box .kb-btn-outline { width: 100%; }
|
||||
.zt-status .tm-aktionen > * { flex: 1 1 auto; }
|
||||
|
||||
+32
-7
@@ -12,12 +12,16 @@
|
||||
Rueckkehr von Stripe: #zeiten?bestellt=1&projekt=<id> (Bestellung) und #zeiten?behalten=1&projekt=<id> (Behalten) waehlen das Projekt und zeigen
|
||||
„Danke!“; #zeiten?projekt=<id> (Abbruch) waehlt nur das Projekt. Beides wird einmal gelesen und aus der Adresse genommen. Die Kachel in Funktionen
|
||||
legt Projekt und Weg in den Behaelter ab (dataset.wunschProjekt, dataset.wunschWeg = 'behalten').
|
||||
Der Editor (Aufgabe 29) haengt in .zt-editor ein (renderEditor(box, ctx) aus zeiten-editor.js). Ruft app.js initZeiten bei jedem Wechsel in die
|
||||
Der Editor haengt in .zt-editor ein (renderEditor(box, ctx) aus zeiten-editor.js). Ruft app.js initZeiten bei jedem Wechsel in die
|
||||
Ansicht, baut sich nichts neu auf, solange Projekt und Stand gleich sind und der Editorplatz steht (Muster initNews): Eingaben im Editor bleiben stehen.
|
||||
Aendert sich der Stand (zeitenLeer nach dem ersten Speichern, setup → active, die Abo-Lage), wird nur der Kopf (Statuskarte) neu gezeichnet: der Editorplatz
|
||||
bleibt mit seinem Editor und den ungespeicherten Eingaben stehen, solange Website und Schreibrecht (abgeschaltet = nur lesen) gleich sind (data-projekt,
|
||||
data-state); eine andere Website oder der Wechsel in „nur lesen“ bekommt einen frischen Editor. Die Rueckkehr von Stripe zeichnet immer ganz neu.
|
||||
Kundenansicht (window.KbView.id() gesetzt): Einschalten, Bestellen und Behalten sind gesperrt, der Server antwortet dort ohnehin mit 403. */
|
||||
|
||||
import { h, infoKarte, request } from './termine-dom.js'
|
||||
import { fehlerKarte, h, infoKarte, request } from './termine-dom.js'
|
||||
import { behaltenLage, renderZeitenBestellen, zeitenStatusKarte } from './zeiten-bestellen.js'
|
||||
import { renderEditor } from './zeiten-editor.js'
|
||||
|
||||
// Auswahl bleibt beim Wechsel zwischen Funktionen und Zeiten erhalten
|
||||
const auswahl = { projectId: '' }
|
||||
@@ -67,8 +71,10 @@ export async function initZeiten(root, { projectId } = {}) {
|
||||
const projects = Array.isArray(antwort.projects) ? antwort.projects : []
|
||||
const s = {
|
||||
root, projects, angebot: antwort.zeiten || null, features: antwort.features || {}, ustHinweis: typeof antwort.ustHinweis === 'string' ? antwort.ustHinweis : '',
|
||||
projekt: null, lauf: 0, buehne: null, danke: '',
|
||||
projekt: null, lauf: 0, buehne: null, danke: '', editorAlt: null,
|
||||
}
|
||||
// der Editorplatz der bisherigen Ansicht (mit seinem Editor) wird in den neuen Aufbau mitgenommen, ausser nach der Rueckkehr von Stripe
|
||||
const alterEditor = !rueckkehr.bestellt && !rueckkehr.behalten && S?.root === root ? root.querySelector('.zt-editor') : null
|
||||
if (!s.angebot?.verfuegbar) {
|
||||
S = s
|
||||
root.dataset.zeitenKey = ''
|
||||
@@ -93,6 +99,7 @@ export async function initZeiten(root, { projectId } = {}) {
|
||||
return
|
||||
}
|
||||
S = s
|
||||
s.editorAlt = alterEditor
|
||||
s.danke = rueckkehr.bestellt && (!rueckkehr.projekt || rueckkehr.projekt === s.projekt.projectId) ? 'bestellt'
|
||||
: rueckkehr.behalten && (!rueckkehr.projekt || rueckkehr.projekt === s.projekt.projectId) ? 'behalten' : ''
|
||||
s.buehne = h('div', { id: 'zeiten-buehne' })
|
||||
@@ -178,12 +185,30 @@ function zeichneProjekt(s, { weg = '', meldung = '', bestellformular = false } =
|
||||
if (meldungEl) teile.push(meldungEl)
|
||||
teile.push(kopf)
|
||||
|
||||
const editorPlatz = (zustand === 'setup' || zustand === 'active' || (zustand === 'disabled' && projekt.zeitenLeer === false))
|
||||
? h('div', { class: 'zt-editor', 'data-state': zustand })
|
||||
: null
|
||||
if (editorPlatz) teile.push(editorPlatz)
|
||||
// Der Editorplatz steht in setup, active und (mit Daten) disabled. Ein Platz derselben Website mit gleichem Schreibrecht bleibt samt Editor stehen
|
||||
// (ungespeicherte Eingaben gehen nicht verloren, der Editor laedt nicht noch einmal); sonst gibt es einen frischen.
|
||||
let editorPlatz = null
|
||||
let frisch = false
|
||||
if (zustand === 'setup' || zustand === 'active' || (zustand === 'disabled' && projekt.zeitenLeer === false)) {
|
||||
const vorhanden = s.editorAlt || s.buehne.querySelector('.zt-editor')
|
||||
if (vorhanden && vorhanden.dataset.projekt === projekt.projectId && (vorhanden.dataset.state === 'disabled') === (zustand === 'disabled')) {
|
||||
editorPlatz = vorhanden
|
||||
editorPlatz.dataset.state = zustand
|
||||
editorPlatz.hidden = false // die Behalten-Seite versteckt ihn; sie wird bei Bedarf unten wieder geoeffnet
|
||||
} else {
|
||||
editorPlatz = h('div', { class: 'zt-editor', 'data-state': zustand, 'data-projekt': projekt.projectId })
|
||||
frisch = true
|
||||
}
|
||||
teile.push(editorPlatz)
|
||||
}
|
||||
s.editorAlt = null
|
||||
s.buehne.replaceChildren(...teile)
|
||||
if (editorPlatz) s.root.dataset.zeitenKey = stand(projekt, s.angebot)
|
||||
if (frisch) {
|
||||
// neuZeichnen gehoert zur jeweils aktuellen Sitzung (S): der Platz kann einen Neuaufbau der Ansicht ueberleben
|
||||
const editorCtx = { projectId: projekt.projectId, kundenansicht: ctx.kundenansicht, neuZeichnen: (optionen) => (S ? neuZeichnen(S, optionen) : undefined) }
|
||||
Promise.resolve(renderEditor(editorPlatz, editorCtx)).catch((err) => editorPlatz.replaceChildren(fehlerKarte(err?.message || 'Der Editor konnte nicht gezeichnet werden.')))
|
||||
}
|
||||
// die Meldung des Servers ersetzt die Seite, auf der der Kunde geklickt hat: dorthin schauen, nicht ins Leere
|
||||
if (meldungEl) {
|
||||
meldungEl.focus({ preventScroll: true })
|
||||
|
||||
Reference in new issue
Block a user