240 lines
14 KiB
JavaScript
240 lines
14 KiB
JavaScript
/* Terminplaner im Kundenbereich: Reiter "Team & Plaetze" – Personen und Plaetze mit Wochenplan (je Tag mehrere Zeitfenster, Text wie
|
||
„09:00–12:30, 13:30–18:00“, fensterParsen), Zuordnung der Leistungen, Kalender-Abo-Link (kopieren, neu erzeugen); darunter die Ausnahmen
|
||
(Abwesenheiten, Feiertage, Sonderoeffnungen: termine-team-ausnahmen.js). render(panel, ctx) ist der Einstieg (Registry REITER in termine.js).
|
||
Jede Aenderung speichert die ganze Einstellung (termine-speichern.js); die Zuordnung steht in der Einstellung bei den Leistungen
|
||
(leistungen[].ressourcen), eine neue Person und ihre Zuordnung gehen deshalb in EINEM Speichern raus. Tokens der Kalender-Links vergibt nur
|
||
der Server. Abgeschaltete Funktion (ctx.nurLesen): Felder gesperrt, keine Knoepfe zum Aendern, keine Links. */
|
||
|
||
import { LIMITS, MAX, WOCHENTAGE_PLAN, eindeutigeId, planAusTexten, planTexte } from './termine-view.js'
|
||
import { abschnitt, allgemeinerFehler, feld, frage, gesperrt, h, hakenFeld, knopfZeile, zeigeFehler } from './termine-dom.js'
|
||
import { entfernenFrage, neuerSpeicher } from './termine-speichern.js'
|
||
import { abwesenheiten, feiertage, sonderoeffnungen } from './termine-team-ausnahmen.js'
|
||
|
||
const TYPEN = [['person', 'Person'], ['platz', 'Platz']]
|
||
const zaehle = (n, eins, viele) => `${n} ${n === 1 ? eins : viele}`
|
||
|
||
export function render(panel, ctx) {
|
||
const wurzel = h('div', { class: 'tm-tab' })
|
||
panel.replaceChildren(wurzel)
|
||
const sp = neuerSpeicher(ctx, wurzel, zeichne)
|
||
const t = { ctx, sp, zeichne, wurzel }
|
||
function zeichne() {
|
||
wurzel.replaceChildren(sp.konflikt, personen(t), abwesenheiten(t), feiertage(t), sonderoeffnungen(t))
|
||
}
|
||
zeichne()
|
||
}
|
||
|
||
/* ----------------------------------------------------- Personen und Plaetze --- */
|
||
function personen(t) {
|
||
const { ctx } = t
|
||
const e = ctx.daten.einstellung
|
||
const liste = e.ressourcen || []
|
||
const voll = liste.length >= LIMITS.ressourcen
|
||
const neuBox = h('div', { class: 'tm-formular-box' })
|
||
const neuKnopf = ctx.nurLesen ? null : h('button', {
|
||
type: 'button', class: 'kb-btn-primary', 'data-werkzeug': 'ressource-neu', 'aria-expanded': 'false', disabled: voll,
|
||
onclick: () => {
|
||
if (neuBox.firstChild) { neuBox.replaceChildren(); neuKnopf.setAttribute('aria-expanded', 'false'); return }
|
||
neuKnopf.setAttribute('aria-expanded', 'true')
|
||
const editor = formular(t, { index: liste.length, r: null, schliessen: () => { neuBox.replaceChildren(); neuKnopf.setAttribute('aria-expanded', 'false'); neuKnopf.focus() } })
|
||
neuBox.replaceChildren(editor)
|
||
editor.querySelector('input')?.focus()
|
||
},
|
||
}, '+ Person oder Platz hinzufügen')
|
||
|
||
const reihe = (r, index) => {
|
||
const li = h('li', { class: 'tm-eintrag', 'data-id': r.id })
|
||
const editor = h('div', { class: 'tm-editor-box' })
|
||
const leistungen = (e.leistungen || []).filter((l) => (l.ressourcen || []).includes(r.id)).length
|
||
const tage = Object.keys(r.plan || {}).length
|
||
const oeffnen = h('button', {
|
||
type: 'button', class: 'kb-btn-outline tm-knopf', 'data-aktion': 'bearbeiten', 'aria-expanded': 'false', 'aria-label': `${ctx.nurLesen ? 'Ansehen' : 'Bearbeiten'}: ${r.name}`,
|
||
onclick: () => {
|
||
if (editor.firstChild) { editor.replaceChildren(); oeffnen.setAttribute('aria-expanded', 'false'); return }
|
||
oeffnen.setAttribute('aria-expanded', 'true')
|
||
const neuerEditor = formular(t, { index, r, schliessen: () => { editor.replaceChildren(); oeffnen.setAttribute('aria-expanded', 'false'); oeffnen.focus() } })
|
||
editor.replaceChildren(neuerEditor)
|
||
neuerEditor.querySelector('input')?.focus()
|
||
},
|
||
}, ctx.nurLesen ? 'Ansehen' : 'Bearbeiten')
|
||
li.append(
|
||
h('div', { class: 'tm-kopf' },
|
||
h('div', { class: 'tm-haupt' },
|
||
h('strong', { class: 'tm-name' }, r.name),
|
||
h('span', { class: 'tm-meta' }, [r.typ === 'platz' ? 'Platz' : 'Person', tage ? `Zeiten an ${zaehle(tage, 'Tag', 'Tagen')}` : 'keine Zeiten', leistungen ? zaehle(leistungen, 'Leistung', 'Leistungen') : 'keine Leistung'].join(' · '))),
|
||
h('span', { class: `tm-pille tm-pille-${r.aktiv ? 'ok' : 'off'}` }, r.aktiv ? 'Aktiv' : 'Inaktiv')),
|
||
h('div', { class: 'tm-aktionen' }, oeffnen),
|
||
editor)
|
||
return li
|
||
}
|
||
|
||
return abschnitt('Personen und Plätze',
|
||
'Wer oder was kann gebucht werden? Jede Person und jeder Platz hat eigene Zeiten und bietet bestimmte Leistungen an. Die Leistungen selbst legst du im Reiter „Leistungen“ an.',
|
||
ctx.nurLesen ? null : h('div', { class: 'tm-werkzeug-zeile' }, neuKnopf, voll ? h('p', { class: 'tm-hinweis' }, `Mehr als ${LIMITS.ressourcen} Personen oder Plätze sind nicht möglich.`) : null),
|
||
neuBox,
|
||
liste.length
|
||
? h('ul', { class: 'tm-eintraege', 'aria-label': 'Personen und Plätze' }, liste.map(reihe))
|
||
: h('p', { class: 'tm-leer' }, 'Noch keine Person und kein Platz. Ohne sie kann niemand buchen.'))
|
||
}
|
||
|
||
/**
|
||
* Editor einer neuen (r null) oder bestehenden Person/eines Platzes: Formular, darunter (nur bestehend) Kalender-Link und die Rueckfrage zum
|
||
* Entfernen. Die Rueckfragen stehen neben dem Formular, nicht darin: ein submit der Rueckfrage steigt sonst ins aeussere Formular auf.
|
||
* index = Stelle in ressourcen (so heissen die Pfade der Fehler des Servers).
|
||
*/
|
||
function formular(t, { index, r, schliessen }) {
|
||
const { ctx, sp, zeichne } = t
|
||
const e = ctx.daten.einstellung
|
||
const neu = r === null
|
||
const alt = r || { id: eindeutigeId('r', (e.ressourcen || []).map((x) => x.id)), typ: 'person', name: '', aktiv: true, plan: {} }
|
||
const p = `ressourcen.${index}`
|
||
const leistungen = e.leistungen || []
|
||
|
||
const name = h('input', { type: 'text', maxlength: MAX.ressourcenName, autocomplete: 'off', value: alt.name })
|
||
const typ = h('select', null, TYPEN.map(([wert, text]) => h('option', { value: wert, selected: alt.typ === wert }, text)))
|
||
const aktiv = h('input', { type: 'checkbox', checked: alt.aktiv })
|
||
const texte = planTexte(alt.plan)
|
||
const tage = WOCHENTAGE_PLAN.map(([nr, tag]) => ({
|
||
nr, name: `${p}.plan.${nr}`,
|
||
input: h('input', { type: 'text', autocomplete: 'off', placeholder: 'geschlossen', value: texte[nr] }),
|
||
tag,
|
||
}))
|
||
const zuordnung = leistungen.map((l, k) => ({ id: l.id, input: h('input', { type: 'checkbox', checked: (l.ressourcen || []).includes(alt.id) }), name: `leistungen.${k}.ressourcen`, label: `${l.name}${l.aktiv ? '' : ' (inaktiv)'}` }))
|
||
|
||
const speichern = h('button', { type: 'submit', class: 'kb-btn-primary' }, neu ? 'Hinzufügen' : 'Speichern')
|
||
const frageBox = h('div', { class: 'tm-frage' })
|
||
const entfernen = neu || ctx.nurLesen ? null : h('button', { type: 'button', class: 'kb-btn-outline tm-gefahr', onclick: () => fragen() }, 'Entfernen')
|
||
const form = h('form', { class: 'kb-panel tm-formular', novalidate: true, 'aria-label': neu ? 'Person oder Platz hinzufügen' : `${ctx.nurLesen ? 'Person oder Platz' : 'Bearbeiten'}: ${alt.name}` },
|
||
h('h3', null, neu ? 'Neue Person oder neuer Platz' : alt.name),
|
||
gesperrt(ctx.nurLesen, [
|
||
h('div', { class: 'tm-raster' },
|
||
feld(`${p}.name`, 'Name', name, 'So steht es beim Buchen, wenn Gäste eine Person wählen dürfen.'),
|
||
feld(`${p}.typ`, 'Art', typ)),
|
||
hakenFeld(`${p}.aktiv`, aktiv, 'Aktiv', 'Nur aktive Personen und Plätze können gebucht werden. Termine, die schon gebucht sind, bleiben bei „Inaktiv“ bestehen.'),
|
||
h('fieldset', { class: 'tm-gruppe-felder tm-wochenplan' },
|
||
h('legend', { class: 'tm-label' }, 'Wochenplan'),
|
||
h('p', { class: 'tm-hinweis' }, 'Wann kann gebucht werden? Mehrere Zeitfenster trennst du mit Komma, zum Beispiel 09:00–12:30, 13:30–18:00. Leer lassen: an diesem Tag geschlossen. Uhrzeiten in 5-Minuten-Schritten, das Ende bis 24:00.'),
|
||
h('div', { class: 'tm-raster' }, tage.map(({ name: feldName, input, tag }) => feld(feldName, tag, input)))),
|
||
h('fieldset', { class: 'tm-gruppe-felder', tabindex: '-1' },
|
||
h('legend', { class: 'tm-label' }, 'Diese Leistungen bietet sie an'),
|
||
zuordnung.length
|
||
? zuordnung.map(({ input, name: feldName, label }) => hakenFeld(feldName, input, label))
|
||
: h('p', { class: 'tm-hinweis' }, 'Es gibt noch keine Leistung. Lege sie im Reiter „Leistungen“ an und ordne sie danach hier zu.')),
|
||
]),
|
||
allgemeinerFehler(),
|
||
h('div', { class: 'tm-frage-knoepfe' },
|
||
ctx.nurLesen ? null : speichern,
|
||
h('button', { type: 'button', class: 'kb-btn-outline', onclick: schliessen }, ctx.nurLesen ? 'Schließen' : 'Abbrechen'),
|
||
entfernen))
|
||
|
||
form.addEventListener('submit', async (ev) => {
|
||
ev.preventDefault()
|
||
const ok = await sp.speichere((entwurf) => {
|
||
const gelesen = planAusTexten(Object.fromEntries(tage.map(({ nr, input }) => [nr, input.value])))
|
||
if (gelesen.fehler) {
|
||
zeigeFehler(form, { fehler: Object.fromEntries(Object.entries(gelesen.fehler).map(([nr, text]) => [`${p}.plan.${nr}`, text])) })
|
||
return false
|
||
}
|
||
const ressource = { ...alt, name: name.value, typ: typ.value, aktiv: aktiv.checked, plan: gelesen.plan }
|
||
if (neu) entwurf.ressourcen.push(ressource)
|
||
else entwurf.ressourcen[index] = ressource
|
||
// die Zuordnung steht bei den Leistungen: genau diese Person/diesen Platz bei jeder Leistung ein- oder austragen
|
||
for (const { id, input } of zuordnung) {
|
||
const leistung = entwurf.leistungen.find((l) => l.id === id)
|
||
if (!leistung) continue
|
||
const ids = (leistung.ressourcen || []).filter((x) => x !== alt.id)
|
||
leistung.ressourcen = input.checked ? [...ids, alt.id] : ids
|
||
}
|
||
return true
|
||
}, { container: form, knopf: speichern })
|
||
if (!ok || !sp.nochHier()) return
|
||
zeichne()
|
||
ctx.meldung(neu ? `„${name.value.trim()}“ ist hinzugefügt.` : 'Gespeichert.')
|
||
t.wurzel.querySelector(`li[data-id="${alt.id}"] [data-aktion="bearbeiten"]`)?.focus()
|
||
})
|
||
|
||
function fragen() {
|
||
const box = entfernenFrage(sp, {
|
||
titel: `„${alt.name}“ entfernen?`,
|
||
text: 'Die Zeiten, die Zuordnung zu den Leistungen und die Abwesenheiten gehen mit verloren. Gibt es noch künftige Termine, geht das Entfernen nicht. Dann setze sie auf „Inaktiv“.',
|
||
fehlerPfad: `ressourcen.${alt.id}`,
|
||
aendere: (entwurf) => {
|
||
entwurf.ressourcen = entwurf.ressourcen.filter((x) => x.id !== alt.id)
|
||
for (const l of entwurf.leistungen) l.ressourcen = (l.ressourcen || []).filter((id) => id !== alt.id)
|
||
entwurf.abwesenheiten = (entwurf.abwesenheiten || []).filter((a) => a.ressourceId !== alt.id)
|
||
},
|
||
nachher: () => { zeichne(); ctx.meldung(`„${alt.name}“ entfernt.`) },
|
||
zurueck: () => { frageBox.replaceChildren(); entfernen.focus() },
|
||
})
|
||
frageBox.replaceChildren(box)
|
||
box.focus()
|
||
}
|
||
return h('div', { class: 'tm-editor' }, form, neu || ctx.nurLesen ? null : kalenderLink(t, alt), frageBox)
|
||
}
|
||
|
||
/* ---------------------------------------------------------- Kalender-Abo --- */
|
||
/** Kalender-Link einer gespeicherten Person/eines Platzes: anzeigen, kopieren, neu erzeugen (der Server vergibt das Token). */
|
||
function kalenderLink(t, r) {
|
||
const { ctx } = t
|
||
const url = ctx.daten.feeds?.[r.id]
|
||
const kopf = [
|
||
h('h4', null, 'Kalender-Abo'),
|
||
h('p', { class: 'tm-hinweis' }, 'Mit diesem Link zeigt dein Kalender (Google, Apple, Outlook) die bestätigten Termine automatisch an. Füge ihn dort als Kalender-Abo ein („Kalender abonnieren“ oder „Per URL hinzufügen“), nicht als Datei. Google aktualisiert abonnierte Kalender nur alle paar Stunden, neue Termine erscheinen dort deshalb mit Verspätung.'),
|
||
]
|
||
if (!url) return h('div', { class: 'tm-feed' }, kopf, h('p', { class: 'tm-hinweis' }, 'Der Link erscheint, sobald die Seite neu geladen ist.'))
|
||
|
||
const eingabe = h('input', { type: 'text', readonly: true, class: 'tm-link-feld', value: url, 'aria-label': `Kalender-Link für ${r.name}`, spellcheck: 'false' })
|
||
eingabe.addEventListener('focus', () => eingabe.select())
|
||
const status = h('p', { class: 'tm-hinweis', role: 'status' })
|
||
const frageBox = h('div', { class: 'tm-frage' })
|
||
|
||
async function kopiere() {
|
||
try {
|
||
await navigator.clipboard.writeText(eingabe.value)
|
||
status.textContent = 'Der Link ist kopiert.'
|
||
} catch {
|
||
// kein Zugriff auf die Zwischenablage (Browser, Einstellung): der Link bleibt sichtbar und markiert zum Kopieren von Hand
|
||
eingabe.focus()
|
||
eingabe.select()
|
||
status.textContent = 'Das Kopieren hat nicht geklappt. Der Link ist markiert: drücke Strg+C (Mac: Cmd+C), am Handy halte ihn gedrückt und wähle „Kopieren“.'
|
||
}
|
||
}
|
||
|
||
function fragen(ausloeser) {
|
||
const { ok, zeile } = knopfZeile('Neuen Link erzeugen', () => { frageBox.replaceChildren(); ausloeser.focus() })
|
||
const box = frage('Neuen Kalender-Link erzeugen?',
|
||
h('p', null, 'Der bisherige Link funktioniert danach nicht mehr. Kalender, die ihn abonniert haben, zeigen keine neuen Termine mehr, bis du dort den neuen Link einträgst. Tu das, wenn der Link in falsche Hände geraten ist.'),
|
||
allgemeinerFehler(),
|
||
zeile)
|
||
box.addEventListener('submit', async () => {
|
||
ok.disabled = true
|
||
try {
|
||
const antwort = await ctx.api.send('POST', `/ressourcen/${encodeURIComponent(r.id)}/feed`)
|
||
if (!ctx.aktuell() || !eingabe.isConnected) return
|
||
ctx.daten.version = antwort.version // das Erzeugen zaehlt die Version hoch: das naechste Speichern baut darauf auf
|
||
ctx.daten.feeds = { ...ctx.daten.feeds, [r.id]: antwort.url }
|
||
eingabe.value = antwort.url
|
||
frageBox.replaceChildren()
|
||
status.textContent = 'Der neue Link ist da. Der alte funktioniert nicht mehr.'
|
||
ctx.meldung(`Neuer Kalender-Link für „${r.name}“ erzeugt.`)
|
||
ausloeser.focus()
|
||
} catch (err) {
|
||
if (!ctx.aktuell() || !eingabe.isConnected) return
|
||
ok.disabled = false
|
||
zeigeFehler(box, err)
|
||
}
|
||
})
|
||
frageBox.replaceChildren(box)
|
||
box.focus()
|
||
}
|
||
|
||
const neuKnopf = h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'link-neu', onclick: (ev) => fragen(ev.currentTarget) }, 'Neuen Link erzeugen')
|
||
return h('div', { class: 'tm-feed' },
|
||
kopf,
|
||
h('p', { class: 'tm-hinweis' }, 'Der Link ist geheim: Wer ihn kennt, sieht die bestätigten Termine mit den Namen der Gäste. Gib ihn nicht weiter.'),
|
||
eingabe,
|
||
h('div', { class: 'tm-frage-knoepfe' }, h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'link-kopieren', onclick: kopiere }, 'Link kopieren'), neuKnopf),
|
||
status,
|
||
frageBox)
|
||
}
|