Der Hinweis „Die N Ausnahmen, die nur für diesen Plan gelten, werden mit gelöscht“ wurde beim Bauen der Karte berechnet und war nach dem Speichern einer Ausnahme veraltet. Er wird jetzt beim Öffnen der Rückfrage aus dem aktuellen Stand gelesen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
214 lines
12 KiB
JavaScript
214 lines
12 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 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 }
|
||
}
|