134 lines
9.7 KiB
JavaScript
134 lines
9.7 KiB
JavaScript
/* 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
|
||
}
|