/* 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/ 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 der PUT laeuft, ist der Editor aria-busy, alle seine Knoepfe UND Eingabefelder sind gesperrt (waehrenddessen Getipptes ginge sonst verloren), und ein weiterer Aufruf von speichere (z. B. ein Enter im Formular) wird abgewiesen (Rueckgabe false), statt mit einer veralteten Version zu gehen. Der Fokus geht danach zum ersten Feld mit Fehler, sonst dorthin zurueck, wo er war. - Nach einem Erfolg zeichnet der Abschnitt nur neu, was das Speichern geaendert hat (nachSpeichern mit neuZeichnen): ungespeicherte Eingaben in den anderen Formularen bleiben stehen. Nur „Neu laden“ nach einem 409 zeichnet den ganzen Editor neu. 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). Den Fokus setzt speichere nach dem Entsperren. */ 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) } /** * 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, 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 und Eingabefelder darin gesperrt (danach wieder frei, der Fokus kehrt zurueck). let laeuft = false function sperre(an, gesperrt) { if (an) { wurzel.setAttribute('aria-busy', 'true') const steuerungen = [...wurzel.querySelectorAll('button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled)')] const fokus = document.activeElement for (const steuerung of steuerungen) steuerung.disabled = true return { steuerungen, fokus } } wurzel.removeAttribute('aria-busy') for (const steuerung of gesperrt.steuerungen) steuerung.disabled = false // Der Fokus war auf einer gesperrten Steuerung und ist weg: zurueck dorthin; hat der Abschnitt ihn inzwischen anders gesetzt (z. B. auf die Meldung), bleibt das so const jetzt = document.activeElement if (gesperrt.steuerungen.includes(gesperrt.fokus) && gesperrt.fokus.isConnected && (!jetzt || jetzt === document.body || jetzt === gesperrt.fokus)) gesperrt.fokus.focus() return null } async function speichere(aendere, { container }) { if (laeuft) return false // ein Speichern zur Zeit laeuft = true let gesperrt = null let amFeld = false try { 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 gesperrt = sperre(true) 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]) amFeld = true } else { ctx.meldung(err.message, 'fehler') } return false } } finally { laeuft = false if (gesperrt) sperre(false, gesperrt) if (amFeld && nochHier()) container.querySelector('[aria-invalid="true"]')?.focus() // gesperrte Felder nehmen den Fokus nicht: erst nach dem Entsperren } } return { konflikt, speichere, 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: neu zeichnen, was das Speichern geaendert hat (neuZeichnen(); ohne Angabe der ganze Editor – die Abschnitte * rufen es immer mit der kleinsten noetigen Aenderung auf, damit ungespeicherte Eingaben in anderen Formularen stehen bleiben), 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, neuZeichnen = () => t.zeichne()) { neuZeichnen() t.ctx.meldung(meldung) t.wurzel.querySelector(selektor)?.focus() } /** * Knopf mit Rueckfrage in der Seite (kein confirm()): Titel, Erklaerung (text: ein Text, eine Liste von Absaetzen oder eine Funktion, die beides beim Oeffnen liefert), 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() }) // text darf eine Funktion sein: sie liest den Stand erst jetzt, beim Oeffnen (die Karte kann aelter sein als eine Ausnahme, die inzwischen dazukam) const gesagt = typeof text === 'function' ? text() : text const absaetze = (Array.isArray(gesagt) ? gesagt : [gesagt]).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 } }