Buchungskalender: Preise je Personenzahl (Portal-Editor)
Neuer Haken "Preis hängt von der Personenzahl ab" im Preis-Editor (nur bei Höchstzahl ab 2 Personen). Angeschaltet zeigt jede Staffel k Euro-Felder (1 Person bis k Personen, k = min(Höchstzahl, 10)) statt eines Preises; "Personen im Preis inklusive" und "Aufpreis je weiterer Person" sind dann ausgeblendet, ihre Werte gehen unverändert mit. Speichern sendet personenCent und preisProNachtCent = personenCent[0]. Beim Laden ist der Haken an, wenn personenCent gespeichert sind. Kundenansicht (nur lesen) sperrt Haken und Felder. Felder brechen auf dem Handy um. Tests im Fake-DOM. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a4ade5f649
commit
1e0eb9f569
3 files changed
+274
-10
No files matched your search
+66
-10
@@ -1,9 +1,15 @@
|
||||
/* 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,
|
||||
/* Buchungskalender: Preis-Editor einer Unterkunft (Staffeln, Preise je Personenzahl, Reinigung, Zusatzpersonen, Rabatt, Steuer, Frist, Stornobedingungen) mit Beispielrechnung.
|
||||
GET|PUT /api/unterkunft/unterkuenfte/:id/preise => { preise:{ staffeln:[{abNaechten,preisProNachtCent,personenCent?}], 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). */
|
||||
POST /api/unterkunft/preisvorschau { unterkunftId, von, bis, personen } => { naechte, posten, summen:{ bruttoCent } } (rechnet mit den gespeicherten Preisen).
|
||||
Zwei Preismodelle, umgeschaltet mit dem Haken "Preis hängt von der Personenzahl ab" (#bk-p-personenmodus, nur bei unterkunft.maxPersonen >= 2):
|
||||
- aus: ein Preis pro Nacht je Staffel; "Personen im Preis inklusive" und "Aufpreis je weiterer Person" gelten wie bisher.
|
||||
- an: je Staffel k Euro-Felder (k = min(maxPersonen, 10), "1 Person" ... "k Personen") = Preis pro Nacht fuer die ganze Unterkunft; gesendet wird
|
||||
staffeln:[{abNaechten, preisProNachtCent: personenCent[0], personenCent}]. Die beiden Personenfelder sind dann ausgeblendet, ihre Werte gehen unveraendert mit.
|
||||
Beim Laden ist der Haken an, wenn die gespeicherten Staffeln personenCent haben (vorhandene Werte eingesetzt, fehlende leer, ueberzaehlige verworfen).
|
||||
ctx.nurLesen sperrt alle Felder samt Haken. */
|
||||
|
||||
import { abschnitt, allgemeinerFehler, euroEingabe, euroText, feld, h, setzeFehler, zeigeFehler } from './shop-dom.js'
|
||||
import { abschnitt, allgemeinerFehler, euroEingabe, euroText, feld, h, hakenFeld, setzeFehler, zeigeFehler } from './shop-dom.js'
|
||||
import { enc, heuteBerlin, plusTage, zeichen, zeigeApiFehler } from './buchungen-uk-hilfe.js'
|
||||
|
||||
// Wortgleich zu docs/funktionen/unterkunft-storno-muster.md, Abschnitt "Stornobedingungen (Muster)" (ohne Markdown-Fettdruck); test/buchungenUi.test.js vergleicht beides.
|
||||
@@ -30,6 +36,7 @@ export const STORNO_MUSTER = [
|
||||
].join('\n')
|
||||
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 PERSONEN_MAX = 10
|
||||
const ganzzahl = (steuer) => { const t = String(steuer.value).trim(); return /^\d{1,9}$/.test(t) ? Number(t) : null }
|
||||
|
||||
export async function zeichnePreise(ctx, unterkunft) {
|
||||
@@ -44,13 +51,27 @@ export async function zeichnePreise(ctx, unterkunft) {
|
||||
wurzel.replaceChildren(h('p', { class: 'sh-fehler', role: 'alert' }, err.message))
|
||||
return wurzel
|
||||
}
|
||||
// Preise je Personenzahl: nur sinnvoll, wenn mehr als eine Person in die Unterkunft passt; k Spalten (1 bis k Personen)
|
||||
const gaeste = Number.isInteger(unterkunft?.maxPersonen) ? Math.min(unterkunft.maxPersonen, PERSONEN_MAX) : 0
|
||||
const modusMoeglich = gaeste >= 2
|
||||
const modusBox = h('input', { type: 'checkbox', id: 'bk-p-personenmodus' })
|
||||
modusBox.checked = modusMoeglich && Array.isArray(preise.staffeln) && preise.staffeln.some((s) => Array.isArray(s?.personenCent) && s.personenCent.length > 0)
|
||||
const personenText = (n) => `${n} ${n === 1 ? 'Person' : 'Personen'}`
|
||||
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 ',
|
||||
const spalten = Array.from({ length: modusMoeglich ? gaeste : 0 }, (_, i) => {
|
||||
const eingabe = euroEingabe({ cent: Array.isArray(s.personenCent) ? s.personenCent[i] : null, label: `Preis pro Nacht in Euro für ${personenText(i + 1)}` })
|
||||
eingabe.dataset.staffel = 'person'
|
||||
eingabe.dataset.personen = String(i + 1)
|
||||
return h('label', { class: 'bk-personpreis' }, h('span', null, personenText(i + 1)), eingabe)
|
||||
})
|
||||
const el = h('div', { class: 'bk-staffel', 'data-staffel-zeile': true }, 'ab ', ab, ' Nächten: ',
|
||||
h('span', { class: 'bk-preis-einzeln' }, preis, ' € pro Nacht '),
|
||||
h('span', { class: 'bk-personenpreise', 'data-personenpreise': true }, spalten),
|
||||
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
|
||||
}
|
||||
@@ -92,14 +113,33 @@ export async function zeichnePreise(ctx, unterkunft) {
|
||||
}
|
||||
for (const el of [naechteEl, personenEl]) el.addEventListener('change', vorschau)
|
||||
|
||||
const modusFeld = hakenFeld('personenmodus', modusBox, 'Preis hängt von der Personenzahl ab', 'Zum Beispiel 55 € für 1 Person, 70 € für 2 Personen und 82 € für 3 Personen pro Nacht.')
|
||||
modusFeld.hidden = !modusMoeglich
|
||||
modusBox.hidden = !modusMoeglich
|
||||
const modusHinweis = h('p', { class: 'sh-hinweis', 'data-hinweis': 'personenpreise' }, 'Preis pro Nacht für die ganze Unterkunft, je nach Anzahl der Gäste.')
|
||||
const inklusiveFeld = feld('personenInklusive', 'Personen im Preis inklusive', inklusive)
|
||||
const aufpreisFeld = feld('aufpreisPersonNachtCent', 'Aufpreis je weiterer Person und Nacht in Euro', aufpreis)
|
||||
/** Haken an: je Staffel die Spalten je Personenzahl statt des einen Preises; die zwei Personenfelder des alten Modells verschwinden (ihre Werte gehen unveraendert mit). */
|
||||
const zeigeModus = () => {
|
||||
const an = modusMoeglich && modusBox.checked
|
||||
for (const el of zeilenEl.querySelectorAll('.bk-preis-einzeln')) el.hidden = an
|
||||
for (const el of zeilenEl.querySelectorAll('.bk-personenpreise')) el.hidden = !an
|
||||
modusHinweis.hidden = !an
|
||||
inklusiveFeld.hidden = an
|
||||
aufpreisFeld.hidden = an
|
||||
}
|
||||
modusBox.addEventListener('change', zeigeModus)
|
||||
const staffelDazu = () => { zeilenEl.append(zeile({ abNaechten: null, preisProNachtCent: null })); zeigeModus() }
|
||||
|
||||
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.'),
|
||||
modusFeld, modusHinweis,
|
||||
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'),
|
||||
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'staffel-dazu', onclick: staffelDazu }, '+ 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),
|
||||
inklusiveFeld,
|
||||
aufpreisFeld,
|
||||
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.'),
|
||||
@@ -109,6 +149,7 @@ export async function zeichnePreise(ctx, unterkunft) {
|
||||
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')))
|
||||
zeigeModus()
|
||||
if (ctx.nurLesen) for (const el of form.querySelectorAll('input, select, textarea, button')) el.disabled = true
|
||||
// Ein Feldfehler (z. B. Platzhalter in den Stornobedingungen) verschwindet, sobald im Feld getippt wird
|
||||
for (const el of form.querySelectorAll('[data-feld]')) {
|
||||
@@ -125,8 +166,16 @@ export async function zeichnePreise(ctx, unterkunft) {
|
||||
const fehler = {}
|
||||
const staffeln = []
|
||||
let staffelFehler = ''
|
||||
const personenModus = modusMoeglich && modusBox.checked
|
||||
for (const z of zeilenEl.children) {
|
||||
const ab = ganzzahl(z.querySelector('[data-staffel="ab"]'))
|
||||
if (personenModus) {
|
||||
const spalten = [...z.querySelectorAll('[data-staffel="person"]')].map((el) => el.inCent())
|
||||
if (ab === null || ab < 1 || spalten.some((p) => p === null)) staffelFehler ||= 'Bitte gib bei jeder Staffel Nächte (ganze Zahl) und für jede Personenzahl einen Preis an.'
|
||||
else if (spalten.some((p) => p < 100 || p > 100000)) staffelFehler ||= 'Der Preis pro Nacht muss zwischen 1,00 € und 1.000,00 € liegen.'
|
||||
else staffeln.push({ abNaechten: ab, preisProNachtCent: spalten[0], personenCent: spalten })
|
||||
continue
|
||||
}
|
||||
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.'
|
||||
@@ -141,8 +190,15 @@ export async function zeichnePreise(ctx, unterkunft) {
|
||||
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()
|
||||
// Im Personen-Modus sind "inklusive" und "Aufpreis" ausgeblendet: ihr Wert geht unveraendert mit; Unbrauchbares im unsichtbaren Feld bleibt ohne Fehlermeldung beim gespeicherten Wert
|
||||
let aufpreisCent = aufpreis.istLeer() ? 0 : aufpreis.inCent()
|
||||
if (personenModus && (aufpreisCent === null || aufpreisCent > 100000)) aufpreisCent = Number.isInteger(preise.aufpreisPersonNachtCent) ? preise.aufpreisPersonNachtCent : 0
|
||||
if (aufpreisCent === null) fehler.aufpreisPersonNachtCent = 'Bitte gib einen Betrag wie 15,00 an.'
|
||||
const inklusiveAlt = Number.isInteger(preise.personenInklusive) && preise.personenInklusive >= 1 && preise.personenInklusive <= 50 ? preise.personenInklusive : 2
|
||||
const inklusiveNeu = ganzzahl(inklusive)
|
||||
const inklusiveWert = personenModus
|
||||
? (inklusiveNeu !== null && inklusiveNeu >= 1 && inklusiveNeu <= 50 ? inklusiveNeu : inklusiveAlt)
|
||||
: zahl('personenInklusive', inklusive, 'Bitte gib eine ganze Zahl ab 1 an.', 1, 1000)
|
||||
const regel = steuerart.value === 'regel'
|
||||
const ustWert = (name, steuer, alt) => {
|
||||
if (!regel) return ganzzahl(steuer) ?? alt // andere Steuerart: gespeicherten Wert unveraendert zurueckschicken
|
||||
@@ -155,7 +211,7 @@ export async function zeichnePreise(ctx, unterkunft) {
|
||||
if (!fehler.stornobedingungen && /\[[^\]]*\]/.test(storn)) fehler.stornobedingungen = 'Bitte ersetze die Platzhalter in eckigen Klammern durch deine Werte.'
|
||||
const body = {
|
||||
staffeln, reinigungCent, aufpreisPersonNachtCent: aufpreisCent,
|
||||
personenInklusive: zahl('personenInklusive', inklusive, 'Bitte gib eine ganze Zahl ab 1 an.', 1, 1000),
|
||||
personenInklusive: inklusiveWert,
|
||||
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),
|
||||
|
||||
@@ -52,6 +52,11 @@
|
||||
.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; }
|
||||
/* Preise je Personenzahl: ein Euro-Feld je Gaestezahl unter der Beschriftung; bricht auf dem Handy um, kein Seitwaertsscrollen */
|
||||
.bk-personenpreise { display: flex; flex-wrap: wrap; gap: 8px 12px; align-items: flex-end; min-width: 0; max-width: 100%; }
|
||||
.bk-personenpreise[hidden], .bk-preis-einzeln[hidden] { display: none; }
|
||||
.bk-personpreis { display: grid; gap: 2px; font-size: 13px; font-weight: 600; }
|
||||
.bk-personpreis > span { white-space: nowrap; }
|
||||
.bk-vorschau { font-weight: 700; }
|
||||
.bk-import { display: flex; align-items: center; gap: 4px 12px; flex-wrap: wrap; }
|
||||
.bk-anleitung details { margin: 8px 0; }
|
||||
|
||||
Reference in new issue
Block a user