Files
Webklar-Kundenbereich/public/buchungen-tab-kalender.js

190 lines
9.8 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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:<id>') | '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)
}
}