Buchungskalender: Rechnungen, Einstellungen und Funktionen-Kachel
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
10fe9a2ae0
commit
efeb99e214
8 files changed
+696
-11
No files matched your search
@@ -0,0 +1,136 @@
|
|||||||
|
/* Buchungskalender im Kundenbereich: die Anfrage der Funktion (Zustand off), Muster shop-anfrage.js.
|
||||||
|
POST /api/unterkunft/anfrage-funktion?projectId= { art, einheiten, portale, nachricht } => { anfrage } (portale: Liste aus 'booking' | 'airbnb'),
|
||||||
|
POST /api/unterkunft/anfrage-funktion/zurueckziehen?projectId=. Status im Stand (GET /status): anfrage:{ status:'offen'|'abgelehnt'|'angenommen',
|
||||||
|
at, begruendung?, anfrage:{ art, einheiten, portale, nachricht } }. Geprueft wird auf dem Server (Feldfehler am Feld). Text nur ueber textContent (h). */
|
||||||
|
|
||||||
|
import { allgemeinerFehler, datumText, feld, h, hakenFeld, request, setzeFehler, zeigeFehler } from './shop-dom.js'
|
||||||
|
import { fehlerFuerFormular } from './buchungen-fehler.js'
|
||||||
|
|
||||||
|
export const ARTEN = [['ferienwohnung', 'Ferienwohnung oder Ferienhaus'], ['monteur', 'Monteurwohnung'], ['pension', 'Pension oder Gästezimmer'], ['sonstiges', 'Sonstiges']]
|
||||||
|
export const PORTALE = [['booking', 'Booking.com'], ['airbnb', 'Airbnb']]
|
||||||
|
export const KALENDER_PREIS = '199 €'
|
||||||
|
|
||||||
|
const text = (wert) => (typeof wert === 'string' ? wert : '')
|
||||||
|
const labelVon = (liste, key) => liste.find(([k]) => k === key)?.[1] || ''
|
||||||
|
const enc = encodeURIComponent
|
||||||
|
|
||||||
|
/** Angaben der Anfrage als Definitionsliste (nur Text). */
|
||||||
|
export function anfrageListe(a) {
|
||||||
|
const s = a && typeof a === 'object' ? a : {}
|
||||||
|
const portale = (Array.isArray(s.portale) ? s.portale : []).map((p) => labelVon(PORTALE, p)).filter(Boolean).join(', ')
|
||||||
|
const zeilen = [
|
||||||
|
['Art der Unterkunft', labelVon(ARTEN, s.art)],
|
||||||
|
['Anzahl Einheiten', Number.isInteger(s.einheiten) ? String(s.einheiten) : ''],
|
||||||
|
['Portale', portale || 'keine'],
|
||||||
|
['Nachricht', text(s.nachricht)],
|
||||||
|
].filter(([, wert]) => wert !== '')
|
||||||
|
return h('dl', { class: 'sh-fakten' }, zeilen.map(([name, wert]) => h('div', null, h('dt', null, name), h('dd', null, wert))))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Formular „Buchungskalender anfragen“ in root; nachSenden(anfrage) wird nach dem Speichern aufgerufen (die Ansicht zeichnet sich neu). */
|
||||||
|
export function renderAnfrageFormular(root, { projectId, vorgabe = null, preis = KALENDER_PREIS, nachSenden } = {}) {
|
||||||
|
const v = vorgabe && typeof vorgabe === 'object' ? vorgabe : {}
|
||||||
|
const art = h('select', null, [h('option', { value: '' }, 'Bitte wählen …'), ...ARTEN.map(([w, n]) => h('option', { value: w }, n))])
|
||||||
|
art.value = ARTEN.some(([k]) => k === v.art) ? v.art : ''
|
||||||
|
const einheiten = h('input', { type: 'text', inputmode: 'numeric', maxlength: 3, autocomplete: 'off' })
|
||||||
|
einheiten.value = Number.isInteger(v.einheiten) ? String(v.einheiten) : ''
|
||||||
|
const portale = PORTALE.map(([wert]) => {
|
||||||
|
const box = h('input', { type: 'checkbox', value: wert })
|
||||||
|
box.checked = Array.isArray(v.portale) && v.portale.includes(wert)
|
||||||
|
return box
|
||||||
|
})
|
||||||
|
const nachricht = h('textarea', { rows: 3, maxlength: 1000 })
|
||||||
|
nachricht.value = text(v.nachricht)
|
||||||
|
const absenden = h('button', { type: 'submit', class: 'kb-btn-primary', 'data-aktion': 'anfrage-senden' }, 'Anfrage senden')
|
||||||
|
|
||||||
|
const form = h('form', { class: 'sh-form', novalidate: true, 'aria-label': 'Buchungskalender anfragen' },
|
||||||
|
feld('art', 'Welche Art von Unterkunft vermietest du?', art),
|
||||||
|
feld('einheiten', 'Wie viele Einheiten (Wohnungen, Zimmer) vermietest du?', einheiten),
|
||||||
|
h('fieldset', { class: 'sh-gruppe' },
|
||||||
|
h('legend', null, 'Nutzt du Booking.com oder Airbnb?'),
|
||||||
|
...PORTALE.map(([wert, name], i) => hakenFeld(`portale.${wert}`, portale[i], name)),
|
||||||
|
h('p', { class: 'sh-fehler', 'data-fehler': 'portale', role: 'alert' })),
|
||||||
|
feld('nachricht', 'Noch etwas, das wir wissen sollten? (freiwillig)', nachricht),
|
||||||
|
allgemeinerFehler(),
|
||||||
|
h('div', { class: 'sh-aktionen' }, absenden))
|
||||||
|
|
||||||
|
let laeuft = false
|
||||||
|
form.addEventListener('submit', async (ev) => {
|
||||||
|
ev.preventDefault()
|
||||||
|
if (laeuft) return
|
||||||
|
laeuft = true
|
||||||
|
absenden.disabled = true
|
||||||
|
setzeFehler(form, {})
|
||||||
|
const zahl = einheiten.value.trim()
|
||||||
|
try {
|
||||||
|
const antwort = await request(`/api/unterkunft/anfrage-funktion?projectId=${enc(projectId)}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
art: art.value,
|
||||||
|
einheiten: /^\d{1,3}$/.test(zahl) ? Number(zahl) : zahl,
|
||||||
|
portale: portale.filter((b) => b.checked).map((b) => b.value),
|
||||||
|
nachricht: nachricht.value.trim(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (typeof nachSenden === 'function') await nachSenden(antwort?.anfrage ?? null)
|
||||||
|
return
|
||||||
|
} catch (err) {
|
||||||
|
zeigeFehler(form, fehlerFuerFormular(err))
|
||||||
|
if (err?.status === 409 && typeof nachSenden === 'function') {
|
||||||
|
try { await nachSenden(null) } catch { /* der Fehler steht schon am Formular */ }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
laeuft = false
|
||||||
|
absenden.disabled = false
|
||||||
|
})
|
||||||
|
|
||||||
|
const erklaerung = h('div', { class: 'sh-erklaerung' },
|
||||||
|
h('h3', null, 'Buchungskalender anfragen'),
|
||||||
|
h('p', null, 'Mit dem Buchungskalender zeigst du auf deiner Website, wann deine Unterkunft frei ist. Gäste fragen direkt an, du gibst frei, und die Rechnung entsteht automatisch.'),
|
||||||
|
h('ul', null,
|
||||||
|
h('li', null, `${preis} einmalig für den Einbau in deine Website.`),
|
||||||
|
h('li', null, 'Belegungskalender mit Abgleich zu Booking.com und Airbnb (über iCal).'),
|
||||||
|
h('li', null, 'Rechnung in deinem Namen und optional Online-Zahlung auf dein eigenes Stripe-Konto.'),
|
||||||
|
h('li', null, 'Wir sehen uns deine Anfrage kurz an. Erst nach unserer Zusage kannst du bestellen.')))
|
||||||
|
const karte = h('section', { class: 'kb-panel sh-anfrage' }, erklaerung, form)
|
||||||
|
root.replaceChildren(karte)
|
||||||
|
return karte
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Karte einer Anfrage im Stand offen (mit „Anfrage zurückziehen“) oder abgelehnt (Begründung, „Neue Anfrage stellen“). */
|
||||||
|
export function anfrageKarte(ansicht, { projectId, neu, neuLaden } = {}) {
|
||||||
|
const offen = ansicht?.status === 'offen'
|
||||||
|
const meldung = h('p', { class: 'sh-fehler', role: 'alert', 'data-meldung': 'anfrage' })
|
||||||
|
const datum = datumText(ansicht?.at)
|
||||||
|
let aktion
|
||||||
|
if (offen) {
|
||||||
|
aktion = h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'zurueckziehen' }, 'Anfrage zurückziehen')
|
||||||
|
aktion.addEventListener('click', async () => {
|
||||||
|
if (aktion.disabled) return
|
||||||
|
aktion.disabled = true
|
||||||
|
meldung.textContent = ''
|
||||||
|
try {
|
||||||
|
await request(`/api/unterkunft/anfrage-funktion/zurueckziehen?projectId=${enc(projectId)}`, { method: 'POST' })
|
||||||
|
if (typeof neuLaden === 'function') await neuLaden()
|
||||||
|
return
|
||||||
|
} catch (err) {
|
||||||
|
meldung.textContent = err.message
|
||||||
|
if (err?.status === 409 && typeof neuLaden === 'function') { try { await neuLaden() } catch { /* Meldung bleibt */ } return }
|
||||||
|
}
|
||||||
|
aktion.disabled = false
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
aktion = h('button', { type: 'button', class: 'kb-btn-primary', 'data-aktion': 'neue-anfrage', onclick: () => { if (typeof neu === 'function') neu() } }, 'Neue Anfrage stellen')
|
||||||
|
}
|
||||||
|
return h('section', { class: 'kb-panel sh-info sh-anfrage-karte', 'data-anfrage': offen ? 'offen' : 'abgelehnt' },
|
||||||
|
h('span', { class: `sh-pille ${offen ? 'sh-pille-wait' : 'sh-pille-bad'}` }, offen ? 'In Prüfung' : 'Abgelehnt'),
|
||||||
|
h('h3', null, offen ? 'Deine Anfrage wird geprüft' : 'Deine Anfrage wurde leider abgelehnt'),
|
||||||
|
offen
|
||||||
|
? h('p', null, `${datum ? `Du hast deine Anfrage am ${datum} Uhr gesendet. ` : ''}Wir melden uns, sobald wir sie angesehen haben. Bis dahin kannst du sie zurückziehen und eine neue stellen.`)
|
||||||
|
: h('p', null, 'Unsere Begründung:'),
|
||||||
|
!offen ? h('blockquote', { class: 'sh-begruendung' }, text(ansicht?.begruendung) || 'Dazu liegt keine Begründung vor.') : null,
|
||||||
|
ansicht?.anfrage ? h('div', { class: 'sh-zusammenfassung' }, h('h4', null, 'Deine Angaben'), anfrageListe(ansicht.anfrage)) : null,
|
||||||
|
meldung,
|
||||||
|
h('div', { class: 'sh-aktionen' }, aktion, h('button', { type: 'button', class: 'kb-btn-outline', 'data-view': 'chat' }, 'Frage dazu? Chat öffnen')))
|
||||||
|
}
|
||||||
@@ -1,6 +1,184 @@
|
|||||||
/* Buchungskalender, Reiter „Einstellungen“: Platzhalter (Task 13/14 fuellt ihn). */
|
/* Buchungskalender, Reiter „Einstellungen“: Firmendaten, Steuernummer/USt-IdNr., Rechnungspraefix, Auftrag zur Rechnungsstellung,
|
||||||
import { infoKarte } from './shop-dom.js'
|
Bankverbindung, Akzentfarbe, Stornobedingungen (Mustertext), Stripe-Konto und Online-Zahlung.
|
||||||
|
API (docs/funktionen/unterkunft-api.md, ?projectId=): GET /einstellungen => { einstellungen:{ firma, inhaber, strasse, plz, ort, email, telefon,
|
||||||
|
steuernummer, ustId, iban, bic, bank, rechnungsPraefix, akzentfarbe, onlineZahlung, rechnungAuftrag, stornobedingungen },
|
||||||
|
stripe:{ status:'keins'|'onboarding'|'aktiv', vorhandenesShopKonto:boolean }, rechnungenVorhanden:boolean };
|
||||||
|
PUT /einstellungen mit denselben Feldern (ohne Wrapper; rechnungAuftrag:true setzt rechnungAuftragAt auf dem Server);
|
||||||
|
POST /stripe/verbinden => { url } (Onboarding bei Stripe), POST /stripe/vorhandenes-konto und /stripe/aktualisieren => { stripe }.
|
||||||
|
Geprueft wird auf dem Server; Feldfehler stehen am Feld. Text nur ueber textContent (h). render(panel, ctx) wie shop-tab-*.js. */
|
||||||
|
|
||||||
export function render(panel) {
|
import { abschnitt, allgemeinerFehler, feld, h, hakenFeld, setzeFehler } from './shop-dom.js'
|
||||||
panel.replaceChildren(infoKarte('Einstellungen', 'Dieser Bereich wird gerade gebaut.'))
|
import { enc, zeichen, zeigeApiFehler } from './buchungen-uk-hilfe.js'
|
||||||
|
|
||||||
|
export const STORNO_MUSTER = 'Stornierung bis 14 Tage vor Anreise: kostenlos. Stornierung 13 bis 3 Tage vor Anreise: 50 % des Gesamtpreises. Stornierung ab 2 Tage vor Anreise oder Nichtanreise: 90 % des Gesamtpreises. Die Stornierung bedarf der Textform (z. B. E-Mail). Maßgeblich ist der Eingang bei uns.'
|
||||||
|
export const STORNO_HINWEIS = 'Vorlage – bitte an deine Bedingungen anpassen, keine Rechtsberatung.'
|
||||||
|
export const AUFTRAG_TEXT = 'Ich beauftrage WEBklar, in meinem Namen und auf meine Rechnung Rechnungen an meine Gäste auszustellen (§ 14 Abs. 2 Satz 4 UStG).'
|
||||||
|
export const AUFTRAG_FEHLT = 'Ohne diesen Auftrag kannst du Buchungen nicht bestätigen, weil wir keine Rechnungen für dich ausstellen dürfen.'
|
||||||
|
const STRIPE_FEHLER = 'Stripe hat keine gültige Adresse geliefert. Bitte versuch es gleich noch einmal.'
|
||||||
|
const PFLICHT = { firma: 'Bitte gib den Namen deiner Firma oder Unterkunft an.', inhaber: 'Bitte gib den Namen des Inhabers an.', strasse: 'Bitte gib Straße und Hausnummer an.', plz: 'Bitte gib die Postleitzahl an.', ort: 'Bitte gib den Ort an.', email: 'Bitte gib eine E-Mail-Adresse an.' }
|
||||||
|
|
||||||
|
const textfeld = (optionen = {}) => h('input', { type: 'text', autocomplete: 'off', ...optionen })
|
||||||
|
const istHttps = (url) => { try { return new URL(url).protocol === 'https:' } catch { return false } }
|
||||||
|
|
||||||
|
export async function render(panel, ctx = {}) {
|
||||||
|
const projectId = ctx.projectId
|
||||||
|
const q = `projectId=${enc(projectId)}`
|
||||||
|
const nurLesen = ctx.nurLesen === true
|
||||||
|
const antwort = await ctx.request(`/api/unterkunft/einstellungen?${q}`)
|
||||||
|
const e = antwort?.einstellungen && typeof antwort.einstellungen === 'object' ? antwort.einstellungen : {}
|
||||||
|
const stripe = antwort?.stripe || ctx.status?.stripe || { status: 'keins' }
|
||||||
|
const rechnungenVorhanden = (antwort?.rechnungenVorhanden ?? ctx.status?.rechnungenVorhanden) === true
|
||||||
|
const melde = (text, art) => { if (typeof ctx.zeigeMeldung === 'function') ctx.zeigeMeldung(text, art) }
|
||||||
|
const neu = () => render(panel, ctx).catch((err) => panel.replaceChildren(h('p', { class: 'sh-fehler', role: 'alert' }, err.message)))
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------- Felder --- */
|
||||||
|
const firma = textfeld({ maxlength: 120, autocomplete: 'organization' }); firma.value = zeichen(e.firma)
|
||||||
|
const inhaber = textfeld({ maxlength: 120 }); inhaber.value = zeichen(e.inhaber)
|
||||||
|
const strasse = textfeld({ maxlength: 120, autocomplete: 'street-address' }); strasse.value = zeichen(e.strasse)
|
||||||
|
const plz = textfeld({ maxlength: 10, inputmode: 'numeric', autocomplete: 'postal-code' }); plz.value = zeichen(e.plz)
|
||||||
|
const ort = textfeld({ maxlength: 80, autocomplete: 'address-level2' }); ort.value = zeichen(e.ort)
|
||||||
|
const email = h('input', { type: 'email', maxlength: 254, autocomplete: 'email' }); email.value = zeichen(e.email)
|
||||||
|
const telefon = h('input', { type: 'tel', maxlength: 40, autocomplete: 'tel' }); telefon.value = zeichen(e.telefon)
|
||||||
|
const steuernummer = textfeld({ maxlength: 30 }); steuernummer.value = zeichen(e.steuernummer)
|
||||||
|
const ustId = textfeld({ maxlength: 20, placeholder: 'DE123456789' }); ustId.value = zeichen(e.ustId)
|
||||||
|
const praefix = textfeld({ maxlength: 8, placeholder: 'RE' }); praefix.value = zeichen(e.rechnungsPraefix) || 'RE'
|
||||||
|
if (rechnungenVorhanden) { praefix.disabled = true; praefix.setAttribute('aria-disabled', 'true') }
|
||||||
|
const auftrag = h('input', { type: 'checkbox' }); auftrag.checked = e.rechnungAuftrag === true
|
||||||
|
const iban = textfeld({ maxlength: 40 }); iban.value = zeichen(e.iban)
|
||||||
|
const bic = textfeld({ maxlength: 11 }); bic.value = zeichen(e.bic)
|
||||||
|
const bank = textfeld({ maxlength: 80 }); bank.value = zeichen(e.bank)
|
||||||
|
const akzent = h('input', { type: 'color' }); akzent.value = /^#[0-9a-fA-F]{6}$/.test(zeichen(e.akzentfarbe)) ? e.akzentfarbe : '#111111'
|
||||||
|
const storno = h('textarea', { rows: 6, maxlength: 5000 }); storno.value = zeichen(e.stornobedingungen)
|
||||||
|
const online = h('input', { type: 'checkbox' }); online.checked = e.onlineZahlung === true && stripe.status === 'aktiv'
|
||||||
|
|
||||||
|
const auftragHinweis = h('div', { class: 'sh-box sh-box-warn', role: 'note', 'data-hinweis': 'auftrag' }, AUFTRAG_FEHLT)
|
||||||
|
const zeigeAuftrag = () => { auftragHinweis.hidden = auftrag.checked }
|
||||||
|
auftrag.addEventListener('change', zeigeAuftrag)
|
||||||
|
zeigeAuftrag()
|
||||||
|
|
||||||
|
const musterKnopf = h('button', {
|
||||||
|
type: 'button', class: 'kb-btn-outline', 'data-aktion': 'storno-muster',
|
||||||
|
onclick: () => { storno.value = STORNO_MUSTER },
|
||||||
|
}, 'Mustertext übernehmen')
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ Stripe --- */
|
||||||
|
let beschaeftigt = false
|
||||||
|
async function stripeAktion(pfad, { weiter = false } = {}) {
|
||||||
|
if (beschaeftigt || nurLesen) return
|
||||||
|
beschaeftigt = true
|
||||||
|
for (const k of stripeKarte.querySelectorAll('button')) k.setAttribute('disabled', '')
|
||||||
|
try {
|
||||||
|
const a = await ctx.request(`/api/unterkunft/stripe/${pfad}?${q}`, { method: 'POST' })
|
||||||
|
if (weiter) {
|
||||||
|
if (!istHttps(a?.url)) throw new Error(STRIPE_FEHLER)
|
||||||
|
melde('Du wirst zu Stripe weitergeleitet …', 'ok')
|
||||||
|
window.location.href = a.url
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (typeof ctx.neuLaden === 'function') { try { await ctx.neuLaden() } catch { /* der Stand erscheint beim naechsten Laden */ } }
|
||||||
|
await neu()
|
||||||
|
} catch (err) {
|
||||||
|
melde(err?.message || 'Das hat nicht geklappt. Bitte versuch es noch einmal.', 'fehler')
|
||||||
|
for (const k of stripeKarte.querySelectorAll('button')) k.removeAttribute('disabled')
|
||||||
|
} finally {
|
||||||
|
beschaeftigt = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const knopf = (text, aktion, pfad, optionen, art = 'kb-btn-outline') => h('button', { type: 'button', class: art, 'data-aktion': aktion, onclick: () => stripeAktion(pfad, optionen) }, text)
|
||||||
|
const zustandText = { keins: 'Noch nicht verbunden', onboarding: 'Angaben bei Stripe noch offen', aktiv: 'Verbunden und aktiv' }
|
||||||
|
const status = ['keins', 'onboarding', 'aktiv'].includes(stripe.status) ? stripe.status : 'keins'
|
||||||
|
const stripeKnoepfe = nurLesen ? [] : status === 'keins'
|
||||||
|
? [knopf('Stripe-Konto verbinden', 'stripe-verbinden', 'verbinden', { weiter: true }, 'kb-btn-primary'),
|
||||||
|
stripe.vorhandenesShopKonto === true ? knopf('Vorhandenes Webshop-Konto verwenden', 'stripe-vorhanden', 'vorhandenes-konto') : null]
|
||||||
|
: status === 'onboarding'
|
||||||
|
? [knopf('Angaben bei Stripe ergänzen', 'stripe-verbinden', 'verbinden', { weiter: true }, 'kb-btn-primary'), knopf('Status aktualisieren', 'stripe-aktualisieren', 'aktualisieren')]
|
||||||
|
: [knopf('Status aktualisieren', 'stripe-aktualisieren', 'aktualisieren')]
|
||||||
|
const stripeKarte = abschnitt('Stripe-Konto für Online-Zahlung', 'Gäste können die Buchung online bezahlen. Das Geld geht direkt auf dein Stripe-Konto.',
|
||||||
|
h('p', { 'data-stripe-status': status, class: status === 'aktiv' ? 'sh-ok' : 'sh-offen' }, zustandText[status]),
|
||||||
|
h('div', { class: 'sh-aktionen' }, ...stripeKnoepfe))
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------- Formular --- */
|
||||||
|
const speichern = h('button', { type: 'submit', class: 'kb-btn-primary', 'data-aktion': 'einstellungen-speichern' }, 'Einstellungen speichern')
|
||||||
|
const form = h('form', { class: 'sh-form sh-einstellungen', novalidate: true, 'aria-label': 'Einstellungen des Buchungskalenders' },
|
||||||
|
abschnitt('Deine Firmendaten', 'Diese Angaben stehen auf der Buchungsseite und auf den Rechnungen an deine Gäste. Pflichtfelder sind mit * markiert.',
|
||||||
|
feld('firma', 'Firma oder Name der Unterkunft *', firma),
|
||||||
|
feld('inhaber', 'Inhaber *', inhaber),
|
||||||
|
feld('strasse', 'Straße und Hausnummer *', strasse),
|
||||||
|
feld('plz', 'Postleitzahl *', plz),
|
||||||
|
feld('ort', 'Ort *', ort),
|
||||||
|
feld('email', 'E-Mail-Adresse *', email, 'An diese Adresse gehen neue Anfragen.'),
|
||||||
|
feld('telefon', 'Telefon (freiwillig)', telefon)),
|
||||||
|
abschnitt('Steuer', 'Eine der beiden Angaben ist Pflicht (§ 14 Abs. 4 UStG).',
|
||||||
|
feld('steuernummer', 'Steuernummer', steuernummer),
|
||||||
|
feld('ustId', 'USt-IdNr.', ustId, 'Zum Beispiel DE123456789.')),
|
||||||
|
abschnitt('Rechnungen', null,
|
||||||
|
feld('rechnungsPraefix', 'Rechnungspräfix', praefix, rechnungenVorhanden
|
||||||
|
? 'Der Präfix ist gesperrt, weil es schon Rechnungen gibt. Die Nummern bleiben so lückenlos.'
|
||||||
|
: '2 bis 8 Zeichen (A–Z, 0–9). Die Nummern lauten dann zum Beispiel RE-2026-0001. Du kannst den Präfix nur vor der ersten Rechnung ändern.'),
|
||||||
|
hakenFeld('rechnungAuftrag', auftrag, AUFTRAG_TEXT),
|
||||||
|
auftragHinweis),
|
||||||
|
abschnitt('Bankverbindung (freiwillig)', 'Erscheint als Hinweis, wenn Gäste vor Ort oder per Überweisung zahlen.',
|
||||||
|
feld('iban', 'IBAN', iban),
|
||||||
|
feld('bic', 'BIC', bic),
|
||||||
|
feld('bank', 'Name der Bank', bank)),
|
||||||
|
abschnitt('Darstellung', null, feld('akzentfarbe', 'Akzentfarbe', akzent, 'Diese Farbe bekommen Knöpfe auf deiner Buchungsseite.')),
|
||||||
|
abschnitt('Stornobedingungen', 'Sie stehen auf der Buchungsseite und gelten für neue Freigaben.',
|
||||||
|
feld('stornobedingungen', 'Stornobedingungen', storno, STORNO_HINWEIS),
|
||||||
|
h('div', { class: 'sh-aktionen' }, musterKnopf)),
|
||||||
|
status === 'aktiv'
|
||||||
|
? abschnitt('Online-Zahlung', null, hakenFeld('onlineZahlung', online, 'Online-Zahlung anbieten', 'Gäste können bei der Buchung per Stripe bezahlen.'))
|
||||||
|
: null,
|
||||||
|
allgemeinerFehler(),
|
||||||
|
nurLesen ? null : h('div', { class: 'sh-aktionen' }, speichern))
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------- Speichern --- */
|
||||||
|
function sammle() {
|
||||||
|
const fehler = {}
|
||||||
|
const felder = { firma, inhaber, strasse, plz, ort, email }
|
||||||
|
for (const [name, el] of Object.entries(felder)) if (!el.value.trim()) fehler[name] = PFLICHT[name]
|
||||||
|
if (!steuernummer.value.trim() && !ustId.value.trim()) {
|
||||||
|
fehler.steuernummer = 'Bitte gib Steuernummer oder USt-IdNr. an.'
|
||||||
|
fehler.ustId = 'Bitte gib Steuernummer oder USt-IdNr. an.'
|
||||||
|
}
|
||||||
|
const p = praefix.value.trim().toUpperCase()
|
||||||
|
if (!rechnungenVorhanden && !/^[A-Z0-9]{2,8}$/.test(p)) fehler.rechnungsPraefix = 'Der Präfix braucht 2 bis 8 Zeichen aus A–Z und 0–9.'
|
||||||
|
if (Object.keys(fehler).length) return { fehler }
|
||||||
|
return {
|
||||||
|
einstellungen: {
|
||||||
|
firma: firma.value.trim(), inhaber: inhaber.value.trim(), strasse: strasse.value.trim(), plz: plz.value.trim(), ort: ort.value.trim(),
|
||||||
|
email: email.value.trim(), telefon: telefon.value.trim(), steuernummer: steuernummer.value.trim(), ustId: ustId.value.trim(),
|
||||||
|
iban: iban.value.trim(), bic: bic.value.trim(), bank: bank.value.trim(),
|
||||||
|
rechnungsPraefix: rechnungenVorhanden ? zeichen(e.rechnungsPraefix) || 'RE' : p,
|
||||||
|
rechnungAuftrag: auftrag.checked, akzentfarbe: akzent.value, stornobedingungen: storno.value.trim(),
|
||||||
|
onlineZahlung: status === 'aktiv' && online.checked,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let laeuft = false
|
||||||
|
form.addEventListener('submit', async (ev) => {
|
||||||
|
ev.preventDefault()
|
||||||
|
if (nurLesen || laeuft) return
|
||||||
|
laeuft = true
|
||||||
|
speichern.disabled = true
|
||||||
|
setzeFehler(form, {})
|
||||||
|
try {
|
||||||
|
const eingabe = sammle()
|
||||||
|
if (eingabe.fehler) {
|
||||||
|
setzeFehler(form, eingabe.fehler)
|
||||||
|
form.querySelector('[aria-invalid]')?.focus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await ctx.request(`/api/unterkunft/einstellungen?${q}`, { method: 'PUT', body: eingabe.einstellungen })
|
||||||
|
melde('Die Einstellungen sind gespeichert.', 'ok')
|
||||||
|
if (typeof ctx.neuLaden === 'function') { try { await ctx.neuLaden() } catch { /* gespeichert ist gespeichert */ } }
|
||||||
|
} catch (err) {
|
||||||
|
zeigeApiFehler(form, err)
|
||||||
|
} finally {
|
||||||
|
laeuft = false
|
||||||
|
speichern.disabled = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (nurLesen) for (const el of form.querySelectorAll('input, select, textarea, button')) el.disabled = true
|
||||||
|
panel.replaceChildren(stripeKarte, form)
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,33 @@
|
|||||||
/* Buchungskalender, Reiter „Rechnungen“: Platzhalter (Task 13/14 fuellt ihn). */
|
/* Buchungskalender, Reiter „Rechnungen“: Liste der Rechnungen und Storno-Rechnungen mit PDF-Link.
|
||||||
import { infoKarte } from './shop-dom.js'
|
API: GET /api/unterkunft/rechnungen?projectId= => { rechnungen:[{ id, nummer, datum, art:'rechnung'|'storno', gastName, bruttoCent, anfrageNummer? }] };
|
||||||
|
PDF: GET /api/unterkunft/rechnungen/<id>.pdf?projectId= (Einzel-PDF, kein ZIP). Fremdtext nur ueber textContent. render(panel, ctx). */
|
||||||
|
|
||||||
export function render(panel) {
|
import { abschnitt, datumText, euroText, h, infoKarte } from './shop-dom.js'
|
||||||
panel.replaceChildren(infoKarte('Rechnungen', 'Dieser Bereich wird gerade gebaut.'))
|
import { enc, zeichen } from './buchungen-uk-hilfe.js'
|
||||||
|
|
||||||
|
const ART = { rechnung: 'Rechnung', storno: 'Storno-Rechnung' }
|
||||||
|
const betragText = (cent) => {
|
||||||
|
if (!Number.isInteger(cent)) return ''
|
||||||
|
return cent < 0 ? `−${euroText(-cent)}` : euroText(cent)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function render(panel, ctx = {}) {
|
||||||
|
const q = `projectId=${enc(ctx.projectId)}`
|
||||||
|
const antwort = await ctx.request(`/api/unterkunft/rechnungen?${q}`)
|
||||||
|
const liste = Array.isArray(antwort?.rechnungen) ? antwort.rechnungen : []
|
||||||
|
if (!liste.length) {
|
||||||
|
panel.replaceChildren(infoKarte('Rechnungen', 'Noch keine Rechnungen. Sobald ein Gast bucht, erscheint hier die Rechnung.'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const zeilen = liste.map((r) => h('li', { class: 'bk-rechnung', 'data-rechnung': zeichen(r.id), 'data-art': zeichen(r.art) },
|
||||||
|
h('span', { class: 'bk-nummer' }, zeichen(r.nummer)),
|
||||||
|
h('span', { class: 'bk-zeitraum' }, datumText(zeichen(r.datum), { zeit: false })),
|
||||||
|
h('span', { class: 'bk-gast' }, zeichen(r.gastName)),
|
||||||
|
h('span', { class: `sh-pille ${r.art === 'storno' ? 'sh-pille-bad' : 'sh-pille-ok'}` }, ART[r.art] || 'Rechnung'),
|
||||||
|
h('span', { class: 'bk-betrag' }, betragText(r.bruttoCent)),
|
||||||
|
h('a', {
|
||||||
|
class: 'kb-btn-outline', 'data-aktion': 'pdf', href: `/api/unterkunft/rechnungen/${enc(zeichen(r.id))}.pdf?${q}`, target: '_blank', rel: 'noopener',
|
||||||
|
'aria-label': `PDF zu ${zeichen(r.nummer)} herunterladen`,
|
||||||
|
}, 'PDF')))
|
||||||
|
panel.replaceChildren(abschnitt('Rechnungen', 'Rechnungen und Storno-Rechnungen deiner Gäste, ausgestellt in deinem Namen.', h('ul', { class: 'bk-liste' }, zeilen)))
|
||||||
}
|
}
|
||||||
@@ -55,3 +55,11 @@
|
|||||||
.bk-vorschau { font-weight: 700; }
|
.bk-vorschau { font-weight: 700; }
|
||||||
.bk-import { display: flex; align-items: center; gap: 4px 12px; flex-wrap: wrap; }
|
.bk-import { display: flex; align-items: center; gap: 4px 12px; flex-wrap: wrap; }
|
||||||
.bk-anleitung details { margin: 8px 0; }
|
.bk-anleitung details { margin: 8px 0; }
|
||||||
|
|
||||||
|
/* Rechnungen */
|
||||||
|
.bk-rechnung {
|
||||||
|
display: grid; grid-template-columns: 110px 100px 1fr auto auto auto; gap: 8px 14px; align-items: center; padding: 12px 16px;
|
||||||
|
border-radius: 12px; border: 1px solid var(--kb-border); background: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
.bk-rechnung .sh-pille { margin: 0; }
|
||||||
|
@media (max-width: 720px) { .bk-rechnung { grid-template-columns: 1fr auto; } }
|
||||||
+29
-2
@@ -5,6 +5,7 @@
|
|||||||
Helfer: shop-dom.js (h, request, Karten), Styles: buchungen.css (bk-) und shop.css (sh-). Fremdtext nur ueber textContent. */
|
Helfer: shop-dom.js (h, request, Karten), Styles: buchungen.css (bk-) und shop.css (sh-). Fremdtext nur ueber textContent. */
|
||||||
|
|
||||||
import { fehlerKarte, h, infoKarte, request } from './shop-dom.js'
|
import { fehlerKarte, h, infoKarte, request } from './shop-dom.js'
|
||||||
|
import { anfrageKarte, renderAnfrageFormular } from './buchungen-anfrage.js'
|
||||||
|
|
||||||
/** Die Reiter: jeder ein Modul mit render(panel, ctx), erst beim ersten Oeffnen geladen. */
|
/** Die Reiter: jeder ein Modul mit render(panel, ctx), erst beim ersten Oeffnen geladen. */
|
||||||
export const REITER = [
|
export const REITER = [
|
||||||
@@ -24,11 +25,14 @@ export function vergissAuswahl() {
|
|||||||
}
|
}
|
||||||
let S = null // aktuelle Sitzung; eine alte Antwort darf eine neuere nicht ueberschreiben
|
let S = null // aktuelle Sitzung; eine alte Antwort darf eine neuere nicht ueberschreiben
|
||||||
|
|
||||||
|
const textOr = (x) => (typeof x === 'string' && x.trim() ? x : '')
|
||||||
const zustandVon = (projekt) => projekt?.features?.unterkunft?.state || 'off'
|
const zustandVon = (projekt) => projekt?.features?.unterkunft?.state || 'off'
|
||||||
|
|
||||||
export function initBuchungen(root, { projectId } = {}) {
|
export function initBuchungen(root, { projectId } = {}) {
|
||||||
if (!root) return Promise.resolve()
|
if (!root) return Promise.resolve()
|
||||||
return starte(root, projectId)
|
const wunsch = projectId || root.dataset?.wunschProjekt || '' // die Kachel in Funktionen legt ihr Projekt hier ab
|
||||||
|
if (root.dataset) delete root.dataset.wunschProjekt
|
||||||
|
return starte(root, wunsch)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function starte(root, projectId) {
|
async function starte(root, projectId) {
|
||||||
@@ -109,10 +113,33 @@ function zeichneAnsicht(s, { status, aktuell }) {
|
|||||||
s.status = status
|
s.status = status
|
||||||
const zustand = status?.state || zustandVon(s.projekt)
|
const zustand = status?.state || zustandVon(s.projekt)
|
||||||
if (zustand === 'setup' || zustand === 'active' || zustand === 'disabled') zeigeReiter(s, { status, aktuell, zustand })
|
if (zustand === 'setup' || zustand === 'active' || zustand === 'disabled') zeigeReiter(s, { status, aktuell, zustand })
|
||||||
else if (zustand === 'off') s.buehne.replaceChildren(infoKarte('Buchungskalender', 'Du hast den Buchungskalender noch nicht angefragt. Die Anfrage kommt in Kürze hier.')) // Task 14: Anfrage-/Bestellkarte
|
else if (zustand === 'off') zeigeAnfrage(s, { status, aktuell })
|
||||||
else s.buehne.replaceChildren(infoKarte('Buchungskalender', 'Der Stand deines Buchungskalenders ist gerade nicht lesbar. Bitte lade die Seite neu.'))
|
else s.buehne.replaceChildren(infoKarte('Buchungskalender', 'Der Stand deines Buchungskalenders ist gerade nicht lesbar. Bitte lade die Seite neu.'))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Zustand off: Anfrageformular, Karte „wird geprueft“ / „abgelehnt“ oder (angenommen) der Hinweis auf die Bestellung. */
|
||||||
|
function zeigeAnfrage(s, { status, aktuell }) {
|
||||||
|
const projectId = s.projekt.projectId
|
||||||
|
const ansicht = status?.anfrage
|
||||||
|
const neuLaden = () => zeigeProjekt(s)
|
||||||
|
if (ansicht?.status === 'angenommen') {
|
||||||
|
s.buehne.replaceChildren(infoKarte('Deine Anfrage wurde angenommen', 'Wir melden uns bei dir mit den nächsten Schritten zur Bestellung.', { chat: true }))
|
||||||
|
} else if (ansicht?.status === 'offen') {
|
||||||
|
s.buehne.replaceChildren(anfrageKarte(ansicht, { projectId, neuLaden }))
|
||||||
|
} else if (ansicht?.status === 'abgelehnt' && !s.neueAnfrage) {
|
||||||
|
s.buehne.replaceChildren(anfrageKarte(ansicht, { projectId, neu: () => { s.neueAnfrage = true; zeigeAnfrage(s, { status, aktuell }) } }))
|
||||||
|
} else {
|
||||||
|
const wurzel = h('div', { class: 'sh-wurzel' })
|
||||||
|
s.buehne.replaceChildren(wurzel)
|
||||||
|
renderAnfrageFormular(wurzel, {
|
||||||
|
projectId,
|
||||||
|
vorgabe: ansicht?.anfrage || null,
|
||||||
|
preis: textOr(s.projekt?.features?.unterkunft?.priceLabel) || '199 €',
|
||||||
|
nachSenden: async () => { s.neueAnfrage = false; if (aktuell()) await neuLaden() },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function zeigeReiter(s, { status, aktuell, zustand }) {
|
function zeigeReiter(s, { status, aktuell, zustand }) {
|
||||||
const neuLaden = async () => {
|
const neuLaden = async () => {
|
||||||
const neu = await ladeStatus(s.projekt.projectId)
|
const neu = await ladeStatus(s.projekt.projectId)
|
||||||
|
|||||||
+52
-1
@@ -260,6 +260,51 @@ function shopTile(projects, features) {
|
|||||||
</article>`
|
</article>`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Buchungskalender (Schluessel unterkunft): Kachel nur, wenn der Server die Funktion freigibt (unterkunft.verfuegbar der Antwort, Schalter
|
||||||
|
UNTERKUNFT_ENABLED); sonst gar keine Kachel. Die Ansicht #buchungen (buchungen.js) zeigt Anfrage und Stand; die Kachel kennt nur den Zustand
|
||||||
|
des Funktionsdokuments (projects[].features.unterkunft: off | ordered | setup | active | disabled), Preis aus features.unterkunft.priceLabel,
|
||||||
|
Rueckfall 199 €. */
|
||||||
|
const UNTERKUNFT_PREIS = '199 €'
|
||||||
|
const UNTERKUNFT_TEXT = 'Belegungskalender auf deiner Website, Anfragen freigeben, Rechnung und Online-Zahlung'
|
||||||
|
const unterkunftOf = (project) => project.features?.unterkunft || { state: 'off' }
|
||||||
|
|
||||||
|
function pickUnterkunftProject(projects) {
|
||||||
|
return projects.find((p) => unterkunftOf(p).state === 'active')
|
||||||
|
|| projects.find((p) => isSetup(unterkunftOf(p).state))
|
||||||
|
|| projects.find((p) => unterkunftOf(p).state === 'disabled')
|
||||||
|
|| projects[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
function unterkunftTile(projects, features) {
|
||||||
|
const preis = textOr(features?.unterkunft?.priceLabel, UNTERKUNFT_PREIS)
|
||||||
|
const active = projects.filter((p) => unterkunftOf(p).state === 'active').length
|
||||||
|
const target = pickUnterkunftProject(projects)
|
||||||
|
const state = target ? unterkunftOf(target).state : 'off'
|
||||||
|
const knopf = (text) => `<button type="button" class="kb-btn-primary fn-tile-cta" data-view="buchungen" data-project="${esc(target.projectId)}">${text}</button>`
|
||||||
|
const ZUSTAENDE = {
|
||||||
|
off: [['Aus', 'off'], 'Buchungskalender anfragen'],
|
||||||
|
ordered: [['Zahlung offen', 'setup'], 'Zahlung offen'],
|
||||||
|
setup: [['In Einrichtung', 'setup'], 'In Einrichtung'],
|
||||||
|
active: [['Aktiv', 'on'], 'Buchungskalender öffnen'],
|
||||||
|
disabled: [['Abgeschaltet', 'off'], 'Buchungskalender ansehen'],
|
||||||
|
}
|
||||||
|
const [pill, knopfText] = ZUSTAENDE[state] || ZUSTAENDE.off
|
||||||
|
const action = target ? knopf(knopfText) : ''
|
||||||
|
let extra = ''
|
||||||
|
if (!target) extra = '<p class="fn-count">Sobald deine Website angelegt ist, kannst du den Buchungskalender anfragen.</p>'
|
||||||
|
else if (state === 'disabled') extra = '<p>Der Buchungskalender ist für diese Website abgeschaltet. Deine Buchungen kannst du noch ansehen.</p>'
|
||||||
|
const count = projects.length > 1 && active ? `<p class="fn-count">aktiv für ${active} von ${projects.length} Websites</p>` : ''
|
||||||
|
return `
|
||||||
|
<article class="fn-tile" data-fn-tile="unterkunft">
|
||||||
|
<div class="fn-tile-head">${svg('calendar')}<span class="fn-pill fn-pill-${pill[1]}">${pill[0]}</span></div>
|
||||||
|
<h3>Buchungskalender</h3>
|
||||||
|
<p>${UNTERKUNFT_TEXT}. ${esc(preis)} einmalig.</p>
|
||||||
|
${extra}
|
||||||
|
${count}
|
||||||
|
${action}
|
||||||
|
</article>`
|
||||||
|
}
|
||||||
|
|
||||||
/* Oeffnungszeiten live: Kachel nur, wenn der Schalter es erlaubt (zeiten.verfuegbar); sonst gibt es sie gar nicht (auch kein "Kommt bald").
|
/* Oeffnungszeiten live: Kachel nur, wenn der Schalter es erlaubt (zeiten.verfuegbar); sonst gibt es sie gar nicht (auch kein "Kommt bald").
|
||||||
Wie bei den anderen Funktionen steht sie inline, die Ansicht #zeiten (zeiten.js) laedt app.js. Alles, was sie zeigt, kommt mit der Liste
|
Wie bei den anderen Funktionen steht sie inline, die Ansicht #zeiten (zeiten.js) laedt app.js. Alles, was sie zeigt, kommt mit der Liste
|
||||||
(GET /api/funktionen): zeiten (Angebot) mit inklusive (true: im laufenden, aktiven Wartungs-Abo enthalten, einschaltbar | false: sonst, dann
|
(GET /api/funktionen): zeiten (Angebot) mit inklusive (true: im laufenden, aktiven Wartungs-Abo enthalten, einschaltbar | false: sonst, dann
|
||||||
@@ -386,12 +431,13 @@ const soonTile = (icon, title, text) => `
|
|||||||
|
|
||||||
export async function initFunktionen(root) {
|
export async function initFunktionen(root) {
|
||||||
if (!root) return
|
if (!root) return
|
||||||
const { projects = [], termine, features, shop, zeiten } = await request('/api/funktionen')
|
const { projects = [], termine, features, shop, zeiten, unterkunft } = await request('/api/funktionen')
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
<div class="fn-tiles">
|
<div class="fn-tiles">
|
||||||
${newsTile(projects, newsOffer(features).price)}
|
${newsTile(projects, newsOffer(features).price)}
|
||||||
${termine?.verfuegbar ? termineTile(projects, termine) : soonTile('calendar', 'Terminplaner', TERMINE_TEXT)}
|
${termine?.verfuegbar ? termineTile(projects, termine) : soonTile('calendar', 'Terminplaner', TERMINE_TEXT)}
|
||||||
${shop?.verfuegbar ? shopTile(projects, features) : ''}
|
${shop?.verfuegbar ? shopTile(projects, features) : ''}
|
||||||
|
${unterkunft?.verfuegbar === true ? unterkunftTile(projects, features) : ''}
|
||||||
${zeiten?.verfuegbar === true ? zeitenTile(projects, zeiten) : ''}
|
${zeiten?.verfuegbar === true ? zeitenTile(projects, zeiten) : ''}
|
||||||
${analyseTile(projects)}
|
${analyseTile(projects)}
|
||||||
</div>`
|
</div>`
|
||||||
@@ -407,6 +453,11 @@ export async function initFunktionen(root) {
|
|||||||
const body = document.getElementById('webshop-body')
|
const body = document.getElementById('webshop-body')
|
||||||
if (body) body.dataset.wunschProjekt = ev.currentTarget.dataset.project
|
if (body) body.dataset.wunschProjekt = ev.currentTarget.dataset.project
|
||||||
})
|
})
|
||||||
|
// Die Buchungskalender-Ansicht liest das gewaehlte Projekt aus ihrem Behaelter (buchungen.js, initBuchungen)
|
||||||
|
root.querySelector('[data-fn-tile="unterkunft"] .fn-tile-cta[data-project]')?.addEventListener('click', (ev) => {
|
||||||
|
const body = document.getElementById('buchungen-body')
|
||||||
|
if (body) body.dataset.wunschProjekt = ev.currentTarget.dataset.project
|
||||||
|
})
|
||||||
// Die Zeiten-Ansicht liest Projekt und Weg (Behalten) aus ihrem Behaelter (zeiten.js, initZeiten); der Klick steigt danach zum Navigationshandler auf
|
// Die Zeiten-Ansicht liest Projekt und Weg (Behalten) aus ihrem Behaelter (zeiten.js, initZeiten); der Klick steigt danach zum Navigationshandler auf
|
||||||
root.querySelector('[data-fn-tile="zeiten"]')?.addEventListener('click', (ev) => {
|
root.querySelector('[data-fn-tile="zeiten"]')?.addEventListener('click', (ev) => {
|
||||||
const knopf = ev.target?.closest?.('button[data-view="zeiten"]')
|
const knopf = ev.target?.closest?.('button[data-view="zeiten"]')
|
||||||
|
|||||||
@@ -467,3 +467,244 @@ test('Kalender: vergangene Tage lassen sich nicht sperren', mit(ukServer(), asyn
|
|||||||
assert.match(fx.text(fx.finde(panel, '[data-hinweis]')), /Vergangene Tage/)
|
assert.match(fx.text(fx.finde(panel, '[data-hinweis]')), /Vergangene Tage/)
|
||||||
assert.equal(fx.finde(panel, '[data-aktion="sperren-senden"]'), null)
|
assert.equal(fx.finde(panel, '[data-aktion="sperren-senden"]'), null)
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
/* ===================================================== Task 14: Rechnungen, Einstellungen, Anfrage ===================================================== */
|
||||||
|
const { render: renderRechnungen } = await import('../public/buchungen-tab-rechnungen.js')
|
||||||
|
const { render: renderEinstellungen, STORNO_MUSTER, STORNO_HINWEIS, AUFTRAG_TEXT } = await import('../public/buchungen-tab-einstellungen.js')
|
||||||
|
const { renderAnfrageFormular, anfrageKarte } = await import('../public/buchungen-anfrage.js')
|
||||||
|
const { initBuchungen, vergissAuswahl } = await import('../public/buchungen.js')
|
||||||
|
|
||||||
|
const zeigeMit = async (fx, modul, ctxExtra = {}) => {
|
||||||
|
const ctx = { ...baueCtx(), ...ctxExtra }
|
||||||
|
const panel = h('div')
|
||||||
|
fx.document.body.append(panel)
|
||||||
|
await modul(panel, ctx)
|
||||||
|
await fx.ruhe()
|
||||||
|
return { panel, ctx }
|
||||||
|
}
|
||||||
|
const ES = { firma: 'Haus am See', inhaber: 'Mimi M.', strasse: 'Seeweg 1', plz: '78532', ort: 'Tuttlingen', email: 'mimi@example.com', telefon: '', steuernummer: '12/345/67890', ustId: '', iban: '', bic: '', bank: '', rechnungsPraefix: 'RE', akzentfarbe: '#336699', onlineZahlung: false, rechnungAuftrag: true, stornobedingungen: '' }
|
||||||
|
const esServer = (o = {}, antwort = {}) => server({ 'GET /api/unterkunft/einstellungen': () => ({ body: { einstellungen: ES, stripe: { status: 'keins', vorhandenesShopKonto: false }, rechnungenVorhanden: false, ...antwort } }), 'PUT /api/unterkunft/einstellungen': () => ({ body: {} }), ...o })
|
||||||
|
|
||||||
|
test('Rechnungen: Liste mit PDF-Link, Art und Betrag; Fremdtext nur als Text', mit(server({
|
||||||
|
'GET /api/unterkunft/rechnungen': () => ({ body: { rechnungen: [
|
||||||
|
{ id: 'r1', nummer: 'RE-2026-0001', datum: '2026-10-07', art: 'rechnung', gastName: 'Anna <b>M</b>', bruttoCent: 40650 },
|
||||||
|
{ id: 'r2', nummer: 'RE-2026-0002', datum: '2026-10-08', art: 'storno', gastName: 'Anna', bruttoCent: -40650 },
|
||||||
|
] } }),
|
||||||
|
}), async (fx) => {
|
||||||
|
const { panel } = await zeigeMit(fx, renderRechnungen)
|
||||||
|
assert.equal(fx.fetchLog[0].url, '/api/unterkunft/rechnungen?projectId=p1')
|
||||||
|
const z = fx.alle(panel, '[data-rechnung]')
|
||||||
|
assert.equal(z.length, 2)
|
||||||
|
assert.match(fx.text(z[0]), /RE-2026-0001/)
|
||||||
|
assert.match(fx.text(z[0]), /07\.10\.2026/)
|
||||||
|
assert.match(fx.text(z[0]), /Anna <b>M<\/b>/)
|
||||||
|
assert.match(fx.text(z[0]), /406,50\s€/)
|
||||||
|
assert.match(fx.text(z[1]), /Storno-Rechnung/)
|
||||||
|
assert.match(fx.text(z[1]), /−406,50\s€/)
|
||||||
|
assert.equal(fx.finde(z[0], '[data-aktion="pdf"]').getAttribute('href'), '/api/unterkunft/rechnungen/r1.pdf?projectId=p1')
|
||||||
|
assert.equal(fx.finde(z[0], 'b'), null)
|
||||||
|
}))
|
||||||
|
|
||||||
|
test('Rechnungen: leer → Hinweis', mit(server({ 'GET /api/unterkunft/rechnungen': () => ({ body: { rechnungen: [] } }) }), async (fx) => {
|
||||||
|
const { panel } = await zeigeMit(fx, renderRechnungen)
|
||||||
|
assert.match(fx.text(panel), /Noch keine Rechnungen/)
|
||||||
|
assert.equal(fx.alle(panel, '[data-rechnung]').length, 0)
|
||||||
|
}))
|
||||||
|
|
||||||
|
test('Einstellungen: Pflichtfelder markiert, leeres Formular sendet nicht und zeigt Feldfehler', mit(esServer({}, { einstellungen: { ...ES, firma: '', steuernummer: '', ustId: '' } }), async (fx) => {
|
||||||
|
const { panel } = await zeigeMit(fx, renderEinstellungen)
|
||||||
|
for (const n of ['firma', 'inhaber', 'strasse', 'plz', 'ort', 'email']) assert.match(fx.text(fx.finde(panel, `[data-feld="${n}"]`).parentNode.querySelector('label')), /\*/, n)
|
||||||
|
await fx.klick(fx.finde(panel, '[data-aktion="einstellungen-speichern"]'))
|
||||||
|
await fx.ruhe()
|
||||||
|
assert.equal(posts(fx).length, 0)
|
||||||
|
assert.match(fx.text(fx.finde(panel, '[data-fehler="firma"]')), /Firma/)
|
||||||
|
assert.match(fx.text(fx.finde(panel, '[data-fehler="steuernummer"]')), /Steuernummer oder USt-IdNr/)
|
||||||
|
assert.match(fx.text(fx.finde(panel, '[data-fehler="ustId"]')), /Steuernummer oder USt-IdNr/)
|
||||||
|
}))
|
||||||
|
|
||||||
|
test('Einstellungen: speichern sendet die Felder ohne Wrapper; Serverfehler am Feld', mit(esServer({ 'PUT /api/unterkunft/einstellungen': () => ({ status: 400, body: { fehler: 'ungueltig', felder: { iban: 'Bitte gib eine gültige IBAN an.' } } }) }), async (fx) => {
|
||||||
|
const { panel } = await zeigeMit(fx, renderEinstellungen)
|
||||||
|
await fx.setze(fx.finde(panel, '[data-feld="iban"]'), 'xx')
|
||||||
|
await fx.klick(fx.finde(panel, '[data-aktion="einstellungen-speichern"]'))
|
||||||
|
await fx.ruhe()
|
||||||
|
const put = posts(fx)[0]
|
||||||
|
assert.equal(put.url, '/api/unterkunft/einstellungen?projectId=p1')
|
||||||
|
assert.equal(put.body.firma, 'Haus am See')
|
||||||
|
assert.equal(put.body.rechnungsPraefix, 'RE')
|
||||||
|
assert.equal(put.body.rechnungAuftrag, true)
|
||||||
|
assert.equal(put.body.iban, 'xx')
|
||||||
|
assert.equal(put.body.onlineZahlung, false)
|
||||||
|
assert.match(fx.text(fx.finde(panel, '[data-fehler="iban"]')), /gültige IBAN/)
|
||||||
|
}))
|
||||||
|
|
||||||
|
test('Einstellungen: Präfix prüfen; gesperrt mit Hinweis, sobald Rechnungen existieren', async () => {
|
||||||
|
await mit(esServer(), async (fx) => {
|
||||||
|
const { panel } = await zeigeMit(fx, renderEinstellungen)
|
||||||
|
await fx.setze(fx.finde(panel, '[data-feld="rechnungsPraefix"]'), 'a')
|
||||||
|
await fx.klick(fx.finde(panel, '[data-aktion="einstellungen-speichern"]'))
|
||||||
|
await fx.ruhe()
|
||||||
|
assert.equal(posts(fx).length, 0)
|
||||||
|
assert.match(fx.text(fx.finde(panel, '[data-fehler="rechnungsPraefix"]')), /2 bis 8/)
|
||||||
|
await fx.setze(fx.finde(panel, '[data-feld="rechnungsPraefix"]'), 'ab1')
|
||||||
|
await fx.klick(fx.finde(panel, '[data-aktion="einstellungen-speichern"]'))
|
||||||
|
await fx.ruhe()
|
||||||
|
assert.equal(posts(fx)[0].body.rechnungsPraefix, 'AB1')
|
||||||
|
})()
|
||||||
|
await mit(esServer({}, { rechnungenVorhanden: true }), async (fx) => {
|
||||||
|
const { panel } = await zeigeMit(fx, renderEinstellungen)
|
||||||
|
const p = fx.finde(panel, '[data-feld="rechnungsPraefix"]')
|
||||||
|
assert.ok(p.disabled)
|
||||||
|
assert.match(fx.text(p.parentNode), /gesperrt/)
|
||||||
|
await fx.klick(fx.finde(panel, '[data-aktion="einstellungen-speichern"]'))
|
||||||
|
await fx.ruhe()
|
||||||
|
assert.equal(posts(fx)[0].body.rechnungsPraefix, 'RE')
|
||||||
|
})()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Einstellungen: Mustertext übernehmen füllt die Stornobedingungen, Hinweis „keine Rechtsberatung“', mit(esServer(), async (fx) => {
|
||||||
|
const { panel } = await zeigeMit(fx, renderEinstellungen)
|
||||||
|
const t = fx.finde(panel, '[data-feld="stornobedingungen"]')
|
||||||
|
assert.equal(t.value, '')
|
||||||
|
assert.match(fx.text(t.parentNode), new RegExp(STORNO_HINWEIS.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')))
|
||||||
|
await fx.klick(fx.finde(panel, '[data-aktion="storno-muster"]'))
|
||||||
|
assert.equal(t.value, STORNO_MUSTER)
|
||||||
|
assert.match(STORNO_MUSTER, /^Stornierung bis 14 Tage vor Anreise: kostenlos\./)
|
||||||
|
assert.match(STORNO_MUSTER, /Maßgeblich ist der Eingang bei uns\.$/)
|
||||||
|
await fx.klick(fx.finde(panel, '[data-aktion="einstellungen-speichern"]'))
|
||||||
|
await fx.ruhe()
|
||||||
|
assert.equal(posts(fx)[0].body.stornobedingungen, STORNO_MUSTER)
|
||||||
|
}))
|
||||||
|
|
||||||
|
test('Einstellungen: Auftrag zur Rechnungsstellung – ohne Haken Hinweis, mit Haken weg und rechnungAuftrag im PUT', mit(esServer({}, { einstellungen: { ...ES, rechnungAuftrag: false } }), async (fx) => {
|
||||||
|
const { panel } = await zeigeMit(fx, renderEinstellungen)
|
||||||
|
const box = fx.finde(panel, '[data-feld="rechnungAuftrag"]')
|
||||||
|
assert.match(fx.text(box.parentNode), new RegExp(AUFTRAG_TEXT.slice(0, 40)))
|
||||||
|
const hinweis = fx.finde(panel, '[data-hinweis="auftrag"]')
|
||||||
|
assert.equal(hinweis.hidden, false)
|
||||||
|
assert.match(fx.text(hinweis), /nicht bestätigen/)
|
||||||
|
await fx.klick(box)
|
||||||
|
assert.equal(hinweis.hidden, true)
|
||||||
|
await fx.klick(fx.finde(panel, '[data-aktion="einstellungen-speichern"]'))
|
||||||
|
await fx.ruhe()
|
||||||
|
assert.equal(posts(fx)[0].body.rechnungAuftrag, true)
|
||||||
|
}))
|
||||||
|
|
||||||
|
test('Einstellungen: Stripe nicht verbunden – Verbinden leitet nur auf https; „Vorhandenes Webshop-Konto“ nur wenn gemeldet', async () => {
|
||||||
|
await mit(esServer({ 'POST /api/unterkunft/stripe/verbinden': () => ({ body: { url: 'https://connect.stripe.com/x' } }) }), async (fx) => {
|
||||||
|
const { panel } = await zeigeMit(fx, renderEinstellungen)
|
||||||
|
assert.equal(fx.finde(panel, '[data-stripe-status]').getAttribute('data-stripe-status'), 'keins')
|
||||||
|
assert.equal(fx.finde(panel, '[data-aktion="stripe-vorhanden"]'), null)
|
||||||
|
assert.equal(fx.finde(panel, '[data-feld="onlineZahlung"]'), null)
|
||||||
|
await fx.klick(fx.finde(panel, '[data-aktion="stripe-verbinden"]'))
|
||||||
|
await fx.ruhe()
|
||||||
|
assert.equal(posts(fx)[0].url, '/api/unterkunft/stripe/verbinden?projectId=p1')
|
||||||
|
assert.equal(fx.window.location.href, 'https://connect.stripe.com/x')
|
||||||
|
})()
|
||||||
|
await mit(esServer({ 'POST /api/unterkunft/stripe/verbinden': () => ({ body: { url: 'javascript:alert(1)' } }) }), async (fx) => {
|
||||||
|
const { panel, ctx } = await zeigeMit(fx, renderEinstellungen)
|
||||||
|
await fx.klick(fx.finde(panel, '[data-aktion="stripe-verbinden"]'))
|
||||||
|
await fx.ruhe()
|
||||||
|
assert.notEqual(fx.window.location.href, 'javascript:alert(1)')
|
||||||
|
assert.equal(ctx.meldungen.at(-1).art, 'fehler')
|
||||||
|
})()
|
||||||
|
await mit(esServer({ 'POST /api/unterkunft/stripe/vorhandenes-konto': () => ({ body: { stripe: { status: 'aktiv' } } }) }, { stripe: { status: 'keins', vorhandenesShopKonto: true } }), async (fx) => {
|
||||||
|
const { panel } = await zeigeMit(fx, renderEinstellungen)
|
||||||
|
await fx.klick(fx.finde(panel, '[data-aktion="stripe-vorhanden"]'))
|
||||||
|
await fx.ruhe()
|
||||||
|
assert.equal(posts(fx)[0].url, '/api/unterkunft/stripe/vorhandenes-konto?projectId=p1')
|
||||||
|
assert.ok(fx.fetchLog.filter((a) => a.method === 'GET' && a.url.startsWith('/api/unterkunft/einstellungen')).length >= 2) // neu geladen
|
||||||
|
})()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Einstellungen: Stripe Onboarding offen und aktiv; Schalter Online-Zahlung nur bei aktiv', async () => {
|
||||||
|
await mit(esServer({ 'POST /api/unterkunft/stripe/aktualisieren': () => ({ body: { stripe: { status: 'aktiv' } } }) }, { stripe: { status: 'onboarding', vorhandenesShopKonto: true } }), async (fx) => {
|
||||||
|
const { panel } = await zeigeMit(fx, renderEinstellungen)
|
||||||
|
assert.equal(fx.finde(panel, '[data-stripe-status]').getAttribute('data-stripe-status'), 'onboarding')
|
||||||
|
assert.match(fx.text(panel), /Angaben bei Stripe/)
|
||||||
|
assert.ok(fx.finde(panel, '[data-aktion="stripe-aktualisieren"]'))
|
||||||
|
assert.equal(fx.finde(panel, '[data-aktion="stripe-vorhanden"]'), null)
|
||||||
|
assert.equal(fx.finde(panel, '[data-feld="onlineZahlung"]'), null)
|
||||||
|
})()
|
||||||
|
await mit(esServer({}, { einstellungen: { ...ES, onlineZahlung: true }, stripe: { status: 'aktiv', vorhandenesShopKonto: false } }), async (fx) => {
|
||||||
|
const { panel } = await zeigeMit(fx, renderEinstellungen)
|
||||||
|
assert.equal(fx.finde(panel, '[data-stripe-status]').getAttribute('data-stripe-status'), 'aktiv')
|
||||||
|
const schalter = fx.finde(panel, '[data-feld="onlineZahlung"]')
|
||||||
|
assert.ok(schalter.checked)
|
||||||
|
await fx.klick(schalter)
|
||||||
|
await fx.klick(fx.finde(panel, '[data-aktion="einstellungen-speichern"]'))
|
||||||
|
await fx.ruhe()
|
||||||
|
assert.equal(posts(fx)[0].body.onlineZahlung, false)
|
||||||
|
})()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Einstellungen: nur lesen sperrt Formular und Stripe-Knöpfe', mit(esServer(), async (fx) => {
|
||||||
|
const { panel } = await zeigeMit(fx, renderEinstellungen, { nurLesen: true })
|
||||||
|
assert.equal(fx.finde(panel, '[data-aktion="stripe-verbinden"]'), null)
|
||||||
|
assert.ok(fx.finde(panel, '[data-feld="firma"]').disabled)
|
||||||
|
assert.equal(fx.finde(panel, '[data-aktion="einstellungen-speichern"]'), null)
|
||||||
|
}))
|
||||||
|
|
||||||
|
test('Anfrageformular sendet { art, einheiten, portale, nachricht }; Feldfehler am Feld', async () => {
|
||||||
|
let antwort = { body: { anfrage: { status: 'offen' } } }
|
||||||
|
await mit((a) => (a.method === 'POST' ? antwort : { body: {} }), async (fx) => {
|
||||||
|
const wurzel = h('div')
|
||||||
|
fx.document.body.append(wurzel)
|
||||||
|
let danach = 0
|
||||||
|
renderAnfrageFormular(wurzel, { projectId: 'p1', nachSenden: async () => { danach += 1 } })
|
||||||
|
antwort = { status: 400, body: { fehler: 'ungueltig', felder: { art: 'Bitte wähle die Art deiner Unterkunft.' } } }
|
||||||
|
await fx.klick(fx.finde(wurzel, '[data-aktion="anfrage-senden"]'))
|
||||||
|
await fx.ruhe()
|
||||||
|
assert.match(fx.text(fx.finde(wurzel, '[data-fehler="art"]')), /Art deiner Unterkunft/)
|
||||||
|
assert.equal(danach, 0)
|
||||||
|
antwort = { body: { anfrage: { status: 'offen' } } }
|
||||||
|
await fx.setze(fx.finde(wurzel, '[data-feld="art"]'), 'ferienwohnung')
|
||||||
|
await fx.setze(fx.finde(wurzel, '[data-feld="einheiten"]'), '3')
|
||||||
|
await fx.klick(fx.finde(wurzel, '[data-feld="portale.airbnb"]'))
|
||||||
|
await fx.setze(fx.finde(wurzel, '[data-feld="nachricht"]'), ' Hallo ')
|
||||||
|
await fx.klick(fx.finde(wurzel, '[data-aktion="anfrage-senden"]'))
|
||||||
|
await fx.ruhe()
|
||||||
|
const p = posts(fx).at(-1)
|
||||||
|
assert.equal(p.url, '/api/unterkunft/anfrage-funktion?projectId=p1')
|
||||||
|
assert.deepEqual(p.body, { art: 'ferienwohnung', einheiten: 3, portale: ['airbnb'], nachricht: 'Hallo' })
|
||||||
|
assert.equal(danach, 1)
|
||||||
|
assert.match(fx.text(wurzel), /199 €/)
|
||||||
|
})()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Anfragekarte: offen mit Zurückziehen, abgelehnt mit Begründung als Text', async () => {
|
||||||
|
await mit(server(), async (fx) => {
|
||||||
|
let neuGeladen = 0
|
||||||
|
const k = anfrageKarte({ status: 'offen', at: '2026-10-07T10:00:00Z', anfrage: { art: 'monteur', einheiten: 2, portale: ['booking'], nachricht: 'x' } }, { projectId: 'p1', neuLaden: async () => { neuGeladen += 1 } })
|
||||||
|
fx.document.body.append(k)
|
||||||
|
assert.match(fx.text(k), /In Prüfung/)
|
||||||
|
assert.match(fx.text(k), /Monteurwohnung/)
|
||||||
|
assert.match(fx.text(k), /Booking\.com/)
|
||||||
|
await fx.klick(fx.finde(k, '[data-aktion="zurueckziehen"]'))
|
||||||
|
await fx.ruhe()
|
||||||
|
assert.equal(posts(fx)[0].url, '/api/unterkunft/anfrage-funktion/zurueckziehen?projectId=p1')
|
||||||
|
assert.equal(neuGeladen, 1)
|
||||||
|
const b = anfrageKarte({ status: 'abgelehnt', begruendung: 'Passt <i>nicht</i>' }, { projectId: 'p1', neu: () => {} })
|
||||||
|
assert.match(fx.text(b), /Passt <i>nicht<\/i>/)
|
||||||
|
assert.equal(fx.finde(b, 'i'), null)
|
||||||
|
assert.ok(fx.finde(b, '[data-aktion="neue-anfrage"]'))
|
||||||
|
})()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Ansicht #buchungen: Zustand off zeigt das Anfrageformular, Projekt vom Wunsch-Attribut', async () => {
|
||||||
|
vergissAuswahl()
|
||||||
|
await mit((a) => {
|
||||||
|
if (a.url === '/api/funktionen') return { body: { unterkunft: { verfuegbar: true }, projects: [{ projectId: 'p1', projectName: 'A', features: { unterkunft: { state: 'off' } } }, { projectId: 'p2', projectName: 'B', features: { unterkunft: { state: 'off' } } }] } }
|
||||||
|
if (a.url.startsWith('/api/unterkunft/status')) return { body: { state: 'off' } }
|
||||||
|
return { body: {} }
|
||||||
|
}, async (fx) => {
|
||||||
|
const root = h('div')
|
||||||
|
root.dataset.wunschProjekt = 'p2'
|
||||||
|
fx.document.body.append(root)
|
||||||
|
await initBuchungen(root)
|
||||||
|
await fx.ruhe()
|
||||||
|
assert.equal(fx.fetchLog[1].url, '/api/unterkunft/status?projectId=p2')
|
||||||
|
assert.ok(fx.finde(root, '[data-aktion="anfrage-senden"]'))
|
||||||
|
assert.match(fx.text(root), /Buchungskalender anfragen/)
|
||||||
|
})()
|
||||||
|
vergissAuswahl()
|
||||||
|
})
|
||||||
@@ -564,3 +564,20 @@ test('Webshop-Kachel: Projekt-ID maskiert, Klick legt das Wunschprojekt in #webs
|
|||||||
}
|
}
|
||||||
assert.equal(body.dataset.wunschProjekt, 'p7')
|
assert.equal(body.dataset.wunschProjekt, 'p7')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ---- Buchungskalender-Kachel: nur bei unterkunft.verfuegbar, Preis 199 €, Zustände wie die Webshop-Kachel
|
||||||
|
test('Buchungskalender-Kachel: nur bei unterkunft.verfuegbar; Text, Preis und Zustände', async () => {
|
||||||
|
const mitUk = (state) => project('a', undefined) && { ...project('a'), features: { news, unterkunft: { state } } }
|
||||||
|
const ohne = await renderMit([mitUk('off')], {})
|
||||||
|
assert.doesNotMatch(ohne, /data-fn-tile="unterkunft"/)
|
||||||
|
const zu = await renderMit([mitUk('off')], { unterkunft: { verfuegbar: false } })
|
||||||
|
assert.doesNotMatch(zu, /data-fn-tile="unterkunft"/)
|
||||||
|
const off = tile(await renderMit([mitUk('off')], { unterkunft: { verfuegbar: true } }), 'unterkunft')
|
||||||
|
assert.match(off, /<h3>Buchungskalender<\/h3>/)
|
||||||
|
assert.match(off, /Belegungskalender auf deiner Website, Anfragen freigeben, Rechnung und Online-Zahlung/)
|
||||||
|
assert.match(off, /199 €/)
|
||||||
|
assert.match(off, /Buchungskalender anfragen/)
|
||||||
|
assert.match(off, /data-view="buchungen" data-project="a"/)
|
||||||
|
const faelle = { active: /Aktiv[\s\S]*Buchungskalender öffnen/, setup: /In Einrichtung/, ordered: /Zahlung offen/, disabled: /Abgeschaltet[\s\S]*Buchungskalender ansehen/ }
|
||||||
|
for (const [state, re] of Object.entries(faelle)) assert.match(tile(await renderMit([mitUk(state)], { unterkunft: { verfuegbar: true } }), 'unterkunft'), re, state)
|
||||||
|
})
|
||||||
Reference in new issue
Block a user