Files
Webklar-Kundenbereich/public/termine-tab-team.js
T

240 lines
14 KiB
JavaScript
Raw 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.
/* 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)
}