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.
|
/* 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}], reinigungCent, personenInklusive, aufpreisPersonNachtCent,
|
GET|PUT /api/unterkunft/unterkuenfte/:id/preise => { preise:{ staffeln:[{abNaechten,preisProNachtCent,personenCent?}], reinigungCent, personenInklusive, aufpreisPersonNachtCent,
|
||||||
direktrabattProzent, steuerart:'kleinunternehmer'|'regel', ustUebernachtung, ustReinigung, fristTage, stornobedingungen } | null }
|
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'
|
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.
|
// 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')
|
].join('\n')
|
||||||
export const STORNO_HINWEIS = 'Vorlage – bitte an deine Bedingungen anpassen, keine Rechtsberatung.'
|
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 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 }
|
const ganzzahl = (steuer) => { const t = String(steuer.value).trim(); return /^\d{1,9}$/.test(t) ? Number(t) : null }
|
||||||
|
|
||||||
export async function zeichnePreise(ctx, unterkunft) {
|
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))
|
wurzel.replaceChildren(h('p', { class: 'sh-fehler', role: 'alert' }, err.message))
|
||||||
return wurzel
|
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 zeilenEl = h('div', { class: 'bk-staffeln' })
|
||||||
const zeile = (s) => {
|
const zeile = (s) => {
|
||||||
const ab = h('input', { type: 'text', inputmode: 'numeric', 'aria-label': 'Ab Nächten', 'data-staffel': 'ab', autocomplete: 'off' })
|
const ab = h('input', { type: 'text', inputmode: 'numeric', 'aria-label': 'Ab Nächten', 'data-staffel': 'ab', autocomplete: 'off' })
|
||||||
ab.value = String(s.abNaechten ?? '')
|
ab.value = String(s.abNaechten ?? '')
|
||||||
const preis = euroEingabe({ cent: s.preisProNachtCent, label: 'Preis pro Nacht in Euro' })
|
const preis = euroEingabe({ cent: s.preisProNachtCent, label: 'Preis pro Nacht in Euro' })
|
||||||
preis.dataset.staffel = 'preis'
|
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() } } }, '−'))
|
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
|
return el
|
||||||
}
|
}
|
||||||
@@ -92,14 +113,33 @@ export async function zeichnePreise(ctx, unterkunft) {
|
|||||||
}
|
}
|
||||||
for (const el of [naechteEl, personenEl]) el.addEventListener('change', vorschau)
|
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() },
|
const form = h('form', { novalidate: true, class: 'bk-preisform', onsubmit: (ev) => ev.preventDefault() },
|
||||||
h('h4', null, 'Preis pro Nacht (Staffeln)'),
|
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.'),
|
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' }),
|
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('reinigungCent', 'Endreinigung in Euro', reinigung),
|
||||||
feld('personenInklusive', 'Personen im Preis inklusive', inklusive),
|
inklusiveFeld,
|
||||||
feld('aufpreisPersonNachtCent', 'Aufpreis je weiterer Person und Nacht in Euro', aufpreis),
|
aufpreisFeld,
|
||||||
feld('direktrabattProzent', 'Direktbuchungsrabatt in %', rabatt, 'Ganze Zahl von 0 bis 50. Gilt für Übernachtung und Zusatzpersonen, nicht für die Reinigung.'),
|
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('steuerart', 'Steuer', steuerart), ustFelder,
|
||||||
feld('fristTage', 'Frist für die Buchung nach Freigabe in Tagen', frist, '1 bis 14 Tage.'),
|
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)),
|
h('p', { class: 'sh-hinweis', 'data-hinweis': 'storno-muster' }, STORNO_HINWEIS)),
|
||||||
allgemeinerFehler(),
|
allgemeinerFehler(),
|
||||||
h('div', { class: 'bk-aktionen' }, ctx.nurLesen ? null : h('button', { type: 'button', class: 'kb-btn', 'data-aktion': 'preise-speichern', onclick: speichern }, 'Preise speichern')))
|
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
|
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
|
// Ein Feldfehler (z. B. Platzhalter in den Stornobedingungen) verschwindet, sobald im Feld getippt wird
|
||||||
for (const el of form.querySelectorAll('[data-feld]')) {
|
for (const el of form.querySelectorAll('[data-feld]')) {
|
||||||
@@ -125,8 +166,16 @@ export async function zeichnePreise(ctx, unterkunft) {
|
|||||||
const fehler = {}
|
const fehler = {}
|
||||||
const staffeln = []
|
const staffeln = []
|
||||||
let staffelFehler = ''
|
let staffelFehler = ''
|
||||||
|
const personenModus = modusMoeglich && modusBox.checked
|
||||||
for (const z of zeilenEl.children) {
|
for (const z of zeilenEl.children) {
|
||||||
const ab = ganzzahl(z.querySelector('[data-staffel="ab"]'))
|
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()
|
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.'
|
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 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 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()
|
const reinigungCent = reinigung.istLeer() ? 0 : reinigung.inCent()
|
||||||
if (reinigungCent === null) fehler.reinigungCent = 'Bitte gib einen Betrag wie 60,00 an.'
|
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.'
|
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 regel = steuerart.value === 'regel'
|
||||||
const ustWert = (name, steuer, alt) => {
|
const ustWert = (name, steuer, alt) => {
|
||||||
if (!regel) return ganzzahl(steuer) ?? alt // andere Steuerart: gespeicherten Wert unveraendert zurueckschicken
|
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.'
|
if (!fehler.stornobedingungen && /\[[^\]]*\]/.test(storn)) fehler.stornobedingungen = 'Bitte ersetze die Platzhalter in eckigen Klammern durch deine Werte.'
|
||||||
const body = {
|
const body = {
|
||||||
staffeln, reinigungCent, aufpreisPersonNachtCent: aufpreisCent,
|
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),
|
direktrabattProzent: zahl('direktrabattProzent', rabatt, 'Bitte gib eine ganze Zahl von 0 bis 50 an.', 0, 50),
|
||||||
steuerart: steuerart.value,
|
steuerart: steuerart.value,
|
||||||
ustUebernachtung: ustWert('ustUebernachtung', ustUe, preise.ustUebernachtung ?? 7),
|
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-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 { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-bottom: 8px; }
|
||||||
.bk-staffel input { width: 90px; }
|
.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-vorschau { font-weight: 700; }
|
||||||
.bk-import { display: flex; align-items: center; gap: 4px 12px; flex-wrap: wrap; }
|
.bk-import { display: flex; align-items: center; gap: 4px 12px; flex-wrap: wrap; }
|
||||||
.bk-anleitung details { margin: 8px 0; }
|
.bk-anleitung details { margin: 8px 0; }
|
||||||
|
|||||||
@@ -868,3 +868,206 @@ test('Preis-Editor: Platzhalter-Feldfehler verschwindet beim Tippen', mit(ukServ
|
|||||||
await fx.setze(storno, 'Bis zu 14 Tage vor Anreise kostenfrei stornierbar, danach 100 %.')
|
await fx.setze(storno, 'Bis zu 14 Tage vor Anreise kostenfrei stornierbar, danach 100 %.')
|
||||||
assert.equal(fehlerText(fx, panel, 'stornobedingungen'), '')
|
assert.equal(fehlerText(fx, panel, 'stornobedingungen'), '')
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// ---- Preis-Editor: Preise je Personenzahl (staffeln[].personenCent), Haken #bk-p-personenmodus
|
||||||
|
const PERSONEN_PREISE = {
|
||||||
|
...PREISE,
|
||||||
|
staffeln: [
|
||||||
|
{ abNaechten: 1, preisProNachtCent: 5500, personenCent: [5500, 7000, 8200] },
|
||||||
|
{ abNaechten: 7, preisProNachtCent: 5000, personenCent: [5000, 6500, 7700] },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
const PUT_PREISE = { 'PUT /api/unterkunft/unterkuenfte/u1/preise': () => ({ body: {} }) }
|
||||||
|
/** Server mit anderen Preisen und/oder anderer Gaestezahl der Unterkunft. */
|
||||||
|
const personenServer = (preise, maxPersonen = 3, o = {}) => ukServer({
|
||||||
|
'GET /api/unterkunft/unterkuenfte': () => ({ body: { unterkuenfte: [{ ...UK, maxPersonen }] } }),
|
||||||
|
'GET /api/unterkunft/unterkuenfte/u1/preise': () => ({ body: { preise } }),
|
||||||
|
...PUT_PREISE,
|
||||||
|
...o,
|
||||||
|
})
|
||||||
|
const zeilen = (fx, panel) => fx.alle(panel, '[data-staffel-zeile]')
|
||||||
|
const spalten = (fx, zeile) => fx.alle(zeile, '[data-staffel="person"]')
|
||||||
|
const putPreise = (fx) => posts(fx).filter((a) => a.url.includes('/preise')).at(-1)
|
||||||
|
const modusBox = (fx, panel) => fx.finde(panel, '#bk-p-personenmodus')
|
||||||
|
const sichtbar = (el) => { for (let e = el; e && e.hidden !== undefined; e = e.parentElement) if (e.hidden) return false; return true }
|
||||||
|
|
||||||
|
test('Preis-Editor Personen-Modus: gespeicherte personenCent laden (Haken an, k Felder je Staffel), Speichern sendet sie unverändert', mit(personenServer(PERSONEN_PREISE), async (fx) => {
|
||||||
|
const panel = await preisEditor(fx)
|
||||||
|
assert.equal(modusBox(fx, panel).checked, true)
|
||||||
|
assert.equal(zeilen(fx, panel).length, 2)
|
||||||
|
for (const [i, erwartet] of [['5500', '7000', '8200'], ['5000', '6500', '7700']].entries()) {
|
||||||
|
const felder = spalten(fx, zeilen(fx, panel)[i])
|
||||||
|
assert.equal(felder.length, 3)
|
||||||
|
assert.deepEqual(felder.map((f) => f.value), erwartet.map((c) => `${c.slice(0, -2)},${c.slice(-2)}`))
|
||||||
|
assert.deepEqual(felder.map((f) => f.dataset.personen), ['1', '2', '3'])
|
||||||
|
assert.ok(felder.every(sichtbar))
|
||||||
|
assert.equal(sichtbar(fx.finde(zeilen(fx, panel)[i], '[data-staffel="preis"]')), false, 'der eine Preis ist ausgeblendet')
|
||||||
|
}
|
||||||
|
const beschriftung = fx.text(zeilen(fx, panel)[0])
|
||||||
|
assert.match(beschriftung, /1 Person(?!en)/)
|
||||||
|
assert.match(beschriftung, /2 Personen/)
|
||||||
|
assert.match(beschriftung, /3 Personen/)
|
||||||
|
assert.equal(sichtbar(fx.finde(panel, '[data-hinweis="personenpreise"]')), true)
|
||||||
|
assert.equal(fx.text(fx.finde(panel, '[data-hinweis="personenpreise"]')), 'Preis pro Nacht für die ganze Unterkunft, je nach Anzahl der Gäste.')
|
||||||
|
assert.equal(sichtbar(fx.finde(panel, '#bk-p-inklusive')), false)
|
||||||
|
assert.equal(sichtbar(fx.finde(panel, '#bk-p-aufpreis')), false)
|
||||||
|
assert.equal(await versuche(fx, panel), 1)
|
||||||
|
const put = putPreise(fx)
|
||||||
|
assert.deepEqual(put.body.staffeln, PERSONEN_PREISE.staffeln)
|
||||||
|
assert.equal(put.body.personenInklusive, 2, 'ausgeblendete Felder gehen unverändert mit')
|
||||||
|
assert.equal(put.body.aufpreisPersonNachtCent, 1000)
|
||||||
|
assert.equal(put.body.reinigungCent, 6000)
|
||||||
|
}))
|
||||||
|
|
||||||
|
test('Preis-Editor Personen-Modus: preisProNachtCent im Body ist personenCent[0]; Felder lassen sich ändern', mit(personenServer(PERSONEN_PREISE), async (fx) => {
|
||||||
|
const panel = await preisEditor(fx)
|
||||||
|
await fx.setze(spalten(fx, zeilen(fx, panel)[1])[0], '48,50')
|
||||||
|
await fx.setze(spalten(fx, zeilen(fx, panel)[1])[2], '75')
|
||||||
|
assert.equal(await versuche(fx, panel), 1)
|
||||||
|
assert.deepEqual(putPreise(fx).body.staffeln[1], { abNaechten: 7, preisProNachtCent: 4850, personenCent: [4850, 6500, 7500] })
|
||||||
|
}))
|
||||||
|
|
||||||
|
test('Preis-Editor Personen-Modus: Haken schaltet vom alten Modell um (k Felder aus maxPersonen, Hilfsfelder weg) und zurück', mit(personenServer(PREISE, 4), async (fx) => {
|
||||||
|
const panel = await preisEditor(fx)
|
||||||
|
const box = modusBox(fx, panel)
|
||||||
|
assert.equal(box.checked, false)
|
||||||
|
assert.equal(sichtbar(box), true)
|
||||||
|
assert.equal(sichtbar(fx.finde(panel, '#bk-p-inklusive')), true)
|
||||||
|
assert.equal(sichtbar(fx.finde(panel, '#bk-p-aufpreis')), true)
|
||||||
|
assert.equal(sichtbar(fx.finde(panel, '[data-hinweis="personenpreise"]')), false)
|
||||||
|
assert.ok(zeilen(fx, panel).every((z) => sichtbar(fx.finde(z, '[data-staffel="preis"]')) && spalten(fx, z).every((f) => !sichtbar(f))))
|
||||||
|
await fx.klick(box)
|
||||||
|
assert.equal(box.checked, true)
|
||||||
|
for (const z of zeilen(fx, panel)) {
|
||||||
|
assert.equal(spalten(fx, z).length, 4, 'k = maxPersonen')
|
||||||
|
assert.ok(spalten(fx, z).every((f) => sichtbar(f) && f.value === ''), 'fehlende Werte bleiben leer')
|
||||||
|
assert.equal(sichtbar(fx.finde(z, '[data-staffel="preis"]')), false)
|
||||||
|
}
|
||||||
|
assert.equal(sichtbar(fx.finde(panel, '#bk-p-inklusive')), false)
|
||||||
|
assert.equal(sichtbar(fx.finde(panel, '#bk-p-aufpreis')), false)
|
||||||
|
assert.equal(sichtbar(fx.finde(panel, '[data-hinweis="personenpreise"]')), true)
|
||||||
|
// neue Staffel hat die Felder ebenfalls
|
||||||
|
await fx.klick(aktion(fx, panel, 'staffel-dazu'))
|
||||||
|
assert.equal(spalten(fx, zeilen(fx, panel)[2]).length, 4)
|
||||||
|
assert.ok(spalten(fx, zeilen(fx, panel)[2]).every(sichtbar))
|
||||||
|
await fx.klick(fx.finde(zeilen(fx, panel)[2], '[data-aktion="staffel-weg"]'))
|
||||||
|
// zurück: alter Modus wie bisher, der eine Preis blieb erhalten
|
||||||
|
await fx.klick(box)
|
||||||
|
assert.equal(box.checked, false)
|
||||||
|
assert.equal(fx.finde(zeilen(fx, panel)[0], '[data-staffel="preis"]').value, '55,00')
|
||||||
|
assert.equal(sichtbar(fx.finde(panel, '#bk-p-inklusive')), true)
|
||||||
|
assert.equal(await versuche(fx, panel), 1)
|
||||||
|
assert.deepEqual(putPreise(fx).body.staffeln, PREISE.staffeln)
|
||||||
|
assert.ok(putPreise(fx).body.staffeln.every((s) => !('personenCent' in s)))
|
||||||
|
}))
|
||||||
|
|
||||||
|
test('Preis-Editor Personen-Modus: Umschalten, alle Felder füllen, Speichern', mit(personenServer(PREISE, 3), async (fx) => {
|
||||||
|
const panel = await preisEditor(fx)
|
||||||
|
await fx.klick(modusBox(fx, panel))
|
||||||
|
assert.equal(await versuche(fx, panel), 0, 'leere Felder werden nicht gesendet')
|
||||||
|
assert.match(fehlerText(fx, panel, 'staffeln'), /für jede Personenzahl einen Preis/)
|
||||||
|
const werte = [['55', '70', '82'], ['50', '65', '77']]
|
||||||
|
for (const [i, z] of zeilen(fx, panel).entries()) for (const [j, f] of spalten(fx, z).entries()) await fx.setze(f, werte[i][j])
|
||||||
|
assert.equal(await versuche(fx, panel), 1)
|
||||||
|
assert.deepEqual(putPreise(fx).body.staffeln, [
|
||||||
|
{ abNaechten: 1, preisProNachtCent: 5500, personenCent: [5500, 7000, 8200] },
|
||||||
|
{ abNaechten: 7, preisProNachtCent: 5000, personenCent: [5000, 6500, 7700] },
|
||||||
|
])
|
||||||
|
assert.equal(putPreise(fx).body.personenInklusive, 2)
|
||||||
|
assert.equal(putPreise(fx).body.aufpreisPersonNachtCent, 1000)
|
||||||
|
}))
|
||||||
|
|
||||||
|
test('Preis-Editor Personen-Modus: Client prüft Preisgrenzen und Staffeln wie der Server', mit(personenServer(PERSONEN_PREISE), async (fx) => {
|
||||||
|
const panel = await preisEditor(fx)
|
||||||
|
const zeile = (i) => zeilen(fx, panel)[i]
|
||||||
|
await fx.setze(spalten(fx, zeile(0))[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(spalten(fx, zeile(0))[1], '1.000,01')
|
||||||
|
assert.equal(await versuche(fx, panel), 0)
|
||||||
|
await fx.setze(spalten(fx, zeile(0))[1], 'abc')
|
||||||
|
assert.equal(await versuche(fx, panel), 0)
|
||||||
|
assert.match(fehlerText(fx, panel, 'staffeln'), /für jede Personenzahl einen Preis/)
|
||||||
|
await fx.setze(spalten(fx, zeile(0))[1], '70')
|
||||||
|
await fx.setze(fx.finde(zeile(1), '[data-staffel="ab"]'), '1')
|
||||||
|
assert.equal(await versuche(fx, panel), 0)
|
||||||
|
assert.match(fehlerText(fx, panel, 'staffeln'), /größer werden/)
|
||||||
|
await fx.setze(fx.finde(zeile(1), '[data-staffel="ab"]'), '7')
|
||||||
|
assert.equal(await versuche(fx, panel), 1)
|
||||||
|
}))
|
||||||
|
|
||||||
|
test('Preis-Editor Personen-Modus: weniger gespeicherte Werte als Felder bleiben leer, überzählige werden verworfen', async () => {
|
||||||
|
const gespeichert = { ...PERSONEN_PREISE, staffeln: [{ abNaechten: 1, preisProNachtCent: 5500, personenCent: [5500, 7000, 8200] }] }
|
||||||
|
// Unterkunft jetzt für 4 Gäste: vierte Spalte leer
|
||||||
|
await mit(personenServer(gespeichert, 4), async (fx) => {
|
||||||
|
const panel = await preisEditor(fx)
|
||||||
|
assert.deepEqual(spalten(fx, zeilen(fx, panel)[0]).map((f) => f.value), ['55,00', '70,00', '82,00', ''])
|
||||||
|
assert.equal(await versuche(fx, panel), 0)
|
||||||
|
assert.match(fehlerText(fx, panel, 'staffeln'), /für jede Personenzahl einen Preis/)
|
||||||
|
})()
|
||||||
|
// Unterkunft jetzt für 2 Gäste: dritter Wert fällt weg
|
||||||
|
await mit(personenServer(gespeichert, 2), async (fx) => {
|
||||||
|
const panel = await preisEditor(fx)
|
||||||
|
assert.deepEqual(spalten(fx, zeilen(fx, panel)[0]).map((f) => f.value), ['55,00', '70,00'])
|
||||||
|
assert.equal(await versuche(fx, panel), 1)
|
||||||
|
assert.deepEqual(putPreise(fx).body.staffeln, [{ abNaechten: 1, preisProNachtCent: 5500, personenCent: [5500, 7000] }])
|
||||||
|
})()
|
||||||
|
// mehr als 10 Gäste: höchstens 10 Felder
|
||||||
|
await mit(personenServer(gespeichert, 15), async (fx) => {
|
||||||
|
const panel = await preisEditor(fx)
|
||||||
|
assert.equal(spalten(fx, zeilen(fx, panel)[0]).length, 10)
|
||||||
|
assert.match(fx.text(zeilen(fx, panel)[0]), /10 Personen/)
|
||||||
|
})()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Preis-Editor Personen-Modus: bei einer Person Höchstzahl kein Haken, altes Modell, personenCent fällt weg', mit(personenServer(PERSONEN_PREISE, 1), async (fx) => {
|
||||||
|
const panel = await preisEditor(fx)
|
||||||
|
const box = modusBox(fx, panel)
|
||||||
|
assert.equal(box.checked, false)
|
||||||
|
assert.equal(sichtbar(box), false)
|
||||||
|
assert.ok(zeilen(fx, panel).every((z) => spalten(fx, z).length === 0 && sichtbar(fx.finde(z, '[data-staffel="preis"]'))))
|
||||||
|
assert.equal(sichtbar(fx.finde(panel, '#bk-p-inklusive')), true)
|
||||||
|
assert.equal(await versuche(fx, panel), 1)
|
||||||
|
assert.deepEqual(putPreise(fx).body.staffeln, [{ abNaechten: 1, preisProNachtCent: 5500 }, { abNaechten: 7, preisProNachtCent: 5000 }])
|
||||||
|
}))
|
||||||
|
|
||||||
|
test('Preis-Editor Personen-Modus: nur lesen sperrt Haken und alle Felder', async () => {
|
||||||
|
await mit(personenServer(PERSONEN_PREISE), async (fx) => {
|
||||||
|
const { panel } = await zeigeMit(fx, ukTab.render, { nurLesen: true })
|
||||||
|
await fx.klick(aktion(fx, panel, 'bearbeiten'))
|
||||||
|
await fx.ruhe()
|
||||||
|
assert.ok(modusBox(fx, panel).disabled)
|
||||||
|
assert.equal(spalten(fx, zeilen(fx, panel)[0]).length, 3)
|
||||||
|
assert.ok(fx.alle(panel, '.bk-preisform input, .bk-preisform select, .bk-preisform textarea, .bk-preisform button').every((el) => el.disabled), 'alles gesperrt')
|
||||||
|
assert.equal(aktion(fx, panel, 'preise-speichern'), null)
|
||||||
|
// Klick auf den gesperrten Haken ändert nichts
|
||||||
|
await fx.klick(modusBox(fx, panel))
|
||||||
|
assert.equal(modusBox(fx, panel).checked, true)
|
||||||
|
})()
|
||||||
|
// auch im alten Modell sperrt nur lesen den Haken
|
||||||
|
await mit(personenServer(PREISE, 3), async (fx) => {
|
||||||
|
const { panel } = await zeigeMit(fx, ukTab.render, { nurLesen: true })
|
||||||
|
await fx.klick(aktion(fx, panel, 'bearbeiten'))
|
||||||
|
await fx.ruhe()
|
||||||
|
assert.ok(modusBox(fx, panel).disabled)
|
||||||
|
await fx.klick(modusBox(fx, panel))
|
||||||
|
assert.equal(modusBox(fx, panel).checked, false)
|
||||||
|
assert.ok(zeilen(fx, panel).every((z) => spalten(fx, z).every((f) => f.disabled)))
|
||||||
|
})()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Preis-Editor Personen-Modus: unlesbare Reste in den ausgeblendeten Feldern blockieren das Speichern nicht', mit(personenServer(PERSONEN_PREISE), async (fx) => {
|
||||||
|
const panel = await preisEditor(fx)
|
||||||
|
await fx.setze(fx.finde(panel, '#bk-p-inklusive'), 'xyz')
|
||||||
|
await fx.setze(fx.finde(panel, '#bk-p-aufpreis'), 'abc')
|
||||||
|
assert.equal(await versuche(fx, panel), 1)
|
||||||
|
assert.equal(putPreise(fx).body.personenInklusive, 2)
|
||||||
|
assert.equal(putPreise(fx).body.aufpreisPersonNachtCent, 1000)
|
||||||
|
}))
|
||||||
|
|
||||||
|
test('CSS: Felder je Personenzahl brechen um, ausgeblendete Teile bleiben unsichtbar', () => {
|
||||||
|
const css = readFileSync(new URL('../public/buchungen.css', import.meta.url), 'utf8')
|
||||||
|
assert.match(css, /\.bk-personenpreise\s*\{[^}]*flex-wrap:\s*wrap/)
|
||||||
|
assert.match(css, /\.bk-personenpreise\[hidden\][^{]*\{\s*display:\s*none/)
|
||||||
|
assert.match(css, /\.bk-preis-einzeln\[hidden\]/)
|
||||||
|
})
|
||||||
Reference in new issue
Block a user