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