Buchungskalender: Strang ui zusammenführen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Opus 5.5 committed 2026-10-07 14:50:03 +00:00
commit 83cdab405b
18 files changed
+2520 -5

No files matched your search

+136
View File
@@ -0,0 +1,136 @@
/* Buchungskalender im Kundenbereich: die Anfrage der Funktion (Zustand off), Muster shop-anfrage.js.
POST /api/unterkunft/anfrage-funktion?projectId= { art, einheiten, portale, nachricht } => { anfrage } (portale: Liste aus 'booking' | 'airbnb'),
POST /api/unterkunft/anfrage-funktion/zurueckziehen?projectId=. Status im Stand (GET /status): anfrage:{ status:'offen'|'abgelehnt'|'angenommen',
at, begruendung?, anfrage:{ art, einheiten, portale, nachricht } }. Geprueft wird auf dem Server (Feldfehler am Feld). Text nur ueber textContent (h). */
import { allgemeinerFehler, datumText, feld, h, hakenFeld, request, setzeFehler, zeigeFehler } from './shop-dom.js'
import { fehlerFuerFormular } from './buchungen-fehler.js'
export const ARTEN = [['ferienwohnung', 'Ferienwohnung oder Ferienhaus'], ['monteur', 'Monteurwohnung'], ['pension', 'Pension oder Gästezimmer'], ['sonstiges', 'Sonstiges']]
export const PORTALE = [['booking', 'Booking.com'], ['airbnb', 'Airbnb']]
export const KALENDER_PREIS = '199 €'
const text = (wert) => (typeof wert === 'string' ? wert : '')
const labelVon = (liste, key) => liste.find(([k]) => k === key)?.[1] || ''
const enc = encodeURIComponent
/** Angaben der Anfrage als Definitionsliste (nur Text). */
export function anfrageListe(a) {
const s = a && typeof a === 'object' ? a : {}
const portale = (Array.isArray(s.portale) ? s.portale : []).map((p) => labelVon(PORTALE, p)).filter(Boolean).join(', ')
const zeilen = [
['Art der Unterkunft', labelVon(ARTEN, s.art)],
['Anzahl Einheiten', Number.isInteger(s.einheiten) ? String(s.einheiten) : ''],
['Portale', portale || 'keine'],
['Nachricht', text(s.nachricht)],
].filter(([, wert]) => wert !== '')
return h('dl', { class: 'sh-fakten' }, zeilen.map(([name, wert]) => h('div', null, h('dt', null, name), h('dd', null, wert))))
}
/** Formular „Buchungskalender anfragen“ in root; nachSenden(anfrage) wird nach dem Speichern aufgerufen (die Ansicht zeichnet sich neu). */
export function renderAnfrageFormular(root, { projectId, vorgabe = null, preis = KALENDER_PREIS, nachSenden } = {}) {
const v = vorgabe && typeof vorgabe === 'object' ? vorgabe : {}
const art = h('select', null, [h('option', { value: '' }, 'Bitte wählen …'), ...ARTEN.map(([w, n]) => h('option', { value: w }, n))])
art.value = ARTEN.some(([k]) => k === v.art) ? v.art : ''
const einheiten = h('input', { type: 'text', inputmode: 'numeric', maxlength: 3, autocomplete: 'off' })
einheiten.value = Number.isInteger(v.einheiten) ? String(v.einheiten) : ''
const portale = PORTALE.map(([wert]) => {
const box = h('input', { type: 'checkbox', value: wert })
box.checked = Array.isArray(v.portale) && v.portale.includes(wert)
return box
})
const nachricht = h('textarea', { rows: 3, maxlength: 1000 })
nachricht.value = text(v.nachricht)
const absenden = h('button', { type: 'submit', class: 'kb-btn-primary', 'data-aktion': 'anfrage-senden' }, 'Anfrage senden')
const form = h('form', { class: 'sh-form', novalidate: true, 'aria-label': 'Buchungskalender anfragen' },
feld('art', 'Welche Art von Unterkunft vermietest du?', art),
feld('einheiten', 'Wie viele Einheiten (Wohnungen, Zimmer) vermietest du?', einheiten),
h('fieldset', { class: 'sh-gruppe' },
h('legend', null, 'Nutzt du Booking.com oder Airbnb?'),
...PORTALE.map(([wert, name], i) => hakenFeld(`portale.${wert}`, portale[i], name)),
h('p', { class: 'sh-fehler', 'data-fehler': 'portale', role: 'alert' })),
feld('nachricht', 'Noch etwas, das wir wissen sollten? (freiwillig)', nachricht),
allgemeinerFehler(),
h('div', { class: 'sh-aktionen' }, absenden))
let laeuft = false
form.addEventListener('submit', async (ev) => {
ev.preventDefault()
if (laeuft) return
laeuft = true
absenden.disabled = true
setzeFehler(form, {})
const zahl = einheiten.value.trim()
try {
const antwort = await request(`/api/unterkunft/anfrage-funktion?projectId=${enc(projectId)}`, {
method: 'POST',
body: {
art: art.value,
einheiten: /^\d{1,3}$/.test(zahl) ? Number(zahl) : zahl,
portale: portale.filter((b) => b.checked).map((b) => b.value),
nachricht: nachricht.value.trim(),
},
})
if (typeof nachSenden === 'function') await nachSenden(antwort?.anfrage ?? null)
return
} catch (err) {
zeigeFehler(form, fehlerFuerFormular(err))
if (err?.status === 409 && typeof nachSenden === 'function') {
try { await nachSenden(null, { meldung: fehlerFuerFormular(err).message }) } catch { /* der Fehler steht schon am Formular */ }
return
}
}
laeuft = false
absenden.disabled = false
})
const erklaerung = h('div', { class: 'sh-erklaerung' },
h('h3', null, 'Buchungskalender anfragen'),
h('p', null, 'Mit dem Buchungskalender zeigst du auf deiner Website, wann deine Unterkunft frei ist. Gäste fragen direkt an, du gibst frei, und die Rechnung entsteht automatisch.'),
h('ul', null,
h('li', null, `${preis} einmalig für den Einbau in deine Website.`),
h('li', null, 'Belegungskalender mit Abgleich zu Booking.com und Airbnb (über iCal).'),
h('li', null, 'Rechnung in deinem Namen und optional Online-Zahlung auf dein eigenes Stripe-Konto.'),
h('li', null, 'Wir sehen uns deine Anfrage kurz an. Erst nach unserer Zusage kannst du bestellen.')))
const karte = h('section', { class: 'kb-panel sh-anfrage' }, erklaerung, form)
root.replaceChildren(karte)
return karte
}
/** Karte einer Anfrage im Stand offen (mit „Anfrage zurückziehen“) oder abgelehnt (Begründung, „Neue Anfrage stellen“). */
export function anfrageKarte(ansicht, { projectId, neu, neuLaden } = {}) {
const offen = ansicht?.status === 'offen'
const meldung = h('p', { class: 'sh-fehler', role: 'alert', 'data-meldung': 'anfrage' })
const datum = datumText(ansicht?.at)
let aktion
if (offen) {
aktion = h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'zurueckziehen' }, 'Anfrage zurückziehen')
aktion.addEventListener('click', async () => {
if (aktion.disabled) return
aktion.disabled = true
meldung.textContent = ''
try {
await request(`/api/unterkunft/anfrage-funktion/zurueckziehen?projectId=${enc(projectId)}`, { method: 'POST' })
if (typeof neuLaden === 'function') await neuLaden()
return
} catch (err) {
meldung.textContent = err.message
if (err?.status === 409 && typeof neuLaden === 'function') { try { await neuLaden() } catch { /* Meldung bleibt */ } return }
}
aktion.disabled = false
})
} else {
aktion = h('button', { type: 'button', class: 'kb-btn-primary', 'data-aktion': 'neue-anfrage', onclick: () => { if (typeof neu === 'function') neu() } }, 'Neue Anfrage stellen')
}
return h('section', { class: 'kb-panel sh-info sh-anfrage-karte', 'data-anfrage': offen ? 'offen' : 'abgelehnt' },
h('span', { class: `sh-pille ${offen ? 'sh-pille-wait' : 'sh-pille-bad'}` }, offen ? 'In Prüfung' : 'Abgelehnt'),
h('h3', null, offen ? 'Deine Anfrage wird geprüft' : 'Deine Anfrage wurde leider abgelehnt'),
offen
? h('p', null, `${datum ? `Du hast deine Anfrage am ${datum} Uhr gesendet. ` : ''}Wir melden uns, sobald wir sie angesehen haben. Bis dahin kannst du sie zurückziehen und eine neue stellen.`)
: h('p', null, 'Unsere Begründung:'),
!offen ? h('blockquote', { class: 'sh-begruendung' }, text(ansicht?.begruendung) || 'Dazu liegt keine Begründung vor.') : null,
ansicht?.anfrage ? h('div', { class: 'sh-zusammenfassung' }, h('h4', null, 'Deine Angaben'), anfrageListe(ansicht.anfrage)) : null,
meldung,
h('div', { class: 'sh-aktionen' }, aktion, h('button', { type: 'button', class: 'kb-btn-outline', 'data-view': 'chat' }, 'Frage dazu? Chat öffnen')))
}
+17
View File
@@ -0,0 +1,17 @@
/* 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 STANDARD = 'Das hat nicht geklappt. Bitte versuch es noch einmal.'
const code = typeof err?.fehler === 'string' ? err.fehler : ''
const message = (code && CODES[code]) || (code ? STANDARD : err?.message) || STANDARD
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)
}
+109
View File
@@ -0,0 +1,109 @@
/* Buchungskalender: Posten-Editor fuer die Freigabe einer Anfrage.
summeLive(posten, steuerart) ist ein Spiegel von server/services/unterkunft/preis.js summiere (gleiche Rundung): Anzeige waehrend des Tippens,
verbindlich rechnet der Server. Posten: { text, menge, einzelCent (negativ bei Rabatt), ustProzent }. */
import { euroText, euroZuCent, h } from './shop-dom.js'
export function summeLive(posten, steuerart) {
const bruttoCent = posten.reduce((a, p) => a + p.menge * p.einzelCent, 0)
if (steuerart === 'kleinunternehmer') return { bruttoCent, nettoCent: bruttoCent, ust: [] }
const proSatz = new Map()
for (const p of posten) proSatz.set(p.ustProzent, (proSatz.get(p.ustProzent) || 0) + p.menge * p.einzelCent)
const ust = [...proSatz.entries()]
.sort((a, b) => a[0] - b[0])
.map(([satz, brutto]) => {
const nettoCent = Math.round((brutto * 100) / (100 + satz))
return { satz, nettoCent, steuerCent: brutto - nettoCent }
})
const nettoCent = ust.reduce((a, u) => a + u.nettoCent, 0)
return { bruttoCent, nettoCent, ust }
}
/** Betrag mit Vorzeichen (Rabatt) in Euro-Schreibweise: -3850 ⇒ '-38,50 €'. */
export const euroMitVorzeichen = (cent) => (Number.isInteger(cent) ? (cent < 0 ? `-${euroText(-cent)}` : euroText(cent)) : '')
/** Feldtext ('-38,50', '−38,50 €', '12,5') in Cent mit Vorzeichen; null bei allem Unlesbaren. */
export function euroZuCentMitVorzeichen(text) {
if (typeof text !== 'string') return null
const s = text.trim()
const minus = /^[-−–]/.test(s)
const c = euroZuCent(minus ? s.slice(1) : s)
return c === null ? null : minus ? -c : c
}
const centFeld = (cent) => (Number.isInteger(cent) ? `${cent < 0 ? '-' : ''}${Math.floor(Math.abs(cent) / 100)},${String(Math.abs(cent) % 100).padStart(2, '0')}` : '')
/**
* Editor bauen. => { el, posten() => [{text,menge,einzelCent,ustProzent}] oder null bei Unlesbarem, summe() }.
* steuerart 'kleinunternehmer': keine USt-Spalte (ustProzent 0). aenderung() wird nach jeder Eingabe gerufen.
*/
export function baueEditor(start, steuerart, { aenderung } = {}) {
const klein = steuerart === 'kleinunternehmer'
const zeilen = []
const liste = h('div', { class: 'bk-posten' })
const summenEl = h('div', { class: 'bk-summen', 'data-summen': true, 'aria-live': 'polite' })
const lies = () => {
const ergebnis = []
for (const z of zeilen) {
const einzelCent = euroZuCentMitVorzeichen(String(z.einzel.value))
const menge = Number(String(z.menge.value).trim())
if (einzelCent === null || !Number.isInteger(menge) || menge < 1) return null
ergebnis.push({ text: String(z.text.value).trim(), menge, einzelCent, ustProzent: klein ? 0 : Number(z.ust.value) })
}
return ergebnis
}
const zeichneSumme = () => {
const p = lies()
if (!p) {
summenEl.replaceChildren(h('p', { class: 'sh-fehler' }, 'Bitte prüfe Menge und Preis der Posten.'))
return
}
const s = summeLive(p, steuerart)
summenEl.replaceChildren(
...(klein ? [] : [h('p', null, `Netto ${euroMitVorzeichen(s.nettoCent)}`), ...s.ust.map((u) => h('p', null, `${u.satz} % USt ${euroMitVorzeichen(u.steuerCent)}`))]),
h('p', { class: 'bk-gesamt' }, h('strong', null, `Gesamt ${euroMitVorzeichen(s.bruttoCent)}`)),
klein ? h('p', { class: 'sh-hinweis' }, 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.') : null)
}
const geaendert = () => {
zeichneSumme()
aenderung?.()
}
function fuegeZeile(p = { text: '', menge: 1, einzelCent: 0, ustProzent: 7 }) {
const z = {
text: h('input', { type: 'text', maxlength: '200', 'aria-label': 'Beschreibung', 'data-posten': 'text' }),
menge: h('input', { type: 'text', inputmode: 'numeric', 'aria-label': 'Menge', 'data-posten': 'menge' }),
einzel: h('input', { type: 'text', inputmode: 'decimal', autocomplete: 'off', 'aria-label': 'Einzelpreis in Euro (Rabatt mit Minus)', 'data-posten': 'einzel' }),
ust: klein ? null : h('select', { 'aria-label': 'Umsatzsteuer', 'data-posten': 'ust' }, [7, 19, 0].map((satz) => h('option', { value: String(satz) }, `${satz} %`))),
}
z.text.value = p.text
z.menge.value = String(p.menge)
z.einzel.value = centFeld(p.einzelCent)
if (z.ust) z.ust.value = String(p.ustProzent)
for (const el of [z.text, z.menge, z.einzel, z.ust]) el?.addEventListener('input', geaendert)
z.ust?.addEventListener('change', geaendert)
z.einzel.addEventListener('change', () => {
const c = euroZuCentMitVorzeichen(String(z.einzel.value))
if (c !== null) z.einzel.value = centFeld(c)
})
z.zeile = h('div', { class: 'bk-posten-zeile', 'data-zeile': true },
z.text, z.menge, z.einzel, z.ust,
h('button', {
type: 'button', class: 'kb-btn-outline', 'data-aktion': 'posten-weg', 'aria-label': 'Posten entfernen',
onclick: () => {
zeilen.splice(zeilen.indexOf(z), 1)
z.zeile.remove()
geaendert()
},
}, 'Entfernen'))
zeilen.push(z)
liste.append(z.zeile)
}
for (const p of start) fuegeZeile(p)
const kopf = h('div', { class: 'bk-posten-kopf', 'aria-hidden': 'true' },
h('span', null, 'Beschreibung'), h('span', null, 'Menge'), h('span', null, 'Einzelpreis €'), klein ? null : h('span', null, 'USt'), h('span'))
const el = h('div', { class: 'bk-editor' }, kopf, liste,
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'posten-neu', onclick: () => { fuegeZeile(); geaendert() } }, 'Posten hinzufügen'),
summenEl)
zeichneSumme()
return { el, posten: lies, summe: () => { const p = lies(); return p ? summeLive(p, steuerart) : null } }
}
+154
View File
@@ -0,0 +1,154 @@
/* 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'
export const STORNO_MUSTER = 'Stornierung bis 14 Tage vor Anreise: kostenlos. Stornierung 13 bis 3 Tage vor Anreise: 50 % des Gesamtpreises. Stornierung ab 2 Tage vor Anreise oder Nichtanreise: 90 % des Gesamtpreises. Die Stornierung bedarf der Textform (z. B. E-Mail). Maßgeblich ist der Eingang bei uns.'
export const STORNO_HINWEIS = 'Vorlage – bitte an deine Bedingungen anpassen, keine Rechtsberatung.'
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.'),
ctx.nurLesen ? null : h('div', { class: 'bk-aktionen' },
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'storno-muster', onclick: () => { storno.value = STORNO_MUSTER } }, 'Mustertext übernehmen'),
h('p', { class: 'sh-hinweis', 'data-hinweis': 'storno-muster' }, STORNO_HINWEIS)),
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 = []
let staffelFehler = ''
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) staffelFehler ||= 'Bitte gib bei jeder Staffel Nächte (ganze Zahl) und einen Preis an.'
else if (p < 100 || p > 100000) staffelFehler ||= 'Der Preis pro Nacht muss zwischen 1,00 € und 1.000,00 € liegen.'
else staffeln.push({ abNaechten: ab, preisProNachtCent: p })
}
if (!staffelFehler) {
if (staffeln.length < 1 || staffeln.length > 6) staffelFehler = 'Du kannst 1 bis 6 Staffeln anlegen.'
else if (staffeln[0].abNaechten !== 1) staffelFehler = 'Die erste Staffel beginnt bei 1 Nacht.'
else if (staffeln.some((s, i) => i > 0 && s.abNaechten <= staffeln[i - 1].abNaechten)) staffelFehler = 'Die Nächte der Staffeln müssen von oben nach unten größer werden, keine Zahl doppelt.'
}
if (staffelFehler) fehler.staffeln = staffelFehler
const zahl = (name, steuer, text, min, max) => { const n = ganzzahl(steuer); if (n === null || n < min || n > max) { fehler[name] = text; return null } 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 regel = steuerart.value === 'regel'
const ustWert = (name, steuer, alt) => {
if (!regel) return ganzzahl(steuer) ?? alt // andere Steuerart: gespeicherten Wert unveraendert zurueckschicken
const n = ganzzahl(steuer)
if (![0, 7, 19].includes(n)) { fehler[name] = 'Erlaubt sind 0, 7 oder 19 Prozent.'; return null }
return n
}
const storn = String(storno.value).trim()
if (storn.length < 20 || storn.length > 5000) fehler.stornobedingungen = 'Die Stornobedingungen brauchen 20 bis 5000 Zeichen.'
const body = {
staffeln, reinigungCent, aufpreisPersonNachtCent: aufpreisCent,
personenInklusive: zahl('personenInklusive', inklusive, 'Bitte gib eine ganze Zahl ab 1 an.', 1, 1000),
direktrabattProzent: zahl('direktrabattProzent', rabatt, 'Bitte gib eine ganze Zahl von 0 bis 50 an.', 0, 50),
steuerart: steuerart.value,
ustUebernachtung: ustWert('ustUebernachtung', ustUe, preise.ustUebernachtung ?? 7),
ustReinigung: ustWert('ustReinigung', ustRe, preise.ustReinigung ?? 7),
fristTage: zahl('fristTage', frist, 'Bitte gib eine ganze Zahl von 1 bis 14 an.', 1, 14),
stornobedingungen: storn,
}
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
}
+301
View File
@@ -0,0 +1,301 @@
/* Buchungskalender, Reiter „Anfragen“: Filter (offen | gebucht | alle), Liste, Detail.
API (docs/funktionen/unterkunft-api.md, alle mit ?projectId=):
GET /api/unterkunft/anfragen?filter= => { anfragen:[{ id, nummer, von, bis, naechte?, gast:{name}, status, summen:{bruttoCent}, unterkunftName? }] }
GET /api/unterkunft/anfragen/:id => { anfrage:{ id, nummer, von, bis, personen, status, gast:{name,firma,email,telefon,nachricht?}, posten, summen,
steuerart, zahlart?, bezahltVia?, erstattetCent?, rechnungId?, rechnungNummer?, fristAt?, unterkunftName? }, vorschlag:{ naechte, posten, summen } }
POST …/freigeben {posten,fristTage,nachricht} (=> { buchungsLink? }), …/ablehnen {text}, …/bezahlt, …/stornieren {grund,erstattungCent}.
Fremdtext (Gast, Nachricht) nur ueber textContent. render(panel, ctx) wie shop-tab-*.js. */
import { abschnitt, allgemeinerFehler, centZuEuro, euroZuCent, fehlerKarte, feld, frage, h, zeigeFehler, datumText } from './shop-dom.js'
import { fehlerFuerFormular } from './buchungen-fehler.js'
import { baueEditor, euroMitVorzeichen } from './buchungen-posten.js'
export const STATUS = {
neu: ['Neu', 'wait'],
freigegeben: ['Freigegeben – wartet auf Buchung', 'wait'],
gebucht: ['Gebucht', 'ok'],
bezahlt: ['Bezahlt', 'ok'],
abgelehnt: ['Abgelehnt', 'bad'],
abgelaufen: ['Abgelaufen', 'off'],
storniert: ['Storniert', 'bad'],
}
const FILTER = [['offen', 'Offen'], ['gebucht', 'Gebucht'], ['alle', 'Alle']]
const FEHLER_LADEN = 'Das konnte nicht geladen werden. Bitte versuch es noch einmal.'
const BELEGT = 'Der Zeitraum ist inzwischen belegt.'
const zeichen = (x) => (typeof x === 'string' ? x : '')
const enc = encodeURIComponent
const pille = (status) => {
const [text, art] = STATUS[status] || [zeichen(status) || 'Unbekannt', 'off']
return h('span', { class: `sh-pille sh-pille-${art}`, 'data-status': status }, text)
}
/** '2026-11-12','2026-11-30' ⇒ '12.–30.11.2026'; über Monats-/Jahresgrenze '28.10.–03.11.2026' bzw. '28.12.2026–03.01.2027'. */
export function zeitraumText(von, bis) {
const m = /^(\d{4})-(\d{2})-(\d{2})$/
const a = m.exec(von || '')
const b = m.exec(bis || '')
if (!a || !b) return ''
if (a[1] !== b[1]) return `${a[3]}.${a[2]}.${a[1]}–${b[3]}.${b[2]}.${b[1]}`
if (a[2] !== b[2]) return `${a[3]}.${a[2]}.–${b[3]}.${b[2]}.${b[1]}`
return `${a[3]}.–${b[3]}.${b[2]}.${b[1]}`
}
export function naechteText(von, bis) {
const ms = (t) => Date.UTC(...t.split('-').map((x, i) => (i === 1 ? Number(x) - 1 : Number(x))))
const n = Math.round((ms(bis) - ms(von)) / 86400000)
return Number.isFinite(n) ? `${n} ${n === 1 ? 'Nacht' : 'Nächte'}` : ''
}
const gastName = (gast) => (typeof gast === 'string' ? gast : zeichen(gast?.name))
export async function render(panel, ctx) {
const st = { filter: 'offen', lauf: 0 }
const kopf = h('div', { class: 'bk-filter' }, h('label', { for: 'bk-a-filter' }, 'Anzeigen'),
h('select', { id: 'bk-a-filter', 'data-aktion': 'filter' }, FILTER.map(([wert, text]) => h('option', { value: wert }, text))))
const inhalt = h('div', { class: 'bk-anfragen' })
panel.replaceChildren(kopf, inhalt)
const select = kopf.querySelector('select')
select.value = st.filter
select.addEventListener('change', () => { st.filter = select.value; zeigeListe() })
await zeigeListe()
async function zeigeListe() {
const lauf = ++st.lauf
const gueltig = () => st.lauf === lauf && ctx.aktuell()
inhalt.replaceChildren(h('p', { class: 'kb-empty' }, 'Lade…'))
let daten
try {
daten = await ctx.request(`/api/unterkunft/anfragen?projectId=${enc(ctx.projectId)}&filter=${enc(st.filter)}`)
} catch (err) {
if (gueltig()) inhalt.replaceChildren(fehlerKarte(err.message || FEHLER_LADEN, zeigeListe))
return
}
if (!gueltig()) return
const zeilen = Array.isArray(daten?.anfragen) ? daten.anfragen : []
if (!zeilen.length) {
inhalt.replaceChildren(h('div', { class: 'kb-panel kb-empty', 'data-leer': true }, st.filter === 'offen' ? 'Keine offenen Anfragen.' : 'Keine Anfragen.'))
return
}
inhalt.replaceChildren(h('ul', { class: 'bk-liste' }, zeilen.map((a) => h('li', null, h('button', {
type: 'button', class: 'bk-zeile', 'data-anfrage': a.id, onclick: () => zeigeDetail(a.id),
},
h('strong', { class: 'bk-nummer' }, zeichen(a.nummer)),
h('span', { class: 'bk-zeitraum' }, `${zeitraumText(a.von, a.bis)} · ${naechteText(a.von, a.bis)}`),
h('span', { class: 'bk-gast' }, gastName(a.gast)),
pille(a.status),
h('span', { class: 'bk-betrag' }, Number.isInteger(a.summen?.bruttoCent) ? euroMitVorzeichen(a.summen.bruttoCent) : ''))))))
}
async function zeigeDetail(id) {
const lauf = ++st.lauf
const gueltig = () => st.lauf === lauf && ctx.aktuell()
inhalt.replaceChildren(h('p', { class: 'kb-empty' }, 'Lade…'))
let daten
try {
daten = await ctx.request(`/api/unterkunft/anfragen/${enc(id)}?projectId=${enc(ctx.projectId)}`)
} catch (err) {
if (gueltig()) inhalt.replaceChildren(zurueck(), fehlerKarte(err.message || FEHLER_LADEN, () => zeigeDetail(id)))
return
}
if (!gueltig()) return
if (!daten?.anfrage) {
inhalt.replaceChildren(zurueck(), fehlerKarte(FEHLER_LADEN, () => zeigeDetail(id)))
return
}
inhalt.replaceChildren(zurueck(), baueDetail(daten.anfrage, daten.vorschlag))
}
const zurueck = () => h('button', { type: 'button', class: 'kb-btn-outline bk-zurueck', 'data-aktion': 'zurueck', onclick: () => zeigeListe() }, '‹ Zur Liste')
function baueDetail(a, vorschlag) {
const gast = a.gast && typeof a.gast === 'object' ? a.gast : { name: zeichen(a.gast) }
const kopfZeile = h('div', { class: 'bk-detail-kopf' },
h('h3', null, `Anfrage ${zeichen(a.nummer)}`), pille(a.status))
const daten = h('dl', { class: 'bk-daten' },
...[
['Unterkunft', zeichen(a.unterkunftName)],
['Zeitraum', `${zeitraumText(a.von, a.bis)} · ${naechteText(a.von, a.bis)}`],
['Personen', Number.isInteger(a.personen) ? String(a.personen) : ''],
['Gast', gastName(gast)],
['Firma', zeichen(gast.firma)],
['E-Mail', zeichen(gast.email)],
['Telefon', zeichen(gast.telefon)],
['Nachricht', zeichen(gast.nachricht)],
['Frist', a.fristAt && a.status === 'freigegeben' ? datumText(a.fristAt) : ''],
].filter(([, w]) => w).flatMap(([n, w]) => [h('dt', null, n), h('dd', null, w)]))
const karte = h('section', { class: 'kb-panel sh-abschnitt bk-detail', 'data-detail': a.id }, kopfZeile, daten)
if (a.status === 'neu') karte.append(neuBereich(a, vorschlag))
else karte.append(festerBereich(a))
return karte
}
/* ---------------------------------------------- Status neu: Freigeben / Ablehnen --- */
function neuBereich(a, vorschlag) {
const steuerart = a.steuerart || vorschlag?.steuerart || ctx.status?.steuerart || 'regelbesteuert'
const startPosten = Array.isArray(vorschlag?.posten) ? vorschlag.posten : []
const editor = baueEditor(startPosten, steuerart)
const frist = h('input', { type: 'text', inputmode: 'numeric', id: 'bk-a-frist' })
frist.value = '7'
const nachricht = h('textarea', { id: 'bk-a-nachricht', rows: '3', maxlength: '2000' })
const form = h('form', { class: 'bk-freigabe', novalidate: true, onsubmit: (ev) => ev.preventDefault() },
h('h4', null, 'Angebot für den Gast'),
editor.el,
h('p', { class: 'sh-fehler', 'data-fehler': 'posten', role: 'alert' }),
feld('fristTage', 'Frist zum Buchen (Tage)', frist, 'So lange ist der Zeitraum für den Gast reserviert.'),
feld('nachricht', 'Nachricht an den Gast (optional)', nachricht),
allgemeinerFehler())
const freigeben = h('button', {
type: 'button', class: 'kb-btn', 'data-aktion': 'freigeben',
onclick: () => sendeFreigabe(a, editor, frist, nachricht, form, freigeben),
}, 'Freigeben')
const ablehnen = h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'ablehnen', onclick: () => ablehnDialog(a, ablehnen) }, 'Ablehnen')
return h('div', null, form, h('div', { class: 'bk-aktionen' }, freigeben, ablehnen), h('div', { class: 'bk-dialog' }))
}
async function sendeFreigabe(a, editor, frist, nachricht, form, knopf) {
const posten = editor.posten()
const tage = Number(String(frist.value).trim())
if (!posten) return zeigeFehler(form, { message: '', fehler: { posten: 'Bitte prüfe Menge und Preis der Posten.' } })
if (!Number.isInteger(tage) || tage < 1) return zeigeFehler(form, { message: '', fehler: { fristTage: 'Bitte gib die Frist in ganzen Tagen an.' } })
knopf.disabled = true
try {
const antwort = await ctx.request(`/api/unterkunft/anfragen/${enc(a.id)}/freigeben?projectId=${enc(ctx.projectId)}`, {
method: 'POST', body: { posten, fristTage: tage, nachricht: String(nachricht.value).trim() },
})
if (!ctx.aktuell()) return
ctx.zeigeMeldung('Freigegeben. Der Buchungslink wurde an deinen Gast geschickt – falls die Mail nicht ankommt, kannst du ihn hier kopieren.', 'ok')
zeigeLink(antwort?.buchungsLink, a)
} catch (err) {
knopf.disabled = false
if (err.status === 409) zeigeFehler(form, { message: BELEGT, fehler: { _: BELEGT } })
else zeigeFehler(form, fehlerFuerFormular(err))
}
}
/** Nach „Freigeben“: der Buchungslink zum Kopieren, falls die Mail nicht ankommt (nur wenn die Antwort ihn enthaelt). */
function zeigeLink(link, a) {
const box = h('section', { class: 'kb-panel sh-abschnitt bk-link', 'data-freigegeben': a.id },
h('h3', null, `Anfrage ${zeichen(a.nummer)} ist freigegeben`),
h('p', null, 'Der Link wurde per Mail verschickt. Falls er nicht ankommt, kopiere ihn hier und schick ihn selbst.'))
if (typeof link === 'string' && link) {
const meldung = h('p', { class: 'sh-hinweis', role: 'status' })
box.append(
h('input', { type: 'text', readonly: true, value: link, 'aria-label': 'Buchungslink', 'data-link': true }),
h('button', {
type: 'button', class: 'kb-btn-outline', 'data-aktion': 'link-kopieren',
onclick: async () => {
try {
await navigator.clipboard.writeText(link)
meldung.textContent = 'Link kopiert.'
} catch {
meldung.textContent = 'Kopieren hat nicht geklappt. Markiere den Link und kopiere ihn von Hand.'
}
},
}, 'Link kopieren'),
meldung)
}
box.append(h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'zurueck', onclick: () => zeigeListe() }, 'Zur Liste'))
inhalt.replaceChildren(box)
}
function ablehnDialog(a, ausloeser) {
const dialogEl = inhalt.querySelector('.bk-dialog')
const text = h('textarea', { id: 'bk-a-ablehn', rows: '3', maxlength: '1000' })
const box = frage('Anfrage ablehnen', h('p', { class: 'sh-hinweis' }, 'Der Gast bekommt deinen Text per Mail.'), feld('text', 'Nachricht an den Gast', text), allgemeinerFehler(),
h('div', { class: 'bk-aktionen' },
h('button', {
type: 'button', class: 'kb-btn', 'data-aktion': 'ablehnen-senden',
onclick: async (ev) => {
ev.currentTarget.disabled = true
try {
await ctx.request(`/api/unterkunft/anfragen/${enc(a.id)}/ablehnen?projectId=${enc(ctx.projectId)}`, { method: 'POST', body: { text: String(text.value).trim() } })
if (!ctx.aktuell()) return
ctx.zeigeMeldung('Anfrage abgelehnt.', 'ok')
await zeigeListe()
} catch (err) {
ev.currentTarget.disabled = false
zeigeFehler(box, fehlerFuerFormular(err))
}
},
}, 'Ablehnen'),
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'abbrechen', onclick: () => { dialogEl.replaceChildren(); ausloeser.focus() } }, 'Abbrechen')))
dialogEl.replaceChildren(box)
text.focus()
}
/* ---------------------------------------------- weitere Status: Posten, Rechnung, Bezahlt, Storno --- */
function festerBereich(a) {
const posten = Array.isArray(a.posten) ? a.posten : []
const teile = []
if (posten.length) {
teile.push(h('table', { class: 'bk-tabelle' }, h('tbody', null, posten.map((p) => h('tr', null,
h('td', null, zeichen(p.text)), h('td', null, `${p.menge} ×`), h('td', null, euroMitVorzeichen(p.einzelCent)))))))
}
if (Number.isInteger(a.summen?.bruttoCent)) teile.push(h('p', { class: 'bk-gesamt' }, h('strong', null, `Gesamt ${euroMitVorzeichen(a.summen.bruttoCent)}`)))
const aktionen = h('div', { class: 'bk-aktionen' })
const dialogEl = h('div', { class: 'bk-dialog' })
const aktiv = a.status === 'gebucht' || a.status === 'bezahlt'
if (aktiv && a.rechnungId) {
teile.push(h('p', null, h('a', {
href: `/api/unterkunft/rechnungen/${enc(a.rechnungId)}.pdf?projectId=${enc(ctx.projectId)}`, target: '_blank', rel: 'noopener', 'data-aktion': 'rechnung',
}, `Rechnung ${zeichen(a.rechnungNummer)}`.trim() + ' (PDF)')))
}
if (a.status === 'gebucht') {
aktionen.append(h('button', { type: 'button', class: 'kb-btn', 'data-aktion': 'bezahlt', onclick: (ev) => markiereBezahlt(a, ev.currentTarget) }, 'Als bezahlt (vor Ort) markieren'))
}
if (aktiv) {
const stornoKnopf = h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'stornieren', onclick: () => stornoDialog(a, dialogEl, stornoKnopf) }, 'Stornieren')
aktionen.append(stornoKnopf)
}
return h('div', null, ...teile, aktionen.childNodes.length ? aktionen : null, dialogEl)
}
async function markiereBezahlt(a, knopf) {
knopf.disabled = true
try {
await ctx.request(`/api/unterkunft/anfragen/${enc(a.id)}/bezahlt?projectId=${enc(ctx.projectId)}`, { method: 'POST' })
if (!ctx.aktuell()) return
ctx.zeigeMeldung('Als bezahlt markiert.', 'ok')
await zeigeDetail(a.id)
} catch (err) {
knopf.disabled = false
ctx.zeigeMeldung(err.message, 'fehler')
}
}
function stornoDialog(a, dialogEl, ausloeser) {
const online = a.bezahltVia === 'stripe'
const grund = h('textarea', { id: 'bk-a-grund', rows: '3', maxlength: '500' })
const betrag = h('input', { type: 'text', inputmode: 'decimal', id: 'bk-a-erstattung', autocomplete: 'off' })
betrag.value = Number.isInteger(a.summen?.bruttoCent) ? centZuEuro(a.summen.bruttoCent - (a.erstattetCent || 0)) : ''
const box = frage('Buchung stornieren', h('p', { class: 'sh-hinweis' }, 'Der Gast bekommt eine Stornomail und eine Storno-Rechnung.'),
feld('grund', 'Grund', grund),
online ? feld('erstattungCent', 'Erstattung in Euro', betrag, 'Der Betrag geht an die Karte des Gastes zurück. 0 = keine Erstattung.') : null,
allgemeinerFehler(),
h('div', { class: 'bk-aktionen' },
h('button', {
type: 'button', class: 'kb-btn', 'data-aktion': 'storno-senden',
onclick: async (ev) => {
const knopf = ev.currentTarget
let erstattungCent = 0
if (online) {
erstattungCent = String(betrag.value).trim() === '' ? 0 : euroZuCent(String(betrag.value))
if (erstattungCent === null) return zeigeFehler(box, { message: '', fehler: { erstattungCent: 'Bitte gib einen Betrag wie 120,00 an.' } })
}
knopf.disabled = true
try {
await ctx.request(`/api/unterkunft/anfragen/${enc(a.id)}/stornieren?projectId=${enc(ctx.projectId)}`, { method: 'POST', body: { grund: String(grund.value).trim(), erstattungCent } })
if (!ctx.aktuell()) return
ctx.zeigeMeldung('Die Buchung ist storniert.', 'ok')
await zeigeDetail(a.id)
} catch (err) {
knopf.disabled = false
if (err.fehler === 'erstattung_fehlgeschlagen' || err.body?.fehler === 'erstattung_fehlgeschlagen') {
zeigeFehler(box, { message: 'Die Buchung ist storniert, aber die Erstattung ist fehlgeschlagen. Bitte versuch die Erstattung gleich noch einmal.', fehler: {} })
} else zeigeFehler(box, fehlerFuerFormular(err))
}
},
}, 'Stornieren'),
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'abbrechen', onclick: () => { dialogEl.replaceChildren(); ausloeser.focus() } }, 'Abbrechen')))
dialogEl.replaceChildren(box)
grund.focus()
}
}
+189
View File
@@ -0,0 +1,189 @@
/* Buchungskalender, Reiter „Einstellungen“: Firmendaten, Steuernummer/USt-IdNr., Rechnungspraefix, Auftrag zur Rechnungsstellung,
Bankverbindung, Akzentfarbe, Stripe-Konto und Online-Zahlung.
API (docs/funktionen/unterkunft-api.md, ?projectId=): GET /einstellungen => { einstellungen:{ firma, inhaber, strasse, plz, ort, email, telefon,
steuernummer, ustId, iban, bic, bank, rechnungsPraefix, akzentfarbe, onlineZahlung, rechnungAuftrag },
stripe:{ status:'keins'|'onboarding'|'aktiv', vorhandenesShopKonto:boolean }, rechnungenVorhanden:boolean };
PUT /einstellungen mit denselben Feldern (ohne Wrapper; rechnungAuftrag:true setzt rechnungAuftragAt, false widerruft);
POST /stripe/verbinden => { url } (Onboarding bei Stripe), POST /stripe/vorhandenes-konto und /stripe/aktualisieren => { stripe }.
Geprueft wird auf dem Server; Feldfehler stehen am Feld. Text nur ueber textContent (h). render(panel, ctx) wie shop-tab-*.js. */
import { abschnitt, allgemeinerFehler, feld, frage, h, hakenFeld, setzeFehler } from './shop-dom.js'
import { enc, zeichen, zeigeApiFehler } from './buchungen-uk-hilfe.js'
export const AUFTRAG_TEXT = 'Ich beauftrage WEBklar, in meinem Namen und auf meine Rechnung Rechnungen an meine Gäste auszustellen (§ 14 Abs. 2 Satz 4 UStG).'
export const AUFTRAG_WIDERRUF = 'Ohne diese Zustimmung können keine Buchungen bestätigt werden. Zustimmung wirklich zurückziehen?'
export const AUFTRAG_FEHLT = 'Ohne diesen Auftrag kannst du Buchungen nicht bestätigen, weil wir keine Rechnungen für dich ausstellen dürfen.'
const STRIPE_FEHLER = 'Stripe hat keine gültige Adresse geliefert. Bitte versuch es gleich noch einmal.'
const PFLICHT = { firma: 'Bitte gib den Namen deiner Firma oder Unterkunft an.', inhaber: 'Bitte gib den Namen des Inhabers an.', strasse: 'Bitte gib Straße und Hausnummer an.', plz: 'Bitte gib die Postleitzahl an.', ort: 'Bitte gib den Ort an.', email: 'Bitte gib eine E-Mail-Adresse an.' }
const textfeld = (optionen = {}) => h('input', { type: 'text', autocomplete: 'off', ...optionen })
const istHttps = (url) => { try { return new URL(url).protocol === 'https:' } catch { return false } }
export async function render(panel, ctx = {}) {
const projectId = ctx.projectId
const q = `projectId=${enc(projectId)}`
const nurLesen = ctx.nurLesen === true
const antwort = await ctx.request(`/api/unterkunft/einstellungen?${q}`)
const e = antwort?.einstellungen && typeof antwort.einstellungen === 'object' ? antwort.einstellungen : {}
const stripe = antwort?.stripe || ctx.status?.stripe || { status: 'keins' }
const rechnungenVorhanden = (antwort?.rechnungenVorhanden ?? ctx.status?.rechnungenVorhanden) === true
const melde = (text, art) => { if (typeof ctx.zeigeMeldung === 'function') ctx.zeigeMeldung(text, art) }
const neu = () => render(panel, ctx).catch((err) => panel.replaceChildren(h('p', { class: 'sh-fehler', role: 'alert' }, err.message)))
/* ----------------------------------------------------------------- Felder --- */
const firma = textfeld({ maxlength: 120, autocomplete: 'organization' }); firma.value = zeichen(e.firma)
const inhaber = textfeld({ maxlength: 120 }); inhaber.value = zeichen(e.inhaber)
const strasse = textfeld({ maxlength: 120, autocomplete: 'street-address' }); strasse.value = zeichen(e.strasse)
const plz = textfeld({ maxlength: 10, inputmode: 'numeric', autocomplete: 'postal-code' }); plz.value = zeichen(e.plz)
const ort = textfeld({ maxlength: 80, autocomplete: 'address-level2' }); ort.value = zeichen(e.ort)
const email = h('input', { type: 'email', maxlength: 254, autocomplete: 'email' }); email.value = zeichen(e.email)
const telefon = h('input', { type: 'tel', maxlength: 40, autocomplete: 'tel' }); telefon.value = zeichen(e.telefon)
const steuernummer = textfeld({ maxlength: 30 }); steuernummer.value = zeichen(e.steuernummer)
const ustId = textfeld({ maxlength: 20, placeholder: 'DE123456789' }); ustId.value = zeichen(e.ustId)
const praefix = textfeld({ maxlength: 8, placeholder: 'RE' }); praefix.value = zeichen(e.rechnungsPraefix) || 'RE'
if (rechnungenVorhanden) { praefix.disabled = true; praefix.setAttribute('aria-disabled', 'true') }
const auftrag = h('input', { type: 'checkbox' }); auftrag.checked = e.rechnungAuftrag === true
const iban = textfeld({ maxlength: 34 }); iban.value = zeichen(e.iban)
const bic = textfeld({ maxlength: 11 }); bic.value = zeichen(e.bic)
const bank = textfeld({ maxlength: 80 }); bank.value = zeichen(e.bank)
const akzent = h('input', { type: 'color' }); akzent.value = /^#[0-9a-fA-F]{6}$/.test(zeichen(e.akzentfarbe)) ? e.akzentfarbe : '#111111'
const online = h('input', { type: 'checkbox' }); online.checked = e.onlineZahlung === true && stripe.status === 'aktiv'
const auftragHinweis = h('div', { class: 'sh-box sh-box-warn', role: 'note', 'data-hinweis': 'auftrag' }, AUFTRAG_FEHLT)
const zeigeAuftrag = () => { auftragHinweis.hidden = auftrag.checked }
// Widerruf einer gespeicherten Zustimmung nur nach Rueckfrage in der Seite
const widerrufPlatz = h('div', { 'data-widerruf-platz': '' })
auftrag.addEventListener('change', () => {
if (!auftrag.checked && e.rechnungAuftrag === true && !widerrufPlatz.firstChild) {
auftrag.checked = true
const zu = () => { widerrufPlatz.replaceChildren(); auftrag.focus() }
const box = frage('Zustimmung zurückziehen', h('p', null, AUFTRAG_WIDERRUF),
h('div', { class: 'sh-aktionen' },
h('button', { type: 'button', class: 'kb-btn-primary', 'data-aktion': 'widerruf-ja', onclick: () => { auftrag.checked = false; zeigeAuftrag(); zu() } }, 'Ja, zurückziehen'),
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'widerruf-nein', onclick: zu }, 'Abbrechen')))
widerrufPlatz.replaceChildren(box)
box.focus()
}
zeigeAuftrag()
})
zeigeAuftrag()
/* ------------------------------------------------------------------ Stripe --- */
let beschaeftigt = false
async function stripeAktion(pfad, { weiter = false } = {}) {
if (beschaeftigt || nurLesen) return
beschaeftigt = true
for (const k of stripeKarte.querySelectorAll('button')) k.setAttribute('disabled', '')
try {
const a = await ctx.request(`/api/unterkunft/stripe/${pfad}?${q}`, { method: 'POST' })
if (weiter) {
if (!istHttps(a?.url)) throw new Error(STRIPE_FEHLER)
melde('Du wirst zu Stripe weitergeleitet …', 'ok')
window.location.href = a.url
return
}
if (typeof ctx.neuLaden === 'function') { try { await ctx.neuLaden() } catch { /* der Stand erscheint beim naechsten Laden */ } }
await neu()
} catch (err) {
melde(err?.message || 'Das hat nicht geklappt. Bitte versuch es noch einmal.', 'fehler')
for (const k of stripeKarte.querySelectorAll('button')) k.removeAttribute('disabled')
} finally {
beschaeftigt = false
}
}
const knopf = (text, aktion, pfad, optionen, art = 'kb-btn-outline') => h('button', { type: 'button', class: art, 'data-aktion': aktion, onclick: () => stripeAktion(pfad, optionen) }, text)
const zustandText = { keins: 'Noch nicht verbunden', onboarding: 'Angaben bei Stripe noch offen', aktiv: 'Verbunden und aktiv' }
const status = ['keins', 'onboarding', 'aktiv'].includes(stripe.status) ? stripe.status : 'keins'
const stripeKnoepfe = nurLesen ? [] : status === 'keins'
? [knopf('Stripe-Konto verbinden', 'stripe-verbinden', 'verbinden', { weiter: true }, 'kb-btn-primary'),
stripe.vorhandenesShopKonto === true ? knopf('Vorhandenes Webshop-Konto verwenden', 'stripe-vorhanden', 'vorhandenes-konto') : null]
: status === 'onboarding'
? [knopf('Angaben bei Stripe ergänzen', 'stripe-verbinden', 'verbinden', { weiter: true }, 'kb-btn-primary'), knopf('Status aktualisieren', 'stripe-aktualisieren', 'aktualisieren')]
: [knopf('Status aktualisieren', 'stripe-aktualisieren', 'aktualisieren')]
const stripeKarte = abschnitt('Stripe-Konto für Online-Zahlung', 'Gäste können die Buchung online bezahlen. Das Geld geht direkt auf dein Stripe-Konto.',
h('p', { 'data-stripe-status': status, class: status === 'aktiv' ? 'sh-ok' : 'sh-offen' }, zustandText[status]),
h('div', { class: 'sh-aktionen' }, ...stripeKnoepfe))
/* ----------------------------------------------------------------- Formular --- */
const speichern = h('button', { type: 'submit', class: 'kb-btn-primary', 'data-aktion': 'einstellungen-speichern' }, 'Einstellungen speichern')
const form = h('form', { class: 'sh-form sh-einstellungen', novalidate: true, 'aria-label': 'Einstellungen des Buchungskalenders' },
abschnitt('Deine Firmendaten', 'Diese Angaben stehen auf der Buchungsseite und auf den Rechnungen an deine Gäste. Pflichtfelder sind mit * markiert.',
feld('firma', 'Firma oder Name der Unterkunft *', firma),
feld('inhaber', 'Inhaber *', inhaber),
feld('strasse', 'Straße und Hausnummer *', strasse),
feld('plz', 'Postleitzahl *', plz),
feld('ort', 'Ort *', ort),
feld('email', 'E-Mail-Adresse *', email, 'An diese Adresse gehen neue Anfragen.'),
feld('telefon', 'Telefon (freiwillig)', telefon)),
abschnitt('Steuer', 'Eine der beiden Angaben ist Pflicht (§ 14 Abs. 4 UStG).',
feld('steuernummer', 'Steuernummer', steuernummer),
feld('ustId', 'USt-IdNr.', ustId, 'Zum Beispiel DE123456789.')),
abschnitt('Rechnungen', null,
feld('rechnungsPraefix', 'Rechnungspräfix', praefix, rechnungenVorhanden
? 'Der Präfix ist gesperrt, weil es schon Rechnungen gibt. Die Nummern bleiben so lückenlos.'
: '2 bis 8 Zeichen (A–Z, 0–9). Die Nummern lauten dann zum Beispiel RE-2026-0001. Du kannst den Präfix nur vor der ersten Rechnung ändern.'),
hakenFeld('rechnungAuftrag', auftrag, AUFTRAG_TEXT),
auftragHinweis,
widerrufPlatz),
abschnitt('Bankverbindung (freiwillig)', 'Erscheint als Hinweis, wenn Gäste vor Ort oder per Überweisung zahlen.',
feld('iban', 'IBAN', iban),
feld('bic', 'BIC', bic),
feld('bank', 'Name der Bank', bank)),
abschnitt('Darstellung', null, feld('akzentfarbe', 'Akzentfarbe', akzent, 'Diese Farbe bekommen Knöpfe auf deiner Buchungsseite.')),
status === 'aktiv'
? abschnitt('Online-Zahlung', null, hakenFeld('onlineZahlung', online, 'Online-Zahlung anbieten', 'Gäste können bei der Buchung per Stripe bezahlen.'))
: null,
allgemeinerFehler(),
nurLesen ? null : h('div', { class: 'sh-aktionen' }, speichern))
/* --------------------------------------------------------------- Speichern --- */
function sammle() {
const fehler = {}
const felder = { firma, inhaber, strasse, plz, ort, email }
for (const [name, el] of Object.entries(felder)) if (!el.value.trim()) fehler[name] = PFLICHT[name]
if (!steuernummer.value.trim() && !ustId.value.trim()) {
fehler.steuernummer = 'Bitte gib Steuernummer oder USt-IdNr. an.'
fehler.ustId = 'Bitte gib Steuernummer oder USt-IdNr. an.'
}
const p = praefix.value.trim().toUpperCase()
if (!rechnungenVorhanden && !/^[A-Z0-9]{2,8}$/.test(p)) fehler.rechnungsPraefix = 'Der Präfix braucht 2 bis 8 Zeichen aus A–Z und 0–9.'
if (Object.keys(fehler).length) return { fehler }
return {
einstellungen: {
firma: firma.value.trim(), inhaber: inhaber.value.trim(), strasse: strasse.value.trim(), plz: plz.value.trim(), ort: ort.value.trim(),
email: email.value.trim(), telefon: telefon.value.trim(), steuernummer: steuernummer.value.trim(), ustId: ustId.value.trim(),
iban: iban.value.trim(), bic: bic.value.trim(), bank: bank.value.trim(),
rechnungsPraefix: rechnungenVorhanden ? zeichen(e.rechnungsPraefix) || 'RE' : p,
rechnungAuftrag: auftrag.checked, akzentfarbe: akzent.value,
onlineZahlung: status === 'aktiv' && online.checked,
},
}
}
let laeuft = false
form.addEventListener('submit', async (ev) => {
ev.preventDefault()
if (nurLesen || laeuft) return
laeuft = true
speichern.disabled = true
setzeFehler(form, {})
try {
const eingabe = sammle()
if (eingabe.fehler) {
setzeFehler(form, eingabe.fehler)
form.querySelector('[aria-invalid]')?.focus()
return
}
await ctx.request(`/api/unterkunft/einstellungen?${q}`, { method: 'PUT', body: eingabe.einstellungen })
melde('Die Einstellungen sind gespeichert.', 'ok')
if (typeof ctx.neuLaden === 'function') { try { await ctx.neuLaden() } catch { /* gespeichert ist gespeichert */ } }
} catch (err) {
zeigeApiFehler(form, err)
} finally {
laeuft = false
speichern.disabled = false
}
})
if (nurLesen) for (const el of form.querySelectorAll('input, select, textarea, button')) el.disabled = true
panel.replaceChildren(stripeKarte, form)
}
+189
View File
@@ -0,0 +1,189 @@
/* Buchungskalender, Reiter „Kalender“: Monatsansicht je Unterkunft, Farben nach Quelle, zwei Klicks auf freie Nächte sperren den Zeitraum.
API (alle mit ?projectId=): GET /api/unterkunft/unterkuenfte => { unterkuenfte:[{ id, name, … }] }
GET /unterkuenfte/:id/kalender?von&bis => { bereiche:[{ von, bis (Abreisetag, exklusiv), quelle, anfrageNummer?, sperreId?, notiz? }] }
quelle: 'gebucht' | 'reserviert' | 'ical' (auch 'ical:<id>') | 'manuell'.
POST /unterkuenfte/:id/sperren { von, bis, notiz } · DELETE /sperren/:id. Wochen beginnen am Montag. Fremdtext nur ueber textContent. */
import { abschnitt, allgemeinerFehler, fehlerKarte, feld, frage, h } from './shop-dom.js'
import { enc, heuteBerlin, ladeUnterkuenfte, msTag, plusTage, tagText, zeichen, zeigeApiFehler } from './buchungen-uk-hilfe.js'
export const QUELLEN = [
['gebucht', 'Direkt gebucht', 'ist-gebucht'],
['reserviert', 'Reserviert (wartet auf Buchung)', 'ist-reserviert'],
['ical', 'Booking.com / Airbnb (iCal)', 'ist-ical'],
['manuell', 'Von dir gesperrt', 'ist-manuell'],
]
const MONATE = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']
const WOCHENTAGE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
const quelleArt = (q) => { const s = zeichen(q); return s.startsWith('ical') ? 'ical' : s.startsWith('anfrage') ? 'gebucht' : s }
/** Alle Tage des Monatsrasters (Montag bis Sonntag, volle Wochen) als 'YYYY-MM-DD'. */
export function rasterTage(jahr, monat) {
const erster = Date.UTC(jahr, monat, 1)
const letzter = Date.UTC(jahr, monat + 1, 0)
const start = erster - ((new Date(erster).getUTCDay() + 6) % 7) * 86400000
const ende = letzter + (6 - ((new Date(letzter).getUTCDay() + 6) % 7)) * 86400000
const tage = []
for (let ms = start; ms <= ende; ms += 86400000) tage.push(msTag(ms))
return tage
}
export async function render(panel, ctx) {
const heute = heuteBerlin()
const st = { unterkunft: null, jahr: Number(heute.slice(0, 4)), monat: Number(heute.slice(5, 7)) - 1, bereiche: [], start: null, lauf: 0 }
panel.replaceChildren(h('p', { class: 'kb-empty' }, 'Lade…'))
let liste
try {
liste = (await ladeUnterkuenfte(ctx)).filter((u) => u.aktiv !== false)
} catch (err) {
if (ctx.aktuell()) panel.replaceChildren(fehlerKarte(err.message, () => render(panel, ctx)))
return
}
if (!ctx.aktuell()) return
if (!liste.length) {
panel.replaceChildren(h('div', { class: 'kb-panel kb-empty', 'data-leer': true }, 'Noch keine Unterkunft. Lege sie im Reiter „Unterkünfte & Preise“ an.'))
return
}
st.unterkunft = liste[0].id
const wahl = h('select', { id: 'bk-k-unterkunft', 'data-aktion': 'unterkunft' }, liste.map((u) => h('option', { value: u.id }, zeichen(u.name))))
wahl.addEventListener('change', () => { st.unterkunft = wahl.value; st.start = null; lade() })
const titel = h('strong', { class: 'bk-monat', 'aria-live': 'polite', 'data-monat': true })
const schritt = (n) => () => { const d = new Date(Date.UTC(st.jahr, st.monat + n, 1)); st.jahr = d.getUTCFullYear(); st.monat = d.getUTCMonth(); st.start = null; lade() }
const kopf = h('div', { class: 'bk-filter bk-kalenderkopf' },
liste.length > 1 ? h('label', { for: 'bk-k-unterkunft' }, 'Unterkunft') : null, liste.length > 1 ? wahl : h('strong', null, zeichen(liste[0].name)),
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'zurueck', 'aria-label': 'Vorheriger Monat', onclick: schritt(-1) }, '‹'),
titel,
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'weiter', 'aria-label': 'Nächster Monat', onclick: schritt(1) }, '›'))
const raster = h('div', { class: 'bk-raster', role: 'grid' })
const hinweis = h('p', { class: 'sh-hinweis', 'data-hinweis': true, role: 'status' })
const dialog = h('div', { class: 'bk-dialog' })
const legende = h('ul', { class: 'bk-legende', 'aria-label': 'Legende' }, QUELLEN.map(([, text, klasse]) => h('li', null, h('span', { class: `bk-farbe ${klasse}`, 'aria-hidden': 'true' }), text)))
panel.replaceChildren(abschnitt('Kalender', ctx.nurLesen ? 'Dein Kalender ist hier nur zum Ansehen.' : 'Klicke zwei freie Tage an, um den Zeitraum zu sperren, zum Beispiel für eigene Aufenthalte. Gesperrte Tage kannst du mit einem Klick wieder freigeben.',
kopf, raster, hinweis, legende, dialog))
await lade()
function bereichAm(tag) {
for (const art of ['gebucht', 'reserviert', 'ical', 'manuell']) {
const b = st.bereiche.find((x) => quelleArt(x.quelle) === art && x.von <= tag && tag < x.bis)
if (b) return b
}
return null
}
async function lade() {
const lauf = ++st.lauf
const tage = rasterTage(st.jahr, st.monat)
titel.textContent = `${MONATE[st.monat]} ${st.jahr}`
dialog.replaceChildren()
hinweis.textContent = ''
try {
const daten = await ctx.request(`/api/unterkunft/unterkuenfte/${enc(st.unterkunft)}/kalender?projectId=${enc(ctx.projectId)}&von=${tage[0]}&bis=${plusTage(tage.at(-1), 1)}`)
if (st.lauf !== lauf || !ctx.aktuell()) return
st.bereiche = Array.isArray(daten?.bereiche) ? daten.bereiche : []
} catch (err) {
if (st.lauf === lauf && ctx.aktuell()) { st.bereiche = []; zeichne(); hinweis.textContent = err.message }
return
}
zeichne()
}
function zeichne() {
const tage = rasterTage(st.jahr, st.monat)
const heute = heuteBerlin()
raster.replaceChildren(
...WOCHENTAGE.map((w) => h('div', { class: 'bk-wochentag', role: 'columnheader' }, w)),
...tage.map((tag) => {
const b = bereichAm(tag)
const art = b ? quelleArt(b.quelle) : null
const klassen = ['bk-tag', art ? `ist-${art}` : 'ist-frei', Number(tag.slice(5, 7)) - 1 === st.monat ? '' : 'bk-ausserhalb', tag === heute ? 'bk-heute' : '', st.start === tag ? 'bk-start' : '']
const label = `${tagText(tag)}, ${b ? (QUELLEN.find((q) => q[0] === art)?.[1] || 'belegt') : 'frei'}${b?.anfrageNummer ? `, Anfrage ${zeichen(b.anfrageNummer)}` : ''}`
return h('button', {
type: 'button', class: klassen.filter(Boolean).join(' '), 'data-tag': tag, 'aria-label': label, 'aria-pressed': st.start === tag ? 'true' : 'false',
onclick: () => klickTag(tag, b),
}, String(Number(tag.slice(8))))
}))
}
function klickTag(tag, b) {
dialog.replaceChildren()
hinweis.textContent = ''
if (b) {
st.start = null
zeichne()
if (quelleArt(b.quelle) === 'manuell' && b.sperreId) return aufhebenDialog(b)
hinweis.textContent = `${tagText(tag)}: ${QUELLEN.find((q) => q[0] === quelleArt(b.quelle))?.[1] || 'belegt'}${b.anfrageNummer ? ` (Anfrage ${zeichen(b.anfrageNummer)})` : ''}. Das kannst du hier nicht ändern.`
return
}
if (ctx.nurLesen) return
if (tag < heuteBerlin()) {
hinweis.textContent = 'Vergangene Tage kannst du nicht sperren.'
return
}
if (!st.start) {
st.start = tag
zeichne()
hinweis.textContent = `Erster Tag: ${tagText(tag)}. Klicke jetzt den letzten Tag an.`
return
}
const [von, letzter] = st.start <= tag ? [st.start, tag] : [tag, st.start]
const bis = plusTage(letzter, 1)
const belegt = st.bereiche.some((x) => x.von < bis && von < x.bis)
if (belegt) {
st.start = null
zeichne()
hinweis.textContent = 'In diesem Zeitraum liegen schon belegte Tage. Wähle einen freien Zeitraum.'
return
}
sperrDialog(von, bis)
}
function sperrDialog(von, bis) {
const notiz = h('input', { type: 'text', id: 'bk-k-notiz', maxlength: '200', autocomplete: 'off' })
const box = frage('Zeitraum sperren', h('p', null, `${tagText(von)} bis ${tagText(plusTage(bis, -1))} (Abreise ${tagText(bis)}) sperren?`),
feld('notiz', 'Notiz (nur für dich)', notiz), allgemeinerFehler(),
h('div', { class: 'bk-aktionen' },
h('button', {
type: 'button', class: 'kb-btn', 'data-aktion': 'sperren-senden',
onclick: async (ev) => {
const knopf = ev.currentTarget
knopf.disabled = true
try {
await ctx.request(`/api/unterkunft/unterkuenfte/${enc(st.unterkunft)}/sperren?projectId=${enc(ctx.projectId)}`, { method: 'POST', body: { von, bis, notiz: String(notiz.value).trim() } })
if (!ctx.aktuell()) return
st.start = null
ctx.zeigeMeldung('Der Zeitraum ist gesperrt.', 'ok')
await lade()
} catch (err) {
knopf.disabled = false
zeigeApiFehler(box, err)
}
},
}, 'Zeitraum sperren'),
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'abbrechen', onclick: () => { st.start = null; dialog.replaceChildren(); zeichne() } }, 'Abbrechen')))
dialog.replaceChildren(box)
notiz.focus()
}
function aufhebenDialog(b) {
const box = frage('Sperre aufheben', h('p', null, `${tagText(b.von)} bis ${tagText(plusTage(b.bis, -1))}${b.notiz ? `: ${zeichen(b.notiz)}` : ''}`), allgemeinerFehler(),
h('div', { class: 'bk-aktionen' },
ctx.nurLesen ? null : h('button', {
type: 'button', class: 'kb-btn', 'data-aktion': 'aufheben',
onclick: async (ev) => {
const knopf = ev.currentTarget
knopf.disabled = true
try {
await ctx.request(`/api/unterkunft/sperren/${enc(b.sperreId)}?projectId=${enc(ctx.projectId)}`, { method: 'DELETE' })
if (!ctx.aktuell()) return
ctx.zeigeMeldung('Die Sperre ist aufgehoben.', 'ok')
await lade()
} catch (err) {
knopf.disabled = false
zeigeApiFehler(box, err)
}
},
}, 'Sperre aufheben'),
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'abbrechen', onclick: () => dialog.replaceChildren() }, 'Schließen')))
dialog.replaceChildren(box)
}
}
+33
View File
@@ -0,0 +1,33 @@
/* Buchungskalender, Reiter „Rechnungen“: Liste der Rechnungen und Storno-Rechnungen mit PDF-Link.
API: GET /api/unterkunft/rechnungen?projectId= => { rechnungen:[{ id, nummer, datum, art:'rechnung'|'storno', gastName, bruttoCent, anfrageNummer? }] };
PDF: GET /api/unterkunft/rechnungen/<id>.pdf?projectId= (Einzel-PDF, kein ZIP). Fremdtext nur ueber textContent. render(panel, ctx). */
import { abschnitt, datumText, euroText, h, infoKarte } from './shop-dom.js'
import { enc, zeichen } from './buchungen-uk-hilfe.js'
const ART = { rechnung: 'Rechnung', storno: 'Storno-Rechnung' }
const betragText = (cent) => {
if (!Number.isInteger(cent)) return ''
return cent < 0 ? `−${euroText(-cent)}` : euroText(cent)
}
export async function render(panel, ctx = {}) {
const q = `projectId=${enc(ctx.projectId)}`
const antwort = await ctx.request(`/api/unterkunft/rechnungen?${q}`)
const liste = Array.isArray(antwort?.rechnungen) ? antwort.rechnungen : []
if (!liste.length) {
panel.replaceChildren(infoKarte('Rechnungen', 'Noch keine Rechnungen. Sobald ein Gast bucht, erscheint hier die Rechnung.'))
return
}
const zeilen = liste.map((r) => h('li', { class: 'bk-rechnung', 'data-rechnung': zeichen(r.id), 'data-art': zeichen(r.art) },
h('span', { class: 'bk-nummer' }, zeichen(r.nummer)),
h('span', { class: 'bk-zeitraum' }, datumText(zeichen(r.datum), { zeit: false })),
h('span', { class: 'bk-gast' }, zeichen(r.gastName)),
h('span', { class: `sh-pille ${r.art === 'storno' ? 'sh-pille-bad' : 'sh-pille-ok'}` }, ART[r.art] || 'Rechnung'),
h('span', { class: 'bk-betrag' }, betragText(r.bruttoCent)),
h('a', {
class: 'kb-btn-outline', 'data-aktion': 'pdf', href: `/api/unterkunft/rechnungen/${enc(zeichen(r.id))}.pdf?${q}`, target: '_blank', rel: 'noopener',
'aria-label': `PDF zu ${zeichen(r.nummer)} herunterladen`,
}, 'PDF')))
panel.replaceChildren(abschnitt('Rechnungen', 'Rechnungen und Storno-Rechnungen deiner Gäste, ausgestellt in deinem Namen.', h('ul', { class: 'bk-liste' }, zeilen)))
}
+127
View File
@@ -0,0 +1,127 @@
/* 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. */
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)
}
}
}
}
+28
View File
@@ -0,0 +1,28 @@
/* 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. */
let jetzt = () => new Date()
/** Uhr der Portal-Reiter austauschen (nur fuer Tests); ohne Argument zurueck auf die echte Uhr. */
export const setzeUhrFuerTests = (fn) => { jetzt = fn || (() => new Date()) }
export const heuteBerlin = () => jetzt().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))
}
+65
View File
@@ -0,0 +1,65 @@
/* Buchungskalender im Kundenbereich (buchungen.js): Klassen mit bk-; Reiter, Pillen, Karten kommen aus shop.css (sh-). */
#view-buchungen .kb-panel { max-width: none; }
#view-buchungen .fn-project { margin-bottom: 16px; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding: 14px 18px; }
#view-buchungen button:focus-visible, #view-buchungen input:focus-visible, #view-buchungen select:focus-visible, #view-buchungen textarea:focus-visible,
#view-buchungen a:focus-visible { outline: 3px solid #d4b4ff; outline-offset: 2px; }
.bk-filter { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
.bk-liste { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
.bk-zeile {
display: grid; grid-template-columns: 90px 1.4fr 1fr auto auto; gap: 8px 14px; align-items: center; width: 100%; padding: 12px 16px; text-align: left;
border-radius: 12px; border: 1px solid var(--kb-border); background: rgba(255, 255, 255, 0.03); color: inherit; font: inherit; cursor: pointer;
}
.bk-zeile:hover { border-color: var(--kb-purple-line); }
.bk-zeile .sh-pille { margin: 0; justify-self: start; }
.bk-betrag { font-weight: 700; text-align: right; }
.bk-zurueck { margin-bottom: 12px; }
.bk-detail-kopf { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.bk-detail-kopf .sh-pille { margin: 0; }
.bk-daten { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; margin: 12px 0 18px; }
.bk-daten dt { color: var(--kb-muted); }
.bk-daten dd { margin: 0; overflow-wrap: anywhere; }
.bk-posten-kopf, .bk-posten-zeile { display: grid; grid-template-columns: minmax(0, 3fr) 70px 110px 80px auto; gap: 8px; align-items: center; }
.bk-posten-kopf { font-size: 12px; color: var(--kb-muted); margin-bottom: 4px; }
.bk-posten-zeile { margin-bottom: 8px; }
.bk-summen { margin: 12px 0; text-align: right; }
.bk-summen p { margin: 2px 0; }
.bk-gesamt { font-size: 18px; }
.bk-aktionen { display: flex; gap: 10px; flex-wrap: wrap; margin: 14px 0; }
.bk-tabelle { width: 100%; border-collapse: collapse; margin: 10px 0; }
.bk-tabelle td { padding: 6px 4px; border-bottom: 1px solid var(--kb-border); }
.bk-tabelle td:last-child { text-align: right; }
.bk-link input { width: 100%; margin: 6px 0 10px; }
@media (max-width: 640px) {
.bk-zeile { grid-template-columns: 1fr auto; }
.bk-posten-kopf { display: none; }
.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; }
/* Rechnungen */
.bk-rechnung {
display: grid; grid-template-columns: 110px 100px 1fr auto auto auto; gap: 8px 14px; align-items: center; padding: 12px 16px;
border-radius: 12px; border: 1px solid var(--kb-border); background: rgba(255, 255, 255, 0.03);
}
.bk-rechnung .sh-pille { margin: 0; }
@media (max-width: 720px) { .bk-rechnung { grid-template-columns: 1fr auto; } }
+211 -3
View File
@@ -1,4 +1,212 @@
// Buchungskalender (Reiter #buchungen). Platzhalter; die Oberflaeche baut Strang 4.
export function initBuchungen(el) {
el.textContent = 'Buchungskalender wird geladen …'
/* Buchungskalender im Kundenbereich (ES-Modul, von app.js beim Oeffnen der Ansicht #buchungen geladen). Aufbau wie shop.js:
initBuchungen(rootEl, { projectId }): Projekt waehlen (GET /api/funktionen, features.unterkunft), Stand holen (GET /api/unterkunft/status)
und je nach state die Reiter (REITER, lazy geladen, render(panel, ctx)) oder (state off) die Anfrage-/Bestellkarte (Task 14).
ctx = { projectId, status, nurLesen, aktuell(), neuLaden(), zeigeMeldung(text, art), request }.
Helfer: shop-dom.js (h, request, Karten), Styles: buchungen.css (bk-) und shop.css (sh-). Fremdtext nur ueber textContent. */
import { fehlerKarte, h, infoKarte, request } from './shop-dom.js'
import { statusKarte } from './funktion-bestellen.js'
import { anfrageKarte, renderAnfrageFormular } from './buchungen-anfrage.js'
/** Die Reiter: jeder ein Modul mit render(panel, ctx), erst beim ersten Oeffnen geladen. */
export const REITER = [
{ key: 'anfragen', label: 'Anfragen', laden: () => import('./buchungen-tab-anfragen.js') },
{ key: 'kalender', label: 'Kalender', laden: () => import('./buchungen-tab-kalender.js') },
{ key: 'unterkuenfte', label: 'Unterkünfte & Preise', laden: () => import('./buchungen-tab-unterkuenfte.js') },
{ key: 'rechnungen', label: 'Rechnungen', laden: () => import('./buchungen-tab-rechnungen.js') },
{ key: 'einstellungen', label: 'Einstellungen', laden: () => import('./buchungen-tab-einstellungen.js') },
]
const NICHT_VERFUEGBAR = 'Der Buchungskalender ist noch nicht verfügbar.'
const auswahl = { projectId: '', reiter: 'anfragen' }
/** Gemerkte Auswahl vergessen (fuer Tests). */
export function vergissAuswahl() {
auswahl.projectId = ''
auswahl.reiter = 'anfragen'
}
let S = null // aktuelle Sitzung; eine alte Antwort darf eine neuere nicht ueberschreiben
const textOr = (x) => (typeof x === 'string' && x.trim() ? x : '')
const zustandVon = (projekt) => projekt?.features?.unterkunft?.state || 'off'
export function initBuchungen(root, { projectId } = {}) {
if (!root) return Promise.resolve()
const wunsch = projectId || root.dataset?.wunschProjekt || '' // die Kachel in Funktionen legt ihr Projekt hier ab
if (root.dataset) delete root.dataset.wunschProjekt
return starte(root, wunsch)
}
async function starte(root, projectId) {
if (projectId) auswahl.projectId = projectId
const s = { root, projects: [], projekt: null, status: null, lauf: 0, buehne: null, meldungEl: null }
S = s
let funktionen
try {
funktionen = await request('/api/funktionen')
} catch (err) {
if (S === s) root.replaceChildren(fehlerKarte(err.message, () => starte(root, auswahl.projectId)))
return
}
if (S !== s) return
if (!funktionen?.unterkunft?.verfuegbar) {
root.replaceChildren(infoKarte(NICHT_VERFUEGBAR, 'Sobald er für dich bereit ist, findest du ihn hier.'))
return
}
s.projects = Array.isArray(funktionen.projects) ? funktionen.projects : []
if (!s.projects.length) {
root.replaceChildren(h('div', { class: 'kb-panel kb-empty' }, 'Noch keine Website-Projekte zugewiesen.'))
return
}
const hat = (zustaende) => s.projects.find((p) => zustaende.includes(zustandVon(p)))
s.projekt = s.projects.find((p) => p.projectId === auswahl.projectId) || hat(['active']) || hat(['setup']) || hat(['disabled']) || s.projects[0]
auswahl.projectId = s.projekt.projectId
s.meldungEl = h('div', { class: 'sh-meldung', role: 'status', 'aria-live': 'polite', tabindex: '-1' })
s.buehne = h('div', { id: 'buchungen-buehne' })
root.replaceChildren(...(s.projects.length > 1 ? [projektwahl(s)] : []), s.meldungEl, s.buehne)
await zeigeProjekt(s)
}
function projektwahl(s) {
const select = h('select', { id: 'buchungen-project' }, s.projects.map((p) => {
const option = h('option', { value: p.projectId }, p.projectName)
option.selected = p.projectId === s.projekt.projectId
return option
}))
select.addEventListener('change', () => {
auswahl.projectId = select.value
s.projekt = s.projects.find((p) => p.projectId === select.value)
s.neueAnfrage = false
zeigeMeldung(s, '')
zeigeProjekt(s)
})
return h('div', { class: 'fn-project kb-panel' }, h('label', { for: 'buchungen-project' }, 'Website'), select)
}
function zeigeMeldung(s, meldung, art = 'ok') {
const el = s.meldungEl
if (!el) return
el.textContent = meldung || ''
el.className = `sh-meldung sh-meldung-${art}`
el.setAttribute('role', art === 'fehler' ? 'alert' : 'status')
el.setAttribute('aria-live', art === 'fehler' ? 'assertive' : 'polite')
if (!meldung) return
if (art === 'fehler') el.focus({ preventScroll: true })
el.scrollIntoView?.({ block: 'nearest' })
}
const ladeStatus = (projectId) => request(`/api/unterkunft/status?projectId=${encodeURIComponent(projectId)}`)
async function zeigeProjekt(s) {
const lauf = ++s.lauf
const aktuell = () => S === s && s.lauf === lauf
s.buehne.replaceChildren(h('p', { class: 'kb-empty' }, 'Lade…'))
let status
try {
status = await ladeStatus(s.projekt.projectId)
} catch (err) {
if (aktuell()) s.buehne.replaceChildren(fehlerKarte(err.message, () => zeigeProjekt(s)))
return
}
if (!aktuell()) return
zeichneAnsicht(s, { status, aktuell })
}
function zeichneAnsicht(s, { status, aktuell }) {
s.status = status
const zustand = status?.state || zustandVon(s.projekt)
if (zustand === 'setup' || zustand === 'active' || zustand === 'disabled') zeigeReiter(s, { status, aktuell, zustand })
else if (zustand === 'ordered') zeigeBestellung(s)
else if (zustand === 'off') zeigeAnfrage(s, { status, aktuell })
else s.buehne.replaceChildren(infoKarte('Buchungskalender', 'Der Stand deines Buchungskalenders ist gerade nicht lesbar. Bitte lade die Seite neu.'))
}
/* Zustand off: Anfrageformular, Karte „wird geprueft“ / „abgelehnt“ oder (angenommen) der Hinweis auf die Bestellung. */
function zeigeAnfrage(s, { status, aktuell }) {
const projectId = s.projekt.projectId
const ansicht = status?.anfrage
// nach einem 409 bleibt die Meldung des Servers sichtbar, waehrend die Ansicht neu laedt
const neuLaden = async (o) => { await zeigeProjekt(s); if (o?.meldung && S === s) zeigeMeldung(s, o.meldung, 'fehler') }
if (ansicht?.status === 'angenommen') {
s.buehne.replaceChildren(infoKarte('Deine Anfrage wurde angenommen', 'Wir melden uns bei dir mit den nächsten Schritten zur Bestellung.', { chat: true }))
} else if (ansicht?.status === 'offen') {
s.buehne.replaceChildren(anfrageKarte(ansicht, { projectId, neuLaden }))
} else if (ansicht?.status === 'abgelehnt' && !s.neueAnfrage) {
s.buehne.replaceChildren(anfrageKarte(ansicht, { projectId, neu: () => { s.neueAnfrage = true; zeigeAnfrage(s, { status, aktuell }) } }))
} else {
const wurzel = h('div', { class: 'sh-wurzel' })
s.buehne.replaceChildren(wurzel)
renderAnfrageFormular(wurzel, {
projectId,
vorgabe: ansicht?.anfrage || null,
preis: textOr(s.projekt?.features?.unterkunft?.priceLabel) || '199 €',
nachSenden: async (_a, o) => { s.neueAnfrage = false; if (aktuell()) await neuLaden(o) },
})
}
}
/* Zustand ordered: Statuskarte „Zahlung offen“ mit „Zahlung fortsetzen“ (generische Bestellseite, Checkout /api/funktionen/unterkunft/checkout). */
function zeigeBestellung(s) {
const eintrag = { ...s.projekt.features?.unterkunft, state: 'ordered' }
s.buehne.replaceChildren(statusKarte({ projekt: s.projekt, bestellung: eintrag, kundenansicht: false, key: 'unterkunft', name: 'Buchungskalender' }, {
weiter: async ({ meldung }) => { await zeigeProjekt(s); if (S === s) zeigeMeldung(s, meldung, 'fehler') },
}))
}
function zeigeReiter(s, { status, aktuell, zustand }) {
const neuLaden = async () => {
const neu = await ladeStatus(s.projekt.projectId)
if (!aktuell()) return neu
s.status = neu
ctx.status = neu
if ((neu.state || 'off') !== zustand) zeichneAnsicht(s, { status: neu, aktuell })
return neu
}
const ctx = {
projectId: s.projekt.projectId,
status,
nurLesen: zustand === 'disabled',
abwickeln: true, // Anfragen und Stornos bleiben auch bei abgeschalteter Funktion bearbeitbar (Pflichten gegenueber Gaesten)
aktuell,
request,
zeigeMeldung: (meldung, art) => { if (aktuell()) zeigeMeldung(s, meldung, art) },
neuLaden,
}
const panel = h('div', { class: 'sh-panel', role: 'tabpanel', id: 'bk-panel', tabindex: '0' })
let aktiv = null
let zaehler = 0
const knoepfe = REITER.map((r) => h('button', {
type: 'button', role: 'tab', class: 'sh-reiter', id: `bk-tab-${r.key}`, 'aria-controls': 'bk-panel', 'data-reiter': r.key,
onclick: () => waehleReiter(r.key),
}, r.label))
const leiste = h('div', { class: 'sh-reiterleiste', role: 'tablist', 'aria-label': 'Bereiche des Buchungskalenders' }, knoepfe)
leiste.addEventListener('keydown', (ev) => {
const i = REITER.findIndex((r) => r.key === aktiv)
const ziel = { ArrowRight: (i + 1) % REITER.length, ArrowLeft: (i + REITER.length - 1) % REITER.length, Home: 0, End: REITER.length - 1 }[ev.key]
if (ziel === undefined) return
ev.preventDefault()
waehleReiter(REITER[ziel].key)
knoepfe[ziel].focus()
})
function waehleReiter(key) {
const reiter = REITER.find((r) => r.key === key) || REITER[0]
auswahl.reiter = reiter.key
aktiv = reiter.key
const nr = ++zaehler
for (const k of knoepfe) {
const an = k.dataset.reiter === reiter.key
k.setAttribute('aria-selected', String(an))
k.tabIndex = an ? 0 : -1
}
panel.setAttribute('aria-labelledby', `bk-tab-${reiter.key}`)
zeigeMeldung(s, '')
panel.replaceChildren()
reiter.laden()
.then((modul) => (nr === zaehler && aktuell() ? modul.render(panel, ctx) : undefined))
.catch((err) => { if (nr === zaehler && aktuell()) panel.replaceChildren(fehlerKarte(err.message, () => waehleReiter(reiter.key))) })
}
const hinweis = zustand === 'setup' ? 'Wir bauen deinen Buchungskalender gerade ein. Du kannst schon Unterkünfte, Preise und Einstellungen vorbereiten.'
: zustand === 'disabled' ? 'Dein Buchungskalender ist abgeschaltet. Du kannst bestehende Buchungen noch ansehen und bearbeiten.' : ''
const hinweisBox = h('div', { class: 'sh-hinweise' }, hinweis ? h('div', { class: `sh-box ${zustand === 'disabled' ? 'sh-box-warn' : 'sh-box-info'}`, role: 'note', 'data-hinweis': zustand }, hinweis) : null)
s.buehne.replaceChildren(hinweisBox, leiste, panel)
waehleReiter(auswahl.reiter)
}
+1
View File
@@ -25,6 +25,7 @@
<link rel="stylesheet" href="/analyse.css">
<link rel="stylesheet" href="/termine.css">
<link rel="stylesheet" href="/shop.css">
<link rel="stylesheet" href="/buchungen.css">
<link rel="stylesheet" href="/zeiten.css">
</head>
<body class="portal-page dashboard-page kb-page">
+52 -1
View File
@@ -260,6 +260,51 @@ function shopTile(projects, features) {
</article>`
}
/* Buchungskalender (Schluessel unterkunft): Kachel nur, wenn der Server die Funktion freigibt (unterkunft.verfuegbar der Antwort, Schalter
UNTERKUNFT_ENABLED); sonst gar keine Kachel. Die Ansicht #buchungen (buchungen.js) zeigt Anfrage und Stand; die Kachel kennt nur den Zustand
des Funktionsdokuments (projects[].features.unterkunft: off | ordered | setup | active | disabled), Preis aus features.unterkunft.priceLabel,
Rueckfall 199 €. */
const UNTERKUNFT_PREIS = '199 €'
const UNTERKUNFT_TEXT = 'Belegungskalender auf deiner Website, Anfragen freigeben, Rechnung und Online-Zahlung'
const unterkunftOf = (project) => project.features?.unterkunft || { state: 'off' }
function pickUnterkunftProject(projects) {
return projects.find((p) => unterkunftOf(p).state === 'active')
|| projects.find((p) => isSetup(unterkunftOf(p).state))
|| projects.find((p) => unterkunftOf(p).state === 'disabled')
|| projects[0]
}
function unterkunftTile(projects, features) {
const preis = textOr(features?.unterkunft?.priceLabel, UNTERKUNFT_PREIS)
const active = projects.filter((p) => unterkunftOf(p).state === 'active').length
const target = pickUnterkunftProject(projects)
const state = target ? unterkunftOf(target).state : 'off'
const knopf = (text) => `<button type="button" class="kb-btn-primary fn-tile-cta" data-view="buchungen" data-project="${esc(target.projectId)}">${text}</button>`
const ZUSTAENDE = {
off: [['Aus', 'off'], 'Buchungskalender anfragen'],
ordered: [['Zahlung offen', 'setup'], 'Zahlung offen'],
setup: [['In Einrichtung', 'setup'], 'In Einrichtung'],
active: [['Aktiv', 'on'], 'Buchungskalender öffnen'],
disabled: [['Abgeschaltet', 'off'], 'Buchungskalender ansehen'],
}
const [pill, knopfText] = ZUSTAENDE[state] || ZUSTAENDE.off
const action = target ? knopf(knopfText) : ''
let extra = ''
if (!target) extra = '<p class="fn-count">Sobald deine Website angelegt ist, kannst du den Buchungskalender anfragen.</p>'
else if (state === 'disabled') extra = '<p>Der Buchungskalender ist für diese Website abgeschaltet. Deine Buchungen kannst du noch ansehen.</p>'
const count = projects.length > 1 && active ? `<p class="fn-count">aktiv für ${active} von ${projects.length} Websites</p>` : ''
return `
<article class="fn-tile" data-fn-tile="unterkunft">
<div class="fn-tile-head">${svg('calendar')}<span class="fn-pill fn-pill-${pill[1]}">${pill[0]}</span></div>
<h3>Buchungskalender</h3>
<p>${UNTERKUNFT_TEXT}. ${esc(preis)} einmalig.</p>
${extra}
${count}
${action}
</article>`
}
/* Oeffnungszeiten live: Kachel nur, wenn der Schalter es erlaubt (zeiten.verfuegbar); sonst gibt es sie gar nicht (auch kein "Kommt bald").
Wie bei den anderen Funktionen steht sie inline, die Ansicht #zeiten (zeiten.js) laedt app.js. Alles, was sie zeigt, kommt mit der Liste
(GET /api/funktionen): zeiten (Angebot) mit inklusive (true: im laufenden, aktiven Wartungs-Abo enthalten, einschaltbar | false: sonst, dann
@@ -386,12 +431,13 @@ const soonTile = (icon, title, text) => `
export async function initFunktionen(root) {
if (!root) return
const { projects = [], termine, features, shop, zeiten } = await request('/api/funktionen')
const { projects = [], termine, features, shop, zeiten, unterkunft } = await request('/api/funktionen')
root.innerHTML = `
<div class="fn-tiles">
${newsTile(projects, newsOffer(features).price)}
${termine?.verfuegbar ? termineTile(projects, termine) : soonTile('calendar', 'Terminplaner', TERMINE_TEXT)}
${shop?.verfuegbar ? shopTile(projects, features) : ''}
${unterkunft?.verfuegbar === true ? unterkunftTile(projects, features) : ''}
${zeiten?.verfuegbar === true ? zeitenTile(projects, zeiten) : ''}
${analyseTile(projects)}
</div>`
@@ -407,6 +453,11 @@ export async function initFunktionen(root) {
const body = document.getElementById('webshop-body')
if (body) body.dataset.wunschProjekt = ev.currentTarget.dataset.project
})
// Die Buchungskalender-Ansicht liest das gewaehlte Projekt aus ihrem Behaelter (buchungen.js, initBuchungen)
root.querySelector('[data-fn-tile="unterkunft"] .fn-tile-cta[data-project]')?.addEventListener('click', (ev) => {
const body = document.getElementById('buchungen-body')
if (body) body.dataset.wunschProjekt = ev.currentTarget.dataset.project
})
// Die Zeiten-Ansicht liest Projekt und Weg (Behalten) aus ihrem Behaelter (zeiten.js, initZeiten); der Klick steigt danach zum Navigationshandler auf
root.querySelector('[data-fn-tile="zeiten"]')?.addEventListener('click', (ev) => {
const knopf = ev.target?.closest?.('button[data-view="zeiten"]')
+782
View File
@@ -0,0 +1,782 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { installFakeDom } from './support/fakeDom.js'
import { h, request } from '../public/shop-dom.js'
import { REITER } from '../public/buchungen.js'
import { render, zeitraumText, naechteText } from '../public/buchungen-tab-anfragen.js'
import { summeLive } from '../public/buchungen-posten.js'
// Task 12: Gerüst und Reiter „Anfragen“ gegen eine Anfrage-Attrappe nach docs/funktionen/unterkunft-api.md.
const POSTEN = [
{ text: 'Übernachtung', menge: 7, einzelCent: 5500, ustProzent: 7 },
{ text: 'Reinigung', menge: 1, einzelCent: 6000, ustProzent: 7 },
{ text: 'Direktbuchungsrabatt 10 %', menge: 1, einzelCent: -3850, ustProzent: 7 },
]
const zeile = (nr, extra = {}) => ({ id: `a${nr}`, nummer: `A-${nr}`, von: '2026-11-12', bis: '2026-11-30', gast: { name: `Gast ${nr}` }, status: 'neu', summen: { bruttoCent: 40650 }, ...extra })
const anfrage = (extra = {}) => ({
id: 'a1', nummer: 'A-1', von: '2026-11-12', bis: '2026-11-19', personen: 2, status: 'neu', steuerart: 'regelbesteuert', unterkunftName: 'Haus am See',
gast: { name: 'Anna <b>Muster</b>', email: 'anna@example.com', telefon: '0151', nachricht: 'Hund dabei' }, ...extra,
})
const vorschlag = { naechte: 7, posten: POSTEN, summen: { bruttoCent: 40650 } }
const mit = (route, fn) => async () => {
const fx = installFakeDom({ antworten: Array.from({ length: 40 }, () => route) })
try { await fn(fx) } finally { fx.restore() }
}
function baueCtx() {
const ctx = { projectId: 'p1', status: { state: 'active' }, nurLesen: false, meldungen: [], aktuell: () => true, request, zeigeMeldung: (t, a) => ctx.meldungen.push({ text: t, art: a }), neuLaden: async () => ctx.status }
return ctx
}
async function zeige(fx) {
const ctx = baueCtx()
const panel = h('div')
fx.document.body.append(panel)
await render(panel, ctx)
await fx.ruhe()
return { panel, ctx }
}
const server = (o = {}) => (a) => {
const key = `${a.method} ${a.url.split('?')[0]}`
if (o[key]) return o[key](a)
if (a.method === 'GET' && a.url.startsWith('/api/unterkunft/anfragen?')) return { body: { anfragen: [zeile(1), zeile(2, { status: 'gebucht' })] } }
if (a.method === 'GET' && a.url.startsWith('/api/unterkunft/anfragen/')) return { body: { anfrage: anfrage(), vorschlag } }
return { body: {} }
}
const posts = (fx) => fx.fetchLog.filter((a) => a.method !== 'GET')
const aktion = (fx, el, n) => fx.finde(el, `[data-aktion="${n}"]`)
const oeffne = async (fx, panel, id = 'a1') => { await fx.klick(fx.finde(panel, `[data-anfrage="${id}"]`)); await fx.ruhe() }
test('REITER: fünf Reiter mit den Labels', () => {
assert.deepEqual(REITER.map((r) => r.key), ['anfragen', 'kalender', 'unterkuenfte', 'rechnungen', 'einstellungen'])
assert.deepEqual(REITER.map((r) => r.label), ['Anfragen', 'Kalender', 'Unterkünfte & Preise', 'Rechnungen', 'Einstellungen'])
})
test('summeLive spiegelt summiere: 40650 / 37991 / 2659', () => {
const s = summeLive(POSTEN, 'regelbesteuert')
assert.equal(s.bruttoCent, 40650)
assert.equal(s.nettoCent, 37991)
assert.equal(s.ust[0].steuerCent, 2659)
const k = summeLive(POSTEN, 'kleinunternehmer')
assert.deepEqual(k, { bruttoCent: 40650, nettoCent: 40650, ust: [] })
})
test('Zeitraum und Nächte', () => {
assert.equal(zeitraumText('2026-11-12', '2026-11-30'), '12.–30.11.2026')
assert.equal(zeitraumText('2026-10-28', '2026-11-03'), '28.10.–03.11.2026')
assert.equal(zeitraumText('2026-12-28', '2027-01-03'), '28.12.2026–03.01.2027')
assert.equal(naechteText('2026-11-12', '2026-11-30'), '18 Nächte')
assert.equal(naechteText('2026-11-12', '2026-11-13'), '1 Nacht')
})
test('Liste: Nummer, Zeitraum, Gast, Status-Pille, Betrag; Filter offen als Standard', mit(server(), async (fx) => {
const { panel } = await zeige(fx)
assert.equal(fx.fetchLog[0].url, '/api/unterkunft/anfragen?projectId=p1&filter=offen')
const z = fx.alle(panel, '[data-anfrage]')
assert.equal(z.length, 2)
const t = fx.text(z[0])
for (const re of [/A-1/, /12\.–30\.11\.2026 · 18 Nächte/, /Gast 1/, /Neu/, /406,50\s€/]) assert.match(t, re)
assert.match(fx.text(z[1]), /Gebucht/)
await fx.setze(fx.finde(panel, '#bk-a-filter'), 'alle')
await fx.ruhe()
assert.equal(fx.fetchLog.at(-1).url, '/api/unterkunft/anfragen?projectId=p1&filter=alle')
}))
test('Detail: Vorschlag editierbar, Summe aktualisiert sich, Freigeben sendet posten/fristTage/nachricht und zeigt den Link', mit(server({
'POST /api/unterkunft/anfragen/a1/freigeben': () => ({ body: { buchungsLink: 'https://x.test/buchen/a1?t=abc' } }),
}), async (fx) => {
const { panel, ctx } = await zeige(fx)
await oeffne(fx, panel)
assert.match(fx.text(panel), /Anna <b>Muster<\/b>/)
assert.equal(fx.finde(panel, 'b'), null, 'Fremdtext bleibt Text')
assert.match(fx.text(fx.finde(panel, '[data-summen]')), /Gesamt 406,50\s€/)
const zeilen = fx.alle(panel, '[data-zeile]')
assert.equal(zeilen.length, 3)
const einzel = fx.finde(zeilen[1], '[data-posten="einzel"]')
await fx.setze(einzel, '70,00')
assert.match(fx.text(fx.finde(panel, '[data-summen]')), /Gesamt 416,50\s€/)
await fx.setze(einzel, '60,00')
await fx.setze(fx.finde(panel, '#bk-a-frist'), '5')
await fx.setze(fx.finde(panel, '#bk-a-nachricht'), 'Gern!')
await fx.klick(aktion(fx, panel, 'freigeben'))
await fx.ruhe()
const [p] = posts(fx)
assert.equal(p.url, '/api/unterkunft/anfragen/a1/freigeben?projectId=p1')
assert.deepEqual(p.body, { posten: POSTEN, fristTage: 5, nachricht: 'Gern!' })
assert.equal(fx.finde(panel, '[data-link]').getAttribute('value'), 'https://x.test/buchen/a1?t=abc')
assert.ok(aktion(fx, panel, 'link-kopieren'))
assert.equal(ctx.meldungen.at(-1).art, 'ok')
}))
test('Freigeben: 409 zeigt „Der Zeitraum ist inzwischen belegt.“', mit(server({
'POST /api/unterkunft/anfragen/a1/freigeben': () => ({ status: 409, body: { fehler: 'zeitraum_belegt' } }),
}), async (fx) => {
const { panel } = await zeige(fx)
await oeffne(fx, panel)
await fx.klick(aktion(fx, panel, 'freigeben'))
await fx.ruhe()
assert.match(fx.text(panel), /Der Zeitraum ist inzwischen belegt\./)
assert.equal(aktion(fx, panel, 'freigeben').disabled, false)
}))
test('Ablehnen sendet den Text', mit(server({ 'POST /api/unterkunft/anfragen/a1/ablehnen': () => ({ body: {} }) }), async (fx) => {
const { panel } = await zeige(fx)
await oeffne(fx, panel)
await fx.klick(aktion(fx, panel, 'ablehnen'))
await fx.setze(fx.finde(panel, '#bk-a-ablehn'), 'Leider belegt')
await fx.klick(aktion(fx, panel, 'ablehnen-senden'))
await fx.ruhe()
assert.deepEqual(posts(fx)[0].body, { text: 'Leider belegt' })
}))
const gebucht = (extra = {}) => ({ anfrage: anfrage({ status: 'gebucht', posten: POSTEN, summen: { bruttoCent: 40650 }, rechnungId: 'r1', rechnungNummer: 'R-2026-001', ...extra }) })
test('Gebucht: Rechnung-Link, „Als bezahlt (vor Ort)“, Storno ohne Erstattungsfeld bei Vor-Ort-Zahlung', mit(server({
'GET /api/unterkunft/anfragen/a1': () => ({ body: gebucht({ zahlart: 'vorort' }) }),
'POST /api/unterkunft/anfragen/a1/bezahlt': () => ({ body: {} }),
'POST /api/unterkunft/anfragen/a1/stornieren': () => ({ body: {} }),
}), async (fx) => {
const { panel } = await zeige(fx)
await oeffne(fx, panel)
assert.equal(aktion(fx, panel, 'rechnung').getAttribute('href'), '/api/unterkunft/rechnungen/r1.pdf?projectId=p1')
await fx.klick(aktion(fx, panel, 'bezahlt'))
await fx.ruhe()
assert.equal(posts(fx)[0].url, '/api/unterkunft/anfragen/a1/bezahlt?projectId=p1')
await fx.klick(aktion(fx, panel, 'stornieren'))
assert.equal(fx.finde(panel, '#bk-a-erstattung'), null)
await fx.setze(fx.finde(panel, '#bk-a-grund'), 'Gast krank')
await fx.klick(aktion(fx, panel, 'storno-senden'))
await fx.ruhe()
assert.deepEqual(posts(fx).at(-1).body, { grund: 'Gast krank', erstattungCent: 0 })
}))
test('Storno bei Online-Zahlung: Erstattungsbetrag wird gesendet', mit(server({
'GET /api/unterkunft/anfragen/a1': () => ({ body: gebucht({ status: 'bezahlt', zahlart: 'online', bezahltVia: 'stripe' }) }),
'POST /api/unterkunft/anfragen/a1/stornieren': () => ({ body: {} }),
}), async (fx) => {
const { panel } = await zeige(fx)
await oeffne(fx, panel)
assert.equal(aktion(fx, panel, 'bezahlt'), null)
await fx.klick(aktion(fx, panel, 'stornieren'))
assert.equal(fx.finde(panel, '#bk-a-erstattung').value, '406,50')
await fx.setze(fx.finde(panel, '#bk-a-erstattung'), '200,00')
await fx.klick(aktion(fx, panel, 'storno-senden'))
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 <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')
import { setzeUhrFuerTests } from '../public/buchungen-uk-hilfe.js'
setzeUhrFuerTests(() => new Date('2026-10-07T10:00:00Z')) // feste Uhr: Kalender und Sperren-Tests nutzen Daten im Herbst 2026
const tag = (fx, panel, t) => fx.finde(panel, `[data-tag="${t}"]`)
const gehZuNovember = async (fx, panel) => {
for (let i = 0; i < 24 && !/November 2026/.test(fx.text(fx.finde(panel, '[data-monat]'))); i += 1) { await fx.klick(aktion(fx, panel, 'weiter')); await fx.ruhe() }
assert.match(fx.text(fx.finde(panel, '[data-monat]')), /November 2026/, 'November 2026 nicht erreicht')
}
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)
}))
test('Freigeben: Serverfehler 400 mit felder zeigt genau den Feldtext', mit(server({
'POST /api/unterkunft/anfragen/a1/freigeben': () => ({ status: 400, body: { fehler: 'ungueltig', felder: { posten: 'Die Summe muss größer als 0 sein.' } } }),
}), async (fx) => {
const { panel } = await zeige(fx)
await oeffne(fx, panel)
await fx.klick(aktion(fx, panel, 'freigeben'))
await fx.ruhe()
assert.equal(fx.text(fx.finde(panel, '[data-fehler="posten"]')), 'Die Summe muss größer als 0 sein.')
assert.equal(fx.text(fx.finde(panel, '[data-fehler="_"]')), '')
}))
// Fix round 1: Preis-Editor prüft Grenzen wie der Server
const preisEditor = async (fx, preise = PREISE) => {
const { panel } = await zeigeTab(fx, ukTab)
await fx.klick(aktion(fx, panel, 'bearbeiten'))
await fx.ruhe()
return panel
}
const versuche = async (fx, panel) => {
const vorher = posts(fx).filter((a) => a.url.includes('/preise')).length
await fx.klick(aktion(fx, panel, 'preise-speichern'))
await fx.ruhe()
return posts(fx).filter((a) => a.url.includes('/preise')).length - vorher
}
const fehlerText = (fx, panel, n) => fx.text(fx.finde(panel, `[data-fehler="${n}"]`))
test('Preis-Editor: Staffeln (Anzahl, erste ab 1, aufsteigend, Preisgrenzen)', mit(ukServer({ 'PUT /api/unterkunft/unterkuenfte/u1/preise': () => ({ body: {} }) }), async (fx) => {
const panel = await preisEditor(fx)
const zeilen = () => fx.alle(panel, '[data-staffel-zeile]')
const ab = (i) => fx.finde(zeilen()[i], '[data-staffel="ab"]')
const preis = (i) => fx.finde(zeilen()[i], '[data-staffel="preis"]')
await fx.setze(ab(1), '1')
assert.equal(await versuche(fx, panel), 0)
assert.match(fehlerText(fx, panel, 'staffeln'), /größer werden/)
await fx.setze(ab(1), '7')
await fx.setze(preis(1), '0,50')
assert.equal(await versuche(fx, panel), 0)
assert.match(fehlerText(fx, panel, 'staffeln'), /1,00 € und 1\.000,00 €/)
await fx.setze(preis(1), '1.000,01')
assert.equal(await versuche(fx, panel), 0)
await fx.setze(preis(1), '50')
for (let i = 0; i < 5; i += 1) { await fx.klick(aktion(fx, panel, 'staffel-dazu')); await fx.setze(ab(zeilen().length - 1), String(10 + i)); await fx.setze(preis(zeilen().length - 1), '40') }
assert.equal(zeilen().length, 7)
assert.equal(await versuche(fx, panel), 0)
assert.match(fehlerText(fx, panel, 'staffeln'), /1 bis 6/)
await fx.klick(fx.finde(zeilen()[6], '[data-aktion="staffel-weg"]'))
assert.equal(await versuche(fx, panel), 1)
}))
test('Preis-Editor: Rabatt, Frist, Personen, Stornobedingungen, USt', mit(ukServer({ 'PUT /api/unterkunft/unterkuenfte/u1/preise': () => ({ body: {} }) }), async (fx) => {
const panel = await preisEditor(fx)
const fall = async (sel, wert, ok, feldName, re) => {
const alt = fx.finde(panel, sel).value
await fx.setze(fx.finde(panel, sel), wert)
assert.equal(await versuche(fx, panel), 0, `${sel}=${wert}`)
assert.match(fehlerText(fx, panel, feldName), re)
await fx.setze(fx.finde(panel, sel), alt)
}
await fall('#bk-p-rabatt', '51', 0, 'direktrabattProzent', /0 bis 50/)
await fall('#bk-p-frist', '0', 0, 'fristTage', /1 bis 14/)
await fall('#bk-p-frist', '15', 0, 'fristTage', /1 bis 14/)
await fall('#bk-p-inklusive', '0', 0, 'personenInklusive', /ab 1/)
await fall('#bk-p-storno', 'zu kurz', 0, 'stornobedingungen', /20 bis 5000/)
await fall('#bk-p-ust-ue', '8', 0, 'ustUebernachtung', /0, 7 oder 19/)
await fall('#bk-p-ust-re', '20', 0, 'ustReinigung', /0, 7 oder 19/)
assert.equal(await versuche(fx, panel), 1)
}))
test('Preis-Editor: gespeicherte USt 19 bleibt bei Kleinunternehmer unverändert', mit(ukServer({
'GET /api/unterkunft/unterkuenfte/u1/preise': () => ({ body: { preise: { ...PREISE, steuerart: 'kleinunternehmer', ustUebernachtung: 19, ustReinigung: 0 } } }),
'PUT /api/unterkunft/unterkuenfte/u1/preise': () => ({ body: {} }),
}), async (fx) => {
const panel = await preisEditor(fx)
assert.equal(await versuche(fx, panel), 1)
const put = posts(fx).find((a) => a.url.includes('/preise'))
assert.equal(put.body.steuerart, 'kleinunternehmer')
assert.equal(put.body.ustUebernachtung, 19)
assert.equal(put.body.ustReinigung, 0)
}))
test('fehlerFuerFormular: unbekannter Code → deutscher Standardtext', async () => {
const { fehlerFuerFormular } = await import('../public/buchungen-fehler.js')
assert.equal(fehlerFuerFormular({ fehler: 'komischer_code', message: 'Fehler 400' }).fehler._, 'Das hat nicht geklappt. Bitte versuch es noch einmal.')
assert.equal(fehlerFuerFormular({ fehler: 'zeitraum_belegt', message: 'x' }).fehler._, 'Der Zeitraum ist bereits belegt.')
})
test('Kalender: vergangene Tage lassen sich nicht sperren', mit(ukServer(), async (fx) => {
const { panel } = await zeigeTab(fx, kal)
await fx.klick(tag(fx, panel, '2026-10-01'))
assert.match(fx.text(fx.finde(panel, '[data-hinweis]')), /Vergangene Tage/)
assert.equal(fx.finde(panel, '[data-aktion="sperren-senden"]'), null)
}))
/* ===================================================== Task 14: Rechnungen, Einstellungen, Anfrage ===================================================== */
const { render: renderRechnungen } = await import('../public/buchungen-tab-rechnungen.js')
const { STORNO_MUSTER, STORNO_HINWEIS } = await import('../public/buchungen-preise.js')
const { render: renderEinstellungen, AUFTRAG_TEXT, AUFTRAG_WIDERRUF } = await import('../public/buchungen-tab-einstellungen.js')
const { renderAnfrageFormular, anfrageKarte } = await import('../public/buchungen-anfrage.js')
const { initBuchungen, vergissAuswahl } = await import('../public/buchungen.js')
const zeigeMit = async (fx, modul, ctxExtra = {}) => {
const ctx = { ...baueCtx(), ...ctxExtra }
const panel = h('div')
fx.document.body.append(panel)
await modul(panel, ctx)
await fx.ruhe()
return { panel, ctx }
}
const ES = { firma: 'Haus am See', inhaber: 'Mimi M.', strasse: 'Seeweg 1', plz: '78532', ort: 'Tuttlingen', email: 'mimi@example.com', telefon: '', steuernummer: '12/345/67890', ustId: '', iban: '', bic: '', bank: '', rechnungsPraefix: 'RE', akzentfarbe: '#336699', onlineZahlung: false, rechnungAuftrag: true }
const esServer = (o = {}, antwort = {}) => server({ 'GET /api/unterkunft/einstellungen': () => ({ body: { einstellungen: ES, stripe: { status: 'keins', vorhandenesShopKonto: false }, rechnungenVorhanden: false, ...antwort } }), 'PUT /api/unterkunft/einstellungen': () => ({ body: {} }), ...o })
test('Rechnungen: Liste mit PDF-Link, Art und Betrag; Fremdtext nur als Text', mit(server({
'GET /api/unterkunft/rechnungen': () => ({ body: { rechnungen: [
{ id: 'r1', nummer: 'RE-2026-0001', datum: '2026-10-07', art: 'rechnung', gastName: 'Anna <b>M</b>', bruttoCent: 40650 },
{ id: 'r2', nummer: 'RE-2026-0002', datum: '2026-10-08', art: 'storno', gastName: 'Anna', bruttoCent: -40650 },
] } }),
}), async (fx) => {
const { panel } = await zeigeMit(fx, renderRechnungen)
assert.equal(fx.fetchLog[0].url, '/api/unterkunft/rechnungen?projectId=p1')
const z = fx.alle(panel, '[data-rechnung]')
assert.equal(z.length, 2)
assert.match(fx.text(z[0]), /RE-2026-0001/)
assert.match(fx.text(z[0]), /07\.10\.2026/)
assert.match(fx.text(z[0]), /Anna <b>M<\/b>/)
assert.match(fx.text(z[0]), /406,50\s€/)
assert.match(fx.text(z[1]), /Storno-Rechnung/)
assert.match(fx.text(z[1]), /−406,50\s€/)
assert.equal(fx.finde(z[0], '[data-aktion="pdf"]').getAttribute('href'), '/api/unterkunft/rechnungen/r1.pdf?projectId=p1')
assert.equal(fx.finde(z[0], 'b'), null)
}))
test('Rechnungen: leer → Hinweis', mit(server({ 'GET /api/unterkunft/rechnungen': () => ({ body: { rechnungen: [] } }) }), async (fx) => {
const { panel } = await zeigeMit(fx, renderRechnungen)
assert.match(fx.text(panel), /Noch keine Rechnungen/)
assert.equal(fx.alle(panel, '[data-rechnung]').length, 0)
}))
test('Einstellungen: Pflichtfelder markiert, leeres Formular sendet nicht und zeigt Feldfehler', mit(esServer({}, { einstellungen: { ...ES, firma: '', steuernummer: '', ustId: '' } }), async (fx) => {
const { panel } = await zeigeMit(fx, renderEinstellungen)
for (const n of ['firma', 'inhaber', 'strasse', 'plz', 'ort', 'email']) assert.match(fx.text(fx.finde(panel, `[data-feld="${n}"]`).parentNode.querySelector('label')), /\*/, n)
await fx.klick(fx.finde(panel, '[data-aktion="einstellungen-speichern"]'))
await fx.ruhe()
assert.equal(posts(fx).length, 0)
assert.match(fx.text(fx.finde(panel, '[data-fehler="firma"]')), /Firma/)
assert.match(fx.text(fx.finde(panel, '[data-fehler="steuernummer"]')), /Steuernummer oder USt-IdNr/)
assert.match(fx.text(fx.finde(panel, '[data-fehler="ustId"]')), /Steuernummer oder USt-IdNr/)
}))
test('Einstellungen: speichern sendet die Felder ohne Wrapper; Serverfehler am Feld', mit(esServer({ 'PUT /api/unterkunft/einstellungen': () => ({ status: 400, body: { fehler: 'ungueltig', felder: { iban: 'Bitte gib eine gültige IBAN an.' } } }) }), async (fx) => {
const { panel } = await zeigeMit(fx, renderEinstellungen)
await fx.setze(fx.finde(panel, '[data-feld="iban"]'), 'xx')
await fx.klick(fx.finde(panel, '[data-aktion="einstellungen-speichern"]'))
await fx.ruhe()
const put = posts(fx)[0]
assert.equal(put.url, '/api/unterkunft/einstellungen?projectId=p1')
assert.equal(put.body.firma, 'Haus am See')
assert.equal(put.body.rechnungsPraefix, 'RE')
assert.equal(put.body.rechnungAuftrag, true)
assert.equal(put.body.iban, 'xx')
assert.equal(put.body.onlineZahlung, false)
assert.match(fx.text(fx.finde(panel, '[data-fehler="iban"]')), /gültige IBAN/)
}))
test('Einstellungen: Präfix prüfen; gesperrt mit Hinweis, sobald Rechnungen existieren', async () => {
await mit(esServer(), async (fx) => {
const { panel } = await zeigeMit(fx, renderEinstellungen)
await fx.setze(fx.finde(panel, '[data-feld="rechnungsPraefix"]'), 'a')
await fx.klick(fx.finde(panel, '[data-aktion="einstellungen-speichern"]'))
await fx.ruhe()
assert.equal(posts(fx).length, 0)
assert.match(fx.text(fx.finde(panel, '[data-fehler="rechnungsPraefix"]')), /2 bis 8/)
await fx.setze(fx.finde(panel, '[data-feld="rechnungsPraefix"]'), 'ab1')
await fx.klick(fx.finde(panel, '[data-aktion="einstellungen-speichern"]'))
await fx.ruhe()
assert.equal(posts(fx)[0].body.rechnungsPraefix, 'AB1')
})()
await mit(esServer({}, { rechnungenVorhanden: true }), async (fx) => {
const { panel } = await zeigeMit(fx, renderEinstellungen)
const p = fx.finde(panel, '[data-feld="rechnungsPraefix"]')
assert.ok(p.disabled)
assert.match(fx.text(p.parentNode), /gesperrt/)
await fx.klick(fx.finde(panel, '[data-aktion="einstellungen-speichern"]'))
await fx.ruhe()
assert.equal(posts(fx)[0].body.rechnungsPraefix, 'RE')
})()
})
test('Einstellungen: keine Stornobedingungen mehr; IBAN maxlength 34', mit(esServer(), async (fx) => {
const { panel } = await zeigeMit(fx, renderEinstellungen)
assert.equal(fx.finde(panel, '[data-feld="stornobedingungen"]'), null)
assert.equal(fx.finde(panel, '[data-feld="iban"]').getAttribute('maxlength'), '34')
await fx.klick(fx.finde(panel, '[data-aktion="einstellungen-speichern"]'))
await fx.ruhe()
assert.equal('stornobedingungen' in posts(fx)[0].body, false)
}))
test('Preis-Editor: Mustertext übernehmen füllt nur das Feld (kein Speichern), Hinweis „keine Rechtsberatung“', mit(ukServer(), async (fx) => {
const panel = await preisEditor(fx)
const t = fx.finde(panel, '#bk-p-storno')
assert.match(fx.text(fx.finde(panel, '[data-hinweis="storno-muster"]')), /Vorlage – bitte an deine Bedingungen anpassen, keine Rechtsberatung\./)
assert.equal(STORNO_HINWEIS, 'Vorlage – bitte an deine Bedingungen anpassen, keine Rechtsberatung.')
const vorher = posts(fx).length
await fx.klick(fx.finde(panel, '[data-aktion="storno-muster"]'))
assert.equal(t.value, STORNO_MUSTER)
assert.match(STORNO_MUSTER, /^Stornierung bis 14 Tage vor Anreise: kostenlos\./)
assert.match(STORNO_MUSTER, /Maßgeblich ist der Eingang bei uns\.$/)
assert.equal(posts(fx).length, vorher)
}))
test('Einstellungen: Zustimmung zurückziehen fragt nach; Abbrechen behält, Ja sendet rechnungAuftrag:false', mit(esServer(), async (fx) => {
const { panel } = await zeigeMit(fx, renderEinstellungen)
const box = fx.finde(panel, '[data-feld="rechnungAuftrag"]')
await fx.klick(box)
assert.ok(box.checked)
assert.match(fx.text(panel), new RegExp(AUFTRAG_WIDERRUF.slice(0, 50)))
await fx.klick(fx.finde(panel, '[data-aktion="widerruf-nein"]'))
assert.ok(box.checked)
assert.equal(fx.finde(panel, '[data-aktion="widerruf-ja"]'), null)
await fx.klick(box)
await fx.klick(fx.finde(panel, '[data-aktion="widerruf-ja"]'))
assert.equal(box.checked, false)
await fx.klick(fx.finde(panel, '[data-aktion="einstellungen-speichern"]'))
await fx.ruhe()
assert.equal(posts(fx)[0].body.rechnungAuftrag, false)
}))
test('Einstellungen: Auftrag zur Rechnungsstellung – ohne Haken Hinweis, mit Haken weg und rechnungAuftrag im PUT', mit(esServer({}, { einstellungen: { ...ES, rechnungAuftrag: false } }), async (fx) => {
const { panel } = await zeigeMit(fx, renderEinstellungen)
const box = fx.finde(panel, '[data-feld="rechnungAuftrag"]')
assert.match(fx.text(box.parentNode), new RegExp(AUFTRAG_TEXT.slice(0, 40)))
const hinweis = fx.finde(panel, '[data-hinweis="auftrag"]')
assert.equal(hinweis.hidden, false)
assert.match(fx.text(hinweis), /nicht bestätigen/)
await fx.klick(box)
assert.equal(hinweis.hidden, true)
await fx.klick(fx.finde(panel, '[data-aktion="einstellungen-speichern"]'))
await fx.ruhe()
assert.equal(posts(fx)[0].body.rechnungAuftrag, true)
}))
test('Einstellungen: Stripe nicht verbunden – Verbinden leitet nur auf https; „Vorhandenes Webshop-Konto“ nur wenn gemeldet', async () => {
await mit(esServer({ 'POST /api/unterkunft/stripe/verbinden': () => ({ body: { url: 'https://connect.stripe.com/x' } }) }), async (fx) => {
const { panel } = await zeigeMit(fx, renderEinstellungen)
assert.equal(fx.finde(panel, '[data-stripe-status]').getAttribute('data-stripe-status'), 'keins')
assert.equal(fx.finde(panel, '[data-aktion="stripe-vorhanden"]'), null)
assert.equal(fx.finde(panel, '[data-feld="onlineZahlung"]'), null)
await fx.klick(fx.finde(panel, '[data-aktion="stripe-verbinden"]'))
await fx.ruhe()
assert.equal(posts(fx)[0].url, '/api/unterkunft/stripe/verbinden?projectId=p1')
assert.equal(fx.window.location.href, 'https://connect.stripe.com/x')
})()
await mit(esServer({ 'POST /api/unterkunft/stripe/verbinden': () => ({ body: { url: 'javascript:alert(1)' } }) }), async (fx) => {
const { panel, ctx } = await zeigeMit(fx, renderEinstellungen)
await fx.klick(fx.finde(panel, '[data-aktion="stripe-verbinden"]'))
await fx.ruhe()
assert.notEqual(fx.window.location.href, 'javascript:alert(1)')
assert.equal(ctx.meldungen.at(-1).art, 'fehler')
})()
await mit(esServer({ 'POST /api/unterkunft/stripe/vorhandenes-konto': () => ({ body: { stripe: { status: 'aktiv' } } }) }, { stripe: { status: 'keins', vorhandenesShopKonto: true } }), async (fx) => {
const { panel } = await zeigeMit(fx, renderEinstellungen)
await fx.klick(fx.finde(panel, '[data-aktion="stripe-vorhanden"]'))
await fx.ruhe()
assert.equal(posts(fx)[0].url, '/api/unterkunft/stripe/vorhandenes-konto?projectId=p1')
assert.ok(fx.fetchLog.filter((a) => a.method === 'GET' && a.url.startsWith('/api/unterkunft/einstellungen')).length >= 2) // neu geladen
})()
})
test('Einstellungen: Stripe Onboarding offen und aktiv; Schalter Online-Zahlung nur bei aktiv', async () => {
await mit(esServer({ 'POST /api/unterkunft/stripe/aktualisieren': () => ({ body: { stripe: { status: 'aktiv' } } }) }, { stripe: { status: 'onboarding', vorhandenesShopKonto: true } }), async (fx) => {
const { panel } = await zeigeMit(fx, renderEinstellungen)
assert.equal(fx.finde(panel, '[data-stripe-status]').getAttribute('data-stripe-status'), 'onboarding')
assert.match(fx.text(panel), /Angaben bei Stripe/)
assert.ok(fx.finde(panel, '[data-aktion="stripe-aktualisieren"]'))
assert.equal(fx.finde(panel, '[data-aktion="stripe-vorhanden"]'), null)
assert.equal(fx.finde(panel, '[data-feld="onlineZahlung"]'), null)
})()
await mit(esServer({}, { einstellungen: { ...ES, onlineZahlung: true }, stripe: { status: 'aktiv', vorhandenesShopKonto: false } }), async (fx) => {
const { panel } = await zeigeMit(fx, renderEinstellungen)
assert.equal(fx.finde(panel, '[data-stripe-status]').getAttribute('data-stripe-status'), 'aktiv')
const schalter = fx.finde(panel, '[data-feld="onlineZahlung"]')
assert.ok(schalter.checked)
await fx.klick(schalter)
await fx.klick(fx.finde(panel, '[data-aktion="einstellungen-speichern"]'))
await fx.ruhe()
assert.equal(posts(fx)[0].body.onlineZahlung, false)
})()
})
test('Einstellungen: nur lesen sperrt Formular und Stripe-Knöpfe', mit(esServer(), async (fx) => {
const { panel } = await zeigeMit(fx, renderEinstellungen, { nurLesen: true })
assert.equal(fx.finde(panel, '[data-aktion="stripe-verbinden"]'), null)
assert.ok(fx.finde(panel, '[data-feld="firma"]').disabled)
assert.equal(fx.finde(panel, '[data-aktion="einstellungen-speichern"]'), null)
}))
test('Anfrageformular sendet { art, einheiten, portale, nachricht }; Feldfehler am Feld', async () => {
let antwort = { body: { anfrage: { status: 'offen' } } }
await mit((a) => (a.method === 'POST' ? antwort : { body: {} }), async (fx) => {
const wurzel = h('div')
fx.document.body.append(wurzel)
let danach = 0
renderAnfrageFormular(wurzel, { projectId: 'p1', nachSenden: async () => { danach += 1 } })
antwort = { status: 400, body: { fehler: 'ungueltig', felder: { art: 'Bitte wähle die Art deiner Unterkunft.' } } }
await fx.klick(fx.finde(wurzel, '[data-aktion="anfrage-senden"]'))
await fx.ruhe()
assert.match(fx.text(fx.finde(wurzel, '[data-fehler="art"]')), /Art deiner Unterkunft/)
assert.equal(danach, 0)
antwort = { body: { anfrage: { status: 'offen' } } }
await fx.setze(fx.finde(wurzel, '[data-feld="art"]'), 'ferienwohnung')
await fx.setze(fx.finde(wurzel, '[data-feld="einheiten"]'), '3')
await fx.klick(fx.finde(wurzel, '[data-feld="portale.airbnb"]'))
await fx.setze(fx.finde(wurzel, '[data-feld="nachricht"]'), ' Hallo ')
await fx.klick(fx.finde(wurzel, '[data-aktion="anfrage-senden"]'))
await fx.ruhe()
const p = posts(fx).at(-1)
assert.equal(p.url, '/api/unterkunft/anfrage-funktion?projectId=p1')
assert.deepEqual(p.body, { art: 'ferienwohnung', einheiten: 3, portale: ['airbnb'], nachricht: 'Hallo' })
assert.equal(danach, 1)
assert.match(fx.text(wurzel), /199 €/)
})()
})
test('Anfragekarte: offen mit Zurückziehen, abgelehnt mit Begründung als Text', async () => {
await mit(server(), async (fx) => {
let neuGeladen = 0
const k = anfrageKarte({ status: 'offen', at: '2026-10-07T10:00:00Z', anfrage: { art: 'monteur', einheiten: 2, portale: ['booking'], nachricht: 'x' } }, { projectId: 'p1', neuLaden: async () => { neuGeladen += 1 } })
fx.document.body.append(k)
assert.match(fx.text(k), /In Prüfung/)
assert.match(fx.text(k), /Monteurwohnung/)
assert.match(fx.text(k), /Booking\.com/)
await fx.klick(fx.finde(k, '[data-aktion="zurueckziehen"]'))
await fx.ruhe()
assert.equal(posts(fx)[0].url, '/api/unterkunft/anfrage-funktion/zurueckziehen?projectId=p1')
assert.equal(neuGeladen, 1)
const b = anfrageKarte({ status: 'abgelehnt', begruendung: 'Passt <i>nicht</i>' }, { projectId: 'p1', neu: () => {} })
assert.match(fx.text(b), /Passt <i>nicht<\/i>/)
assert.equal(fx.finde(b, 'i'), null)
assert.ok(fx.finde(b, '[data-aktion="neue-anfrage"]'))
})()
})
test('Ansicht #buchungen: Zustand off zeigt das Anfrageformular, Projekt vom Wunsch-Attribut', async () => {
vergissAuswahl()
await mit((a) => {
if (a.url === '/api/funktionen') return { body: { unterkunft: { verfuegbar: true }, projects: [{ projectId: 'p1', projectName: 'A', features: { unterkunft: { state: 'off' } } }, { projectId: 'p2', projectName: 'B', features: { unterkunft: { state: 'off' } } }] } }
if (a.url.startsWith('/api/unterkunft/status')) return { body: { state: 'off' } }
return { body: {} }
}, async (fx) => {
const root = h('div')
root.dataset.wunschProjekt = 'p2'
fx.document.body.append(root)
await initBuchungen(root)
await fx.ruhe()
assert.equal(fx.fetchLog[1].url, '/api/unterkunft/status?projectId=p2')
assert.ok(fx.finde(root, '[data-aktion="anfrage-senden"]'))
assert.match(fx.text(root), /Buchungskalender anfragen/)
})()
vergissAuswahl()
})
test('Ansicht #buchungen: Zustand ordered zeigt „Zahlung offen“ mit Zahlung fortsetzen (Checkout unterkunft)', async () => {
vergissAuswahl()
await mit((a) => {
if (a.url === '/api/funktionen') return { body: { unterkunft: { verfuegbar: true }, projects: [{ projectId: 'p1', projectName: 'A', features: { unterkunft: { state: 'ordered' } } }] } }
if (a.url.startsWith('/api/unterkunft/status')) return { body: { state: 'ordered' } }
if (a.url === '/api/funktionen/unterkunft/checkout') return { status: 409, body: { error: 'Bitte stimme zu.', code: 'andere' } }
return { body: {} }
}, async (fx) => {
const root = h('div')
fx.document.body.append(root)
await initBuchungen(root)
await fx.ruhe()
assert.match(fx.text(root), /Zahlung offen/)
await fx.klick(fx.finde(root, '[data-aktion="fortsetzen"]'))
await fx.ruhe()
assert.ok(posts(fx).some((a) => a.url === '/api/funktionen/unterkunft/checkout'))
})()
vergissAuswahl()
})
test('Anfrageformular: 409 lädt die Ansicht neu, die Meldung bleibt sichtbar; Projektwechsel setzt „neue Anfrage“ zurück', async () => {
vergissAuswahl()
let status = { state: 'off', anfrage: { status: 'abgelehnt', begruendung: 'Nein' } }
await mit((a) => {
if (a.url === '/api/funktionen') return { body: { unterkunft: { verfuegbar: true }, projects: [{ projectId: 'p1', projectName: 'A', features: { unterkunft: { state: 'off' } } }, { projectId: 'p2', projectName: 'B', features: { unterkunft: { state: 'off' } } }] } }
if (a.url.startsWith('/api/unterkunft/status')) return { body: status }
if (a.method === 'POST') { status = { state: 'off', anfrage: { status: 'offen', anfrage: {} } }; return { status: 409, body: { fehler: 'anfrage_laeuft' } } }
return { body: {} }
}, async (fx) => {
const root = h('div')
fx.document.body.append(root)
await initBuchungen(root)
await fx.ruhe()
await fx.klick(fx.finde(root, '[data-aktion="neue-anfrage"]'))
await fx.klick(fx.finde(root, '[data-aktion="anfrage-senden"]'))
await fx.ruhe()
assert.ok(fx.finde(root, '[data-anfrage="offen"]'))
assert.match(fx.text(fx.finde(root, '.sh-meldung')), /nicht geklappt/)
status = { state: 'off', anfrage: { status: 'abgelehnt', begruendung: 'Nein' } }
await fx.setze(fx.finde(root, '#buchungen-project'), 'p2')
await fx.ruhe()
assert.ok(fx.finde(root, '[data-anfrage="abgelehnt"]'))
})()
vergissAuswahl()
})
+17
View File
@@ -564,3 +564,20 @@ test('Webshop-Kachel: Projekt-ID maskiert, Klick legt das Wunschprojekt in #webs
}
assert.equal(body.dataset.wunschProjekt, 'p7')
})
// ---- Buchungskalender-Kachel: nur bei unterkunft.verfuegbar, Preis 199 €, Zustände wie die Webshop-Kachel
test('Buchungskalender-Kachel: nur bei unterkunft.verfuegbar; Text, Preis und Zustände', async () => {
const mitUk = (state) => project('a', undefined) && { ...project('a'), features: { news, unterkunft: { state } } }
const ohne = await renderMit([mitUk('off')], {})
assert.doesNotMatch(ohne, /data-fn-tile="unterkunft"/)
const zu = await renderMit([mitUk('off')], { unterkunft: { verfuegbar: false } })
assert.doesNotMatch(zu, /data-fn-tile="unterkunft"/)
const off = tile(await renderMit([mitUk('off')], { unterkunft: { verfuegbar: true } }), 'unterkunft')
assert.match(off, /<h3>Buchungskalender<\/h3>/)
assert.match(off, /Belegungskalender auf deiner Website, Anfragen freigeben, Rechnung und Online-Zahlung/)
assert.match(off, /199 €/)
assert.match(off, /Buchungskalender anfragen/)
assert.match(off, /data-view="buchungen" data-project="a"/)
const faelle = { active: /Aktiv[\s\S]*Buchungskalender öffnen/, setup: /In Einrichtung/, ordered: /Zahlung offen/, disabled: /Abgeschaltet[\s\S]*Buchungskalender ansehen/ }
for (const [state, re] of Object.entries(faelle)) assert.match(tile(await renderMit([mitUk(state)], { unterkunft: { verfuegbar: true } }), 'unterkunft'), re, state)
})
+1 -1
View File
@@ -127,7 +127,7 @@ test('Portal: app.js und dashboard.html kennen den Reiter buchungen', () => {
const html = lies('public/dashboard.html')
assert.match(html, /<section class="kb-view" id="view-buchungen">[\s\S]*?<div id="buchungen-body">[\s\S]*?<\/section>/)
assert.equal([...html.matchAll(/id="buchungen-body"/g)].length, 1)
assert.match(lies('public/buchungen.js'), /export function initBuchungen\(el\)/)
assert.match(lies('public/buchungen.js'), /export function initBuchungen\(root, \{ projectId \} = \{\}\)/)
})
test('Server: index.js hängt Mounts und Jobs ein, die Router-Dateien existieren', () => {