171 lines
8.9 KiB
JavaScript
171 lines
8.9 KiB
JavaScript
/* Terminplaner im Kundenbereich: Helfer ohne Zustand (Elemente bauen, Schnittstelle, Formularfelder, Hinweiskarten).
|
|
Alle Module der Ansicht (termine.js, termine-tab-*.js, termine-aktionen.js, termine-formulare.js) bauen die Seite nur hiermit:
|
|
Kundentext kommt ausschliesslich ueber textContent in die Seite (h), nie ueber innerHTML. */
|
|
|
|
/** Element bauen: Texte nur als Textknoten, Attribute mit on… als Ereignis, true setzt ein Attribut ohne Wert, false/null/undefined lassen es weg. */
|
|
export function h(tag, attrs, ...kinder) {
|
|
const el = document.createElement(tag)
|
|
for (const [name, wert] of Object.entries(attrs || {})) {
|
|
if (wert === undefined || wert === null || wert === false) continue
|
|
if (name === 'class') el.className = wert
|
|
else if (name.startsWith('on')) el.addEventListener(name.slice(2), wert)
|
|
else el.setAttribute(name, wert === true ? '' : wert)
|
|
}
|
|
for (const kind of kinder.flat()) if (kind !== undefined && kind !== null && kind !== false) el.append(kind)
|
|
return el
|
|
}
|
|
|
|
export async function request(path, { method = 'GET', body } = {}) {
|
|
let response
|
|
try {
|
|
response = await fetch(path, {
|
|
method,
|
|
credentials: 'same-origin',
|
|
headers: body ? { 'Content-Type': 'application/json' } : {},
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
})
|
|
} catch {
|
|
// Netzwerkfehler (offline, Server nicht erreichbar): deutscher Text statt „Failed to fetch“; status 0 = nie eine Antwort bekommen
|
|
throw Object.assign(new Error('Keine Verbindung. Bitte versuch es gleich noch einmal.'), { status: 0, fehler: {} })
|
|
}
|
|
const data = await response.json().catch(() => ({}))
|
|
if (!response.ok) {
|
|
const err = new Error(data.error || (response.status === 502 ? 'Server nicht erreichbar. Bitte kurz warten und noch einmal versuchen.' : `Fehler ${response.status}`))
|
|
err.status = response.status
|
|
err.fehler = data.fehler || {}
|
|
err.code = data.code
|
|
throw err
|
|
}
|
|
return data
|
|
}
|
|
|
|
/** Adresse des CSV-Exports (die einzige Adresse, die die Ansicht als Link setzt). */
|
|
export const exportUrl = (projectId) => `/api/termine/export.csv?projectId=${encodeURIComponent(projectId)}`
|
|
|
|
let idZaehler = 0
|
|
const neueId = () => `tm-f${++idZaehler}`
|
|
|
|
/** Formularfeld mit Beschriftung, Hinweis und Platz fuer den Fehler (data-feld = Feldname der Schnittstelle). */
|
|
export function feld(name, label, steuer, hinweis) {
|
|
const id = steuer.id || (steuer.id = neueId())
|
|
steuer.dataset.feld = name
|
|
return h('div', { class: 'tm-feld' },
|
|
h('label', { for: id }, label),
|
|
steuer,
|
|
hinweis ? h('p', { class: 'tm-hinweis' }, hinweis) : null,
|
|
h('p', { class: 'tm-fehler', 'data-fehler': name, role: 'alert' }))
|
|
}
|
|
|
|
/** Ankreuzfeld (Wert steuert der Aufrufer: input type=checkbox) mit Beschriftung, Hinweis und Platz fuer den Fehler. */
|
|
export function hakenFeld(name, steuer, text, hinweis) {
|
|
steuer.dataset.feld = name
|
|
return h('div', { class: 'tm-feld tm-feld-haken' },
|
|
h('label', { class: 'tm-haken' }, steuer, h('span', null, text, hinweis ? h('small', { class: 'tm-hinweis' }, hinweis) : null)),
|
|
h('p', { class: 'tm-fehler', 'data-fehler': name, role: 'alert' }))
|
|
}
|
|
|
|
/** Platz fuer Fehler, die zu keinem Feld gehoeren (Feldname „_“). */
|
|
export const allgemeinerFehler = () => h('p', { class: 'tm-fehler', 'data-fehler': '_', role: 'alert' })
|
|
|
|
export function setzeFehler(form, fehler) {
|
|
for (const el of form.querySelectorAll('[data-fehler]')) el.textContent = ''
|
|
for (const el of form.querySelectorAll('[aria-invalid]')) el.removeAttribute('aria-invalid')
|
|
const rest = []
|
|
for (const [name, text] of Object.entries(fehler || {})) {
|
|
// „_“ und Namen ohne Platz im Formular werden allgemein gezeigt
|
|
const ziel = name === '_' ? null : form.querySelector(`[data-fehler="${CSS.escape(name)}"]`)
|
|
if (ziel) {
|
|
ziel.textContent = text
|
|
form.querySelector(`[data-feld="${CSS.escape(name)}"]`)?.setAttribute('aria-invalid', 'true')
|
|
} else {
|
|
rest.push(text)
|
|
}
|
|
}
|
|
const allgemein = form.querySelector('[data-fehler="_"]')
|
|
if (allgemein) allgemein.textContent = rest.join(' ')
|
|
}
|
|
|
|
/** Fehler der Schnittstelle am Formular zeigen (Feldfehler am Feld, sonst allgemein). */
|
|
export function zeigeFehler(form, err) {
|
|
const fehler = err.fehler && Object.keys(err.fehler).length ? err.fehler : { _: err.message }
|
|
setzeFehler(form, fehler)
|
|
form.querySelector('[aria-invalid]')?.focus()
|
|
}
|
|
|
|
/** Rueckfrage in der Seite (kein confirm()): Formular mit Titel; der Fokus gehoert dem Aufrufer. */
|
|
export function frage(titel, ...inhalt) {
|
|
return h('form', { class: 'tm-frage-box', novalidate: true, tabindex: '-1', 'aria-label': titel, onsubmit: (ev) => ev.preventDefault() }, h('strong', null, titel), ...inhalt)
|
|
}
|
|
|
|
/** Knopfzeile einer Rueckfrage: Bestaetigen (schickt das Formular ab) und Abbrechen. */
|
|
export function knopfZeile(bestaetigen, zu) {
|
|
const ok = h('button', { type: 'submit', class: 'kb-btn-primary' }, bestaetigen)
|
|
return { ok, zeile: h('div', { class: 'tm-frage-knoepfe' }, ok, h('button', { type: 'button', class: 'kb-btn-outline', onclick: zu }, 'Abbrechen')) }
|
|
}
|
|
|
|
/** Abschnitt eines Reiters: Karte mit Ueberschrift und einem erklaerenden Satz (hinweis darf fehlen). */
|
|
export const abschnitt = (titel, hinweis, ...inhalt) => h('section', { class: 'kb-panel tm-abschnitt' },
|
|
h('h3', null, titel),
|
|
hinweis ? h('p', { class: 'tm-hinweis tm-abschnitt-text' }, hinweis) : null,
|
|
...inhalt)
|
|
|
|
/** Felder einer abgeschalteten Funktion: gesperrt (fieldset disabled sperrt alle Felder darin), sonst unveraendert. */
|
|
export const gesperrt = (nurLesen, felder) => (nurLesen ? h('fieldset', { class: 'tm-gruppe-felder', disabled: true }, felder) : felder)
|
|
|
|
/**
|
|
* Klappt unter einem Knopf einen Editor auf und zu (Bearbeiten, „+ Neu“). baue(schliessen) liefert den Editor (Element), der in box
|
|
* gesetzt wird. Fokus-Regel fuer alle Editoren: beim Oeffnen ins erste Feld, beim Schliessen ueber „Abbrechen“/„Schliessen“ (schliessen())
|
|
* zurueck auf den Knopf, beim erneuten Klick auf den Knopf bleibt er dort.
|
|
*/
|
|
export function editorUmschalter({ knopf, box, baue }) {
|
|
const zu = () => { box.replaceChildren(); knopf.setAttribute('aria-expanded', 'false') }
|
|
knopf.addEventListener('click', () => {
|
|
if (box.firstChild) { zu(); return }
|
|
knopf.setAttribute('aria-expanded', 'true')
|
|
const editor = baue(() => { zu(); knopf.focus() })
|
|
box.replaceChildren(editor)
|
|
editor.querySelector('input:not(:disabled), select:not(:disabled), textarea:not(:disabled)')?.focus()
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Zeile einer Liste mit Editor darunter (Leistung, Person/Platz, Abwesenheit, Sonderoeffnung): Kurzfassung (haupt) mit Pille, Knoepfe
|
|
* (vorKnopf, z. B. Pfeile) und der Knopf „Bearbeiten“ (bei abgeschalteter Funktion „Ansehen“), der den Editor unter der Zeile oeffnet.
|
|
* attrs: Attribute der Zeile (data-id / data-tag, so findet der Reiter sie nach dem Neuzeichnen), label: Name fuer den Screenreader.
|
|
*/
|
|
export function editorZeile({ attrs, haupt, pille = null, vorKnopf = [], nurLesen, label, baue }) {
|
|
const box = h('div', { class: 'tm-editor-box' })
|
|
const knopf = h('button', {
|
|
type: 'button', class: 'kb-btn-outline tm-knopf', 'data-aktion': 'bearbeiten', 'aria-expanded': 'false', 'aria-label': `${nurLesen ? 'Ansehen' : 'Bearbeiten'}: ${label}`,
|
|
}, nurLesen ? 'Ansehen' : 'Bearbeiten')
|
|
editorUmschalter({ knopf, box, baue })
|
|
return h('li', { class: 'tm-eintrag', ...attrs },
|
|
h('div', { class: 'tm-kopf' }, h('div', { class: 'tm-haupt' }, haupt), pille),
|
|
h('div', { class: 'tm-aktionen' }, vorKnopf, knopf),
|
|
box)
|
|
}
|
|
|
|
/**
|
|
* „+ Neu“-Knopf mit Editor darunter. voll: die Obergrenze der Liste ist erreicht (Knopf gesperrt, grenzText erklaert es).
|
|
* => { leiste, box }: leiste (Knopf und Hinweis; null bei abgeschalteter Funktion) und box (Platz fuer den Editor) setzt der Aufrufer in den Reiter.
|
|
*/
|
|
export function neuerEintrag({ nurLesen, text, werkzeug, primaer = false, voll = false, grenzText = '', baue }) {
|
|
const box = h('div', { class: 'tm-formular-box' })
|
|
if (nurLesen) return { leiste: null, box }
|
|
const knopf = h('button', { type: 'button', class: primaer ? 'kb-btn-primary' : 'kb-btn-outline', 'data-werkzeug': werkzeug, 'aria-expanded': 'false', disabled: voll }, text)
|
|
editorUmschalter({ knopf, box, baue })
|
|
return { leiste: h('div', { class: 'tm-werkzeug-zeile' }, knopf, voll ? h('p', { class: 'tm-hinweis' }, grenzText) : null), box }
|
|
}
|
|
|
|
/** Link zum CSV-Export der Termine. */
|
|
export const exportLink = (projectId, text) => h('a', { href: exportUrl(projectId), download: 'termine.csv' }, text)
|
|
|
|
export const infoKarte = (titel, text, { chat = false } = {}) => h('div', { class: 'kb-panel tm-info' },
|
|
h('h3', null, titel),
|
|
text ? h('p', null, text) : null,
|
|
chat ? h('button', { type: 'button', class: 'kb-btn-outline', 'data-view': 'chat' }, 'Chat öffnen') : null)
|
|
|
|
export const fehlerKarte = (text, nochmal) => h('div', { class: 'kb-panel tm-info', role: 'alert' },
|
|
h('p', null, text),
|
|
nochmal ? h('button', { type: 'button', class: 'kb-btn-outline', onclick: nochmal }, 'Noch einmal versuchen') : null)
|