/* Terminplaner im Kundenbereich: Speichern der Einstellungen – gemeinsam fuer die Reiter Leistungen, Team & Plaetze und Einstellungen. Jede Aenderung ist ein Speichern: PATCH /api/termine/einstellungen schickt die GANZE Einstellung mit der Version, auf der sie beruht (server/routes/termine.js). Der Reiter gibt nur an, was sich aendert: speichere(aendere, { container }) holt eine Kopie des aktuellen Standes (ctx.daten.einstellung), laesst aendere(entwurf) sie aendern und schickt sie ab. - Erfolg: die zurueckgegebene Einstellung und Version gelten (ctx.daten), die Hinweisleiste der Huelle wird neu gezeichnet und die Daten (Kalender-Links) werden neu geladen; Rueckgabe true. - 400: Fehler stehen am Feld (data-fehler = Pfad wie 'leistungen.2.dauer' oder 'ressourcen.1.plan.3'); Pfade ohne Platz im container erscheinen allgemein, mit dem Namen des Eintrags davor. Rueckgabe false. - 409 EINSTELLUNG_VERALTET (jemand hat inzwischen gespeichert): Hinweis mit Knopf „Neu laden“ oben im Reiter. Rueckgabe false. - alles andere: Meldung ueber den Reitern. - Es laeuft immer nur ein Speichern je Reiter (PATCH und das Nachladen danach): solange es laeuft, ist der Reiter 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 gesendet zu werden. Dasselbe gilt fuer das Erzeugen eines Kalender-Links (exklusiv), das die Version ebenfalls aendert. Der Reiter ruft speichere nur, wenn ctx.nurLesen nicht gesetzt ist. Die Tokens der Kalender-Links gehoeren dem Server und stehen nie in der Einstellung des Browsers. */ import { allgemeinerFehler, frage, h, knopfZeile, setzeFehler, zeigeFehler } from './termine-dom.js' /** Wie viele Speichern gerade auf einem Tabpanel laufen (aria-busy bleibt, bis das letzte fertig ist). */ const belegt = new WeakMap() export const VERALTET_TEXT = 'Die Einstellungen wurden inzwischen geändert. Bitte neu laden.' const WORT = { leistungen: 'Leistung', abwesenheiten: 'Abwesenheit' } /** „Leistung „Färben“: Text“ aus Pfad und Text eines Fehlers; Eintraege finden sich per Index oder per ID in den Einstellungen (quellen). */ export function beschrifteFehler(pfad, text, quellen) { if (pfad.startsWith('betrieb.')) return `${text} Das änderst du im Reiter „Einstellungen“.` const m = /^(leistungen|ressourcen|abwesenheiten)\.([^.]+)/.exec(pfad) if (!m) return text for (const quelle of quellen) { const liste = Array.isArray(quelle?.[m[1]]) ? quelle[m[1]] : [] const eintrag = /^\d+$/.test(m[2]) ? liste[Number(m[2])] : liste.find((e) => e?.id === m[2]) const name = eintrag?.name || eintrag?.titel if (!name) continue const wort = m[1] === 'ressourcen' ? (eintrag.typ === 'platz' ? 'Platz' : 'Person') : WORT[m[1]] return `${wort} „${name}“: ${text}` } return text } /** * Speicher eines Reiters. wurzel = das Element des Reiters (verschwindet beim Wechsel des Reiters: danach fasst nichts mehr die Seite an), * neuZeichnen() zeichnet den Reiter aus ctx.daten neu (nach „Neu laden“). * => { konflikt, speichere, nochHier }: konflikt ist das Hinweis-Element (der Reiter setzt es an den Anfang), nochHier() sagt, ob der * Reiter noch angezeigt wird (vor jedem Anfassen der Seite nach einem await pruefen). */ export function neuerSpeicher(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' }, '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 Reiter und alle Knoepfe darin gesperrt (danach wieder frei, der Fokus kehrt zurueck). // Das Tabpanel ist fuer alle Reiter dasselbe Element und wechselt der Nutzer waehrend des Speicherns den Reiter, ist wurzel beim Freigeben // schon ausgehaengt: darum merkt sich die Sperre das Panel, das sie gesetzt hat, und aria-busy faellt erst, wenn kein Speichern mehr darauf laeuft. let laeuft = false function sperre(an, gesperrt) { if (an) { const tafel = wurzel.closest('[role="tabpanel"]') || wurzel belegt.set(tafel, (belegt.get(tafel) || 0) + 1) tafel.setAttribute('aria-busy', 'true') const knoepfe = [...wurzel.querySelectorAll('button:not(:disabled)')] const fokus = document.activeElement for (const knopf of knoepfe) knopf.disabled = true return { tafel, knoepfe, fokus } } const rest = (belegt.get(gesperrt.tafel) || 1) - 1 belegt.set(gesperrt.tafel, rest) if (rest === 0) gesperrt.tafel.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 Reiter 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.daten.einstellung const entwurf = structuredClone(bisher) if (aendere(entwurf) === false) return false // der Reiter hat selbst Fehler am Feld gezeigt try { const antwort = await ctx.api.send('PATCH', '/einstellungen', { version: ctx.daten.version, einstellung: entwurf }) if (!ctx.aktuell()) return false // fehlt (Pflichtangaben fuer Gaeste) nennt der Server in der Antwort: in aktiver Funktion ist es nach einem Erfolg leer, im Entwurf // des Fragebogens koennen Telefon und Anschrift noch fehlen Object.assign(ctx.daten, { einstellung: antwort.einstellung, version: antwort.version, fehlt: antwort.fehlt ?? [] }) ctx.hinweise() try { await ctx.neuLaden() // neue Personen/Plaetze bekommen vom Server ihre Kalender-Links } catch { /* dann zeigen neue Eintraege ihren Kalender-Link erst nach dem naechsten Laden */ } return true } catch (err) { if (!nochHier()) return false if (err.code === 'EINSTELLUNG_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. Editoren merken sich keine Stelle, sondern die ID und suchen sie beim * Absenden (die Liste kann sich inzwischen anders sortiert haben). 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: Reiter 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, text, selektor) { t.zeichne() t.ctx.meldung(text) t.wurzel.querySelector(selektor)?.focus() } /** 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() } /** * „Entfernen“-Knopf mit Rueckfrage in der Seite (kein confirm()): Titel, Erklaerung, Platz fuer den Fehler des Servers am Eintrag * (fehlerPfad, z. B. 'leistungen.s_abcd1234': „Fuer diese Leistung gibt es noch 2 kuenftige Termine …“), Knoepfe. aendere(entwurf) entfernt * den Eintrag, nachher() laeuft nach dem erfolgreichen Speichern (der Reiter 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, { 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' }, 'Entfernen') knopf.addEventListener('click', () => { const { zeile } = knopfZeile('Ja, entfernen', () => { box.replaceChildren(); knopf.focus() }) const rueckfrage = frage(titel, h('p', null, text), 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 } }