Files
Webklar-Kundenbereich/public/buchungen-tab-anfragen.js
T

303 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Buchungskalender, Reiter „Anfragen“: Filter (offen | gebucht | alle), Liste, Detail.
API (docs/funktionen/unterkunft-api.md, alle mit ?projectId=):
GET /api/unterkunft/anfragen?filter= => { anfragen:[{ id, nummer, von, bis, naechte?, gast:{name}, status, summen:{bruttoCent}, unterkunftName? }] }
GET /api/unterkunft/anfragen/:id => { anfrage:{ id, nummer, von, bis, personen, status, gast:{name,firma,email,telefon,nachricht?}, posten, summen,
steuerart, zahlart?, bezahltVia?, erstattetCent?, rechnungId?, rechnungNummer?, fristAt?, unterkunftName? }, vorschlag:{ naechte, posten, summen } }
POST …/freigeben {posten,fristTage,nachricht} (=> { buchungsLink? }), …/ablehnen {text}, …/bezahlt, …/stornieren {grund,erstattungCent}.
Fremdtext (Gast, Nachricht) nur ueber textContent. render(panel, ctx) wie shop-tab-*.js. */
import { abschnitt, allgemeinerFehler, centZuEuro, euroZuCent, fehlerKarte, feld, frage, h, zeigeFehler, datumText } from './shop-dom.js'
import { fehlerFuerFormular } from './buchungen-fehler.js'
import { baueEditor, euroMitVorzeichen } from './buchungen-posten.js'
export const STATUS = {
neu: ['Neu', 'wait'],
freigegeben: ['Freigegeben – wartet auf Buchung', 'wait'],
gebucht: ['Gebucht', 'ok'],
bezahlt: ['Bezahlt', 'ok'],
abgelehnt: ['Abgelehnt', 'bad'],
abgelaufen: ['Abgelaufen', 'off'],
storniert: ['Storniert', 'bad'],
}
const FILTER = [['offen', 'Offen'], ['gebucht', 'Gebucht'], ['alle', 'Alle']]
const FEHLER_LADEN = 'Das konnte nicht geladen werden. Bitte versuch es noch einmal.'
const BELEGT = 'Der Zeitraum ist inzwischen belegt.'
const zeichen = (x) => (typeof x === 'string' ? x : '')
const enc = encodeURIComponent
const pille = (status) => {
const [text, art] = STATUS[status] || [zeichen(status) || 'Unbekannt', 'off']
return h('span', { class: `sh-pille sh-pille-${art}`, 'data-status': status }, text)
}
/** '2026-11-12','2026-11-30' ⇒ '12.–30.11.2026'; über Monats-/Jahresgrenze '28.10.–03.11.2026' bzw. '28.12.2026–03.01.2027'. */
export function zeitraumText(von, bis) {
const m = /^(\d{4})-(\d{2})-(\d{2})$/
const a = m.exec(von || '')
const b = m.exec(bis || '')
if (!a || !b) return ''
if (a[1] !== b[1]) return `${a[3]}.${a[2]}.${a[1]}–${b[3]}.${b[2]}.${b[1]}`
if (a[2] !== b[2]) return `${a[3]}.${a[2]}.–${b[3]}.${b[2]}.${b[1]}`
return `${a[3]}.–${b[3]}.${b[2]}.${b[1]}`
}
export function naechteText(von, bis) {
const ms = (t) => Date.UTC(...t.split('-').map((x, i) => (i === 1 ? Number(x) - 1 : Number(x))))
const n = Math.round((ms(bis) - ms(von)) / 86400000)
return Number.isFinite(n) ? `${n} ${n === 1 ? 'Nacht' : 'Nächte'}` : ''
}
const gastName = (gast) => (typeof gast === 'string' ? gast : zeichen(gast?.name))
export async function render(panel, ctx) {
const st = { filter: 'offen', lauf: 0 }
const kopf = h('div', { class: 'bk-filter' }, h('label', { for: 'bk-a-filter' }, 'Anzeigen'),
h('select', { id: 'bk-a-filter', 'data-aktion': 'filter' }, FILTER.map(([wert, text]) => h('option', { value: wert }, text))))
const inhalt = h('div', { class: 'bk-anfragen' })
panel.replaceChildren(kopf, inhalt)
const select = kopf.querySelector('select')
select.value = st.filter
select.addEventListener('change', () => { st.filter = select.value; zeigeListe() })
await zeigeListe()
async function zeigeListe() {
const lauf = ++st.lauf
const gueltig = () => st.lauf === lauf && ctx.aktuell()
inhalt.replaceChildren(h('p', { class: 'kb-empty' }, 'Lade…'))
let daten
try {
daten = await ctx.request(`/api/unterkunft/anfragen?projectId=${enc(ctx.projectId)}&filter=${enc(st.filter)}`)
} catch (err) {
if (gueltig()) inhalt.replaceChildren(fehlerKarte(err.message || FEHLER_LADEN, zeigeListe))
return
}
if (!gueltig()) return
const zeilen = Array.isArray(daten?.anfragen) ? daten.anfragen : []
if (!zeilen.length) {
inhalt.replaceChildren(h('div', { class: 'kb-panel kb-empty', 'data-leer': true }, st.filter === 'offen' ? 'Keine offenen Anfragen.' : 'Keine Anfragen.'))
return
}
inhalt.replaceChildren(h('ul', { class: 'bk-liste' }, zeilen.map((a) => h('li', null, h('button', {
type: 'button', class: 'bk-zeile', 'data-anfrage': a.id, onclick: () => zeigeDetail(a.id),
},
h('strong', { class: 'bk-nummer' }, zeichen(a.nummer)),
h('span', { class: 'bk-zeitraum' }, `${zeitraumText(a.von, a.bis)} · ${naechteText(a.von, a.bis)}`),
h('span', { class: 'bk-gast' }, gastName(a.gast)),
pille(a.status),
h('span', { class: 'bk-betrag' }, Number.isInteger(a.summen?.bruttoCent) ? euroMitVorzeichen(a.summen.bruttoCent) : ''))))))
}
async function zeigeDetail(id) {
const lauf = ++st.lauf
const gueltig = () => st.lauf === lauf && ctx.aktuell()
inhalt.replaceChildren(h('p', { class: 'kb-empty' }, 'Lade…'))
let daten
try {
daten = await ctx.request(`/api/unterkunft/anfragen/${enc(id)}?projectId=${enc(ctx.projectId)}`)
} catch (err) {
if (gueltig()) inhalt.replaceChildren(zurueck(), fehlerKarte(err.message || FEHLER_LADEN, () => zeigeDetail(id)))
return
}
if (!gueltig()) return
if (!daten?.anfrage) {
inhalt.replaceChildren(zurueck(), fehlerKarte(FEHLER_LADEN, () => zeigeDetail(id)))
return
}
inhalt.replaceChildren(zurueck(), baueDetail(daten.anfrage, daten.vorschlag))
}
const zurueck = () => h('button', { type: 'button', class: 'kb-btn-outline bk-zurueck', 'data-aktion': 'zurueck', onclick: () => zeigeListe() }, '‹ Zur Liste')
function baueDetail(a, vorschlag) {
const gast = a.gast && typeof a.gast === 'object' ? a.gast : { name: zeichen(a.gast) }
const kopfZeile = h('div', { class: 'bk-detail-kopf' },
h('h3', null, `Anfrage ${zeichen(a.nummer)}`), pille(a.status))
const daten = h('dl', { class: 'bk-daten' },
...[
['Unterkunft', zeichen(a.unterkunftName)],
['Zeitraum', `${zeitraumText(a.von, a.bis)} · ${naechteText(a.von, a.bis)}`],
['Personen', Number.isInteger(a.personen) ? String(a.personen) : ''],
['Gast', gastName(gast)],
['Firma', zeichen(gast.firma)],
['E-Mail', zeichen(gast.email)],
['Telefon', zeichen(gast.telefon)],
['Nachricht', zeichen(gast.nachricht)],
['Frist', a.fristAt && a.status === 'freigegeben' ? datumText(a.fristAt) : ''],
].filter(([, w]) => w).flatMap(([n, w]) => [h('dt', null, n), h('dd', null, w)]))
const karte = h('section', { class: 'kb-panel sh-abschnitt bk-detail', 'data-detail': a.id }, kopfZeile, daten)
if (a.status === 'neu') karte.append(neuBereich(a, vorschlag))
else karte.append(festerBereich(a))
return karte
}
/* ---------------------------------------------- Status neu: Freigeben / Ablehnen --- */
function neuBereich(a, vorschlag) {
const steuerart = a.steuerart || vorschlag?.steuerart || ctx.status?.steuerart || 'regelbesteuert'
const startPosten = Array.isArray(vorschlag?.posten) ? vorschlag.posten : []
const editor = baueEditor(startPosten, steuerart)
const frist = h('input', { type: 'text', inputmode: 'numeric', id: 'bk-a-frist' })
frist.value = '7'
const nachricht = h('textarea', { id: 'bk-a-nachricht', rows: '3', maxlength: '2000' })
const form = h('form', { class: 'bk-freigabe', novalidate: true, onsubmit: (ev) => ev.preventDefault() },
h('h4', null, 'Angebot für den Gast'),
editor.el,
h('p', { class: 'sh-fehler', 'data-fehler': 'posten', role: 'alert' }),
feld('fristTage', 'Frist zum Buchen (Tage)', frist, 'So lange ist der Zeitraum für den Gast reserviert.'),
feld('nachricht', 'Nachricht an den Gast (optional)', nachricht),
allgemeinerFehler())
const freigeben = h('button', {
type: 'button', class: 'kb-btn', 'data-aktion': 'freigeben',
onclick: () => sendeFreigabe(a, editor, frist, nachricht, form, freigeben),
}, 'Freigeben')
const ablehnen = h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'ablehnen', onclick: () => ablehnDialog(a, ablehnen) }, 'Ablehnen')
return h('div', null, form, h('div', { class: 'bk-aktionen' }, freigeben, ablehnen), h('div', { class: 'bk-dialog' }))
}
async function sendeFreigabe(a, editor, frist, nachricht, form, knopf) {
const posten = editor.posten()
const tage = Number(String(frist.value).trim())
if (!posten) return zeigeFehler(form, { message: '', fehler: { posten: 'Bitte prüfe Menge und Preis der Posten.' } })
if (!Number.isInteger(tage) || tage < 1) return zeigeFehler(form, { message: '', fehler: { fristTage: 'Bitte gib die Frist in ganzen Tagen an.' } })
knopf.disabled = true
try {
const antwort = await ctx.request(`/api/unterkunft/anfragen/${enc(a.id)}/freigeben?projectId=${enc(ctx.projectId)}`, {
method: 'POST', body: { posten, fristTage: tage, nachricht: String(nachricht.value).trim() },
})
if (!ctx.aktuell()) return
ctx.zeigeMeldung('Freigegeben. Der Buchungslink wurde an deinen Gast geschickt – falls die Mail nicht ankommt, kannst du ihn hier kopieren.', 'ok')
zeigeLink(antwort?.buchungsLink, a)
} catch (err) {
knopf.disabled = false
if (err.status === 409) zeigeFehler(form, { message: BELEGT, fehler: { _: BELEGT } })
else zeigeFehler(form, fehlerFuerFormular(err))
}
}
/** Nach „Freigeben“: der Buchungslink zum Kopieren, falls die Mail nicht ankommt (nur wenn die Antwort ihn enthaelt). */
function zeigeLink(link, a) {
const box = h('section', { class: 'kb-panel sh-abschnitt bk-link', 'data-freigegeben': a.id },
h('h3', null, `Anfrage ${zeichen(a.nummer)} ist freigegeben`),
h('p', null, 'Der Link wurde per Mail verschickt. Falls er nicht ankommt, kopiere ihn hier und schick ihn selbst.'))
if (typeof link === 'string' && link) {
const meldung = h('p', { class: 'sh-hinweis', role: 'status' })
box.append(
h('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, 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()
}
}