diff --git a/public/buchungen-fehler.js b/public/buchungen-fehler.js new file mode 100644 index 0000000..9eab0c3 --- /dev/null +++ b/public/buchungen-fehler.js @@ -0,0 +1,15 @@ +/* Buchungskalender: Fehler der Schnittstelle fuers Formular. Die Schnittstelle antwortet { fehler: '', 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 } } +} diff --git a/public/buchungen-ical.js b/public/buchungen-ical.js new file mode 100644 index 0000000..c5b22ea --- /dev/null +++ b/public/buchungen-ical.js @@ -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) +} diff --git a/public/buchungen-preise.js b/public/buchungen-preise.js new file mode 100644 index 0000000..1254415 --- /dev/null +++ b/public/buchungen-preise.js @@ -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 +} diff --git a/public/buchungen-tab-kalender.js b/public/buchungen-tab-kalender.js index f97bed8..9c6334d 100644 --- a/public/buchungen-tab-kalender.js +++ b/public/buchungen-tab-kalender.js @@ -1,6 +1,185 @@ -/* Buchungskalender, Reiter „Kalender“: Platzhalter (Task 13/14 fuellt ihn). */ -import { infoKarte } from './shop-dom.js' +/* 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. */ -export function render(panel) { - panel.replaceChildren(infoKarte('Kalender', 'Dieser Bereich wird gerade gebaut.')) +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 = []; 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) + } } diff --git a/public/buchungen-tab-unterkuenfte.js b/public/buchungen-tab-unterkuenfte.js index a457ea0..3bfead4 100644 --- a/public/buchungen-tab-unterkuenfte.js +++ b/public/buchungen-tab-unterkuenfte.js @@ -1,6 +1,127 @@ -/* Buchungskalender, Reiter „Unterkünfte & Preise“: Platzhalter (Task 13/14 fuellt ihn). */ -import { infoKarte } from './shop-dom.js' +/* Buchungskalender, Reiter „Unterkünfte & Preise“: Liste (anlegen, sortieren, aktivieren), Bearbeiten (Stammdaten, Preise, iCal). + 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) { - panel.replaceChildren(infoKarte('Unterkünfte & Preise', 'Dieser Bereich wird gerade gebaut.')) +import { abschnitt, allgemeinerFehler, fehlerKarte, feld, h, hakenFeld, setzeFehler, zeigeFehler } from './shop-dom.js' +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) + } + } + } } diff --git a/public/buchungen-uk-hilfe.js b/public/buchungen-uk-hilfe.js new file mode 100644 index 0000000..0eb9b86 --- /dev/null +++ b/public/buchungen-uk-hilfe.js @@ -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)) +} diff --git a/public/buchungen.css b/public/buchungen.css index 566ae9b..6789bec 100644 --- a/public/buchungen.css +++ b/public/buchungen.css @@ -35,3 +35,23 @@ .bk-posten-zeile { grid-template-columns: 1fr 1fr; } .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; } diff --git a/test/buchungenUi.test.js b/test/buchungenUi.test.js index 152055b..8f88022 100644 --- a/test/buchungenUi.test.js +++ b/test/buchungenUi.test.js @@ -164,3 +164,208 @@ test('Storno bei Online-Zahlung: Erstattungsbetrag wird gesendet', mit(server({ await fx.ruhe() 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 am 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 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) +}))