Files
ClaudeandClaude Opus 5.5 242fbb01e5 Öffnungszeiten: Plan-löschen-Rückfrage zählt die Ausnahmen beim Öffnen
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>
2026-10-05 06:23:56 +00:00

214 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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 }
}