Buchungskalender: Kalender und Unterkünfte im Portal
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3ecea05b52
commit
db9fc61894
8 files changed
+814
-8
No files matched your search
@@ -0,0 +1,15 @@
|
||||
/* Buchungskalender: Fehler der Schnittstelle fuers Formular. Die Schnittstelle antwortet { fehler: '<code>', felder?: {feld: text} };
|
||||
request() (shop-dom.js) legt den Code in err.fehler und die ganze Antwort in err.body. Fuer zeigeFehler() daher vorher umformen:
|
||||
zeigeFehler(form, fehlerFuerFormular(err)). */
|
||||
const CODES = {
|
||||
zeitraum_belegt: 'Der Zeitraum ist bereits belegt.',
|
||||
zeitraum_ungueltig: 'Bitte prüfe den Zeitraum.',
|
||||
}
|
||||
|
||||
/** => { message, fehler: { feld: text } } (Feldfehler aus body.felder, sonst ein Text unter „_“). */
|
||||
export function fehlerFuerFormular(err) {
|
||||
const felder = err?.body?.felder
|
||||
const message = (typeof err?.fehler === 'string' && CODES[err.fehler]) || err?.message || 'Das hat nicht geklappt. Bitte versuch es noch einmal.'
|
||||
if (felder && typeof felder === 'object' && Object.keys(felder).length) return { message, fehler: felder }
|
||||
return { message, fehler: { _: message } }
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/* Buchungskalender: iCal-Bereich einer Unterkunft (Importe von Booking.com/Airbnb, Export-Link, Kurzanleitungen).
|
||||
unterkunft = Eintrag aus GET /api/unterkunft/unterkuenfte: { id, icalImporte:[{ id, name, url, letzterAbrufAt, letzterErfolgAt, fehler }], icalExportUrl }.
|
||||
POST /unterkuenfte/:id/ical-importe { name, url } · DELETE …/ical-importe/:importId · POST …/ical-export/erneuern => { icalExportUrl }.
|
||||
Nach jeder Aenderung ruft der Aufrufer aenderung() (laedt die Unterkunft neu). */
|
||||
|
||||
import { abschnitt, allgemeinerFehler, datumText, feld, frage, h, zeigeFehler } from './shop-dom.js'
|
||||
import { enc, zeichen, zeigeApiFehler } from './buchungen-uk-hilfe.js'
|
||||
|
||||
export function zeichneIcal(ctx, unterkunft, aenderung) {
|
||||
const basis = `/api/unterkunft/unterkuenfte/${enc(unterkunft.id)}`
|
||||
const q = `projectId=${enc(ctx.projectId)}`
|
||||
const importe = Array.isArray(unterkunft.icalImporte) ? unterkunft.icalImporte : []
|
||||
const dialog = h('div', { class: 'bk-dialog' })
|
||||
|
||||
const status = (i) => {
|
||||
if (i.fehler) return `Der letzte Abgleich hat nicht geklappt: ${zeichen(i.fehler)}${i.letzterErfolgAt ? ` (zuletzt erfolgreich ${datumText(i.letzterErfolgAt)})` : ''}`
|
||||
if (i.letzterErfolgAt) return `Zuletzt abgeglichen ${datumText(i.letzterErfolgAt)}`
|
||||
return 'Noch nicht abgeglichen. Das passiert automatisch alle 15 Minuten.'
|
||||
}
|
||||
const importListe = importe.length
|
||||
? h('ul', { class: 'bk-liste' }, importe.map((i) => h('li', { class: 'bk-import', 'data-import': i.id },
|
||||
h('strong', null, zeichen(i.name)),
|
||||
h('span', { class: i.fehler ? 'sh-fehler' : 'sh-hinweis', 'data-status': true }, status(i)),
|
||||
ctx.nurLesen ? null : h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'import-weg', onclick: () => entfernenDialog(i) }, 'Entfernen'))))
|
||||
: h('p', { class: 'sh-hinweis' }, 'Noch kein Kalender angebunden.')
|
||||
|
||||
function entfernenDialog(i) {
|
||||
const box = frage('Kalender entfernen', h('p', null, `Die Belegung aus „${zeichen(i.name)}“ verschwindet dann aus deinem Kalender.`), allgemeinerFehler(),
|
||||
h('div', { class: 'bk-aktionen' },
|
||||
h('button', {
|
||||
type: 'button', class: 'kb-btn', 'data-aktion': 'import-weg-ok',
|
||||
onclick: async (ev) => {
|
||||
ev.currentTarget.disabled = true
|
||||
try {
|
||||
await ctx.request(`${basis}/ical-importe/${enc(i.id)}?${q}`, { method: 'DELETE' })
|
||||
if (ctx.aktuell()) await aenderung('Der Kalender ist entfernt.')
|
||||
} catch (err) { ev.currentTarget.disabled = false; zeigeApiFehler(box, err) }
|
||||
},
|
||||
}, 'Entfernen'),
|
||||
h('button', { type: 'button', class: 'kb-btn-outline', onclick: () => dialog.replaceChildren() }, 'Abbrechen')))
|
||||
dialog.replaceChildren(box)
|
||||
}
|
||||
|
||||
const name = h('input', { type: 'text', id: 'bk-i-name', maxlength: '60', autocomplete: 'off', placeholder: 'Booking.com' })
|
||||
const url = h('input', { type: 'text', id: 'bk-i-url', inputmode: 'url', autocomplete: 'off', placeholder: 'https://…' })
|
||||
const neu = ctx.nurLesen ? null : h('form', { novalidate: true, class: 'bk-importform', onsubmit: (ev) => ev.preventDefault() },
|
||||
feld('name', 'Name', name), feld('url', 'Link (iCal-Adresse)', url, 'Der Link beginnt mit https:// und endet oft auf .ics.'), allgemeinerFehler(),
|
||||
h('button', {
|
||||
type: 'button', class: 'kb-btn', 'data-aktion': 'import-dazu',
|
||||
onclick: async (ev) => {
|
||||
const form = ev.currentTarget.form || ev.currentTarget.closest('form')
|
||||
const body = { name: String(name.value).trim(), url: String(url.value).trim() }
|
||||
if (!body.name || !/^https:\/\//i.test(body.url)) return zeigeFehler(form, { message: '', fehler: { _: 'Bitte gib einen Namen und einen Link an, der mit https:// beginnt.' } })
|
||||
ev.currentTarget.disabled = true
|
||||
try {
|
||||
await ctx.request(`${basis}/ical-importe?${q}`, { method: 'POST', body })
|
||||
if (ctx.aktuell()) await aenderung('Der Kalender ist angebunden.')
|
||||
} catch (err) { ev.currentTarget.disabled = false; zeigeApiFehler(form, err) }
|
||||
},
|
||||
}, 'Kalender hinzufügen'))
|
||||
|
||||
const exportUrl = zeichen(unterkunft.icalExportUrl)
|
||||
const kopiert = h('p', { class: 'sh-hinweis', role: 'status', 'data-kopiert': true })
|
||||
const erneuern = () => {
|
||||
const box = frage('Link erneuern', h('p', null, 'Der alte Link funktioniert danach nicht mehr. Du musst den neuen Link bei Booking.com und Airbnb eintragen.'), allgemeinerFehler(),
|
||||
h('div', { class: 'bk-aktionen' },
|
||||
h('button', {
|
||||
type: 'button', class: 'kb-btn', 'data-aktion': 'erneuern-ok',
|
||||
onclick: async (ev) => {
|
||||
ev.currentTarget.disabled = true
|
||||
try {
|
||||
await ctx.request(`${basis}/ical-export/erneuern?${q}`, { method: 'POST' })
|
||||
if (ctx.aktuell()) await aenderung('Der Link ist erneuert. Trage den neuen Link bei den Portalen ein.')
|
||||
} catch (err) { ev.currentTarget.disabled = false; zeigeApiFehler(box, err) }
|
||||
},
|
||||
}, 'Link erneuern'),
|
||||
h('button', { type: 'button', class: 'kb-btn-outline', onclick: () => dialog.replaceChildren() }, 'Abbrechen')))
|
||||
dialog.replaceChildren(box)
|
||||
}
|
||||
const export_ = h('div', { class: 'bk-link' },
|
||||
h('p', { class: 'sh-hinweis' }, 'Mit diesem Link sehen Booking.com und Airbnb, wann deine Unterkunft belegt ist. Es werden keine Gastdaten weitergegeben.'),
|
||||
exportUrl ? h('input', { type: 'text', readonly: true, value: exportUrl, 'aria-label': 'Export-Link', 'data-export': true }) : h('p', { class: 'sh-hinweis' }, 'Der Link ist noch nicht verfügbar.'),
|
||||
h('div', { class: 'bk-aktionen' },
|
||||
exportUrl ? h('button', {
|
||||
type: 'button', class: 'kb-btn-outline', 'data-aktion': 'export-kopieren',
|
||||
onclick: async () => {
|
||||
try { await navigator.clipboard.writeText(exportUrl); kopiert.textContent = 'Link kopiert.' } catch { kopiert.textContent = 'Kopieren hat nicht geklappt. Markiere den Link und kopiere ihn von Hand.' }
|
||||
},
|
||||
}, 'Kopieren') : null,
|
||||
ctx.nurLesen ? null : h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'export-erneuern', onclick: erneuern }, 'Link erneuern')),
|
||||
kopiert)
|
||||
|
||||
const anleitung = h('div', { class: 'bk-anleitung' },
|
||||
h('details', { 'data-anleitung': 'booking' }, h('summary', null, 'Anleitung Booking.com'),
|
||||
h('ol', null,
|
||||
h('li', null, 'Unterkunft → Tarife & Verfügbarkeit → Kalender synchronisieren → Kalender importieren → Link einfügen.'),
|
||||
h('li', null, 'Den Export-Link von oben dort einfügen und speichern.'),
|
||||
h('li', null, 'Für die andere Richtung: Booking.com zeigt dir dort auch einen Link zum Exportieren. Den trägst du oben bei „Kalender anbinden“ ein.'))),
|
||||
h('details', { 'data-anleitung': 'airbnb' }, h('summary', null, 'Anleitung Airbnb'),
|
||||
h('ol', null,
|
||||
h('li', null, 'Inserat → Verfügbarkeit → Kalender synchronisieren → Kalender verbinden.'),
|
||||
h('li', null, 'Den Export-Link von oben dort einfügen und speichern.'),
|
||||
h('li', null, 'Airbnb zeigt dir unter „Kalender exportieren“ einen Link. Den trägst du oben bei „Kalender anbinden“ ein.'))))
|
||||
|
||||
return abschnitt('Kalender-Abgleich (iCal)', 'So verhinderst du Doppelbuchungen: Belegungen von Booking.com oder Airbnb kommen in deinen Kalender, und deine Belegungen gehen dorthin.',
|
||||
h('h4', null, 'Kalender anbinden'), importListe, neu,
|
||||
h('h4', null, 'Dein Export-Link'), export_, anleitung, dialog)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/* Buchungskalender: Preis-Editor einer Unterkunft (Staffeln, Reinigung, Zusatzpersonen, Rabatt, Steuer, Frist, Stornobedingungen) mit Beispielrechnung.
|
||||
GET|PUT /api/unterkunft/unterkuenfte/:id/preise => { preise:{ staffeln:[{abNaechten,preisProNachtCent}], reinigungCent, personenInklusive, aufpreisPersonNachtCent,
|
||||
direktrabattProzent, steuerart:'kleinunternehmer'|'regel', ustUebernachtung, ustReinigung, fristTage, stornobedingungen } | null }
|
||||
POST /api/unterkunft/preisvorschau { unterkunftId, von, bis, personen } => { naechte, posten, summen:{ bruttoCent } } (rechnet mit den gespeicherten Preisen). */
|
||||
|
||||
import { abschnitt, allgemeinerFehler, euroEingabe, euroText, feld, h, setzeFehler, zeigeFehler } from './shop-dom.js'
|
||||
import { enc, heuteBerlin, plusTage, zeichen, zeigeApiFehler } from './buchungen-uk-hilfe.js'
|
||||
|
||||
const STANDARD = { staffeln: [{ abNaechten: 1, preisProNachtCent: null }], reinigungCent: 0, personenInklusive: 2, aufpreisPersonNachtCent: 0, direktrabattProzent: 0, steuerart: 'kleinunternehmer', ustUebernachtung: 7, ustReinigung: 7, fristTage: 3, stornobedingungen: '' }
|
||||
const ganzzahl = (steuer) => { const t = String(steuer.value).trim(); return /^\d{1,9}$/.test(t) ? Number(t) : null }
|
||||
|
||||
export async function zeichnePreise(ctx, unterkunft) {
|
||||
const basis = `/api/unterkunft/unterkuenfte/${enc(unterkunft.id)}`
|
||||
const q = `projectId=${enc(ctx.projectId)}`
|
||||
const wurzel = h('div', { class: 'bk-preise' }, h('p', { class: 'kb-empty' }, 'Lade…'))
|
||||
let preise = STANDARD
|
||||
try {
|
||||
const daten = await ctx.request(`${basis}/preise?${q}`)
|
||||
if (daten?.preise) preise = { ...STANDARD, ...daten.preise }
|
||||
} catch (err) {
|
||||
wurzel.replaceChildren(h('p', { class: 'sh-fehler', role: 'alert' }, err.message))
|
||||
return wurzel
|
||||
}
|
||||
const zeilenEl = h('div', { class: 'bk-staffeln' })
|
||||
const zeile = (s) => {
|
||||
const ab = h('input', { type: 'text', inputmode: 'numeric', 'aria-label': 'Ab Nächten', 'data-staffel': 'ab', autocomplete: 'off' })
|
||||
ab.value = String(s.abNaechten ?? '')
|
||||
const preis = euroEingabe({ cent: s.preisProNachtCent, label: 'Preis pro Nacht in Euro' })
|
||||
preis.dataset.staffel = 'preis'
|
||||
const el = h('div', { class: 'bk-staffel', 'data-staffel-zeile': true }, 'ab ', ab, ' Nächten: ', preis, ' € pro Nacht ',
|
||||
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'staffel-weg', 'aria-label': 'Staffel entfernen', onclick: () => { if (zeilenEl.children.length > 1) { el.remove(); halteErste() } } }, '−'))
|
||||
return el
|
||||
}
|
||||
const halteErste = () => { const ab = zeilenEl.querySelector('[data-staffel="ab"]'); if (ab) { ab.value = '1'; ab.readOnly = true; ab.setAttribute('readonly', '') } }
|
||||
zeilenEl.append(...(preise.staffeln?.length ? preise.staffeln : STANDARD.staffeln).map(zeile))
|
||||
halteErste()
|
||||
const reinigung = euroEingabe({ cent: preise.reinigungCent, id: 'bk-p-reinigung' })
|
||||
const inklusive = h('input', { type: 'text', inputmode: 'numeric', id: 'bk-p-inklusive' }); inklusive.value = String(preise.personenInklusive ?? 2)
|
||||
const aufpreis = euroEingabe({ cent: preise.aufpreisPersonNachtCent, id: 'bk-p-aufpreis' })
|
||||
const rabatt = h('input', { type: 'text', inputmode: 'numeric', id: 'bk-p-rabatt' }); rabatt.value = String(preise.direktrabattProzent ?? 0)
|
||||
const steuerart = h('select', { id: 'bk-p-steuerart' }, [['kleinunternehmer', 'Kleinunternehmer (keine Umsatzsteuer)'], ['regel', 'Regelbesteuerung (mit Umsatzsteuer)']].map(([w, t]) => h('option', { value: w }, t)))
|
||||
steuerart.value = preise.steuerart === 'regel' ? 'regel' : 'kleinunternehmer'
|
||||
const ustUe = h('input', { type: 'text', inputmode: 'numeric', id: 'bk-p-ust-ue' }); ustUe.value = String(preise.ustUebernachtung ?? 7)
|
||||
const ustRe = h('input', { type: 'text', inputmode: 'numeric', id: 'bk-p-ust-re' }); ustRe.value = String(preise.ustReinigung ?? 7)
|
||||
const frist = h('input', { type: 'text', inputmode: 'numeric', id: 'bk-p-frist' }); frist.value = String(preise.fristTage ?? 3)
|
||||
const storno = h('textarea', { id: 'bk-p-storno', rows: '6', maxlength: '5000' }); storno.value = zeichen(preise.stornobedingungen)
|
||||
const ustFelder = h('div', { class: 'bk-ust' }, feld('ustUebernachtung', 'USt Übernachtung in %', ustUe), feld('ustReinigung', 'USt Endreinigung in %', ustRe))
|
||||
const zeigeUst = () => { ustFelder.hidden = steuerart.value !== 'regel' }
|
||||
steuerart.addEventListener('change', zeigeUst)
|
||||
zeigeUst()
|
||||
|
||||
const vorschauText = h('p', { class: 'bk-vorschau', 'data-vorschau': true, role: 'status' })
|
||||
const naechteEl = h('input', { type: 'text', inputmode: 'numeric', id: 'bk-p-vn' }); naechteEl.value = '7'
|
||||
const personenEl = h('input', { type: 'text', inputmode: 'numeric', id: 'bk-p-vp' }); personenEl.value = '2'
|
||||
async function vorschau() {
|
||||
const naechte = ganzzahl(naechteEl)
|
||||
const personen = ganzzahl(personenEl)
|
||||
if (!naechte || !personen) { vorschauText.textContent = 'Gib Nächte und Personen als ganze Zahlen an.'; return }
|
||||
const von = plusTage(heuteBerlin(), 60)
|
||||
try {
|
||||
const antwort = await ctx.request(`/api/unterkunft/preisvorschau?${q}`, { method: 'POST', body: { unterkunftId: unterkunft.id, von, bis: plusTage(von, naechte), personen } })
|
||||
if (!ctx.aktuell()) return
|
||||
vorschauText.textContent = Number.isInteger(antwort?.summen?.bruttoCent)
|
||||
? `Beispiel: ${antwort.naechte ?? naechte} ${(antwort.naechte ?? naechte) === 1 ? 'Nacht' : 'Nächte'}, ${personen} ${personen === 1 ? 'Person' : 'Personen'} = ${euroText(antwort.summen.bruttoCent)}`
|
||||
: 'Für diese Angaben gibt es noch keine Beispielrechnung.'
|
||||
} catch (err) {
|
||||
vorschauText.textContent = Object.values(err.body?.felder || {})[0] || 'Die Beispielrechnung klappt erst, wenn die Preise gespeichert sind.'
|
||||
}
|
||||
}
|
||||
for (const el of [naechteEl, personenEl]) el.addEventListener('change', vorschau)
|
||||
|
||||
const form = h('form', { novalidate: true, class: 'bk-preisform', onsubmit: (ev) => ev.preventDefault() },
|
||||
h('h4', null, 'Preis pro Nacht (Staffeln)'),
|
||||
h('p', { class: 'sh-hinweis' }, 'Je länger der Aufenthalt, desto günstiger die Nacht: Die passende Staffel gilt für alle Nächte der Buchung.'),
|
||||
zeilenEl, h('div', { class: 'sh-fehler', 'data-fehler': 'staffeln', role: 'alert' }),
|
||||
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'staffel-dazu', onclick: () => { zeilenEl.append(zeile({ abNaechten: null, preisProNachtCent: null })) } }, '+ Staffel'),
|
||||
feld('reinigungCent', 'Endreinigung in Euro', reinigung),
|
||||
feld('personenInklusive', 'Personen im Preis inklusive', inklusive),
|
||||
feld('aufpreisPersonNachtCent', 'Aufpreis je weiterer Person und Nacht in Euro', aufpreis),
|
||||
feld('direktrabattProzent', 'Direktbuchungsrabatt in %', rabatt, 'Ganze Zahl von 0 bis 50. Gilt für Übernachtung und Zusatzpersonen, nicht für die Reinigung.'),
|
||||
feld('steuerart', 'Steuer', steuerart), ustFelder,
|
||||
feld('fristTage', 'Frist für die Buchung nach Freigabe in Tagen', frist, '1 bis 14 Tage.'),
|
||||
feld('stornobedingungen', 'Stornobedingungen', storno, 'Pflicht, bevor du den Buchungskalender freischaltest. Sie stehen auf der Buchungsseite.'),
|
||||
allgemeinerFehler(),
|
||||
h('div', { class: 'bk-aktionen' }, ctx.nurLesen ? null : h('button', { type: 'button', class: 'kb-btn', 'data-aktion': 'preise-speichern', onclick: speichern }, 'Preise speichern')))
|
||||
if (ctx.nurLesen) for (const el of form.querySelectorAll('input, select, textarea, button')) el.disabled = true
|
||||
|
||||
async function speichern(ev) {
|
||||
const knopf = ev.currentTarget
|
||||
const fehler = {}
|
||||
const staffeln = []
|
||||
for (const z of zeilenEl.children) {
|
||||
const ab = ganzzahl(z.querySelector('[data-staffel="ab"]'))
|
||||
const p = z.querySelector('[data-staffel="preis"]').inCent()
|
||||
if (ab === null || ab < 1 || p === null) fehler.staffeln = 'Bitte gib bei jeder Staffel Nächte (ganze Zahl) und einen Preis an.'
|
||||
else staffeln.push({ abNaechten: ab, preisProNachtCent: p })
|
||||
}
|
||||
const zahl = (name, steuer, text) => { const n = ganzzahl(steuer); if (n === null) fehler[name] = text; return n }
|
||||
const reinigungCent = reinigung.istLeer() ? 0 : reinigung.inCent()
|
||||
if (reinigungCent === null) fehler.reinigungCent = 'Bitte gib einen Betrag wie 60,00 an.'
|
||||
const aufpreisCent = aufpreis.istLeer() ? 0 : aufpreis.inCent()
|
||||
if (aufpreisCent === null) fehler.aufpreisPersonNachtCent = 'Bitte gib einen Betrag wie 15,00 an.'
|
||||
const body = {
|
||||
staffeln, reinigungCent, aufpreisPersonNachtCent: aufpreisCent,
|
||||
personenInklusive: zahl('personenInklusive', inklusive, 'Bitte gib eine ganze Zahl an.'),
|
||||
direktrabattProzent: zahl('direktrabattProzent', rabatt, 'Bitte gib eine ganze Zahl von 0 bis 50 an.'),
|
||||
steuerart: steuerart.value,
|
||||
ustUebernachtung: zahl('ustUebernachtung', ustUe, 'Bitte gib eine ganze Zahl an.'),
|
||||
ustReinigung: zahl('ustReinigung', ustRe, 'Bitte gib eine ganze Zahl an.'),
|
||||
fristTage: zahl('fristTage', frist, 'Bitte gib eine ganze Zahl von 1 bis 14 an.'),
|
||||
stornobedingungen: String(storno.value).trim(),
|
||||
}
|
||||
if (steuerart.value !== 'regel') { delete fehler.ustUebernachtung; delete fehler.ustReinigung; body.ustUebernachtung = 7; body.ustReinigung = 7 }
|
||||
if (Object.keys(fehler).length) return zeigeFehler(form, { message: '', fehler })
|
||||
knopf.disabled = true
|
||||
try {
|
||||
await ctx.request(`${basis}/preise?${q}`, { method: 'PUT', body })
|
||||
if (!ctx.aktuell()) return
|
||||
setzeFehler(form, {})
|
||||
ctx.zeigeMeldung('Die Preise sind gespeichert.', 'ok')
|
||||
await vorschau()
|
||||
} catch (err) {
|
||||
zeigeApiFehler(form, err)
|
||||
}
|
||||
knopf.disabled = false
|
||||
}
|
||||
|
||||
wurzel.replaceChildren(form,
|
||||
abschnitt('Beispielrechnung', 'So rechnet dein Gast. Sie nutzt die gespeicherten Preise.',
|
||||
h('div', { class: 'bk-vorschaufelder' }, feld('naechte', 'Nächte', naechteEl), feld('personen', 'Personen', personenEl)), vorschauText))
|
||||
vorschau()
|
||||
return wurzel
|
||||
}
|
||||
@@ -1,6 +1,185 @@
|
||||
/* Buchungskalender, Reiter „Kalender“: Platzhalter (Task 13/14 fuellt ihn). */
|
||||
import { infoKarte } from './shop-dom.js'
|
||||
/* Buchungskalender, Reiter „Kalender“: Monatsansicht je Unterkunft, Farben nach Quelle, zwei Klicks auf freie Nächte sperren den Zeitraum.
|
||||
API (alle mit ?projectId=): GET /api/unterkunft/unterkuenfte => { unterkuenfte:[{ id, name, … }] }
|
||||
GET /unterkuenfte/:id/kalender?von&bis => { bereiche:[{ von, bis (Abreisetag, exklusiv), quelle, anfrageNummer?, sperreId?, notiz? }] }
|
||||
quelle: 'gebucht' | 'reserviert' | 'ical' (auch 'ical:<id>') | 'manuell'.
|
||||
POST /unterkuenfte/:id/sperren { von, bis, notiz } · DELETE /sperren/:id. Wochen beginnen am Montag. Fremdtext nur ueber textContent. */
|
||||
|
||||
export function render(panel) {
|
||||
panel.replaceChildren(infoKarte('Kalender', 'Dieser Bereich wird gerade gebaut.'))
|
||||
import { abschnitt, allgemeinerFehler, fehlerKarte, feld, frage, h } from './shop-dom.js'
|
||||
import { enc, heuteBerlin, ladeUnterkuenfte, msTag, plusTage, tagText, zeichen, zeigeApiFehler } from './buchungen-uk-hilfe.js'
|
||||
|
||||
export const QUELLEN = [
|
||||
['gebucht', 'Direkt gebucht', 'ist-gebucht'],
|
||||
['reserviert', 'Reserviert (wartet auf Buchung)', 'ist-reserviert'],
|
||||
['ical', 'Booking.com / Airbnb (iCal)', 'ist-ical'],
|
||||
['manuell', 'Von dir gesperrt', 'ist-manuell'],
|
||||
]
|
||||
const MONATE = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']
|
||||
const WOCHENTAGE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
|
||||
const quelleArt = (q) => { const s = zeichen(q); return s.startsWith('ical') ? 'ical' : s.startsWith('anfrage') ? 'gebucht' : s }
|
||||
|
||||
/** Alle Tage des Monatsrasters (Montag bis Sonntag, volle Wochen) als 'YYYY-MM-DD'. */
|
||||
export function rasterTage(jahr, monat) {
|
||||
const erster = Date.UTC(jahr, monat, 1)
|
||||
const letzter = Date.UTC(jahr, monat + 1, 0)
|
||||
const start = erster - ((new Date(erster).getUTCDay() + 6) % 7) * 86400000
|
||||
const ende = letzter + (6 - ((new Date(letzter).getUTCDay() + 6) % 7)) * 86400000
|
||||
const tage = []
|
||||
for (let ms = start; ms <= ende; ms += 86400000) tage.push(msTag(ms))
|
||||
return tage
|
||||
}
|
||||
|
||||
export async function render(panel, ctx) {
|
||||
const heute = heuteBerlin()
|
||||
const st = { unterkunft: null, jahr: Number(heute.slice(0, 4)), monat: Number(heute.slice(5, 7)) - 1, bereiche: [], start: null, lauf: 0 }
|
||||
panel.replaceChildren(h('p', { class: 'kb-empty' }, 'Lade…'))
|
||||
let liste
|
||||
try {
|
||||
liste = (await ladeUnterkuenfte(ctx)).filter((u) => u.aktiv !== false)
|
||||
} catch (err) {
|
||||
if (ctx.aktuell()) panel.replaceChildren(fehlerKarte(err.message, () => render(panel, ctx)))
|
||||
return
|
||||
}
|
||||
if (!ctx.aktuell()) return
|
||||
if (!liste.length) {
|
||||
panel.replaceChildren(h('div', { class: 'kb-panel kb-empty', 'data-leer': true }, 'Noch keine Unterkunft. Lege sie im Reiter „Unterkünfte & Preise“ an.'))
|
||||
return
|
||||
}
|
||||
st.unterkunft = liste[0].id
|
||||
const wahl = h('select', { id: 'bk-k-unterkunft', 'data-aktion': 'unterkunft' }, liste.map((u) => h('option', { value: u.id }, zeichen(u.name))))
|
||||
wahl.addEventListener('change', () => { st.unterkunft = wahl.value; st.start = null; lade() })
|
||||
const titel = h('strong', { class: 'bk-monat', 'aria-live': 'polite', 'data-monat': true })
|
||||
const schritt = (n) => () => { const d = new Date(Date.UTC(st.jahr, st.monat + n, 1)); st.jahr = d.getUTCFullYear(); st.monat = d.getUTCMonth(); st.start = null; lade() }
|
||||
const kopf = h('div', { class: 'bk-filter bk-kalenderkopf' },
|
||||
liste.length > 1 ? h('label', { for: 'bk-k-unterkunft' }, 'Unterkunft') : null, liste.length > 1 ? wahl : h('strong', null, zeichen(liste[0].name)),
|
||||
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'zurueck', 'aria-label': 'Vorheriger Monat', onclick: schritt(-1) }, '‹'),
|
||||
titel,
|
||||
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'weiter', 'aria-label': 'Nächster Monat', onclick: schritt(1) }, '›'))
|
||||
const raster = h('div', { class: 'bk-raster', role: 'grid' })
|
||||
const hinweis = h('p', { class: 'sh-hinweis', 'data-hinweis': true, role: 'status' })
|
||||
const dialog = h('div', { class: 'bk-dialog' })
|
||||
const legende = h('ul', { class: 'bk-legende', 'aria-label': 'Legende' }, QUELLEN.map(([, text, klasse]) => h('li', null, h('span', { class: `bk-farbe ${klasse}`, 'aria-hidden': 'true' }), text)))
|
||||
panel.replaceChildren(abschnitt('Kalender', ctx.nurLesen ? 'Dein Kalender ist hier nur zum Ansehen.' : 'Klicke zwei freie Tage an, um den Zeitraum zu sperren, zum Beispiel für eigene Aufenthalte. Gesperrte Tage kannst du mit einem Klick wieder freigeben.',
|
||||
kopf, raster, hinweis, legende, dialog))
|
||||
await lade()
|
||||
|
||||
function bereichAm(tag) {
|
||||
for (const art of ['gebucht', 'reserviert', 'ical', 'manuell']) {
|
||||
const b = st.bereiche.find((x) => quelleArt(x.quelle) === art && x.von <= tag && tag < x.bis)
|
||||
if (b) return b
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function lade() {
|
||||
const lauf = ++st.lauf
|
||||
const tage = rasterTage(st.jahr, st.monat)
|
||||
titel.textContent = `${MONATE[st.monat]} ${st.jahr}`
|
||||
dialog.replaceChildren()
|
||||
hinweis.textContent = ''
|
||||
try {
|
||||
const daten = await ctx.request(`/api/unterkunft/unterkuenfte/${enc(st.unterkunft)}/kalender?projectId=${enc(ctx.projectId)}&von=${tage[0]}&bis=${plusTage(tage.at(-1), 1)}`)
|
||||
if (st.lauf !== lauf || !ctx.aktuell()) return
|
||||
st.bereiche = Array.isArray(daten?.bereiche) ? daten.bereiche : []
|
||||
} catch (err) {
|
||||
if (st.lauf === lauf && ctx.aktuell()) { st.bereiche = []; hinweis.textContent = err.message }
|
||||
return
|
||||
}
|
||||
zeichne()
|
||||
}
|
||||
|
||||
function zeichne() {
|
||||
const tage = rasterTage(st.jahr, st.monat)
|
||||
const heute = heuteBerlin()
|
||||
raster.replaceChildren(
|
||||
...WOCHENTAGE.map((w) => h('div', { class: 'bk-wochentag', role: 'columnheader' }, w)),
|
||||
...tage.map((tag) => {
|
||||
const b = bereichAm(tag)
|
||||
const art = b ? quelleArt(b.quelle) : null
|
||||
const klassen = ['bk-tag', art ? `ist-${art}` : 'ist-frei', Number(tag.slice(5, 7)) - 1 === st.monat ? '' : 'bk-ausserhalb', tag === heute ? 'bk-heute' : '', st.start === tag ? 'bk-start' : '']
|
||||
const label = `${tagText(tag)}, ${b ? (QUELLEN.find((q) => q[0] === art)?.[1] || 'belegt') : 'frei'}${b?.anfrageNummer ? `, Anfrage ${zeichen(b.anfrageNummer)}` : ''}`
|
||||
return h('button', {
|
||||
type: 'button', class: klassen.filter(Boolean).join(' '), 'data-tag': tag, 'aria-label': label, 'aria-pressed': st.start === tag ? 'true' : 'false',
|
||||
onclick: () => klickTag(tag, b),
|
||||
}, String(Number(tag.slice(8))))
|
||||
}))
|
||||
}
|
||||
|
||||
function klickTag(tag, b) {
|
||||
dialog.replaceChildren()
|
||||
hinweis.textContent = ''
|
||||
if (b) {
|
||||
st.start = null
|
||||
zeichne()
|
||||
if (quelleArt(b.quelle) === 'manuell' && b.sperreId) return aufhebenDialog(b)
|
||||
hinweis.textContent = `${tagText(tag)}: ${QUELLEN.find((q) => q[0] === quelleArt(b.quelle))?.[1] || 'belegt'}${b.anfrageNummer ? ` (Anfrage ${zeichen(b.anfrageNummer)})` : ''}. Das kannst du hier nicht ändern.`
|
||||
return
|
||||
}
|
||||
if (ctx.nurLesen) return
|
||||
if (!st.start) {
|
||||
st.start = tag
|
||||
zeichne()
|
||||
hinweis.textContent = `Erster Tag: ${tagText(tag)}. Klicke jetzt den letzten Tag an.`
|
||||
return
|
||||
}
|
||||
const [von, letzter] = st.start <= tag ? [st.start, tag] : [tag, st.start]
|
||||
const bis = plusTage(letzter, 1)
|
||||
const belegt = st.bereiche.some((x) => x.von < bis && von < x.bis)
|
||||
if (belegt) {
|
||||
st.start = null
|
||||
zeichne()
|
||||
hinweis.textContent = 'In diesem Zeitraum liegen schon belegte Tage. Wähle einen freien Zeitraum.'
|
||||
return
|
||||
}
|
||||
sperrDialog(von, bis)
|
||||
}
|
||||
|
||||
function sperrDialog(von, bis) {
|
||||
const notiz = h('input', { type: 'text', id: 'bk-k-notiz', maxlength: '200', autocomplete: 'off' })
|
||||
const box = frage('Zeitraum sperren', h('p', null, `${tagText(von)} bis ${tagText(plusTage(bis, -1))} (Abreise ${tagText(bis)}) sperren?`),
|
||||
feld('notiz', 'Notiz (nur für dich)', notiz), allgemeinerFehler(),
|
||||
h('div', { class: 'bk-aktionen' },
|
||||
h('button', {
|
||||
type: 'button', class: 'kb-btn', 'data-aktion': 'sperren-senden',
|
||||
onclick: async (ev) => {
|
||||
const knopf = ev.currentTarget
|
||||
knopf.disabled = true
|
||||
try {
|
||||
await ctx.request(`/api/unterkunft/unterkuenfte/${enc(st.unterkunft)}/sperren?projectId=${enc(ctx.projectId)}`, { method: 'POST', body: { von, bis, notiz: String(notiz.value).trim() } })
|
||||
if (!ctx.aktuell()) return
|
||||
st.start = null
|
||||
ctx.zeigeMeldung('Der Zeitraum ist gesperrt.', 'ok')
|
||||
await lade()
|
||||
} catch (err) {
|
||||
knopf.disabled = false
|
||||
zeigeApiFehler(box, err)
|
||||
}
|
||||
},
|
||||
}, 'Zeitraum sperren'),
|
||||
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'abbrechen', onclick: () => { st.start = null; dialog.replaceChildren(); zeichne() } }, 'Abbrechen')))
|
||||
dialog.replaceChildren(box)
|
||||
notiz.focus()
|
||||
}
|
||||
|
||||
function aufhebenDialog(b) {
|
||||
const box = frage('Sperre aufheben', h('p', null, `${tagText(b.von)} bis ${tagText(plusTage(b.bis, -1))}${b.notiz ? `: ${zeichen(b.notiz)}` : ''}`), allgemeinerFehler(),
|
||||
h('div', { class: 'bk-aktionen' },
|
||||
ctx.nurLesen ? null : h('button', {
|
||||
type: 'button', class: 'kb-btn', 'data-aktion': 'aufheben',
|
||||
onclick: async (ev) => {
|
||||
const knopf = ev.currentTarget
|
||||
knopf.disabled = true
|
||||
try {
|
||||
await ctx.request(`/api/unterkunft/sperren/${enc(b.sperreId)}?projectId=${enc(ctx.projectId)}`, { method: 'DELETE' })
|
||||
if (!ctx.aktuell()) return
|
||||
ctx.zeigeMeldung('Die Sperre ist aufgehoben.', 'ok')
|
||||
await lade()
|
||||
} catch (err) {
|
||||
knopf.disabled = false
|
||||
zeigeApiFehler(box, err)
|
||||
}
|
||||
},
|
||||
}, 'Sperre aufheben'),
|
||||
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'abbrechen', onclick: () => dialog.replaceChildren() }, 'Schließen')))
|
||||
dialog.replaceChildren(box)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,127 @@
|
||||
/* Buchungskalender, Reiter „Unterkünfte & Preise“: Platzhalter (Task 13/14 fuellt ihn). */
|
||||
import { infoKarte } from './shop-dom.js'
|
||||
/* Buchungskalender, Reiter „Unterkünfte & Preise“: Liste (anlegen, sortieren, aktivieren), Bearbeiten (Stammdaten, Preise, iCal).
|
||||
API (alle mit ?projectId=): GET /api/unterkunft/unterkuenfte => { unterkuenfte:[{ id, name, beschreibung, maxPersonen, mindestNaechte, checkIn, checkOut, sort, aktiv,
|
||||
icalImporte, icalExportUrl }] }; POST /unterkuenfte und PUT /unterkuenfte/:id mit { name, beschreibung, maxPersonen, mindestNaechte, checkIn, checkOut, sort, aktiv }
|
||||
(Antwort { unterkunft:{…} }). Preise: buchungen-preise.js, iCal: buchungen-ical.js. Fremdtext nur ueber textContent. */
|
||||
|
||||
export function render(panel) {
|
||||
panel.replaceChildren(infoKarte('Unterkünfte & Preise', 'Dieser Bereich wird gerade gebaut.'))
|
||||
import { abschnitt, allgemeinerFehler, fehlerKarte, feld, h, hakenFeld, setzeFehler, zeigeFehler } from './shop-dom.js'
|
||||
import { enc, ladeUnterkuenfte, zeichen, zeigeApiFehler } from './buchungen-uk-hilfe.js'
|
||||
import { zeichnePreise } from './buchungen-preise.js'
|
||||
import { zeichneIcal } from './buchungen-ical.js'
|
||||
|
||||
const ganz = (steuer) => { const t = String(steuer.value).trim(); return /^\d{1,3}$/.test(t) ? Number(t) : null }
|
||||
const zeit = (t) => /^([01]\d|2[0-3]):[0-5]\d$/.test(t)
|
||||
|
||||
export async function render(panel, ctx) {
|
||||
const q = `projectId=${enc(ctx.projectId)}`
|
||||
const inhalt = h('div', { class: 'bk-unterkuenfte' })
|
||||
panel.replaceChildren(inhalt)
|
||||
await zeigeListe()
|
||||
|
||||
async function zeigeListe() {
|
||||
inhalt.replaceChildren(h('p', { class: 'kb-empty' }, 'Lade…'))
|
||||
let liste
|
||||
try {
|
||||
liste = await ladeUnterkuenfte(ctx)
|
||||
} catch (err) {
|
||||
if (ctx.aktuell()) inhalt.replaceChildren(fehlerKarte(err.message, zeigeListe))
|
||||
return
|
||||
}
|
||||
if (!ctx.aktuell()) return
|
||||
const kopf = h('div', { class: 'bk-aktionen' }, ctx.nurLesen ? null : h('button', { type: 'button', class: 'kb-btn', 'data-aktion': 'neu', onclick: () => zeigeForm(null, liste) }, 'Unterkunft hinzufügen'))
|
||||
if (!liste.length) {
|
||||
inhalt.replaceChildren(kopf, h('div', { class: 'kb-panel kb-empty', 'data-leer': true }, 'Noch keine Unterkunft. Lege deine erste Unterkunft an.'))
|
||||
return
|
||||
}
|
||||
inhalt.replaceChildren(kopf, h('ul', { class: 'bk-liste' }, liste.map((u, i) => h('li', { class: 'kb-panel', 'data-unterkunft': u.id },
|
||||
h('div', { class: 'bk-detail-kopf' },
|
||||
h('strong', null, zeichen(u.name)),
|
||||
h('span', { class: `sh-pille sh-pille-${u.aktiv === false ? 'off' : 'ok'}` }, u.aktiv === false ? 'Deaktiviert' : 'Aktiv')),
|
||||
h('p', { class: 'sh-hinweis' }, `bis ${u.maxPersonen ?? '?'} Personen, ab ${u.mindestNaechte ?? 1} ${u.mindestNaechte === 1 ? 'Nacht' : 'Nächten'}, Check-in ${zeichen(u.checkIn)}, Check-out ${zeichen(u.checkOut)}`),
|
||||
h('div', { class: 'bk-aktionen' },
|
||||
h('button', { type: 'button', class: 'kb-btn', 'data-aktion': 'bearbeiten', onclick: () => zeigeForm(u, liste) }, 'Bearbeiten'),
|
||||
ctx.nurLesen ? null : [
|
||||
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'hoch', 'aria-label': `${zeichen(u.name)} nach oben`, disabled: i === 0, onclick: () => schiebe(liste, i, -1) }, '↑'),
|
||||
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'runter', 'aria-label': `${zeichen(u.name)} nach unten`, disabled: i === liste.length - 1, onclick: () => schiebe(liste, i, 1) }, '↓'),
|
||||
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'umschalten', onclick: () => speichere(u, { aktiv: u.aktiv === false }, 'Gespeichert.') }, u.aktiv === false ? 'Aktivieren' : 'Deaktivieren'),
|
||||
])))))
|
||||
}
|
||||
|
||||
const koerper = (u, extra = {}) => ({
|
||||
name: u.name, beschreibung: zeichen(u.beschreibung), maxPersonen: u.maxPersonen, mindestNaechte: u.mindestNaechte, checkIn: u.checkIn, checkOut: u.checkOut, sort: u.sort ?? 0, aktiv: u.aktiv !== false, ...extra,
|
||||
})
|
||||
async function speichere(u, extra, meldung) {
|
||||
try {
|
||||
await ctx.request(`/api/unterkunft/unterkuenfte/${enc(u.id)}?${q}`, { method: 'PUT', body: koerper(u, extra) })
|
||||
if (!ctx.aktuell()) return
|
||||
ctx.zeigeMeldung(meldung, 'ok')
|
||||
await zeigeListe()
|
||||
} catch (err) { ctx.zeigeMeldung(err.message, 'fehler') }
|
||||
}
|
||||
async function schiebe(liste, i, d) {
|
||||
const a = liste[i]
|
||||
const b = liste[i + d]
|
||||
try {
|
||||
await ctx.request(`/api/unterkunft/unterkuenfte/${enc(a.id)}?${q}`, { method: 'PUT', body: koerper(a, { sort: i + d }) })
|
||||
await ctx.request(`/api/unterkunft/unterkuenfte/${enc(b.id)}?${q}`, { method: 'PUT', body: koerper(b, { sort: i }) })
|
||||
if (ctx.aktuell()) await zeigeListe()
|
||||
} catch (err) { ctx.zeigeMeldung(err.message, 'fehler') }
|
||||
}
|
||||
|
||||
/** Formular (u = null: neu). Bei einer bestehenden Unterkunft folgen Preise und iCal. */
|
||||
async function zeigeForm(u, liste) {
|
||||
const name = h('input', { type: 'text', id: 'bk-u-name', maxlength: '120', autocomplete: 'off' }); name.value = zeichen(u?.name)
|
||||
const beschr = h('textarea', { id: 'bk-u-beschr', rows: '3', maxlength: '500' }); beschr.value = zeichen(u?.beschreibung)
|
||||
const maxP = h('input', { type: 'text', inputmode: 'numeric', id: 'bk-u-max' }); maxP.value = String(u?.maxPersonen ?? 2)
|
||||
const minN = h('input', { type: 'text', inputmode: 'numeric', id: 'bk-u-min' }); minN.value = String(u?.mindestNaechte ?? 1)
|
||||
const ein = h('input', { type: 'text', id: 'bk-u-ein', placeholder: '15:00', maxlength: '5' }); ein.value = zeichen(u?.checkIn) || '15:00'
|
||||
const aus = h('input', { type: 'text', id: 'bk-u-aus', placeholder: '10:00', maxlength: '5' }); aus.value = zeichen(u?.checkOut) || '10:00'
|
||||
const aktiv = h('input', { type: 'checkbox', id: 'bk-u-aktiv' }); aktiv.checked = u ? u.aktiv !== false : true
|
||||
const form = h('form', { novalidate: true, onsubmit: (ev) => ev.preventDefault() },
|
||||
feld('name', 'Name der Unterkunft', name), feld('beschreibung', 'Kurzbeschreibung (optional)', beschr),
|
||||
feld('maxPersonen', 'Höchstens Personen (1 bis 20)', maxP), feld('mindestNaechte', 'Mindestaufenthalt in Nächten (1 bis 90)', minN),
|
||||
feld('checkIn', 'Check-in ab (HH:MM)', ein), feld('checkOut', 'Check-out bis (HH:MM)', aus),
|
||||
hakenFeld('aktiv', aktiv, 'Aktiv (für Gäste buchbar)'), allgemeinerFehler(),
|
||||
h('div', { class: 'bk-aktionen' },
|
||||
ctx.nurLesen ? null : h('button', { type: 'button', class: 'kb-btn', 'data-aktion': 'speichern', onclick: senden }, u ? 'Speichern' : 'Unterkunft anlegen'),
|
||||
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'zur-liste', onclick: zeigeListe }, 'Zur Liste')))
|
||||
if (ctx.nurLesen) for (const el of form.querySelectorAll('input, textarea')) el.disabled = true
|
||||
const bereiche = h('div', { class: 'bk-bearbeiten' })
|
||||
inhalt.replaceChildren(abschnitt(u ? `Unterkunft „${zeichen(u.name)}“` : 'Neue Unterkunft', null, form), bereiche)
|
||||
name.focus()
|
||||
if (u) {
|
||||
bereiche.replaceChildren(abschnitt('Preise', 'Preise sind Bruttopreise in Euro, so sieht sie dein Gast.', await zeichnePreise(ctx, u)), zeichneIcal(ctx, u, async (meldung) => {
|
||||
ctx.zeigeMeldung(meldung, 'ok')
|
||||
const neu = (await ladeUnterkuenfte(ctx)).find((x) => x.id === u.id)
|
||||
if (ctx.aktuell()) await zeigeForm(neu || u, liste)
|
||||
}))
|
||||
}
|
||||
|
||||
async function senden(ev) {
|
||||
const fehler = {}
|
||||
const n = String(name.value).trim()
|
||||
const mp = ganz(maxP)
|
||||
const mn = ganz(minN)
|
||||
if (!n) fehler.name = 'Bitte gib einen Namen an.'
|
||||
if (mp === null || mp < 1 || mp > 20) fehler.maxPersonen = 'Bitte gib eine Zahl von 1 bis 20 an.'
|
||||
if (mn === null || mn < 1 || mn > 90) fehler.mindestNaechte = 'Bitte gib eine Zahl von 1 bis 90 an.'
|
||||
if (!zeit(String(ein.value).trim())) fehler.checkIn = 'Bitte gib eine Uhrzeit wie 15:00 an.'
|
||||
if (!zeit(String(aus.value).trim())) fehler.checkOut = 'Bitte gib eine Uhrzeit wie 10:00 an.'
|
||||
if (Object.keys(fehler).length) return zeigeFehler(form, { message: '', fehler })
|
||||
setzeFehler(form, {})
|
||||
const body = { name: n, beschreibung: String(beschr.value).trim(), maxPersonen: mp, mindestNaechte: mn, checkIn: String(ein.value).trim(), checkOut: String(aus.value).trim(), sort: u?.sort ?? liste.length, aktiv: aktiv.checked }
|
||||
const knopf = ev.currentTarget
|
||||
knopf.disabled = true
|
||||
try {
|
||||
const antwort = await ctx.request(u ? `/api/unterkunft/unterkuenfte/${enc(u.id)}?${q}` : `/api/unterkunft/unterkuenfte?${q}`, { method: u ? 'PUT' : 'POST', body })
|
||||
if (!ctx.aktuell()) return
|
||||
ctx.zeigeMeldung(u ? 'Gespeichert.' : 'Die Unterkunft ist angelegt. Jetzt kannst du die Preise eintragen.', 'ok')
|
||||
const neu = antwort?.unterkunft?.id ? antwort.unterkunft : null
|
||||
if (u || !neu) await zeigeListe()
|
||||
else await zeigeForm({ ...neu }, [...liste, neu])
|
||||
} catch (err) {
|
||||
knopf.disabled = false
|
||||
zeigeApiFehler(form, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/* Buchungskalender: gemeinsame Helfer der Reiter „Kalender“ und „Unterkünfte & Preise“ (Datumsrechnung, Fehlertexte, Unterkunftsliste).
|
||||
Kalendertage sind Texte YYYY-MM-DD (Berlin); gerechnet wird nur mit UTC-Mitternacht, nie mit der Zeitzone des Browsers. */
|
||||
import { zeigeFehler } from './shop-dom.js'
|
||||
import { fehlerFuerFormular } from './buchungen-fehler.js'
|
||||
|
||||
export const enc = encodeURIComponent
|
||||
export const zeichen = (x) => (typeof x === 'string' ? x : '')
|
||||
export const tagMs = (tag) => { const [j, m, t] = String(tag).split('-').map(Number); return Date.UTC(j, m - 1, t) }
|
||||
export const msTag = (ms) => new Date(ms).toISOString().slice(0, 10)
|
||||
export const plusTage = (tag, n) => msTag(tagMs(tag) + n * 86400000)
|
||||
/** Heute als Berlin-Datum. */
|
||||
export const heuteBerlin = () => new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' })
|
||||
/** '2026-11-12' ⇒ '12.11.2026'. */
|
||||
export const tagText = (tag) => { const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(tag || ''); return m ? `${m[3]}.${m[2]}.${m[1]}` : '' }
|
||||
|
||||
/** Fehler am Formular zeigen (Feldfehler am Feld, sonst allgemein); der Fokus geht auf das erste fehlerhafte Feld. */
|
||||
export function zeigeApiFehler(form, err) {
|
||||
zeigeFehler(form, fehlerFuerFormular(err))
|
||||
}
|
||||
|
||||
/** Unterkuenfte des Projekts: GET /api/unterkunft/unterkuenfte => { unterkuenfte:[…] }. */
|
||||
export async function ladeUnterkuenfte(ctx) {
|
||||
const daten = await ctx.request(`/api/unterkunft/unterkuenfte?projectId=${enc(ctx.projectId)}`)
|
||||
return (Array.isArray(daten?.unterkuenfte) ? daten.unterkuenfte : []).slice().sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0))
|
||||
}
|
||||
@@ -35,3 +35,23 @@
|
||||
.bk-posten-zeile { grid-template-columns: 1fr 1fr; }
|
||||
.bk-posten-zeile input:first-child { grid-column: 1 / -1; }
|
||||
}
|
||||
.bk-kalenderkopf { flex-wrap: wrap; }
|
||||
.bk-monat { min-width: 9em; text-align: center; }
|
||||
.bk-raster { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 4px; max-width: 560px; margin: 10px 0; }
|
||||
.bk-wochentag { text-align: center; font-size: 12px; color: var(--kb-muted); }
|
||||
.bk-tag { aspect-ratio: 1; min-height: 40px; border-radius: 8px; border: 1px solid var(--kb-border); background: rgba(255, 255, 255, 0.03); color: inherit; font: inherit; cursor: pointer; }
|
||||
.bk-tag.bk-ausserhalb { opacity: 0.45; }
|
||||
.bk-tag.bk-heute { border-color: var(--kb-purple-line); font-weight: 700; }
|
||||
.bk-tag.bk-start { outline: 2px solid #d4b4ff; }
|
||||
.ist-gebucht { background: rgba(74, 222, 128, 0.28); }
|
||||
.ist-reserviert { background: rgba(251, 191, 36, 0.28); }
|
||||
.ist-ical { background: rgba(96, 165, 250, 0.28); }
|
||||
.ist-manuell { background: rgba(156, 163, 175, 0.35); }
|
||||
.bk-legende { list-style: none; display: flex; flex-wrap: wrap; gap: 6px 16px; padding: 0; margin: 8px 0; }
|
||||
.bk-legende li { display: flex; align-items: center; gap: 6px; font-size: 13px; }
|
||||
.bk-farbe { width: 16px; height: 16px; border-radius: 4px; border: 1px solid var(--kb-border); display: inline-block; }
|
||||
.bk-staffel { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-bottom: 8px; }
|
||||
.bk-staffel input { width: 90px; }
|
||||
.bk-vorschau { font-weight: 700; }
|
||||
.bk-import { display: flex; align-items: center; gap: 4px 12px; flex-wrap: wrap; }
|
||||
.bk-anleitung details { margin: 8px 0; }
|
||||
Reference in new issue
Block a user