Compare commits

..
3 Commits
6 changed files with 255 additions and 55 deletions

No files matched your search

+3
View File
@@ -104,6 +104,9 @@ export const DOCUMENTS = [
{ key: "avv-webshop", title: "AVV Webshop", liveUrl: "https://project.webklar.com/rechtliches/avv-webshop.html" },
// Oeffnungszeiten live: Schluessel hoechstens 36 Zeichen (WOMS-Grenze fuer Dokument-IDs); die Fassungen (PDF/HTML) laedt der Inhaber hoch
{ key: "leistungsbeschreibung-zeiten", title: "Leistungsbeschreibung Öffnungszeiten live", liveUrl: "https://project.webklar.com/vertragsunterlagen/zeiten-leistungsbeschreibung.html" },
// Buchungskalender (Funktion unterkunft): Alias-Seiten ohne Fassungsdatum wie beim Webshop; die Fassungen laedt der Inhaber hoch
{ key: "leistungsbeschreibung-unterkunft", title: "Leistungsbeschreibung Buchungskalender", liveUrl: "https://project.webklar.com/rechtliches/leistungsbeschreibung-unterkunft.html" },
{ key: "avv-unterkunft", title: "AVV Buchungskalender", liveUrl: "https://project.webklar.com/rechtliches/avv-unterkunft.html" },
].map((d, i) => ({ ...d, sort: i + 1 }));
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+83 -34
View File
@@ -2,7 +2,7 @@ import { Fragment, useCallback, useEffect, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import { Card, Badge, Button, EmptyState } from '../ui'
import { copyText } from '../CopyableCredential'
import { getFunktionen, setFeature, getTermineAnleitung, getShop, entscheideShopAnfrage } from '../../lib/teamApi'
import { getFunktionen, setFeature, getTermineAnleitung, getShop, entscheideShopAnfrage, getUnterkunft, entscheideUnterkunftAnfrage } from '../../lib/teamApi'
import {
FUNKTIONEN, ZUSTAND, AKTION_LABEL, funktionOf, aktionen, mergeFunktion, siteKeyOf, abgeschaltetText,
hindernisText, panelModell, anleitungOffen, textformNoetig, textformAnsicht, textformBereit, speicherStatus,
@@ -115,7 +115,7 @@ function AnleitungPanel({ customerId, projectId, feature, reloadKey }) {
)
}
// Zustimmung des Kunden in Textform (Terminplaner und Webshop im Modus on): Haken, Datum, Weg und die Textvorlage fuer den Chat. Die Pflichtangaben
// Zustimmung des Kunden in Textform (Terminplaner, Webshop und Buchungskalender im Modus on): Haken, Datum, Weg und die Textvorlage fuer den Chat. Die Pflichtangaben
// prueft textformBereit (funktionenView); das Portal prueft sie noch einmal. Links nur als geprueftes https (textformAnsicht).
function TextformPanel({ funktion, feld, value, onChange, projectId, today, disabled }) {
const ansicht = textformAnsicht(feld, funktion)
@@ -160,21 +160,18 @@ function TextformPanel({ funktion, feld, value, onChange, projectId, today, disa
)
}
// Webshop je Projekt: Anfrage des Kunden (annehmen / ablehnen mit Begruendung), Stripe-Stand und Kennzahlen. Die Entscheidung geht ans Portal
// (POST /shop/:customerId/anfrage); danach laden Funktionen und Shop neu (onDecided). Texte sind ausschliesslich React-Textknoten.
function ShopPanel({ customerId, shop, onDecided }) {
// Entscheidung ueber eine Anfrage (Webshop und Buchungskalender): Annehmen / Ablehnen mit Begruendung. Die Entscheidung geht ueber entscheide
// ans Portal; danach laden Funktionen und Bereich neu (onDecided). Texte sind ausschliesslich React-Textknoten.
function EntscheidungBlock({ projectId, anfrage, entscheide, onDecided }) {
const [frage, setFrage] = useState(null) // null | 'annehmen' | 'ablehnen'
const [begruendung, setBegruendung] = useState('')
const [busy, setBusy] = useState(false)
const [status, setStatus] = useState(null) // { ok, text }
const a = shop.anfrage
const [tone, label] = anfrageStatus(a?.status)
const stripe = shop.stripe || { accountId: false, status: null }
const entscheiden = async () => {
const run = async () => {
setBusy(true)
setStatus(null)
try {
const antwort = await entscheideShopAnfrage(customerId, { projectId: shop.projectId, entscheidung: frage, begruendung })
const antwort = await entscheide({ projectId, entscheidung: frage, begruendung })
setFrage(null)
setBegruendung('')
setStatus(entscheidungStatus(antwort))
@@ -189,32 +186,15 @@ function ShopPanel({ customerId, shop, onDecided }) {
}
const ablehnenFehlt = frage === 'ablehnen' && !begruendungOk(begruendung)
return (
<div className="funktionen-shop">
<div className="funktionen-panel-head">
<strong>Webshop: {shop.projectName}</strong>
{a && <Badge tone={tone}>Anfrage: {label}</Badge>}
<Badge tone={shop.geoeffnet ? 'ok' : 'muted'}>{shop.geoeffnet ? 'Geöffnet' : 'Geschlossen'}</Badge>
<Badge tone={stripeTon(stripe.status, !!stripe.accountId)}>Stripe: {stripeText(stripe.status, !!stripe.accountId)}</Badge>
</div>
{a && (
<dl className="kv">
{anfrageZeilen(a.anfrage).map((z) => (
<Fragment key={z.label}><dt>{z.label}</dt><dd>{z.text}</dd></Fragment>
))}
<dt>Eingegangen</dt><dd>{a.at ? fmtDateTime(a.at) : '–'}</dd>
{a.entschiedenAt && <><dt>Entschieden</dt><dd>{fmtDateTime(a.entschiedenAt)}</dd></>}
{a.begruendung && <><dt>Begründung</dt><dd>{a.begruendung}</dd></>}
</dl>
)}
<div className="muted small">Artikel: {shop.zahlen?.artikel ?? 0} · Offene Bestellungen: {shop.zahlen?.offeneBestellungen ?? 0}</div>
{a?.status === 'offen' && (
<>
{anfrage?.status === 'offen' && (
frage ? (
<div className="flex items-center gap-2 wrap">
<span>{frage === 'annehmen' ? 'Anfrage annehmen? Der Kunde bekommt eine Mail.' : 'Anfrage ablehnen? Der Kunde bekommt die Begründung per Mail.'}</span>
{frage === 'ablehnen' && (
<textarea className="textarea funktionen-begruendung" required rows={3} maxLength={BEGRUENDUNG_MAX} placeholder="Begründung (Pflicht)" aria-label="Begründung (Pflicht)" value={begruendung} onChange={(e) => setBegruendung(e.target.value)} />
)}
<Button type="button" variant="primary" onClick={entscheiden} disabled={busy || ablehnenFehlt}>{busy ? 'Speichert …' : 'Ja'}</Button>
<Button type="button" variant="primary" onClick={run} disabled={busy || ablehnenFehlt}>{busy ? 'Speichert …' : 'Ja'}</Button>
<Button type="button" onClick={() => setFrage(null)} disabled={busy}>Abbrechen</Button>
</div>
) : (
@@ -225,6 +205,61 @@ function ShopPanel({ customerId, shop, onDecided }) {
)
)}
{status && <div className={`msg ${status.ok ? 'ok' : 'err'}`} role={status.ok ? undefined : 'alert'}>{status.text}</div>}
</>
)
}
// Zeilen der Anfrage samt Eingang, Entscheidung und Begruendung
function AnfrageListe({ a, art }) {
return (
<dl className="kv">
{anfrageZeilen(a.anfrage, art).map((z) => (
<Fragment key={z.label}><dt>{z.label}</dt><dd>{z.text}</dd></Fragment>
))}
<dt>Eingegangen</dt><dd>{a.at ? fmtDateTime(a.at) : '–'}</dd>
{a.entschiedenAt && <><dt>Entschieden</dt><dd>{fmtDateTime(a.entschiedenAt)}</dd></>}
{a.begruendung && <><dt>Begründung</dt><dd>{a.begruendung}</dd></>}
</dl>
)
}
// Webshop je Projekt: Anfrage des Kunden (annehmen / ablehnen mit Begruendung), Stripe-Stand und Kennzahlen. Die Entscheidung geht ans Portal
// (POST /shop/:customerId/anfrage).
function ShopPanel({ customerId, shop, onDecided }) {
const a = shop.anfrage
const [tone, label] = anfrageStatus(a?.status)
const stripe = shop.stripe || { accountId: false, status: null }
return (
<div className="funktionen-shop">
<div className="funktionen-panel-head">
<strong>Webshop: {shop.projectName}</strong>
{a && <Badge tone={tone}>Anfrage: {label}</Badge>}
<Badge tone={shop.geoeffnet ? 'ok' : 'muted'}>{shop.geoeffnet ? 'Geöffnet' : 'Geschlossen'}</Badge>
<Badge tone={stripeTon(stripe.status, !!stripe.accountId)}>Stripe: {stripeText(stripe.status, !!stripe.accountId)}</Badge>
</div>
{a && <AnfrageListe a={a} art="shop" />}
<div className="muted small">Artikel: {shop.zahlen?.artikel ?? 0} · Offene Bestellungen: {shop.zahlen?.offeneBestellungen ?? 0}</div>
<EntscheidungBlock projectId={shop.projectId} anfrage={a} entscheide={(body) => entscheideShopAnfrage(customerId, body)} onDecided={onDecided} />
</div>
)
}
// Buchungskalender je Projekt: Anfrage (annehmen / ablehnen mit Begruendung), Stripe-Stand, Zahl der Unterkuenfte und offene Buchungsanfragen
// (Portal: GET /unterkunft/:customerId, POST /unterkunft/:customerId/anfrage).
function UnterkunftPanel({ customerId, eintrag, onDecided }) {
const a = eintrag.anfrage
const [tone, label] = anfrageStatus(a?.status)
const stripe = eintrag.stripe || { accountId: false, status: null }
return (
<div className="funktionen-shop">
<div className="funktionen-panel-head">
<strong>Buchungskalender: {eintrag.name}</strong>
{a && <Badge tone={tone}>Anfrage: {label}</Badge>}
<Badge tone={stripeTon(stripe.status, !!stripe.accountId)}>Stripe: {stripeText(stripe.status, !!stripe.accountId)}</Badge>
</div>
{a && <AnfrageListe a={a} art="unterkunft" />}
<div className="muted small">Unterkünfte: {eintrag.unterkuenfte ?? 0} · Offene Buchungsanfragen: {eintrag.offeneAnfragen ?? 0}</div>
<EntscheidungBlock projectId={eintrag.projectId} anfrage={a} entscheide={(body) => entscheideUnterkunftAnfrage(customerId, body)} onDecided={onDecided} />
</div>
)
}
@@ -233,7 +268,7 @@ const LEERE_TEXTFORM = { confirmed: false, date: '', channel: '' }
export default function CustomerFunktionen({ customerId }) {
const [projects, setProjects] = useState(null)
const [felder, setFelder] = useState({ termine: null, shop: null }) // Portal-Felder termine und shop: { textformNoetig, avvVersion, links }
const [felder, setFelder] = useState({ termine: null, shop: null, unterkunft: null }) // Portal-Felder termine, shop und unterkunft: { textformNoetig, avvVersion, links }
const [textform, setTextform] = useState(LEERE_TEXTFORM)
const [error, setError] = useState(null)
const [confirm, setConfirm] = useState(null) // { projectId, key, action }
@@ -243,12 +278,14 @@ export default function CustomerFunktionen({ customerId }) {
const [panel, setPanel] = useState({}) // projectId -> true/false (Knopf "Anleitung"); ohne Eintrag entscheidet die Adresse (?anleitung=<projectId>)
const [shop, setShop] = useState([]) // GET /shop/:customerId => projects; Fehler nur im Webshop-Bereich, nicht in der Tabelle
const [shopFehler, setShopFehler] = useState(null)
const [unterkunft, setUnterkunft] = useState([]) // GET /unterkunft/:customerId => projects
const [unterkunftFehler, setUnterkunftFehler] = useState(null)
const [panelReload, setPanelReload] = useState({}) // projectId -> Zaehler, nach jeder Aktion hochgezaehlt
const load = useCallback(() => {
setError(null)
getFunktionen(customerId)
.then(({ projects: list, termine, shop: shopFeld }) => { setProjects(list || []); setFelder({ termine: termine || null, shop: shopFeld || null }) })
.then(({ projects: list, termine, shop: shopFeld, unterkunft: ukFeld }) => { setProjects(list || []); setFelder({ termine: termine || null, shop: shopFeld || null, unterkunft: ukFeld || null }) })
.catch((err) => setError(err.message || 'Funktionen konnten nicht geladen werden.'))
}, [customerId])
const loadShop = useCallback(() => {
@@ -257,8 +294,14 @@ export default function CustomerFunktionen({ customerId }) {
.then(({ projects: list }) => setShop(Array.isArray(list) ? list : []))
.catch((err) => setShopFehler(err.message || 'Webshop-Daten konnten nicht geladen werden.'))
}, [customerId])
useEffect(() => { load(); loadShop() }, [load, loadShop])
const nachEntscheidung = () => Promise.all([getFunktionen(customerId).then(({ projects: list, termine, shop: shopFeld }) => { setProjects(list || []); setFelder({ termine: termine || null, shop: shopFeld || null }) }), loadShop()])
const loadUnterkunft = useCallback(() => {
setUnterkunftFehler(null)
return getUnterkunft(customerId)
.then(({ projects: list }) => setUnterkunft(Array.isArray(list) ? list : []))
.catch((err) => setUnterkunftFehler(err.message || 'Daten des Buchungskalenders konnten nicht geladen werden.'))
}, [customerId])
useEffect(() => { load(); loadShop(); loadUnterkunft() }, [load, loadShop, loadUnterkunft])
const nachEntscheidung = () => Promise.all([getFunktionen(customerId).then(({ projects: list, termine, shop: shopFeld, unterkunft: ukFeld }) => { setProjects(list || []); setFelder({ termine: termine || null, shop: shopFeld || null, unterkunft: ukFeld || null }) }), loadShop(), loadUnterkunft()])
const save = async () => {
const { projectId, key, action } = confirm
@@ -285,6 +328,7 @@ export default function CustomerFunktionen({ customerId }) {
setBusy(false)
// Version und Hindernis der Anleitung haengen vom Zustand ab: Panel dieser Zeile neu laden
if (key === 'shop') loadShop()
if (key === 'unterkunft') loadUnterkunft()
if (key === 'termine') setPanelReload((r) => ({ ...r, [projectId]: (r[projectId] || 0) + 1 }))
}
}
@@ -300,6 +344,7 @@ export default function CustomerFunktionen({ customerId }) {
if (!projects.length) return <EmptyState title="Keine Projekte für diesen Kunden." />
const shopEintraege = shop.filter(shopHatEintrag)
const unterkunftEintraege = unterkunft.filter(shopHatEintrag)
return (
<Card title="Funktionen">
<table className="table funktionen-table">
@@ -411,6 +456,10 @@ export default function CustomerFunktionen({ customerId }) {
{shopEintraege.map((s) => (
<ShopPanel key={s.projectId} customerId={customerId} shop={s} onDecided={nachEntscheidung} />
))}
{unterkunftFehler && <div className="msg err funktionen-msg" role="alert">{unterkunftFehler}</div>}
{unterkunftEintraege.map((u) => (
<UnterkunftPanel key={u.projectId} customerId={customerId} eintrag={u} onDecided={nachEntscheidung} />
))}
</Card>
)
}
+65 -16
View File
@@ -44,6 +44,16 @@ export const FUNKTIONEN = [
ticket: 'Fehlendes Einbau-Ticket neu anlegen?',
},
},
{
key: 'unterkunft',
label: 'Buchungskalender',
bestaetigen: {
activate: 'Buchungskalender ohne Zahlung aktivieren?',
disable: 'Buchungskalender abschalten? Auf der Website sind dann keine Buchungsanfragen mehr möglich.',
abnahme: 'Buchungskalender freischalten? Der Kunde bekommt die Mail „bereit“ und Gäste können anfragen.',
ticket: 'Fehlendes Einrichtungs-Ticket neu anlegen?',
},
},
]
/** Zustand einer Funktion -> [Ton des Abzeichens, Beschriftung]. */
@@ -83,7 +93,7 @@ export const funktionOf = (project, key) => project?.features?.[key] || { state:
*/
export function siteKeyOf(feature, key) {
const wert = feature?.siteKey
return (key === 'termine' || key === 'shop' || key === 'zeiten') && typeof wert === 'string' ? wert.trim() : ''
return (key === 'termine' || key === 'shop' || key === 'zeiten' || key === 'unterkunft') && typeof wert === 'string' ? wert.trim() : ''
}
/** Beschriftung der Aktionsknoepfe (Aktion = action im POST /funktionen/:customerId/:key). */
@@ -104,7 +114,7 @@ export const AKTION_LABEL = {
* (hindernisText); bei Oeffnungszeiten live entfaellt sie, solange der Kunde noch keine Zeiten eingetragen hat ('zeiten_fehlen').
*/
export function aktionen(state, key, { ticketMissing = false, hindernis = null } = {}) {
if (key === 'termine' || key === 'shop' || key === 'zeiten') {
if (key === 'termine' || key === 'shop' || key === 'zeiten' || key === 'unterkunft') {
if (state === 'ordered' || state === 'active') return ['disable']
if (state === 'setup') {
const abnahme = key === 'zeiten' && hindernis ? [] : ['abnahme']
@@ -191,14 +201,14 @@ export function mergeFunktion(projects, projectId, key, aktualisiert) {
return projects.map((p) => (p.projectId === projectId ? { ...p, features: { ...p.features, [key]: view } } : p))
}
// --- Aktivieren (ohne Zahlung) mit Zustimmung in Textform (Modus on des Terminplaners und des Webshops; Portal: GET /funktionen/:customerId => termine, shop) ---
// --- Aktivieren (ohne Zahlung) mit Zustimmung in Textform (Modus on des Terminplaners, des Webshops und des Buchungskalenders; Portal: GET /funktionen/:customerId => termine, shop, unterkunft) ---
/** Funktionen, deren Aktivierung durch das Team die Zustimmung des Kunden in Textform brauchen kann (Portal: TEXTFORM_KEYS). */
export const TEXTFORM_FUNKTIONEN = ['termine', 'shop']
export const TEXTFORM_FUNKTIONEN = ['termine', 'shop', 'unterkunft']
/**
* true, wenn diese Aktion die Zustimmung in Textform braucht: nur "Aktivieren (ohne Zahlung)" des Terminplaners oder des Webshops und nur, wenn das
* Portal es verlangt (feld = Portal-Feld dieser Funktion, termine bzw. shop, feld.textformNoetig === true).
* true, wenn diese Aktion die Zustimmung in Textform braucht: nur "Aktivieren (ohne Zahlung)" des Terminplaners, des Webshops oder des Buchungskalenders
* und nur, wenn das Portal es verlangt (feld = Portal-Feld dieser Funktion, termine, shop bzw. unterkunft, feld.textformNoetig === true).
*/
export const textformNoetig = (key, action, feld) => TEXTFORM_FUNKTIONEN.includes(key) && action === 'activate' && feld?.textformNoetig === true
@@ -228,6 +238,25 @@ export const termineChatTemplate = (version, links) =>
export const shopChatTemplate = (version, links) =>
`Damit wir den Webshop für Dich einschalten können, brauchen wir Deine Zustimmung in Textform. Es gelten die Leistungsbeschreibung Webshop und der Auftragsverarbeitungsvertrag Webshop in der Fassung ${version}: ${links.lb} · ${links.avv}. Bitte antworte hier mit „Ich stimme zu“ und bestätige, dass Du als Unternehmer in eigenem Namen verkaufst, für Deine Produkte, Preise, Produktangaben und Rechtstexte selbst verantwortlich bist, keine der in der Leistungsbeschreibung ausgeschlossenen Waren verkaufst und Deine Zahlungen über Dein eigenes Stripe-Konto laufen.`
/** Kaestchentext des Buchungskalenders (Unterkunftsvermietung); Erklaerungen nach Nr. 4 und 3 der Leistungsbeschreibung (Vermieter = Unternehmer, eigener Name, eigene Angaben, eigenes Stripe-Konto). */
export const unterkunftTextformLabel = (version) => ({
before: 'Der Kunde hat der ',
lb: 'Leistungsbeschreibung Buchungskalender',
between: ' und dem ',
avv: 'Auftragsverarbeitungsvertrag Buchungskalender',
after: ` (Fassung ${version}) in Textform zugestimmt und bestätigt, dass er als Unternehmer in eigenem Namen vermietet, für seine Unterkünfte, Preise, Angaben zum Angebot, Stornobedingungen und Rechtstexte selbst verantwortlich ist und Online-Zahlungen seiner Gäste, wenn er sie nutzt, über sein eigenes Stripe-Konto laufen.`,
})
/** Textvorlage fuer den Chat beim Buchungskalender; links = unterkunft.links. Die Bestaetigung deckt dieselben Erklaerungen wie unterkunftTextformLabel. */
export const unterkunftChatTemplate = (version, links) =>
`Damit wir den Buchungskalender für Dich einschalten können, brauchen wir Deine Zustimmung in Textform. Es gelten die Leistungsbeschreibung Buchungskalender und der Auftragsverarbeitungsvertrag Buchungskalender in der Fassung ${version}: ${links.lb} · ${links.avv}. Bitte antworte hier mit „Ich stimme zu“ und bestätige, dass Du als Unternehmer in eigenem Namen vermietest, für Deine Unterkünfte, Preise, Angaben zum Angebot, Stornobedingungen und Rechtstexte selbst verantwortlich bist und Online-Zahlungen Deiner Gäste, wenn Du sie nutzt, über Dein eigenes Stripe-Konto laufen.`
const TEXTFORM_TEXTE = {
termine: { label: termineTextformLabel, vorlage: termineChatTemplate },
shop: { label: shopTextformLabel, vorlage: shopChatTemplate },
unterkunft: { label: unterkunftTextformLabel, vorlage: unterkunftChatTemplate },
}
/** Nur geprüfte https-Adressen werden Links; alles andere ''. */
const httpsUrl = (wert) => {
if (typeof wert !== 'string') return ''
@@ -239,19 +268,19 @@ const httpsUrl = (wert) => {
}
/**
* Alles, was der Bestaetigungsdialog zeigt, aus dem Portal-Feld der Funktion (feld = termine bzw. shop, key = 'termine' | 'shop'):
* Alles, was der Bestaetigungsdialog zeigt, aus dem Portal-Feld der Funktion (feld = termine, shop bzw. unterkunft, key = 'termine' | 'shop' | 'unterkunft'):
* { version, links { lb, avv } (nur https, sonst ''), label, vorlage }.
* vorlage nur, wenn Fassung und beide Adressen da sind (eine Vorlage mit leeren Adressen geht nie an den Kunden), sonst ''.
*/
export function textformAnsicht(feld, key = 'termine') {
const shop = key === 'shop'
const texte = TEXTFORM_TEXTE[key] || TEXTFORM_TEXTE.termine
const version = typeof feld?.avvVersion === 'string' ? feld.avvVersion : ''
const links = { lb: httpsUrl(feld?.links?.lb), avv: httpsUrl(feld?.links?.avv) }
return {
version,
links,
label: (shop ? shopTextformLabel : termineTextformLabel)(version),
vorlage: version && links.lb && links.avv ? (shop ? shopChatTemplate : termineChatTemplate)(version, links) : '',
label: texte.label(version),
vorlage: version && links.lb && links.avv ? texte.vorlage(version, links) : '',
}
}
@@ -313,9 +342,25 @@ export const anfrageStatus = (status) => (has(ANFRAGE_STATUS, status) ? ANFRAGE_
const jaNein = (wert) => (wert === true ? 'Ja' : wert === false ? 'Nein' : '–')
const textOder = (wert) => (typeof wert === 'string' && wert.trim() ? wert.trim() : '–')
/** Zeilen der Anfrage fuer die Anzeige: [{ label, text }]. Ohne Anfrage []. */
export function anfrageZeilen(a) {
const UNTERKUNFT_ART_LABEL = { ferienwohnung: 'Ferienwohnung', monteurzimmer: 'Monteurzimmer', pension: 'Pension', sonstiges: 'Sonstiges' }
/** Art der Unterkunft -> Beschriftung; unbekannt oder leer '–'. */
export const unterkunftArtLabel = (code) => (has(UNTERKUNFT_ART_LABEL, code) ? UNTERKUNFT_ART_LABEL[code] : code ? String(code) : '–')
const einheitenText = (n) => (Number.isFinite(n) && n > 0 ? String(n) : '–')
const portaleText = (wert) => (wert === true ? 'nutzt Booking.com/Airbnb' : wert === false ? 'nutzt keine Portale' : '–')
/**
* Zeilen der Anfrage fuer die Anzeige: [{ label, text }]. Ohne Anfrage []. art = 'shop' (Standard) oder 'unterkunft' (Buchungskalender).
*/
export function anfrageZeilen(a, art = 'shop') {
if (!a || typeof a !== 'object') return []
if (art === 'unterkunft') {
return [
{ label: 'Art', text: unterkunftArtLabel(a.art) },
{ label: 'Einheiten', text: einheitenText(a.einheiten) },
{ label: 'Portale', text: portaleText(a.portale) },
{ label: 'Nachricht', text: textOder(a.nachricht) },
]
}
const wege = [a.versand === true ? 'Versand' : '', a.abholung === true ? 'Abholung' : ''].filter(Boolean)
return [
{ label: 'Warenart', text: warenartLabel(a.warenart) },
@@ -328,9 +373,13 @@ export function anfrageZeilen(a) {
]
}
/** Kurzfassung der Anfrage in einer Zeile ("Waren, bis 20 Artikel, Versand und Abholung"); ohne Anfrage ''. */
export function anfrageText(a) {
/** Kurzfassung der Anfrage in einer Zeile ("Waren, bis 20 Artikel, Versand und Abholung"); ohne Anfrage ''. art wie bei anfrageZeilen. */
export function anfrageText(a, art = 'shop') {
if (!a || typeof a !== 'object') return ''
if (art === 'unterkunft') {
const n = Number.isFinite(a.einheiten) && a.einheiten > 0 ? `${a.einheiten} ${a.einheiten === 1 ? 'Einheit' : 'Einheiten'}` : ''
return [unterkunftArtLabel(a.art), n, a.portale === true ? 'nutzt Booking.com/Airbnb' : ''].filter((t) => t && t !== '–').join(', ')
}
const wege = [a.versand === true ? 'Versand' : '', a.abholung === true ? 'Abholung' : ''].filter(Boolean).join(' und ')
const zahl = a.artikelzahl ? `${artikelzahlLabel(a.artikelzahl)} Artikel` : ''
return [warenartLabel(a.warenart), zahl, wege].filter((t) => t && t !== '–').join(', ')
@@ -359,14 +408,14 @@ export const stripeTon = (status, hatKonto = false) => {
return status || hatKonto ? 'warn' : 'muted'
}
/** Gibt es zu diesem Projekt etwas anzuzeigen (Anfrage vorhanden oder Webshop nicht "off")? */
/** Gibt es zu diesem Projekt etwas anzuzeigen (Anfrage vorhanden oder Funktion nicht "off")? Gilt fuer Webshop und Buchungskalender. */
export const shopHatEintrag = (shop) => !!shop && (shop.anfrage != null || (shop.state && shop.state !== 'off'))
/** Begruendung der Ablehnung: Pflicht, hoechstens 1000 Zeichen (ohne Rand). */
export const BEGRUENDUNG_MAX = 1000
export const begruendungOk = (text) => typeof text === 'string' && text.trim().length > 0 && text.trim().length <= BEGRUENDUNG_MAX
/** Rumpf fuer POST /shop/:customerId/anfrage. Begruendung nur beim Ablehnen. */
/** Rumpf fuer POST /shop/:customerId/anfrage und /unterkunft/:customerId/anfrage. Begruendung nur beim Ablehnen. */
export function entscheidungBody({ projectId, entscheidung, begruendung }) {
const body = { projectId, entscheidung }
if (entscheidung === 'ablehnen') body.begruendung = String(begruendung ?? '').trim()
+9 -1
View File
@@ -96,7 +96,7 @@ export const markStorageDone = (id) => request(`/storage-tasks/${enc(id)}/done`,
export const getFunktionen = (customerId) => request(`/funktionen/${enc(customerId)}`)
/**
* Aktivieren (ohne Zahlung) oder Abschalten einer Funktion (key: news | termine | shop); Antwort { projects: [das eine Projekt] }.
* Aktivieren (ohne Zahlung) oder Abschalten einer Funktion (key: news | termine | shop | zeiten | unterkunft); Antwort { projects: [das eine Projekt] }.
* Terminplaner und Webshop im Modus on: beim Aktivieren zusaetzlich textformDate (JJJJ-MM-TT) und textformChannel (chat | email).
*/
export const setFeature = (customerId, key, { projectId, action, note, textformDate, textformChannel }) =>
@@ -113,6 +113,14 @@ export const getShop = (customerId) => request(`/shop/${enc(customerId)}`)
export const entscheideShopAnfrage = (customerId, { projectId, entscheidung, begruendung }) =>
request(`/shop/${enc(customerId)}/anfrage`, { method: 'POST', body: entscheidungBody({ projectId, entscheidung, begruendung }) })
// --- Buchungskalender (Anfrage des Kunden, Stripe-Stand, Unterkuenfte) ---
/** { projects: [{ projectId, name, state, anfrage, stripe, unterkuenfte, offeneAnfragen }] } (Aufbereitung: funktionenView) */
export const getUnterkunft = (customerId) => request(`/unterkunft/${enc(customerId)}`)
/** Anfrage annehmen oder ablehnen: body { projectId, entscheidung: 'annehmen' | 'ablehnen', begruendung } => { ok, anfrage, mailFehler? } */
export const entscheideUnterkunftAnfrage = (customerId, { projectId, entscheidung, begruendung }) =>
request(`/unterkunft/${enc(customerId)}/anfrage`, { method: 'POST', body: entscheidungBody({ projectId, entscheidung, begruendung }) })
// --- Oeffnungszeiten live (Funktion zeiten): Ersatz-HTML und Team-Einstellungen ---
/** Ersatz-HTML (Wochentabelle) eines Plans fuer den Einbau: { html }; 404 unbekannter Plan, 409 "Der Kunde hat noch keine Zeiten eingetragen." */
+12 -2
View File
@@ -65,7 +65,7 @@ test('terminplaner_karten_vorhanden', () => {
assert.ok(!/\d{4}-\d{2}-\d{2}/.test(d.liveUrl), 'Alias-Seite ohne Fassungsdatum')
}
// Oeffnungszeiten live: Karte 12, hinter den Terminplaner- und Webshop-Karten; der Schluessel hat 28 Zeichen (WOMS-Grenze 36, Ruling R35)
assert.equal(DOCUMENTS.length, 12, 'keine Analyse-Karten und keine weiteren')
assert.equal(DOCUMENTS.length, 14, 'keine Analyse-Karten und keine weiteren')
})
test('zeiten_karte_vorhanden_am_ende', () => {
@@ -78,10 +78,18 @@ test('zeiten_karte_vorhanden_am_ende', () => {
sort: 12,
})
assert.ok(d.key.length <= 36, 'Dokument-ID hoechstens 36 Zeichen')
assert.equal(DOCUMENTS.at(-1), d, 'am Ende von DOCUMENTS, nach den Terminplaner- und Webshop-Karten')
assert.equal(DOCUMENTS.at(-3), d, 'vor den Buchungskalender-Karten, nach den Terminplaner- und Webshop-Karten')
assert.equal(DOCUMENTS.findIndex((x) => x.key === 'widerrufsbelehrung-funktionen'), 8, 'Terminplaner-Karten unveraendert davor')
})
test('unterkunft_karten_am_ende', () => {
assert.deepEqual(DOCUMENTS.slice(-2), [
{ key: 'leistungsbeschreibung-unterkunft', title: 'Leistungsbeschreibung Buchungskalender', liveUrl: 'https://project.webklar.com/rechtliches/leistungsbeschreibung-unterkunft.html', sort: 13 },
{ key: 'avv-unterkunft', title: 'AVV Buchungskalender', liveUrl: 'https://project.webklar.com/rechtliches/avv-unterkunft.html', sort: 14 },
])
assert.equal(DOCUMENTS.at(-1).key, 'avv-unterkunft')
})
test('bestehende_karten_unveraendert', () => {
assert.deepEqual(DOCUMENTS.slice(0, 9), [
{ key: 'agb', title: 'AGB', liveUrl: 'https://webklar.com/agb', sort: 1 },
@@ -111,5 +119,7 @@ test('dry_run_listet_karten', () => {
assert.ok(zeilen.some((z) => z.startsWith('Rechtstext leistungsbeschreibung-webshop:')))
assert.ok(zeilen.some((z) => z.startsWith('Rechtstext avv-webshop:')))
assert.ok(zeilen.includes('Rechtstext leistungsbeschreibung-zeiten: Leistungsbeschreibung Öffnungszeiten live'))
assert.ok(zeilen.includes('Rechtstext leistungsbeschreibung-unterkunft: Leistungsbeschreibung Buchungskalender'))
assert.ok(zeilen.includes('Rechtstext avv-unterkunft: AVV Buchungskalender'))
assert.ok(!/angelegt|vorhanden|Fertig/.test(r.stdout), 'Trockenlauf legt nichts an')
})
+83 -2
View File
@@ -21,6 +21,8 @@ const {
termineTextformLabel,
termineChatTemplate,
shopTextformLabel,
unterkunftTextformLabel,
unterkunftChatTemplate,
shopChatTemplate,
TEXTFORM_FUNKTIONEN,
textformAnsicht,
@@ -82,11 +84,21 @@ test('FUNKTIONEN: News, Terminplaner, Webshop und Öffnungszeiten live (in diese
ticket: 'Fehlendes Einbau-Ticket neu anlegen?',
},
},
{
key: 'unterkunft',
label: 'Buchungskalender',
bestaetigen: {
activate: 'Buchungskalender ohne Zahlung aktivieren?',
disable: 'Buchungskalender abschalten? Auf der Website sind dann keine Buchungsanfragen mehr möglich.',
abnahme: 'Buchungskalender freischalten? Der Kunde bekommt die Mail „bereit“ und Gäste können anfragen.',
ticket: 'Fehlendes Einrichtungs-Ticket neu anlegen?',
},
},
])
})
test('FUNKTIONEN: Schlüssel sind eindeutig und jede Funktion hat Aktivieren und Abschalten bestätigt', () => {
assert.deepEqual(FUNKTIONEN.map((f) => f.key), ['news', 'termine', 'shop', 'zeiten'])
assert.deepEqual(FUNKTIONEN.map((f) => f.key), ['news', 'termine', 'shop', 'zeiten', 'unterkunft'])
for (const f of FUNKTIONEN) {
assert.ok(f.bestaetigen.activate && f.bestaetigen.disable, f.key)
}
@@ -473,7 +485,7 @@ const shopFeld = (extra = {}) => ({
})
test('textformNoetig: auch das Aktivieren (ohne Zahlung) des Webshops, mit dem Feld shop; die Funktion kommt aus dem Schlüssel', () => {
assert.deepEqual(TEXTFORM_FUNKTIONEN, ['termine', 'shop'])
assert.deepEqual(TEXTFORM_FUNKTIONEN, ['termine', 'shop', 'unterkunft'])
assert.equal(textformNoetig('shop', 'activate', shopFeld()), true)
assert.equal(textformNoetig('shop', 'activate', shopFeld({ textformNoetig: false })), false)
assert.equal(textformNoetig('shop', 'activate', undefined), false, 'altes Portal ohne Feld shop: wie bisher')
@@ -754,3 +766,72 @@ test('entscheidungStatus: Warnung, wenn die Mail an den Kunden fehlschlug', () =
assert.deepEqual(entscheidungStatus({ ok: true, mailFehler: true }), { ok: false, text: 'Entscheidung gespeichert, Mail an den Kunden fehlgeschlagen.' })
assert.deepEqual(entscheidungStatus(null), { ok: true, text: 'Gespeichert.' })
})
test('unterkunft: Aktionen wie beim Webshop, Site-Key wird gezeigt', () => {
for (const state of ['off', 'ordered', 'setup', 'active', 'disabled', undefined]) {
assert.deepEqual(aktionen(state, 'unterkunft'), aktionen(state, 'shop'), String(state))
assert.deepEqual(aktionen(state, 'unterkunft', { ticketMissing: true }), aktionen(state, 'shop', { ticketMissing: true }), `${state} mit Ticket fehlt`)
}
assert.equal(siteKeyOf({ state: 'active', siteKey: ' abcdefghijklmnop ' }, 'unterkunft'), 'abcdefghijklmnop')
assert.equal(siteKeyOf({ state: 'active', siteKey: null }, 'unterkunft'), '')
})
test('anfrageZeilen (unterkunft): Art, Einheiten, Portale, Nachricht', () => {
const a = { art: 'ferienwohnung', einheiten: 3, portale: true, nachricht: ' Hallo ' }
assert.deepEqual(anfrageZeilen(a, 'unterkunft'), [
{ label: 'Art', text: 'Ferienwohnung' },
{ label: 'Einheiten', text: '3' },
{ label: 'Portale', text: 'nutzt Booking.com/Airbnb' },
{ label: 'Nachricht', text: 'Hallo' },
])
assert.equal(anfrageZeilen({ ...a, art: 'monteurzimmer' }, 'unterkunft')[0].text, 'Monteurzimmer')
assert.equal(anfrageZeilen({ ...a, art: 'pension' }, 'unterkunft')[0].text, 'Pension')
assert.equal(anfrageZeilen({ ...a, art: 'sonstiges' }, 'unterkunft')[0].text, 'Sonstiges')
assert.equal(anfrageZeilen({ ...a, art: 'xyz' }, 'unterkunft')[0].text, 'xyz')
const leer = anfrageZeilen({ portale: false }, 'unterkunft')
assert.deepEqual(leer.map((z) => z.text), ['–', '–', 'nutzt keine Portale', '–'])
assert.equal(anfrageZeilen({ ...a, einheiten: 'x' }, 'unterkunft')[1].text, '–')
assert.deepEqual(anfrageZeilen(null, 'unterkunft'), [])
// ohne art: unveraendert Webshop
assert.deepEqual(anfrageZeilen(a), anfrageZeilen(a, 'shop'))
})
test('anfrageText (unterkunft): Kurzfassung', () => {
assert.equal(anfrageText({ art: 'pension', einheiten: 4, portale: true }, 'unterkunft'), 'Pension, 4 Einheiten, nutzt Booking.com/Airbnb')
assert.equal(anfrageText({ art: 'pension', einheiten: 1 }, 'unterkunft'), 'Pension, 1 Einheit')
assert.equal(anfrageText(null, 'unterkunft'), '')
})
test('entscheidungBody und begruendungOk gelten unverändert für den Buchungskalender', () => {
assert.deepEqual(entscheidungBody({ projectId: 'p', entscheidung: 'ablehnen', begruendung: ' x ' }), { projectId: 'p', entscheidung: 'ablehnen', begruendung: 'x' })
})
// Buchungskalender (key unterkunft): Zustimmung in Textform wie beim Webshop
const ukFeld = (extra = {}) => ({
textformNoetig: true,
avvVersion: '2026-10-07',
links: {
lb: 'https://project.webklar.com/rechtliches/leistungsbeschreibung-unterkunft-2026-10-07.html',
avv: 'https://project.webklar.com/rechtliches/avv-unterkunft-2026-10-07.html',
},
...extra,
})
test('Buchungskalender: textformNoetig, Label, Vorlage und Links aus dem Feld unterkunft', () => {
assert.equal(textformNoetig('unterkunft', 'activate', ukFeld()), true)
assert.equal(textformNoetig('unterkunft', 'activate', ukFeld({ textformNoetig: false })), false)
assert.equal(textformNoetig('unterkunft', 'activate', undefined), false)
for (const action of ['disable', 'abnahme', 'ticket']) assert.equal(textformNoetig('unterkunft', action, ukFeld()), false, action)
const a = textformAnsicht(ukFeld(), 'unterkunft')
assert.deepEqual(a.label, unterkunftTextformLabel('2026-10-07'))
assert.equal(a.label.lb, 'Leistungsbeschreibung Buchungskalender')
assert.equal(a.label.avv, 'Auftragsverarbeitungsvertrag Buchungskalender')
assert.match(a.label.after, /Fassung 2026-10-07/)
assert.equal(a.vorlage, unterkunftChatTemplate('2026-10-07', a.links))
assert.ok(a.vorlage.startsWith('Damit wir den Buchungskalender für Dich einschalten können'))
assert.ok(a.vorlage.includes(a.links.lb) && a.vorlage.includes(a.links.avv))
const schlecht = textformAnsicht(ukFeld({ links: { lb: 'javascript:alert(1)', avv: 'http://x.example/a.html' } }), 'unterkunft')
assert.deepEqual(schlecht.links, { lb: '', avv: '' })
assert.equal(schlecht.vorlage, '')
assert.equal(textformBereit({ confirmed: true, date: '2026-10-07', channel: 'email', today: '2026-10-07' }, ukFeld()), true)
})