Terminplaner: Portal-Ansicht Leistungen, Team, Einstellungen

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-03 08:17:44 +00:00
1 parent 650919b022
commit 0b75b31009
15 files changed
+2479 -79

No files matched your search

+128
View File
@@ -0,0 +1,128 @@
/* 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, knopf }) 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.
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 } from './termine-dom.js'
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
}
})
async function speichere(aendere, { container, knopf = null } = {}) {
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
if (knopf) knopf.disabled = true
try {
const antwort = await ctx.api.send('PATCH', '/einstellungen', { version: ctx.daten.version, einstellung: entwurf })
if (!ctx.aktuell()) return false
// Der Server hat streng geprueft: Telefon und Anschrift fehlen nach einem Erfolg nie mehr
Object.assign(ctx.daten, { einstellung: antwort.einstellung, version: antwort.version, 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
} finally {
if (knopf?.isConnected) knopf.disabled = false
}
}
return { konflikt, speichere, nochHier }
}
/** 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()
}
/**
* Rueckfrage „entfernen?“ 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), zurueck() nach „Abbrechen“. Gibt das Formular zurueck;
* der Aufrufer setzt es in seine Box und gibt ihm den Fokus.
*/
export function entfernenFrage(sp, { titel, text, fehlerPfad, aendere, nachher, zurueck }) {
const { ok, zeile } = knopfZeile('Ja, entfernen', zurueck)
const box = frage(titel,
h('p', null, text),
fehlerPfad ? h('p', { class: 'tm-fehler', 'data-fehler': fehlerPfad, role: 'alert' }) : null,
allgemeinerFehler(),
zeile)
box.addEventListener('submit', async () => {
if (await sp.speichere(aendere, { container: box, knopf: ok }) && sp.nochHier()) nachher()
})
return box
}