Terminplaner: Portal-Ansicht, Kachel, Termine
Kachel in Funktionen (Kommt bald / Aus / Aktiv mit Anfragen-Zahl / Abgeschaltet), Ansicht #termine als Unterseite von Funktionen: Projektauswahl, Vorlagenauswahl beim ersten Oeffnen, Reiter-Geruest (Platzhalter fuer Leistungen, Team & Plaetze, Einstellungen) und der Reiter Termine mit Tag/Woche/Monat, Filtern, Aktionen mit Rueckfragen in der Seite, Termin eintragen und Zeit sperren. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
70940e02c8
commit
43e4826ae3
10 files changed
+2552
-8
No files matched your search
+4
-3
@@ -139,7 +139,7 @@ function projectState(project) {
|
||||
return ['kb-state-off', 'Noch keine Website']
|
||||
}
|
||||
|
||||
const FEATURE_LABELS = { preview: 'Vorschau', chat: 'Chat', invoices: 'Rechnungen', news: 'News-Pinnwand' }
|
||||
const FEATURE_LABELS = { preview: 'Vorschau', chat: 'Chat', invoices: 'Rechnungen', news: 'News-Pinnwand', termine: 'Terminplaner' }
|
||||
|
||||
function renderProjectCard(project, features) {
|
||||
const li = document.createElement('li')
|
||||
@@ -1910,7 +1910,7 @@ function initPasswordView() {
|
||||
// "projekt" (Projektdetails als eigene Seite) gibt es ebenfalls nur in der App-Ansicht.
|
||||
// "passwort" oeffnet das Konto-Menue (Profilbild). #fortschritt fuehrt zu Projekte und scrollt zum Abschnitt
|
||||
// "Was wir zuletzt erledigt haben" (#pj-activity); hinter dem Hash darf ein Query stehen (#news?bestellt=1, Rueckkehr von Stripe).
|
||||
const VIEW_HASHES = { projects: '', funktionen: 'funktionen', news: 'news', analyse: 'analyse', invoices: 'rechnungen', chat: 'chat', vertraege: 'vertraege', stunden: 'stunden', 'bestellen-hosting': 'bestellen-hosting', 'bestellen-wartung': 'bestellen-wartung', wechsel: 'wechsel', takeover: 'gutschrift', projekt: 'projekt', passwort: 'passwort' }
|
||||
const VIEW_HASHES = { projects: '', funktionen: 'funktionen', news: 'news', analyse: 'analyse', termine: 'termine', invoices: 'rechnungen', chat: 'chat', vertraege: 'vertraege', stunden: 'stunden', 'bestellen-hosting': 'bestellen-hosting', 'bestellen-wartung': 'bestellen-wartung', wechsel: 'wechsel', takeover: 'gutschrift', projekt: 'projekt', passwort: 'passwort' }
|
||||
const HASH_ALIASES = { wartung: 'vertraege', abo: 'vertraege' }
|
||||
const viewLoaders = { chat: initChat }
|
||||
// Funktionen und News laden bei jedem Oeffnen neu (Zustand aendert sich durch Bestellung und Freischaltung); Modul erst dann laden
|
||||
@@ -1918,6 +1918,7 @@ const viewRefreshers = {
|
||||
funktionen: () => import('/funktionen.js').then((m) => m.initFunktionen(document.getElementById('funktionen-body'))),
|
||||
news: () => import('/funktionen.js').then((m) => m.initNews(document.getElementById('news-body'))),
|
||||
analyse: () => import('/funktionen.js').then((m) => m.initAnalyse(document.getElementById('analyse-body'))),
|
||||
termine: () => import('/termine.js').then((m) => m.initTermine(document.getElementById('termine-body'))),
|
||||
}
|
||||
const loadedViews = new Set()
|
||||
|
||||
@@ -2110,7 +2111,7 @@ const appLayout = window.matchMedia('(max-width: 880px), (pointer: coarse) and (
|
||||
const coarsePointer = window.matchMedia('(pointer: coarse)')
|
||||
// Unterseite -> Tab, zu dem sie gehoert (und Text neben dem Zurueck-Pfeil).
|
||||
// "passwort" setzt onAppViewShown: die Ansicht, aus der das Konto-Menue geoeffnet wurde.
|
||||
const APP_PARENT = { news: 'funktionen', analyse: 'funktionen', stunden: 'vertraege', 'bestellen-hosting': 'vertraege', 'bestellen-wartung': 'vertraege', wechsel: 'vertraege', takeover: 'vertraege', projekt: 'projects' }
|
||||
const APP_PARENT = { news: 'funktionen', analyse: 'funktionen', termine: 'funktionen', stunden: 'vertraege', 'bestellen-hosting': 'vertraege', 'bestellen-wartung': 'vertraege', wechsel: 'vertraege', takeover: 'vertraege', projekt: 'projects' }
|
||||
const APP_PARENT_LABEL = { vertraege: 'Verträge', projects: 'Projekte', funktionen: 'Funktionen', invoices: 'Rechnungen', chat: 'Chat' }
|
||||
const appShell = { view: 'projects', scroll: {}, fromHistory: false, sideCards: [], installPrompt: null }
|
||||
const TEXT_INPUT = 'textarea, input:not([type=checkbox]):not([type=radio]):not([type=file]):not([type=date]):not([type=button]):not([type=submit]):not([type=range]):not([type=color])'
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
<link rel="stylesheet" href="/kundenbereich.css">
|
||||
<link rel="stylesheet" href="/chat-composer.css">
|
||||
<link rel="stylesheet" href="/funktionen.css">
|
||||
<link rel="stylesheet" href="/termine.css">
|
||||
</head>
|
||||
<body class="portal-page dashboard-page kb-page">
|
||||
<div class="dot-background" aria-hidden="true">
|
||||
@@ -185,6 +186,15 @@
|
||||
<div id="analyse-body"><p class="kb-empty">Lade…</p></div>
|
||||
</section>
|
||||
|
||||
<!-- Terminplaner: Vorlage, Termine, Leistungen, Team & Plätze, Einstellungen (termine.js, initTermine); Unterseite von Funktionen -->
|
||||
<section class="kb-view" id="view-termine">
|
||||
<header class="kb-head">
|
||||
<h1>Terminplaner</h1>
|
||||
<p>Termine, die deine Kundschaft über deine Website bucht – und was du selbst einträgst.</p>
|
||||
</header>
|
||||
<div id="termine-body"><p class="kb-empty">Lade…</p></div>
|
||||
</section>
|
||||
|
||||
<!-- Rechnungen -->
|
||||
<section class="kb-view" id="view-invoices">
|
||||
<header class="kb-head">
|
||||
|
||||
+60
-2
@@ -136,6 +136,58 @@ function analyseTile(projects) {
|
||||
</article>`
|
||||
}
|
||||
|
||||
/* Terminplaner: Kachel nur, wenn der Schalter es erlaubt (termine.verfuegbar), sonst "Kommt bald". Die Ansicht selbst
|
||||
(termine.js) wird von app.js geladen; hier steht nur die Kachel, damit funktionen.js keinen weiteren Import braucht. */
|
||||
const TERMINE_TEXT = 'Deine Kundschaft bucht Termine direkt über deine Website.'
|
||||
const termineOf = (project) => project.features?.termine || { state: 'off' }
|
||||
const anfragenText = (n) => (n === 1 ? '1 Anfrage wartet' : `${n} Anfragen warten`)
|
||||
|
||||
function pickTermineProject(projects) {
|
||||
return projects.find((p) => termineOf(p).state === 'active') || projects.find((p) => termineOf(p).state === 'disabled') || projects[0]
|
||||
}
|
||||
|
||||
function termineTile(projects) {
|
||||
const states = projects.map((p) => termineOf(p).state)
|
||||
const active = states.filter((s) => s === 'active').length
|
||||
const target = pickTermineProject(projects)
|
||||
const disabled = !active && states.length > 0 && states.every((s) => s === 'disabled')
|
||||
const pill = active ? ['Aktiv', 'on'] : disabled ? ['Abgeschaltet', 'off'] : ['Aus', 'off']
|
||||
let extra
|
||||
let action
|
||||
if (active) {
|
||||
extra = '<p class="fn-count" data-termine-count hidden></p>'
|
||||
action = `<button type="button" class="kb-btn-primary fn-tile-cta" data-view="termine" data-project="${esc(target.projectId)}">Termine öffnen</button>`
|
||||
} else if (disabled) {
|
||||
extra = '<p>Der Terminplaner ist für diese Website abgeschaltet. Deine Termine kannst du noch ansehen und exportieren.</p>'
|
||||
action = `<button type="button" class="kb-btn-primary fn-tile-cta" data-view="termine" data-project="${esc(target.projectId)}">Termine ansehen</button>`
|
||||
} else {
|
||||
extra = '<p>Wir schalten den Terminplaner für dich frei.</p>'
|
||||
action = '<button type="button" class="kb-btn-primary fn-tile-cta" data-view="chat">Chat öffnen</button>'
|
||||
}
|
||||
const count = projects.length > 1 && active ? `<p class="fn-count">aktiv für ${active} von ${projects.length} Websites</p>` : ''
|
||||
return `
|
||||
<article class="fn-tile" data-fn-tile="termine">
|
||||
<div class="fn-tile-head">${svg('calendar')}<span class="fn-pill fn-pill-${pill[1]}">${pill[0]}</span></div>
|
||||
<h3>Terminplaner</h3>
|
||||
<p>${TERMINE_TEXT}</p>
|
||||
${extra}
|
||||
${count}
|
||||
${action}
|
||||
</article>`
|
||||
}
|
||||
|
||||
// "{n} Anfragen warten" aus anfragenOffen der aktiven Websites; ein Fehler laesst die Kachel einfach ohne Zahl stehen
|
||||
async function showTermineCount(root, projects) {
|
||||
const el = root.querySelector('[data-termine-count]')
|
||||
const active = projects.filter((p) => termineOf(p).state === 'active')
|
||||
if (!el || !active.length) return
|
||||
const counts = await Promise.all(active.map((p) => request(`/api/termine/einstellungen?projectId=${encodeURIComponent(p.projectId)}`).then((d) => Number(d.anfragenOffen) || 0, () => 0)))
|
||||
const open = counts.reduce((sum, n) => sum + n, 0)
|
||||
if (open < 1) return
|
||||
el.textContent = anfragenText(open)
|
||||
el.hidden = false
|
||||
}
|
||||
|
||||
const soonTile = (icon, title, text) => `
|
||||
<article class="fn-tile fn-tile-soon">
|
||||
<div class="fn-tile-head">${svg(icon)}<span class="fn-pill fn-pill-soon">Kommt bald</span></div>
|
||||
@@ -145,15 +197,21 @@ const soonTile = (icon, title, text) => `
|
||||
|
||||
export async function initFunktionen(root) {
|
||||
if (!root) return
|
||||
const { projects = [] } = await request('/api/funktionen')
|
||||
const { projects = [], termine } = await request('/api/funktionen')
|
||||
root.innerHTML = `
|
||||
<div class="fn-tiles">
|
||||
${newsTile(projects)}
|
||||
${soonTile('calendar', 'Terminplaner', 'Deine Kundschaft bucht Termine direkt über deine Website.')}
|
||||
${termine?.verfuegbar ? termineTile(projects) : soonTile('calendar', 'Terminplaner', TERMINE_TEXT)}
|
||||
${analyseTile(projects)}
|
||||
</div>`
|
||||
root.querySelector('[data-fn-tile="news"] .fn-tile-cta')?.addEventListener('click', (ev) => { selection.projectId = ev.currentTarget.dataset.project })
|
||||
root.querySelector('[data-fn-tile="analyse"] .fn-tile-cta[data-project]')?.addEventListener('click', (ev) => { analyseSel.projectId = ev.currentTarget.dataset.project })
|
||||
// Die Terminplaner-Ansicht liest das gewaehlte Projekt aus ihrem Behaelter (termine.js, initTermine)
|
||||
root.querySelector('[data-fn-tile="termine"] .fn-tile-cta[data-project]')?.addEventListener('click', (ev) => {
|
||||
const body = document.getElementById('termine-body')
|
||||
if (body) body.dataset.wunschProjekt = ev.currentTarget.dataset.project
|
||||
})
|
||||
if (termine?.verfuegbar) await showTermineCount(root, projects)
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- News --- */
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
/* Terminplaner im Kundenbereich: reine Helfer ohne DOM (ES-Modul; termine.js nutzt sie, test/termineView.test.js prueft sie).
|
||||
Tage sind Berliner Kalendertage 'JJJJ-MM-TT' (so liefert und erwartet sie /api/termine). Gerechnet wird nur mit dem Kalender
|
||||
(UTC-Mitternacht), nie mit der Zeitzone des Browsers; nur heuteBerlin() und endeZeit() fragen die Uhr bzw. Zeitzone Berlin. */
|
||||
|
||||
const TAG_RE = /^(\d{4})-(\d{2})-(\d{2})$/
|
||||
const MONATE = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']
|
||||
const WOCHENTAGE = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag']
|
||||
const TAG_MS = 24 * 60 * 60 * 1000
|
||||
/** Die Schnittstelle liefert Buchungen fuer hoechstens 62 Tage nach dem Beginn des Zeitraums. */
|
||||
const MAX_TAGE = 62
|
||||
const GELOESCHT = 'Gelöscht'
|
||||
|
||||
/* ------------------------------------------------------------ Kalender --- */
|
||||
function utc(tag) {
|
||||
const m = TAG_RE.exec(String(tag))
|
||||
return Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]))
|
||||
}
|
||||
|
||||
const tagVon = (ms) => new Date(ms).toISOString().slice(0, 10)
|
||||
|
||||
/** Der Tag n Kalendertage nach (oder vor, n < 0) dem Tag. */
|
||||
export function tagPlus(tag, n) {
|
||||
return tagVon(utc(tag) + n * TAG_MS)
|
||||
}
|
||||
|
||||
/** Montag bis Sonntag der Woche, in der der Tag liegt. */
|
||||
export function wocheVon(tag) {
|
||||
const wochentag = new Date(utc(tag)).getUTCDay() // 0 = Sonntag
|
||||
const montag = tagPlus(tag, -((wochentag + 6) % 7))
|
||||
return { von: montag, bis: tagPlus(montag, 6) }
|
||||
}
|
||||
|
||||
/** Erster bis letzter Tag des Monats, in dem der Tag liegt. */
|
||||
export function monatVon(tag) {
|
||||
const [jahr, monat] = String(tag).split('-').map(Number)
|
||||
return { von: `${tag.slice(0, 7)}-01`, bis: tagVon(Date.UTC(jahr, monat, 0)) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeitraum fuer die Abfrage der Buchungen. ansicht: 'tag' | 'woche' | 'monat' | 'anfragen' (ab heute, so weit die
|
||||
* Schnittstelle reicht).
|
||||
*/
|
||||
export function bereichFuer(ansicht, tag, heute) {
|
||||
if (ansicht === 'woche') return wocheVon(tag)
|
||||
if (ansicht === 'monat') return monatVon(tag)
|
||||
if (ansicht === 'anfragen') return { von: heute, bis: tagPlus(heute, MAX_TAGE) }
|
||||
return { von: tag, bis: tag }
|
||||
}
|
||||
|
||||
/** Naechster (richtung 1) oder vorheriger (-1) Zeitraum: Tag +-1, Woche +-7, Monat auf den 1. des Nachbarmonats. */
|
||||
export function verschiebeTag(ansicht, tag, richtung) {
|
||||
if (ansicht === 'woche') return tagPlus(tag, 7 * richtung)
|
||||
if (ansicht === 'monat') {
|
||||
const [jahr, monat] = tag.split('-').map(Number)
|
||||
return tagVon(Date.UTC(jahr, monat - 1 + richtung, 1))
|
||||
}
|
||||
return tagPlus(tag, richtung)
|
||||
}
|
||||
|
||||
/** Heutiger Kalendertag in Berlin (nicht der des Browsers). */
|
||||
export function heuteBerlin(jetzt = new Date()) {
|
||||
return jetzt.toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' })
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- Datum --- */
|
||||
const tagOhneJahr = (tag) => `${Number(tag.slice(8))}. ${MONATE[Number(tag.slice(5, 7)) - 1]}`
|
||||
|
||||
/** 'Montag, 5. Oktober 2026' */
|
||||
export function datumLang(tag) {
|
||||
return `${WOCHENTAGE[new Date(utc(tag)).getUTCDay()]}, ${tagOhneJahr(tag)} ${tag.slice(0, 4)}`
|
||||
}
|
||||
|
||||
/** '05.10.2026' */
|
||||
export function datumKurz(tag) {
|
||||
return `${tag.slice(8)}.${tag.slice(5, 7)}.${tag.slice(0, 4)}`
|
||||
}
|
||||
|
||||
/** Ueberschrift eines Tages in der Liste: 'Heute · Montag, 5. Oktober'; das Jahr nur, wenn es nicht das heutige ist. */
|
||||
export function tagUeberschrift(tag, heute) {
|
||||
const rel = tag === heute ? 'Heute · ' : tag === tagPlus(heute, 1) ? 'Morgen · ' : tag === tagPlus(heute, -1) ? 'Gestern · ' : ''
|
||||
const jahr = tag.slice(0, 4) === heute.slice(0, 4) ? '' : ` ${tag.slice(0, 4)}`
|
||||
return `${rel}${WOCHENTAGE[new Date(utc(tag)).getUTCDay()]}, ${tagOhneJahr(tag)}${jahr}`
|
||||
}
|
||||
|
||||
/** Titel des gewaehlten Zeitraums ueber der Liste: '5.–11. Oktober 2026', 'Oktober 2026', 'Montag, 5. Oktober 2026'. */
|
||||
export function zeitraumTitel(ansicht, tag) {
|
||||
if (ansicht === 'anfragen') return 'Offene Anfragen'
|
||||
if (ansicht === 'monat') return `${MONATE[Number(tag.slice(5, 7)) - 1]} ${tag.slice(0, 4)}`
|
||||
if (ansicht === 'woche') {
|
||||
const { von, bis } = wocheVon(tag)
|
||||
if (von.slice(0, 7) === bis.slice(0, 7)) return `${Number(von.slice(8))}.–${tagOhneJahr(bis)} ${bis.slice(0, 4)}`
|
||||
if (von.slice(0, 4) === bis.slice(0, 4)) return `${tagOhneJahr(von)} – ${tagOhneJahr(bis)} ${bis.slice(0, 4)}`
|
||||
return `${tagOhneJahr(von)} ${von.slice(0, 4)} – ${tagOhneJahr(bis)} ${bis.slice(0, 4)}`
|
||||
}
|
||||
return datumLang(tag)
|
||||
}
|
||||
|
||||
const ENDE_FORMAT = new Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })
|
||||
|
||||
/** Ende des Termins als Berliner Wanduhr 'HH:MM' (aus endsAt); '' wenn kein Zeitpunkt. */
|
||||
export function endeZeit(buchung) {
|
||||
const ende = new Date(buchung?.endsAt)
|
||||
return Number.isNaN(ende.getTime()) ? '' : ENDE_FORMAT.format(ende)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- Uhrzeit --- */
|
||||
/** 'H:MM', 'HH:MM' oder 'HH.MM' ⇒ 'HH:MM'; ende erlaubt 24:00. Das 5-Minuten-Raster prueft der Server (und fensterParsen). */
|
||||
export function zeitNormal(text, { ende = false } = {}) {
|
||||
const m = typeof text === 'string' ? /^(\d{1,2})[:.](\d{2})$/.exec(text.trim()) : null
|
||||
if (!m) return null
|
||||
const stunde = Number(m[1])
|
||||
const minute = Number(m[2])
|
||||
if (minute > 59 || stunde > 24 || (stunde === 24 && (!ende || minute !== 0))) return null
|
||||
return `${String(stunde).padStart(2, '0')}:${m[2]}`
|
||||
}
|
||||
|
||||
const inMinuten = (zeit) => Number(zeit.slice(0, 2)) * 60 + Number(zeit.slice(3))
|
||||
|
||||
/**
|
||||
* Zeitfenster eines Wochentags aus Text: '09:00–12:30, 13:30–18:00' ⇒ [['09:00','12:30'], ['13:30','18:00']].
|
||||
* Getrennt durch Komma, Strichpunkt, Zeilenumbruch oder „und“; Bis-Strich: -, –, — oder „bis“. Leerer Text ⇒ [] (geschlossen).
|
||||
* Ungueltig ⇒ null: keine Uhrzeit, kein 5-Minuten-Raster, Ende nicht nach dem Beginn, nicht aufsteigend, Ueberlappung.
|
||||
*/
|
||||
export function fensterParsen(text) {
|
||||
if (typeof text !== 'string') return null
|
||||
if (text.trim() === '') return []
|
||||
const fenster = []
|
||||
let vorher = null
|
||||
for (const teil of text.split(/[,;\n]|\bund\b/)) {
|
||||
const m = /^\s*(\S+?)\s*(?:[-–—]|\bbis\b)\s*(\S+)\s*$/.exec(teil)
|
||||
const von = m ? zeitNormal(m[1]) : null
|
||||
const bis = m ? zeitNormal(m[2], { ende: true }) : null
|
||||
if (von === null || bis === null || inMinuten(von) % 5 !== 0 || inMinuten(bis) % 5 !== 0) return null
|
||||
if (inMinuten(bis) <= inMinuten(von) || (vorher !== null && inMinuten(von) < inMinuten(vorher))) return null
|
||||
vorher = bis
|
||||
fenster.push([von, bis])
|
||||
}
|
||||
return fenster
|
||||
}
|
||||
|
||||
/** [['09:00','12:30'], ['13:30','18:00']] ⇒ '09:00–12:30, 13:30–18:00' (Umkehrung von fensterParsen). */
|
||||
export function fensterText(fenster) {
|
||||
return (Array.isArray(fenster) ? fenster : []).map(([von, bis]) => `${von}–${bis}`).join(', ')
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- Status --- */
|
||||
const STATUS = {
|
||||
gehalten: ['Reserviert', 'wait'],
|
||||
angefragt: ['Angefragt', 'wait'],
|
||||
bestaetigt: ['Bestätigt', 'ok'],
|
||||
erledigt: ['Erledigt', 'off'],
|
||||
nichterschienen: ['Nicht erschienen', 'bad'],
|
||||
abgesagt: ['Abgesagt', 'off'],
|
||||
abgelehnt: ['Abgelehnt', 'off'],
|
||||
verfallen: ['Verfallen', 'off'],
|
||||
}
|
||||
|
||||
/** Anzeigetext eines Status; Unbekanntes nie roh. */
|
||||
export function statusLabel(status) {
|
||||
return Object.hasOwn(STATUS, status) ? STATUS[status][0] : 'Unbekannt'
|
||||
}
|
||||
|
||||
/** Farbton der Pille: ok | wait | off | bad. */
|
||||
export function statusTon(status) {
|
||||
return Object.hasOwn(STATUS, status) ? STATUS[status][1] : 'off'
|
||||
}
|
||||
|
||||
/** Filter der Terminliste; status = Liste fuer ?status= der Schnittstelle (null = alle). */
|
||||
export const FILTER = [
|
||||
{ key: 'alle', label: 'Alle', status: null },
|
||||
{ key: 'offen', label: 'Offen', status: ['angefragt', 'bestaetigt'] },
|
||||
{ key: 'angefragt', label: 'Angefragt', status: ['angefragt'] },
|
||||
{ key: 'bestaetigt', label: 'Bestätigt', status: ['bestaetigt'] },
|
||||
{ key: 'abgesagt', label: 'Abgesagt', status: ['abgesagt', 'abgelehnt', 'verfallen'] },
|
||||
]
|
||||
|
||||
/** Wert fuer ?status= ('' = alle Status). */
|
||||
export function filterStatus(key) {
|
||||
return FILTER.find((f) => f.key === key)?.status?.join(',') ?? ''
|
||||
}
|
||||
|
||||
/** „3 Anfragen warten“ / „1 Anfrage wartet“; bei 0 oder ohne Zahl ''. */
|
||||
export function anfragenText(n) {
|
||||
if (!Number.isInteger(n) || n < 1) return ''
|
||||
return n === 1 ? '1 Anfrage wartet' : `${n} Anfragen warten`
|
||||
}
|
||||
|
||||
/** Gruende fuer die Absage durch den Betrieb (Pflichtauswahl; der Gast liest sie in der Absage-Mail). */
|
||||
export const ABSAGE_GRUENDE = ['Krankheit', 'Betriebliche Gründe', 'Urlaub oder Betriebsferien', 'Auf Wunsch des Gastes', 'Sonstiges']
|
||||
|
||||
/* ----------------------------------------------------------- Einstellung --- */
|
||||
/** „Person“, „Platz“ oder „Person/Platz“, je nachdem, was der Betrieb als Ressourcen hat. */
|
||||
export function ressourcenWort(ressourcen) {
|
||||
const typen = new Set((Array.isArray(ressourcen) ? ressourcen : []).map((r) => r?.typ))
|
||||
if (typen.size === 1 && typen.has('platz')) return 'Platz'
|
||||
if (typen.size === 1 && typen.has('person')) return 'Person'
|
||||
return 'Person/Platz'
|
||||
}
|
||||
|
||||
const FEHLT = { 'betrieb.telefon': 'deine Telefonnummer', 'betrieb.adresse': 'deine Anschrift' }
|
||||
|
||||
/** Was fuer die Buchbarkeit noch fehlt (fehlt = Pfade aus GET /einstellungen): „deine Telefonnummer und deine Anschrift“; '' wenn nichts. */
|
||||
export function fehltText(fehlt) {
|
||||
const teile = (Array.isArray(fehlt) ? fehlt : []).map((pfad) => FEHLT[pfad]).filter(Boolean)
|
||||
return teile.join(' und ')
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- Buchungen --- */
|
||||
export const istSperre = (buchung) => buchung?.quelle === 'sperre'
|
||||
|
||||
/** Buchungen nach Berliner Tag gruppiert (datum), Tage und Termine aufsteigend: [{ tag, buchungen }]. */
|
||||
export function gruppiereNachTag(buchungen) {
|
||||
const nachTag = new Map()
|
||||
for (const b of [...(Array.isArray(buchungen) ? buchungen : [])].sort((a, c) => (a.startsAt < c.startsAt ? -1 : a.startsAt > c.startsAt ? 1 : 0))) {
|
||||
if (!nachTag.has(b.datum)) nachTag.set(b.datum, [])
|
||||
nachTag.get(b.datum).push(b)
|
||||
}
|
||||
return [...nachTag.keys()].sort().map((tag) => ({ tag, buchungen: nachTag.get(tag) }))
|
||||
}
|
||||
|
||||
const hatGastdaten = (b) => Boolean((b.gastName && b.gastName !== GELOESCHT) || b.gastEmail || b.gastTelefon || b.gastNachricht)
|
||||
|
||||
/**
|
||||
* Aktionen, die der Betrieb fuer die Buchung anbieten darf (jetzt = ms), in der Reihenfolge der Knoepfe:
|
||||
* annehmen | ablehnen | verschieben | absagen | nichterschienen | anonymisieren | entsperren.
|
||||
* Der Server prueft alles noch einmal; das hier haelt nur sinnlose Knoepfe weg.
|
||||
*/
|
||||
export function moeglicheAktionen(buchung, jetzt) {
|
||||
const b = buchung || {}
|
||||
const beginn = Date.parse(b.startsAt)
|
||||
const ende = Date.parse(b.endsAt)
|
||||
if (istSperre(b)) return ende > jetzt ? ['entsperren'] : []
|
||||
const aktionen = []
|
||||
const begonnen = !(beginn > jetzt)
|
||||
if (b.status === 'angefragt') {
|
||||
if (!begonnen) aktionen.push('annehmen')
|
||||
aktionen.push('ablehnen')
|
||||
if (!begonnen) aktionen.push('verschieben')
|
||||
} else if (b.status === 'bestaetigt') {
|
||||
if (!begonnen) aktionen.push('verschieben', 'absagen')
|
||||
else aktionen.push('nichterschienen')
|
||||
} else if (b.status === 'erledigt') {
|
||||
aktionen.push('nichterschienen')
|
||||
}
|
||||
if (b.status !== 'gehalten' && hatGastdaten(b)) aktionen.push('anonymisieren')
|
||||
return aktionen
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/* Terminplaner im Kundenbereich (termine.js): Projektauswahl, Vorlagen, Reiter, Terminliste, Rueckfragen und Formulare.
|
||||
Farben und Abstaende aus den Tokens von kundenbereich.css (.kb-page); nur Klassen mit tm-. Handy: bis 880px wie die App-Ansicht. */
|
||||
|
||||
#view-termine .kb-panel { max-width: none; }
|
||||
#view-termine .fn-project { margin-bottom: 16px; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding: 14px 18px; }
|
||||
#view-termine button:focus-visible, #view-termine input:focus-visible, #view-termine select:focus-visible, #view-termine textarea:focus-visible,
|
||||
#view-termine summary:focus-visible, #view-termine a:focus-visible { outline: 3px solid #d4b4ff; outline-offset: 2px; }
|
||||
.tm-sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
|
||||
|
||||
/* -------------------------------------------------- Hinweise und Meldungen --- */
|
||||
.tm-box { display: flex; align-items: center; justify-content: space-between; gap: 10px 16px; flex-wrap: wrap; margin-bottom: 16px; padding: 12px 16px; border-radius: 12px; font-size: 14px; line-height: 1.5; }
|
||||
.tm-box[hidden] { display: none; }
|
||||
.tm-box-warn { background: rgba(251, 191, 36, 0.12); border: 1px solid rgba(251, 191, 36, 0.45); }
|
||||
.tm-box-info { background: var(--kb-purple-soft); border: 1px solid var(--kb-purple-line); }
|
||||
.tm-meldung { margin: 0 0 16px; padding: 12px 16px; border-radius: 12px; font-size: 14px; line-height: 1.5; }
|
||||
.tm-meldung[hidden] { display: none; }
|
||||
.tm-meldung-ok { background: rgba(74, 222, 128, 0.1); border: 1px solid rgba(74, 222, 128, 0.4); }
|
||||
.tm-meldung-fehler { background: rgba(248, 113, 113, 0.1); border: 1px solid rgba(248, 113, 113, 0.5); color: #fecaca; }
|
||||
.tm-info p { margin: 0 0 12px; line-height: 1.55; color: var(--kb-muted); }
|
||||
.tm-info p:last-child { margin-bottom: 0; }
|
||||
|
||||
/* ----------------------------------------------------------------- Reiter --- */
|
||||
.tm-reiterleiste { display: grid; grid-template-columns: repeat(4, auto); justify-content: start; gap: 6px; margin-bottom: 18px; padding-bottom: 12px; border-bottom: 1px solid var(--kb-border); }
|
||||
.tm-reiter {
|
||||
padding: 10px 16px; border-radius: 999px; border: 1px solid transparent; background: transparent; color: var(--kb-muted);
|
||||
font: inherit; font-size: 14px; font-weight: 700; white-space: nowrap; cursor: pointer;
|
||||
}
|
||||
.tm-reiter:hover { color: #fff; background: rgba(255, 255, 255, 0.06); }
|
||||
.tm-reiter[aria-selected='true'] { color: #fff; background: var(--kb-purple-soft); border-color: var(--kb-purple-line); }
|
||||
.tm-panel:focus { outline: none; }
|
||||
|
||||
/* ------------------------------------------------------------- Werkzeugleiste --- */
|
||||
.tm-leiste { display: flex; align-items: center; gap: 10px 14px; flex-wrap: wrap; }
|
||||
.tm-leiste [hidden], .tm-filter[hidden] { display: none; }
|
||||
.tm-segment { display: inline-flex; border: 1px solid var(--kb-border-strong); border-radius: 999px; overflow: hidden; }
|
||||
.tm-seg { padding: 9px 18px; border: none; background: transparent; color: inherit; font: inherit; font-size: 14px; font-weight: 700; cursor: pointer; }
|
||||
.tm-seg + .tm-seg { border-left: 1px solid var(--kb-border-strong); }
|
||||
.tm-seg[aria-pressed='true'] { background: var(--kb-purple); color: #fff; }
|
||||
.tm-nav { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.tm-pfeil {
|
||||
display: grid; place-items: center; width: 40px; height: 40px; padding: 0; border-radius: 50%; border: 1px solid var(--kb-border-strong);
|
||||
background: transparent; color: #fff; font: inherit; font-size: 22px; line-height: 1; cursor: pointer;
|
||||
}
|
||||
.tm-pfeil:hover { background: rgba(255, 255, 255, 0.07); }
|
||||
.tm-sprung { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; color: var(--kb-muted); }
|
||||
.tm-zeitraum { margin: 18px 0 10px; font-size: 19px; }
|
||||
.tm-filter { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; }
|
||||
.tm-chip {
|
||||
padding: 8px 15px; border-radius: 999px; border: 1px solid var(--kb-border-strong); background: transparent; color: inherit;
|
||||
font: inherit; font-size: 13.5px; font-weight: 600; cursor: pointer;
|
||||
}
|
||||
.tm-chip:hover { background: rgba(255, 255, 255, 0.07); }
|
||||
.tm-chip[aria-pressed='true'] { background: var(--kb-purple-soft); border-color: var(--kb-purple-line); color: #fff; }
|
||||
.tm-werkzeug-zeile { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 16px; }
|
||||
.tm-werkzeuge { display: contents; }
|
||||
.tm-werkzeuge button[aria-expanded='true'] { box-shadow: 0 0 0 3px var(--kb-purple-soft); }
|
||||
#view-termine .kb-btn-outline, #view-termine .kb-btn-primary { min-height: 40px; }
|
||||
|
||||
/* ------------------------------------------------------------------- Liste --- */
|
||||
.tm-tag-kopf { margin: 22px 0 8px; font-size: 14px; font-weight: 700; color: var(--kb-muted); }
|
||||
.tm-tag:first-child .tm-tag-kopf { margin-top: 6px; }
|
||||
.tm-liste[aria-busy='true'] { opacity: 0.55; }
|
||||
.tm-leer { margin: 0 0 8px; padding: 14px 4px; font-size: 14px; color: var(--kb-faint); }
|
||||
.tm-eintraege { display: grid; gap: 10px; margin: 0; padding: 0; list-style: none; }
|
||||
.tm-eintrag { padding: 14px 16px; border-radius: 14px; background: var(--kb-surface); border: 1px solid var(--kb-border); }
|
||||
.tm-eintrag.tm-offen { border-color: var(--kb-purple-line); }
|
||||
.tm-sperre {
|
||||
border-style: dashed; border-color: var(--kb-border-strong);
|
||||
background: repeating-linear-gradient(135deg, rgba(255, 255, 255, 0.035) 0 10px, transparent 10px 20px), var(--kb-surface);
|
||||
}
|
||||
.tm-zeile { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 4px 14px; align-items: start; }
|
||||
.tm-zeit { font-weight: 700; font-variant-numeric: tabular-nums; white-space: nowrap; padding-top: 1px; }
|
||||
.tm-haupt { min-width: 0; }
|
||||
.tm-name, .tm-meta { display: block; overflow-wrap: anywhere; }
|
||||
.tm-meta { margin-top: 2px; font-size: 13.5px; color: var(--kb-muted); }
|
||||
.tm-pille { display: inline-block; padding: 3px 11px; border-radius: 999px; font-size: 12px; font-weight: 700; white-space: nowrap; }
|
||||
.tm-pille-ok { color: #86efac; background: rgba(74, 222, 128, 0.1); border: 1px solid rgba(74, 222, 128, 0.4); }
|
||||
.tm-pille-wait { color: #fcd34d; background: rgba(251, 191, 36, 0.1); border: 1px solid rgba(251, 191, 36, 0.4); }
|
||||
.tm-pille-off { color: var(--kb-muted); background: rgba(255, 255, 255, 0.05); border: 1px solid var(--kb-border-strong); }
|
||||
.tm-pille-bad { color: #fca5a5; background: rgba(248, 113, 113, 0.1); border: 1px solid rgba(248, 113, 113, 0.45); }
|
||||
.tm-hinweis-zeile { margin: 8px 0 0; font-size: 13.5px; line-height: 1.5; color: var(--kb-muted); overflow-wrap: anywhere; }
|
||||
.tm-hinweis-zeile.tm-warn { color: #fcd34d; }
|
||||
.tm-aktionen { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
|
||||
.tm-frage:empty { display: none; }
|
||||
.tm-frage-box {
|
||||
display: grid; gap: 10px; margin-top: 12px; padding: 14px; border-radius: 12px; background: var(--kb-purple-soft); border: 1px solid var(--kb-purple-line);
|
||||
}
|
||||
.tm-frage-box:focus { outline: none; }
|
||||
.tm-frage-box p { margin: 0; font-size: 14px; line-height: 1.5; }
|
||||
.tm-frage-knoepfe { display: flex; flex-wrap: wrap; gap: 10px; }
|
||||
.tm-details { margin-top: 10px; font-size: 14px; }
|
||||
.tm-details summary { display: inline-block; padding: 4px 0; color: var(--kb-lilac); font-size: 13.5px; cursor: pointer; }
|
||||
.tm-fakten { display: grid; gap: 8px; margin: 8px 0 0; }
|
||||
.tm-fakten > div { display: grid; grid-template-columns: 9.5rem minmax(0, 1fr); gap: 2px 12px; }
|
||||
.tm-fakten dt { color: var(--kb-muted); font-size: 13px; }
|
||||
.tm-fakten dd { margin: 0; overflow-wrap: anywhere; white-space: pre-wrap; }
|
||||
.tm-link-knopf { margin-top: 10px; padding: 4px 0; border: none; background: none; color: #fca5a5; font: inherit; font-size: 13.5px; text-decoration: underline; cursor: pointer; }
|
||||
|
||||
/* ------------------------------------------------------------------ Formulare --- */
|
||||
.tm-formular { margin-bottom: 16px; }
|
||||
.tm-formular h3 { margin-bottom: 6px; }
|
||||
.tm-raster { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 0 14px; }
|
||||
.tm-feld { display: grid; gap: 6px; margin: 12px 0 0; min-width: 0; }
|
||||
.tm-feld > label, .tm-label { font-size: 13px; font-weight: 600; }
|
||||
.tm-feld input:not([type=checkbox]):not([type=radio]), .tm-feld select, .tm-feld textarea {
|
||||
width: 100%; box-sizing: border-box; padding: 11px 12px; border-radius: 12px; font: inherit; font-size: 16px; resize: vertical;
|
||||
border: 1px solid var(--kb-border-strong); background: rgba(255, 255, 255, 0.05); color: inherit;
|
||||
}
|
||||
.tm-feld select option { color: #111; }
|
||||
.tm-feld [aria-invalid='true'] { border-color: #f87171; }
|
||||
.tm-feld input:focus, .tm-feld select:focus, .tm-feld textarea:focus { border-color: var(--kb-purple-line); box-shadow: 0 0 0 3px var(--kb-purple-soft); }
|
||||
.tm-hinweis { margin: 0; font-size: 12.5px; color: var(--kb-muted); }
|
||||
.tm-fehler { margin: 0; font-size: 13px; color: #fca5a5; }
|
||||
.tm-fehler:empty { display: none; }
|
||||
.tm-zeiten { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.tm-haken { display: flex; align-items: flex-start; gap: 10px; margin: 14px 0 6px; font-size: 14px; line-height: 1.5; cursor: pointer; }
|
||||
.tm-haken input { flex: none; width: 18px; height: 18px; margin-top: 2px; accent-color: var(--kb-purple); }
|
||||
.tm-formular .tm-frage-knoepfe { margin-top: 16px; }
|
||||
|
||||
/* ------------------------------------------------------------- Vorlagenauswahl --- */
|
||||
.tm-vorlagen > p { margin: 0 0 14px; line-height: 1.55; color: var(--kb-muted); }
|
||||
.tm-vorlagen-liste { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; margin: 0 0 16px; padding: 0; border: 0; min-width: 0; }
|
||||
.tm-vorlage { display: flex; align-items: flex-start; gap: 12px; padding: 14px; border-radius: 14px; border: 2px solid var(--kb-border-strong); cursor: pointer; }
|
||||
.tm-vorlage:hover { background: rgba(255, 255, 255, 0.04); }
|
||||
.tm-vorlage:has(input:checked) { border-color: var(--kb-purple); background: var(--kb-purple-soft); }
|
||||
.tm-vorlage input { flex: none; width: 18px; height: 18px; margin-top: 2px; accent-color: var(--kb-purple); }
|
||||
.tm-vorlage strong, .tm-vorlage small { display: block; }
|
||||
.tm-vorlage small { margin-top: 3px; font-size: 13px; line-height: 1.45; color: var(--kb-muted); }
|
||||
|
||||
/* ----------------------------------------------------------------- Handy --- */
|
||||
@media (max-width: 880px), (pointer: coarse) {
|
||||
#view-termine button:not(.tm-link-knopf), #view-termine .kb-btn-outline, #view-termine .kb-btn-primary { min-height: 44px; }
|
||||
.tm-pfeil { width: 44px; height: 44px; }
|
||||
.tm-link-knopf { padding: 11px 0; }
|
||||
.tm-details summary { display: flex; align-items: center; min-height: 44px; padding: 0; }
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.tm-reiterleiste { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.tm-reiter { text-align: center; }
|
||||
.tm-leiste { gap: 10px; }
|
||||
.tm-segment { width: 100%; }
|
||||
.tm-seg { flex: 1; padding-left: 8px; padding-right: 8px; }
|
||||
.tm-nav { width: 100%; }
|
||||
.tm-nav .tm-heute { flex: 1; }
|
||||
.tm-sprung { width: 100%; }
|
||||
.tm-sprung input { flex: 1; min-width: 0; }
|
||||
.tm-zeile { grid-template-columns: minmax(0, 1fr) auto; }
|
||||
.tm-haupt { grid-column: 1 / -1; grid-row: 2; }
|
||||
.tm-fakten > div { grid-template-columns: minmax(0, 1fr); }
|
||||
.tm-werkzeug-zeile > *, .tm-werkzeuge > * { flex: 1 1 calc(50% - 5px); }
|
||||
.tm-werkzeuge > .kb-btn-primary { flex-basis: 100%; }
|
||||
.tm-aktionen > * { flex: 1 1 auto; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { #view-termine * { transition: none !important; animation: none !important; } }
|
||||
@@ -0,0 +1,832 @@
|
||||
/* Terminplaner im Kundenbereich (ES-Modul; app.js laedt es erst beim Oeffnen der Ansicht #termine).
|
||||
initTermine(rootEl, { projectId }): Projektauswahl, Vorlagenauswahl beim ersten Oeffnen, Reiter-Geruest und der Reiter "Termine".
|
||||
Die Reiter stehen in REITER: jeder ist eine Funktion (panel, ctx) => void | Promise. Leistungen, Team & Plaetze und Einstellungen
|
||||
(Aufgabe 21b) ersetzen dort nur ihren Platzhalter. ctx = { s, projectId, daten, nurLesen, api, meldung, zumReiter, neuLaden }:
|
||||
daten ist die Antwort von GET /api/termine/einstellungen, nurLesen gilt fuer abgeschaltete Funktionen.
|
||||
Kundentext kommt nur ueber textContent in die Seite (Hilfsfunktion h), nie ueber innerHTML. Server: server/routes/termine.js.
|
||||
Tage sind Berliner Kalendertage 'JJJJ-MM-TT'; gerechnet wird in termine-view.js. Styles: termine.css. */
|
||||
|
||||
import {
|
||||
ABSAGE_GRUENDE, FILTER, anfragenText, bereichFuer, datumKurz, endeZeit, fehltText, filterStatus, gruppiereNachTag, heuteBerlin, istSperre,
|
||||
moeglicheAktionen, ressourcenWort, statusLabel, statusTon, tagPlus, tagUeberschrift, verschiebeTag, zeitNormal, zeitraumTitel,
|
||||
} from './termine-view.js'
|
||||
|
||||
const NAME_MAX = 100
|
||||
const TELEFON_MAX = 40
|
||||
const NACHRICHT_MAX = 500
|
||||
const TITEL_MAX = 120
|
||||
|
||||
// Auswahl bleibt beim Wechsel zwischen Funktionen und Terminplaner erhalten (der Tag nicht: er beginnt immer bei heute)
|
||||
const auswahl = { projectId: '', reiter: 'termine', ansicht: 'tag', filter: 'alle' }
|
||||
let S = null // aktuelle Sitzung der Ansicht; eine alte Antwort darf eine neuere nicht ueberschreiben (S !== s)
|
||||
|
||||
/* ------------------------------------------------------------ Helfer --- */
|
||||
/** Element bauen: Texte nur als Textknoten, Attribute mit on… als Ereignis, true setzt ein Attribut ohne Wert, false/null/undefined lassen es weg. */
|
||||
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
|
||||
}
|
||||
|
||||
async function request(path, { method = 'GET', body } = {}) {
|
||||
const response = await fetch(path, {
|
||||
method,
|
||||
credentials: 'same-origin',
|
||||
headers: body ? { 'Content-Type': 'application/json' } : {},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
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
|
||||
}
|
||||
|
||||
let idZaehler = 0
|
||||
const neueId = () => `tm-f${++idZaehler}`
|
||||
|
||||
/** Formularfeld mit Beschriftung, Hinweis und Platz fuer den Fehler (data-feld = Feldname der Schnittstelle). */
|
||||
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' }))
|
||||
}
|
||||
|
||||
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 || {})) {
|
||||
const ziel = 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). */
|
||||
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()
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
/* --------------------------------------------------------- Einstieg --- */
|
||||
export async function initTermine(root, { projectId } = {}) {
|
||||
if (!root) return
|
||||
const wunsch = projectId || root.dataset.wunschProjekt || '' // die Kachel in Funktionen legt ihr Projekt hier ab
|
||||
delete root.dataset.wunschProjekt
|
||||
if (wunsch) auswahl.projectId = wunsch
|
||||
const { projects = [], termine } = await request('/api/funktionen')
|
||||
const s = { root, projects, projekt: null, daten: null, tag: heuteBerlin(), nurLesen: false, buehne: null, meldungEl: null }
|
||||
S = s
|
||||
if (!termine?.verfuegbar) {
|
||||
root.replaceChildren(infoKarte('Der Terminplaner ist noch nicht verfügbar', 'Sobald er für dich bereit ist, findest du ihn hier.'))
|
||||
return
|
||||
}
|
||||
if (!projects.length) {
|
||||
root.replaceChildren(h('div', { class: 'kb-panel kb-empty' }, 'Noch keine Website-Projekte zugewiesen.'))
|
||||
return
|
||||
}
|
||||
const zustand = (p) => p.features?.termine?.state
|
||||
s.projekt = projects.find((p) => p.projectId === auswahl.projectId)
|
||||
|| projects.find((p) => zustand(p) === 'active') || projects.find((p) => zustand(p) === 'disabled') || projects[0]
|
||||
auswahl.projectId = s.projekt.projectId
|
||||
s.buehne = h('div', { id: 'termine-buehne' })
|
||||
root.replaceChildren(...(projects.length > 1 ? [projektwahl(s)] : []), s.buehne)
|
||||
await zeigeProjekt(s)
|
||||
}
|
||||
|
||||
function projektwahl(s) {
|
||||
const select = h('select', { id: 'termine-project' }, s.projects.map((p) => {
|
||||
const option = h('option', { value: p.projectId }, p.projectName)
|
||||
option.selected = p.projectId === s.projekt.projectId
|
||||
return option
|
||||
}))
|
||||
select.addEventListener('change', () => {
|
||||
auswahl.projectId = select.value
|
||||
s.projekt = s.projects.find((p) => p.projectId === select.value)
|
||||
s.tag = heuteBerlin()
|
||||
zeigeProjekt(s)
|
||||
})
|
||||
return h('div', { class: 'fn-project kb-panel' }, h('label', { for: 'termine-project' }, 'Website'), select)
|
||||
}
|
||||
|
||||
const ladeDaten = (s) => request(`/api/termine/einstellungen?projectId=${encodeURIComponent(s.projekt.projectId)}`)
|
||||
|
||||
/** Je nach Zustand der Funktion: nicht freigeschaltet, Vorlage waehlen oder die Reiter. */
|
||||
async function zeigeProjekt(s) {
|
||||
const zustand = s.projekt.features?.termine?.state || 'off'
|
||||
s.nurLesen = zustand !== 'active'
|
||||
if (zustand !== 'active' && zustand !== 'disabled') {
|
||||
s.buehne.replaceChildren(infoKarte('Der Terminplaner ist für diese Website noch nicht freigeschaltet', 'Wir schalten den Terminplaner für dich frei.', { chat: true }))
|
||||
return
|
||||
}
|
||||
s.buehne.replaceChildren(h('p', { class: 'kb-empty' }, 'Lade…'))
|
||||
try {
|
||||
s.daten = await ladeDaten(s)
|
||||
} catch (err) {
|
||||
if (S === s) s.buehne.replaceChildren(fehlerKarte(err.message, () => zeigeProjekt(s)))
|
||||
return
|
||||
}
|
||||
if (S !== s) return
|
||||
if (s.daten.einstellung) zeigeReiter(s)
|
||||
else if (s.nurLesen) s.buehne.replaceChildren(infoKarte('Der Terminplaner ist für diese Website abgeschaltet', 'Es wurden keine Termine eingerichtet.', { chat: true }))
|
||||
else zeigeVorlagen(s)
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------- Vorlage wählen --- */
|
||||
function zeigeVorlagen(s) {
|
||||
const projektId = s.projekt.projectId
|
||||
const fehler = h('p', { class: 'tm-fehler', role: 'alert' })
|
||||
const start = h('button', { type: 'button', class: 'kb-btn-primary', disabled: true }, 'Mit dieser Vorlage starten')
|
||||
let gewaehlt = null
|
||||
const karten = (s.daten.vorlagen || []).map((v) => {
|
||||
const radio = h('input', { type: 'radio', name: 'termine-vorlage', value: v.key })
|
||||
radio.addEventListener('change', () => { gewaehlt = v; start.disabled = false; fehler.textContent = '' })
|
||||
return h('label', { class: 'tm-vorlage' }, radio, h('span', null, h('strong', null, v.name), h('small', null, v.beschreibung)))
|
||||
})
|
||||
start.addEventListener('click', async () => {
|
||||
if (!gewaehlt) return
|
||||
start.disabled = true
|
||||
fehler.textContent = ''
|
||||
try {
|
||||
await request('/api/termine/einstellungen/vorlage', { method: 'POST', body: { projectId: projektId, vorlage: gewaehlt.key } })
|
||||
s.daten = await ladeDaten(s)
|
||||
if (S !== s) return
|
||||
zeigeReiter(s)
|
||||
zeigeMeldung(s, `Die Vorlage „${gewaehlt.name}“ ist angelegt. Alles darin ist ein Vorschlag, den du anpassen kannst.`)
|
||||
} catch (err) {
|
||||
fehler.textContent = err.message
|
||||
start.disabled = false
|
||||
}
|
||||
})
|
||||
s.buehne.replaceChildren(h('div', { class: 'kb-panel tm-vorlagen' },
|
||||
h('h3', null, 'Womit möchtest du starten?'),
|
||||
h('p', null, 'Such dir die Vorlage aus, die am besten zu deinem Betrieb passt. Die Vorlage legt Leistungen, Zeiten und Regeln als Vorschlag an. Du kannst danach alles ändern.'),
|
||||
h('fieldset', { class: 'tm-vorlagen-liste' }, h('legend', { class: 'tm-sr' }, 'Vorlage'), karten),
|
||||
fehler,
|
||||
h('div', { class: 'abo-actions' }, start)))
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------- Reiter-Gerüst --- */
|
||||
const platzhalter = (titel, text) => (panel) => {
|
||||
panel.replaceChildren(h('div', { class: 'kb-panel tm-info' }, h('h3', null, titel), h('p', null, text)))
|
||||
}
|
||||
|
||||
/** key, Beschriftung, Funktion (panel, ctx). Aufgabe 21b ersetzt die Platzhalter, sonst bleibt alles wie es ist. */
|
||||
const REITER = [
|
||||
{ key: 'termine', label: 'Termine', render: renderTermine },
|
||||
{ key: 'leistungen', label: 'Leistungen', render: platzhalter('Leistungen', 'Hier legst du bald fest, was deine Kundschaft buchen kann – mit Dauer, Preis und Reihenfolge.') },
|
||||
{ key: 'team', label: 'Team & Plätze', render: platzhalter('Team & Plätze', 'Hier pflegst du bald Personen und Plätze mit ihren Zeiten, Urlaub und Kalender-Links.') },
|
||||
{ key: 'einstellungen', label: 'Einstellungen', render: platzhalter('Einstellungen', 'Hier ändern wir bald Betriebsangaben, Regeln und Texte für deine Gäste.') },
|
||||
]
|
||||
|
||||
/** Meldung ueber der Reiterleiste: art 'ok' (Standard) oder 'fehler'; leerer Text blendet sie aus. */
|
||||
function zeigeMeldung(s, text, art = 'ok') {
|
||||
const el = s.meldungEl
|
||||
if (!el) return
|
||||
el.textContent = text || ''
|
||||
el.hidden = !text
|
||||
el.className = `tm-meldung tm-meldung-${art}`
|
||||
el.setAttribute('role', art === 'fehler' ? 'alert' : 'status')
|
||||
}
|
||||
|
||||
function zeigeReiter(s) {
|
||||
const projectId = s.projekt.projectId
|
||||
const nurLesen = s.nurLesen
|
||||
s.meldungEl = h('div', { class: 'tm-meldung', hidden: true })
|
||||
const ctx = {
|
||||
s,
|
||||
projectId,
|
||||
daten: s.daten,
|
||||
nurLesen,
|
||||
api: {
|
||||
get: (pfad, query = {}) => request(`/api/termine${pfad}?${new URLSearchParams({ projectId, ...query })}`),
|
||||
send: (method, pfad, body = {}) => (method === 'DELETE'
|
||||
? request(`/api/termine${pfad}?${new URLSearchParams({ projectId })}`, { method })
|
||||
: request(`/api/termine${pfad}`, { method, body: { projectId, ...body } })),
|
||||
},
|
||||
meldung: (text, art) => zeigeMeldung(s, text, art),
|
||||
zumReiter: (key) => waehleReiter(key),
|
||||
neuLaden: async () => {
|
||||
s.daten = await ladeDaten(s)
|
||||
ctx.daten = s.daten
|
||||
return s.daten
|
||||
},
|
||||
}
|
||||
|
||||
const hinweise = []
|
||||
if (nurLesen) {
|
||||
hinweise.push(h('div', { class: 'tm-box tm-box-warn', role: 'note' },
|
||||
'Der Terminplaner ist für diese Website abgeschaltet. Du kannst Termine nur noch ansehen und ',
|
||||
h('a', { href: `/api/termine/export.csv?projectId=${encodeURIComponent(projectId)}`, download: 'termine.csv' }, 'exportieren'),
|
||||
'.'))
|
||||
} else if ((s.daten.fehlt || []).length) {
|
||||
hinweise.push(h('div', { class: 'tm-box tm-box-warn', role: 'note' },
|
||||
h('span', null, `Noch nicht buchbar: Es fehlt ${fehltText(s.daten.fehlt) || 'eine Angabe zu deinem Betrieb'}. Solange das fehlt, können Gäste über deine Website nicht buchen.`),
|
||||
h('button', { type: 'button', class: 'kb-btn-outline', onclick: () => waehleReiter('einstellungen') }, 'Zu den Einstellungen')))
|
||||
}
|
||||
|
||||
const panel = h('div', { class: 'tm-panel', role: 'tabpanel', id: 'tm-panel', tabindex: '0' })
|
||||
const knoepfe = REITER.map((r) => h('button', {
|
||||
type: 'button', role: 'tab', class: 'tm-reiter', id: `tm-tab-${r.key}`, 'aria-controls': 'tm-panel', 'data-reiter': r.key,
|
||||
onclick: () => waehleReiter(r.key),
|
||||
}, r.label))
|
||||
const leiste = h('div', { class: 'tm-reiterleiste', role: 'tablist', 'aria-label': 'Bereiche des Terminplaners' }, knoepfe)
|
||||
leiste.addEventListener('keydown', (ev) => {
|
||||
const i = REITER.findIndex((r) => r.key === auswahl.reiter)
|
||||
const ziel = { ArrowRight: (i + 1) % REITER.length, ArrowLeft: (i + REITER.length - 1) % REITER.length, Home: 0, End: REITER.length - 1 }[ev.key]
|
||||
if (ziel === undefined) return
|
||||
ev.preventDefault()
|
||||
waehleReiter(REITER[ziel].key)
|
||||
knoepfe[ziel].focus()
|
||||
})
|
||||
|
||||
function waehleReiter(key) {
|
||||
const reiter = REITER.find((r) => r.key === key) || REITER[0]
|
||||
auswahl.reiter = reiter.key
|
||||
for (const k of knoepfe) {
|
||||
const an = k.dataset.reiter === reiter.key
|
||||
k.setAttribute('aria-selected', String(an))
|
||||
k.tabIndex = an ? 0 : -1
|
||||
}
|
||||
panel.setAttribute('aria-labelledby', `tm-tab-${reiter.key}`)
|
||||
zeigeMeldung(s, '')
|
||||
panel.replaceChildren()
|
||||
Promise.resolve()
|
||||
.then(() => reiter.render(panel, ctx))
|
||||
.catch((err) => { if (S === s) panel.replaceChildren(fehlerKarte(err.message, () => waehleReiter(reiter.key))) })
|
||||
}
|
||||
|
||||
s.buehne.replaceChildren(...hinweise, s.meldungEl, leiste, panel)
|
||||
waehleReiter(auswahl.reiter)
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------- Reiter Termine --- */
|
||||
const AKTION_TEXT = {
|
||||
annehmen: 'Annehmen', ablehnen: 'Ablehnen', verschieben: 'Verschieben', absagen: 'Absagen', nichterschienen: 'Nicht erschienen',
|
||||
anonymisieren: 'Gastdaten löschen', entsperren: 'Sperre aufheben',
|
||||
}
|
||||
const ABSENDER = { gast: 'Vom Gast abgesagt', betrieb: 'Von dir abgesagt', system: 'Automatisch abgelehnt (Termin vorbei)' }
|
||||
|
||||
function renderTermine(panel, ctx) {
|
||||
const t = { ctx, panel, nr: 0, buchungen: [], offen: null, formular: null }
|
||||
const { s } = ctx
|
||||
|
||||
// Kopf: Ansicht, Navigation, Titel, Filter, Werkzeuge
|
||||
const ansichtKnoepfe = [['tag', 'Tag'], ['woche', 'Woche'], ['monat', 'Monat']].map(([key, text]) =>
|
||||
h('button', { type: 'button', class: 'tm-seg', 'data-ansicht': key, onclick: () => setze(t, { ansicht: key }) }, text))
|
||||
t.zurueck = h('button', { type: 'button', class: 'kb-btn-outline', hidden: true, onclick: () => setze(t, { ansicht: 'tag' }) }, 'Zurück zur Übersicht')
|
||||
t.nav = h('div', { class: 'tm-nav' },
|
||||
h('button', { type: 'button', class: 'tm-pfeil', 'aria-label': 'Zurück', onclick: () => setze(t, { tag: verschiebeTag(auswahl.ansicht, s.tag, -1) }) }, h('span', { 'aria-hidden': 'true' }, '‹')),
|
||||
h('button', { type: 'button', class: 'kb-btn-outline tm-heute', onclick: () => setze(t, { tag: heuteBerlin() }) }, 'Heute'),
|
||||
h('button', { type: 'button', class: 'tm-pfeil', 'aria-label': 'Weiter', onclick: () => setze(t, { tag: verschiebeTag(auswahl.ansicht, s.tag, 1) }) }, h('span', { 'aria-hidden': 'true' }, '›')))
|
||||
t.datumFeld = h('input', { type: 'date', id: 'tm-sprung', 'aria-label': 'Zu einem Datum springen' })
|
||||
t.datumFeld.addEventListener('change', () => { if (t.datumFeld.value) setze(t, { tag: t.datumFeld.value }) })
|
||||
t.titel = h('h3', { class: 'tm-zeitraum', 'aria-live': 'polite' })
|
||||
const filterKnoepfe = FILTER.map((f) => h('button', { type: 'button', class: 'tm-chip', 'data-filter': f.key, onclick: () => setze(t, { filter: f.key }) }, f.label))
|
||||
t.filterBox = h('div', { class: 'tm-filter', role: 'group', 'aria-label': 'Nach Status filtern' }, filterKnoepfe)
|
||||
t.ansichtBox = h('div', { class: 'tm-segment', role: 'group', 'aria-label': 'Ansicht' }, ansichtKnoepfe)
|
||||
t.werkzeuge = ctx.nurLesen ? null : h('div', { class: 'tm-werkzeuge' },
|
||||
h('button', { type: 'button', class: 'kb-btn-primary', 'data-werkzeug': 'manuell', onclick: () => oeffneFormular(t, 'manuell') }, '+ Termin eintragen'),
|
||||
h('button', { type: 'button', class: 'kb-btn-outline', 'data-werkzeug': 'sperre', onclick: () => oeffneFormular(t, 'sperre') }, 'Zeit sperren'))
|
||||
t.neuBtn = h('button', { type: 'button', class: 'kb-btn-outline', onclick: () => ladeListe(t, { auchZahl: true }) }, 'Aktualisieren')
|
||||
t.anfragenBox = h('div', { class: 'tm-box tm-box-info', role: 'status', hidden: true })
|
||||
t.formularBox = h('div', { class: 'tm-formular-box' })
|
||||
t.liste = h('div', { class: 'tm-liste', 'aria-live': 'polite' })
|
||||
|
||||
panel.replaceChildren(
|
||||
h('div', { class: 'tm-leiste' }, t.ansichtBox, t.nav, h('label', { class: 'tm-sprung', for: 'tm-sprung' }, h('span', null, 'Datum'), t.datumFeld), t.zurueck),
|
||||
t.titel,
|
||||
t.filterBox,
|
||||
h('div', { class: 'tm-werkzeug-zeile' }, t.werkzeuge, t.neuBtn),
|
||||
t.anfragenBox,
|
||||
t.formularBox,
|
||||
t.liste)
|
||||
aktualisiereKopf(t)
|
||||
ladeListe(t)
|
||||
}
|
||||
|
||||
/** Auswahl aendern (Ansicht, Filter, Tag) und die Liste neu laden. */
|
||||
function setze(t, aenderung) {
|
||||
const { s } = t.ctx
|
||||
if (aenderung.ansicht) auswahl.ansicht = aenderung.ansicht
|
||||
if (aenderung.filter) auswahl.filter = aenderung.filter
|
||||
if (aenderung.tag) s.tag = aenderung.tag
|
||||
t.ctx.meldung('')
|
||||
schliesseFrage(t)
|
||||
aktualisiereKopf(t)
|
||||
ladeListe(t)
|
||||
}
|
||||
|
||||
function aktualisiereKopf(t) {
|
||||
const { s, daten } = t.ctx
|
||||
const anfragen = auswahl.ansicht === 'anfragen'
|
||||
for (const k of t.ansichtBox.children) k.setAttribute('aria-pressed', String(!anfragen && k.dataset.ansicht === auswahl.ansicht))
|
||||
for (const k of t.filterBox.children) k.setAttribute('aria-pressed', String(!anfragen && k.dataset.filter === auswahl.filter))
|
||||
t.ansichtBox.hidden = anfragen
|
||||
t.nav.hidden = anfragen
|
||||
t.filterBox.hidden = anfragen
|
||||
t.datumFeld.parentElement.hidden = anfragen
|
||||
t.zurueck.hidden = !anfragen
|
||||
t.titel.textContent = zeitraumTitel(auswahl.ansicht, s.tag)
|
||||
t.datumFeld.value = s.tag
|
||||
const n = daten.anfragenOffen
|
||||
t.anfragenBox.hidden = anfragen || !(n > 0)
|
||||
if (!t.anfragenBox.hidden) {
|
||||
t.anfragenBox.replaceChildren(
|
||||
h('span', null, anfragenText(n)),
|
||||
h('button', { type: 'button', class: 'kb-btn-outline', onclick: () => setze(t, { ansicht: 'anfragen' }) }, 'Anfragen ansehen'))
|
||||
}
|
||||
}
|
||||
|
||||
async function ladeListe(t, { auchZahl = false } = {}) {
|
||||
const { ctx } = t
|
||||
const { s } = ctx
|
||||
const nr = ++t.nr
|
||||
const anfragen = auswahl.ansicht === 'anfragen'
|
||||
const { von, bis } = bereichFuer(auswahl.ansicht, s.tag, heuteBerlin())
|
||||
const status = anfragen ? 'angefragt' : filterStatus(auswahl.filter)
|
||||
t.liste.setAttribute('aria-busy', 'true')
|
||||
try {
|
||||
const [antwort] = await Promise.all([
|
||||
ctx.api.get('/buchungen', { von, bis, ...(status ? { status } : {}) }),
|
||||
auchZahl ? ctx.neuLaden().then(() => aktualisiereKopf(t), () => {}) : null, // die Zahl der Anfragen ist Beiwerk: ein Fehler dabei kostet nicht die Liste
|
||||
])
|
||||
if (nr !== t.nr || S !== s || !t.liste.isConnected) return
|
||||
t.buchungen = antwort.buchungen || []
|
||||
zeichneListe(t)
|
||||
} catch (err) {
|
||||
if (nr !== t.nr || S !== s) return
|
||||
t.liste.replaceChildren(fehlerKarte(err.message, () => ladeListe(t)))
|
||||
} finally {
|
||||
if (nr === t.nr) t.liste.removeAttribute('aria-busy')
|
||||
}
|
||||
}
|
||||
|
||||
/** Nach einer Aenderung: Liste und Zahl der Anfragen neu holen. */
|
||||
const nachAenderung = (t) => ladeListe(t, { auchZahl: true })
|
||||
|
||||
function leerText() {
|
||||
if (auswahl.ansicht === 'anfragen') return 'Es warten keine Anfragen.'
|
||||
const wo = auswahl.ansicht === 'monat' ? 'in diesem Monat' : 'an diesem Tag'
|
||||
return auswahl.filter === 'alle' ? `Keine Termine ${wo}.` : `Keine Termine mit diesem Filter ${wo}.`
|
||||
}
|
||||
|
||||
function zeichneListe(t) {
|
||||
const { s } = t.ctx
|
||||
const heute = heuteBerlin()
|
||||
const gruppen = gruppiereNachTag(t.buchungen)
|
||||
let tage = gruppen
|
||||
// Tag und Woche zeigen jeden Tag des Zeitraums, auch leere; Monat und Anfragen nur Tage mit Terminen
|
||||
if (auswahl.ansicht === 'tag' || auswahl.ansicht === 'woche') {
|
||||
const { von, bis } = bereichFuer(auswahl.ansicht, s.tag, heute)
|
||||
tage = []
|
||||
for (let tag = von; tag <= bis; tag = tagPlus(tag, 1)) tage.push(gruppen.find((g) => g.tag === tag) || { tag, buchungen: [] })
|
||||
}
|
||||
t.offen = null
|
||||
if (!gruppen.length && auswahl.ansicht !== 'woche') {
|
||||
t.liste.replaceChildren(h('p', { class: 'tm-leer' }, leerText()))
|
||||
return
|
||||
}
|
||||
t.liste.replaceChildren(...tage.map((g) => h('section', { class: 'tm-tag', 'data-tag': g.tag },
|
||||
auswahl.ansicht === 'tag' ? null : h('h4', { class: 'tm-tag-kopf' }, tagUeberschrift(g.tag, heute)),
|
||||
g.buchungen.length
|
||||
? h('ul', { class: 'tm-eintraege' }, g.buchungen.map((b) => eintrag(t, b)))
|
||||
: h('p', { class: 'tm-leer' }, 'Keine Termine'))))
|
||||
}
|
||||
|
||||
function eintrag(t, b) {
|
||||
const { ctx } = t
|
||||
const sperre = istSperre(b)
|
||||
const aktionen = ctx.nurLesen ? [] : moeglicheAktionen(b, Date.now())
|
||||
const kontext = `${sperre ? 'Sperre' : b.leistung}, ${datumKurz(b.datum)} um ${b.zeit} Uhr`
|
||||
const li = h('li', { class: `tm-eintrag${sperre ? ' tm-sperre' : ''}`, 'data-id': b.id, 'data-status': b.status, 'data-quelle': b.quelle })
|
||||
|
||||
const titelBlock = sperre
|
||||
? [h('strong', { class: 'tm-name' }, 'Gesperrt'), b.titel && b.titel !== 'Gesperrt' ? h('span', { class: 'tm-meta' }, b.titel) : null, b.person ? h('span', { class: 'tm-meta' }, b.person) : null]
|
||||
: [h('strong', { class: 'tm-name' }, b.leistung || 'Termin'),
|
||||
h('span', { class: 'tm-meta' }, [b.person, b.gastName || 'Ohne Namen', b.quelle === 'manuell' ? 'von dir eingetragen' : ''].filter(Boolean).join(' · '))]
|
||||
li.append(h('div', { class: 'tm-zeile' },
|
||||
h('span', { class: 'tm-zeit' }, `${b.zeit}–${endeZeit(b)}`),
|
||||
h('div', { class: 'tm-haupt' }, titelBlock),
|
||||
sperre ? h('span', { class: 'tm-pille tm-pille-off' }, 'Gesperrt') : h('span', { class: `tm-pille tm-pille-${statusTon(b.status)}` }, statusLabel(b.status))))
|
||||
|
||||
const hinweise = []
|
||||
if (b.zustellFehler) hinweise.push(h('p', { class: 'tm-hinweis-zeile tm-warn' }, h('strong', null, 'Bestätigung nicht zugestellt'), ' – die E-Mail an den Gast ist nicht angekommen. Melde dich am besten telefonisch.'))
|
||||
if (b.status === 'abgesagt') {
|
||||
// absageGrund steht auch bei Vermerken an noch bestehenden Terminen (z. B. spaeter Widerruf): als Grund nur bei einer Absage zeigen
|
||||
if (b.widerrufenAt) hinweise.push(h('p', { class: 'tm-hinweis-zeile' }, 'Vom Gast widerrufen'))
|
||||
else if (ABSENDER[b.abgesagtVon]) hinweise.push(h('p', { class: 'tm-hinweis-zeile' }, ABSENDER[b.abgesagtVon]))
|
||||
if (b.absageGrund) hinweise.push(h('p', { class: 'tm-hinweis-zeile' }, `${b.abgesagtVon === 'betrieb' ? 'Grund' : 'Vermerk'}: ${b.absageGrund}`))
|
||||
}
|
||||
if (b.status === 'abgelehnt' && b.abgesagtVon === 'system') hinweise.push(h('p', { class: 'tm-hinweis-zeile' }, ABSENDER.system))
|
||||
li.append(...hinweise)
|
||||
|
||||
const knoepfe = aktionen.filter((a) => a !== 'anonymisieren')
|
||||
if (knoepfe.length) {
|
||||
li.append(h('div', { class: 'tm-aktionen' }, knoepfe.map((a) => h('button', {
|
||||
type: 'button',
|
||||
class: a === 'annehmen' ? 'kb-btn-primary tm-knopf' : 'kb-btn-outline tm-knopf',
|
||||
'data-aktion': a,
|
||||
'aria-label': `${AKTION_TEXT[a]}: ${kontext}`,
|
||||
onclick: (ev) => starteAktion(t, li, b, a, ev.currentTarget),
|
||||
}, AKTION_TEXT[a]))))
|
||||
}
|
||||
li.append(h('div', { class: 'tm-frage', 'data-frage': '' }))
|
||||
|
||||
if (!sperre) {
|
||||
const fakten = [
|
||||
['Buchungsnummer', b.nummer], ['Dauer', `${b.dauer} Minuten`], ['Name', b.gastName], ['E-Mail', b.gastEmail], ['Telefon', b.gastTelefon],
|
||||
['Nachricht', b.gastNachricht], ['Erinnerung', b.erinnerung ? 'Per E-Mail gewünscht' : ''],
|
||||
].filter(([, wert]) => wert)
|
||||
li.append(h('details', { class: 'tm-details' },
|
||||
h('summary', { 'aria-label': `Details: ${kontext}` }, 'Details'),
|
||||
h('dl', { class: 'tm-fakten' }, fakten.map(([name, wert]) => h('div', null, h('dt', null, name), h('dd', null, wert)))),
|
||||
aktionen.includes('anonymisieren')
|
||||
? h('button', { type: 'button', class: 'tm-link-knopf', 'data-aktion': 'anonymisieren', onclick: (ev) => starteAktion(t, li, b, 'anonymisieren', ev.currentTarget) }, 'Gastdaten löschen')
|
||||
: null))
|
||||
}
|
||||
return li
|
||||
}
|
||||
|
||||
/* ------------------------------------------------- Aktionen mit Rückfrage --- */
|
||||
function schliesseFrage(t, fokus = false) {
|
||||
if (!t.offen) return
|
||||
const { li, knopf } = t.offen
|
||||
li.querySelector('[data-frage]')?.replaceChildren()
|
||||
li.classList.remove('tm-offen')
|
||||
t.offen = null
|
||||
if (fokus && knopf?.isConnected) knopf.focus()
|
||||
}
|
||||
|
||||
function starteAktion(t, li, b, art, knopf) {
|
||||
t.ctx.meldung('')
|
||||
if (art === 'annehmen') {
|
||||
schliesseFrage(t)
|
||||
fuehreAus(t, b, { aktion: 'annehmen' }, b.gastEmail ? `Anfrage angenommen. ${b.gastName} bekommt eine Bestätigung per E-Mail.` : 'Anfrage angenommen.', null, knopf)
|
||||
return
|
||||
}
|
||||
schliesseFrage(t)
|
||||
const zu = () => schliesseFrage(t, true)
|
||||
const fragen = { ablehnen: frageAblehnen, absagen: frageAbsagen, verschieben: frageVerschieben, nichterschienen: frageNichtErschienen, anonymisieren: frageAnonymisieren, entsperren: frageEntsperren }
|
||||
const box = fragen[art](t, b, zu)
|
||||
li.querySelector('[data-frage]').replaceChildren(box)
|
||||
li.classList.add('tm-offen')
|
||||
t.offen = { li, knopf, art }
|
||||
// Fokus ins erste Eingabefeld; ohne Eingabefeld auf die Rueckfrage selbst, damit ein Enter nichts bestaetigt
|
||||
const erster = box.querySelector('select, textarea, input') || box
|
||||
erster.focus()
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktion an die Schnittstelle schicken. erfolg = Text der Meldung. form = Rueckfrage-Formular fuer Fehler (400 und belegte Zeit
|
||||
* bleiben dort), sonst Meldung ueber den Reitern; bei Fehlern, die auf einen geaenderten Stand deuten (404, 409), wird die Liste neu geladen.
|
||||
*/
|
||||
async function fuehreAus(t, b, body, erfolg, form, knopf) {
|
||||
const { ctx } = t
|
||||
knopf.disabled = true
|
||||
try {
|
||||
const entsperren = body.aktion === 'entsperren'
|
||||
const antwort = entsperren
|
||||
? await ctx.api.send('DELETE', `/buchungen/${encodeURIComponent(b.id)}`)
|
||||
: await ctx.api.send('POST', `/buchungen/${encodeURIComponent(b.id)}/aktion`, body)
|
||||
await nachAenderung(t) // erst die Liste, dann die Meldung: wer die Meldung sieht, sieht auch den neuen Stand
|
||||
ctx.meldung(antwort.schon ? 'Das war schon so erledigt.' : erfolg)
|
||||
} catch (err) {
|
||||
const imFormular = form && (err.status === 400 || err.code === 'SLOT_VERGEBEN')
|
||||
if (imFormular) {
|
||||
zeigeFehler(form, err)
|
||||
knopf.disabled = false
|
||||
return
|
||||
}
|
||||
ctx.meldung(err.message, 'fehler')
|
||||
if (err.status === 404 || err.status === 409) await nachAenderung(t)
|
||||
else knopf.disabled = false
|
||||
}
|
||||
}
|
||||
|
||||
const mailHinweis = (b, text) => (b.gastEmail ? text : 'Der Gast bekommt keine E-Mail, weil keine Adresse hinterlegt ist.')
|
||||
|
||||
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 (ok, schickt das Formular ab) und Abbrechen. */
|
||||
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')) }
|
||||
}
|
||||
|
||||
const allgemeinerFehler = () => h('p', { class: 'tm-fehler', 'data-fehler': '_', role: 'alert' })
|
||||
|
||||
function frageAblehnen(t, b, zu) {
|
||||
const nachricht = h('textarea', { rows: '3', maxlength: String(NACHRICHT_MAX) })
|
||||
const { ok, zeile } = knopfZeile('Anfrage ablehnen', zu)
|
||||
const form = frage('Anfrage ablehnen?',
|
||||
h('p', null, mailHinweis(b, 'Der Gast bekommt eine E-Mail, dass der Termin nicht möglich ist. Die Zeit wird wieder frei.')),
|
||||
feld('nachricht', 'Nachricht an den Gast (optional)', nachricht),
|
||||
allgemeinerFehler(),
|
||||
zeile)
|
||||
form.addEventListener('submit', () => fuehreAus(t, b, { aktion: 'ablehnen', nachricht: nachricht.value }, 'Anfrage abgelehnt.', form, ok))
|
||||
return form
|
||||
}
|
||||
|
||||
function frageAbsagen(t, b, zu) {
|
||||
const grund = h('select', null, h('option', { value: '' }, 'Bitte wählen'), ABSAGE_GRUENDE.map((g) => h('option', { value: g }, g)))
|
||||
const nachricht = h('textarea', { rows: '3', maxlength: String(NACHRICHT_MAX) })
|
||||
const { ok, zeile } = knopfZeile('Termin absagen', zu)
|
||||
const form = frage('Termin absagen?',
|
||||
h('p', null, mailHinweis(b, 'Der Gast bekommt eine E-Mail mit dem Grund. Die Zeit wird wieder frei.')),
|
||||
feld('grund', 'Grund', grund),
|
||||
feld('nachricht', 'Nachricht an den Gast (optional)', nachricht),
|
||||
allgemeinerFehler(),
|
||||
zeile)
|
||||
form.addEventListener('submit', () => {
|
||||
if (!grund.value) {
|
||||
setzeFehler(form, { grund: 'Bitte wähle einen Grund aus.' })
|
||||
grund.focus()
|
||||
return
|
||||
}
|
||||
fuehreAus(t, b, { aktion: 'absagen', grund: grund.value, nachricht: nachricht.value }, 'Termin abgesagt.', form, ok)
|
||||
})
|
||||
return form
|
||||
}
|
||||
|
||||
function frageNichtErschienen(t, b, zu) {
|
||||
const { ok, zeile } = knopfZeile('Ja, nicht erschienen', zu)
|
||||
const form = frage('Als „nicht erschienen“ markieren?',
|
||||
h('p', null, 'Der Termin bleibt in der Liste und wird als „Nicht erschienen“ geführt. Der Gast bekommt keine E-Mail.'),
|
||||
allgemeinerFehler(),
|
||||
zeile)
|
||||
form.addEventListener('submit', () => fuehreAus(t, b, { aktion: 'nichterschienen' }, 'Als „nicht erschienen“ markiert.', form, ok))
|
||||
return form
|
||||
}
|
||||
|
||||
function frageAnonymisieren(t, b, zu) {
|
||||
const { ok, zeile } = knopfZeile('Gastdaten löschen', zu)
|
||||
const form = frage('Gastdaten löschen?',
|
||||
h('p', null, 'Name, E-Mail, Telefon und Nachricht werden gelöscht. Das lässt sich nicht rückgängig machen. Der Termin bleibt ohne Namen bestehen, die Links in alten E-Mails funktionieren danach nicht mehr.'),
|
||||
allgemeinerFehler(),
|
||||
zeile)
|
||||
form.addEventListener('submit', () => fuehreAus(t, b, { aktion: 'anonymisieren' }, 'Gastdaten gelöscht.', form, ok))
|
||||
return form
|
||||
}
|
||||
|
||||
function frageEntsperren(t, b, zu) {
|
||||
const { ok, zeile } = knopfZeile('Sperre aufheben', zu)
|
||||
const form = frage('Sperre aufheben?',
|
||||
h('p', null, 'Die Zeit ist danach wieder buchbar.'),
|
||||
allgemeinerFehler(),
|
||||
zeile)
|
||||
form.addEventListener('submit', () => fuehreAus(t, b, { aktion: 'entsperren' }, 'Sperre aufgehoben.', form, ok))
|
||||
return form
|
||||
}
|
||||
|
||||
function frageVerschieben(t, b, zu) {
|
||||
const { ctx } = t
|
||||
const datum = h('input', { type: 'date', value: b.datum, min: heuteBerlin() })
|
||||
const zeiten = zeitWahl(ctx, () => ({ ohne: b.id, tag: datum.value }))
|
||||
const { ok, zeile } = knopfZeile('Verschieben', zu)
|
||||
const form = frage('Termin verschieben',
|
||||
h('p', null, mailHinweis(b, 'Der Gast bekommt eine E-Mail mit der neuen Zeit. Es gelten keine Fristen und kein Vorlauf.')),
|
||||
feld('tag', 'Neuer Tag', datum),
|
||||
zeiten.el,
|
||||
allgemeinerFehler(),
|
||||
zeile)
|
||||
datum.addEventListener('change', () => zeiten.laden())
|
||||
zeiten.laden()
|
||||
form.addEventListener('submit', () => {
|
||||
if (!zeiten.wert()) {
|
||||
setzeFehler(form, { zeit: 'Bitte wähle eine Uhrzeit aus.' })
|
||||
return
|
||||
}
|
||||
fuehreAus(t, b, { aktion: 'verschieben', tag: datum.value, zeit: zeiten.wert() }, `Termin verschoben auf ${datumKurz(datum.value)}, ${zeiten.wert()} Uhr.`, form, ok)
|
||||
})
|
||||
return form
|
||||
}
|
||||
|
||||
/** Freie Uhrzeiten als Auswahl-Knoepfe: laden() holt sie fuer query() (leistung/ressource/tag oder ohne/tag), wert() ist die gewaehlte. */
|
||||
function zeitWahl(ctx, query, { onWahl } = {}) {
|
||||
let gewaehlt = ''
|
||||
let nr = 0
|
||||
const status = h('p', { class: 'tm-hinweis', 'aria-live': 'polite' })
|
||||
const knoepfe = h('div', { class: 'tm-zeiten', role: 'group', 'aria-label': 'Freie Uhrzeiten' })
|
||||
const fehler = h('p', { class: 'tm-fehler', 'data-fehler': 'zeit', role: 'alert' })
|
||||
const el = h('div', { class: 'tm-feld', 'data-feld': 'zeit' }, h('span', { class: 'tm-label' }, 'Uhrzeit'), status, knoepfe, fehler)
|
||||
async function laden() {
|
||||
const mein = ++nr
|
||||
gewaehlt = ''
|
||||
knoepfe.replaceChildren()
|
||||
fehler.textContent = ''
|
||||
const q = query()
|
||||
if (!q || !q.tag) { status.textContent = 'Wähle zuerst einen Tag.'; return }
|
||||
status.textContent = 'Freie Zeiten werden geladen …'
|
||||
try {
|
||||
const { zeiten } = await ctx.api.get('/zeiten', q)
|
||||
if (mein !== nr) return
|
||||
status.textContent = zeiten.length ? 'Wähle eine Uhrzeit:' : 'An diesem Tag ist nichts frei.'
|
||||
knoepfe.replaceChildren(...zeiten.map((z) => h('button', {
|
||||
type: 'button', class: 'tm-chip tm-zeit-knopf', 'aria-pressed': 'false', 'data-zeit': z,
|
||||
onclick: (ev) => {
|
||||
gewaehlt = z
|
||||
for (const k of knoepfe.children) k.setAttribute('aria-pressed', String(k === ev.currentTarget))
|
||||
fehler.textContent = ''
|
||||
onWahl?.(z)
|
||||
},
|
||||
}, z)))
|
||||
} catch (err) {
|
||||
if (mein === nr) status.textContent = err.message
|
||||
}
|
||||
}
|
||||
return { el, laden, wert: () => gewaehlt }
|
||||
}
|
||||
|
||||
/* ---------------------------------------- Termin eintragen / Zeit sperren --- */
|
||||
function oeffneFormular(t, art) {
|
||||
schliesseFrage(t)
|
||||
t.ctx.meldung('')
|
||||
if (t.formular === art) { schliesseFormular(t); return }
|
||||
t.formular = art
|
||||
for (const k of t.werkzeuge.children) k.setAttribute('aria-expanded', String(k.dataset.werkzeug === art))
|
||||
const form = art === 'manuell' ? formularTermin(t) : formularSperre(t)
|
||||
t.formularBox.replaceChildren(form)
|
||||
form.querySelector('select, input')?.focus()
|
||||
}
|
||||
|
||||
function schliesseFormular(t) {
|
||||
t.formular = null
|
||||
t.formularBox.replaceChildren()
|
||||
for (const k of t.werkzeuge?.children || []) k.setAttribute('aria-expanded', 'false')
|
||||
}
|
||||
|
||||
/** Startwert fuer Tag-Felder: der gewaehlte Tag, wenn er nicht in der Vergangenheit liegt, sonst heute. */
|
||||
const startTag = (t) => (t.ctx.s.tag >= heuteBerlin() ? t.ctx.s.tag : heuteBerlin())
|
||||
|
||||
async function nachErfolg(t, tag, text) {
|
||||
schliesseFormular(t)
|
||||
t.ctx.s.tag = tag
|
||||
if (auswahl.ansicht === 'anfragen') auswahl.ansicht = 'tag'
|
||||
aktualisiereKopf(t)
|
||||
await nachAenderung(t)
|
||||
t.ctx.meldung(text)
|
||||
}
|
||||
|
||||
function formularTermin(t) {
|
||||
const { ctx } = t
|
||||
const e = ctx.daten.einstellung
|
||||
const aktiveRessourcen = (e.ressourcen || []).filter((r) => r.aktiv)
|
||||
const leistungen = (e.leistungen || []).filter((l) => l.aktiv && (l.ressourcen || []).some((id) => aktiveRessourcen.some((r) => r.id === id)))
|
||||
if (!leistungen.length) {
|
||||
return h('div', { class: 'kb-panel tm-formular' }, h('p', null, 'Lege zuerst im Reiter „Leistungen“ eine Leistung an, die du buchen kannst.'),
|
||||
h('button', { type: 'button', class: 'kb-btn-outline', onclick: () => ctx.zumReiter('leistungen') }, 'Zu den Leistungen'))
|
||||
}
|
||||
const wort = ressourcenWort(aktiveRessourcen)
|
||||
const leistung = h('select', null, leistungen.map((l) => h('option', { value: l.id }, `${l.name} (${l.dauer} Min.)`)))
|
||||
const person = h('select')
|
||||
const tag = h('input', { type: 'date', value: startTag(t), min: heuteBerlin() })
|
||||
const name = h('input', { type: 'text', maxlength: String(NAME_MAX), autocomplete: 'off' })
|
||||
const email = h('input', { type: 'email', maxlength: '254', autocomplete: 'off' })
|
||||
const telefon = h('input', { type: 'tel', maxlength: String(TELEFON_MAX), autocomplete: 'off' })
|
||||
const nachricht = h('textarea', { rows: '3', maxlength: String(NACHRICHT_MAX) })
|
||||
const senden = h('input', { type: 'checkbox', id: 'tm-senden' })
|
||||
const sendenHinweis = h('span', { class: 'tm-hinweis' })
|
||||
const zeiten = zeitWahl(ctx, () => ({ leistung: leistung.value, ressource: person.value, tag: tag.value }))
|
||||
|
||||
function ressourcenOptionen() {
|
||||
const l = leistungen.find((x) => x.id === leistung.value)
|
||||
const infrage = aktiveRessourcen.filter((r) => (l.ressourcen || []).includes(r.id))
|
||||
person.replaceChildren(...(infrage.length > 1 ? [h('option', { value: 'egal' }, 'Egal (erste freie)')] : []), ...infrage.map((r) => h('option', { value: r.id }, r.name)))
|
||||
person.disabled = infrage.length === 1
|
||||
}
|
||||
ressourcenOptionen()
|
||||
leistung.addEventListener('change', () => { ressourcenOptionen(); zeiten.laden() })
|
||||
person.addEventListener('change', () => zeiten.laden())
|
||||
tag.addEventListener('change', () => zeiten.laden())
|
||||
const mailAn = () => { senden.disabled = email.value.trim() === ''; if (senden.disabled) senden.checked = false; sendenHinweis.textContent = senden.disabled ? ' (nur mit E-Mail-Adresse)' : '' }
|
||||
email.addEventListener('input', mailAn)
|
||||
mailAn()
|
||||
|
||||
const ok = h('button', { type: 'submit', class: 'kb-btn-primary' }, 'Termin eintragen')
|
||||
const form = h('form', { class: 'kb-panel tm-formular', novalidate: true, 'aria-label': 'Termin eintragen' },
|
||||
h('h3', null, 'Termin eintragen'),
|
||||
h('p', { class: 'tm-hinweis' }, 'Für Anrufe und Laufkundschaft. Der Termin ist sofort bestätigt. Fristen und Vorlauf gelten dafür nicht.'),
|
||||
h('div', { class: 'tm-raster' },
|
||||
feld('leistung', 'Leistung', leistung),
|
||||
feld('ressource', wort, person),
|
||||
feld('tag', 'Tag', tag)),
|
||||
zeiten.el,
|
||||
h('div', { class: 'tm-raster' },
|
||||
feld('name', 'Name des Gastes', name),
|
||||
feld('email', 'E-Mail (optional)', email),
|
||||
feld('telefon', 'Telefon (optional)', telefon)),
|
||||
feld('nachricht', 'Notiz (optional)', nachricht, 'Bitte keine Gesundheitsangaben.'),
|
||||
h('label', { class: 'tm-haken', for: 'tm-senden' }, senden, h('span', null, 'Bestätigung an den Gast senden', sendenHinweis)),
|
||||
h('p', { class: 'tm-fehler', 'data-fehler': '_', role: 'alert' }),
|
||||
h('div', { class: 'tm-frage-knoepfe' }, ok, h('button', { type: 'button', class: 'kb-btn-outline', onclick: () => { schliesseFormular(t); t.werkzeuge.querySelector('[data-werkzeug="manuell"]').focus() } }, 'Abbrechen')))
|
||||
zeiten.laden()
|
||||
form.addEventListener('submit', async (ev) => {
|
||||
ev.preventDefault()
|
||||
setzeFehler(form, {})
|
||||
if (!zeiten.wert()) {
|
||||
setzeFehler(form, { zeit: 'Bitte wähle eine Uhrzeit aus.' })
|
||||
return
|
||||
}
|
||||
ok.disabled = true
|
||||
try {
|
||||
const gewaehlt = tag.value
|
||||
await ctx.api.send('POST', '/buchungen', {
|
||||
art: 'manuell', leistung: leistung.value, ressource: person.value, tag: gewaehlt, zeit: zeiten.wert(), name: name.value,
|
||||
email: email.value, telefon: telefon.value, nachricht: nachricht.value, bestaetigungSenden: senden.checked,
|
||||
})
|
||||
await nachErfolg(t, gewaehlt, 'Termin eingetragen.')
|
||||
} catch (err) {
|
||||
ok.disabled = false
|
||||
if (err.status === 400 || err.code === 'SLOT_VERGEBEN') {
|
||||
zeigeFehler(form, err)
|
||||
if (err.code === 'SLOT_VERGEBEN') zeiten.laden()
|
||||
} else {
|
||||
ctx.meldung(err.message, 'fehler')
|
||||
}
|
||||
}
|
||||
})
|
||||
return form
|
||||
}
|
||||
|
||||
function formularSperre(t) {
|
||||
const { ctx } = t
|
||||
const e = ctx.daten.einstellung
|
||||
const ressourcen = (e.ressourcen || []).filter((r) => r.aktiv)
|
||||
const wort = ressourcenWort(ressourcen)
|
||||
const person = h('select', null, ressourcen.map((r) => h('option', { value: r.id }, r.name)))
|
||||
const tag = h('input', { type: 'date', value: startTag(t), min: heuteBerlin() })
|
||||
const von = h('input', { type: 'text', inputmode: 'numeric', maxlength: '5', placeholder: '12:00', autocomplete: 'off' })
|
||||
const bis = h('input', { type: 'text', inputmode: 'numeric', maxlength: '5', placeholder: '13:00', autocomplete: 'off' })
|
||||
const titel = h('input', { type: 'text', maxlength: String(TITEL_MAX), placeholder: 'z. B. Mittagspause', autocomplete: 'off' })
|
||||
const ok = h('button', { type: 'submit', class: 'kb-btn-primary' }, 'Zeit sperren')
|
||||
const form = h('form', { class: 'kb-panel tm-formular', novalidate: true, 'aria-label': 'Zeit sperren' },
|
||||
h('h3', null, 'Zeit sperren'),
|
||||
h('p', { class: 'tm-hinweis' }, 'In dieser Zeit kann niemand buchen. Eine Sperre kann höchstens 8 Stunden 20 Minuten (500 Minuten) dauern. Für ganze Tage oder Urlaub nutze die Abwesenheiten im Reiter „Team & Plätze“.'),
|
||||
h('div', { class: 'tm-raster' },
|
||||
feld('ressource', wort, person),
|
||||
feld('tag', 'Tag', tag)),
|
||||
h('div', { class: 'tm-raster' },
|
||||
feld('von', 'Von (Uhrzeit)', von, 'In 5-Minuten-Schritten, z. B. 12:00.'),
|
||||
feld('bis', 'Bis (Uhrzeit)', bis, 'Auch 24:00 ist möglich.')),
|
||||
feld('titel', 'Titel (optional)', titel, 'Nur für dich sichtbar.'),
|
||||
h('p', { class: 'tm-fehler', 'data-fehler': '_', role: 'alert' }),
|
||||
h('div', { class: 'tm-frage-knoepfe' }, ok, h('button', { type: 'button', class: 'kb-btn-outline', onclick: () => { schliesseFormular(t); t.werkzeuge.querySelector('[data-werkzeug="sperre"]').focus() } }, 'Abbrechen')))
|
||||
form.addEventListener('submit', async (ev) => {
|
||||
ev.preventDefault()
|
||||
setzeFehler(form, {})
|
||||
const vonZeit = zeitNormal(von.value)
|
||||
const bisZeit = zeitNormal(bis.value, { ende: true })
|
||||
const fehler = {}
|
||||
if (!vonZeit) fehler.von = 'Bitte gib die Uhrzeit so an: 12:00.'
|
||||
if (!bisZeit) fehler.bis = 'Bitte gib die Uhrzeit so an: 13:00.'
|
||||
if (Object.keys(fehler).length) {
|
||||
zeigeFehler(form, { fehler })
|
||||
return
|
||||
}
|
||||
ok.disabled = true
|
||||
try {
|
||||
const gewaehlt = tag.value
|
||||
await ctx.api.send('POST', '/buchungen', { art: 'sperre', ressource: person.value, tag: gewaehlt, von: vonZeit, bis: bisZeit, titel: titel.value })
|
||||
await nachErfolg(t, gewaehlt, 'Zeit gesperrt.')
|
||||
} catch (err) {
|
||||
ok.disabled = false
|
||||
if (err.status === 400 || err.code === 'SLOT_VERGEBEN') zeigeFehler(form, err)
|
||||
else ctx.meldung(err.message, 'fehler')
|
||||
}
|
||||
})
|
||||
return form
|
||||
}
|
||||
Reference in new issue
Block a user