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>
213 lines
11 KiB
JavaScript
213 lines
11 KiB
JavaScript
/* 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 }
|
||
}
|