Buchungskalender: Strang ui zusammenführen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
83cdab405b
18 files changed
+2520
-5
No files matched your search
@@ -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')))
|
||||||
|
}
|
||||||
@@ -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 } }
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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 } }
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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
@@ -1,4 +1,212 @@
|
|||||||
// Buchungskalender (Reiter #buchungen). Platzhalter; die Oberflaeche baut Strang 4.
|
/* Buchungskalender im Kundenbereich (ES-Modul, von app.js beim Oeffnen der Ansicht #buchungen geladen). Aufbau wie shop.js:
|
||||||
export function initBuchungen(el) {
|
initBuchungen(rootEl, { projectId }): Projekt waehlen (GET /api/funktionen, features.unterkunft), Stand holen (GET /api/unterkunft/status)
|
||||||
el.textContent = 'Buchungskalender wird geladen …'
|
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)
|
||||||
}
|
}
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
<link rel="stylesheet" href="/analyse.css">
|
<link rel="stylesheet" href="/analyse.css">
|
||||||
<link rel="stylesheet" href="/termine.css">
|
<link rel="stylesheet" href="/termine.css">
|
||||||
<link rel="stylesheet" href="/shop.css">
|
<link rel="stylesheet" href="/shop.css">
|
||||||
|
<link rel="stylesheet" href="/buchungen.css">
|
||||||
<link rel="stylesheet" href="/zeiten.css">
|
<link rel="stylesheet" href="/zeiten.css">
|
||||||
</head>
|
</head>
|
||||||
<body class="portal-page dashboard-page kb-page">
|
<body class="portal-page dashboard-page kb-page">
|
||||||
|
|||||||
+52
-1
@@ -260,6 +260,51 @@ function shopTile(projects, features) {
|
|||||||
</article>`
|
</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").
|
/* 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
|
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
|
(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) {
|
export async function initFunktionen(root) {
|
||||||
if (!root) return
|
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 = `
|
root.innerHTML = `
|
||||||
<div class="fn-tiles">
|
<div class="fn-tiles">
|
||||||
${newsTile(projects, newsOffer(features).price)}
|
${newsTile(projects, newsOffer(features).price)}
|
||||||
${termine?.verfuegbar ? termineTile(projects, termine) : soonTile('calendar', 'Terminplaner', TERMINE_TEXT)}
|
${termine?.verfuegbar ? termineTile(projects, termine) : soonTile('calendar', 'Terminplaner', TERMINE_TEXT)}
|
||||||
${shop?.verfuegbar ? shopTile(projects, features) : ''}
|
${shop?.verfuegbar ? shopTile(projects, features) : ''}
|
||||||
|
${unterkunft?.verfuegbar === true ? unterkunftTile(projects, features) : ''}
|
||||||
${zeiten?.verfuegbar === true ? zeitenTile(projects, zeiten) : ''}
|
${zeiten?.verfuegbar === true ? zeitenTile(projects, zeiten) : ''}
|
||||||
${analyseTile(projects)}
|
${analyseTile(projects)}
|
||||||
</div>`
|
</div>`
|
||||||
@@ -407,6 +453,11 @@ export async function initFunktionen(root) {
|
|||||||
const body = document.getElementById('webshop-body')
|
const body = document.getElementById('webshop-body')
|
||||||
if (body) body.dataset.wunschProjekt = ev.currentTarget.dataset.project
|
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
|
// 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) => {
|
root.querySelector('[data-fn-tile="zeiten"]')?.addEventListener('click', (ev) => {
|
||||||
const knopf = ev.target?.closest?.('button[data-view="zeiten"]')
|
const knopf = ev.target?.closest?.('button[data-view="zeiten"]')
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
@@ -564,3 +564,20 @@ test('Webshop-Kachel: Projekt-ID maskiert, Klick legt das Wunschprojekt in #webs
|
|||||||
}
|
}
|
||||||
assert.equal(body.dataset.wunschProjekt, 'p7')
|
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)
|
||||||
|
})
|
||||||
@@ -127,7 +127,7 @@ test('Portal: app.js und dashboard.html kennen den Reiter buchungen', () => {
|
|||||||
const html = lies('public/dashboard.html')
|
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.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.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', () => {
|
test('Server: index.js hängt Mounts und Jobs ein, die Router-Dateien existieren', () => {
|
||||||
|
|||||||
Reference in new issue
Block a user