/* Buchungskalender, Reiter „Anfragen“: Filter (offen | gebucht | alle), Liste, Detail. API (docs/funktionen/unterkunft-api.md, alle mit ?projectId=): GET /api/unterkunft/anfragen?filter= => { anfragen:[{ id, nummer, von, bis, naechte?, gast:{name}, status, summen:{bruttoCent}, unterkunftName? }] } GET /api/unterkunft/anfragen/:id => { anfrage:{ id, nummer, von, bis, personen, status, gast:{name,firma,email,telefon,nachricht?}, posten, summen, steuerart, zahlart?, bezahltVia?, erstattetCent?, rechnungId?, rechnungNummer?, fristAt?, unterkunftName? }, vorschlag:{ naechte, posten, summen } } POST …/freigeben {posten,fristTage,nachricht} (=> { buchungsLink? }), …/ablehnen {text}, …/bezahlt, …/stornieren {grund,erstattungCent}. Fremdtext (Gast, Nachricht) nur ueber textContent. render(panel, ctx) wie shop-tab-*.js. */ import { abschnitt, allgemeinerFehler, centZuEuro, euroZuCent, fehlerKarte, feld, frage, h, zeigeFehler, datumText } from './shop-dom.js' import { fehlerFuerFormular } from './buchungen-fehler.js' import { baueEditor, euroMitVorzeichen } from './buchungen-posten.js' export const STATUS = { neu: ['Neu', 'wait'], freigegeben: ['Freigegeben – wartet auf Buchung', 'wait'], gebucht: ['Gebucht', 'ok'], bezahlt: ['Bezahlt', 'ok'], abgelehnt: ['Abgelehnt', 'bad'], abgelaufen: ['Abgelaufen', 'off'], storniert: ['Storniert', 'bad'], } const FILTER = [['offen', 'Offen'], ['gebucht', 'Gebucht'], ['alle', 'Alle']] const FEHLER_LADEN = 'Das konnte nicht geladen werden. Bitte versuch es noch einmal.' const BELEGT = 'Der Zeitraum ist inzwischen belegt.' const zeichen = (x) => (typeof x === 'string' ? x : '') const enc = encodeURIComponent const pille = (status) => { const [text, art] = STATUS[status] || [zeichen(status) || 'Unbekannt', 'off'] return h('span', { class: `sh-pille sh-pille-${art}`, 'data-status': status }, text) } /** '2026-11-12','2026-11-30' ⇒ '12.–30.11.2026'; über Monats-/Jahresgrenze '28.10.–03.11.2026' bzw. '28.12.2026–03.01.2027'. */ export function zeitraumText(von, bis) { const m = /^(\d{4})-(\d{2})-(\d{2})$/ const a = m.exec(von || '') const b = m.exec(bis || '') if (!a || !b) return '' if (a[1] !== b[1]) return `${a[3]}.${a[2]}.${a[1]}–${b[3]}.${b[2]}.${b[1]}` if (a[2] !== b[2]) return `${a[3]}.${a[2]}.–${b[3]}.${b[2]}.${b[1]}` return `${a[3]}.–${b[3]}.${b[2]}.${b[1]}` } export function naechteText(von, bis) { const ms = (t) => Date.UTC(...t.split('-').map((x, i) => (i === 1 ? Number(x) - 1 : Number(x)))) const n = Math.round((ms(bis) - ms(von)) / 86400000) return Number.isFinite(n) ? `${n} ${n === 1 ? 'Nacht' : 'Nächte'}` : '' } const gastName = (gast) => (typeof gast === 'string' ? gast : zeichen(gast?.name)) export async function render(panel, ctx) { const st = { filter: 'offen', lauf: 0 } const kopf = h('div', { class: 'bk-filter' }, h('label', { for: 'bk-a-filter' }, 'Anzeigen'), h('select', { id: 'bk-a-filter', 'data-aktion': 'filter' }, FILTER.map(([wert, text]) => h('option', { value: wert }, text)))) const inhalt = h('div', { class: 'bk-anfragen' }) panel.replaceChildren(kopf, inhalt) const select = kopf.querySelector('select') select.value = st.filter select.addEventListener('change', () => { st.filter = select.value; zeigeListe() }) await zeigeListe() async function zeigeListe() { const lauf = ++st.lauf const gueltig = () => st.lauf === lauf && ctx.aktuell() inhalt.replaceChildren(h('p', { class: 'kb-empty' }, 'Lade…')) let daten try { daten = await ctx.request(`/api/unterkunft/anfragen?projectId=${enc(ctx.projectId)}&filter=${enc(st.filter)}`) } catch (err) { if (gueltig()) inhalt.replaceChildren(fehlerKarte(err.message || FEHLER_LADEN, zeigeListe)) return } if (!gueltig()) return const zeilen = Array.isArray(daten?.anfragen) ? daten.anfragen : [] if (!zeilen.length) { inhalt.replaceChildren(h('div', { class: 'kb-panel kb-empty', 'data-leer': true }, st.filter === 'offen' ? 'Keine offenen Anfragen.' : 'Keine Anfragen.')) return } inhalt.replaceChildren(h('ul', { class: 'bk-liste' }, zeilen.map((a) => h('li', null, h('button', { type: 'button', class: 'bk-zeile', 'data-anfrage': a.id, onclick: () => zeigeDetail(a.id), }, h('strong', { class: 'bk-nummer' }, zeichen(a.nummer)), h('span', { class: 'bk-zeitraum' }, `${zeitraumText(a.von, a.bis)} · ${naechteText(a.von, a.bis)}`), h('span', { class: 'bk-gast' }, gastName(a.gast)), pille(a.status), h('span', { class: 'bk-betrag' }, Number.isInteger(a.summen?.bruttoCent) ? euroMitVorzeichen(a.summen.bruttoCent) : '')))))) } async function zeigeDetail(id) { const lauf = ++st.lauf const gueltig = () => st.lauf === lauf && ctx.aktuell() inhalt.replaceChildren(h('p', { class: 'kb-empty' }, 'Lade…')) let daten try { daten = await ctx.request(`/api/unterkunft/anfragen/${enc(id)}?projectId=${enc(ctx.projectId)}`) } catch (err) { if (gueltig()) inhalt.replaceChildren(zurueck(), fehlerKarte(err.message || FEHLER_LADEN, () => zeigeDetail(id))) return } if (!gueltig()) return if (!daten?.anfrage) { inhalt.replaceChildren(zurueck(), fehlerKarte(FEHLER_LADEN, () => zeigeDetail(id))) return } inhalt.replaceChildren(zurueck(), baueDetail(daten.anfrage, daten.vorschlag)) } const zurueck = () => h('button', { type: 'button', class: 'kb-btn-outline bk-zurueck', 'data-aktion': 'zurueck', onclick: () => zeigeListe() }, '‹ Zur Liste') function baueDetail(a, vorschlag) { const gast = a.gast && typeof a.gast === 'object' ? a.gast : { name: zeichen(a.gast) } const kopfZeile = h('div', { class: 'bk-detail-kopf' }, h('h3', null, `Anfrage ${zeichen(a.nummer)}`), pille(a.status)) const daten = h('dl', { class: 'bk-daten' }, ...[ ['Unterkunft', zeichen(a.unterkunftName)], ['Zeitraum', `${zeitraumText(a.von, a.bis)} · ${naechteText(a.von, a.bis)}`], ['Personen', Number.isInteger(a.personen) ? String(a.personen) : ''], ['Gast', gastName(gast)], ['Firma', zeichen(gast.firma)], ['E-Mail', zeichen(gast.email)], ['Telefon', zeichen(gast.telefon)], ['Nachricht', zeichen(gast.nachricht)], ['Frist', a.fristAt && a.status === 'freigegeben' ? datumText(a.fristAt) : ''], ].filter(([, w]) => w).flatMap(([n, w]) => [h('dt', null, n), h('dd', null, w)])) const karte = h('section', { class: 'kb-panel sh-abschnitt bk-detail', 'data-detail': a.id }, kopfZeile, daten) if (a.status === 'neu') karte.append(neuBereich(a, vorschlag)) else karte.append(festerBereich(a)) return karte } /* ---------------------------------------------- Status neu: Freigeben / Ablehnen --- */ function neuBereich(a, vorschlag) { const steuerart = a.steuerart || vorschlag?.steuerart || ctx.status?.steuerart || 'regelbesteuert' const startPosten = Array.isArray(vorschlag?.posten) ? vorschlag.posten : [] const editor = baueEditor(startPosten, steuerart) const frist = h('input', { type: 'text', inputmode: 'numeric', id: 'bk-a-frist' }) frist.value = '7' const nachricht = h('textarea', { id: 'bk-a-nachricht', rows: '3', maxlength: '2000' }) const form = h('form', { class: 'bk-freigabe', novalidate: true, onsubmit: (ev) => ev.preventDefault() }, h('h4', null, 'Angebot für den Gast'), editor.el, h('p', { class: 'sh-fehler', 'data-fehler': 'posten', role: 'alert' }), feld('fristTage', 'Frist zum Buchen (Tage)', frist, 'So lange ist der Zeitraum für den Gast reserviert.'), feld('nachricht', 'Nachricht an den Gast (optional)', nachricht), allgemeinerFehler()) const freigeben = h('button', { type: 'button', class: 'kb-btn', 'data-aktion': 'freigeben', onclick: () => sendeFreigabe(a, editor, frist, nachricht, form, freigeben), }, 'Freigeben') const ablehnen = h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'ablehnen', onclick: () => ablehnDialog(a, ablehnen) }, 'Ablehnen') return h('div', null, form, h('div', { class: 'bk-aktionen' }, freigeben, ablehnen), h('div', { class: 'bk-dialog' })) } async function sendeFreigabe(a, editor, frist, nachricht, form, knopf) { const posten = editor.posten() const tage = Number(String(frist.value).trim()) if (!posten) return zeigeFehler(form, { message: '', fehler: { posten: 'Bitte prüfe Menge und Preis der Posten.' } }) if (!Number.isInteger(tage) || tage < 1) return zeigeFehler(form, { message: '', fehler: { fristTage: 'Bitte gib die Frist in ganzen Tagen an.' } }) knopf.disabled = true try { const antwort = await ctx.request(`/api/unterkunft/anfragen/${enc(a.id)}/freigeben?projectId=${enc(ctx.projectId)}`, { method: 'POST', body: { posten, fristTage: tage, nachricht: String(nachricht.value).trim() }, }) if (!ctx.aktuell()) return ctx.zeigeMeldung('Freigegeben. Der Buchungslink wurde an deinen Gast geschickt – falls die Mail nicht ankommt, kannst du ihn hier kopieren.', 'ok') zeigeLink(antwort?.buchungsLink, a) } catch (err) { knopf.disabled = false // 409 heisst nicht immer belegt (auch zustimmung_fehlt, status_ungueltig): nur der Code zeitraum_belegt bekommt den Belegt-Text if (err.status === 409 && err.fehler === 'zeitraum_belegt') zeigeFehler(form, { message: BELEGT, fehler: { _: BELEGT } }) else zeigeFehler(form, fehlerFuerFormular(err)) } } /** Nach „Freigeben“: der Buchungslink zum Kopieren, falls die Mail nicht ankommt (nur wenn die Antwort ihn enthaelt). */ function zeigeLink(link, a) { const box = h('section', { class: 'kb-panel sh-abschnitt bk-link', 'data-freigegeben': a.id }, h('h3', null, `Anfrage ${zeichen(a.nummer)} ist freigegeben`), h('p', null, 'Der Link wurde per Mail verschickt. Falls er nicht ankommt, kopiere ihn hier und schick ihn selbst.')) if (typeof link === 'string' && link) { const meldung = h('p', { class: 'sh-hinweis', role: 'status' }) box.append( h('input', { type: 'text', readonly: true, value: link, 'aria-label': 'Buchungslink', 'data-link': true }), h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'link-kopieren', onclick: async () => { try { await navigator.clipboard.writeText(link) meldung.textContent = 'Link kopiert.' } catch { meldung.textContent = 'Kopieren hat nicht geklappt. Markiere den Link und kopiere ihn von Hand.' } }, }, 'Link kopieren'), meldung) } box.append(h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'zurueck', onclick: () => zeigeListe() }, 'Zur Liste')) inhalt.replaceChildren(box) } function ablehnDialog(a, ausloeser) { const dialogEl = inhalt.querySelector('.bk-dialog') const text = h('textarea', { id: 'bk-a-ablehn', rows: '3', maxlength: '1000' }) const box = frage('Anfrage ablehnen', h('p', { class: 'sh-hinweis' }, 'Der Gast bekommt deinen Text per Mail.'), feld('text', 'Nachricht an den Gast', text), allgemeinerFehler(), h('div', { class: 'bk-aktionen' }, h('button', { type: 'button', class: 'kb-btn', 'data-aktion': 'ablehnen-senden', onclick: async (ev) => { ev.currentTarget.disabled = true try { await ctx.request(`/api/unterkunft/anfragen/${enc(a.id)}/ablehnen?projectId=${enc(ctx.projectId)}`, { method: 'POST', body: { text: String(text.value).trim() } }) if (!ctx.aktuell()) return ctx.zeigeMeldung('Anfrage abgelehnt.', 'ok') await zeigeListe() } catch (err) { ev.currentTarget.disabled = false zeigeFehler(box, fehlerFuerFormular(err)) } }, }, 'Ablehnen'), h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'abbrechen', onclick: () => { dialogEl.replaceChildren(); ausloeser.focus() } }, 'Abbrechen'))) dialogEl.replaceChildren(box) text.focus() } /* ---------------------------------------------- weitere Status: Posten, Rechnung, Bezahlt, Storno --- */ function festerBereich(a) { const posten = Array.isArray(a.posten) ? a.posten : [] const teile = [] if (posten.length) { teile.push(h('table', { class: 'bk-tabelle' }, h('tbody', null, posten.map((p) => h('tr', null, h('td', null, zeichen(p.text)), h('td', null, `${p.menge} ×`), h('td', null, euroMitVorzeichen(p.einzelCent))))))) } if (Number.isInteger(a.summen?.bruttoCent)) teile.push(h('p', { class: 'bk-gesamt' }, h('strong', null, `Gesamt ${euroMitVorzeichen(a.summen.bruttoCent)}`))) const aktionen = h('div', { class: 'bk-aktionen' }) const dialogEl = h('div', { class: 'bk-dialog' }) const aktiv = a.status === 'gebucht' || a.status === 'bezahlt' if (aktiv && a.rechnungId) { teile.push(h('p', null, h('a', { href: `/api/unterkunft/rechnungen/${enc(a.rechnungId)}.pdf?projectId=${enc(ctx.projectId)}`, target: '_blank', rel: 'noopener', 'data-aktion': 'rechnung', }, `Rechnung ${zeichen(a.rechnungNummer)}`.trim() + ' (PDF)'))) } if (a.status === 'gebucht') { aktionen.append(h('button', { type: 'button', class: 'kb-btn', 'data-aktion': 'bezahlt', onclick: (ev) => markiereBezahlt(a, ev.currentTarget) }, 'Als bezahlt (vor Ort) markieren')) } if (aktiv) { const stornoKnopf = h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'stornieren', onclick: () => stornoDialog(a, dialogEl, stornoKnopf) }, 'Stornieren') aktionen.append(stornoKnopf) } return h('div', null, ...teile, aktionen.childNodes.length ? aktionen : null, dialogEl) } async function markiereBezahlt(a, knopf) { knopf.disabled = true try { await ctx.request(`/api/unterkunft/anfragen/${enc(a.id)}/bezahlt?projectId=${enc(ctx.projectId)}`, { method: 'POST' }) if (!ctx.aktuell()) return ctx.zeigeMeldung('Als bezahlt markiert.', 'ok') await zeigeDetail(a.id) } catch (err) { knopf.disabled = false ctx.zeigeMeldung(err.message, 'fehler') } } function stornoDialog(a, dialogEl, ausloeser) { const online = a.bezahltVia === 'stripe' const grund = h('textarea', { id: 'bk-a-grund', rows: '3', maxlength: '500' }) const betrag = h('input', { type: 'text', inputmode: 'decimal', id: 'bk-a-erstattung', autocomplete: 'off' }) betrag.value = Number.isInteger(a.summen?.bruttoCent) ? centZuEuro(a.summen.bruttoCent - (a.erstattetCent || 0)) : '' const box = frage('Buchung stornieren', h('p', { class: 'sh-hinweis' }, 'Der Gast bekommt eine Stornomail und eine Storno-Rechnung.'), feld('grund', 'Grund', grund), online ? feld('erstattungCent', 'Erstattung in Euro', betrag, 'Der Betrag geht an die Karte des Gastes zurück. 0 = keine Erstattung.') : null, allgemeinerFehler(), h('div', { class: 'bk-aktionen' }, h('button', { type: 'button', class: 'kb-btn', 'data-aktion': 'storno-senden', onclick: async (ev) => { const knopf = ev.currentTarget let erstattungCent = 0 if (online) { erstattungCent = String(betrag.value).trim() === '' ? 0 : euroZuCent(String(betrag.value)) if (erstattungCent === null) return zeigeFehler(box, { message: '', fehler: { erstattungCent: 'Bitte gib einen Betrag wie 120,00 an.' } }) } knopf.disabled = true try { await ctx.request(`/api/unterkunft/anfragen/${enc(a.id)}/stornieren?projectId=${enc(ctx.projectId)}`, { method: 'POST', body: { grund: String(grund.value).trim(), erstattungCent } }) if (!ctx.aktuell()) return ctx.zeigeMeldung('Die Buchung ist storniert.', 'ok') await zeigeDetail(a.id) } catch (err) { knopf.disabled = false if (err.fehler === 'erstattung_fehlgeschlagen' || err.body?.fehler === 'erstattung_fehlgeschlagen') { zeigeFehler(box, { message: 'Die Buchung ist storniert, aber die Erstattung ist fehlgeschlagen. Bitte versuch die Erstattung gleich noch einmal.', fehler: {} }) } else zeigeFehler(box, fehlerFuerFormular(err)) } }, }, 'Stornieren'), h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'abbrechen', onclick: () => { dialogEl.replaceChildren(); ausloeser.focus() } }, 'Abbrechen'))) dialogEl.replaceChildren(box) grund.focus() } }