diff --git a/public/buchungen-preise.js b/public/buchungen-preise.js index 3dad3ca..1d477d9 100644 --- a/public/buchungen-preise.js +++ b/public/buchungen-preise.js @@ -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), diff --git a/public/buchungen.css b/public/buchungen.css index 022dad3..9f6dfb4 100644 --- a/public/buchungen.css +++ b/public/buchungen.css @@ -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; } diff --git a/test/buchungenUi.test.js b/test/buchungenUi.test.js index 63e8b1e..2d11ccb 100644 --- a/test/buchungenUi.test.js +++ b/test/buchungenUi.test.js @@ -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 %.') 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\]/) +})