190 lines
9.8 KiB
JavaScript
190 lines
9.8 KiB
JavaScript
/* 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)
|
||
}
|
||
}
|