/* 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, editorZeile, feld, frage, gesperrt, h, hakenFeld, knopfZeile, neuerEintrag, zeigeFehler } from './termine-dom.js' import { entfernenKnopf, nachSpeichern, neuerSpeicher, stelleVon } 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 neu = neuerEintrag({ nurLesen: ctx.nurLesen, text: '+ Person oder Platz hinzufügen', werkzeug: 'ressource-neu', primaer: true, voll: liste.length >= LIMITS.ressourcen, grenzText: `Mehr als ${LIMITS.ressourcen} Personen oder Plätze sind nicht möglich.`, baue: (schliessen) => formular(t, { r: null, schliessen }), }) const reihe = (r) => { const leistungen = (e.leistungen || []).filter((l) => (l.ressourcen || []).includes(r.id)).length const tage = Object.keys(r.plan || {}).length return editorZeile({ attrs: { 'data-id': r.id }, nurLesen: ctx.nurLesen, label: r.name, 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(' · ')), ], pille: h('span', { class: `tm-pille tm-pille-${r.aktiv ? 'ok' : 'off'}` }, r.aktiv ? 'Aktiv' : 'Inaktiv'), baue: (schliessen) => formular(t, { r, schliessen }), }) } 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.', neu.leiste, neu.box, 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.')) } /** * Wochenplan einer Person/eines Platzes: je Wochentag ein Textfeld mit den Zeitfenstern (Text wie „09:00–12:30, 13:30–18:00“, leer = geschlossen), * vorbelegt aus r.plan (r darf fehlen). pfad ist der Pfad der Person in der Einstellung ('ressourcen.1'); die Felder heissen `${pfad}.plan.${nr}`, * so finden die Fehler des Servers (ressourcen.1.plan.3) ihr Feld. Der Editor einer Person und der Fragebogen (Schritt „Zeiten“) bauen ihn hiermit. * => { el, lese }: el ist die Gruppe (Beschriftung, Hinweis, sieben Felder), lese() liefert { plan } (geschlossene Tage fehlen im Plan) oder, bei * einem Fenster, das nicht zu lesen ist, { fehler } (Feldname ⇒ Text; dann gibt es keinen Teilplan). Pruefen im Einzelnen bleibt Sache des Servers. */ export function wochenplanFeld(r, pfad) { const texte = planTexte(r?.plan) const tage = WOCHENTAGE_PLAN.map(([nr, tag]) => ({ nr, name: `${pfad}.plan.${nr}`, input: h('input', { type: 'text', autocomplete: 'off', placeholder: 'geschlossen', value: texte[nr] }), tag, })) const el = 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, input, tag }) => feld(name, tag, input)))) return { el, lese() { const gelesen = planAusTexten(Object.fromEntries(tage.map(({ nr, input }) => [nr, input.value]))) if (gelesen.fehler) return { fehler: Object.fromEntries(Object.entries(gelesen.fehler).map(([nr, text]) => [`${pfad}.plan.${nr}`, text])) } return { plan: gelesen.plan } }, } } /** * 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. * Die Pfade der Fehler des Servers nennen die Stelle in der gespeicherten Liste (ressourcen.1.plan.3); die Felder heissen danach. Beim * Absenden wird die Person/der Platz per ID gesucht, nicht per Stelle. */ function formular(t, { r, schliessen }) { const { ctx, sp } = t const e = ctx.daten.einstellung const neu = r === null const index = neu ? (e.ressourcen || []).length : (e.ressourcen || []).findIndex((x) => x.id === r.id) 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 wochenplan = wochenplanFeld(alt, p) 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 entfernen = neu || ctx.nurLesen ? null : entfernenKnopf(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: () => nachSpeichern(t, `„${alt.name}“ entfernt.`, '[data-werkzeug="ressource-neu"]'), }) 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.'), wochenplan.el, 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?.knopf)) form.addEventListener('submit', async (ev) => { ev.preventDefault() const ok = await sp.speichere((entwurf) => { const gelesen = wochenplan.lese() if (gelesen.fehler) { zeigeFehler(form, { fehler: gelesen.fehler }) return false } const ressource = { ...alt, name: name.value, typ: typ.value, aktiv: aktiv.checked, plan: gelesen.plan } if (neu) { entwurf.ressourcen.push(ressource) } else { const stelle = stelleVon(entwurf.ressourcen, alt.id, form, 'Diese Person oder dieser Platz') if (stelle < 0) return false entwurf.ressourcen[stelle] = 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 }) if (!ok || !sp.nochHier()) return nachSpeichern(t, neu ? `„${name.value.trim()}“ ist hinzugefügt.` : 'Gespeichert.', `li[data-id="${alt.id}"] [data-aktion="bearbeiten"]`) }) return h('div', { class: 'tm-editor' }, form, neu || ctx.nurLesen ? null : kalenderLink(t, alt), entfernen?.box) } /* ---------------------------------------------------------- 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 { 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) // exklusiv wie ein Speichern: das Erzeugen aendert die Version, ein gleichzeitiges Speichern bekaeme sonst die alte box.addEventListener('submit', async () => { const erzeugt = await t.sp.exklusiv(async () => { try { const antwort = await ctx.api.send('POST', `/ressourcen/${encodeURIComponent(r.id)}/feed`) if (!ctx.aktuell()) return false 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 } if (!eingabe.isConnected) return true // der Reiter wurde inzwischen neu gezeichnet und zeigt schon den neuen Link 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.`) return true } catch (err) { if (ctx.aktuell() && eingabe.isConnected) zeigeFehler(box, err) return false } }) // Fokus erst nach dem Freigeben der Knoepfe (im gesperrten Zustand nimmt ein Knopf keinen Fokus an); ersetzt, den Knopf derselben Person suchen if (!erzeugt || !t.sp.nochHier()) return const knopf = ausloeser.isConnected ? ausloeser : t.wurzel.querySelector(`li[data-id="${r.id}"] [data-aktion="link-neu"]`) knopf?.focus() }) 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) }