/* Buchungskalender, Reiter „Kalender“: Monatsansicht je Unterkunft, Farben nach Quelle, zwei Klicks auf freie Nächte sperren den Zeitraum. API (alle mit ?projectId=): GET /api/unterkunft/unterkuenfte => { unterkuenfte:[{ id, name, … }] } GET /unterkuenfte/:id/kalender?von&bis => { bereiche:[{ von, bis (Abreisetag, exklusiv), quelle, anfrageNummer?, sperreId?, notiz? }] } quelle: 'gebucht' | 'reserviert' | 'ical' (auch 'ical:') | 'manuell'. POST /unterkuenfte/:id/sperren { von, bis, notiz } · DELETE /sperren/:id. Wochen beginnen am Montag. Fremdtext nur ueber textContent. */ import { abschnitt, allgemeinerFehler, fehlerKarte, feld, frage, h } from './shop-dom.js' import { enc, heuteBerlin, ladeUnterkuenfte, msTag, plusTage, tagText, zeichen, zeigeApiFehler } from './buchungen-uk-hilfe.js' export const QUELLEN = [ ['gebucht', 'Direkt gebucht', 'ist-gebucht'], ['reserviert', 'Reserviert (wartet auf Buchung)', 'ist-reserviert'], ['ical', 'Booking.com / Airbnb (iCal)', 'ist-ical'], ['manuell', 'Von dir gesperrt', 'ist-manuell'], ] const MONATE = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'] const WOCHENTAGE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] const quelleArt = (q) => { const s = zeichen(q); return s.startsWith('ical') ? 'ical' : s.startsWith('anfrage') ? 'gebucht' : s } /** Alle Tage des Monatsrasters (Montag bis Sonntag, volle Wochen) als 'YYYY-MM-DD'. */ export function rasterTage(jahr, monat) { const erster = Date.UTC(jahr, monat, 1) const letzter = Date.UTC(jahr, monat + 1, 0) const start = erster - ((new Date(erster).getUTCDay() + 6) % 7) * 86400000 const ende = letzter + (6 - ((new Date(letzter).getUTCDay() + 6) % 7)) * 86400000 const tage = [] for (let ms = start; ms <= ende; ms += 86400000) tage.push(msTag(ms)) return tage } export async function render(panel, ctx) { const heute = heuteBerlin() const st = { unterkunft: null, jahr: Number(heute.slice(0, 4)), monat: Number(heute.slice(5, 7)) - 1, bereiche: [], start: null, lauf: 0 } panel.replaceChildren(h('p', { class: 'kb-empty' }, 'Lade…')) let liste try { liste = (await ladeUnterkuenfte(ctx)).filter((u) => u.aktiv !== false) } catch (err) { if (ctx.aktuell()) panel.replaceChildren(fehlerKarte(err.message, () => render(panel, ctx))) return } if (!ctx.aktuell()) return if (!liste.length) { panel.replaceChildren(h('div', { class: 'kb-panel kb-empty', 'data-leer': true }, 'Noch keine Unterkunft. Lege sie im Reiter „Unterkünfte & Preise“ an.')) return } st.unterkunft = liste[0].id const wahl = h('select', { id: 'bk-k-unterkunft', 'data-aktion': 'unterkunft' }, liste.map((u) => h('option', { value: u.id }, zeichen(u.name)))) wahl.addEventListener('change', () => { st.unterkunft = wahl.value; st.start = null; lade() }) const titel = h('strong', { class: 'bk-monat', 'aria-live': 'polite', 'data-monat': true }) const schritt = (n) => () => { const d = new Date(Date.UTC(st.jahr, st.monat + n, 1)); st.jahr = d.getUTCFullYear(); st.monat = d.getUTCMonth(); st.start = null; lade() } const kopf = h('div', { class: 'bk-filter bk-kalenderkopf' }, liste.length > 1 ? h('label', { for: 'bk-k-unterkunft' }, 'Unterkunft') : null, liste.length > 1 ? wahl : h('strong', null, zeichen(liste[0].name)), h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'zurueck', 'aria-label': 'Vorheriger Monat', onclick: schritt(-1) }, '‹'), titel, h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'weiter', 'aria-label': 'Nächster Monat', onclick: schritt(1) }, '›')) const raster = h('div', { class: 'bk-raster', role: 'grid' }) const hinweis = h('p', { class: 'sh-hinweis', 'data-hinweis': true, role: 'status' }) const dialog = h('div', { class: 'bk-dialog' }) const legende = h('ul', { class: 'bk-legende', 'aria-label': 'Legende' }, QUELLEN.map(([, text, klasse]) => h('li', null, h('span', { class: `bk-farbe ${klasse}`, 'aria-hidden': 'true' }), text))) panel.replaceChildren(abschnitt('Kalender', ctx.nurLesen ? 'Dein Kalender ist hier nur zum Ansehen.' : 'Klicke zwei freie Tage an, um den Zeitraum zu sperren, zum Beispiel für eigene Aufenthalte. Gesperrte Tage kannst du mit einem Klick wieder freigeben.', kopf, raster, hinweis, legende, dialog)) await lade() function bereichAm(tag) { for (const art of ['gebucht', 'reserviert', 'ical', 'manuell']) { const b = st.bereiche.find((x) => quelleArt(x.quelle) === art && x.von <= tag && tag < x.bis) if (b) return b } return null } async function lade() { const lauf = ++st.lauf const tage = rasterTage(st.jahr, st.monat) titel.textContent = `${MONATE[st.monat]} ${st.jahr}` dialog.replaceChildren() hinweis.textContent = '' try { const daten = await ctx.request(`/api/unterkunft/unterkuenfte/${enc(st.unterkunft)}/kalender?projectId=${enc(ctx.projectId)}&von=${tage[0]}&bis=${plusTage(tage.at(-1), 1)}`) if (st.lauf !== lauf || !ctx.aktuell()) return st.bereiche = Array.isArray(daten?.bereiche) ? daten.bereiche : [] } catch (err) { if (st.lauf === lauf && ctx.aktuell()) { st.bereiche = []; zeichne(); hinweis.textContent = err.message } return } zeichne() } function zeichne() { const tage = rasterTage(st.jahr, st.monat) const heute = heuteBerlin() raster.replaceChildren( ...WOCHENTAGE.map((w) => h('div', { class: 'bk-wochentag', role: 'columnheader' }, w)), ...tage.map((tag) => { const b = bereichAm(tag) const art = b ? quelleArt(b.quelle) : null const klassen = ['bk-tag', art ? `ist-${art}` : 'ist-frei', Number(tag.slice(5, 7)) - 1 === st.monat ? '' : 'bk-ausserhalb', tag === heute ? 'bk-heute' : '', st.start === tag ? 'bk-start' : ''] const label = `${tagText(tag)}, ${b ? (QUELLEN.find((q) => q[0] === art)?.[1] || 'belegt') : 'frei'}${b?.anfrageNummer ? `, Anfrage ${zeichen(b.anfrageNummer)}` : ''}` return h('button', { type: 'button', class: klassen.filter(Boolean).join(' '), 'data-tag': tag, 'aria-label': label, 'aria-pressed': st.start === tag ? 'true' : 'false', onclick: () => klickTag(tag, b), }, String(Number(tag.slice(8)))) })) } function klickTag(tag, b) { dialog.replaceChildren() hinweis.textContent = '' if (b) { st.start = null zeichne() if (quelleArt(b.quelle) === 'manuell' && b.sperreId) return aufhebenDialog(b) hinweis.textContent = `${tagText(tag)}: ${QUELLEN.find((q) => q[0] === quelleArt(b.quelle))?.[1] || 'belegt'}${b.anfrageNummer ? ` (Anfrage ${zeichen(b.anfrageNummer)})` : ''}. Das kannst du hier nicht ändern.` return } if (ctx.nurLesen) return if (tag < heuteBerlin()) { hinweis.textContent = 'Vergangene Tage kannst du nicht sperren.' return } if (!st.start) { st.start = tag zeichne() hinweis.textContent = `Erster Tag: ${tagText(tag)}. Klicke jetzt den letzten Tag an.` return } const [von, letzter] = st.start <= tag ? [st.start, tag] : [tag, st.start] const bis = plusTage(letzter, 1) const belegt = st.bereiche.some((x) => x.von < bis && von < x.bis) if (belegt) { st.start = null zeichne() hinweis.textContent = 'In diesem Zeitraum liegen schon belegte Tage. Wähle einen freien Zeitraum.' return } sperrDialog(von, bis) } function sperrDialog(von, bis) { const notiz = h('input', { type: 'text', id: 'bk-k-notiz', maxlength: '200', autocomplete: 'off' }) const box = frage('Zeitraum sperren', h('p', null, `${tagText(von)} bis ${tagText(plusTage(bis, -1))} (Abreise ${tagText(bis)}) sperren?`), feld('notiz', 'Notiz (nur für dich)', notiz), allgemeinerFehler(), h('div', { class: 'bk-aktionen' }, h('button', { type: 'button', class: 'kb-btn', 'data-aktion': 'sperren-senden', onclick: async (ev) => { const knopf = ev.currentTarget knopf.disabled = true try { await ctx.request(`/api/unterkunft/unterkuenfte/${enc(st.unterkunft)}/sperren?projectId=${enc(ctx.projectId)}`, { method: 'POST', body: { von, bis, notiz: String(notiz.value).trim() } }) if (!ctx.aktuell()) return st.start = null ctx.zeigeMeldung('Der Zeitraum ist gesperrt.', 'ok') await lade() } catch (err) { knopf.disabled = false zeigeApiFehler(box, err) } }, }, 'Zeitraum sperren'), h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'abbrechen', onclick: () => { st.start = null; dialog.replaceChildren(); zeichne() } }, 'Abbrechen'))) dialog.replaceChildren(box) notiz.focus() } function aufhebenDialog(b) { const box = frage('Sperre aufheben', h('p', null, `${tagText(b.von)} bis ${tagText(plusTage(b.bis, -1))}${b.notiz ? `: ${zeichen(b.notiz)}` : ''}`), allgemeinerFehler(), h('div', { class: 'bk-aktionen' }, ctx.nurLesen ? null : h('button', { type: 'button', class: 'kb-btn', 'data-aktion': 'aufheben', onclick: async (ev) => { const knopf = ev.currentTarget knopf.disabled = true try { await ctx.request(`/api/unterkunft/sperren/${enc(b.sperreId)}?projectId=${enc(ctx.projectId)}`, { method: 'DELETE' }) if (!ctx.aktuell()) return ctx.zeigeMeldung('Die Sperre ist aufgehoben.', 'ok') await lade() } catch (err) { knopf.disabled = false zeigeApiFehler(box, err) } }, }, 'Sperre aufheben'), h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'abbrechen', onclick: () => dialog.replaceChildren() }, 'Schließen'))) dialog.replaceChildren(box) } }