diff --git a/public/buchungen-posten.js b/public/buchungen-posten.js new file mode 100644 index 0000000..ab37508 --- /dev/null +++ b/public/buchungen-posten.js @@ -0,0 +1,109 @@ +/* Buchungskalender: Posten-Editor fuer die Freigabe einer Anfrage. + summeLive(posten, steuerart) ist ein Spiegel von server/services/unterkunft/preis.js summiere (gleiche Rundung): Anzeige waehrend des Tippens, + verbindlich rechnet der Server. Posten: { text, menge, einzelCent (negativ bei Rabatt), ustProzent }. */ + +import { euroText, euroZuCent, h } from './shop-dom.js' + +export function summeLive(posten, steuerart) { + const bruttoCent = posten.reduce((a, p) => a + p.menge * p.einzelCent, 0) + if (steuerart === 'kleinunternehmer') return { bruttoCent, nettoCent: bruttoCent, ust: [] } + const proSatz = new Map() + for (const p of posten) proSatz.set(p.ustProzent, (proSatz.get(p.ustProzent) || 0) + p.menge * p.einzelCent) + const ust = [...proSatz.entries()] + .sort((a, b) => a[0] - b[0]) + .map(([satz, brutto]) => { + const nettoCent = Math.round((brutto * 100) / (100 + satz)) + return { satz, nettoCent, steuerCent: brutto - nettoCent } + }) + const nettoCent = ust.reduce((a, u) => a + u.nettoCent, 0) + return { bruttoCent, nettoCent, ust } +} + +/** Betrag mit Vorzeichen (Rabatt) in Euro-Schreibweise: -3850 ⇒ '-38,50 €'. */ +export const euroMitVorzeichen = (cent) => (Number.isInteger(cent) ? (cent < 0 ? `-${euroText(-cent)}` : euroText(cent)) : '') + +/** Feldtext ('-38,50', '−38,50 €', '12,5') in Cent mit Vorzeichen; null bei allem Unlesbaren. */ +export function euroZuCentMitVorzeichen(text) { + if (typeof text !== 'string') return null + const s = text.trim() + const minus = /^[-−–]/.test(s) + const c = euroZuCent(minus ? s.slice(1) : s) + return c === null ? null : minus ? -c : c +} + +const centFeld = (cent) => (Number.isInteger(cent) ? `${cent < 0 ? '-' : ''}${Math.floor(Math.abs(cent) / 100)},${String(Math.abs(cent) % 100).padStart(2, '0')}` : '') + +/** + * Editor bauen. => { el, posten() => [{text,menge,einzelCent,ustProzent}] oder null bei Unlesbarem, summe() }. + * steuerart 'kleinunternehmer': keine USt-Spalte (ustProzent 0). aenderung() wird nach jeder Eingabe gerufen. + */ +export function baueEditor(start, steuerart, { aenderung } = {}) { + const klein = steuerart === 'kleinunternehmer' + const zeilen = [] + const liste = h('div', { class: 'bk-posten' }) + const summenEl = h('div', { class: 'bk-summen', 'data-summen': true, 'aria-live': 'polite' }) + const lies = () => { + const ergebnis = [] + for (const z of zeilen) { + const einzelCent = euroZuCentMitVorzeichen(String(z.einzel.value)) + const menge = Number(String(z.menge.value).trim()) + if (einzelCent === null || !Number.isInteger(menge) || menge < 1) return null + ergebnis.push({ text: String(z.text.value).trim(), menge, einzelCent, ustProzent: klein ? 0 : Number(z.ust.value) }) + } + return ergebnis + } + const zeichneSumme = () => { + const p = lies() + if (!p) { + summenEl.replaceChildren(h('p', { class: 'sh-fehler' }, 'Bitte prüfe Menge und Preis der Posten.')) + return + } + const s = summeLive(p, steuerart) + summenEl.replaceChildren( + ...(klein ? [] : [h('p', null, `Netto ${euroMitVorzeichen(s.nettoCent)}`), ...s.ust.map((u) => h('p', null, `${u.satz} % USt ${euroMitVorzeichen(u.steuerCent)}`))]), + h('p', { class: 'bk-gesamt' }, h('strong', null, `Gesamt ${euroMitVorzeichen(s.bruttoCent)}`)), + klein ? h('p', { class: 'sh-hinweis' }, 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.') : null) + } + const geaendert = () => { + zeichneSumme() + aenderung?.() + } + function fuegeZeile(p = { text: '', menge: 1, einzelCent: 0, ustProzent: 7 }) { + const z = { + text: h('input', { type: 'text', maxlength: '200', 'aria-label': 'Beschreibung', 'data-posten': 'text' }), + menge: h('input', { type: 'text', inputmode: 'numeric', 'aria-label': 'Menge', 'data-posten': 'menge' }), + einzel: h('input', { type: 'text', inputmode: 'decimal', autocomplete: 'off', 'aria-label': 'Einzelpreis in Euro (Rabatt mit Minus)', 'data-posten': 'einzel' }), + ust: klein ? null : h('select', { 'aria-label': 'Umsatzsteuer', 'data-posten': 'ust' }, [7, 19, 0].map((satz) => h('option', { value: String(satz) }, `${satz} %`))), + } + z.text.value = p.text + z.menge.value = String(p.menge) + z.einzel.value = centFeld(p.einzelCent) + if (z.ust) z.ust.value = String(p.ustProzent) + for (const el of [z.text, z.menge, z.einzel, z.ust]) el?.addEventListener('input', geaendert) + z.ust?.addEventListener('change', geaendert) + z.einzel.addEventListener('change', () => { + const c = euroZuCentMitVorzeichen(String(z.einzel.value)) + if (c !== null) z.einzel.value = centFeld(c) + }) + z.zeile = h('div', { class: 'bk-posten-zeile', 'data-zeile': true }, + z.text, z.menge, z.einzel, z.ust, + h('button', { + type: 'button', class: 'kb-btn-outline', 'data-aktion': 'posten-weg', 'aria-label': 'Posten entfernen', + onclick: () => { + zeilen.splice(zeilen.indexOf(z), 1) + z.zeile.remove() + geaendert() + }, + }, 'Entfernen')) + zeilen.push(z) + liste.append(z.zeile) + } + for (const p of start) fuegeZeile(p) + const kopf = h('div', { class: 'bk-posten-kopf', 'aria-hidden': 'true' }, + h('span', null, 'Beschreibung'), h('span', null, 'Menge'), h('span', null, 'Einzelpreis €'), klein ? null : h('span', null, 'USt'), h('span')) + const el = h('div', { class: 'bk-editor' }, kopf, liste, + h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'posten-neu', onclick: () => { fuegeZeile(); geaendert() } }, 'Posten hinzufügen'), + summenEl) + zeichneSumme() + return { el, posten: lies, summe: () => { const p = lies(); return p ? summeLive(p, steuerart) : null } } +} diff --git a/public/buchungen-tab-anfragen.js b/public/buchungen-tab-anfragen.js new file mode 100644 index 0000000..5e411c0 --- /dev/null +++ b/public/buchungen-tab-anfragen.js @@ -0,0 +1,301 @@ +/* Buchungskalender, Reiter „Anfragen“: Filter (offen | gebucht | alle), Liste, Detail. + API (docs/funktionen/unterkunft-api.md, alle mit ?projectId=): + GET /api/unterkunft/anfragen?filter= => { anfragen:[{ id, nummer, von, bis, naechte?, gast:{name}, status, summen:{bruttoCent}, unterkunftName? }] } + GET /api/unterkunft/anfragen/:id => { anfrage:{ id, nummer, von, bis, personen, status, gast:{name,firma,email,telefon,nachricht?}, posten, summen, + steuerart, zahlart?, bezahltVia?, erstattetCent?, rechnungId?, rechnungNummer?, fristAt?, unterkunftName? }, vorschlag:{ naechte, posten, summen } } + POST …/freigeben {posten,fristTage,nachricht} (=> { buchungsLink? }), …/ablehnen {text}, …/bezahlt, …/stornieren {grund,erstattungCent}. + Fremdtext (Gast, Nachricht) nur ueber textContent. render(panel, ctx) wie shop-tab-*.js. */ + +import { abschnitt, allgemeinerFehler, euroText, euroZuCent, fehlerKarte, feld, frage, h, zeigeFehler, datumText } from './shop-dom.js' +import { baueEditor, euroMitVorzeichen } from './buchungen-posten.js' + +export const STATUS = { + neu: ['Neu', 'wait'], + freigegeben: ['Freigegeben – wartet auf Buchung', 'wait'], + gebucht: ['Gebucht', 'ok'], + bezahlt: ['Bezahlt', 'ok'], + abgelehnt: ['Abgelehnt', 'bad'], + abgelaufen: ['Abgelaufen', 'off'], + storniert: ['Storniert', 'bad'], +} +const FILTER = [['offen', 'Offen'], ['gebucht', 'Gebucht'], ['alle', 'Alle']] +const FEHLER_LADEN = 'Das konnte nicht geladen werden. Bitte versuch es noch einmal.' +const BELEGT = 'Der Zeitraum ist inzwischen belegt.' + +const zeichen = (x) => (typeof x === 'string' ? x : '') +const enc = encodeURIComponent +const pille = (status) => { + const [text, art] = STATUS[status] || [zeichen(status) || 'Unbekannt', 'off'] + return h('span', { class: `sh-pille sh-pille-${art}`, 'data-status': status }, text) +} + +/** '2026-11-12','2026-11-30' ⇒ '12.–30.11.2026'; über Monats-/Jahresgrenze '28.10.–03.11.2026' bzw. '28.12.2026–03.01.2027'. */ +export function zeitraumText(von, bis) { + const m = /^(\d{4})-(\d{2})-(\d{2})$/ + const a = m.exec(von || '') + const b = m.exec(bis || '') + if (!a || !b) return '' + if (a[1] !== b[1]) return `${a[3]}.${a[2]}.${a[1]}–${b[3]}.${b[2]}.${b[1]}` + if (a[2] !== b[2]) return `${a[3]}.${a[2]}.–${b[3]}.${b[2]}.${b[1]}` + return `${a[3]}.–${b[3]}.${b[2]}.${b[1]}` +} +export function naechteText(von, bis) { + const ms = (t) => Date.UTC(...t.split('-').map((x, i) => (i === 1 ? Number(x) - 1 : Number(x)))) + const n = Math.round((ms(bis) - ms(von)) / 86400000) + return Number.isFinite(n) ? `${n} ${n === 1 ? 'Nacht' : 'Nächte'}` : '' +} +const gastName = (gast) => (typeof gast === 'string' ? gast : zeichen(gast?.name)) + +export async function render(panel, ctx) { + const st = { filter: 'offen', lauf: 0 } + const kopf = h('div', { class: 'bk-filter' }, h('label', { for: 'bk-a-filter' }, 'Anzeigen'), + h('select', { id: 'bk-a-filter', 'data-aktion': 'filter' }, FILTER.map(([wert, text]) => h('option', { value: wert }, text)))) + const inhalt = h('div', { class: 'bk-anfragen' }) + panel.replaceChildren(kopf, inhalt) + const select = kopf.querySelector('select') + select.value = st.filter + select.addEventListener('change', () => { st.filter = select.value; zeigeListe() }) + await zeigeListe() + + async function zeigeListe() { + const lauf = ++st.lauf + const gueltig = () => st.lauf === lauf && ctx.aktuell() + inhalt.replaceChildren(h('p', { class: 'kb-empty' }, 'Lade…')) + let daten + try { + daten = await ctx.request(`/api/unterkunft/anfragen?projectId=${enc(ctx.projectId)}&filter=${enc(st.filter)}`) + } catch (err) { + if (gueltig()) inhalt.replaceChildren(fehlerKarte(err.message || FEHLER_LADEN, zeigeListe)) + return + } + if (!gueltig()) return + const zeilen = Array.isArray(daten?.anfragen) ? daten.anfragen : [] + if (!zeilen.length) { + inhalt.replaceChildren(h('div', { class: 'kb-panel kb-empty', 'data-leer': true }, st.filter === 'offen' ? 'Keine offenen Anfragen.' : 'Keine Anfragen.')) + return + } + inhalt.replaceChildren(h('ul', { class: 'bk-liste' }, zeilen.map((a) => h('li', null, h('button', { + type: 'button', class: 'bk-zeile', 'data-anfrage': a.id, onclick: () => zeigeDetail(a.id), + }, + h('strong', { class: 'bk-nummer' }, zeichen(a.nummer)), + h('span', { class: 'bk-zeitraum' }, `${zeitraumText(a.von, a.bis)} · ${naechteText(a.von, a.bis)}`), + h('span', { class: 'bk-gast' }, gastName(a.gast)), + pille(a.status), + h('span', { class: 'bk-betrag' }, Number.isInteger(a.summen?.bruttoCent) ? euroMitVorzeichen(a.summen.bruttoCent) : '')))))) + } + + async function zeigeDetail(id) { + const lauf = ++st.lauf + const gueltig = () => st.lauf === lauf && ctx.aktuell() + inhalt.replaceChildren(h('p', { class: 'kb-empty' }, 'Lade…')) + let daten + try { + daten = await ctx.request(`/api/unterkunft/anfragen/${enc(id)}?projectId=${enc(ctx.projectId)}`) + } catch (err) { + if (gueltig()) inhalt.replaceChildren(zurueck(), fehlerKarte(err.message || FEHLER_LADEN, () => zeigeDetail(id))) + return + } + if (!gueltig()) return + if (!daten?.anfrage) { + inhalt.replaceChildren(zurueck(), fehlerKarte(FEHLER_LADEN, () => zeigeDetail(id))) + return + } + inhalt.replaceChildren(zurueck(), baueDetail(daten.anfrage, daten.vorschlag)) + } + const zurueck = () => h('button', { type: 'button', class: 'kb-btn-outline bk-zurueck', 'data-aktion': 'zurueck', onclick: () => zeigeListe() }, '‹ Zur Liste') + + function baueDetail(a, vorschlag) { + const gast = a.gast && typeof a.gast === 'object' ? a.gast : { name: zeichen(a.gast) } + const kopfZeile = h('div', { class: 'bk-detail-kopf' }, + h('h3', null, `Anfrage ${zeichen(a.nummer)}`), pille(a.status)) + const daten = h('dl', { class: 'bk-daten' }, + ...[ + ['Unterkunft', zeichen(a.unterkunftName)], + ['Zeitraum', `${zeitraumText(a.von, a.bis)} · ${naechteText(a.von, a.bis)}`], + ['Personen', Number.isInteger(a.personen) ? String(a.personen) : ''], + ['Gast', gastName(gast)], + ['Firma', zeichen(gast.firma)], + ['E-Mail', zeichen(gast.email)], + ['Telefon', zeichen(gast.telefon)], + ['Nachricht', zeichen(gast.nachricht)], + ['Frist', a.fristAt && a.status === 'freigegeben' ? datumText(a.fristAt) : ''], + ].filter(([, w]) => w).flatMap(([n, w]) => [h('dt', null, n), h('dd', null, w)])) + const karte = h('section', { class: 'kb-panel sh-abschnitt bk-detail', 'data-detail': a.id }, kopfZeile, daten) + if (a.status === 'neu') karte.append(neuBereich(a, vorschlag)) + else karte.append(festerBereich(a)) + return karte + } + + /* ---------------------------------------------- Status neu: Freigeben / Ablehnen --- */ + function neuBereich(a, vorschlag) { + const steuerart = a.steuerart || vorschlag?.steuerart || ctx.status?.steuerart || 'regelbesteuert' + const startPosten = Array.isArray(vorschlag?.posten) ? vorschlag.posten : [] + const editor = baueEditor(startPosten, steuerart) + const frist = h('input', { type: 'text', inputmode: 'numeric', id: 'bk-a-frist' }) + frist.value = '7' + const nachricht = h('textarea', { id: 'bk-a-nachricht', rows: '3', maxlength: '2000' }) + const form = h('form', { class: 'bk-freigabe', novalidate: true, onsubmit: (ev) => ev.preventDefault() }, + h('h4', null, 'Angebot für den Gast'), + editor.el, + h('p', { class: 'sh-fehler', 'data-fehler': 'posten', role: 'alert' }), + feld('fristTage', 'Frist zum Buchen (Tage)', frist, 'So lange ist der Zeitraum für den Gast reserviert.'), + feld('nachricht', 'Nachricht an den Gast (optional)', nachricht), + allgemeinerFehler()) + const freigeben = h('button', { + type: 'button', class: 'kb-btn', 'data-aktion': 'freigeben', disabled: ctx.nurLesen && !ctx.abwickeln, + onclick: () => sendeFreigabe(a, editor, frist, nachricht, form, freigeben), + }, 'Freigeben') + const ablehnen = h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'ablehnen', onclick: () => ablehnDialog(a, ablehnen) }, 'Ablehnen') + return h('div', null, form, h('div', { class: 'bk-aktionen' }, freigeben, ablehnen), h('div', { class: 'bk-dialog' })) + } + + async function sendeFreigabe(a, editor, frist, nachricht, form, knopf) { + const posten = editor.posten() + const tage = Number(String(frist.value).trim()) + if (!posten) return zeigeFehler(form, { message: '', fehler: { posten: 'Bitte prüfe Menge und Preis der Posten.' } }) + if (!Number.isInteger(tage) || tage < 1) return zeigeFehler(form, { message: '', fehler: { fristTage: 'Bitte gib die Frist in ganzen Tagen an.' } }) + knopf.disabled = true + try { + const antwort = await ctx.request(`/api/unterkunft/anfragen/${enc(a.id)}/freigeben?projectId=${enc(ctx.projectId)}`, { + method: 'POST', body: { posten, fristTage: tage, nachricht: String(nachricht.value).trim() }, + }) + if (!ctx.aktuell()) return + ctx.zeigeMeldung('Freigegeben. Dein Gast hat den Buchungslink per Mail bekommen.', 'ok') + zeigeLink(antwort?.buchungsLink, a) + } catch (err) { + knopf.disabled = false + if (err.status === 409) zeigeFehler(form, { message: BELEGT, fehler: { _: BELEGT } }) + else zeigeFehler(form, err) + } + } + + /** Nach „Freigeben“: der Buchungslink zum Kopieren, falls die Mail nicht ankommt (nur wenn die Antwort ihn enthaelt). */ + function zeigeLink(link, a) { + const box = h('section', { class: 'kb-panel sh-abschnitt bk-link', 'data-freigegeben': a.id }, + h('h3', null, `Anfrage ${zeichen(a.nummer)} ist freigegeben`), + h('p', null, 'Dein Gast hat den Buchungslink per Mail bekommen.')) + if (typeof link === 'string' && link) { + const meldung = h('p', { class: 'sh-hinweis', role: 'status' }) + box.append( + h('p', null, 'Falls die Mail nicht ankommt, kannst du den Link selbst schicken:'), + h('input', { type: 'text', readonly: true, value: link, 'aria-label': 'Buchungslink', 'data-link': true }), + h('button', { + type: 'button', class: 'kb-btn-outline', 'data-aktion': 'link-kopieren', + onclick: async () => { + try { + await navigator.clipboard.writeText(link) + meldung.textContent = 'Link kopiert.' + } catch { + meldung.textContent = 'Kopieren hat nicht geklappt. Markiere den Link und kopiere ihn von Hand.' + } + }, + }, 'Link kopieren'), + meldung) + } + box.append(h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'zurueck', onclick: () => zeigeListe() }, 'Zur Liste')) + inhalt.replaceChildren(box) + } + + function ablehnDialog(a, ausloeser) { + const dialogEl = inhalt.querySelector('.bk-dialog') + const text = h('textarea', { id: 'bk-a-ablehn', rows: '3', maxlength: '1000' }) + const box = frage('Anfrage ablehnen', h('p', { class: 'sh-hinweis' }, 'Der Gast bekommt deinen Text per Mail.'), feld('text', 'Nachricht an den Gast', text), allgemeinerFehler(), + h('div', { class: 'bk-aktionen' }, + h('button', { + type: 'button', class: 'kb-btn', 'data-aktion': 'ablehnen-senden', + onclick: async (ev) => { + ev.currentTarget.disabled = true + try { + await ctx.request(`/api/unterkunft/anfragen/${enc(a.id)}/ablehnen?projectId=${enc(ctx.projectId)}`, { method: 'POST', body: { text: String(text.value).trim() } }) + if (!ctx.aktuell()) return + ctx.zeigeMeldung('Anfrage abgelehnt.', 'ok') + await zeigeListe() + } catch (err) { + ev.currentTarget.disabled = false + zeigeFehler(box, err) + } + }, + }, 'Ablehnen'), + h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'abbrechen', onclick: () => { dialogEl.replaceChildren(); ausloeser.focus() } }, 'Abbrechen'))) + dialogEl.replaceChildren(box) + text.focus() + } + + /* ---------------------------------------------- weitere Status: Posten, Rechnung, Bezahlt, Storno --- */ + function festerBereich(a) { + const posten = Array.isArray(a.posten) ? a.posten : [] + const teile = [] + if (posten.length) { + teile.push(h('table', { class: 'bk-tabelle' }, h('tbody', null, posten.map((p) => h('tr', null, + h('td', null, zeichen(p.text)), h('td', null, `${p.menge} ×`), h('td', null, euroMitVorzeichen(p.einzelCent))))))) + } + if (Number.isInteger(a.summen?.bruttoCent)) teile.push(h('p', { class: 'bk-gesamt' }, h('strong', null, `Gesamt ${euroMitVorzeichen(a.summen.bruttoCent)}`))) + const aktionen = h('div', { class: 'bk-aktionen' }) + const dialogEl = h('div', { class: 'bk-dialog' }) + const aktiv = a.status === 'gebucht' || a.status === 'bezahlt' + if (aktiv && a.rechnungId) { + teile.push(h('p', null, h('a', { + href: `/api/unterkunft/rechnungen/${enc(a.rechnungId)}.pdf?projectId=${enc(ctx.projectId)}`, target: '_blank', rel: 'noopener', 'data-aktion': 'rechnung', + }, `Rechnung ${zeichen(a.rechnungNummer)}`.trim() + ' (PDF)'))) + } + if (a.status === 'gebucht') { + aktionen.append(h('button', { type: 'button', class: 'kb-btn', 'data-aktion': 'bezahlt', onclick: (ev) => markiereBezahlt(a, ev.currentTarget) }, 'Als bezahlt (vor Ort) markieren')) + } + if (aktiv) { + const stornoKnopf = h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'stornieren', onclick: () => stornoDialog(a, dialogEl, stornoKnopf) }, 'Stornieren') + aktionen.append(stornoKnopf) + } + return h('div', null, ...teile, aktionen.childNodes.length ? aktionen : null, dialogEl) + } + + async function markiereBezahlt(a, knopf) { + knopf.disabled = true + try { + await ctx.request(`/api/unterkunft/anfragen/${enc(a.id)}/bezahlt?projectId=${enc(ctx.projectId)}`, { method: 'POST' }) + if (!ctx.aktuell()) return + ctx.zeigeMeldung('Als bezahlt markiert.', 'ok') + await zeigeDetail(a.id) + } catch (err) { + knopf.disabled = false + ctx.zeigeMeldung(err.message, 'fehler') + } + } + + function stornoDialog(a, dialogEl, ausloeser) { + const online = a.bezahltVia === 'online' + const grund = h('textarea', { id: 'bk-a-grund', rows: '3', maxlength: '500' }) + const betrag = h('input', { type: 'text', inputmode: 'decimal', id: 'bk-a-erstattung', autocomplete: 'off' }) + betrag.value = Number.isInteger(a.summen?.bruttoCent) ? `${Math.floor((a.summen.bruttoCent - (a.erstattetCent || 0)) / 100)},${String((a.summen.bruttoCent - (a.erstattetCent || 0)) % 100).padStart(2, '0')}` : '' + const box = frage('Buchung stornieren', h('p', { class: 'sh-hinweis' }, 'Der Gast bekommt eine Stornomail und eine Storno-Rechnung.'), + feld('grund', 'Grund', grund), + online ? feld('erstattungCent', 'Erstattung in Euro', betrag, 'Der Betrag geht an die Karte des Gastes zurück. 0 = keine Erstattung.') : null, + allgemeinerFehler(), + h('div', { class: 'bk-aktionen' }, + h('button', { + type: 'button', class: 'kb-btn', 'data-aktion': 'storno-senden', + onclick: async (ev) => { + const knopf = ev.currentTarget + let erstattungCent = 0 + if (online) { + erstattungCent = String(betrag.value).trim() === '' ? 0 : euroZuCent(String(betrag.value)) + if (erstattungCent === null) return zeigeFehler(box, { message: '', fehler: { erstattungCent: 'Bitte gib einen Betrag wie 120,00 an.' } }) + } + knopf.disabled = true + try { + await ctx.request(`/api/unterkunft/anfragen/${enc(a.id)}/stornieren?projectId=${enc(ctx.projectId)}`, { method: 'POST', body: { grund: String(grund.value).trim(), erstattungCent } }) + if (!ctx.aktuell()) return + ctx.zeigeMeldung('Die Buchung ist storniert.', 'ok') + await zeigeDetail(a.id) + } catch (err) { + knopf.disabled = false + if (err.fehler === 'erstattung_fehlgeschlagen' || err.body?.fehler === 'erstattung_fehlgeschlagen') { + zeigeFehler(box, { message: 'Die Buchung ist storniert, aber die Erstattung ist fehlgeschlagen. Bitte versuch die Erstattung gleich noch einmal.', fehler: {} }) + } else zeigeFehler(box, err) + } + }, + }, 'Stornieren'), + h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'abbrechen', onclick: () => { dialogEl.replaceChildren(); ausloeser.focus() } }, 'Abbrechen'))) + dialogEl.replaceChildren(box) + grund.focus() + } +} diff --git a/public/buchungen-tab-einstellungen.js b/public/buchungen-tab-einstellungen.js new file mode 100644 index 0000000..ef2a77e --- /dev/null +++ b/public/buchungen-tab-einstellungen.js @@ -0,0 +1,6 @@ +/* Buchungskalender, Reiter „Einstellungen“: Platzhalter (Task 13/14 fuellt ihn). */ +import { infoKarte } from './shop-dom.js' + +export function render(panel) { + panel.replaceChildren(infoKarte('Einstellungen', 'Dieser Bereich wird gerade gebaut.')) +} diff --git a/public/buchungen-tab-kalender.js b/public/buchungen-tab-kalender.js new file mode 100644 index 0000000..f97bed8 --- /dev/null +++ b/public/buchungen-tab-kalender.js @@ -0,0 +1,6 @@ +/* Buchungskalender, Reiter „Kalender“: Platzhalter (Task 13/14 fuellt ihn). */ +import { infoKarte } from './shop-dom.js' + +export function render(panel) { + panel.replaceChildren(infoKarte('Kalender', 'Dieser Bereich wird gerade gebaut.')) +} diff --git a/public/buchungen-tab-rechnungen.js b/public/buchungen-tab-rechnungen.js new file mode 100644 index 0000000..1969d58 --- /dev/null +++ b/public/buchungen-tab-rechnungen.js @@ -0,0 +1,6 @@ +/* Buchungskalender, Reiter „Rechnungen“: Platzhalter (Task 13/14 fuellt ihn). */ +import { infoKarte } from './shop-dom.js' + +export function render(panel) { + panel.replaceChildren(infoKarte('Rechnungen', 'Dieser Bereich wird gerade gebaut.')) +} diff --git a/public/buchungen-tab-unterkuenfte.js b/public/buchungen-tab-unterkuenfte.js new file mode 100644 index 0000000..a457ea0 --- /dev/null +++ b/public/buchungen-tab-unterkuenfte.js @@ -0,0 +1,6 @@ +/* Buchungskalender, Reiter „Unterkünfte & Preise“: Platzhalter (Task 13/14 fuellt ihn). */ +import { infoKarte } from './shop-dom.js' + +export function render(panel) { + panel.replaceChildren(infoKarte('Unterkünfte & Preise', 'Dieser Bereich wird gerade gebaut.')) +} diff --git a/public/buchungen.css b/public/buchungen.css new file mode 100644 index 0000000..566ae9b --- /dev/null +++ b/public/buchungen.css @@ -0,0 +1,37 @@ +/* Buchungskalender im Kundenbereich (buchungen.js): Klassen mit bk-; Reiter, Pillen, Karten kommen aus shop.css (sh-). */ +#view-buchungen .kb-panel { max-width: none; } +#view-buchungen .fn-project { margin-bottom: 16px; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding: 14px 18px; } +#view-buchungen button:focus-visible, #view-buchungen input:focus-visible, #view-buchungen select:focus-visible, #view-buchungen textarea:focus-visible, +#view-buchungen a:focus-visible { outline: 3px solid #d4b4ff; outline-offset: 2px; } +.bk-filter { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; } +.bk-liste { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; } +.bk-zeile { + display: grid; grid-template-columns: 90px 1.4fr 1fr auto auto; gap: 8px 14px; align-items: center; width: 100%; padding: 12px 16px; text-align: left; + border-radius: 12px; border: 1px solid var(--kb-border); background: rgba(255, 255, 255, 0.03); color: inherit; font: inherit; cursor: pointer; +} +.bk-zeile:hover { border-color: var(--kb-purple-line); } +.bk-zeile .sh-pille { margin: 0; justify-self: start; } +.bk-betrag { font-weight: 700; text-align: right; } +.bk-zurueck { margin-bottom: 12px; } +.bk-detail-kopf { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; } +.bk-detail-kopf .sh-pille { margin: 0; } +.bk-daten { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; margin: 12px 0 18px; } +.bk-daten dt { color: var(--kb-muted); } +.bk-daten dd { margin: 0; overflow-wrap: anywhere; } +.bk-posten-kopf, .bk-posten-zeile { display: grid; grid-template-columns: minmax(0, 3fr) 70px 110px 80px auto; gap: 8px; align-items: center; } +.bk-posten-kopf { font-size: 12px; color: var(--kb-muted); margin-bottom: 4px; } +.bk-posten-zeile { margin-bottom: 8px; } +.bk-summen { margin: 12px 0; text-align: right; } +.bk-summen p { margin: 2px 0; } +.bk-gesamt { font-size: 18px; } +.bk-aktionen { display: flex; gap: 10px; flex-wrap: wrap; margin: 14px 0; } +.bk-tabelle { width: 100%; border-collapse: collapse; margin: 10px 0; } +.bk-tabelle td { padding: 6px 4px; border-bottom: 1px solid var(--kb-border); } +.bk-tabelle td:last-child { text-align: right; } +.bk-link input { width: 100%; margin: 6px 0 10px; } +@media (max-width: 640px) { + .bk-zeile { grid-template-columns: 1fr auto; } + .bk-posten-kopf { display: none; } + .bk-posten-zeile { grid-template-columns: 1fr 1fr; } + .bk-posten-zeile input:first-child { grid-column: 1 / -1; } +} diff --git a/public/buchungen.js b/public/buchungen.js index fb992af..ce75ecb 100644 --- a/public/buchungen.js +++ b/public/buchungen.js @@ -1,4 +1,173 @@ -// Buchungskalender (Reiter #buchungen). Platzhalter; die Oberflaeche baut Strang 4. -export function initBuchungen(el) { - el.textContent = 'Buchungskalender wird geladen …' +/* Buchungskalender im Kundenbereich (ES-Modul, von app.js beim Oeffnen der Ansicht #buchungen geladen). Aufbau wie shop.js: + initBuchungen(rootEl, { projectId }): Projekt waehlen (GET /api/funktionen, features.unterkunft), Stand holen (GET /api/unterkunft/status) + und je nach state die Reiter (REITER, lazy geladen, render(panel, ctx)) oder (state off) die Anfrage-/Bestellkarte (Task 14). + ctx = { projectId, status, nurLesen, aktuell(), neuLaden(), zeigeMeldung(text, art), request }. + Helfer: shop-dom.js (h, request, Karten), Styles: buchungen.css (bk-) und shop.css (sh-). Fremdtext nur ueber textContent. */ + +import { fehlerKarte, h, infoKarte, request } from './shop-dom.js' + +/** Die Reiter: jeder ein Modul mit render(panel, ctx), erst beim ersten Oeffnen geladen. */ +export const REITER = [ + { key: 'anfragen', label: 'Anfragen', laden: () => import('./buchungen-tab-anfragen.js') }, + { key: 'kalender', label: 'Kalender', laden: () => import('./buchungen-tab-kalender.js') }, + { key: 'unterkuenfte', label: 'Unterkünfte & Preise', laden: () => import('./buchungen-tab-unterkuenfte.js') }, + { key: 'rechnungen', label: 'Rechnungen', laden: () => import('./buchungen-tab-rechnungen.js') }, + { key: 'einstellungen', label: 'Einstellungen', laden: () => import('./buchungen-tab-einstellungen.js') }, +] + +const NICHT_VERFUEGBAR = 'Der Buchungskalender ist noch nicht verfügbar.' +const auswahl = { projectId: '', reiter: 'anfragen' } +/** Gemerkte Auswahl vergessen (fuer Tests). */ +export function vergissAuswahl() { + auswahl.projectId = '' + auswahl.reiter = 'anfragen' +} +let S = null // aktuelle Sitzung; eine alte Antwort darf eine neuere nicht ueberschreiben + +const zustandVon = (projekt) => projekt?.features?.unterkunft?.state || 'off' + +export function initBuchungen(root, { projectId } = {}) { + if (!root) return Promise.resolve() + return starte(root, projectId) +} + +async function starte(root, projectId) { + if (projectId) auswahl.projectId = projectId + const s = { root, projects: [], projekt: null, status: null, lauf: 0, buehne: null, meldungEl: null } + S = s + let funktionen + try { + funktionen = await request('/api/funktionen') + } catch (err) { + if (S === s) root.replaceChildren(fehlerKarte(err.message, () => starte(root, auswahl.projectId))) + return + } + if (S !== s) return + if (!funktionen?.unterkunft?.verfuegbar) { + root.replaceChildren(infoKarte(NICHT_VERFUEGBAR, 'Sobald er für dich bereit ist, findest du ihn hier.')) + return + } + s.projects = Array.isArray(funktionen.projects) ? funktionen.projects : [] + if (!s.projects.length) { + root.replaceChildren(h('div', { class: 'kb-panel kb-empty' }, 'Noch keine Website-Projekte zugewiesen.')) + return + } + const hat = (zustaende) => s.projects.find((p) => zustaende.includes(zustandVon(p))) + s.projekt = s.projects.find((p) => p.projectId === auswahl.projectId) || hat(['active']) || hat(['setup']) || hat(['disabled']) || s.projects[0] + auswahl.projectId = s.projekt.projectId + s.meldungEl = h('div', { class: 'sh-meldung', role: 'status', 'aria-live': 'polite', tabindex: '-1' }) + s.buehne = h('div', { id: 'buchungen-buehne' }) + root.replaceChildren(...(s.projects.length > 1 ? [projektwahl(s)] : []), s.meldungEl, s.buehne) + await zeigeProjekt(s) +} + +function projektwahl(s) { + const select = h('select', { id: 'buchungen-project' }, s.projects.map((p) => { + const option = h('option', { value: p.projectId }, p.projectName) + option.selected = p.projectId === s.projekt.projectId + return option + })) + select.addEventListener('change', () => { + auswahl.projectId = select.value + s.projekt = s.projects.find((p) => p.projectId === select.value) + zeigeMeldung(s, '') + zeigeProjekt(s) + }) + return h('div', { class: 'fn-project kb-panel' }, h('label', { for: 'buchungen-project' }, 'Website'), select) +} + +function zeigeMeldung(s, meldung, art = 'ok') { + const el = s.meldungEl + if (!el) return + el.textContent = meldung || '' + el.className = `sh-meldung sh-meldung-${art}` + el.setAttribute('role', art === 'fehler' ? 'alert' : 'status') + el.setAttribute('aria-live', art === 'fehler' ? 'assertive' : 'polite') + if (!meldung) return + if (art === 'fehler') el.focus({ preventScroll: true }) + el.scrollIntoView?.({ block: 'nearest' }) +} + +const ladeStatus = (projectId) => request(`/api/unterkunft/status?projectId=${encodeURIComponent(projectId)}`) + +async function zeigeProjekt(s) { + const lauf = ++s.lauf + const aktuell = () => S === s && s.lauf === lauf + s.buehne.replaceChildren(h('p', { class: 'kb-empty' }, 'Lade…')) + let status + try { + status = await ladeStatus(s.projekt.projectId) + } catch (err) { + if (aktuell()) s.buehne.replaceChildren(fehlerKarte(err.message, () => zeigeProjekt(s))) + return + } + if (!aktuell()) return + zeichneAnsicht(s, { status, aktuell }) +} + +function zeichneAnsicht(s, { status, aktuell }) { + s.status = status + const zustand = status?.state || zustandVon(s.projekt) + if (zustand === 'setup' || zustand === 'active' || zustand === 'disabled') zeigeReiter(s, { status, aktuell, zustand }) + else if (zustand === 'off') s.buehne.replaceChildren(infoKarte('Buchungskalender', 'Du hast den Buchungskalender noch nicht angefragt. Die Anfrage kommt in Kürze hier.')) // Task 14: Anfrage-/Bestellkarte + else s.buehne.replaceChildren(infoKarte('Buchungskalender', 'Der Stand deines Buchungskalenders ist gerade nicht lesbar. Bitte lade die Seite neu.')) +} + +function zeigeReiter(s, { status, aktuell, zustand }) { + const neuLaden = async () => { + const neu = await ladeStatus(s.projekt.projectId) + if (!aktuell()) return neu + s.status = neu + ctx.status = neu + if ((neu.state || 'off') !== zustand) zeichneAnsicht(s, { status: neu, aktuell }) + return neu + } + const ctx = { + projectId: s.projekt.projectId, + status, + nurLesen: zustand === 'disabled', + abwickeln: true, // Anfragen und Stornos bleiben auch bei abgeschalteter Funktion bearbeitbar (Pflichten gegenueber Gaesten) + aktuell, + request, + zeigeMeldung: (meldung, art) => { if (aktuell()) zeigeMeldung(s, meldung, art) }, + neuLaden, + } + const panel = h('div', { class: 'sh-panel', role: 'tabpanel', id: 'bk-panel', tabindex: '0' }) + let aktiv = null + let zaehler = 0 + const knoepfe = REITER.map((r) => h('button', { + type: 'button', role: 'tab', class: 'sh-reiter', id: `bk-tab-${r.key}`, 'aria-controls': 'bk-panel', 'data-reiter': r.key, + onclick: () => waehleReiter(r.key), + }, r.label)) + const leiste = h('div', { class: 'sh-reiterleiste', role: 'tablist', 'aria-label': 'Bereiche des Buchungskalenders' }, knoepfe) + leiste.addEventListener('keydown', (ev) => { + const i = REITER.findIndex((r) => r.key === aktiv) + const ziel = { ArrowRight: (i + 1) % REITER.length, ArrowLeft: (i + REITER.length - 1) % REITER.length, Home: 0, End: REITER.length - 1 }[ev.key] + if (ziel === undefined) return + ev.preventDefault() + waehleReiter(REITER[ziel].key) + knoepfe[ziel].focus() + }) + function waehleReiter(key) { + const reiter = REITER.find((r) => r.key === key) || REITER[0] + auswahl.reiter = reiter.key + aktiv = reiter.key + const nr = ++zaehler + for (const k of knoepfe) { + const an = k.dataset.reiter === reiter.key + k.setAttribute('aria-selected', String(an)) + k.tabIndex = an ? 0 : -1 + } + panel.setAttribute('aria-labelledby', `bk-tab-${reiter.key}`) + zeigeMeldung(s, '') + panel.replaceChildren() + reiter.laden() + .then((modul) => (nr === zaehler && aktuell() ? modul.render(panel, ctx) : undefined)) + .catch((err) => { if (nr === zaehler && aktuell()) panel.replaceChildren(fehlerKarte(err.message, () => waehleReiter(reiter.key))) }) + } + const hinweis = zustand === 'setup' ? 'Wir bauen deinen Buchungskalender gerade ein. Du kannst schon Unterkünfte, Preise und Einstellungen vorbereiten.' + : zustand === 'disabled' ? 'Dein Buchungskalender ist abgeschaltet. Du kannst bestehende Buchungen noch ansehen und bearbeiten.' : '' + const hinweisBox = h('div', { class: 'sh-hinweise' }, hinweis ? h('div', { class: `sh-box ${zustand === 'disabled' ? 'sh-box-warn' : 'sh-box-info'}`, role: 'note', 'data-hinweis': zustand }, hinweis) : null) + s.buehne.replaceChildren(hinweisBox, leiste, panel) + waehleReiter(auswahl.reiter) } diff --git a/public/dashboard.html b/public/dashboard.html index 883f671..03278c7 100644 --- a/public/dashboard.html +++ b/public/dashboard.html @@ -25,6 +25,7 @@ + diff --git a/test/buchungenUi.test.js b/test/buchungenUi.test.js new file mode 100644 index 0000000..152055b --- /dev/null +++ b/test/buchungenUi.test.js @@ -0,0 +1,166 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { installFakeDom } from './support/fakeDom.js' +import { h, request } from '../public/shop-dom.js' +import { REITER } from '../public/buchungen.js' +import { render, zeitraumText, naechteText } from '../public/buchungen-tab-anfragen.js' +import { summeLive } from '../public/buchungen-posten.js' + +// Task 12: Gerüst und Reiter „Anfragen“ gegen eine Anfrage-Attrappe nach docs/funktionen/unterkunft-api.md. + +const POSTEN = [ + { text: 'Übernachtung', menge: 7, einzelCent: 5500, ustProzent: 7 }, + { text: 'Reinigung', menge: 1, einzelCent: 6000, ustProzent: 7 }, + { text: 'Direktbuchungsrabatt 10 %', menge: 1, einzelCent: -3850, ustProzent: 7 }, +] +const zeile = (nr, extra = {}) => ({ id: `a${nr}`, nummer: `A-${nr}`, von: '2026-11-12', bis: '2026-11-30', gast: { name: `Gast ${nr}` }, status: 'neu', summen: { bruttoCent: 40650 }, ...extra }) +const anfrage = (extra = {}) => ({ + id: 'a1', nummer: 'A-1', von: '2026-11-12', bis: '2026-11-19', personen: 2, status: 'neu', steuerart: 'regelbesteuert', unterkunftName: 'Haus am See', + gast: { name: 'Anna Muster', email: 'anna@example.com', telefon: '0151', nachricht: 'Hund dabei' }, ...extra, +}) +const vorschlag = { naechte: 7, posten: POSTEN, summen: { bruttoCent: 40650 } } + +const mit = (route, fn) => async () => { + const fx = installFakeDom({ antworten: Array.from({ length: 40 }, () => route) }) + try { await fn(fx) } finally { fx.restore() } +} +function baueCtx() { + const ctx = { projectId: 'p1', status: { state: 'active' }, nurLesen: false, meldungen: [], aktuell: () => true, request, zeigeMeldung: (t, a) => ctx.meldungen.push({ text: t, art: a }), neuLaden: async () => ctx.status } + return ctx +} +async function zeige(fx) { + const ctx = baueCtx() + const panel = h('div') + fx.document.body.append(panel) + await render(panel, ctx) + await fx.ruhe() + return { panel, ctx } +} +const server = (o = {}) => (a) => { + const key = `${a.method} ${a.url.split('?')[0]}` + if (o[key]) return o[key](a) + if (a.method === 'GET' && a.url.startsWith('/api/unterkunft/anfragen?')) return { body: { anfragen: [zeile(1), zeile(2, { status: 'gebucht' })] } } + if (a.method === 'GET' && a.url.startsWith('/api/unterkunft/anfragen/')) return { body: { anfrage: anfrage(), vorschlag } } + return { body: {} } +} +const posts = (fx) => fx.fetchLog.filter((a) => a.method !== 'GET') +const aktion = (fx, el, n) => fx.finde(el, `[data-aktion="${n}"]`) +const oeffne = async (fx, panel, id = 'a1') => { await fx.klick(fx.finde(panel, `[data-anfrage="${id}"]`)); await fx.ruhe() } + +test('REITER: fünf Reiter mit den Labels', () => { + assert.deepEqual(REITER.map((r) => r.key), ['anfragen', 'kalender', 'unterkuenfte', 'rechnungen', 'einstellungen']) + assert.deepEqual(REITER.map((r) => r.label), ['Anfragen', 'Kalender', 'Unterkünfte & Preise', 'Rechnungen', 'Einstellungen']) +}) + +test('summeLive spiegelt summiere: 40650 / 37991 / 2659', () => { + const s = summeLive(POSTEN, 'regelbesteuert') + assert.equal(s.bruttoCent, 40650) + assert.equal(s.nettoCent, 37991) + assert.equal(s.ust[0].steuerCent, 2659) + const k = summeLive(POSTEN, 'kleinunternehmer') + assert.deepEqual(k, { bruttoCent: 40650, nettoCent: 40650, ust: [] }) +}) + +test('Zeitraum und Nächte', () => { + assert.equal(zeitraumText('2026-11-12', '2026-11-30'), '12.–30.11.2026') + assert.equal(zeitraumText('2026-10-28', '2026-11-03'), '28.10.–03.11.2026') + assert.equal(zeitraumText('2026-12-28', '2027-01-03'), '28.12.2026–03.01.2027') + assert.equal(naechteText('2026-11-12', '2026-11-30'), '18 Nächte') + assert.equal(naechteText('2026-11-12', '2026-11-13'), '1 Nacht') +}) + +test('Liste: Nummer, Zeitraum, Gast, Status-Pille, Betrag; Filter offen als Standard', mit(server(), async (fx) => { + const { panel } = await zeige(fx) + assert.equal(fx.fetchLog[0].url, '/api/unterkunft/anfragen?projectId=p1&filter=offen') + const z = fx.alle(panel, '[data-anfrage]') + assert.equal(z.length, 2) + const t = fx.text(z[0]) + for (const re of [/A-1/, /12\.–30\.11\.2026 · 18 Nächte/, /Gast 1/, /Neu/, /406,50\s€/]) assert.match(t, re) + assert.match(fx.text(z[1]), /Gebucht/) + await fx.setze(fx.finde(panel, '#bk-a-filter'), 'alle') + await fx.ruhe() + assert.equal(fx.fetchLog.at(-1).url, '/api/unterkunft/anfragen?projectId=p1&filter=alle') +})) + +test('Detail: Vorschlag editierbar, Summe aktualisiert sich, Freigeben sendet posten/fristTage/nachricht und zeigt den Link', mit(server({ + 'POST /api/unterkunft/anfragen/a1/freigeben': () => ({ body: { buchungsLink: 'https://x.test/buchen/a1?t=abc' } }), +}), async (fx) => { + const { panel, ctx } = await zeige(fx) + await oeffne(fx, panel) + assert.match(fx.text(panel), /Anna Muster<\/b>/) + assert.equal(fx.finde(panel, 'b'), null, 'Fremdtext bleibt Text') + assert.match(fx.text(fx.finde(panel, '[data-summen]')), /Gesamt 406,50\s€/) + const zeilen = fx.alle(panel, '[data-zeile]') + assert.equal(zeilen.length, 3) + const einzel = fx.finde(zeilen[1], '[data-posten="einzel"]') + await fx.setze(einzel, '70,00') + assert.match(fx.text(fx.finde(panel, '[data-summen]')), /Gesamt 416,50\s€/) + await fx.setze(einzel, '60,00') + await fx.setze(fx.finde(panel, '#bk-a-frist'), '5') + await fx.setze(fx.finde(panel, '#bk-a-nachricht'), 'Gern!') + await fx.klick(aktion(fx, panel, 'freigeben')) + await fx.ruhe() + const [p] = posts(fx) + assert.equal(p.url, '/api/unterkunft/anfragen/a1/freigeben?projectId=p1') + assert.deepEqual(p.body, { posten: POSTEN, fristTage: 5, nachricht: 'Gern!' }) + assert.equal(fx.finde(panel, '[data-link]').getAttribute('value'), 'https://x.test/buchen/a1?t=abc') + assert.ok(aktion(fx, panel, 'link-kopieren')) + assert.equal(ctx.meldungen.at(-1).art, 'ok') +})) + +test('Freigeben: 409 zeigt „Der Zeitraum ist inzwischen belegt.“', mit(server({ + 'POST /api/unterkunft/anfragen/a1/freigeben': () => ({ status: 409, body: { fehler: 'zeitraum_belegt' } }), +}), async (fx) => { + const { panel } = await zeige(fx) + await oeffne(fx, panel) + await fx.klick(aktion(fx, panel, 'freigeben')) + await fx.ruhe() + assert.match(fx.text(panel), /Der Zeitraum ist inzwischen belegt\./) + assert.equal(aktion(fx, panel, 'freigeben').disabled, false) +})) + +test('Ablehnen sendet den Text', mit(server({ 'POST /api/unterkunft/anfragen/a1/ablehnen': () => ({ body: {} }) }), async (fx) => { + const { panel } = await zeige(fx) + await oeffne(fx, panel) + await fx.klick(aktion(fx, panel, 'ablehnen')) + await fx.setze(fx.finde(panel, '#bk-a-ablehn'), 'Leider belegt') + await fx.klick(aktion(fx, panel, 'ablehnen-senden')) + await fx.ruhe() + assert.deepEqual(posts(fx)[0].body, { text: 'Leider belegt' }) +})) + +const gebucht = (extra = {}) => ({ anfrage: anfrage({ status: 'gebucht', posten: POSTEN, summen: { bruttoCent: 40650 }, rechnungId: 'r1', rechnungNummer: 'R-2026-001', ...extra }) }) + +test('Gebucht: Rechnung-Link, „Als bezahlt (vor Ort)“, Storno ohne Erstattungsfeld bei Vor-Ort-Zahlung', mit(server({ + 'GET /api/unterkunft/anfragen/a1': () => ({ body: gebucht({ zahlart: 'vorort' }) }), + 'POST /api/unterkunft/anfragen/a1/bezahlt': () => ({ body: {} }), + 'POST /api/unterkunft/anfragen/a1/stornieren': () => ({ body: {} }), +}), async (fx) => { + const { panel } = await zeige(fx) + await oeffne(fx, panel) + assert.equal(aktion(fx, panel, 'rechnung').getAttribute('href'), '/api/unterkunft/rechnungen/r1.pdf?projectId=p1') + await fx.klick(aktion(fx, panel, 'bezahlt')) + await fx.ruhe() + assert.equal(posts(fx)[0].url, '/api/unterkunft/anfragen/a1/bezahlt?projectId=p1') + await fx.klick(aktion(fx, panel, 'stornieren')) + assert.equal(fx.finde(panel, '#bk-a-erstattung'), null) + await fx.setze(fx.finde(panel, '#bk-a-grund'), 'Gast krank') + await fx.klick(aktion(fx, panel, 'storno-senden')) + await fx.ruhe() + assert.deepEqual(posts(fx).at(-1).body, { grund: 'Gast krank', erstattungCent: 0 }) +})) + +test('Storno bei Online-Zahlung: Erstattungsbetrag wird gesendet', mit(server({ + 'GET /api/unterkunft/anfragen/a1': () => ({ body: gebucht({ status: 'bezahlt', zahlart: 'online', bezahltVia: 'online' }) }), + 'POST /api/unterkunft/anfragen/a1/stornieren': () => ({ body: {} }), +}), async (fx) => { + const { panel } = await zeige(fx) + await oeffne(fx, panel) + assert.equal(aktion(fx, panel, 'bezahlt'), null) + await fx.klick(aktion(fx, panel, 'stornieren')) + assert.equal(fx.finde(panel, '#bk-a-erstattung').value, '406,50') + await fx.setze(fx.finde(panel, '#bk-a-erstattung'), '200,00') + await fx.klick(aktion(fx, panel, 'storno-senden')) + await fx.ruhe() + assert.equal(posts(fx)[0].body.erstattungCent, 20000) +})) diff --git a/test/unterkunftWiring.test.js b/test/unterkunftWiring.test.js index 46b24ab..26ba26c 100644 --- a/test/unterkunftWiring.test.js +++ b/test/unterkunftWiring.test.js @@ -127,7 +127,7 @@ test('Portal: app.js und dashboard.html kennen den Reiter buchungen', () => { const html = lies('public/dashboard.html') assert.match(html, /
[\s\S]*?
[\s\S]*?<\/section>/) assert.equal([...html.matchAll(/id="buchungen-body"/g)].length, 1) - assert.match(lies('public/buchungen.js'), /export function initBuchungen\(el\)/) + assert.match(lies('public/buchungen.js'), /export function initBuchungen\(root, \{ projectId \} = \{\}\)/) }) test('Server: index.js hängt Mounts und Jobs ein, die Router-Dateien existieren', () => {