Bisher stand bei jedem 409 „Der Zeitraum ist inzwischen belegt.“, auch wenn der Rechnungsauftrag fehlte (zustimmung_fehlt) oder sich der Stand geändert hatte (status_ungueltig). Jetzt nur bei zeitraum_belegt; sonst der Text aus buchungen-fehler.js. Aufgefallen beim ersten Test mit Donau Home. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
303 lines
16 KiB
JavaScript
303 lines
16 KiB
JavaScript
/* 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()
|
||
}
|
||
}
|