From 3ecea05b528dca2afb9671d2dc1fe0ba7c4605fc Mon Sep 17 00:00:00 2001 From: Kenso Grimm Date: Wed, 7 Oct 2026 14:06:48 +0000 Subject: [PATCH 1/7] Buchungskalender: Portal-Bereich und Anfragen Co-Authored-By: Claude Opus 5.5 --- public/buchungen-posten.js | 109 ++++++++++ public/buchungen-tab-anfragen.js | 301 ++++++++++++++++++++++++++ public/buchungen-tab-einstellungen.js | 6 + public/buchungen-tab-kalender.js | 6 + public/buchungen-tab-rechnungen.js | 6 + public/buchungen-tab-unterkuenfte.js | 6 + public/buchungen.css | 37 ++++ public/buchungen.js | 175 ++++++++++++++- public/dashboard.html | 1 + test/buchungenUi.test.js | 166 ++++++++++++++ test/unterkunftWiring.test.js | 2 +- 11 files changed, 811 insertions(+), 4 deletions(-) create mode 100644 public/buchungen-posten.js create mode 100644 public/buchungen-tab-anfragen.js create mode 100644 public/buchungen-tab-einstellungen.js create mode 100644 public/buchungen-tab-kalender.js create mode 100644 public/buchungen-tab-rechnungen.js create mode 100644 public/buchungen-tab-unterkuenfte.js create mode 100644 public/buchungen.css create mode 100644 test/buchungenUi.test.js diff --git a/public/buchungen-posten.js b/public/buchungen-posten.js new file mode 100644 index 0000000..ab37508 --- /dev/null +++ b/public/buchungen-posten.js @@ -0,0 +1,109 @@ +/* Buchungskalender: Posten-Editor fuer die Freigabe einer Anfrage. + summeLive(posten, steuerart) ist ein Spiegel von server/services/unterkunft/preis.js summiere (gleiche Rundung): Anzeige waehrend des Tippens, + verbindlich rechnet der Server. Posten: { text, menge, einzelCent (negativ bei Rabatt), ustProzent }. */ + +import { euroText, euroZuCent, h } from './shop-dom.js' + +export function summeLive(posten, steuerart) { + const bruttoCent = posten.reduce((a, p) => a + p.menge * p.einzelCent, 0) + if (steuerart === 'kleinunternehmer') return { bruttoCent, nettoCent: bruttoCent, ust: [] } + const proSatz = new Map() + for (const p of posten) proSatz.set(p.ustProzent, (proSatz.get(p.ustProzent) || 0) + p.menge * p.einzelCent) + const ust = [...proSatz.entries()] + .sort((a, b) => a[0] - b[0]) + .map(([satz, brutto]) => { + const nettoCent = Math.round((brutto * 100) / (100 + satz)) + return { satz, nettoCent, steuerCent: brutto - nettoCent } + }) + const nettoCent = ust.reduce((a, u) => a + u.nettoCent, 0) + return { bruttoCent, nettoCent, ust } +} + +/** Betrag mit Vorzeichen (Rabatt) in Euro-Schreibweise: -3850 ⇒ '-38,50 €'. */ +export const euroMitVorzeichen = (cent) => (Number.isInteger(cent) ? (cent < 0 ? `-${euroText(-cent)}` : euroText(cent)) : '') + +/** Feldtext ('-38,50', '−38,50 €', '12,5') in Cent mit Vorzeichen; null bei allem Unlesbaren. */ +export function euroZuCentMitVorzeichen(text) { + if (typeof text !== 'string') return null + const s = text.trim() + const minus = /^[-−–]/.test(s) + const c = euroZuCent(minus ? s.slice(1) : s) + return c === null ? null : minus ? -c : c +} + +const centFeld = (cent) => (Number.isInteger(cent) ? `${cent < 0 ? '-' : ''}${Math.floor(Math.abs(cent) / 100)},${String(Math.abs(cent) % 100).padStart(2, '0')}` : '') + +/** + * Editor bauen. => { el, posten() => [{text,menge,einzelCent,ustProzent}] oder null bei Unlesbarem, summe() }. + * steuerart 'kleinunternehmer': keine USt-Spalte (ustProzent 0). aenderung() wird nach jeder Eingabe gerufen. + */ +export function baueEditor(start, steuerart, { aenderung } = {}) { + const klein = steuerart === 'kleinunternehmer' + const zeilen = [] + const liste = h('div', { class: 'bk-posten' }) + const summenEl = h('div', { class: 'bk-summen', 'data-summen': true, 'aria-live': 'polite' }) + const lies = () => { + const ergebnis = [] + for (const z of zeilen) { + const einzelCent = euroZuCentMitVorzeichen(String(z.einzel.value)) + const menge = Number(String(z.menge.value).trim()) + if (einzelCent === null || !Number.isInteger(menge) || menge < 1) return null + ergebnis.push({ text: String(z.text.value).trim(), menge, einzelCent, ustProzent: klein ? 0 : Number(z.ust.value) }) + } + return ergebnis + } + const zeichneSumme = () => { + const p = lies() + if (!p) { + summenEl.replaceChildren(h('p', { class: 'sh-fehler' }, 'Bitte prüfe Menge und Preis der Posten.')) + return + } + const s = summeLive(p, steuerart) + summenEl.replaceChildren( + ...(klein ? [] : [h('p', null, `Netto ${euroMitVorzeichen(s.nettoCent)}`), ...s.ust.map((u) => h('p', null, `${u.satz} % USt ${euroMitVorzeichen(u.steuerCent)}`))]), + h('p', { class: 'bk-gesamt' }, h('strong', null, `Gesamt ${euroMitVorzeichen(s.bruttoCent)}`)), + klein ? h('p', { class: 'sh-hinweis' }, 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.') : null) + } + const geaendert = () => { + zeichneSumme() + aenderung?.() + } + function fuegeZeile(p = { text: '', menge: 1, einzelCent: 0, ustProzent: 7 }) { + const z = { + text: h('input', { type: 'text', maxlength: '200', 'aria-label': 'Beschreibung', 'data-posten': 'text' }), + menge: h('input', { type: 'text', inputmode: 'numeric', 'aria-label': 'Menge', 'data-posten': 'menge' }), + einzel: h('input', { type: 'text', inputmode: 'decimal', autocomplete: 'off', 'aria-label': 'Einzelpreis in Euro (Rabatt mit Minus)', 'data-posten': 'einzel' }), + ust: klein ? null : h('select', { 'aria-label': 'Umsatzsteuer', 'data-posten': 'ust' }, [7, 19, 0].map((satz) => h('option', { value: String(satz) }, `${satz} %`))), + } + z.text.value = p.text + z.menge.value = String(p.menge) + z.einzel.value = centFeld(p.einzelCent) + if (z.ust) z.ust.value = String(p.ustProzent) + for (const el of [z.text, z.menge, z.einzel, z.ust]) el?.addEventListener('input', geaendert) + z.ust?.addEventListener('change', geaendert) + z.einzel.addEventListener('change', () => { + const c = euroZuCentMitVorzeichen(String(z.einzel.value)) + if (c !== null) z.einzel.value = centFeld(c) + }) + z.zeile = h('div', { class: 'bk-posten-zeile', 'data-zeile': true }, + z.text, z.menge, z.einzel, z.ust, + h('button', { + type: 'button', class: 'kb-btn-outline', 'data-aktion': 'posten-weg', 'aria-label': 'Posten entfernen', + onclick: () => { + zeilen.splice(zeilen.indexOf(z), 1) + z.zeile.remove() + geaendert() + }, + }, 'Entfernen')) + zeilen.push(z) + liste.append(z.zeile) + } + for (const p of start) fuegeZeile(p) + const kopf = h('div', { class: 'bk-posten-kopf', 'aria-hidden': 'true' }, + h('span', null, 'Beschreibung'), h('span', null, 'Menge'), h('span', null, 'Einzelpreis €'), klein ? null : h('span', null, 'USt'), h('span')) + const el = h('div', { class: 'bk-editor' }, kopf, liste, + h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'posten-neu', onclick: () => { fuegeZeile(); geaendert() } }, 'Posten hinzufügen'), + summenEl) + zeichneSumme() + return { el, posten: lies, summe: () => { const p = lies(); return p ? summeLive(p, steuerart) : null } } +} diff --git a/public/buchungen-tab-anfragen.js b/public/buchungen-tab-anfragen.js new file mode 100644 index 0000000..5e411c0 --- /dev/null +++ b/public/buchungen-tab-anfragen.js @@ -0,0 +1,301 @@ +/* Buchungskalender, Reiter „Anfragen“: Filter (offen | gebucht | alle), Liste, Detail. + API (docs/funktionen/unterkunft-api.md, alle mit ?projectId=): + GET /api/unterkunft/anfragen?filter= => { anfragen:[{ id, nummer, von, bis, naechte?, gast:{name}, status, summen:{bruttoCent}, unterkunftName? }] } + GET /api/unterkunft/anfragen/:id => { anfrage:{ id, nummer, von, bis, personen, status, gast:{name,firma,email,telefon,nachricht?}, posten, summen, + steuerart, zahlart?, bezahltVia?, erstattetCent?, rechnungId?, rechnungNummer?, fristAt?, unterkunftName? }, vorschlag:{ naechte, posten, summen } } + POST …/freigeben {posten,fristTage,nachricht} (=> { buchungsLink? }), …/ablehnen {text}, …/bezahlt, …/stornieren {grund,erstattungCent}. + Fremdtext (Gast, Nachricht) nur ueber textContent. render(panel, ctx) wie shop-tab-*.js. */ + +import { abschnitt, allgemeinerFehler, euroText, euroZuCent, fehlerKarte, feld, frage, h, zeigeFehler, datumText } from './shop-dom.js' +import { baueEditor, euroMitVorzeichen } from './buchungen-posten.js' + +export const STATUS = { + neu: ['Neu', 'wait'], + freigegeben: ['Freigegeben – wartet auf Buchung', 'wait'], + gebucht: ['Gebucht', 'ok'], + bezahlt: ['Bezahlt', 'ok'], + abgelehnt: ['Abgelehnt', 'bad'], + abgelaufen: ['Abgelaufen', 'off'], + storniert: ['Storniert', 'bad'], +} +const FILTER = [['offen', 'Offen'], ['gebucht', 'Gebucht'], ['alle', 'Alle']] +const FEHLER_LADEN = 'Das konnte nicht geladen werden. Bitte versuch es noch einmal.' +const BELEGT = 'Der Zeitraum ist inzwischen belegt.' + +const zeichen = (x) => (typeof x === 'string' ? x : '') +const enc = encodeURIComponent +const pille = (status) => { + const [text, art] = STATUS[status] || [zeichen(status) || 'Unbekannt', 'off'] + return h('span', { class: `sh-pille sh-pille-${art}`, 'data-status': status }, text) +} + +/** '2026-11-12','2026-11-30' ⇒ '12.–30.11.2026'; über Monats-/Jahresgrenze '28.10.–03.11.2026' bzw. '28.12.2026–03.01.2027'. */ +export function zeitraumText(von, bis) { + const m = /^(\d{4})-(\d{2})-(\d{2})$/ + const a = m.exec(von || '') + const b = m.exec(bis || '') + if (!a || !b) return '' + if (a[1] !== b[1]) return `${a[3]}.${a[2]}.${a[1]}–${b[3]}.${b[2]}.${b[1]}` + if (a[2] !== b[2]) return `${a[3]}.${a[2]}.–${b[3]}.${b[2]}.${b[1]}` + return `${a[3]}.–${b[3]}.${b[2]}.${b[1]}` +} +export function naechteText(von, bis) { + const ms = (t) => Date.UTC(...t.split('-').map((x, i) => (i === 1 ? Number(x) - 1 : Number(x)))) + const n = Math.round((ms(bis) - ms(von)) / 86400000) + return Number.isFinite(n) ? `${n} ${n === 1 ? 'Nacht' : 'Nächte'}` : '' +} +const gastName = (gast) => (typeof gast === 'string' ? gast : zeichen(gast?.name)) + +export async function render(panel, ctx) { + const st = { filter: 'offen', lauf: 0 } + const kopf = h('div', { class: 'bk-filter' }, h('label', { for: 'bk-a-filter' }, 'Anzeigen'), + h('select', { id: 'bk-a-filter', 'data-aktion': 'filter' }, FILTER.map(([wert, text]) => h('option', { value: wert }, text)))) + const inhalt = h('div', { class: 'bk-anfragen' }) + panel.replaceChildren(kopf, inhalt) + const select = kopf.querySelector('select') + select.value = st.filter + select.addEventListener('change', () => { st.filter = select.value; zeigeListe() }) + await zeigeListe() + + async function zeigeListe() { + const lauf = ++st.lauf + const gueltig = () => st.lauf === lauf && ctx.aktuell() + inhalt.replaceChildren(h('p', { class: 'kb-empty' }, 'Lade…')) + let daten + try { + daten = await ctx.request(`/api/unterkunft/anfragen?projectId=${enc(ctx.projectId)}&filter=${enc(st.filter)}`) + } catch (err) { + if (gueltig()) inhalt.replaceChildren(fehlerKarte(err.message || FEHLER_LADEN, zeigeListe)) + return + } + if (!gueltig()) return + const zeilen = Array.isArray(daten?.anfragen) ? daten.anfragen : [] + if (!zeilen.length) { + inhalt.replaceChildren(h('div', { class: 'kb-panel kb-empty', 'data-leer': true }, st.filter === 'offen' ? 'Keine offenen Anfragen.' : 'Keine Anfragen.')) + return + } + inhalt.replaceChildren(h('ul', { class: 'bk-liste' }, zeilen.map((a) => h('li', null, h('button', { + type: 'button', class: 'bk-zeile', 'data-anfrage': a.id, onclick: () => zeigeDetail(a.id), + }, + h('strong', { class: 'bk-nummer' }, zeichen(a.nummer)), + h('span', { class: 'bk-zeitraum' }, `${zeitraumText(a.von, a.bis)} · ${naechteText(a.von, a.bis)}`), + h('span', { class: 'bk-gast' }, gastName(a.gast)), + pille(a.status), + h('span', { class: 'bk-betrag' }, Number.isInteger(a.summen?.bruttoCent) ? euroMitVorzeichen(a.summen.bruttoCent) : '')))))) + } + + async function zeigeDetail(id) { + const lauf = ++st.lauf + const gueltig = () => st.lauf === lauf && ctx.aktuell() + inhalt.replaceChildren(h('p', { class: 'kb-empty' }, 'Lade…')) + let daten + try { + daten = await ctx.request(`/api/unterkunft/anfragen/${enc(id)}?projectId=${enc(ctx.projectId)}`) + } catch (err) { + if (gueltig()) inhalt.replaceChildren(zurueck(), fehlerKarte(err.message || FEHLER_LADEN, () => zeigeDetail(id))) + return + } + if (!gueltig()) return + if (!daten?.anfrage) { + inhalt.replaceChildren(zurueck(), fehlerKarte(FEHLER_LADEN, () => zeigeDetail(id))) + return + } + inhalt.replaceChildren(zurueck(), baueDetail(daten.anfrage, daten.vorschlag)) + } + const zurueck = () => h('button', { type: 'button', class: 'kb-btn-outline bk-zurueck', 'data-aktion': 'zurueck', onclick: () => zeigeListe() }, '‹ Zur Liste') + + function baueDetail(a, vorschlag) { + const gast = a.gast && typeof a.gast === 'object' ? a.gast : { name: zeichen(a.gast) } + const kopfZeile = h('div', { class: 'bk-detail-kopf' }, + h('h3', null, `Anfrage ${zeichen(a.nummer)}`), pille(a.status)) + const daten = h('dl', { class: 'bk-daten' }, + ...[ + ['Unterkunft', zeichen(a.unterkunftName)], + ['Zeitraum', `${zeitraumText(a.von, a.bis)} · ${naechteText(a.von, a.bis)}`], + ['Personen', Number.isInteger(a.personen) ? String(a.personen) : ''], + ['Gast', gastName(gast)], + ['Firma', zeichen(gast.firma)], + ['E-Mail', zeichen(gast.email)], + ['Telefon', zeichen(gast.telefon)], + ['Nachricht', zeichen(gast.nachricht)], + ['Frist', a.fristAt && a.status === 'freigegeben' ? datumText(a.fristAt) : ''], + ].filter(([, w]) => w).flatMap(([n, w]) => [h('dt', null, n), h('dd', null, w)])) + const karte = h('section', { class: 'kb-panel sh-abschnitt bk-detail', 'data-detail': a.id }, kopfZeile, daten) + if (a.status === 'neu') karte.append(neuBereich(a, vorschlag)) + else karte.append(festerBereich(a)) + return karte + } + + /* ---------------------------------------------- Status neu: Freigeben / Ablehnen --- */ + function neuBereich(a, vorschlag) { + const steuerart = a.steuerart || vorschlag?.steuerart || ctx.status?.steuerart || 'regelbesteuert' + const startPosten = Array.isArray(vorschlag?.posten) ? vorschlag.posten : [] + const editor = baueEditor(startPosten, steuerart) + const frist = h('input', { type: 'text', inputmode: 'numeric', id: 'bk-a-frist' }) + frist.value = '7' + const nachricht = h('textarea', { id: 'bk-a-nachricht', rows: '3', maxlength: '2000' }) + const form = h('form', { class: 'bk-freigabe', novalidate: true, onsubmit: (ev) => ev.preventDefault() }, + h('h4', null, 'Angebot für den Gast'), + editor.el, + h('p', { class: 'sh-fehler', 'data-fehler': 'posten', role: 'alert' }), + feld('fristTage', 'Frist zum Buchen (Tage)', frist, 'So lange ist der Zeitraum für den Gast reserviert.'), + feld('nachricht', 'Nachricht an den Gast (optional)', nachricht), + allgemeinerFehler()) + const freigeben = h('button', { + type: 'button', class: 'kb-btn', 'data-aktion': 'freigeben', disabled: ctx.nurLesen && !ctx.abwickeln, + onclick: () => sendeFreigabe(a, editor, frist, nachricht, form, freigeben), + }, 'Freigeben') + const ablehnen = h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'ablehnen', onclick: () => ablehnDialog(a, ablehnen) }, 'Ablehnen') + return h('div', null, form, h('div', { class: 'bk-aktionen' }, freigeben, ablehnen), h('div', { class: 'bk-dialog' })) + } + + async function sendeFreigabe(a, editor, frist, nachricht, form, knopf) { + const posten = editor.posten() + const tage = Number(String(frist.value).trim()) + if (!posten) return zeigeFehler(form, { message: '', fehler: { posten: 'Bitte prüfe Menge und Preis der Posten.' } }) + if (!Number.isInteger(tage) || tage < 1) return zeigeFehler(form, { message: '', fehler: { fristTage: 'Bitte gib die Frist in ganzen Tagen an.' } }) + knopf.disabled = true + try { + const antwort = await ctx.request(`/api/unterkunft/anfragen/${enc(a.id)}/freigeben?projectId=${enc(ctx.projectId)}`, { + method: 'POST', body: { posten, fristTage: tage, nachricht: String(nachricht.value).trim() }, + }) + if (!ctx.aktuell()) return + ctx.zeigeMeldung('Freigegeben. Dein Gast hat den Buchungslink per Mail bekommen.', 'ok') + zeigeLink(antwort?.buchungsLink, a) + } catch (err) { + knopf.disabled = false + if (err.status === 409) zeigeFehler(form, { message: BELEGT, fehler: { _: BELEGT } }) + else zeigeFehler(form, err) + } + } + + /** Nach „Freigeben“: der Buchungslink zum Kopieren, falls die Mail nicht ankommt (nur wenn die Antwort ihn enthaelt). */ + function zeigeLink(link, a) { + const box = h('section', { class: 'kb-panel sh-abschnitt bk-link', 'data-freigegeben': a.id }, + h('h3', null, `Anfrage ${zeichen(a.nummer)} ist freigegeben`), + h('p', null, 'Dein Gast hat den Buchungslink per Mail bekommen.')) + if (typeof link === 'string' && link) { + const meldung = h('p', { class: 'sh-hinweis', role: 'status' }) + box.append( + h('p', null, 'Falls die Mail nicht ankommt, kannst du den Link selbst schicken:'), + h('input', { type: 'text', readonly: true, value: link, 'aria-label': 'Buchungslink', 'data-link': true }), + h('button', { + type: 'button', class: 'kb-btn-outline', 'data-aktion': 'link-kopieren', + onclick: async () => { + try { + await navigator.clipboard.writeText(link) + meldung.textContent = 'Link kopiert.' + } catch { + meldung.textContent = 'Kopieren hat nicht geklappt. Markiere den Link und kopiere ihn von Hand.' + } + }, + }, 'Link kopieren'), + meldung) + } + box.append(h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'zurueck', onclick: () => zeigeListe() }, 'Zur Liste')) + inhalt.replaceChildren(box) + } + + function ablehnDialog(a, ausloeser) { + const dialogEl = inhalt.querySelector('.bk-dialog') + const text = h('textarea', { id: 'bk-a-ablehn', rows: '3', maxlength: '1000' }) + const box = frage('Anfrage ablehnen', h('p', { class: 'sh-hinweis' }, 'Der Gast bekommt deinen Text per Mail.'), feld('text', 'Nachricht an den Gast', text), allgemeinerFehler(), + h('div', { class: 'bk-aktionen' }, + h('button', { + type: 'button', class: 'kb-btn', 'data-aktion': 'ablehnen-senden', + onclick: async (ev) => { + ev.currentTarget.disabled = true + try { + await ctx.request(`/api/unterkunft/anfragen/${enc(a.id)}/ablehnen?projectId=${enc(ctx.projectId)}`, { method: 'POST', body: { text: String(text.value).trim() } }) + if (!ctx.aktuell()) return + ctx.zeigeMeldung('Anfrage abgelehnt.', 'ok') + await zeigeListe() + } catch (err) { + ev.currentTarget.disabled = false + zeigeFehler(box, err) + } + }, + }, 'Ablehnen'), + h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'abbrechen', onclick: () => { dialogEl.replaceChildren(); ausloeser.focus() } }, 'Abbrechen'))) + dialogEl.replaceChildren(box) + text.focus() + } + + /* ---------------------------------------------- weitere Status: Posten, Rechnung, Bezahlt, Storno --- */ + function festerBereich(a) { + const posten = Array.isArray(a.posten) ? a.posten : [] + const teile = [] + if (posten.length) { + teile.push(h('table', { class: 'bk-tabelle' }, h('tbody', null, posten.map((p) => h('tr', null, + h('td', null, zeichen(p.text)), h('td', null, `${p.menge} ×`), h('td', null, euroMitVorzeichen(p.einzelCent))))))) + } + if (Number.isInteger(a.summen?.bruttoCent)) teile.push(h('p', { class: 'bk-gesamt' }, h('strong', null, `Gesamt ${euroMitVorzeichen(a.summen.bruttoCent)}`))) + const aktionen = h('div', { class: 'bk-aktionen' }) + const dialogEl = h('div', { class: 'bk-dialog' }) + const aktiv = a.status === 'gebucht' || a.status === 'bezahlt' + if (aktiv && a.rechnungId) { + teile.push(h('p', null, h('a', { + href: `/api/unterkunft/rechnungen/${enc(a.rechnungId)}.pdf?projectId=${enc(ctx.projectId)}`, target: '_blank', rel: 'noopener', 'data-aktion': 'rechnung', + }, `Rechnung ${zeichen(a.rechnungNummer)}`.trim() + ' (PDF)'))) + } + if (a.status === 'gebucht') { + aktionen.append(h('button', { type: 'button', class: 'kb-btn', 'data-aktion': 'bezahlt', onclick: (ev) => markiereBezahlt(a, ev.currentTarget) }, 'Als bezahlt (vor Ort) markieren')) + } + if (aktiv) { + const stornoKnopf = h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'stornieren', onclick: () => stornoDialog(a, dialogEl, stornoKnopf) }, 'Stornieren') + aktionen.append(stornoKnopf) + } + return h('div', null, ...teile, aktionen.childNodes.length ? aktionen : null, dialogEl) + } + + async function markiereBezahlt(a, knopf) { + knopf.disabled = true + try { + await ctx.request(`/api/unterkunft/anfragen/${enc(a.id)}/bezahlt?projectId=${enc(ctx.projectId)}`, { method: 'POST' }) + if (!ctx.aktuell()) return + ctx.zeigeMeldung('Als bezahlt markiert.', 'ok') + await zeigeDetail(a.id) + } catch (err) { + knopf.disabled = false + ctx.zeigeMeldung(err.message, 'fehler') + } + } + + function stornoDialog(a, dialogEl, ausloeser) { + const online = a.bezahltVia === 'online' + const grund = h('textarea', { id: 'bk-a-grund', rows: '3', maxlength: '500' }) + const betrag = h('input', { type: 'text', inputmode: 'decimal', id: 'bk-a-erstattung', autocomplete: 'off' }) + betrag.value = Number.isInteger(a.summen?.bruttoCent) ? `${Math.floor((a.summen.bruttoCent - (a.erstattetCent || 0)) / 100)},${String((a.summen.bruttoCent - (a.erstattetCent || 0)) % 100).padStart(2, '0')}` : '' + const box = frage('Buchung stornieren', h('p', { class: 'sh-hinweis' }, 'Der Gast bekommt eine Stornomail und eine Storno-Rechnung.'), + feld('grund', 'Grund', grund), + online ? feld('erstattungCent', 'Erstattung in Euro', betrag, 'Der Betrag geht an die Karte des Gastes zurück. 0 = keine Erstattung.') : null, + allgemeinerFehler(), + h('div', { class: 'bk-aktionen' }, + h('button', { + type: 'button', class: 'kb-btn', 'data-aktion': 'storno-senden', + onclick: async (ev) => { + const knopf = ev.currentTarget + let erstattungCent = 0 + if (online) { + erstattungCent = String(betrag.value).trim() === '' ? 0 : euroZuCent(String(betrag.value)) + if (erstattungCent === null) return zeigeFehler(box, { message: '', fehler: { erstattungCent: 'Bitte gib einen Betrag wie 120,00 an.' } }) + } + knopf.disabled = true + try { + await ctx.request(`/api/unterkunft/anfragen/${enc(a.id)}/stornieren?projectId=${enc(ctx.projectId)}`, { method: 'POST', body: { grund: String(grund.value).trim(), erstattungCent } }) + if (!ctx.aktuell()) return + ctx.zeigeMeldung('Die Buchung ist storniert.', 'ok') + await zeigeDetail(a.id) + } catch (err) { + knopf.disabled = false + if (err.fehler === 'erstattung_fehlgeschlagen' || err.body?.fehler === 'erstattung_fehlgeschlagen') { + zeigeFehler(box, { message: 'Die Buchung ist storniert, aber die Erstattung ist fehlgeschlagen. Bitte versuch die Erstattung gleich noch einmal.', fehler: {} }) + } else zeigeFehler(box, err) + } + }, + }, 'Stornieren'), + h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'abbrechen', onclick: () => { dialogEl.replaceChildren(); ausloeser.focus() } }, 'Abbrechen'))) + dialogEl.replaceChildren(box) + grund.focus() + } +} diff --git a/public/buchungen-tab-einstellungen.js b/public/buchungen-tab-einstellungen.js new file mode 100644 index 0000000..ef2a77e --- /dev/null +++ b/public/buchungen-tab-einstellungen.js @@ -0,0 +1,6 @@ +/* Buchungskalender, Reiter „Einstellungen“: Platzhalter (Task 13/14 fuellt ihn). */ +import { infoKarte } from './shop-dom.js' + +export function render(panel) { + panel.replaceChildren(infoKarte('Einstellungen', 'Dieser Bereich wird gerade gebaut.')) +} diff --git a/public/buchungen-tab-kalender.js b/public/buchungen-tab-kalender.js new file mode 100644 index 0000000..f97bed8 --- /dev/null +++ b/public/buchungen-tab-kalender.js @@ -0,0 +1,6 @@ +/* Buchungskalender, Reiter „Kalender“: Platzhalter (Task 13/14 fuellt ihn). */ +import { infoKarte } from './shop-dom.js' + +export function render(panel) { + panel.replaceChildren(infoKarte('Kalender', 'Dieser Bereich wird gerade gebaut.')) +} diff --git a/public/buchungen-tab-rechnungen.js b/public/buchungen-tab-rechnungen.js new file mode 100644 index 0000000..1969d58 --- /dev/null +++ b/public/buchungen-tab-rechnungen.js @@ -0,0 +1,6 @@ +/* Buchungskalender, Reiter „Rechnungen“: Platzhalter (Task 13/14 fuellt ihn). */ +import { infoKarte } from './shop-dom.js' + +export function render(panel) { + panel.replaceChildren(infoKarte('Rechnungen', 'Dieser Bereich wird gerade gebaut.')) +} diff --git a/public/buchungen-tab-unterkuenfte.js b/public/buchungen-tab-unterkuenfte.js new file mode 100644 index 0000000..a457ea0 --- /dev/null +++ b/public/buchungen-tab-unterkuenfte.js @@ -0,0 +1,6 @@ +/* Buchungskalender, Reiter „Unterkünfte & Preise“: Platzhalter (Task 13/14 fuellt ihn). */ +import { infoKarte } from './shop-dom.js' + +export function render(panel) { + panel.replaceChildren(infoKarte('Unterkünfte & Preise', 'Dieser Bereich wird gerade gebaut.')) +} diff --git a/public/buchungen.css b/public/buchungen.css new file mode 100644 index 0000000..566ae9b --- /dev/null +++ b/public/buchungen.css @@ -0,0 +1,37 @@ +/* Buchungskalender im Kundenbereich (buchungen.js): Klassen mit bk-; Reiter, Pillen, Karten kommen aus shop.css (sh-). */ +#view-buchungen .kb-panel { max-width: none; } +#view-buchungen .fn-project { margin-bottom: 16px; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding: 14px 18px; } +#view-buchungen button:focus-visible, #view-buchungen input:focus-visible, #view-buchungen select:focus-visible, #view-buchungen textarea:focus-visible, +#view-buchungen a:focus-visible { outline: 3px solid #d4b4ff; outline-offset: 2px; } +.bk-filter { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; } +.bk-liste { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; } +.bk-zeile { + display: grid; grid-template-columns: 90px 1.4fr 1fr auto auto; gap: 8px 14px; align-items: center; width: 100%; padding: 12px 16px; text-align: left; + border-radius: 12px; border: 1px solid var(--kb-border); background: rgba(255, 255, 255, 0.03); color: inherit; font: inherit; cursor: pointer; +} +.bk-zeile:hover { border-color: var(--kb-purple-line); } +.bk-zeile .sh-pille { margin: 0; justify-self: start; } +.bk-betrag { font-weight: 700; text-align: right; } +.bk-zurueck { margin-bottom: 12px; } +.bk-detail-kopf { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; } +.bk-detail-kopf .sh-pille { margin: 0; } +.bk-daten { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; margin: 12px 0 18px; } +.bk-daten dt { color: var(--kb-muted); } +.bk-daten dd { margin: 0; overflow-wrap: anywhere; } +.bk-posten-kopf, .bk-posten-zeile { display: grid; grid-template-columns: minmax(0, 3fr) 70px 110px 80px auto; gap: 8px; align-items: center; } +.bk-posten-kopf { font-size: 12px; color: var(--kb-muted); margin-bottom: 4px; } +.bk-posten-zeile { margin-bottom: 8px; } +.bk-summen { margin: 12px 0; text-align: right; } +.bk-summen p { margin: 2px 0; } +.bk-gesamt { font-size: 18px; } +.bk-aktionen { display: flex; gap: 10px; flex-wrap: wrap; margin: 14px 0; } +.bk-tabelle { width: 100%; border-collapse: collapse; margin: 10px 0; } +.bk-tabelle td { padding: 6px 4px; border-bottom: 1px solid var(--kb-border); } +.bk-tabelle td:last-child { text-align: right; } +.bk-link input { width: 100%; margin: 6px 0 10px; } +@media (max-width: 640px) { + .bk-zeile { grid-template-columns: 1fr auto; } + .bk-posten-kopf { display: none; } + .bk-posten-zeile { grid-template-columns: 1fr 1fr; } + .bk-posten-zeile input:first-child { grid-column: 1 / -1; } +} diff --git a/public/buchungen.js b/public/buchungen.js index fb992af..ce75ecb 100644 --- a/public/buchungen.js +++ b/public/buchungen.js @@ -1,4 +1,173 @@ -// Buchungskalender (Reiter #buchungen). Platzhalter; die Oberflaeche baut Strang 4. -export function initBuchungen(el) { - el.textContent = 'Buchungskalender wird geladen …' +/* Buchungskalender im Kundenbereich (ES-Modul, von app.js beim Oeffnen der Ansicht #buchungen geladen). Aufbau wie shop.js: + initBuchungen(rootEl, { projectId }): Projekt waehlen (GET /api/funktionen, features.unterkunft), Stand holen (GET /api/unterkunft/status) + und je nach state die Reiter (REITER, lazy geladen, render(panel, ctx)) oder (state off) die Anfrage-/Bestellkarte (Task 14). + ctx = { projectId, status, nurLesen, aktuell(), neuLaden(), zeigeMeldung(text, art), request }. + Helfer: shop-dom.js (h, request, Karten), Styles: buchungen.css (bk-) und shop.css (sh-). Fremdtext nur ueber textContent. */ + +import { fehlerKarte, h, infoKarte, request } from './shop-dom.js' + +/** Die Reiter: jeder ein Modul mit render(panel, ctx), erst beim ersten Oeffnen geladen. */ +export const REITER = [ + { key: 'anfragen', label: 'Anfragen', laden: () => import('./buchungen-tab-anfragen.js') }, + { key: 'kalender', label: 'Kalender', laden: () => import('./buchungen-tab-kalender.js') }, + { key: 'unterkuenfte', label: 'Unterkünfte & Preise', laden: () => import('./buchungen-tab-unterkuenfte.js') }, + { key: 'rechnungen', label: 'Rechnungen', laden: () => import('./buchungen-tab-rechnungen.js') }, + { key: 'einstellungen', label: 'Einstellungen', laden: () => import('./buchungen-tab-einstellungen.js') }, +] + +const NICHT_VERFUEGBAR = 'Der Buchungskalender ist noch nicht verfügbar.' +const auswahl = { projectId: '', reiter: 'anfragen' } +/** Gemerkte Auswahl vergessen (fuer Tests). */ +export function vergissAuswahl() { + auswahl.projectId = '' + auswahl.reiter = 'anfragen' +} +let S = null // aktuelle Sitzung; eine alte Antwort darf eine neuere nicht ueberschreiben + +const zustandVon = (projekt) => projekt?.features?.unterkunft?.state || 'off' + +export function initBuchungen(root, { projectId } = {}) { + if (!root) return Promise.resolve() + return starte(root, projectId) +} + +async function starte(root, projectId) { + if (projectId) auswahl.projectId = projectId + const s = { root, projects: [], projekt: null, status: null, lauf: 0, buehne: null, meldungEl: null } + S = s + let funktionen + try { + funktionen = await request('/api/funktionen') + } catch (err) { + if (S === s) root.replaceChildren(fehlerKarte(err.message, () => starte(root, auswahl.projectId))) + return + } + if (S !== s) return + if (!funktionen?.unterkunft?.verfuegbar) { + root.replaceChildren(infoKarte(NICHT_VERFUEGBAR, 'Sobald er für dich bereit ist, findest du ihn hier.')) + return + } + s.projects = Array.isArray(funktionen.projects) ? funktionen.projects : [] + if (!s.projects.length) { + root.replaceChildren(h('div', { class: 'kb-panel kb-empty' }, 'Noch keine Website-Projekte zugewiesen.')) + return + } + const hat = (zustaende) => s.projects.find((p) => zustaende.includes(zustandVon(p))) + s.projekt = s.projects.find((p) => p.projectId === auswahl.projectId) || hat(['active']) || hat(['setup']) || hat(['disabled']) || s.projects[0] + auswahl.projectId = s.projekt.projectId + s.meldungEl = h('div', { class: 'sh-meldung', role: 'status', 'aria-live': 'polite', tabindex: '-1' }) + s.buehne = h('div', { id: 'buchungen-buehne' }) + root.replaceChildren(...(s.projects.length > 1 ? [projektwahl(s)] : []), s.meldungEl, s.buehne) + await zeigeProjekt(s) +} + +function projektwahl(s) { + const select = h('select', { id: 'buchungen-project' }, s.projects.map((p) => { + const option = h('option', { value: p.projectId }, p.projectName) + option.selected = p.projectId === s.projekt.projectId + return option + })) + select.addEventListener('change', () => { + auswahl.projectId = select.value + s.projekt = s.projects.find((p) => p.projectId === select.value) + zeigeMeldung(s, '') + zeigeProjekt(s) + }) + return h('div', { class: 'fn-project kb-panel' }, h('label', { for: 'buchungen-project' }, 'Website'), select) +} + +function zeigeMeldung(s, meldung, art = 'ok') { + const el = s.meldungEl + if (!el) return + el.textContent = meldung || '' + el.className = `sh-meldung sh-meldung-${art}` + el.setAttribute('role', art === 'fehler' ? 'alert' : 'status') + el.setAttribute('aria-live', art === 'fehler' ? 'assertive' : 'polite') + if (!meldung) return + if (art === 'fehler') el.focus({ preventScroll: true }) + el.scrollIntoView?.({ block: 'nearest' }) +} + +const ladeStatus = (projectId) => request(`/api/unterkunft/status?projectId=${encodeURIComponent(projectId)}`) + +async function zeigeProjekt(s) { + const lauf = ++s.lauf + const aktuell = () => S === s && s.lauf === lauf + s.buehne.replaceChildren(h('p', { class: 'kb-empty' }, 'Lade…')) + let status + try { + status = await ladeStatus(s.projekt.projectId) + } catch (err) { + if (aktuell()) s.buehne.replaceChildren(fehlerKarte(err.message, () => zeigeProjekt(s))) + return + } + if (!aktuell()) return + zeichneAnsicht(s, { status, aktuell }) +} + +function zeichneAnsicht(s, { status, aktuell }) { + s.status = status + const zustand = status?.state || zustandVon(s.projekt) + if (zustand === 'setup' || zustand === 'active' || zustand === 'disabled') zeigeReiter(s, { status, aktuell, zustand }) + else if (zustand === 'off') s.buehne.replaceChildren(infoKarte('Buchungskalender', 'Du hast den Buchungskalender noch nicht angefragt. Die Anfrage kommt in Kürze hier.')) // Task 14: Anfrage-/Bestellkarte + else s.buehne.replaceChildren(infoKarte('Buchungskalender', 'Der Stand deines Buchungskalenders ist gerade nicht lesbar. Bitte lade die Seite neu.')) +} + +function zeigeReiter(s, { status, aktuell, zustand }) { + const neuLaden = async () => { + const neu = await ladeStatus(s.projekt.projectId) + if (!aktuell()) return neu + s.status = neu + ctx.status = neu + if ((neu.state || 'off') !== zustand) zeichneAnsicht(s, { status: neu, aktuell }) + return neu + } + const ctx = { + projectId: s.projekt.projectId, + status, + nurLesen: zustand === 'disabled', + abwickeln: true, // Anfragen und Stornos bleiben auch bei abgeschalteter Funktion bearbeitbar (Pflichten gegenueber Gaesten) + aktuell, + request, + zeigeMeldung: (meldung, art) => { if (aktuell()) zeigeMeldung(s, meldung, art) }, + neuLaden, + } + const panel = h('div', { class: 'sh-panel', role: 'tabpanel', id: 'bk-panel', tabindex: '0' }) + let aktiv = null + let zaehler = 0 + const knoepfe = REITER.map((r) => h('button', { + type: 'button', role: 'tab', class: 'sh-reiter', id: `bk-tab-${r.key}`, 'aria-controls': 'bk-panel', 'data-reiter': r.key, + onclick: () => waehleReiter(r.key), + }, r.label)) + const leiste = h('div', { class: 'sh-reiterleiste', role: 'tablist', 'aria-label': 'Bereiche des Buchungskalenders' }, knoepfe) + leiste.addEventListener('keydown', (ev) => { + const i = REITER.findIndex((r) => r.key === aktiv) + const ziel = { ArrowRight: (i + 1) % REITER.length, ArrowLeft: (i + REITER.length - 1) % REITER.length, Home: 0, End: REITER.length - 1 }[ev.key] + if (ziel === undefined) return + ev.preventDefault() + waehleReiter(REITER[ziel].key) + knoepfe[ziel].focus() + }) + function waehleReiter(key) { + const reiter = REITER.find((r) => r.key === key) || REITER[0] + auswahl.reiter = reiter.key + aktiv = reiter.key + const nr = ++zaehler + for (const k of knoepfe) { + const an = k.dataset.reiter === reiter.key + k.setAttribute('aria-selected', String(an)) + k.tabIndex = an ? 0 : -1 + } + panel.setAttribute('aria-labelledby', `bk-tab-${reiter.key}`) + zeigeMeldung(s, '') + panel.replaceChildren() + reiter.laden() + .then((modul) => (nr === zaehler && aktuell() ? modul.render(panel, ctx) : undefined)) + .catch((err) => { if (nr === zaehler && aktuell()) panel.replaceChildren(fehlerKarte(err.message, () => waehleReiter(reiter.key))) }) + } + const hinweis = zustand === 'setup' ? 'Wir bauen deinen Buchungskalender gerade ein. Du kannst schon Unterkünfte, Preise und Einstellungen vorbereiten.' + : zustand === 'disabled' ? 'Dein Buchungskalender ist abgeschaltet. Du kannst bestehende Buchungen noch ansehen und bearbeiten.' : '' + const hinweisBox = h('div', { class: 'sh-hinweise' }, hinweis ? h('div', { class: `sh-box ${zustand === 'disabled' ? 'sh-box-warn' : 'sh-box-info'}`, role: 'note', 'data-hinweis': zustand }, hinweis) : null) + s.buehne.replaceChildren(hinweisBox, leiste, panel) + waehleReiter(auswahl.reiter) } diff --git a/public/dashboard.html b/public/dashboard.html index 883f671..03278c7 100644 --- a/public/dashboard.html +++ b/public/dashboard.html @@ -25,6 +25,7 @@ + diff --git a/test/buchungenUi.test.js b/test/buchungenUi.test.js new file mode 100644 index 0000000..152055b --- /dev/null +++ b/test/buchungenUi.test.js @@ -0,0 +1,166 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { installFakeDom } from './support/fakeDom.js' +import { h, request } from '../public/shop-dom.js' +import { REITER } from '../public/buchungen.js' +import { render, zeitraumText, naechteText } from '../public/buchungen-tab-anfragen.js' +import { summeLive } from '../public/buchungen-posten.js' + +// Task 12: Gerüst und Reiter „Anfragen“ gegen eine Anfrage-Attrappe nach docs/funktionen/unterkunft-api.md. + +const POSTEN = [ + { text: 'Übernachtung', menge: 7, einzelCent: 5500, ustProzent: 7 }, + { text: 'Reinigung', menge: 1, einzelCent: 6000, ustProzent: 7 }, + { text: 'Direktbuchungsrabatt 10 %', menge: 1, einzelCent: -3850, ustProzent: 7 }, +] +const zeile = (nr, extra = {}) => ({ id: `a${nr}`, nummer: `A-${nr}`, von: '2026-11-12', bis: '2026-11-30', gast: { name: `Gast ${nr}` }, status: 'neu', summen: { bruttoCent: 40650 }, ...extra }) +const anfrage = (extra = {}) => ({ + id: 'a1', nummer: 'A-1', von: '2026-11-12', bis: '2026-11-19', personen: 2, status: 'neu', steuerart: 'regelbesteuert', unterkunftName: 'Haus am See', + gast: { name: 'Anna Muster', email: 'anna@example.com', telefon: '0151', nachricht: 'Hund dabei' }, ...extra, +}) +const vorschlag = { naechte: 7, posten: POSTEN, summen: { bruttoCent: 40650 } } + +const mit = (route, fn) => async () => { + const fx = installFakeDom({ antworten: Array.from({ length: 40 }, () => route) }) + try { await fn(fx) } finally { fx.restore() } +} +function baueCtx() { + const ctx = { projectId: 'p1', status: { state: 'active' }, nurLesen: false, meldungen: [], aktuell: () => true, request, zeigeMeldung: (t, a) => ctx.meldungen.push({ text: t, art: a }), neuLaden: async () => ctx.status } + return ctx +} +async function zeige(fx) { + const ctx = baueCtx() + const panel = h('div') + fx.document.body.append(panel) + await render(panel, ctx) + await fx.ruhe() + return { panel, ctx } +} +const server = (o = {}) => (a) => { + const key = `${a.method} ${a.url.split('?')[0]}` + if (o[key]) return o[key](a) + if (a.method === 'GET' && a.url.startsWith('/api/unterkunft/anfragen?')) return { body: { anfragen: [zeile(1), zeile(2, { status: 'gebucht' })] } } + if (a.method === 'GET' && a.url.startsWith('/api/unterkunft/anfragen/')) return { body: { anfrage: anfrage(), vorschlag } } + return { body: {} } +} +const posts = (fx) => fx.fetchLog.filter((a) => a.method !== 'GET') +const aktion = (fx, el, n) => fx.finde(el, `[data-aktion="${n}"]`) +const oeffne = async (fx, panel, id = 'a1') => { await fx.klick(fx.finde(panel, `[data-anfrage="${id}"]`)); await fx.ruhe() } + +test('REITER: fünf Reiter mit den Labels', () => { + assert.deepEqual(REITER.map((r) => r.key), ['anfragen', 'kalender', 'unterkuenfte', 'rechnungen', 'einstellungen']) + assert.deepEqual(REITER.map((r) => r.label), ['Anfragen', 'Kalender', 'Unterkünfte & Preise', 'Rechnungen', 'Einstellungen']) +}) + +test('summeLive spiegelt summiere: 40650 / 37991 / 2659', () => { + const s = summeLive(POSTEN, 'regelbesteuert') + assert.equal(s.bruttoCent, 40650) + assert.equal(s.nettoCent, 37991) + assert.equal(s.ust[0].steuerCent, 2659) + const k = summeLive(POSTEN, 'kleinunternehmer') + assert.deepEqual(k, { bruttoCent: 40650, nettoCent: 40650, ust: [] }) +}) + +test('Zeitraum und Nächte', () => { + assert.equal(zeitraumText('2026-11-12', '2026-11-30'), '12.–30.11.2026') + assert.equal(zeitraumText('2026-10-28', '2026-11-03'), '28.10.–03.11.2026') + assert.equal(zeitraumText('2026-12-28', '2027-01-03'), '28.12.2026–03.01.2027') + assert.equal(naechteText('2026-11-12', '2026-11-30'), '18 Nächte') + assert.equal(naechteText('2026-11-12', '2026-11-13'), '1 Nacht') +}) + +test('Liste: Nummer, Zeitraum, Gast, Status-Pille, Betrag; Filter offen als Standard', mit(server(), async (fx) => { + const { panel } = await zeige(fx) + assert.equal(fx.fetchLog[0].url, '/api/unterkunft/anfragen?projectId=p1&filter=offen') + const z = fx.alle(panel, '[data-anfrage]') + assert.equal(z.length, 2) + const t = fx.text(z[0]) + for (const re of [/A-1/, /12\.–30\.11\.2026 · 18 Nächte/, /Gast 1/, /Neu/, /406,50\s€/]) assert.match(t, re) + assert.match(fx.text(z[1]), /Gebucht/) + await fx.setze(fx.finde(panel, '#bk-a-filter'), 'alle') + await fx.ruhe() + assert.equal(fx.fetchLog.at(-1).url, '/api/unterkunft/anfragen?projectId=p1&filter=alle') +})) + +test('Detail: Vorschlag editierbar, Summe aktualisiert sich, Freigeben sendet posten/fristTage/nachricht und zeigt den Link', mit(server({ + 'POST /api/unterkunft/anfragen/a1/freigeben': () => ({ body: { buchungsLink: 'https://x.test/buchen/a1?t=abc' } }), +}), async (fx) => { + const { panel, ctx } = await zeige(fx) + await oeffne(fx, panel) + assert.match(fx.text(panel), /Anna Muster<\/b>/) + assert.equal(fx.finde(panel, 'b'), null, 'Fremdtext bleibt Text') + assert.match(fx.text(fx.finde(panel, '[data-summen]')), /Gesamt 406,50\s€/) + const zeilen = fx.alle(panel, '[data-zeile]') + assert.equal(zeilen.length, 3) + const einzel = fx.finde(zeilen[1], '[data-posten="einzel"]') + await fx.setze(einzel, '70,00') + assert.match(fx.text(fx.finde(panel, '[data-summen]')), /Gesamt 416,50\s€/) + await fx.setze(einzel, '60,00') + await fx.setze(fx.finde(panel, '#bk-a-frist'), '5') + await fx.setze(fx.finde(panel, '#bk-a-nachricht'), 'Gern!') + await fx.klick(aktion(fx, panel, 'freigeben')) + await fx.ruhe() + const [p] = posts(fx) + assert.equal(p.url, '/api/unterkunft/anfragen/a1/freigeben?projectId=p1') + assert.deepEqual(p.body, { posten: POSTEN, fristTage: 5, nachricht: 'Gern!' }) + assert.equal(fx.finde(panel, '[data-link]').getAttribute('value'), 'https://x.test/buchen/a1?t=abc') + assert.ok(aktion(fx, panel, 'link-kopieren')) + assert.equal(ctx.meldungen.at(-1).art, 'ok') +})) + +test('Freigeben: 409 zeigt „Der Zeitraum ist inzwischen belegt.“', mit(server({ + 'POST /api/unterkunft/anfragen/a1/freigeben': () => ({ status: 409, body: { fehler: 'zeitraum_belegt' } }), +}), async (fx) => { + const { panel } = await zeige(fx) + await oeffne(fx, panel) + await fx.klick(aktion(fx, panel, 'freigeben')) + await fx.ruhe() + assert.match(fx.text(panel), /Der Zeitraum ist inzwischen belegt\./) + assert.equal(aktion(fx, panel, 'freigeben').disabled, false) +})) + +test('Ablehnen sendet den Text', mit(server({ 'POST /api/unterkunft/anfragen/a1/ablehnen': () => ({ body: {} }) }), async (fx) => { + const { panel } = await zeige(fx) + await oeffne(fx, panel) + await fx.klick(aktion(fx, panel, 'ablehnen')) + await fx.setze(fx.finde(panel, '#bk-a-ablehn'), 'Leider belegt') + await fx.klick(aktion(fx, panel, 'ablehnen-senden')) + await fx.ruhe() + assert.deepEqual(posts(fx)[0].body, { text: 'Leider belegt' }) +})) + +const gebucht = (extra = {}) => ({ anfrage: anfrage({ status: 'gebucht', posten: POSTEN, summen: { bruttoCent: 40650 }, rechnungId: 'r1', rechnungNummer: 'R-2026-001', ...extra }) }) + +test('Gebucht: Rechnung-Link, „Als bezahlt (vor Ort)“, Storno ohne Erstattungsfeld bei Vor-Ort-Zahlung', mit(server({ + 'GET /api/unterkunft/anfragen/a1': () => ({ body: gebucht({ zahlart: 'vorort' }) }), + 'POST /api/unterkunft/anfragen/a1/bezahlt': () => ({ body: {} }), + 'POST /api/unterkunft/anfragen/a1/stornieren': () => ({ body: {} }), +}), async (fx) => { + const { panel } = await zeige(fx) + await oeffne(fx, panel) + assert.equal(aktion(fx, panel, 'rechnung').getAttribute('href'), '/api/unterkunft/rechnungen/r1.pdf?projectId=p1') + await fx.klick(aktion(fx, panel, 'bezahlt')) + await fx.ruhe() + assert.equal(posts(fx)[0].url, '/api/unterkunft/anfragen/a1/bezahlt?projectId=p1') + await fx.klick(aktion(fx, panel, 'stornieren')) + assert.equal(fx.finde(panel, '#bk-a-erstattung'), null) + await fx.setze(fx.finde(panel, '#bk-a-grund'), 'Gast krank') + await fx.klick(aktion(fx, panel, 'storno-senden')) + await fx.ruhe() + assert.deepEqual(posts(fx).at(-1).body, { grund: 'Gast krank', erstattungCent: 0 }) +})) + +test('Storno bei Online-Zahlung: Erstattungsbetrag wird gesendet', mit(server({ + 'GET /api/unterkunft/anfragen/a1': () => ({ body: gebucht({ status: 'bezahlt', zahlart: 'online', bezahltVia: 'online' }) }), + 'POST /api/unterkunft/anfragen/a1/stornieren': () => ({ body: {} }), +}), async (fx) => { + const { panel } = await zeige(fx) + await oeffne(fx, panel) + assert.equal(aktion(fx, panel, 'bezahlt'), null) + await fx.klick(aktion(fx, panel, 'stornieren')) + assert.equal(fx.finde(panel, '#bk-a-erstattung').value, '406,50') + await fx.setze(fx.finde(panel, '#bk-a-erstattung'), '200,00') + await fx.klick(aktion(fx, panel, 'storno-senden')) + await fx.ruhe() + assert.equal(posts(fx)[0].body.erstattungCent, 20000) +})) diff --git a/test/unterkunftWiring.test.js b/test/unterkunftWiring.test.js index 46b24ab..26ba26c 100644 --- a/test/unterkunftWiring.test.js +++ b/test/unterkunftWiring.test.js @@ -127,7 +127,7 @@ test('Portal: app.js und dashboard.html kennen den Reiter buchungen', () => { const html = lies('public/dashboard.html') assert.match(html, /
[\s\S]*?
[\s\S]*?<\/section>/) assert.equal([...html.matchAll(/id="buchungen-body"/g)].length, 1) - assert.match(lies('public/buchungen.js'), /export function initBuchungen\(el\)/) + assert.match(lies('public/buchungen.js'), /export function initBuchungen\(root, \{ projectId \} = \{\}\)/) }) test('Server: index.js hängt Mounts und Jobs ein, die Router-Dateien existieren', () => { From db9fc6189443db375ceea79399692eeacfd96dd1 Mon Sep 17 00:00:00 2001 From: Kenso Grimm Date: Wed, 7 Oct 2026 14:11:51 +0000 Subject: [PATCH 2/7] =?UTF-8?q?Buchungskalender:=20Kalender=20und=20Unterk?= =?UTF-8?q?=C3=BCnfte=20im=20Portal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- public/buchungen-fehler.js | 15 ++ public/buchungen-ical.js | 108 ++++++++++++++ public/buchungen-preise.js | 133 +++++++++++++++++ public/buchungen-tab-kalender.js | 187 +++++++++++++++++++++++- public/buchungen-tab-unterkuenfte.js | 129 ++++++++++++++++- public/buchungen-uk-hilfe.js | 25 ++++ public/buchungen.css | 20 +++ test/buchungenUi.test.js | 205 +++++++++++++++++++++++++++ 8 files changed, 814 insertions(+), 8 deletions(-) create mode 100644 public/buchungen-fehler.js create mode 100644 public/buchungen-ical.js create mode 100644 public/buchungen-preise.js create mode 100644 public/buchungen-uk-hilfe.js diff --git a/public/buchungen-fehler.js b/public/buchungen-fehler.js new file mode 100644 index 0000000..9eab0c3 --- /dev/null +++ b/public/buchungen-fehler.js @@ -0,0 +1,15 @@ +/* Buchungskalender: Fehler der Schnittstelle fuers Formular. Die Schnittstelle antwortet { fehler: '', 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 } } +} diff --git a/public/buchungen-ical.js b/public/buchungen-ical.js new file mode 100644 index 0000000..c5b22ea --- /dev/null +++ b/public/buchungen-ical.js @@ -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) +} diff --git a/public/buchungen-preise.js b/public/buchungen-preise.js new file mode 100644 index 0000000..1254415 --- /dev/null +++ b/public/buchungen-preise.js @@ -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 +} diff --git a/public/buchungen-tab-kalender.js b/public/buchungen-tab-kalender.js index f97bed8..9c6334d 100644 --- a/public/buchungen-tab-kalender.js +++ b/public/buchungen-tab-kalender.js @@ -1,6 +1,185 @@ -/* Buchungskalender, Reiter „Kalender“: Platzhalter (Task 13/14 fuellt ihn). */ -import { infoKarte } from './shop-dom.js' +/* Buchungskalender, Reiter „Kalender“: Monatsansicht je Unterkunft, Farben nach Quelle, zwei Klicks auf freie Nächte sperren den Zeitraum. + 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:') | 'manuell'. + POST /unterkuenfte/:id/sperren { von, bis, notiz } · DELETE /sperren/:id. Wochen beginnen am Montag. Fremdtext nur ueber textContent. */ -export function render(panel) { - panel.replaceChildren(infoKarte('Kalender', 'Dieser Bereich wird gerade gebaut.')) +import { abschnitt, allgemeinerFehler, fehlerKarte, feld, frage, h } from './shop-dom.js' +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) + } } diff --git a/public/buchungen-tab-unterkuenfte.js b/public/buchungen-tab-unterkuenfte.js index a457ea0..3bfead4 100644 --- a/public/buchungen-tab-unterkuenfte.js +++ b/public/buchungen-tab-unterkuenfte.js @@ -1,6 +1,127 @@ -/* Buchungskalender, Reiter „Unterkünfte & Preise“: Platzhalter (Task 13/14 fuellt ihn). */ -import { infoKarte } from './shop-dom.js' +/* Buchungskalender, Reiter „Unterkünfte & Preise“: Liste (anlegen, sortieren, aktivieren), Bearbeiten (Stammdaten, Preise, iCal). + 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) { - panel.replaceChildren(infoKarte('Unterkünfte & Preise', 'Dieser Bereich wird gerade gebaut.')) +import { abschnitt, allgemeinerFehler, fehlerKarte, feld, h, hakenFeld, setzeFehler, zeigeFehler } from './shop-dom.js' +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) + } + } + } } diff --git a/public/buchungen-uk-hilfe.js b/public/buchungen-uk-hilfe.js new file mode 100644 index 0000000..0eb9b86 --- /dev/null +++ b/public/buchungen-uk-hilfe.js @@ -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)) +} diff --git a/public/buchungen.css b/public/buchungen.css index 566ae9b..6789bec 100644 --- a/public/buchungen.css +++ b/public/buchungen.css @@ -35,3 +35,23 @@ .bk-posten-zeile { grid-template-columns: 1fr 1fr; } .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; } diff --git a/test/buchungenUi.test.js b/test/buchungenUi.test.js index 152055b..8f88022 100644 --- a/test/buchungenUi.test.js +++ b/test/buchungenUi.test.js @@ -164,3 +164,208 @@ test('Storno bei Online-Zahlung: Erstattungsbetrag wird gesendet', mit(server({ await fx.ruhe() 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 am 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 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) +})) From 37cd31254929eaa0de53683086afe8d3b83cb36b Mon Sep 17 00:00:00 2001 From: Kenso Grimm Date: Wed, 7 Oct 2026 14:12:37 +0000 Subject: [PATCH 3/7] =?UTF-8?q?Buchungskalender:=20Anfragen-Reiter=20?= =?UTF-8?q?=E2=80=93=20Erstattung=20und=20Fehlermeldungen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- public/buchungen-tab-anfragen.js | 17 +++++++++-------- test/buchungenUi.test.js | 13 ++++++++++++- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/public/buchungen-tab-anfragen.js b/public/buchungen-tab-anfragen.js index 5e411c0..92705cf 100644 --- a/public/buchungen-tab-anfragen.js +++ b/public/buchungen-tab-anfragen.js @@ -6,7 +6,8 @@ POST …/freigeben {posten,fristTage,nachricht} (=> { buchungsLink? }), …/ablehnen {text}, …/bezahlt, …/stornieren {grund,erstattungCent}. Fremdtext (Gast, Nachricht) nur ueber textContent. render(panel, ctx) wie shop-tab-*.js. */ -import { abschnitt, allgemeinerFehler, euroText, euroZuCent, fehlerKarte, feld, frage, h, zeigeFehler, datumText } from './shop-dom.js' +import { abschnitt, allgemeinerFehler, centZuEuro, euroZuCent, fehlerKarte, feld, frage, h, zeigeFehler, datumText } from './shop-dom.js' +import { fehlerFuerFormular } from './buchungen-fehler.js' import { baueEditor, euroMitVorzeichen } from './buchungen-posten.js' export const STATUS = { @@ -142,7 +143,7 @@ export async function render(panel, ctx) { feld('nachricht', 'Nachricht an den Gast (optional)', nachricht), allgemeinerFehler()) const freigeben = h('button', { - type: 'button', class: 'kb-btn', 'data-aktion': 'freigeben', disabled: ctx.nurLesen && !ctx.abwickeln, + type: 'button', class: 'kb-btn', 'data-aktion': 'freigeben', onclick: () => sendeFreigabe(a, editor, frist, nachricht, form, freigeben), }, 'Freigeben') const ablehnen = h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'ablehnen', onclick: () => ablehnDialog(a, ablehnen) }, 'Ablehnen') @@ -160,12 +161,12 @@ export async function render(panel, ctx) { method: 'POST', body: { posten, fristTage: tage, nachricht: String(nachricht.value).trim() }, }) if (!ctx.aktuell()) return - ctx.zeigeMeldung('Freigegeben. Dein Gast hat den Buchungslink per Mail bekommen.', 'ok') + ctx.zeigeMeldung('Freigegeben. Der Buchungslink wurde an deinen Gast geschickt – falls die Mail nicht ankommt, kannst du ihn hier kopieren.', 'ok') zeigeLink(antwort?.buchungsLink, a) } catch (err) { knopf.disabled = false if (err.status === 409) zeigeFehler(form, { message: BELEGT, fehler: { _: BELEGT } }) - else zeigeFehler(form, err) + else zeigeFehler(form, fehlerFuerFormular(err)) } } @@ -212,7 +213,7 @@ export async function render(panel, ctx) { await zeigeListe() } catch (err) { ev.currentTarget.disabled = false - zeigeFehler(box, err) + zeigeFehler(box, fehlerFuerFormular(err)) } }, }, 'Ablehnen'), @@ -262,10 +263,10 @@ export async function render(panel, ctx) { } function stornoDialog(a, dialogEl, ausloeser) { - const online = a.bezahltVia === 'online' + const online = a.bezahltVia === 'stripe' const grund = h('textarea', { id: 'bk-a-grund', rows: '3', maxlength: '500' }) const betrag = h('input', { type: 'text', inputmode: 'decimal', id: 'bk-a-erstattung', autocomplete: 'off' }) - betrag.value = Number.isInteger(a.summen?.bruttoCent) ? `${Math.floor((a.summen.bruttoCent - (a.erstattetCent || 0)) / 100)},${String((a.summen.bruttoCent - (a.erstattetCent || 0)) % 100).padStart(2, '0')}` : '' + betrag.value = Number.isInteger(a.summen?.bruttoCent) ? centZuEuro(a.summen.bruttoCent - (a.erstattetCent || 0)) : '' const box = frage('Buchung stornieren', h('p', { class: 'sh-hinweis' }, 'Der Gast bekommt eine Stornomail und eine Storno-Rechnung.'), feld('grund', 'Grund', grund), online ? feld('erstattungCent', 'Erstattung in Euro', betrag, 'Der Betrag geht an die Karte des Gastes zurück. 0 = keine Erstattung.') : null, @@ -290,7 +291,7 @@ export async function render(panel, ctx) { knopf.disabled = false if (err.fehler === 'erstattung_fehlgeschlagen' || err.body?.fehler === 'erstattung_fehlgeschlagen') { zeigeFehler(box, { message: 'Die Buchung ist storniert, aber die Erstattung ist fehlgeschlagen. Bitte versuch die Erstattung gleich noch einmal.', fehler: {} }) - } else zeigeFehler(box, err) + } else zeigeFehler(box, fehlerFuerFormular(err)) } }, }, 'Stornieren'), diff --git a/test/buchungenUi.test.js b/test/buchungenUi.test.js index 8f88022..7264d23 100644 --- a/test/buchungenUi.test.js +++ b/test/buchungenUi.test.js @@ -151,7 +151,7 @@ test('Gebucht: Rechnung-Link, „Als bezahlt (vor Ort)“, Storno ohne Erstattun })) test('Storno bei Online-Zahlung: Erstattungsbetrag wird gesendet', mit(server({ - 'GET /api/unterkunft/anfragen/a1': () => ({ body: gebucht({ status: 'bezahlt', zahlart: 'online', bezahltVia: 'online' }) }), + 'GET /api/unterkunft/anfragen/a1': () => ({ body: gebucht({ status: 'bezahlt', zahlart: 'online', bezahltVia: 'stripe' }) }), 'POST /api/unterkunft/anfragen/a1/stornieren': () => ({ body: {} }), }), async (fx) => { const { panel } = await zeige(fx) @@ -369,3 +369,14 @@ test('iCal: Import hinzufügen (POST), entfernen mit Rückfrage, Link erneuern m await fx.ruhe() assert.equal(posts(fx).filter((a) => a.url.includes('erneuern')).length, 1) })) + +test('Freigeben: Serverfehler 400 mit felder zeigt genau den Feldtext', mit(server({ + 'POST /api/unterkunft/anfragen/a1/freigeben': () => ({ status: 400, body: { fehler: 'ungueltig', felder: { posten: 'Die Summe muss größer als 0 sein.' } } }), +}), async (fx) => { + const { panel } = await zeige(fx) + await oeffne(fx, panel) + await fx.klick(aktion(fx, panel, 'freigeben')) + await fx.ruhe() + assert.equal(fx.text(fx.finde(panel, '[data-fehler="posten"]')), 'Die Summe muss größer als 0 sein.') + assert.equal(fx.text(fx.finde(panel, '[data-fehler="_"]')), '') +})) From 8c8fa055911f05c4d9470a94d8401cd720d61f35 Mon Sep 17 00:00:00 2001 From: Kenso Grimm Date: Wed, 7 Oct 2026 14:14:38 +0000 Subject: [PATCH 4/7] =?UTF-8?q?Buchungskalender:=20Preis-Editor=20pr=C3=BC?= =?UTF-8?q?ft=20Grenzen=20wie=20der=20Server?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- public/buchungen-fehler.js | 4 +- public/buchungen-preise.js | 34 +++++++++---- public/buchungen-tab-anfragen.js | 2 +- public/buchungen-tab-kalender.js | 6 ++- test/buchungenUi.test.js | 82 ++++++++++++++++++++++++++++++++ 5 files changed, 116 insertions(+), 12 deletions(-) diff --git a/public/buchungen-fehler.js b/public/buchungen-fehler.js index 9eab0c3..8f45297 100644 --- a/public/buchungen-fehler.js +++ b/public/buchungen-fehler.js @@ -9,7 +9,9 @@ const CODES = { /** => { 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.' + const STANDARD = 'Das hat nicht geklappt. Bitte versuch es noch einmal.' + const code = typeof err?.fehler === 'string' ? err.fehler : '' + const message = (code && CODES[code]) || (code ? STANDARD : err?.message) || STANDARD if (felder && typeof felder === 'object' && Object.keys(felder).length) return { message, fehler: felder } return { message, fehler: { _: message } } } diff --git a/public/buchungen-preise.js b/public/buchungen-preise.js index 1254415..8085f63 100644 --- a/public/buchungen-preise.js +++ b/public/buchungen-preise.js @@ -89,28 +89,44 @@ export async function zeichnePreise(ctx, unterkunft) { const knopf = ev.currentTarget const fehler = {} const staffeln = [] + let staffelFehler = '' 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.' + 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 staffeln.push({ abNaechten: ab, preisProNachtCent: p }) } - const zahl = (name, steuer, text) => { const n = ganzzahl(steuer); if (n === null) fehler[name] = text; return n } + if (!staffelFehler) { + if (staffeln.length < 1 || staffeln.length > 6) staffelFehler = 'Du kannst 1 bis 6 Staffeln anlegen.' + else if (staffeln[0].abNaechten !== 1) staffelFehler = 'Die erste Staffel beginnt bei 1 Nacht.' + else if (staffeln.some((s, i) => i > 0 && s.abNaechten <= staffeln[i - 1].abNaechten)) staffelFehler = 'Die Nächte der Staffeln müssen von oben nach unten größer werden, keine Zahl doppelt.' + } + if (staffelFehler) fehler.staffeln = staffelFehler + 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() if (aufpreisCent === null) fehler.aufpreisPersonNachtCent = 'Bitte gib einen Betrag wie 15,00 an.' + const regel = steuerart.value === 'regel' + const ustWert = (name, steuer, alt) => { + if (!regel) return ganzzahl(steuer) ?? alt // andere Steuerart: gespeicherten Wert unveraendert zurueckschicken + const n = ganzzahl(steuer) + if (![0, 7, 19].includes(n)) { fehler[name] = 'Erlaubt sind 0, 7 oder 19 Prozent.'; return null } + return n + } + const storn = String(storno.value).trim() + if (storn.length < 20 || storn.length > 5000) fehler.stornobedingungen = 'Die Stornobedingungen brauchen 20 bis 5000 Zeichen.' 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.'), + personenInklusive: zahl('personenInklusive', inklusive, 'Bitte gib eine ganze Zahl ab 1 an.', 1, 1000), + direktrabattProzent: zahl('direktrabattProzent', rabatt, 'Bitte gib eine ganze Zahl von 0 bis 50 an.', 0, 50), 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(), + ustUebernachtung: ustWert('ustUebernachtung', ustUe, preise.ustUebernachtung ?? 7), + ustReinigung: ustWert('ustReinigung', ustRe, preise.ustReinigung ?? 7), + fristTage: zahl('fristTage', frist, 'Bitte gib eine ganze Zahl von 1 bis 14 an.', 1, 14), + stornobedingungen: storn, } - 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 { diff --git a/public/buchungen-tab-anfragen.js b/public/buchungen-tab-anfragen.js index 92705cf..d94e08e 100644 --- a/public/buchungen-tab-anfragen.js +++ b/public/buchungen-tab-anfragen.js @@ -174,7 +174,7 @@ export async function render(panel, ctx) { function zeigeLink(link, a) { const box = h('section', { class: 'kb-panel sh-abschnitt bk-link', 'data-freigegeben': a.id }, h('h3', null, `Anfrage ${zeichen(a.nummer)} ist freigegeben`), - h('p', null, 'Dein Gast hat den Buchungslink per Mail bekommen.')) + h('p', null, 'Der Link wurde per Mail verschickt. Falls er nicht ankommt, kopiere ihn hier und schick ihn selbst.')) if (typeof link === 'string' && link) { const meldung = h('p', { class: 'sh-hinweis', role: 'status' }) box.append( diff --git a/public/buchungen-tab-kalender.js b/public/buchungen-tab-kalender.js index 9c6334d..61a997b 100644 --- a/public/buchungen-tab-kalender.js +++ b/public/buchungen-tab-kalender.js @@ -81,7 +81,7 @@ export async function render(panel, ctx) { 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 } + if (st.lauf === lauf && ctx.aktuell()) { st.bereiche = []; zeichne(); hinweis.textContent = err.message } return } zeichne() @@ -115,6 +115,10 @@ export async function render(panel, ctx) { return } if (ctx.nurLesen) return + if (tag < heuteBerlin()) { + hinweis.textContent = 'Vergangene Tage kannst du nicht sperren.' + return + } if (!st.start) { st.start = tag zeichne() diff --git a/test/buchungenUi.test.js b/test/buchungenUi.test.js index 7264d23..549182e 100644 --- a/test/buchungenUi.test.js +++ b/test/buchungenUi.test.js @@ -380,3 +380,85 @@ test('Freigeben: Serverfehler 400 mit felder zeigt genau den Feldtext', mit(serv assert.equal(fx.text(fx.finde(panel, '[data-fehler="posten"]')), 'Die Summe muss größer als 0 sein.') assert.equal(fx.text(fx.finde(panel, '[data-fehler="_"]')), '') })) + +// Fix round 1: Preis-Editor prüft Grenzen wie der Server +const preisEditor = async (fx, preise = PREISE) => { + const { panel } = await zeigeTab(fx, ukTab) + await fx.klick(aktion(fx, panel, 'bearbeiten')) + await fx.ruhe() + return panel +} +const versuche = async (fx, panel) => { + const vorher = posts(fx).filter((a) => a.url.includes('/preise')).length + await fx.klick(aktion(fx, panel, 'preise-speichern')) + await fx.ruhe() + return posts(fx).filter((a) => a.url.includes('/preise')).length - vorher +} +const fehlerText = (fx, panel, n) => fx.text(fx.finde(panel, `[data-fehler="${n}"]`)) + +test('Preis-Editor: Staffeln (Anzahl, erste ab 1, aufsteigend, Preisgrenzen)', mit(ukServer({ 'PUT /api/unterkunft/unterkuenfte/u1/preise': () => ({ body: {} }) }), async (fx) => { + const panel = await preisEditor(fx) + const zeilen = () => fx.alle(panel, '[data-staffel-zeile]') + const ab = (i) => fx.finde(zeilen()[i], '[data-staffel="ab"]') + const preis = (i) => fx.finde(zeilen()[i], '[data-staffel="preis"]') + await fx.setze(ab(1), '1') + assert.equal(await versuche(fx, panel), 0) + assert.match(fehlerText(fx, panel, 'staffeln'), /größer werden/) + await fx.setze(ab(1), '7') + await fx.setze(preis(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(preis(1), '1.000,01') + assert.equal(await versuche(fx, panel), 0) + await fx.setze(preis(1), '50') + for (let i = 0; i < 5; i += 1) { await fx.klick(aktion(fx, panel, 'staffel-dazu')); await fx.setze(ab(zeilen().length - 1), String(10 + i)); await fx.setze(preis(zeilen().length - 1), '40') } + assert.equal(zeilen().length, 7) + assert.equal(await versuche(fx, panel), 0) + assert.match(fehlerText(fx, panel, 'staffeln'), /1 bis 6/) + await fx.klick(fx.finde(zeilen()[6], '[data-aktion="staffel-weg"]')) + assert.equal(await versuche(fx, panel), 1) +})) + +test('Preis-Editor: Rabatt, Frist, Personen, Stornobedingungen, USt', mit(ukServer({ 'PUT /api/unterkunft/unterkuenfte/u1/preise': () => ({ body: {} }) }), async (fx) => { + const panel = await preisEditor(fx) + const fall = async (sel, wert, ok, feldName, re) => { + const alt = fx.finde(panel, sel).value + await fx.setze(fx.finde(panel, sel), wert) + assert.equal(await versuche(fx, panel), 0, `${sel}=${wert}`) + assert.match(fehlerText(fx, panel, feldName), re) + await fx.setze(fx.finde(panel, sel), alt) + } + await fall('#bk-p-rabatt', '51', 0, 'direktrabattProzent', /0 bis 50/) + await fall('#bk-p-frist', '0', 0, 'fristTage', /1 bis 14/) + await fall('#bk-p-frist', '15', 0, 'fristTage', /1 bis 14/) + await fall('#bk-p-inklusive', '0', 0, 'personenInklusive', /ab 1/) + await fall('#bk-p-storno', 'zu kurz', 0, 'stornobedingungen', /20 bis 5000/) + await fall('#bk-p-ust-ue', '8', 0, 'ustUebernachtung', /0, 7 oder 19/) + await fall('#bk-p-ust-re', '20', 0, 'ustReinigung', /0, 7 oder 19/) + assert.equal(await versuche(fx, panel), 1) +})) + +test('Preis-Editor: gespeicherte USt 19 bleibt bei Kleinunternehmer unverändert', mit(ukServer({ + 'GET /api/unterkunft/unterkuenfte/u1/preise': () => ({ body: { preise: { ...PREISE, steuerart: 'kleinunternehmer', ustUebernachtung: 19, ustReinigung: 0 } } }), + 'PUT /api/unterkunft/unterkuenfte/u1/preise': () => ({ body: {} }), +}), async (fx) => { + const panel = await preisEditor(fx) + assert.equal(await versuche(fx, panel), 1) + const put = posts(fx).find((a) => a.url.includes('/preise')) + assert.equal(put.body.steuerart, 'kleinunternehmer') + assert.equal(put.body.ustUebernachtung, 19) + assert.equal(put.body.ustReinigung, 0) +})) + +test('fehlerFuerFormular: unbekannter Code → deutscher Standardtext', async () => { + const { fehlerFuerFormular } = await import('../public/buchungen-fehler.js') + assert.equal(fehlerFuerFormular({ fehler: 'komischer_code', message: 'Fehler 400' }).fehler._, 'Das hat nicht geklappt. Bitte versuch es noch einmal.') + assert.equal(fehlerFuerFormular({ fehler: 'zeitraum_belegt', message: 'x' }).fehler._, 'Der Zeitraum ist bereits belegt.') +}) + +test('Kalender: vergangene Tage lassen sich nicht sperren', mit(ukServer(), async (fx) => { + const { panel } = await zeigeTab(fx, kal) + await fx.klick(tag(fx, panel, '2026-10-01')) + assert.match(fx.text(fx.finde(panel, '[data-hinweis]')), /Vergangene Tage/) + assert.equal(fx.finde(panel, '[data-aktion="sperren-senden"]'), null) +})) From 10fe9a2ae025f87f73f2c26d22e870ac0b1be70b Mon Sep 17 00:00:00 2001 From: Kenso Grimm Date: Wed, 7 Oct 2026 14:16:24 +0000 Subject: [PATCH 5/7] Buchungskalender: Portal-Tests mit fester Uhr Co-Authored-By: Claude Opus 5.5 --- public/buchungen-tab-anfragen.js | 1 - public/buchungen-uk-hilfe.js | 5 ++++- test/buchungenUi.test.js | 7 ++++++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/public/buchungen-tab-anfragen.js b/public/buchungen-tab-anfragen.js index d94e08e..8d9ad5d 100644 --- a/public/buchungen-tab-anfragen.js +++ b/public/buchungen-tab-anfragen.js @@ -178,7 +178,6 @@ export async function render(panel, ctx) { if (typeof link === 'string' && link) { const meldung = h('p', { class: 'sh-hinweis', role: 'status' }) box.append( - h('p', null, 'Falls die Mail nicht ankommt, kannst du den Link selbst schicken:'), h('input', { type: 'text', readonly: true, value: link, 'aria-label': 'Buchungslink', 'data-link': true }), h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'link-kopieren', diff --git a/public/buchungen-uk-hilfe.js b/public/buchungen-uk-hilfe.js index 0eb9b86..cfa1b95 100644 --- a/public/buchungen-uk-hilfe.js +++ b/public/buchungen-uk-hilfe.js @@ -9,7 +9,10 @@ export const tagMs = (tag) => { const [j, m, t] = String(tag).split('-').map(Num 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' }) +let jetzt = () => new Date() +/** Uhr der Portal-Reiter austauschen (nur fuer Tests); ohne Argument zurueck auf die echte Uhr. */ +export const setzeUhrFuerTests = (fn) => { jetzt = fn || (() => new Date()) } +export const heuteBerlin = () => jetzt().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]}` : '' } diff --git a/test/buchungenUi.test.js b/test/buchungenUi.test.js index 549182e..596c91c 100644 --- a/test/buchungenUi.test.js +++ b/test/buchungenUi.test.js @@ -192,8 +192,13 @@ const zeigeTab = async (fx, modul) => { } const kal = await import('../public/buchungen-tab-kalender.js') const ukTab = await import('../public/buchungen-tab-unterkuenfte.js') +import { setzeUhrFuerTests } from '../public/buchungen-uk-hilfe.js' +setzeUhrFuerTests(() => new Date('2026-10-07T10:00:00Z')) // feste Uhr: Kalender und Sperren-Tests nutzen Daten im Herbst 2026 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() } } +const gehZuNovember = async (fx, panel) => { + for (let i = 0; i < 24 && !/November 2026/.test(fx.text(fx.finde(panel, '[data-monat]'))); i += 1) { await fx.klick(aktion(fx, panel, 'weiter')); await fx.ruhe() } + assert.match(fx.text(fx.finde(panel, '[data-monat]')), /November 2026/, 'November 2026 nicht erreicht') +} test('rasterTage: November 2026 beginnt am Montag 26.10. und endet am Sonntag 29.11.... ', () => { const t = kal.rasterTage(2026, 10) From efeb99e21478868b7368860786a237b173527dc6 Mon Sep 17 00:00:00 2001 From: Kenso Grimm Date: Wed, 7 Oct 2026 14:23:19 +0000 Subject: [PATCH 6/7] Buchungskalender: Rechnungen, Einstellungen und Funktionen-Kachel Co-Authored-By: Claude Opus 5.5 --- public/buchungen-anfrage.js | 136 +++++++++++++++ public/buchungen-tab-einstellungen.js | 186 +++++++++++++++++++- public/buchungen-tab-rechnungen.js | 35 +++- public/buchungen.css | 8 + public/buchungen.js | 31 +++- public/funktionen.js | 53 +++++- test/buchungenUi.test.js | 241 ++++++++++++++++++++++++++ test/funktionenTiles.test.js | 17 ++ 8 files changed, 696 insertions(+), 11 deletions(-) create mode 100644 public/buchungen-anfrage.js diff --git a/public/buchungen-anfrage.js b/public/buchungen-anfrage.js new file mode 100644 index 0000000..29f604e --- /dev/null +++ b/public/buchungen-anfrage.js @@ -0,0 +1,136 @@ +/* Buchungskalender im Kundenbereich: die Anfrage der Funktion (Zustand off), Muster shop-anfrage.js. + POST /api/unterkunft/anfrage-funktion?projectId= { art, einheiten, portale, nachricht } => { anfrage } (portale: Liste aus 'booking' | 'airbnb'), + POST /api/unterkunft/anfrage-funktion/zurueckziehen?projectId=. Status im Stand (GET /status): anfrage:{ status:'offen'|'abgelehnt'|'angenommen', + at, begruendung?, anfrage:{ art, einheiten, portale, nachricht } }. Geprueft wird auf dem Server (Feldfehler am Feld). Text nur ueber textContent (h). */ + +import { allgemeinerFehler, datumText, feld, h, hakenFeld, request, setzeFehler, zeigeFehler } from './shop-dom.js' +import { fehlerFuerFormular } from './buchungen-fehler.js' + +export const ARTEN = [['ferienwohnung', 'Ferienwohnung oder Ferienhaus'], ['monteur', 'Monteurwohnung'], ['pension', 'Pension oder Gästezimmer'], ['sonstiges', 'Sonstiges']] +export const PORTALE = [['booking', 'Booking.com'], ['airbnb', 'Airbnb']] +export const KALENDER_PREIS = '199 €' + +const text = (wert) => (typeof wert === 'string' ? wert : '') +const labelVon = (liste, key) => liste.find(([k]) => k === key)?.[1] || '' +const enc = encodeURIComponent + +/** Angaben der Anfrage als Definitionsliste (nur Text). */ +export function anfrageListe(a) { + const s = a && typeof a === 'object' ? a : {} + const portale = (Array.isArray(s.portale) ? s.portale : []).map((p) => labelVon(PORTALE, p)).filter(Boolean).join(', ') + const zeilen = [ + ['Art der Unterkunft', labelVon(ARTEN, s.art)], + ['Anzahl Einheiten', Number.isInteger(s.einheiten) ? String(s.einheiten) : ''], + ['Portale', portale || 'keine'], + ['Nachricht', text(s.nachricht)], + ].filter(([, wert]) => wert !== '') + return h('dl', { class: 'sh-fakten' }, zeilen.map(([name, wert]) => h('div', null, h('dt', null, name), h('dd', null, wert)))) +} + +/** Formular „Buchungskalender anfragen“ in root; nachSenden(anfrage) wird nach dem Speichern aufgerufen (die Ansicht zeichnet sich neu). */ +export function renderAnfrageFormular(root, { projectId, vorgabe = null, preis = KALENDER_PREIS, nachSenden } = {}) { + const v = vorgabe && typeof vorgabe === 'object' ? vorgabe : {} + const art = h('select', null, [h('option', { value: '' }, 'Bitte wählen …'), ...ARTEN.map(([w, n]) => h('option', { value: w }, n))]) + art.value = ARTEN.some(([k]) => k === v.art) ? v.art : '' + const einheiten = h('input', { type: 'text', inputmode: 'numeric', maxlength: 3, autocomplete: 'off' }) + einheiten.value = Number.isInteger(v.einheiten) ? String(v.einheiten) : '' + const portale = PORTALE.map(([wert]) => { + const box = h('input', { type: 'checkbox', value: wert }) + box.checked = Array.isArray(v.portale) && v.portale.includes(wert) + return box + }) + const nachricht = h('textarea', { rows: 3, maxlength: 1000 }) + nachricht.value = text(v.nachricht) + const absenden = h('button', { type: 'submit', class: 'kb-btn-primary', 'data-aktion': 'anfrage-senden' }, 'Anfrage senden') + + const form = h('form', { class: 'sh-form', novalidate: true, 'aria-label': 'Buchungskalender anfragen' }, + feld('art', 'Welche Art von Unterkunft vermietest du?', art), + feld('einheiten', 'Wie viele Einheiten (Wohnungen, Zimmer) vermietest du?', einheiten), + h('fieldset', { class: 'sh-gruppe' }, + h('legend', null, 'Nutzt du Booking.com oder Airbnb?'), + ...PORTALE.map(([wert, name], i) => hakenFeld(`portale.${wert}`, portale[i], name)), + h('p', { class: 'sh-fehler', 'data-fehler': 'portale', role: 'alert' })), + feld('nachricht', 'Noch etwas, das wir wissen sollten? (freiwillig)', nachricht), + allgemeinerFehler(), + h('div', { class: 'sh-aktionen' }, absenden)) + + let laeuft = false + form.addEventListener('submit', async (ev) => { + ev.preventDefault() + if (laeuft) return + laeuft = true + absenden.disabled = true + setzeFehler(form, {}) + const zahl = einheiten.value.trim() + try { + const antwort = await request(`/api/unterkunft/anfrage-funktion?projectId=${enc(projectId)}`, { + method: 'POST', + body: { + art: art.value, + einheiten: /^\d{1,3}$/.test(zahl) ? Number(zahl) : zahl, + portale: portale.filter((b) => b.checked).map((b) => b.value), + nachricht: nachricht.value.trim(), + }, + }) + if (typeof nachSenden === 'function') await nachSenden(antwort?.anfrage ?? null) + return + } catch (err) { + zeigeFehler(form, fehlerFuerFormular(err)) + if (err?.status === 409 && typeof nachSenden === 'function') { + try { await nachSenden(null) } catch { /* der Fehler steht schon am Formular */ } + return + } + } + laeuft = false + absenden.disabled = false + }) + + const erklaerung = h('div', { class: 'sh-erklaerung' }, + h('h3', null, 'Buchungskalender anfragen'), + h('p', null, 'Mit dem Buchungskalender zeigst du auf deiner Website, wann deine Unterkunft frei ist. Gäste fragen direkt an, du gibst frei, und die Rechnung entsteht automatisch.'), + h('ul', null, + h('li', null, `${preis} einmalig für den Einbau in deine Website.`), + h('li', null, 'Belegungskalender mit Abgleich zu Booking.com und Airbnb (über iCal).'), + h('li', null, 'Rechnung in deinem Namen und optional Online-Zahlung auf dein eigenes Stripe-Konto.'), + h('li', null, 'Wir sehen uns deine Anfrage kurz an. Erst nach unserer Zusage kannst du bestellen.'))) + const karte = h('section', { class: 'kb-panel sh-anfrage' }, erklaerung, form) + root.replaceChildren(karte) + return karte +} + +/** Karte einer Anfrage im Stand offen (mit „Anfrage zurückziehen“) oder abgelehnt (Begründung, „Neue Anfrage stellen“). */ +export function anfrageKarte(ansicht, { projectId, neu, neuLaden } = {}) { + const offen = ansicht?.status === 'offen' + const meldung = h('p', { class: 'sh-fehler', role: 'alert', 'data-meldung': 'anfrage' }) + const datum = datumText(ansicht?.at) + let aktion + if (offen) { + aktion = h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'zurueckziehen' }, 'Anfrage zurückziehen') + aktion.addEventListener('click', async () => { + if (aktion.disabled) return + aktion.disabled = true + meldung.textContent = '' + try { + await request(`/api/unterkunft/anfrage-funktion/zurueckziehen?projectId=${enc(projectId)}`, { method: 'POST' }) + if (typeof neuLaden === 'function') await neuLaden() + return + } catch (err) { + meldung.textContent = err.message + if (err?.status === 409 && typeof neuLaden === 'function') { try { await neuLaden() } catch { /* Meldung bleibt */ } return } + } + aktion.disabled = false + }) + } else { + aktion = h('button', { type: 'button', class: 'kb-btn-primary', 'data-aktion': 'neue-anfrage', onclick: () => { if (typeof neu === 'function') neu() } }, 'Neue Anfrage stellen') + } + return h('section', { class: 'kb-panel sh-info sh-anfrage-karte', 'data-anfrage': offen ? 'offen' : 'abgelehnt' }, + h('span', { class: `sh-pille ${offen ? 'sh-pille-wait' : 'sh-pille-bad'}` }, offen ? 'In Prüfung' : 'Abgelehnt'), + h('h3', null, offen ? 'Deine Anfrage wird geprüft' : 'Deine Anfrage wurde leider abgelehnt'), + offen + ? h('p', null, `${datum ? `Du hast deine Anfrage am ${datum} Uhr gesendet. ` : ''}Wir melden uns, sobald wir sie angesehen haben. Bis dahin kannst du sie zurückziehen und eine neue stellen.`) + : h('p', null, 'Unsere Begründung:'), + !offen ? h('blockquote', { class: 'sh-begruendung' }, text(ansicht?.begruendung) || 'Dazu liegt keine Begründung vor.') : null, + ansicht?.anfrage ? h('div', { class: 'sh-zusammenfassung' }, h('h4', null, 'Deine Angaben'), anfrageListe(ansicht.anfrage)) : null, + meldung, + h('div', { class: 'sh-aktionen' }, aktion, h('button', { type: 'button', class: 'kb-btn-outline', 'data-view': 'chat' }, 'Frage dazu? Chat öffnen'))) +} diff --git a/public/buchungen-tab-einstellungen.js b/public/buchungen-tab-einstellungen.js index ef2a77e..4e2d5b9 100644 --- a/public/buchungen-tab-einstellungen.js +++ b/public/buchungen-tab-einstellungen.js @@ -1,6 +1,184 @@ -/* Buchungskalender, Reiter „Einstellungen“: Platzhalter (Task 13/14 fuellt ihn). */ -import { infoKarte } from './shop-dom.js' +/* Buchungskalender, Reiter „Einstellungen“: Firmendaten, Steuernummer/USt-IdNr., Rechnungspraefix, Auftrag zur Rechnungsstellung, + Bankverbindung, Akzentfarbe, Stornobedingungen (Mustertext), Stripe-Konto und Online-Zahlung. + API (docs/funktionen/unterkunft-api.md, ?projectId=): GET /einstellungen => { einstellungen:{ firma, inhaber, strasse, plz, ort, email, telefon, + steuernummer, ustId, iban, bic, bank, rechnungsPraefix, akzentfarbe, onlineZahlung, rechnungAuftrag, stornobedingungen }, + stripe:{ status:'keins'|'onboarding'|'aktiv', vorhandenesShopKonto:boolean }, rechnungenVorhanden:boolean }; + PUT /einstellungen mit denselben Feldern (ohne Wrapper; rechnungAuftrag:true setzt rechnungAuftragAt auf dem Server); + POST /stripe/verbinden => { url } (Onboarding bei Stripe), POST /stripe/vorhandenes-konto und /stripe/aktualisieren => { stripe }. + Geprueft wird auf dem Server; Feldfehler stehen am Feld. Text nur ueber textContent (h). render(panel, ctx) wie shop-tab-*.js. */ -export function render(panel) { - panel.replaceChildren(infoKarte('Einstellungen', 'Dieser Bereich wird gerade gebaut.')) +import { abschnitt, allgemeinerFehler, feld, h, hakenFeld, setzeFehler } from './shop-dom.js' +import { enc, zeichen, zeigeApiFehler } from './buchungen-uk-hilfe.js' + +export const STORNO_MUSTER = 'Stornierung bis 14 Tage vor Anreise: kostenlos. Stornierung 13 bis 3 Tage vor Anreise: 50 % des Gesamtpreises. Stornierung ab 2 Tage vor Anreise oder Nichtanreise: 90 % des Gesamtpreises. Die Stornierung bedarf der Textform (z. B. E-Mail). Maßgeblich ist der Eingang bei uns.' +export const STORNO_HINWEIS = 'Vorlage – bitte an deine Bedingungen anpassen, keine Rechtsberatung.' +export const AUFTRAG_TEXT = 'Ich beauftrage WEBklar, in meinem Namen und auf meine Rechnung Rechnungen an meine Gäste auszustellen (§ 14 Abs. 2 Satz 4 UStG).' +export const AUFTRAG_FEHLT = 'Ohne diesen Auftrag kannst du Buchungen nicht bestätigen, weil wir keine Rechnungen für dich ausstellen dürfen.' +const STRIPE_FEHLER = 'Stripe hat keine gültige Adresse geliefert. Bitte versuch es gleich noch einmal.' +const PFLICHT = { firma: 'Bitte gib den Namen deiner Firma oder Unterkunft an.', inhaber: 'Bitte gib den Namen des Inhabers an.', strasse: 'Bitte gib Straße und Hausnummer an.', plz: 'Bitte gib die Postleitzahl an.', ort: 'Bitte gib den Ort an.', email: 'Bitte gib eine E-Mail-Adresse an.' } + +const textfeld = (optionen = {}) => h('input', { type: 'text', autocomplete: 'off', ...optionen }) +const istHttps = (url) => { try { return new URL(url).protocol === 'https:' } catch { return false } } + +export async function render(panel, ctx = {}) { + const projectId = ctx.projectId + const q = `projectId=${enc(projectId)}` + const nurLesen = ctx.nurLesen === true + const antwort = await ctx.request(`/api/unterkunft/einstellungen?${q}`) + const e = antwort?.einstellungen && typeof antwort.einstellungen === 'object' ? antwort.einstellungen : {} + const stripe = antwort?.stripe || ctx.status?.stripe || { status: 'keins' } + const rechnungenVorhanden = (antwort?.rechnungenVorhanden ?? ctx.status?.rechnungenVorhanden) === true + const melde = (text, art) => { if (typeof ctx.zeigeMeldung === 'function') ctx.zeigeMeldung(text, art) } + const neu = () => render(panel, ctx).catch((err) => panel.replaceChildren(h('p', { class: 'sh-fehler', role: 'alert' }, err.message))) + + /* ----------------------------------------------------------------- Felder --- */ + const firma = textfeld({ maxlength: 120, autocomplete: 'organization' }); firma.value = zeichen(e.firma) + const inhaber = textfeld({ maxlength: 120 }); inhaber.value = zeichen(e.inhaber) + const strasse = textfeld({ maxlength: 120, autocomplete: 'street-address' }); strasse.value = zeichen(e.strasse) + const plz = textfeld({ maxlength: 10, inputmode: 'numeric', autocomplete: 'postal-code' }); plz.value = zeichen(e.plz) + const ort = textfeld({ maxlength: 80, autocomplete: 'address-level2' }); ort.value = zeichen(e.ort) + const email = h('input', { type: 'email', maxlength: 254, autocomplete: 'email' }); email.value = zeichen(e.email) + const telefon = h('input', { type: 'tel', maxlength: 40, autocomplete: 'tel' }); telefon.value = zeichen(e.telefon) + const steuernummer = textfeld({ maxlength: 30 }); steuernummer.value = zeichen(e.steuernummer) + const ustId = textfeld({ maxlength: 20, placeholder: 'DE123456789' }); ustId.value = zeichen(e.ustId) + const praefix = textfeld({ maxlength: 8, placeholder: 'RE' }); praefix.value = zeichen(e.rechnungsPraefix) || 'RE' + if (rechnungenVorhanden) { praefix.disabled = true; praefix.setAttribute('aria-disabled', 'true') } + const auftrag = h('input', { type: 'checkbox' }); auftrag.checked = e.rechnungAuftrag === true + const iban = textfeld({ maxlength: 40 }); iban.value = zeichen(e.iban) + const bic = textfeld({ maxlength: 11 }); bic.value = zeichen(e.bic) + const bank = textfeld({ maxlength: 80 }); bank.value = zeichen(e.bank) + const akzent = h('input', { type: 'color' }); akzent.value = /^#[0-9a-fA-F]{6}$/.test(zeichen(e.akzentfarbe)) ? e.akzentfarbe : '#111111' + const storno = h('textarea', { rows: 6, maxlength: 5000 }); storno.value = zeichen(e.stornobedingungen) + const online = h('input', { type: 'checkbox' }); online.checked = e.onlineZahlung === true && stripe.status === 'aktiv' + + const auftragHinweis = h('div', { class: 'sh-box sh-box-warn', role: 'note', 'data-hinweis': 'auftrag' }, AUFTRAG_FEHLT) + const zeigeAuftrag = () => { auftragHinweis.hidden = auftrag.checked } + auftrag.addEventListener('change', zeigeAuftrag) + zeigeAuftrag() + + const musterKnopf = h('button', { + type: 'button', class: 'kb-btn-outline', 'data-aktion': 'storno-muster', + onclick: () => { storno.value = STORNO_MUSTER }, + }, 'Mustertext übernehmen') + + /* ------------------------------------------------------------------ Stripe --- */ + let beschaeftigt = false + async function stripeAktion(pfad, { weiter = false } = {}) { + if (beschaeftigt || nurLesen) return + beschaeftigt = true + for (const k of stripeKarte.querySelectorAll('button')) k.setAttribute('disabled', '') + try { + const a = await ctx.request(`/api/unterkunft/stripe/${pfad}?${q}`, { method: 'POST' }) + if (weiter) { + if (!istHttps(a?.url)) throw new Error(STRIPE_FEHLER) + melde('Du wirst zu Stripe weitergeleitet …', 'ok') + window.location.href = a.url + return + } + if (typeof ctx.neuLaden === 'function') { try { await ctx.neuLaden() } catch { /* der Stand erscheint beim naechsten Laden */ } } + await neu() + } catch (err) { + melde(err?.message || 'Das hat nicht geklappt. Bitte versuch es noch einmal.', 'fehler') + for (const k of stripeKarte.querySelectorAll('button')) k.removeAttribute('disabled') + } finally { + beschaeftigt = false + } + } + const knopf = (text, aktion, pfad, optionen, art = 'kb-btn-outline') => h('button', { type: 'button', class: art, 'data-aktion': aktion, onclick: () => stripeAktion(pfad, optionen) }, text) + const zustandText = { keins: 'Noch nicht verbunden', onboarding: 'Angaben bei Stripe noch offen', aktiv: 'Verbunden und aktiv' } + const status = ['keins', 'onboarding', 'aktiv'].includes(stripe.status) ? stripe.status : 'keins' + const stripeKnoepfe = nurLesen ? [] : status === 'keins' + ? [knopf('Stripe-Konto verbinden', 'stripe-verbinden', 'verbinden', { weiter: true }, 'kb-btn-primary'), + stripe.vorhandenesShopKonto === true ? knopf('Vorhandenes Webshop-Konto verwenden', 'stripe-vorhanden', 'vorhandenes-konto') : null] + : status === 'onboarding' + ? [knopf('Angaben bei Stripe ergänzen', 'stripe-verbinden', 'verbinden', { weiter: true }, 'kb-btn-primary'), knopf('Status aktualisieren', 'stripe-aktualisieren', 'aktualisieren')] + : [knopf('Status aktualisieren', 'stripe-aktualisieren', 'aktualisieren')] + const stripeKarte = abschnitt('Stripe-Konto für Online-Zahlung', 'Gäste können die Buchung online bezahlen. Das Geld geht direkt auf dein Stripe-Konto.', + h('p', { 'data-stripe-status': status, class: status === 'aktiv' ? 'sh-ok' : 'sh-offen' }, zustandText[status]), + h('div', { class: 'sh-aktionen' }, ...stripeKnoepfe)) + + /* ----------------------------------------------------------------- Formular --- */ + const speichern = h('button', { type: 'submit', class: 'kb-btn-primary', 'data-aktion': 'einstellungen-speichern' }, 'Einstellungen speichern') + const form = h('form', { class: 'sh-form sh-einstellungen', novalidate: true, 'aria-label': 'Einstellungen des Buchungskalenders' }, + abschnitt('Deine Firmendaten', 'Diese Angaben stehen auf der Buchungsseite und auf den Rechnungen an deine Gäste. Pflichtfelder sind mit * markiert.', + feld('firma', 'Firma oder Name der Unterkunft *', firma), + feld('inhaber', 'Inhaber *', inhaber), + feld('strasse', 'Straße und Hausnummer *', strasse), + feld('plz', 'Postleitzahl *', plz), + feld('ort', 'Ort *', ort), + feld('email', 'E-Mail-Adresse *', email, 'An diese Adresse gehen neue Anfragen.'), + feld('telefon', 'Telefon (freiwillig)', telefon)), + abschnitt('Steuer', 'Eine der beiden Angaben ist Pflicht (§ 14 Abs. 4 UStG).', + feld('steuernummer', 'Steuernummer', steuernummer), + feld('ustId', 'USt-IdNr.', ustId, 'Zum Beispiel DE123456789.')), + abschnitt('Rechnungen', null, + feld('rechnungsPraefix', 'Rechnungspräfix', praefix, rechnungenVorhanden + ? 'Der Präfix ist gesperrt, weil es schon Rechnungen gibt. Die Nummern bleiben so lückenlos.' + : '2 bis 8 Zeichen (A–Z, 0–9). Die Nummern lauten dann zum Beispiel RE-2026-0001. Du kannst den Präfix nur vor der ersten Rechnung ändern.'), + hakenFeld('rechnungAuftrag', auftrag, AUFTRAG_TEXT), + auftragHinweis), + abschnitt('Bankverbindung (freiwillig)', 'Erscheint als Hinweis, wenn Gäste vor Ort oder per Überweisung zahlen.', + feld('iban', 'IBAN', iban), + feld('bic', 'BIC', bic), + feld('bank', 'Name der Bank', bank)), + abschnitt('Darstellung', null, feld('akzentfarbe', 'Akzentfarbe', akzent, 'Diese Farbe bekommen Knöpfe auf deiner Buchungsseite.')), + abschnitt('Stornobedingungen', 'Sie stehen auf der Buchungsseite und gelten für neue Freigaben.', + feld('stornobedingungen', 'Stornobedingungen', storno, STORNO_HINWEIS), + h('div', { class: 'sh-aktionen' }, musterKnopf)), + status === 'aktiv' + ? abschnitt('Online-Zahlung', null, hakenFeld('onlineZahlung', online, 'Online-Zahlung anbieten', 'Gäste können bei der Buchung per Stripe bezahlen.')) + : null, + allgemeinerFehler(), + nurLesen ? null : h('div', { class: 'sh-aktionen' }, speichern)) + + /* --------------------------------------------------------------- Speichern --- */ + function sammle() { + const fehler = {} + const felder = { firma, inhaber, strasse, plz, ort, email } + for (const [name, el] of Object.entries(felder)) if (!el.value.trim()) fehler[name] = PFLICHT[name] + if (!steuernummer.value.trim() && !ustId.value.trim()) { + fehler.steuernummer = 'Bitte gib Steuernummer oder USt-IdNr. an.' + fehler.ustId = 'Bitte gib Steuernummer oder USt-IdNr. an.' + } + const p = praefix.value.trim().toUpperCase() + if (!rechnungenVorhanden && !/^[A-Z0-9]{2,8}$/.test(p)) fehler.rechnungsPraefix = 'Der Präfix braucht 2 bis 8 Zeichen aus A–Z und 0–9.' + if (Object.keys(fehler).length) return { fehler } + return { + einstellungen: { + firma: firma.value.trim(), inhaber: inhaber.value.trim(), strasse: strasse.value.trim(), plz: plz.value.trim(), ort: ort.value.trim(), + email: email.value.trim(), telefon: telefon.value.trim(), steuernummer: steuernummer.value.trim(), ustId: ustId.value.trim(), + iban: iban.value.trim(), bic: bic.value.trim(), bank: bank.value.trim(), + rechnungsPraefix: rechnungenVorhanden ? zeichen(e.rechnungsPraefix) || 'RE' : p, + rechnungAuftrag: auftrag.checked, akzentfarbe: akzent.value, stornobedingungen: storno.value.trim(), + onlineZahlung: status === 'aktiv' && online.checked, + }, + } + } + let laeuft = false + form.addEventListener('submit', async (ev) => { + ev.preventDefault() + if (nurLesen || laeuft) return + laeuft = true + speichern.disabled = true + setzeFehler(form, {}) + try { + const eingabe = sammle() + if (eingabe.fehler) { + setzeFehler(form, eingabe.fehler) + form.querySelector('[aria-invalid]')?.focus() + return + } + await ctx.request(`/api/unterkunft/einstellungen?${q}`, { method: 'PUT', body: eingabe.einstellungen }) + melde('Die Einstellungen sind gespeichert.', 'ok') + if (typeof ctx.neuLaden === 'function') { try { await ctx.neuLaden() } catch { /* gespeichert ist gespeichert */ } } + } catch (err) { + zeigeApiFehler(form, err) + } finally { + laeuft = false + speichern.disabled = false + } + }) + + if (nurLesen) for (const el of form.querySelectorAll('input, select, textarea, button')) el.disabled = true + panel.replaceChildren(stripeKarte, form) } diff --git a/public/buchungen-tab-rechnungen.js b/public/buchungen-tab-rechnungen.js index 1969d58..ccdca39 100644 --- a/public/buchungen-tab-rechnungen.js +++ b/public/buchungen-tab-rechnungen.js @@ -1,6 +1,33 @@ -/* Buchungskalender, Reiter „Rechnungen“: Platzhalter (Task 13/14 fuellt ihn). */ -import { infoKarte } from './shop-dom.js' +/* Buchungskalender, Reiter „Rechnungen“: Liste der Rechnungen und Storno-Rechnungen mit PDF-Link. + API: GET /api/unterkunft/rechnungen?projectId= => { rechnungen:[{ id, nummer, datum, art:'rechnung'|'storno', gastName, bruttoCent, anfrageNummer? }] }; + PDF: GET /api/unterkunft/rechnungen/.pdf?projectId= (Einzel-PDF, kein ZIP). Fremdtext nur ueber textContent. render(panel, ctx). */ -export function render(panel) { - panel.replaceChildren(infoKarte('Rechnungen', 'Dieser Bereich wird gerade gebaut.')) +import { abschnitt, datumText, euroText, h, infoKarte } from './shop-dom.js' +import { enc, zeichen } from './buchungen-uk-hilfe.js' + +const ART = { rechnung: 'Rechnung', storno: 'Storno-Rechnung' } +const betragText = (cent) => { + if (!Number.isInteger(cent)) return '' + return cent < 0 ? `−${euroText(-cent)}` : euroText(cent) +} + +export async function render(panel, ctx = {}) { + const q = `projectId=${enc(ctx.projectId)}` + const antwort = await ctx.request(`/api/unterkunft/rechnungen?${q}`) + const liste = Array.isArray(antwort?.rechnungen) ? antwort.rechnungen : [] + if (!liste.length) { + panel.replaceChildren(infoKarte('Rechnungen', 'Noch keine Rechnungen. Sobald ein Gast bucht, erscheint hier die Rechnung.')) + return + } + const zeilen = liste.map((r) => h('li', { class: 'bk-rechnung', 'data-rechnung': zeichen(r.id), 'data-art': zeichen(r.art) }, + h('span', { class: 'bk-nummer' }, zeichen(r.nummer)), + h('span', { class: 'bk-zeitraum' }, datumText(zeichen(r.datum), { zeit: false })), + h('span', { class: 'bk-gast' }, zeichen(r.gastName)), + h('span', { class: `sh-pille ${r.art === 'storno' ? 'sh-pille-bad' : 'sh-pille-ok'}` }, ART[r.art] || 'Rechnung'), + h('span', { class: 'bk-betrag' }, betragText(r.bruttoCent)), + h('a', { + class: 'kb-btn-outline', 'data-aktion': 'pdf', href: `/api/unterkunft/rechnungen/${enc(zeichen(r.id))}.pdf?${q}`, target: '_blank', rel: 'noopener', + 'aria-label': `PDF zu ${zeichen(r.nummer)} herunterladen`, + }, 'PDF'))) + panel.replaceChildren(abschnitt('Rechnungen', 'Rechnungen und Storno-Rechnungen deiner Gäste, ausgestellt in deinem Namen.', h('ul', { class: 'bk-liste' }, zeilen))) } diff --git a/public/buchungen.css b/public/buchungen.css index 6789bec..a366100 100644 --- a/public/buchungen.css +++ b/public/buchungen.css @@ -55,3 +55,11 @@ .bk-vorschau { font-weight: 700; } .bk-import { display: flex; align-items: center; gap: 4px 12px; flex-wrap: wrap; } .bk-anleitung details { margin: 8px 0; } + +/* Rechnungen */ +.bk-rechnung { + display: grid; grid-template-columns: 110px 100px 1fr auto auto auto; gap: 8px 14px; align-items: center; padding: 12px 16px; + border-radius: 12px; border: 1px solid var(--kb-border); background: rgba(255, 255, 255, 0.03); +} +.bk-rechnung .sh-pille { margin: 0; } +@media (max-width: 720px) { .bk-rechnung { grid-template-columns: 1fr auto; } } diff --git a/public/buchungen.js b/public/buchungen.js index ce75ecb..03fe037 100644 --- a/public/buchungen.js +++ b/public/buchungen.js @@ -5,6 +5,7 @@ Helfer: shop-dom.js (h, request, Karten), Styles: buchungen.css (bk-) und shop.css (sh-). Fremdtext nur ueber textContent. */ import { fehlerKarte, h, infoKarte, request } from './shop-dom.js' +import { anfrageKarte, renderAnfrageFormular } from './buchungen-anfrage.js' /** Die Reiter: jeder ein Modul mit render(panel, ctx), erst beim ersten Oeffnen geladen. */ export const REITER = [ @@ -24,11 +25,14 @@ export function vergissAuswahl() { } let S = null // aktuelle Sitzung; eine alte Antwort darf eine neuere nicht ueberschreiben +const textOr = (x) => (typeof x === 'string' && x.trim() ? x : '') const zustandVon = (projekt) => projekt?.features?.unterkunft?.state || 'off' export function initBuchungen(root, { projectId } = {}) { if (!root) return Promise.resolve() - return starte(root, projectId) + const wunsch = projectId || root.dataset?.wunschProjekt || '' // die Kachel in Funktionen legt ihr Projekt hier ab + if (root.dataset) delete root.dataset.wunschProjekt + return starte(root, wunsch) } async function starte(root, projectId) { @@ -109,10 +113,33 @@ function zeichneAnsicht(s, { status, aktuell }) { s.status = status const zustand = status?.state || zustandVon(s.projekt) if (zustand === 'setup' || zustand === 'active' || zustand === 'disabled') zeigeReiter(s, { status, aktuell, zustand }) - else if (zustand === 'off') s.buehne.replaceChildren(infoKarte('Buchungskalender', 'Du hast den Buchungskalender noch nicht angefragt. Die Anfrage kommt in Kürze hier.')) // Task 14: Anfrage-/Bestellkarte + else if (zustand === 'off') zeigeAnfrage(s, { status, aktuell }) else s.buehne.replaceChildren(infoKarte('Buchungskalender', 'Der Stand deines Buchungskalenders ist gerade nicht lesbar. Bitte lade die Seite neu.')) } +/* Zustand off: Anfrageformular, Karte „wird geprueft“ / „abgelehnt“ oder (angenommen) der Hinweis auf die Bestellung. */ +function zeigeAnfrage(s, { status, aktuell }) { + const projectId = s.projekt.projectId + const ansicht = status?.anfrage + const neuLaden = () => zeigeProjekt(s) + if (ansicht?.status === 'angenommen') { + s.buehne.replaceChildren(infoKarte('Deine Anfrage wurde angenommen', 'Wir melden uns bei dir mit den nächsten Schritten zur Bestellung.', { chat: true })) + } else if (ansicht?.status === 'offen') { + s.buehne.replaceChildren(anfrageKarte(ansicht, { projectId, neuLaden })) + } else if (ansicht?.status === 'abgelehnt' && !s.neueAnfrage) { + s.buehne.replaceChildren(anfrageKarte(ansicht, { projectId, neu: () => { s.neueAnfrage = true; zeigeAnfrage(s, { status, aktuell }) } })) + } else { + const wurzel = h('div', { class: 'sh-wurzel' }) + s.buehne.replaceChildren(wurzel) + renderAnfrageFormular(wurzel, { + projectId, + vorgabe: ansicht?.anfrage || null, + preis: textOr(s.projekt?.features?.unterkunft?.priceLabel) || '199 €', + nachSenden: async () => { s.neueAnfrage = false; if (aktuell()) await neuLaden() }, + }) + } +} + function zeigeReiter(s, { status, aktuell, zustand }) { const neuLaden = async () => { const neu = await ladeStatus(s.projekt.projectId) diff --git a/public/funktionen.js b/public/funktionen.js index d1cf11f..6b8efd6 100644 --- a/public/funktionen.js +++ b/public/funktionen.js @@ -260,6 +260,51 @@ function shopTile(projects, features) { ` } +/* Buchungskalender (Schluessel unterkunft): Kachel nur, wenn der Server die Funktion freigibt (unterkunft.verfuegbar der Antwort, Schalter + UNTERKUNFT_ENABLED); sonst gar keine Kachel. Die Ansicht #buchungen (buchungen.js) zeigt Anfrage und Stand; die Kachel kennt nur den Zustand + des Funktionsdokuments (projects[].features.unterkunft: off | ordered | setup | active | disabled), Preis aus features.unterkunft.priceLabel, + Rueckfall 199 €. */ +const UNTERKUNFT_PREIS = '199 €' +const UNTERKUNFT_TEXT = 'Belegungskalender auf deiner Website, Anfragen freigeben, Rechnung und Online-Zahlung' +const unterkunftOf = (project) => project.features?.unterkunft || { state: 'off' } + +function pickUnterkunftProject(projects) { + return projects.find((p) => unterkunftOf(p).state === 'active') + || projects.find((p) => isSetup(unterkunftOf(p).state)) + || projects.find((p) => unterkunftOf(p).state === 'disabled') + || projects[0] +} + +function unterkunftTile(projects, features) { + const preis = textOr(features?.unterkunft?.priceLabel, UNTERKUNFT_PREIS) + const active = projects.filter((p) => unterkunftOf(p).state === 'active').length + const target = pickUnterkunftProject(projects) + const state = target ? unterkunftOf(target).state : 'off' + const knopf = (text) => `` + const ZUSTAENDE = { + off: [['Aus', 'off'], 'Buchungskalender anfragen'], + ordered: [['Zahlung offen', 'setup'], 'Zahlung offen'], + setup: [['In Einrichtung', 'setup'], 'In Einrichtung'], + active: [['Aktiv', 'on'], 'Buchungskalender öffnen'], + disabled: [['Abgeschaltet', 'off'], 'Buchungskalender ansehen'], + } + const [pill, knopfText] = ZUSTAENDE[state] || ZUSTAENDE.off + const action = target ? knopf(knopfText) : '' + let extra = '' + if (!target) extra = '

Sobald deine Website angelegt ist, kannst du den Buchungskalender anfragen.

' + else if (state === 'disabled') extra = '

Der Buchungskalender ist für diese Website abgeschaltet. Deine Buchungen kannst du noch ansehen.

' + const count = projects.length > 1 && active ? `

aktiv für ${active} von ${projects.length} Websites

` : '' + return ` +
+
${svg('calendar')}${pill[0]}
+

Buchungskalender

+

${UNTERKUNFT_TEXT}. ${esc(preis)} einmalig.

+ ${extra} + ${count} + ${action} +
` +} + /* Oeffnungszeiten live: Kachel nur, wenn der Schalter es erlaubt (zeiten.verfuegbar); sonst gibt es sie gar nicht (auch kein "Kommt bald"). Wie bei den anderen Funktionen steht sie inline, die Ansicht #zeiten (zeiten.js) laedt app.js. Alles, was sie zeigt, kommt mit der Liste (GET /api/funktionen): zeiten (Angebot) mit inklusive (true: im laufenden, aktiven Wartungs-Abo enthalten, einschaltbar | false: sonst, dann @@ -386,12 +431,13 @@ const soonTile = (icon, title, text) => ` export async function initFunktionen(root) { if (!root) return - const { projects = [], termine, features, shop, zeiten } = await request('/api/funktionen') + const { projects = [], termine, features, shop, zeiten, unterkunft } = await request('/api/funktionen') root.innerHTML = `
${newsTile(projects, newsOffer(features).price)} ${termine?.verfuegbar ? termineTile(projects, termine) : soonTile('calendar', 'Terminplaner', TERMINE_TEXT)} ${shop?.verfuegbar ? shopTile(projects, features) : ''} + ${unterkunft?.verfuegbar === true ? unterkunftTile(projects, features) : ''} ${zeiten?.verfuegbar === true ? zeitenTile(projects, zeiten) : ''} ${analyseTile(projects)}
` @@ -407,6 +453,11 @@ export async function initFunktionen(root) { const body = document.getElementById('webshop-body') if (body) body.dataset.wunschProjekt = ev.currentTarget.dataset.project }) + // Die Buchungskalender-Ansicht liest das gewaehlte Projekt aus ihrem Behaelter (buchungen.js, initBuchungen) + root.querySelector('[data-fn-tile="unterkunft"] .fn-tile-cta[data-project]')?.addEventListener('click', (ev) => { + const body = document.getElementById('buchungen-body') + if (body) body.dataset.wunschProjekt = ev.currentTarget.dataset.project + }) // Die Zeiten-Ansicht liest Projekt und Weg (Behalten) aus ihrem Behaelter (zeiten.js, initZeiten); der Klick steigt danach zum Navigationshandler auf root.querySelector('[data-fn-tile="zeiten"]')?.addEventListener('click', (ev) => { const knopf = ev.target?.closest?.('button[data-view="zeiten"]') diff --git a/test/buchungenUi.test.js b/test/buchungenUi.test.js index 596c91c..3506b91 100644 --- a/test/buchungenUi.test.js +++ b/test/buchungenUi.test.js @@ -467,3 +467,244 @@ test('Kalender: vergangene Tage lassen sich nicht sperren', mit(ukServer(), asyn assert.match(fx.text(fx.finde(panel, '[data-hinweis]')), /Vergangene Tage/) assert.equal(fx.finde(panel, '[data-aktion="sperren-senden"]'), null) })) + +/* ===================================================== Task 14: Rechnungen, Einstellungen, Anfrage ===================================================== */ +const { render: renderRechnungen } = await import('../public/buchungen-tab-rechnungen.js') +const { render: renderEinstellungen, STORNO_MUSTER, STORNO_HINWEIS, AUFTRAG_TEXT } = await import('../public/buchungen-tab-einstellungen.js') +const { renderAnfrageFormular, anfrageKarte } = await import('../public/buchungen-anfrage.js') +const { initBuchungen, vergissAuswahl } = await import('../public/buchungen.js') + +const zeigeMit = async (fx, modul, ctxExtra = {}) => { + const ctx = { ...baueCtx(), ...ctxExtra } + const panel = h('div') + fx.document.body.append(panel) + await modul(panel, ctx) + await fx.ruhe() + return { panel, ctx } +} +const ES = { firma: 'Haus am See', inhaber: 'Mimi M.', strasse: 'Seeweg 1', plz: '78532', ort: 'Tuttlingen', email: 'mimi@example.com', telefon: '', steuernummer: '12/345/67890', ustId: '', iban: '', bic: '', bank: '', rechnungsPraefix: 'RE', akzentfarbe: '#336699', onlineZahlung: false, rechnungAuftrag: true, stornobedingungen: '' } +const esServer = (o = {}, antwort = {}) => server({ 'GET /api/unterkunft/einstellungen': () => ({ body: { einstellungen: ES, stripe: { status: 'keins', vorhandenesShopKonto: false }, rechnungenVorhanden: false, ...antwort } }), 'PUT /api/unterkunft/einstellungen': () => ({ body: {} }), ...o }) + +test('Rechnungen: Liste mit PDF-Link, Art und Betrag; Fremdtext nur als Text', mit(server({ + 'GET /api/unterkunft/rechnungen': () => ({ body: { rechnungen: [ + { id: 'r1', nummer: 'RE-2026-0001', datum: '2026-10-07', art: 'rechnung', gastName: 'Anna M', bruttoCent: 40650 }, + { id: 'r2', nummer: 'RE-2026-0002', datum: '2026-10-08', art: 'storno', gastName: 'Anna', bruttoCent: -40650 }, + ] } }), +}), async (fx) => { + const { panel } = await zeigeMit(fx, renderRechnungen) + assert.equal(fx.fetchLog[0].url, '/api/unterkunft/rechnungen?projectId=p1') + const z = fx.alle(panel, '[data-rechnung]') + assert.equal(z.length, 2) + assert.match(fx.text(z[0]), /RE-2026-0001/) + assert.match(fx.text(z[0]), /07\.10\.2026/) + assert.match(fx.text(z[0]), /Anna M<\/b>/) + assert.match(fx.text(z[0]), /406,50\s€/) + assert.match(fx.text(z[1]), /Storno-Rechnung/) + assert.match(fx.text(z[1]), /−406,50\s€/) + assert.equal(fx.finde(z[0], '[data-aktion="pdf"]').getAttribute('href'), '/api/unterkunft/rechnungen/r1.pdf?projectId=p1') + assert.equal(fx.finde(z[0], 'b'), null) +})) + +test('Rechnungen: leer → Hinweis', mit(server({ 'GET /api/unterkunft/rechnungen': () => ({ body: { rechnungen: [] } }) }), async (fx) => { + const { panel } = await zeigeMit(fx, renderRechnungen) + assert.match(fx.text(panel), /Noch keine Rechnungen/) + assert.equal(fx.alle(panel, '[data-rechnung]').length, 0) +})) + +test('Einstellungen: Pflichtfelder markiert, leeres Formular sendet nicht und zeigt Feldfehler', mit(esServer({}, { einstellungen: { ...ES, firma: '', steuernummer: '', ustId: '' } }), async (fx) => { + const { panel } = await zeigeMit(fx, renderEinstellungen) + for (const n of ['firma', 'inhaber', 'strasse', 'plz', 'ort', 'email']) assert.match(fx.text(fx.finde(panel, `[data-feld="${n}"]`).parentNode.querySelector('label')), /\*/, n) + await fx.klick(fx.finde(panel, '[data-aktion="einstellungen-speichern"]')) + await fx.ruhe() + assert.equal(posts(fx).length, 0) + assert.match(fx.text(fx.finde(panel, '[data-fehler="firma"]')), /Firma/) + assert.match(fx.text(fx.finde(panel, '[data-fehler="steuernummer"]')), /Steuernummer oder USt-IdNr/) + assert.match(fx.text(fx.finde(panel, '[data-fehler="ustId"]')), /Steuernummer oder USt-IdNr/) +})) + +test('Einstellungen: speichern sendet die Felder ohne Wrapper; Serverfehler am Feld', mit(esServer({ 'PUT /api/unterkunft/einstellungen': () => ({ status: 400, body: { fehler: 'ungueltig', felder: { iban: 'Bitte gib eine gültige IBAN an.' } } }) }), async (fx) => { + const { panel } = await zeigeMit(fx, renderEinstellungen) + await fx.setze(fx.finde(panel, '[data-feld="iban"]'), 'xx') + await fx.klick(fx.finde(panel, '[data-aktion="einstellungen-speichern"]')) + await fx.ruhe() + const put = posts(fx)[0] + assert.equal(put.url, '/api/unterkunft/einstellungen?projectId=p1') + assert.equal(put.body.firma, 'Haus am See') + assert.equal(put.body.rechnungsPraefix, 'RE') + assert.equal(put.body.rechnungAuftrag, true) + assert.equal(put.body.iban, 'xx') + assert.equal(put.body.onlineZahlung, false) + assert.match(fx.text(fx.finde(panel, '[data-fehler="iban"]')), /gültige IBAN/) +})) + +test('Einstellungen: Präfix prüfen; gesperrt mit Hinweis, sobald Rechnungen existieren', async () => { + await mit(esServer(), async (fx) => { + const { panel } = await zeigeMit(fx, renderEinstellungen) + await fx.setze(fx.finde(panel, '[data-feld="rechnungsPraefix"]'), 'a') + await fx.klick(fx.finde(panel, '[data-aktion="einstellungen-speichern"]')) + await fx.ruhe() + assert.equal(posts(fx).length, 0) + assert.match(fx.text(fx.finde(panel, '[data-fehler="rechnungsPraefix"]')), /2 bis 8/) + await fx.setze(fx.finde(panel, '[data-feld="rechnungsPraefix"]'), 'ab1') + await fx.klick(fx.finde(panel, '[data-aktion="einstellungen-speichern"]')) + await fx.ruhe() + assert.equal(posts(fx)[0].body.rechnungsPraefix, 'AB1') + })() + await mit(esServer({}, { rechnungenVorhanden: true }), async (fx) => { + const { panel } = await zeigeMit(fx, renderEinstellungen) + const p = fx.finde(panel, '[data-feld="rechnungsPraefix"]') + assert.ok(p.disabled) + assert.match(fx.text(p.parentNode), /gesperrt/) + await fx.klick(fx.finde(panel, '[data-aktion="einstellungen-speichern"]')) + await fx.ruhe() + assert.equal(posts(fx)[0].body.rechnungsPraefix, 'RE') + })() +}) + +test('Einstellungen: Mustertext übernehmen füllt die Stornobedingungen, Hinweis „keine Rechtsberatung“', mit(esServer(), async (fx) => { + const { panel } = await zeigeMit(fx, renderEinstellungen) + const t = fx.finde(panel, '[data-feld="stornobedingungen"]') + assert.equal(t.value, '') + assert.match(fx.text(t.parentNode), new RegExp(STORNO_HINWEIS.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))) + await fx.klick(fx.finde(panel, '[data-aktion="storno-muster"]')) + assert.equal(t.value, STORNO_MUSTER) + assert.match(STORNO_MUSTER, /^Stornierung bis 14 Tage vor Anreise: kostenlos\./) + assert.match(STORNO_MUSTER, /Maßgeblich ist der Eingang bei uns\.$/) + await fx.klick(fx.finde(panel, '[data-aktion="einstellungen-speichern"]')) + await fx.ruhe() + assert.equal(posts(fx)[0].body.stornobedingungen, STORNO_MUSTER) +})) + +test('Einstellungen: Auftrag zur Rechnungsstellung – ohne Haken Hinweis, mit Haken weg und rechnungAuftrag im PUT', mit(esServer({}, { einstellungen: { ...ES, rechnungAuftrag: false } }), async (fx) => { + const { panel } = await zeigeMit(fx, renderEinstellungen) + const box = fx.finde(panel, '[data-feld="rechnungAuftrag"]') + assert.match(fx.text(box.parentNode), new RegExp(AUFTRAG_TEXT.slice(0, 40))) + const hinweis = fx.finde(panel, '[data-hinweis="auftrag"]') + assert.equal(hinweis.hidden, false) + assert.match(fx.text(hinweis), /nicht bestätigen/) + await fx.klick(box) + assert.equal(hinweis.hidden, true) + await fx.klick(fx.finde(panel, '[data-aktion="einstellungen-speichern"]')) + await fx.ruhe() + assert.equal(posts(fx)[0].body.rechnungAuftrag, true) +})) + +test('Einstellungen: Stripe nicht verbunden – Verbinden leitet nur auf https; „Vorhandenes Webshop-Konto“ nur wenn gemeldet', async () => { + await mit(esServer({ 'POST /api/unterkunft/stripe/verbinden': () => ({ body: { url: 'https://connect.stripe.com/x' } }) }), async (fx) => { + const { panel } = await zeigeMit(fx, renderEinstellungen) + assert.equal(fx.finde(panel, '[data-stripe-status]').getAttribute('data-stripe-status'), 'keins') + assert.equal(fx.finde(panel, '[data-aktion="stripe-vorhanden"]'), null) + assert.equal(fx.finde(panel, '[data-feld="onlineZahlung"]'), null) + await fx.klick(fx.finde(panel, '[data-aktion="stripe-verbinden"]')) + await fx.ruhe() + assert.equal(posts(fx)[0].url, '/api/unterkunft/stripe/verbinden?projectId=p1') + assert.equal(fx.window.location.href, 'https://connect.stripe.com/x') + })() + await mit(esServer({ 'POST /api/unterkunft/stripe/verbinden': () => ({ body: { url: 'javascript:alert(1)' } }) }), async (fx) => { + const { panel, ctx } = await zeigeMit(fx, renderEinstellungen) + await fx.klick(fx.finde(panel, '[data-aktion="stripe-verbinden"]')) + await fx.ruhe() + assert.notEqual(fx.window.location.href, 'javascript:alert(1)') + assert.equal(ctx.meldungen.at(-1).art, 'fehler') + })() + await mit(esServer({ 'POST /api/unterkunft/stripe/vorhandenes-konto': () => ({ body: { stripe: { status: 'aktiv' } } }) }, { stripe: { status: 'keins', vorhandenesShopKonto: true } }), async (fx) => { + const { panel } = await zeigeMit(fx, renderEinstellungen) + await fx.klick(fx.finde(panel, '[data-aktion="stripe-vorhanden"]')) + await fx.ruhe() + assert.equal(posts(fx)[0].url, '/api/unterkunft/stripe/vorhandenes-konto?projectId=p1') + assert.ok(fx.fetchLog.filter((a) => a.method === 'GET' && a.url.startsWith('/api/unterkunft/einstellungen')).length >= 2) // neu geladen + })() +}) + +test('Einstellungen: Stripe Onboarding offen und aktiv; Schalter Online-Zahlung nur bei aktiv', async () => { + await mit(esServer({ 'POST /api/unterkunft/stripe/aktualisieren': () => ({ body: { stripe: { status: 'aktiv' } } }) }, { stripe: { status: 'onboarding', vorhandenesShopKonto: true } }), async (fx) => { + const { panel } = await zeigeMit(fx, renderEinstellungen) + assert.equal(fx.finde(panel, '[data-stripe-status]').getAttribute('data-stripe-status'), 'onboarding') + assert.match(fx.text(panel), /Angaben bei Stripe/) + assert.ok(fx.finde(panel, '[data-aktion="stripe-aktualisieren"]')) + assert.equal(fx.finde(panel, '[data-aktion="stripe-vorhanden"]'), null) + assert.equal(fx.finde(panel, '[data-feld="onlineZahlung"]'), null) + })() + await mit(esServer({}, { einstellungen: { ...ES, onlineZahlung: true }, stripe: { status: 'aktiv', vorhandenesShopKonto: false } }), async (fx) => { + const { panel } = await zeigeMit(fx, renderEinstellungen) + assert.equal(fx.finde(panel, '[data-stripe-status]').getAttribute('data-stripe-status'), 'aktiv') + const schalter = fx.finde(panel, '[data-feld="onlineZahlung"]') + assert.ok(schalter.checked) + await fx.klick(schalter) + await fx.klick(fx.finde(panel, '[data-aktion="einstellungen-speichern"]')) + await fx.ruhe() + assert.equal(posts(fx)[0].body.onlineZahlung, false) + })() +}) + +test('Einstellungen: nur lesen sperrt Formular und Stripe-Knöpfe', mit(esServer(), async (fx) => { + const { panel } = await zeigeMit(fx, renderEinstellungen, { nurLesen: true }) + assert.equal(fx.finde(panel, '[data-aktion="stripe-verbinden"]'), null) + assert.ok(fx.finde(panel, '[data-feld="firma"]').disabled) + assert.equal(fx.finde(panel, '[data-aktion="einstellungen-speichern"]'), null) +})) + +test('Anfrageformular sendet { art, einheiten, portale, nachricht }; Feldfehler am Feld', async () => { + let antwort = { body: { anfrage: { status: 'offen' } } } + await mit((a) => (a.method === 'POST' ? antwort : { body: {} }), async (fx) => { + const wurzel = h('div') + fx.document.body.append(wurzel) + let danach = 0 + renderAnfrageFormular(wurzel, { projectId: 'p1', nachSenden: async () => { danach += 1 } }) + antwort = { status: 400, body: { fehler: 'ungueltig', felder: { art: 'Bitte wähle die Art deiner Unterkunft.' } } } + await fx.klick(fx.finde(wurzel, '[data-aktion="anfrage-senden"]')) + await fx.ruhe() + assert.match(fx.text(fx.finde(wurzel, '[data-fehler="art"]')), /Art deiner Unterkunft/) + assert.equal(danach, 0) + antwort = { body: { anfrage: { status: 'offen' } } } + await fx.setze(fx.finde(wurzel, '[data-feld="art"]'), 'ferienwohnung') + await fx.setze(fx.finde(wurzel, '[data-feld="einheiten"]'), '3') + await fx.klick(fx.finde(wurzel, '[data-feld="portale.airbnb"]')) + await fx.setze(fx.finde(wurzel, '[data-feld="nachricht"]'), ' Hallo ') + await fx.klick(fx.finde(wurzel, '[data-aktion="anfrage-senden"]')) + await fx.ruhe() + const p = posts(fx).at(-1) + assert.equal(p.url, '/api/unterkunft/anfrage-funktion?projectId=p1') + assert.deepEqual(p.body, { art: 'ferienwohnung', einheiten: 3, portale: ['airbnb'], nachricht: 'Hallo' }) + assert.equal(danach, 1) + assert.match(fx.text(wurzel), /199 €/) + })() +}) + +test('Anfragekarte: offen mit Zurückziehen, abgelehnt mit Begründung als Text', async () => { + await mit(server(), async (fx) => { + let neuGeladen = 0 + const k = anfrageKarte({ status: 'offen', at: '2026-10-07T10:00:00Z', anfrage: { art: 'monteur', einheiten: 2, portale: ['booking'], nachricht: 'x' } }, { projectId: 'p1', neuLaden: async () => { neuGeladen += 1 } }) + fx.document.body.append(k) + assert.match(fx.text(k), /In Prüfung/) + assert.match(fx.text(k), /Monteurwohnung/) + assert.match(fx.text(k), /Booking\.com/) + await fx.klick(fx.finde(k, '[data-aktion="zurueckziehen"]')) + await fx.ruhe() + assert.equal(posts(fx)[0].url, '/api/unterkunft/anfrage-funktion/zurueckziehen?projectId=p1') + assert.equal(neuGeladen, 1) + const b = anfrageKarte({ status: 'abgelehnt', begruendung: 'Passt nicht' }, { projectId: 'p1', neu: () => {} }) + assert.match(fx.text(b), /Passt nicht<\/i>/) + assert.equal(fx.finde(b, 'i'), null) + assert.ok(fx.finde(b, '[data-aktion="neue-anfrage"]')) + })() +}) + +test('Ansicht #buchungen: Zustand off zeigt das Anfrageformular, Projekt vom Wunsch-Attribut', async () => { + vergissAuswahl() + await mit((a) => { + if (a.url === '/api/funktionen') return { body: { unterkunft: { verfuegbar: true }, projects: [{ projectId: 'p1', projectName: 'A', features: { unterkunft: { state: 'off' } } }, { projectId: 'p2', projectName: 'B', features: { unterkunft: { state: 'off' } } }] } } + if (a.url.startsWith('/api/unterkunft/status')) return { body: { state: 'off' } } + return { body: {} } + }, async (fx) => { + const root = h('div') + root.dataset.wunschProjekt = 'p2' + fx.document.body.append(root) + await initBuchungen(root) + await fx.ruhe() + assert.equal(fx.fetchLog[1].url, '/api/unterkunft/status?projectId=p2') + assert.ok(fx.finde(root, '[data-aktion="anfrage-senden"]')) + assert.match(fx.text(root), /Buchungskalender anfragen/) + })() + vergissAuswahl() +}) diff --git a/test/funktionenTiles.test.js b/test/funktionenTiles.test.js index 66136da..d678363 100644 --- a/test/funktionenTiles.test.js +++ b/test/funktionenTiles.test.js @@ -564,3 +564,20 @@ test('Webshop-Kachel: Projekt-ID maskiert, Klick legt das Wunschprojekt in #webs } assert.equal(body.dataset.wunschProjekt, 'p7') }) + +// ---- Buchungskalender-Kachel: nur bei unterkunft.verfuegbar, Preis 199 €, Zustände wie die Webshop-Kachel +test('Buchungskalender-Kachel: nur bei unterkunft.verfuegbar; Text, Preis und Zustände', async () => { + const mitUk = (state) => project('a', undefined) && { ...project('a'), features: { news, unterkunft: { state } } } + const ohne = await renderMit([mitUk('off')], {}) + assert.doesNotMatch(ohne, /data-fn-tile="unterkunft"/) + const zu = await renderMit([mitUk('off')], { unterkunft: { verfuegbar: false } }) + assert.doesNotMatch(zu, /data-fn-tile="unterkunft"/) + const off = tile(await renderMit([mitUk('off')], { unterkunft: { verfuegbar: true } }), 'unterkunft') + assert.match(off, /

Buchungskalender<\/h3>/) + assert.match(off, /Belegungskalender auf deiner Website, Anfragen freigeben, Rechnung und Online-Zahlung/) + assert.match(off, /199 €/) + assert.match(off, /Buchungskalender anfragen/) + assert.match(off, /data-view="buchungen" data-project="a"/) + const faelle = { active: /Aktiv[\s\S]*Buchungskalender öffnen/, setup: /In Einrichtung/, ordered: /Zahlung offen/, disabled: /Abgeschaltet[\s\S]*Buchungskalender ansehen/ } + for (const [state, re] of Object.entries(faelle)) assert.match(tile(await renderMit([mitUk(state)], { unterkunft: { verfuegbar: true } }), 'unterkunft'), re, state) +}) From 05006f1480ee2f74139838dc66f391548df479ac Mon Sep 17 00:00:00 2001 From: Kenso Grimm Date: Wed, 7 Oct 2026 14:26:22 +0000 Subject: [PATCH 7/7] Buchungskalender: Stornobedingungen je Unterkunft, Zustimmung und Bestellstatus Co-Authored-By: Claude Opus 5.5 --- public/buchungen-anfrage.js | 2 +- public/buchungen-preise.js | 5 ++ public/buchungen-tab-einstellungen.js | 43 ++++++++------ public/buchungen.js | 16 ++++- test/buchungenUi.test.js | 86 ++++++++++++++++++++++++--- 5 files changed, 123 insertions(+), 29 deletions(-) diff --git a/public/buchungen-anfrage.js b/public/buchungen-anfrage.js index 29f604e..73f3b47 100644 --- a/public/buchungen-anfrage.js +++ b/public/buchungen-anfrage.js @@ -77,7 +77,7 @@ export function renderAnfrageFormular(root, { projectId, vorgabe = null, preis = } catch (err) { zeigeFehler(form, fehlerFuerFormular(err)) if (err?.status === 409 && typeof nachSenden === 'function') { - try { await nachSenden(null) } catch { /* der Fehler steht schon am Formular */ } + try { await nachSenden(null, { meldung: fehlerFuerFormular(err).message }) } catch { /* der Fehler steht schon am Formular */ } return } } diff --git a/public/buchungen-preise.js b/public/buchungen-preise.js index 8085f63..8fe6021 100644 --- a/public/buchungen-preise.js +++ b/public/buchungen-preise.js @@ -6,6 +6,8 @@ import { abschnitt, allgemeinerFehler, euroEingabe, euroText, feld, h, setzeFehler, zeigeFehler } from './shop-dom.js' import { enc, heuteBerlin, plusTage, zeichen, zeigeApiFehler } from './buchungen-uk-hilfe.js' +export const STORNO_MUSTER = 'Stornierung bis 14 Tage vor Anreise: kostenlos. Stornierung 13 bis 3 Tage vor Anreise: 50 % des Gesamtpreises. Stornierung ab 2 Tage vor Anreise oder Nichtanreise: 90 % des Gesamtpreises. Die Stornierung bedarf der Textform (z. B. E-Mail). Maßgeblich ist der Eingang bei uns.' +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 ganzzahl = (steuer) => { const t = String(steuer.value).trim(); return /^\d{1,9}$/.test(t) ? Number(t) : null } @@ -81,6 +83,9 @@ export async function zeichnePreise(ctx, unterkunft) { 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.'), + ctx.nurLesen ? null : h('div', { class: 'bk-aktionen' }, + h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'storno-muster', onclick: () => { storno.value = STORNO_MUSTER } }, 'Mustertext übernehmen'), + 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'))) if (ctx.nurLesen) for (const el of form.querySelectorAll('input, select, textarea, button')) el.disabled = true diff --git a/public/buchungen-tab-einstellungen.js b/public/buchungen-tab-einstellungen.js index 4e2d5b9..545db5d 100644 --- a/public/buchungen-tab-einstellungen.js +++ b/public/buchungen-tab-einstellungen.js @@ -1,18 +1,17 @@ /* Buchungskalender, Reiter „Einstellungen“: Firmendaten, Steuernummer/USt-IdNr., Rechnungspraefix, Auftrag zur Rechnungsstellung, - Bankverbindung, Akzentfarbe, Stornobedingungen (Mustertext), Stripe-Konto und Online-Zahlung. + Bankverbindung, Akzentfarbe, Stripe-Konto und Online-Zahlung. API (docs/funktionen/unterkunft-api.md, ?projectId=): GET /einstellungen => { einstellungen:{ firma, inhaber, strasse, plz, ort, email, telefon, - steuernummer, ustId, iban, bic, bank, rechnungsPraefix, akzentfarbe, onlineZahlung, rechnungAuftrag, stornobedingungen }, + steuernummer, ustId, iban, bic, bank, rechnungsPraefix, akzentfarbe, onlineZahlung, rechnungAuftrag }, stripe:{ status:'keins'|'onboarding'|'aktiv', vorhandenesShopKonto:boolean }, rechnungenVorhanden:boolean }; - PUT /einstellungen mit denselben Feldern (ohne Wrapper; rechnungAuftrag:true setzt rechnungAuftragAt auf dem Server); + PUT /einstellungen mit denselben Feldern (ohne Wrapper; rechnungAuftrag:true setzt rechnungAuftragAt, false widerruft); POST /stripe/verbinden => { url } (Onboarding bei Stripe), POST /stripe/vorhandenes-konto und /stripe/aktualisieren => { stripe }. Geprueft wird auf dem Server; Feldfehler stehen am Feld. Text nur ueber textContent (h). render(panel, ctx) wie shop-tab-*.js. */ -import { abschnitt, allgemeinerFehler, feld, h, hakenFeld, setzeFehler } from './shop-dom.js' +import { abschnitt, allgemeinerFehler, feld, frage, h, hakenFeld, setzeFehler } from './shop-dom.js' import { enc, zeichen, zeigeApiFehler } from './buchungen-uk-hilfe.js' -export const STORNO_MUSTER = 'Stornierung bis 14 Tage vor Anreise: kostenlos. Stornierung 13 bis 3 Tage vor Anreise: 50 % des Gesamtpreises. Stornierung ab 2 Tage vor Anreise oder Nichtanreise: 90 % des Gesamtpreises. Die Stornierung bedarf der Textform (z. B. E-Mail). Maßgeblich ist der Eingang bei uns.' -export const STORNO_HINWEIS = 'Vorlage – bitte an deine Bedingungen anpassen, keine Rechtsberatung.' export const AUFTRAG_TEXT = 'Ich beauftrage WEBklar, in meinem Namen und auf meine Rechnung Rechnungen an meine Gäste auszustellen (§ 14 Abs. 2 Satz 4 UStG).' +export const AUFTRAG_WIDERRUF = 'Ohne diese Zustimmung können keine Buchungen bestätigt werden. Zustimmung wirklich zurückziehen?' export const AUFTRAG_FEHLT = 'Ohne diesen Auftrag kannst du Buchungen nicht bestätigen, weil wir keine Rechnungen für dich ausstellen dürfen.' const STRIPE_FEHLER = 'Stripe hat keine gültige Adresse geliefert. Bitte versuch es gleich noch einmal.' const PFLICHT = { firma: 'Bitte gib den Namen deiner Firma oder Unterkunft an.', inhaber: 'Bitte gib den Namen des Inhabers an.', strasse: 'Bitte gib Straße und Hausnummer an.', plz: 'Bitte gib die Postleitzahl an.', ort: 'Bitte gib den Ort an.', email: 'Bitte gib eine E-Mail-Adresse an.' } @@ -44,23 +43,31 @@ export async function render(panel, ctx = {}) { const praefix = textfeld({ maxlength: 8, placeholder: 'RE' }); praefix.value = zeichen(e.rechnungsPraefix) || 'RE' if (rechnungenVorhanden) { praefix.disabled = true; praefix.setAttribute('aria-disabled', 'true') } const auftrag = h('input', { type: 'checkbox' }); auftrag.checked = e.rechnungAuftrag === true - const iban = textfeld({ maxlength: 40 }); iban.value = zeichen(e.iban) + const iban = textfeld({ maxlength: 34 }); iban.value = zeichen(e.iban) const bic = textfeld({ maxlength: 11 }); bic.value = zeichen(e.bic) const bank = textfeld({ maxlength: 80 }); bank.value = zeichen(e.bank) const akzent = h('input', { type: 'color' }); akzent.value = /^#[0-9a-fA-F]{6}$/.test(zeichen(e.akzentfarbe)) ? e.akzentfarbe : '#111111' - const storno = h('textarea', { rows: 6, maxlength: 5000 }); storno.value = zeichen(e.stornobedingungen) const online = h('input', { type: 'checkbox' }); online.checked = e.onlineZahlung === true && stripe.status === 'aktiv' const auftragHinweis = h('div', { class: 'sh-box sh-box-warn', role: 'note', 'data-hinweis': 'auftrag' }, AUFTRAG_FEHLT) const zeigeAuftrag = () => { auftragHinweis.hidden = auftrag.checked } - auftrag.addEventListener('change', zeigeAuftrag) + // Widerruf einer gespeicherten Zustimmung nur nach Rueckfrage in der Seite + const widerrufPlatz = h('div', { 'data-widerruf-platz': '' }) + auftrag.addEventListener('change', () => { + if (!auftrag.checked && e.rechnungAuftrag === true && !widerrufPlatz.firstChild) { + auftrag.checked = true + const zu = () => { widerrufPlatz.replaceChildren(); auftrag.focus() } + const box = frage('Zustimmung zurückziehen', h('p', null, AUFTRAG_WIDERRUF), + h('div', { class: 'sh-aktionen' }, + h('button', { type: 'button', class: 'kb-btn-primary', 'data-aktion': 'widerruf-ja', onclick: () => { auftrag.checked = false; zeigeAuftrag(); zu() } }, 'Ja, zurückziehen'), + h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'widerruf-nein', onclick: zu }, 'Abbrechen'))) + widerrufPlatz.replaceChildren(box) + box.focus() + } + zeigeAuftrag() + }) zeigeAuftrag() - const musterKnopf = h('button', { - type: 'button', class: 'kb-btn-outline', 'data-aktion': 'storno-muster', - onclick: () => { storno.value = STORNO_MUSTER }, - }, 'Mustertext übernehmen') - /* ------------------------------------------------------------------ Stripe --- */ let beschaeftigt = false async function stripeAktion(pfad, { weiter = false } = {}) { @@ -116,15 +123,13 @@ export async function render(panel, ctx = {}) { ? 'Der Präfix ist gesperrt, weil es schon Rechnungen gibt. Die Nummern bleiben so lückenlos.' : '2 bis 8 Zeichen (A–Z, 0–9). Die Nummern lauten dann zum Beispiel RE-2026-0001. Du kannst den Präfix nur vor der ersten Rechnung ändern.'), hakenFeld('rechnungAuftrag', auftrag, AUFTRAG_TEXT), - auftragHinweis), + auftragHinweis, + widerrufPlatz), abschnitt('Bankverbindung (freiwillig)', 'Erscheint als Hinweis, wenn Gäste vor Ort oder per Überweisung zahlen.', feld('iban', 'IBAN', iban), feld('bic', 'BIC', bic), feld('bank', 'Name der Bank', bank)), abschnitt('Darstellung', null, feld('akzentfarbe', 'Akzentfarbe', akzent, 'Diese Farbe bekommen Knöpfe auf deiner Buchungsseite.')), - abschnitt('Stornobedingungen', 'Sie stehen auf der Buchungsseite und gelten für neue Freigaben.', - feld('stornobedingungen', 'Stornobedingungen', storno, STORNO_HINWEIS), - h('div', { class: 'sh-aktionen' }, musterKnopf)), status === 'aktiv' ? abschnitt('Online-Zahlung', null, hakenFeld('onlineZahlung', online, 'Online-Zahlung anbieten', 'Gäste können bei der Buchung per Stripe bezahlen.')) : null, @@ -149,7 +154,7 @@ export async function render(panel, ctx = {}) { email: email.value.trim(), telefon: telefon.value.trim(), steuernummer: steuernummer.value.trim(), ustId: ustId.value.trim(), iban: iban.value.trim(), bic: bic.value.trim(), bank: bank.value.trim(), rechnungsPraefix: rechnungenVorhanden ? zeichen(e.rechnungsPraefix) || 'RE' : p, - rechnungAuftrag: auftrag.checked, akzentfarbe: akzent.value, stornobedingungen: storno.value.trim(), + rechnungAuftrag: auftrag.checked, akzentfarbe: akzent.value, onlineZahlung: status === 'aktiv' && online.checked, }, } diff --git a/public/buchungen.js b/public/buchungen.js index 03fe037..5967ba7 100644 --- a/public/buchungen.js +++ b/public/buchungen.js @@ -5,6 +5,7 @@ Helfer: shop-dom.js (h, request, Karten), Styles: buchungen.css (bk-) und shop.css (sh-). Fremdtext nur ueber textContent. */ import { fehlerKarte, h, infoKarte, request } from './shop-dom.js' +import { statusKarte } from './funktion-bestellen.js' import { anfrageKarte, renderAnfrageFormular } from './buchungen-anfrage.js' /** Die Reiter: jeder ein Modul mit render(panel, ctx), erst beim ersten Oeffnen geladen. */ @@ -74,6 +75,7 @@ function projektwahl(s) { select.addEventListener('change', () => { auswahl.projectId = select.value s.projekt = s.projects.find((p) => p.projectId === select.value) + s.neueAnfrage = false zeigeMeldung(s, '') zeigeProjekt(s) }) @@ -113,6 +115,7 @@ function zeichneAnsicht(s, { status, aktuell }) { s.status = status const zustand = status?.state || zustandVon(s.projekt) if (zustand === 'setup' || zustand === 'active' || zustand === 'disabled') zeigeReiter(s, { status, aktuell, zustand }) + else if (zustand === 'ordered') zeigeBestellung(s) else if (zustand === 'off') zeigeAnfrage(s, { status, aktuell }) else s.buehne.replaceChildren(infoKarte('Buchungskalender', 'Der Stand deines Buchungskalenders ist gerade nicht lesbar. Bitte lade die Seite neu.')) } @@ -121,7 +124,8 @@ function zeichneAnsicht(s, { status, aktuell }) { function zeigeAnfrage(s, { status, aktuell }) { const projectId = s.projekt.projectId const ansicht = status?.anfrage - const neuLaden = () => zeigeProjekt(s) + // nach einem 409 bleibt die Meldung des Servers sichtbar, waehrend die Ansicht neu laedt + const neuLaden = async (o) => { await zeigeProjekt(s); if (o?.meldung && S === s) zeigeMeldung(s, o.meldung, 'fehler') } if (ansicht?.status === 'angenommen') { s.buehne.replaceChildren(infoKarte('Deine Anfrage wurde angenommen', 'Wir melden uns bei dir mit den nächsten Schritten zur Bestellung.', { chat: true })) } else if (ansicht?.status === 'offen') { @@ -135,11 +139,19 @@ function zeigeAnfrage(s, { status, aktuell }) { projectId, vorgabe: ansicht?.anfrage || null, preis: textOr(s.projekt?.features?.unterkunft?.priceLabel) || '199 €', - nachSenden: async () => { s.neueAnfrage = false; if (aktuell()) await neuLaden() }, + nachSenden: async (_a, o) => { s.neueAnfrage = false; if (aktuell()) await neuLaden(o) }, }) } } +/* Zustand ordered: Statuskarte „Zahlung offen“ mit „Zahlung fortsetzen“ (generische Bestellseite, Checkout /api/funktionen/unterkunft/checkout). */ +function zeigeBestellung(s) { + const eintrag = { ...s.projekt.features?.unterkunft, state: 'ordered' } + s.buehne.replaceChildren(statusKarte({ projekt: s.projekt, bestellung: eintrag, kundenansicht: false, key: 'unterkunft', name: 'Buchungskalender' }, { + weiter: async ({ meldung }) => { await zeigeProjekt(s); if (S === s) zeigeMeldung(s, meldung, 'fehler') }, + })) +} + function zeigeReiter(s, { status, aktuell, zustand }) { const neuLaden = async () => { const neu = await ladeStatus(s.projekt.projectId) diff --git a/test/buchungenUi.test.js b/test/buchungenUi.test.js index 3506b91..3a77ae8 100644 --- a/test/buchungenUi.test.js +++ b/test/buchungenUi.test.js @@ -470,7 +470,8 @@ test('Kalender: vergangene Tage lassen sich nicht sperren', mit(ukServer(), asyn /* ===================================================== Task 14: Rechnungen, Einstellungen, Anfrage ===================================================== */ const { render: renderRechnungen } = await import('../public/buchungen-tab-rechnungen.js') -const { render: renderEinstellungen, STORNO_MUSTER, STORNO_HINWEIS, AUFTRAG_TEXT } = await import('../public/buchungen-tab-einstellungen.js') +const { STORNO_MUSTER, STORNO_HINWEIS } = await import('../public/buchungen-preise.js') +const { render: renderEinstellungen, AUFTRAG_TEXT, AUFTRAG_WIDERRUF } = await import('../public/buchungen-tab-einstellungen.js') const { renderAnfrageFormular, anfrageKarte } = await import('../public/buchungen-anfrage.js') const { initBuchungen, vergissAuswahl } = await import('../public/buchungen.js') @@ -482,7 +483,7 @@ const zeigeMit = async (fx, modul, ctxExtra = {}) => { await fx.ruhe() return { panel, ctx } } -const ES = { firma: 'Haus am See', inhaber: 'Mimi M.', strasse: 'Seeweg 1', plz: '78532', ort: 'Tuttlingen', email: 'mimi@example.com', telefon: '', steuernummer: '12/345/67890', ustId: '', iban: '', bic: '', bank: '', rechnungsPraefix: 'RE', akzentfarbe: '#336699', onlineZahlung: false, rechnungAuftrag: true, stornobedingungen: '' } +const ES = { firma: 'Haus am See', inhaber: 'Mimi M.', strasse: 'Seeweg 1', plz: '78532', ort: 'Tuttlingen', email: 'mimi@example.com', telefon: '', steuernummer: '12/345/67890', ustId: '', iban: '', bic: '', bank: '', rechnungsPraefix: 'RE', akzentfarbe: '#336699', onlineZahlung: false, rechnungAuftrag: true } const esServer = (o = {}, antwort = {}) => server({ 'GET /api/unterkunft/einstellungen': () => ({ body: { einstellungen: ES, stripe: { status: 'keins', vorhandenesShopKonto: false }, rechnungenVorhanden: false, ...antwort } }), 'PUT /api/unterkunft/einstellungen': () => ({ body: {} }), ...o }) test('Rechnungen: Liste mit PDF-Link, Art und Betrag; Fremdtext nur als Text', mit(server({ @@ -561,18 +562,43 @@ test('Einstellungen: Präfix prüfen; gesperrt mit Hinweis, sobald Rechnungen ex })() }) -test('Einstellungen: Mustertext übernehmen füllt die Stornobedingungen, Hinweis „keine Rechtsberatung“', mit(esServer(), async (fx) => { +test('Einstellungen: keine Stornobedingungen mehr; IBAN maxlength 34', mit(esServer(), async (fx) => { const { panel } = await zeigeMit(fx, renderEinstellungen) - const t = fx.finde(panel, '[data-feld="stornobedingungen"]') - assert.equal(t.value, '') - assert.match(fx.text(t.parentNode), new RegExp(STORNO_HINWEIS.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))) + assert.equal(fx.finde(panel, '[data-feld="stornobedingungen"]'), null) + assert.equal(fx.finde(panel, '[data-feld="iban"]').getAttribute('maxlength'), '34') + await fx.klick(fx.finde(panel, '[data-aktion="einstellungen-speichern"]')) + await fx.ruhe() + assert.equal('stornobedingungen' in posts(fx)[0].body, false) +})) + +test('Preis-Editor: Mustertext übernehmen füllt nur das Feld (kein Speichern), Hinweis „keine Rechtsberatung“', mit(ukServer(), async (fx) => { + const panel = await preisEditor(fx) + const t = fx.finde(panel, '#bk-p-storno') + assert.match(fx.text(fx.finde(panel, '[data-hinweis="storno-muster"]')), /Vorlage – bitte an deine Bedingungen anpassen, keine Rechtsberatung\./) + assert.equal(STORNO_HINWEIS, 'Vorlage – bitte an deine Bedingungen anpassen, keine Rechtsberatung.') + const vorher = posts(fx).length await fx.klick(fx.finde(panel, '[data-aktion="storno-muster"]')) assert.equal(t.value, STORNO_MUSTER) assert.match(STORNO_MUSTER, /^Stornierung bis 14 Tage vor Anreise: kostenlos\./) assert.match(STORNO_MUSTER, /Maßgeblich ist der Eingang bei uns\.$/) + assert.equal(posts(fx).length, vorher) +})) + +test('Einstellungen: Zustimmung zurückziehen fragt nach; Abbrechen behält, Ja sendet rechnungAuftrag:false', mit(esServer(), async (fx) => { + const { panel } = await zeigeMit(fx, renderEinstellungen) + const box = fx.finde(panel, '[data-feld="rechnungAuftrag"]') + await fx.klick(box) + assert.ok(box.checked) + assert.match(fx.text(panel), new RegExp(AUFTRAG_WIDERRUF.slice(0, 50))) + await fx.klick(fx.finde(panel, '[data-aktion="widerruf-nein"]')) + assert.ok(box.checked) + assert.equal(fx.finde(panel, '[data-aktion="widerruf-ja"]'), null) + await fx.klick(box) + await fx.klick(fx.finde(panel, '[data-aktion="widerruf-ja"]')) + assert.equal(box.checked, false) await fx.klick(fx.finde(panel, '[data-aktion="einstellungen-speichern"]')) await fx.ruhe() - assert.equal(posts(fx)[0].body.stornobedingungen, STORNO_MUSTER) + assert.equal(posts(fx)[0].body.rechnungAuftrag, false) })) test('Einstellungen: Auftrag zur Rechnungsstellung – ohne Haken Hinweis, mit Haken weg und rechnungAuftrag im PUT', mit(esServer({}, { einstellungen: { ...ES, rechnungAuftrag: false } }), async (fx) => { @@ -708,3 +734,49 @@ test('Ansicht #buchungen: Zustand off zeigt das Anfrageformular, Projekt vom Wun })() vergissAuswahl() }) + +test('Ansicht #buchungen: Zustand ordered zeigt „Zahlung offen“ mit Zahlung fortsetzen (Checkout unterkunft)', async () => { + vergissAuswahl() + await mit((a) => { + if (a.url === '/api/funktionen') return { body: { unterkunft: { verfuegbar: true }, projects: [{ projectId: 'p1', projectName: 'A', features: { unterkunft: { state: 'ordered' } } }] } } + if (a.url.startsWith('/api/unterkunft/status')) return { body: { state: 'ordered' } } + if (a.url === '/api/funktionen/unterkunft/checkout') return { status: 409, body: { error: 'Bitte stimme zu.', code: 'andere' } } + return { body: {} } + }, async (fx) => { + const root = h('div') + fx.document.body.append(root) + await initBuchungen(root) + await fx.ruhe() + assert.match(fx.text(root), /Zahlung offen/) + await fx.klick(fx.finde(root, '[data-aktion="fortsetzen"]')) + await fx.ruhe() + assert.ok(posts(fx).some((a) => a.url === '/api/funktionen/unterkunft/checkout')) + })() + vergissAuswahl() +}) + +test('Anfrageformular: 409 lädt die Ansicht neu, die Meldung bleibt sichtbar; Projektwechsel setzt „neue Anfrage“ zurück', async () => { + vergissAuswahl() + let status = { state: 'off', anfrage: { status: 'abgelehnt', begruendung: 'Nein' } } + await mit((a) => { + if (a.url === '/api/funktionen') return { body: { unterkunft: { verfuegbar: true }, projects: [{ projectId: 'p1', projectName: 'A', features: { unterkunft: { state: 'off' } } }, { projectId: 'p2', projectName: 'B', features: { unterkunft: { state: 'off' } } }] } } + if (a.url.startsWith('/api/unterkunft/status')) return { body: status } + if (a.method === 'POST') { status = { state: 'off', anfrage: { status: 'offen', anfrage: {} } }; return { status: 409, body: { fehler: 'anfrage_laeuft' } } } + return { body: {} } + }, async (fx) => { + const root = h('div') + fx.document.body.append(root) + await initBuchungen(root) + await fx.ruhe() + await fx.klick(fx.finde(root, '[data-aktion="neue-anfrage"]')) + await fx.klick(fx.finde(root, '[data-aktion="anfrage-senden"]')) + await fx.ruhe() + assert.ok(fx.finde(root, '[data-anfrage="offen"]')) + assert.match(fx.text(fx.finde(root, '.sh-meldung')), /nicht geklappt/) + status = { state: 'off', anfrage: { status: 'abgelehnt', begruendung: 'Nein' } } + await fx.setze(fx.finde(root, '#buchungen-project'), 'p2') + await fx.ruhe() + assert.ok(fx.finde(root, '[data-anfrage="abgelehnt"]')) + })() + vergissAuswahl() +})