- termine-tab-einstellungen.js: die acht Abschnitte (betrieb, darstellung, regeln,
gastFelder, erinnerung, storno, rechtliches, zahlung) sind Exporte; betrieb nimmt
{ nur, titel, hinweis } (nur die gewaehlten Felder, lese schreibt nur diese)
- termine-tab-team.js: wochenplanFeld(r, pfad) => { el, lese() => { plan } | { fehler } },
der Editor einer Person benutzt es selbst
- termine-speichern.js: fehlt kommt aus der Antwort des Servers
- termine.js: baueCtx aus zeigeReiter herausgeloest (Verhalten gleich)
- test/support/fakeDom.js: Minimal-DOM und Fake-fetch fuer die Client-Tests
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
200 lines
10 KiB
JavaScript
200 lines
10 KiB
JavaScript
/* 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 }
|
||
}
|