Buchungskalender: Portal-Bereich und Anfragen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Opus 5.5 committed 2026-10-07 14:06:48 +00:00
1 parent d7b5b98187
commit 3ecea05b52
11 files changed
+811 -4

No files matched your search

+109
View File
@@ -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 } }
}
+301
View File
@@ -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()
}
}
+6
View File
@@ -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.'))
}
+6
View File
@@ -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.'))
}
+6
View File
@@ -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.'))
}
+6
View File
@@ -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.'))
}
+37
View File
@@ -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; }
}
+172 -3
View File
@@ -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)
}
+1
View File
@@ -25,6 +25,7 @@
<link rel="stylesheet" href="/analyse.css">
<link rel="stylesheet" href="/termine.css">
<link rel="stylesheet" href="/shop.css">
<link rel="stylesheet" href="/buchungen.css">
<link rel="stylesheet" href="/zeiten.css">
</head>
<body class="portal-page dashboard-page kb-page">
+166
View File
@@ -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 <b>Muster</b>', 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 <b>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)
}))
+1 -1
View File
@@ -127,7 +127,7 @@ test('Portal: app.js und dashboard.html kennen den Reiter buchungen', () => {
const html = lies('public/dashboard.html')
assert.match(html, /<section class="kb-view" id="view-buchungen">[\s\S]*?<div id="buchungen-body">[\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', () => {