Buchungskalender: Portal-Bereich und Anfragen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d7b5b98187
commit
3ecea05b52
11 files changed
+811
-4
No files matched your search
@@ -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 } }
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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.'))
|
||||
}
|
||||
@@ -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.'))
|
||||
}
|
||||
@@ -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.'))
|
||||
}
|
||||
@@ -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.'))
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
}))
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in new issue
Block a user