Buchungskalender: Kalender und Unterkünfte im Portal

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Opus 5.5 committed 2026-10-07 14:11:51 +00:00
1 parent 3ecea05b52
commit db9fc61894
8 files changed
+814 -8

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
/* Buchungskalender: Fehler der Schnittstelle fuers Formular. Die Schnittstelle antwortet { fehler: '<code>', felder?: {feld: text} };
request() (shop-dom.js) legt den Code in err.fehler und die ganze Antwort in err.body. Fuer zeigeFehler() daher vorher umformen:
zeigeFehler(form, fehlerFuerFormular(err)). */
const CODES = {
zeitraum_belegt: 'Der Zeitraum ist bereits belegt.',
zeitraum_ungueltig: 'Bitte prüfe den Zeitraum.',
}
/** => { message, fehler: { feld: text } } (Feldfehler aus body.felder, sonst ein Text unter „_“). */
export function fehlerFuerFormular(err) {
const felder = err?.body?.felder
const message = (typeof err?.fehler === 'string' && CODES[err.fehler]) || err?.message || 'Das hat nicht geklappt. Bitte versuch es noch einmal.'
if (felder && typeof felder === 'object' && Object.keys(felder).length) return { message, fehler: felder }
return { message, fehler: { _: message } }
}
+108
View File
@@ -0,0 +1,108 @@
/* Buchungskalender: iCal-Bereich einer Unterkunft (Importe von Booking.com/Airbnb, Export-Link, Kurzanleitungen).
unterkunft = Eintrag aus GET /api/unterkunft/unterkuenfte: { id, icalImporte:[{ id, name, url, letzterAbrufAt, letzterErfolgAt, fehler }], icalExportUrl }.
POST /unterkuenfte/:id/ical-importe { name, url } · DELETE …/ical-importe/:importId · POST …/ical-export/erneuern => { icalExportUrl }.
Nach jeder Aenderung ruft der Aufrufer aenderung() (laedt die Unterkunft neu). */
import { abschnitt, allgemeinerFehler, datumText, feld, frage, h, zeigeFehler } from './shop-dom.js'
import { enc, zeichen, zeigeApiFehler } from './buchungen-uk-hilfe.js'
export function zeichneIcal(ctx, unterkunft, aenderung) {
const basis = `/api/unterkunft/unterkuenfte/${enc(unterkunft.id)}`
const q = `projectId=${enc(ctx.projectId)}`
const importe = Array.isArray(unterkunft.icalImporte) ? unterkunft.icalImporte : []
const dialog = h('div', { class: 'bk-dialog' })
const status = (i) => {
if (i.fehler) return `Der letzte Abgleich hat nicht geklappt: ${zeichen(i.fehler)}${i.letzterErfolgAt ? ` (zuletzt erfolgreich ${datumText(i.letzterErfolgAt)})` : ''}`
if (i.letzterErfolgAt) return `Zuletzt abgeglichen ${datumText(i.letzterErfolgAt)}`
return 'Noch nicht abgeglichen. Das passiert automatisch alle 15 Minuten.'
}
const importListe = importe.length
? h('ul', { class: 'bk-liste' }, importe.map((i) => h('li', { class: 'bk-import', 'data-import': i.id },
h('strong', null, zeichen(i.name)),
h('span', { class: i.fehler ? 'sh-fehler' : 'sh-hinweis', 'data-status': true }, status(i)),
ctx.nurLesen ? null : h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'import-weg', onclick: () => entfernenDialog(i) }, 'Entfernen'))))
: h('p', { class: 'sh-hinweis' }, 'Noch kein Kalender angebunden.')
function entfernenDialog(i) {
const box = frage('Kalender entfernen', h('p', null, `Die Belegung aus „${zeichen(i.name)}“ verschwindet dann aus deinem Kalender.`), allgemeinerFehler(),
h('div', { class: 'bk-aktionen' },
h('button', {
type: 'button', class: 'kb-btn', 'data-aktion': 'import-weg-ok',
onclick: async (ev) => {
ev.currentTarget.disabled = true
try {
await ctx.request(`${basis}/ical-importe/${enc(i.id)}?${q}`, { method: 'DELETE' })
if (ctx.aktuell()) await aenderung('Der Kalender ist entfernt.')
} catch (err) { ev.currentTarget.disabled = false; zeigeApiFehler(box, err) }
},
}, 'Entfernen'),
h('button', { type: 'button', class: 'kb-btn-outline', onclick: () => dialog.replaceChildren() }, 'Abbrechen')))
dialog.replaceChildren(box)
}
const name = h('input', { type: 'text', id: 'bk-i-name', maxlength: '60', autocomplete: 'off', placeholder: 'Booking.com' })
const url = h('input', { type: 'text', id: 'bk-i-url', inputmode: 'url', autocomplete: 'off', placeholder: 'https://…' })
const neu = ctx.nurLesen ? null : h('form', { novalidate: true, class: 'bk-importform', onsubmit: (ev) => ev.preventDefault() },
feld('name', 'Name', name), feld('url', 'Link (iCal-Adresse)', url, 'Der Link beginnt mit https:// und endet oft auf .ics.'), allgemeinerFehler(),
h('button', {
type: 'button', class: 'kb-btn', 'data-aktion': 'import-dazu',
onclick: async (ev) => {
const form = ev.currentTarget.form || ev.currentTarget.closest('form')
const body = { name: String(name.value).trim(), url: String(url.value).trim() }
if (!body.name || !/^https:\/\//i.test(body.url)) return zeigeFehler(form, { message: '', fehler: { _: 'Bitte gib einen Namen und einen Link an, der mit https:// beginnt.' } })
ev.currentTarget.disabled = true
try {
await ctx.request(`${basis}/ical-importe?${q}`, { method: 'POST', body })
if (ctx.aktuell()) await aenderung('Der Kalender ist angebunden.')
} catch (err) { ev.currentTarget.disabled = false; zeigeApiFehler(form, err) }
},
}, 'Kalender hinzufügen'))
const exportUrl = zeichen(unterkunft.icalExportUrl)
const kopiert = h('p', { class: 'sh-hinweis', role: 'status', 'data-kopiert': true })
const erneuern = () => {
const box = frage('Link erneuern', h('p', null, 'Der alte Link funktioniert danach nicht mehr. Du musst den neuen Link bei Booking.com und Airbnb eintragen.'), allgemeinerFehler(),
h('div', { class: 'bk-aktionen' },
h('button', {
type: 'button', class: 'kb-btn', 'data-aktion': 'erneuern-ok',
onclick: async (ev) => {
ev.currentTarget.disabled = true
try {
await ctx.request(`${basis}/ical-export/erneuern?${q}`, { method: 'POST' })
if (ctx.aktuell()) await aenderung('Der Link ist erneuert. Trage den neuen Link bei den Portalen ein.')
} catch (err) { ev.currentTarget.disabled = false; zeigeApiFehler(box, err) }
},
}, 'Link erneuern'),
h('button', { type: 'button', class: 'kb-btn-outline', onclick: () => dialog.replaceChildren() }, 'Abbrechen')))
dialog.replaceChildren(box)
}
const export_ = h('div', { class: 'bk-link' },
h('p', { class: 'sh-hinweis' }, 'Mit diesem Link sehen Booking.com und Airbnb, wann deine Unterkunft belegt ist. Es werden keine Gastdaten weitergegeben.'),
exportUrl ? h('input', { type: 'text', readonly: true, value: exportUrl, 'aria-label': 'Export-Link', 'data-export': true }) : h('p', { class: 'sh-hinweis' }, 'Der Link ist noch nicht verfügbar.'),
h('div', { class: 'bk-aktionen' },
exportUrl ? h('button', {
type: 'button', class: 'kb-btn-outline', 'data-aktion': 'export-kopieren',
onclick: async () => {
try { await navigator.clipboard.writeText(exportUrl); kopiert.textContent = 'Link kopiert.' } catch { kopiert.textContent = 'Kopieren hat nicht geklappt. Markiere den Link und kopiere ihn von Hand.' }
},
}, 'Kopieren') : null,
ctx.nurLesen ? null : h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'export-erneuern', onclick: erneuern }, 'Link erneuern')),
kopiert)
const anleitung = h('div', { class: 'bk-anleitung' },
h('details', { 'data-anleitung': 'booking' }, h('summary', null, 'Anleitung Booking.com'),
h('ol', null,
h('li', null, 'Unterkunft → Tarife & Verfügbarkeit → Kalender synchronisieren → Kalender importieren → Link einfügen.'),
h('li', null, 'Den Export-Link von oben dort einfügen und speichern.'),
h('li', null, 'Für die andere Richtung: Booking.com zeigt dir dort auch einen Link zum Exportieren. Den trägst du oben bei „Kalender anbinden“ ein.'))),
h('details', { 'data-anleitung': 'airbnb' }, h('summary', null, 'Anleitung Airbnb'),
h('ol', null,
h('li', null, 'Inserat → Verfügbarkeit → Kalender synchronisieren → Kalender verbinden.'),
h('li', null, 'Den Export-Link von oben dort einfügen und speichern.'),
h('li', null, 'Airbnb zeigt dir unter „Kalender exportieren“ einen Link. Den trägst du oben bei „Kalender anbinden“ ein.'))))
return abschnitt('Kalender-Abgleich (iCal)', 'So verhinderst du Doppelbuchungen: Belegungen von Booking.com oder Airbnb kommen in deinen Kalender, und deine Belegungen gehen dorthin.',
h('h4', null, 'Kalender anbinden'), importListe, neu,
h('h4', null, 'Dein Export-Link'), export_, anleitung, dialog)
}
+133
View File
@@ -0,0 +1,133 @@
/* Buchungskalender: Preis-Editor einer Unterkunft (Staffeln, Reinigung, Zusatzpersonen, Rabatt, Steuer, Frist, Stornobedingungen) mit Beispielrechnung.
GET|PUT /api/unterkunft/unterkuenfte/:id/preise => { preise:{ staffeln:[{abNaechten,preisProNachtCent}], reinigungCent, personenInklusive, aufpreisPersonNachtCent,
direktrabattProzent, steuerart:'kleinunternehmer'|'regel', ustUebernachtung, ustReinigung, fristTage, stornobedingungen } | null }
POST /api/unterkunft/preisvorschau { unterkunftId, von, bis, personen } => { naechte, posten, summen:{ bruttoCent } } (rechnet mit den gespeicherten Preisen). */
import { abschnitt, allgemeinerFehler, euroEingabe, euroText, feld, h, setzeFehler, zeigeFehler } from './shop-dom.js'
import { enc, heuteBerlin, plusTage, zeichen, zeigeApiFehler } from './buchungen-uk-hilfe.js'
const STANDARD = { staffeln: [{ abNaechten: 1, preisProNachtCent: null }], reinigungCent: 0, personenInklusive: 2, aufpreisPersonNachtCent: 0, direktrabattProzent: 0, steuerart: 'kleinunternehmer', ustUebernachtung: 7, ustReinigung: 7, fristTage: 3, stornobedingungen: '' }
const ganzzahl = (steuer) => { const t = String(steuer.value).trim(); return /^\d{1,9}$/.test(t) ? Number(t) : null }
export async function zeichnePreise(ctx, unterkunft) {
const basis = `/api/unterkunft/unterkuenfte/${enc(unterkunft.id)}`
const q = `projectId=${enc(ctx.projectId)}`
const wurzel = h('div', { class: 'bk-preise' }, h('p', { class: 'kb-empty' }, 'Lade…'))
let preise = STANDARD
try {
const daten = await ctx.request(`${basis}/preise?${q}`)
if (daten?.preise) preise = { ...STANDARD, ...daten.preise }
} catch (err) {
wurzel.replaceChildren(h('p', { class: 'sh-fehler', role: 'alert' }, err.message))
return wurzel
}
const zeilenEl = h('div', { class: 'bk-staffeln' })
const zeile = (s) => {
const ab = h('input', { type: 'text', inputmode: 'numeric', 'aria-label': 'Ab Nächten', 'data-staffel': 'ab', autocomplete: 'off' })
ab.value = String(s.abNaechten ?? '')
const preis = euroEingabe({ cent: s.preisProNachtCent, label: 'Preis pro Nacht in Euro' })
preis.dataset.staffel = 'preis'
const el = h('div', { class: 'bk-staffel', 'data-staffel-zeile': true }, 'ab ', ab, ' Nächten: ', preis, ' € pro Nacht ',
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'staffel-weg', 'aria-label': 'Staffel entfernen', onclick: () => { if (zeilenEl.children.length > 1) { el.remove(); halteErste() } } }, '−'))
return el
}
const halteErste = () => { const ab = zeilenEl.querySelector('[data-staffel="ab"]'); if (ab) { ab.value = '1'; ab.readOnly = true; ab.setAttribute('readonly', '') } }
zeilenEl.append(...(preise.staffeln?.length ? preise.staffeln : STANDARD.staffeln).map(zeile))
halteErste()
const reinigung = euroEingabe({ cent: preise.reinigungCent, id: 'bk-p-reinigung' })
const inklusive = h('input', { type: 'text', inputmode: 'numeric', id: 'bk-p-inklusive' }); inklusive.value = String(preise.personenInklusive ?? 2)
const aufpreis = euroEingabe({ cent: preise.aufpreisPersonNachtCent, id: 'bk-p-aufpreis' })
const rabatt = h('input', { type: 'text', inputmode: 'numeric', id: 'bk-p-rabatt' }); rabatt.value = String(preise.direktrabattProzent ?? 0)
const steuerart = h('select', { id: 'bk-p-steuerart' }, [['kleinunternehmer', 'Kleinunternehmer (keine Umsatzsteuer)'], ['regel', 'Regelbesteuerung (mit Umsatzsteuer)']].map(([w, t]) => h('option', { value: w }, t)))
steuerart.value = preise.steuerart === 'regel' ? 'regel' : 'kleinunternehmer'
const ustUe = h('input', { type: 'text', inputmode: 'numeric', id: 'bk-p-ust-ue' }); ustUe.value = String(preise.ustUebernachtung ?? 7)
const ustRe = h('input', { type: 'text', inputmode: 'numeric', id: 'bk-p-ust-re' }); ustRe.value = String(preise.ustReinigung ?? 7)
const frist = h('input', { type: 'text', inputmode: 'numeric', id: 'bk-p-frist' }); frist.value = String(preise.fristTage ?? 3)
const storno = h('textarea', { id: 'bk-p-storno', rows: '6', maxlength: '5000' }); storno.value = zeichen(preise.stornobedingungen)
const ustFelder = h('div', { class: 'bk-ust' }, feld('ustUebernachtung', 'USt Übernachtung in %', ustUe), feld('ustReinigung', 'USt Endreinigung in %', ustRe))
const zeigeUst = () => { ustFelder.hidden = steuerart.value !== 'regel' }
steuerart.addEventListener('change', zeigeUst)
zeigeUst()
const vorschauText = h('p', { class: 'bk-vorschau', 'data-vorschau': true, role: 'status' })
const naechteEl = h('input', { type: 'text', inputmode: 'numeric', id: 'bk-p-vn' }); naechteEl.value = '7'
const personenEl = h('input', { type: 'text', inputmode: 'numeric', id: 'bk-p-vp' }); personenEl.value = '2'
async function vorschau() {
const naechte = ganzzahl(naechteEl)
const personen = ganzzahl(personenEl)
if (!naechte || !personen) { vorschauText.textContent = 'Gib Nächte und Personen als ganze Zahlen an.'; return }
const von = plusTage(heuteBerlin(), 60)
try {
const antwort = await ctx.request(`/api/unterkunft/preisvorschau?${q}`, { method: 'POST', body: { unterkunftId: unterkunft.id, von, bis: plusTage(von, naechte), personen } })
if (!ctx.aktuell()) return
vorschauText.textContent = Number.isInteger(antwort?.summen?.bruttoCent)
? `Beispiel: ${antwort.naechte ?? naechte} ${(antwort.naechte ?? naechte) === 1 ? 'Nacht' : 'Nächte'}, ${personen} ${personen === 1 ? 'Person' : 'Personen'} = ${euroText(antwort.summen.bruttoCent)}`
: 'Für diese Angaben gibt es noch keine Beispielrechnung.'
} catch (err) {
vorschauText.textContent = Object.values(err.body?.felder || {})[0] || 'Die Beispielrechnung klappt erst, wenn die Preise gespeichert sind.'
}
}
for (const el of [naechteEl, personenEl]) el.addEventListener('change', vorschau)
const form = h('form', { novalidate: true, class: 'bk-preisform', onsubmit: (ev) => ev.preventDefault() },
h('h4', null, 'Preis pro Nacht (Staffeln)'),
h('p', { class: 'sh-hinweis' }, 'Je länger der Aufenthalt, desto günstiger die Nacht: Die passende Staffel gilt für alle Nächte der Buchung.'),
zeilenEl, h('div', { class: 'sh-fehler', 'data-fehler': 'staffeln', role: 'alert' }),
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'staffel-dazu', onclick: () => { zeilenEl.append(zeile({ abNaechten: null, preisProNachtCent: null })) } }, '+ Staffel'),
feld('reinigungCent', 'Endreinigung in Euro', reinigung),
feld('personenInklusive', 'Personen im Preis inklusive', inklusive),
feld('aufpreisPersonNachtCent', 'Aufpreis je weiterer Person und Nacht in Euro', aufpreis),
feld('direktrabattProzent', 'Direktbuchungsrabatt in %', rabatt, 'Ganze Zahl von 0 bis 50. Gilt für Übernachtung und Zusatzpersonen, nicht für die Reinigung.'),
feld('steuerart', 'Steuer', steuerart), ustFelder,
feld('fristTage', 'Frist für die Buchung nach Freigabe in Tagen', frist, '1 bis 14 Tage.'),
feld('stornobedingungen', 'Stornobedingungen', storno, 'Pflicht, bevor du den Buchungskalender freischaltest. Sie stehen auf der Buchungsseite.'),
allgemeinerFehler(),
h('div', { class: 'bk-aktionen' }, ctx.nurLesen ? null : h('button', { type: 'button', class: 'kb-btn', 'data-aktion': 'preise-speichern', onclick: speichern }, 'Preise speichern')))
if (ctx.nurLesen) for (const el of form.querySelectorAll('input, select, textarea, button')) el.disabled = true
async function speichern(ev) {
const knopf = ev.currentTarget
const fehler = {}
const staffeln = []
for (const z of zeilenEl.children) {
const ab = ganzzahl(z.querySelector('[data-staffel="ab"]'))
const p = z.querySelector('[data-staffel="preis"]').inCent()
if (ab === null || ab < 1 || p === null) fehler.staffeln = 'Bitte gib bei jeder Staffel Nächte (ganze Zahl) und einen Preis an.'
else staffeln.push({ abNaechten: ab, preisProNachtCent: p })
}
const zahl = (name, steuer, text) => { const n = ganzzahl(steuer); if (n === null) fehler[name] = text; return n }
const reinigungCent = reinigung.istLeer() ? 0 : reinigung.inCent()
if (reinigungCent === null) fehler.reinigungCent = 'Bitte gib einen Betrag wie 60,00 an.'
const aufpreisCent = aufpreis.istLeer() ? 0 : aufpreis.inCent()
if (aufpreisCent === null) fehler.aufpreisPersonNachtCent = 'Bitte gib einen Betrag wie 15,00 an.'
const body = {
staffeln, reinigungCent, aufpreisPersonNachtCent: aufpreisCent,
personenInklusive: zahl('personenInklusive', inklusive, 'Bitte gib eine ganze Zahl an.'),
direktrabattProzent: zahl('direktrabattProzent', rabatt, 'Bitte gib eine ganze Zahl von 0 bis 50 an.'),
steuerart: steuerart.value,
ustUebernachtung: zahl('ustUebernachtung', ustUe, 'Bitte gib eine ganze Zahl an.'),
ustReinigung: zahl('ustReinigung', ustRe, 'Bitte gib eine ganze Zahl an.'),
fristTage: zahl('fristTage', frist, 'Bitte gib eine ganze Zahl von 1 bis 14 an.'),
stornobedingungen: String(storno.value).trim(),
}
if (steuerart.value !== 'regel') { delete fehler.ustUebernachtung; delete fehler.ustReinigung; body.ustUebernachtung = 7; body.ustReinigung = 7 }
if (Object.keys(fehler).length) return zeigeFehler(form, { message: '', fehler })
knopf.disabled = true
try {
await ctx.request(`${basis}/preise?${q}`, { method: 'PUT', body })
if (!ctx.aktuell()) return
setzeFehler(form, {})
ctx.zeigeMeldung('Die Preise sind gespeichert.', 'ok')
await vorschau()
} catch (err) {
zeigeApiFehler(form, err)
}
knopf.disabled = false
}
wurzel.replaceChildren(form,
abschnitt('Beispielrechnung', 'So rechnet dein Gast. Sie nutzt die gespeicherten Preise.',
h('div', { class: 'bk-vorschaufelder' }, feld('naechte', 'Nächte', naechteEl), feld('personen', 'Personen', personenEl)), vorschauText))
vorschau()
return wurzel
}
+183 -4
View File
@@ -1,6 +1,185 @@
/* Buchungskalender, Reiter „Kalender“: Platzhalter (Task 13/14 fuellt ihn). */ /* Buchungskalender, Reiter „Kalender“: Monatsansicht je Unterkunft, Farben nach Quelle, zwei Klicks auf freie Nächte sperren den Zeitraum.
import { infoKarte } from './shop-dom.js' 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. */
export function render(panel) { import { abschnitt, allgemeinerFehler, fehlerKarte, feld, frage, h } from './shop-dom.js'
panel.replaceChildren(infoKarte('Kalender', 'Dieser Bereich wird gerade gebaut.')) 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 = []; 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 (!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)
}
} }
+125 -4
View File
@@ -1,6 +1,127 @@
/* Buchungskalender, Reiter „Unterkünfte & Preise“: Platzhalter (Task 13/14 fuellt ihn). */ /* Buchungskalender, Reiter „Unterkünfte & Preise“: Liste (anlegen, sortieren, aktivieren), Bearbeiten (Stammdaten, Preise, iCal).
import { infoKarte } from './shop-dom.js' API (alle mit ?projectId=): GET /api/unterkunft/unterkuenfte => { unterkuenfte:[{ id, name, beschreibung, maxPersonen, mindestNaechte, checkIn, checkOut, sort, aktiv,
icalImporte, icalExportUrl }] }; POST /unterkuenfte und PUT /unterkuenfte/:id mit { name, beschreibung, maxPersonen, mindestNaechte, checkIn, checkOut, sort, aktiv }
(Antwort { unterkunft:{…} }). Preise: buchungen-preise.js, iCal: buchungen-ical.js. Fremdtext nur ueber textContent. */
export function render(panel) { import { abschnitt, allgemeinerFehler, fehlerKarte, feld, h, hakenFeld, setzeFehler, zeigeFehler } from './shop-dom.js'
panel.replaceChildren(infoKarte('Unterkünfte & Preise', 'Dieser Bereich wird gerade gebaut.')) import { enc, ladeUnterkuenfte, zeichen, zeigeApiFehler } from './buchungen-uk-hilfe.js'
import { zeichnePreise } from './buchungen-preise.js'
import { zeichneIcal } from './buchungen-ical.js'
const ganz = (steuer) => { const t = String(steuer.value).trim(); return /^\d{1,3}$/.test(t) ? Number(t) : null }
const zeit = (t) => /^([01]\d|2[0-3]):[0-5]\d$/.test(t)
export async function render(panel, ctx) {
const q = `projectId=${enc(ctx.projectId)}`
const inhalt = h('div', { class: 'bk-unterkuenfte' })
panel.replaceChildren(inhalt)
await zeigeListe()
async function zeigeListe() {
inhalt.replaceChildren(h('p', { class: 'kb-empty' }, 'Lade…'))
let liste
try {
liste = await ladeUnterkuenfte(ctx)
} catch (err) {
if (ctx.aktuell()) inhalt.replaceChildren(fehlerKarte(err.message, zeigeListe))
return
}
if (!ctx.aktuell()) return
const kopf = h('div', { class: 'bk-aktionen' }, ctx.nurLesen ? null : h('button', { type: 'button', class: 'kb-btn', 'data-aktion': 'neu', onclick: () => zeigeForm(null, liste) }, 'Unterkunft hinzufügen'))
if (!liste.length) {
inhalt.replaceChildren(kopf, h('div', { class: 'kb-panel kb-empty', 'data-leer': true }, 'Noch keine Unterkunft. Lege deine erste Unterkunft an.'))
return
}
inhalt.replaceChildren(kopf, h('ul', { class: 'bk-liste' }, liste.map((u, i) => h('li', { class: 'kb-panel', 'data-unterkunft': u.id },
h('div', { class: 'bk-detail-kopf' },
h('strong', null, zeichen(u.name)),
h('span', { class: `sh-pille sh-pille-${u.aktiv === false ? 'off' : 'ok'}` }, u.aktiv === false ? 'Deaktiviert' : 'Aktiv')),
h('p', { class: 'sh-hinweis' }, `bis ${u.maxPersonen ?? '?'} Personen, ab ${u.mindestNaechte ?? 1} ${u.mindestNaechte === 1 ? 'Nacht' : 'Nächten'}, Check-in ${zeichen(u.checkIn)}, Check-out ${zeichen(u.checkOut)}`),
h('div', { class: 'bk-aktionen' },
h('button', { type: 'button', class: 'kb-btn', 'data-aktion': 'bearbeiten', onclick: () => zeigeForm(u, liste) }, 'Bearbeiten'),
ctx.nurLesen ? null : [
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'hoch', 'aria-label': `${zeichen(u.name)} nach oben`, disabled: i === 0, onclick: () => schiebe(liste, i, -1) }, '↑'),
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'runter', 'aria-label': `${zeichen(u.name)} nach unten`, disabled: i === liste.length - 1, onclick: () => schiebe(liste, i, 1) }, '↓'),
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'umschalten', onclick: () => speichere(u, { aktiv: u.aktiv === false }, 'Gespeichert.') }, u.aktiv === false ? 'Aktivieren' : 'Deaktivieren'),
])))))
}
const koerper = (u, extra = {}) => ({
name: u.name, beschreibung: zeichen(u.beschreibung), maxPersonen: u.maxPersonen, mindestNaechte: u.mindestNaechte, checkIn: u.checkIn, checkOut: u.checkOut, sort: u.sort ?? 0, aktiv: u.aktiv !== false, ...extra,
})
async function speichere(u, extra, meldung) {
try {
await ctx.request(`/api/unterkunft/unterkuenfte/${enc(u.id)}?${q}`, { method: 'PUT', body: koerper(u, extra) })
if (!ctx.aktuell()) return
ctx.zeigeMeldung(meldung, 'ok')
await zeigeListe()
} catch (err) { ctx.zeigeMeldung(err.message, 'fehler') }
}
async function schiebe(liste, i, d) {
const a = liste[i]
const b = liste[i + d]
try {
await ctx.request(`/api/unterkunft/unterkuenfte/${enc(a.id)}?${q}`, { method: 'PUT', body: koerper(a, { sort: i + d }) })
await ctx.request(`/api/unterkunft/unterkuenfte/${enc(b.id)}?${q}`, { method: 'PUT', body: koerper(b, { sort: i }) })
if (ctx.aktuell()) await zeigeListe()
} catch (err) { ctx.zeigeMeldung(err.message, 'fehler') }
}
/** Formular (u = null: neu). Bei einer bestehenden Unterkunft folgen Preise und iCal. */
async function zeigeForm(u, liste) {
const name = h('input', { type: 'text', id: 'bk-u-name', maxlength: '120', autocomplete: 'off' }); name.value = zeichen(u?.name)
const beschr = h('textarea', { id: 'bk-u-beschr', rows: '3', maxlength: '500' }); beschr.value = zeichen(u?.beschreibung)
const maxP = h('input', { type: 'text', inputmode: 'numeric', id: 'bk-u-max' }); maxP.value = String(u?.maxPersonen ?? 2)
const minN = h('input', { type: 'text', inputmode: 'numeric', id: 'bk-u-min' }); minN.value = String(u?.mindestNaechte ?? 1)
const ein = h('input', { type: 'text', id: 'bk-u-ein', placeholder: '15:00', maxlength: '5' }); ein.value = zeichen(u?.checkIn) || '15:00'
const aus = h('input', { type: 'text', id: 'bk-u-aus', placeholder: '10:00', maxlength: '5' }); aus.value = zeichen(u?.checkOut) || '10:00'
const aktiv = h('input', { type: 'checkbox', id: 'bk-u-aktiv' }); aktiv.checked = u ? u.aktiv !== false : true
const form = h('form', { novalidate: true, onsubmit: (ev) => ev.preventDefault() },
feld('name', 'Name der Unterkunft', name), feld('beschreibung', 'Kurzbeschreibung (optional)', beschr),
feld('maxPersonen', 'Höchstens Personen (1 bis 20)', maxP), feld('mindestNaechte', 'Mindestaufenthalt in Nächten (1 bis 90)', minN),
feld('checkIn', 'Check-in ab (HH:MM)', ein), feld('checkOut', 'Check-out bis (HH:MM)', aus),
hakenFeld('aktiv', aktiv, 'Aktiv (für Gäste buchbar)'), allgemeinerFehler(),
h('div', { class: 'bk-aktionen' },
ctx.nurLesen ? null : h('button', { type: 'button', class: 'kb-btn', 'data-aktion': 'speichern', onclick: senden }, u ? 'Speichern' : 'Unterkunft anlegen'),
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'zur-liste', onclick: zeigeListe }, 'Zur Liste')))
if (ctx.nurLesen) for (const el of form.querySelectorAll('input, textarea')) el.disabled = true
const bereiche = h('div', { class: 'bk-bearbeiten' })
inhalt.replaceChildren(abschnitt(u ? `Unterkunft „${zeichen(u.name)}“` : 'Neue Unterkunft', null, form), bereiche)
name.focus()
if (u) {
bereiche.replaceChildren(abschnitt('Preise', 'Preise sind Bruttopreise in Euro, so sieht sie dein Gast.', await zeichnePreise(ctx, u)), zeichneIcal(ctx, u, async (meldung) => {
ctx.zeigeMeldung(meldung, 'ok')
const neu = (await ladeUnterkuenfte(ctx)).find((x) => x.id === u.id)
if (ctx.aktuell()) await zeigeForm(neu || u, liste)
}))
}
async function senden(ev) {
const fehler = {}
const n = String(name.value).trim()
const mp = ganz(maxP)
const mn = ganz(minN)
if (!n) fehler.name = 'Bitte gib einen Namen an.'
if (mp === null || mp < 1 || mp > 20) fehler.maxPersonen = 'Bitte gib eine Zahl von 1 bis 20 an.'
if (mn === null || mn < 1 || mn > 90) fehler.mindestNaechte = 'Bitte gib eine Zahl von 1 bis 90 an.'
if (!zeit(String(ein.value).trim())) fehler.checkIn = 'Bitte gib eine Uhrzeit wie 15:00 an.'
if (!zeit(String(aus.value).trim())) fehler.checkOut = 'Bitte gib eine Uhrzeit wie 10:00 an.'
if (Object.keys(fehler).length) return zeigeFehler(form, { message: '', fehler })
setzeFehler(form, {})
const body = { name: n, beschreibung: String(beschr.value).trim(), maxPersonen: mp, mindestNaechte: mn, checkIn: String(ein.value).trim(), checkOut: String(aus.value).trim(), sort: u?.sort ?? liste.length, aktiv: aktiv.checked }
const knopf = ev.currentTarget
knopf.disabled = true
try {
const antwort = await ctx.request(u ? `/api/unterkunft/unterkuenfte/${enc(u.id)}?${q}` : `/api/unterkunft/unterkuenfte?${q}`, { method: u ? 'PUT' : 'POST', body })
if (!ctx.aktuell()) return
ctx.zeigeMeldung(u ? 'Gespeichert.' : 'Die Unterkunft ist angelegt. Jetzt kannst du die Preise eintragen.', 'ok')
const neu = antwort?.unterkunft?.id ? antwort.unterkunft : null
if (u || !neu) await zeigeListe()
else await zeigeForm({ ...neu }, [...liste, neu])
} catch (err) {
knopf.disabled = false
zeigeApiFehler(form, err)
}
}
}
} }
+25
View File
@@ -0,0 +1,25 @@
/* Buchungskalender: gemeinsame Helfer der Reiter „Kalender“ und „Unterkünfte & Preise“ (Datumsrechnung, Fehlertexte, Unterkunftsliste).
Kalendertage sind Texte YYYY-MM-DD (Berlin); gerechnet wird nur mit UTC-Mitternacht, nie mit der Zeitzone des Browsers. */
import { zeigeFehler } from './shop-dom.js'
import { fehlerFuerFormular } from './buchungen-fehler.js'
export const enc = encodeURIComponent
export const zeichen = (x) => (typeof x === 'string' ? x : '')
export const tagMs = (tag) => { const [j, m, t] = String(tag).split('-').map(Number); return Date.UTC(j, m - 1, t) }
export const msTag = (ms) => new Date(ms).toISOString().slice(0, 10)
export const plusTage = (tag, n) => msTag(tagMs(tag) + n * 86400000)
/** Heute als Berlin-Datum. */
export const heuteBerlin = () => new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' })
/** '2026-11-12' ⇒ '12.11.2026'. */
export const tagText = (tag) => { const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(tag || ''); return m ? `${m[3]}.${m[2]}.${m[1]}` : '' }
/** Fehler am Formular zeigen (Feldfehler am Feld, sonst allgemein); der Fokus geht auf das erste fehlerhafte Feld. */
export function zeigeApiFehler(form, err) {
zeigeFehler(form, fehlerFuerFormular(err))
}
/** Unterkuenfte des Projekts: GET /api/unterkunft/unterkuenfte => { unterkuenfte:[…] }. */
export async function ladeUnterkuenfte(ctx) {
const daten = await ctx.request(`/api/unterkunft/unterkuenfte?projectId=${enc(ctx.projectId)}`)
return (Array.isArray(daten?.unterkuenfte) ? daten.unterkuenfte : []).slice().sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0))
}
+20
View File
@@ -35,3 +35,23 @@
.bk-posten-zeile { grid-template-columns: 1fr 1fr; } .bk-posten-zeile { grid-template-columns: 1fr 1fr; }
.bk-posten-zeile input:first-child { grid-column: 1 / -1; } .bk-posten-zeile input:first-child { grid-column: 1 / -1; }
} }
.bk-kalenderkopf { flex-wrap: wrap; }
.bk-monat { min-width: 9em; text-align: center; }
.bk-raster { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 4px; max-width: 560px; margin: 10px 0; }
.bk-wochentag { text-align: center; font-size: 12px; color: var(--kb-muted); }
.bk-tag { aspect-ratio: 1; min-height: 40px; border-radius: 8px; border: 1px solid var(--kb-border); background: rgba(255, 255, 255, 0.03); color: inherit; font: inherit; cursor: pointer; }
.bk-tag.bk-ausserhalb { opacity: 0.45; }
.bk-tag.bk-heute { border-color: var(--kb-purple-line); font-weight: 700; }
.bk-tag.bk-start { outline: 2px solid #d4b4ff; }
.ist-gebucht { background: rgba(74, 222, 128, 0.28); }
.ist-reserviert { background: rgba(251, 191, 36, 0.28); }
.ist-ical { background: rgba(96, 165, 250, 0.28); }
.ist-manuell { background: rgba(156, 163, 175, 0.35); }
.bk-legende { list-style: none; display: flex; flex-wrap: wrap; gap: 6px 16px; padding: 0; margin: 8px 0; }
.bk-legende li { display: flex; align-items: center; gap: 6px; font-size: 13px; }
.bk-farbe { width: 16px; height: 16px; border-radius: 4px; border: 1px solid var(--kb-border); display: inline-block; }
.bk-staffel { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-bottom: 8px; }
.bk-staffel input { width: 90px; }
.bk-vorschau { font-weight: 700; }
.bk-import { display: flex; align-items: center; gap: 4px 12px; flex-wrap: wrap; }
.bk-anleitung details { margin: 8px 0; }
+205
View File
@@ -164,3 +164,208 @@ test('Storno bei Online-Zahlung: Erstattungsbetrag wird gesendet', mit(server({
await fx.ruhe() await fx.ruhe()
assert.equal(posts(fx)[0].body.erstattungCent, 20000) assert.equal(posts(fx)[0].body.erstattungCent, 20000)
})) }))
// Task 13: Reiter „Kalender“ und „Unterkünfte & Preise“ (Antwortformen: ui-erwartungen.md)
const UK = { id: 'u1', name: 'Haus <i>am</i> See', beschreibung: 'Schön', maxPersonen: 4, mindestNaechte: 2, checkIn: '15:00', checkOut: '10:00', sort: 0, aktiv: true, icalExportUrl: 'https://x.test/unterkunft/ical/tok.ics',
icalImporte: [{ id: 'i1', name: 'Booking.com', url: 'https://b.test/x.ics', letzterErfolgAt: '2026-10-07T10:00:00.000Z', fehler: '' }, { id: 'i2', name: 'Airbnb', url: 'https://a.test/x.ics', fehler: 'Zeitüberschreitung' }] }
const PREISE = { staffeln: [{ abNaechten: 1, preisProNachtCent: 5500 }, { abNaechten: 7, preisProNachtCent: 5000 }], reinigungCent: 6000, personenInklusive: 2, aufpreisPersonNachtCent: 1000, direktrabattProzent: 10, steuerart: 'regel', ustUebernachtung: 7, ustReinigung: 7, fristTage: 3, stornobedingungen: 'Kostenlos bis 14 Tage vorher.' }
const BEREICHE = [
{ von: '2026-11-03', bis: '2026-11-06', quelle: 'gebucht', anfrageNummer: 'A-1' }, { von: '2026-11-10', bis: '2026-11-12', quelle: 'reserviert' },
{ von: '2026-11-15', bis: '2026-11-17', quelle: 'ical:i1' }, { von: '2026-11-20', bis: '2026-11-22', quelle: 'manuell', sperreId: 's1', notiz: 'Eigenbedarf' },
]
const ukServer = (o = {}) => (a) => {
const key = `${a.method} ${a.url.split('?')[0]}`
if (o[key]) return o[key](a)
if (key === 'GET /api/unterkunft/unterkuenfte') return { body: { unterkuenfte: [UK] } }
if (key === 'GET /api/unterkunft/unterkuenfte/u1/kalender') return { body: { bereiche: BEREICHE } }
if (key === 'GET /api/unterkunft/unterkuenfte/u1/preise') return { body: { preise: PREISE } }
if (key === 'POST /api/unterkunft/preisvorschau') return { body: { naechte: 7, posten: [], summen: { bruttoCent: 40650 } } }
return { body: {} }
}
const zeigeTab = async (fx, modul) => {
const ctx = baueCtx()
const panel = h('div')
fx.document.body.append(panel)
await modul.render(panel, ctx)
await fx.ruhe()
return { panel, ctx }
}
const kal = await import('../public/buchungen-tab-kalender.js')
const ukTab = await import('../public/buchungen-tab-unterkuenfte.js')
const tag = (fx, panel, t) => fx.finde(panel, `[data-tag="${t}"]`)
const gehZuNovember = async (fx, panel) => { while (!/November 2026/.test(fx.text(fx.finde(panel, '[data-monat]')))) { await fx.klick(aktion(fx, panel, 'weiter')); await fx.ruhe() } }
test('rasterTage: November 2026 beginnt am Montag 26.10. und endet am Sonntag 29.11.... ', () => {
const t = kal.rasterTage(2026, 10)
assert.equal(t[0], '2026-10-26')
assert.equal(t.length % 7, 0)
assert.equal(t.at(-1), '2026-12-06')
assert.equal(kal.rasterTage(2026, 5)[0], '2026-06-01') // Juni 2026 beginnt am Montag
})
test('Kalender: Raster Montag zuerst, Farben nach Quelle, Legende', mit(ukServer(), async (fx) => {
const { panel } = await zeigeTab(fx, kal)
await gehZuNovember(fx, panel)
assert.equal(fx.text(fx.alle(panel, '[role="columnheader"]')[0]), 'Mo')
assert.equal(fx.alle(panel, '[data-tag]')[0].dataset.tag, '2026-10-26')
assert.ok(tag(fx, panel, '2026-11-03').classList.contains('ist-gebucht'))
assert.ok(!tag(fx, panel, '2026-11-06').classList.contains('ist-gebucht'), 'Abreisetag ist frei')
assert.ok(tag(fx, panel, '2026-11-10').classList.contains('ist-reserviert'))
assert.ok(tag(fx, panel, '2026-11-16').classList.contains('ist-ical'))
assert.ok(tag(fx, panel, '2026-11-20').classList.contains('ist-manuell'))
assert.ok(tag(fx, panel, '2026-11-08').classList.contains('ist-frei'))
for (const t of ['Direkt gebucht', 'Reserviert', 'Booking.com / Airbnb', 'Von dir gesperrt']) assert.match(fx.text(panel), new RegExp(t))
assert.ok(fx.fetchLog.some((a) => /kalender\?projectId=p1&von=2026-10-26&bis=2026-12-07$/.test(a.url)))
}))
test('Kalender: zwei Klicks auf freie Tage → Notiz → POST sperren', mit(ukServer({ 'POST /api/unterkunft/unterkuenfte/u1/sperren': () => ({ status: 201, body: {} }) }), async (fx) => {
const { panel } = await zeigeTab(fx, kal)
await gehZuNovember(fx, panel)
await fx.klick(tag(fx, panel, '2026-11-25'))
assert.equal(posts(fx).length, 0)
await fx.klick(tag(fx, panel, '2026-11-27'))
assert.match(fx.text(panel), /Zeitraum sperren/)
await fx.setze(fx.finde(panel, '#bk-k-notiz'), 'Familie')
await fx.klick(aktion(fx, panel, 'sperren-senden'))
await fx.ruhe()
const [p] = posts(fx)
assert.equal(p.url, '/api/unterkunft/unterkuenfte/u1/sperren?projectId=p1')
assert.deepEqual(p.body, { von: '2026-11-25', bis: '2026-11-28', notiz: 'Familie' })
}))
test('Kalender: Zeitraum über belegte Tage wird nicht angeboten; 409 vom Server wird gezeigt', mit(ukServer({ 'POST /api/unterkunft/unterkuenfte/u1/sperren': () => ({ status: 409, body: { fehler: 'zeitraum_belegt' } }) }), async (fx) => {
const { panel } = await zeigeTab(fx, kal)
await gehZuNovember(fx, panel)
await fx.klick(tag(fx, panel, '2026-11-08'))
await fx.klick(tag(fx, panel, '2026-11-13'))
assert.equal(aktion(fx, panel, 'sperren-senden'), null)
assert.match(fx.text(fx.finde(panel, '[data-hinweis]')), /belegte Tage/)
await fx.klick(tag(fx, panel, '2026-11-24'))
await fx.klick(tag(fx, panel, '2026-11-24'))
await fx.klick(aktion(fx, panel, 'sperren-senden'))
await fx.ruhe()
assert.match(fx.text(panel), /Der Zeitraum ist bereits belegt\./)
}))
test('Kalender: Klick auf manuelle Sperre bietet „Sperre aufheben“ (DELETE); andere Quellen nicht', mit(ukServer({ 'DELETE /api/unterkunft/sperren/s1': () => ({ body: {} }) }), async (fx) => {
const { panel } = await zeigeTab(fx, kal)
await gehZuNovember(fx, panel)
await fx.klick(tag(fx, panel, '2026-11-04'))
assert.equal(aktion(fx, panel, 'aufheben'), null)
await fx.klick(tag(fx, panel, '2026-11-21'))
assert.match(fx.text(panel), /Eigenbedarf/)
await fx.klick(aktion(fx, panel, 'aufheben'))
await fx.ruhe()
const [p] = posts(fx)
assert.equal(p.method, 'DELETE')
assert.equal(p.url, '/api/unterkunft/sperren/s1?projectId=p1')
}))
test('Unterkünfte: Liste mit Fremdtext als Text; Formular hat alle Felder; Speichern sendet PUT', mit(ukServer({ 'PUT /api/unterkunft/unterkuenfte/u1': () => ({ body: { unterkunft: UK } }) }), async (fx) => {
const { panel } = await zeigeTab(fx, ukTab)
assert.match(fx.text(panel), /Haus <i>am<\/i> See/)
assert.equal(fx.finde(panel, 'i'), null)
await fx.klick(aktion(fx, panel, 'bearbeiten'))
await fx.ruhe()
for (const f of ['name', 'beschreibung', 'maxPersonen', 'mindestNaechte', 'checkIn', 'checkOut', 'aktiv']) assert.ok(fx.finde(panel, `[data-feld="${f}"]`), f)
await fx.setze(fx.finde(panel, '#bk-u-max'), '6')
await fx.klick(fx.finde(panel, '.bk-unterkuenfte form [data-aktion="speichern"]'))
await fx.ruhe()
const put = posts(fx).find((a) => a.method === 'PUT')
assert.equal(put.url, '/api/unterkunft/unterkuenfte/u1?projectId=p1')
assert.deepEqual(put.body, { name: UK.name, beschreibung: 'Schön', maxPersonen: 6, mindestNaechte: 2, checkIn: '15:00', checkOut: '10:00', sort: 0, aktiv: true })
}))
test('Unterkünfte: Feldfehler (400 mit felder) kommen am Feld an', mit(ukServer({ 'PUT /api/unterkunft/unterkuenfte/u1': () => ({ status: 400, body: { fehler: 'ungueltig', felder: { name: 'Name ist schon vergeben.' } } }) }), async (fx) => {
const { panel } = await zeigeTab(fx, ukTab)
await fx.klick(aktion(fx, panel, 'bearbeiten'))
await fx.ruhe()
await fx.klick(fx.finde(panel, '.bk-unterkuenfte form [data-aktion="speichern"]'))
await fx.ruhe()
assert.equal(fx.text(fx.finde(panel, '[data-fehler="name"]')), 'Name ist schon vergeben.')
}))
test('Unterkünfte: Neue Unterkunft prüft Felder lokal und sendet POST', mit(ukServer({ 'POST /api/unterkunft/unterkuenfte': () => ({ status: 201, body: { unterkunft: { ...UK, id: 'u2', name: 'Neu' } } }) }), async (fx) => {
const { panel } = await zeigeTab(fx, ukTab)
await fx.klick(aktion(fx, panel, 'neu'))
await fx.klick(aktion(fx, panel, 'speichern'))
assert.match(fx.text(fx.finde(panel, '[data-fehler="name"]')), /Namen/)
assert.equal(posts(fx).length, 0)
await fx.setze(fx.finde(panel, '#bk-u-name'), 'Neu')
await fx.klick(aktion(fx, panel, 'speichern'))
await fx.ruhe()
const [p] = posts(fx)
assert.equal(p.method, 'POST')
assert.equal(p.body.name, 'Neu')
assert.equal(p.body.sort, 1)
}))
test('Preis-Editor: Staffelzeilen +/−, Beispielrechnung über /preisvorschau, Speichern sendet Cent', mit(ukServer({ 'PUT /api/unterkunft/unterkuenfte/u1/preise': () => ({ body: {} }) }), async (fx) => {
const { panel } = await zeigeTab(fx, ukTab)
await fx.klick(aktion(fx, panel, 'bearbeiten'))
await fx.ruhe()
assert.match(fx.text(fx.finde(panel, '[data-vorschau]')), /7 Nächte, 2 Personen = 406,50\s€/)
const vor = fx.fetchLog.find((a) => a.url.startsWith('/api/unterkunft/preisvorschau'))
assert.equal(vor.method, 'POST')
assert.equal(vor.body.unterkunftId, 'u1')
assert.equal(vor.body.personen, 2)
assert.equal(fx.alle(panel, '[data-staffel-zeile]').length, 2)
await fx.klick(aktion(fx, panel, 'staffel-dazu'))
assert.equal(fx.alle(panel, '[data-staffel-zeile]').length, 3)
const neu = fx.alle(panel, '[data-staffel-zeile]')[2]
await fx.setze(fx.finde(neu, '[data-staffel="ab"]'), '14')
await fx.setze(fx.finde(neu, '[data-staffel="preis"]'), '45')
await fx.klick(fx.finde(fx.alle(panel, '[data-staffel-zeile]')[1], '[data-aktion="staffel-weg"]'))
assert.equal(fx.alle(panel, '[data-staffel-zeile]').length, 2)
await fx.klick(aktion(fx, panel, 'preise-speichern'))
await fx.ruhe()
const put = posts(fx).find((a) => a.url.includes('/preise'))
assert.equal(put.method, 'PUT')
assert.deepEqual(put.body.staffeln, [{ abNaechten: 1, preisProNachtCent: 5500 }, { abNaechten: 14, preisProNachtCent: 4500 }])
assert.equal(put.body.reinigungCent, 6000)
assert.equal(put.body.aufpreisPersonNachtCent, 1000)
assert.equal(put.body.steuerart, 'regel')
assert.equal(put.body.direktrabattProzent, 10)
assert.equal(put.body.stornobedingungen, 'Kostenlos bis 14 Tage vorher.')
}))
test('iCal: Status je Import, Fehlertext, Export-Link, Anleitungen', mit(ukServer(), async (fx) => {
const { panel } = await zeigeTab(fx, ukTab)
await fx.klick(aktion(fx, panel, 'bearbeiten'))
await fx.ruhe()
assert.match(fx.text(fx.finde(panel, '[data-import="i1"]')), /Zuletzt abgeglichen 07\.10\.2026/)
assert.match(fx.text(fx.finde(panel, '[data-import="i2"]')), /nicht geklappt: Zeitüberschreitung/)
assert.equal(fx.finde(panel, '[data-export]').getAttribute('value'), UK.icalExportUrl)
assert.ok(aktion(fx, panel, 'export-kopieren'))
assert.match(fx.text(fx.finde(panel, '[data-anleitung="booking"]')), /Unterkunft → Tarife & Verfügbarkeit → Kalender synchronisieren → Kalender importieren → Link einfügen/)
assert.ok(fx.finde(panel, '[data-anleitung="airbnb"]'))
}))
test('iCal: Import hinzufügen (POST), entfernen mit Rückfrage, Link erneuern mit Bestätigung', mit(ukServer({
'POST /api/unterkunft/unterkuenfte/u1/ical-importe': () => ({ status: 201, body: {} }),
'DELETE /api/unterkunft/unterkuenfte/u1/ical-importe/i1': () => ({ body: {} }),
'POST /api/unterkunft/unterkuenfte/u1/ical-export/erneuern': () => ({ body: {} }),
}), async (fx) => {
const { panel } = await zeigeTab(fx, ukTab)
await fx.klick(aktion(fx, panel, 'bearbeiten'))
await fx.ruhe()
await fx.setze(fx.finde(panel, '#bk-i-name'), 'Vrbo')
await fx.setze(fx.finde(panel, '#bk-i-url'), 'http://unsicher.test/x.ics')
const vorher = posts(fx).length
await fx.klick(aktion(fx, panel, 'import-dazu'))
assert.equal(posts(fx).length, vorher, 'nur https')
await fx.setze(fx.finde(panel, '#bk-i-url'), 'https://vrbo.test/x.ics')
await fx.klick(aktion(fx, panel, 'import-dazu'))
await fx.ruhe()
assert.deepEqual(posts(fx).find((a) => a.url.includes('ical-importe')).body, { name: 'Vrbo', url: 'https://vrbo.test/x.ics' })
await fx.klick(fx.finde(fx.finde(panel, '[data-import="i1"]'), '[data-aktion="import-weg"]'))
assert.equal(posts(fx).filter((a) => a.method === 'DELETE').length, 0)
await fx.klick(aktion(fx, panel, 'import-weg-ok'))
await fx.ruhe()
assert.equal(posts(fx).find((a) => a.method === 'DELETE').url, '/api/unterkunft/unterkuenfte/u1/ical-importe/i1?projectId=p1')
await fx.klick(aktion(fx, panel, 'export-erneuern'))
assert.equal(posts(fx).filter((a) => a.url.includes('erneuern')).length, 0)
await fx.klick(aktion(fx, panel, 'erneuern-ok'))
await fx.ruhe()
assert.equal(posts(fx).filter((a) => a.url.includes('erneuern')).length, 1)
}))