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,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()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user