diff --git a/public/buchungen-anfrage.js b/public/buchungen-anfrage.js new file mode 100644 index 0000000..73f3b47 --- /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, { meldung: fehlerFuerFormular(err).message }) } 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-fehler.js b/public/buchungen-fehler.js new file mode 100644 index 0000000..8f45297 --- /dev/null +++ b/public/buchungen-fehler.js @@ -0,0 +1,17 @@ +/* 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 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-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-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-preise.js b/public/buchungen-preise.js new file mode 100644 index 0000000..8fe6021 --- /dev/null +++ b/public/buchungen-preise.js @@ -0,0 +1,154 @@ +/* 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' + +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 } + +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.'), + 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 + + async function speichern(ev) { + 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) 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 }) + } + 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 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: 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 (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-anfragen.js b/public/buchungen-tab-anfragen.js new file mode 100644 index 0000000..8d9ad5d --- /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, 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 = { + 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', + 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. 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, fehlerFuerFormular(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, '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( + 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, fehlerFuerFormular(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 === '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) ? 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, + 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, fehlerFuerFormular(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..545db5d --- /dev/null +++ b/public/buchungen-tab-einstellungen.js @@ -0,0 +1,189 @@ +/* Buchungskalender, Reiter „Einstellungen“: Firmendaten, Steuernummer/USt-IdNr., Rechnungspraefix, Auftrag zur Rechnungsstellung, + 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 }, + stripe:{ status:'keins'|'onboarding'|'aktiv', vorhandenesShopKonto:boolean }, rechnungenVorhanden:boolean }; + 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, frage, h, hakenFeld, setzeFehler } from './shop-dom.js' +import { enc, zeichen, zeigeApiFehler } from './buchungen-uk-hilfe.js' + +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.' } + +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: 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 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 } + // 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() + + /* ------------------------------------------------------------------ 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, + 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.')), + 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, + 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-kalender.js b/public/buchungen-tab-kalender.js new file mode 100644 index 0000000..61a997b --- /dev/null +++ b/public/buchungen-tab-kalender.js @@ -0,0 +1,189 @@ +/* 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. */ + +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 = []; zeichne(); 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 (tag < heuteBerlin()) { + hinweis.textContent = 'Vergangene Tage kannst du nicht sperren.' + 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-rechnungen.js b/public/buchungen-tab-rechnungen.js new file mode 100644 index 0000000..ccdca39 --- /dev/null +++ b/public/buchungen-tab-rechnungen.js @@ -0,0 +1,33 @@ +/* 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). */ + +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-tab-unterkuenfte.js b/public/buchungen-tab-unterkuenfte.js new file mode 100644 index 0000000..3bfead4 --- /dev/null +++ b/public/buchungen-tab-unterkuenfte.js @@ -0,0 +1,127 @@ +/* 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. */ + +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..cfa1b95 --- /dev/null +++ b/public/buchungen-uk-hilfe.js @@ -0,0 +1,28 @@ +/* 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. */ +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]}` : '' } + +/** 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 new file mode 100644 index 0000000..a366100 --- /dev/null +++ b/public/buchungen.css @@ -0,0 +1,65 @@ +/* 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; } +} +.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; } + +/* 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 fb992af..5967ba7 100644 --- a/public/buchungen.js +++ b/public/buchungen.js @@ -1,4 +1,212 @@ -// 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' +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. */ +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 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() + 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) { + 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) + s.neueAnfrage = false + 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 === '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.')) +} + +/* 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 + // 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') { + 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 (_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) + 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/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 new file mode 100644 index 0000000..3a77ae8 --- /dev/null +++ b/test/buchungenUi.test.js @@ -0,0 +1,782 @@ +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: 'stripe' }) }), + '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) +})) + +// 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') +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) => { + 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) + 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) +})) + +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="_"]')), '') +})) + +// 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) +})) + +/* ===================================================== Task 14: Rechnungen, Einstellungen, Anfrage ===================================================== */ +const { render: renderRechnungen } = await import('../public/buchungen-tab-rechnungen.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') + +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 } +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: keine Stornobedingungen mehr; IBAN maxlength 34', mit(esServer(), async (fx) => { + const { panel } = await zeigeMit(fx, renderEinstellungen) + 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.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) => { + 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() +}) + +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() +}) 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) +}) diff --git a/test/unterkunftWiring.test.js b/test/unterkunftWiring.test.js index 5e94142..70ec4ec 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', () => {