Merge feature/terminplaner-tp2 (a629b25) in feature/oeffnungszeiten

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 04:42:31 +00:00
commit 1d5a565e9f
95 files changed
+12879 -546

No files matched your search

+2 -1
View File
@@ -228,7 +228,8 @@ export function pruefeEingabe(e, roh, now, { manuell = false, bestaetigung = fal
// Zustimmungen
const zustimmungen = istObjekt(roh.zustimmungen) ? roh.zustimmungen : {}
const stornoNoetig = !manuell && typeof e.storno?.text === 'string' && e.storno.text !== ''
// Stornobedingungen gelten nur fuer Leistungen mit Preis: bei kostenlosen Leistungen kein Pflichthaken und kein Nachweis
const stornoNoetig = !manuell && hatPreis(leistung) && typeof e.storno?.text === 'string' && e.storno.text !== ''
if (stornoNoetig && zustimmungen.storno !== true) fehl('storno', MELDUNG.storno)
const vorzeitigNoetig = !manuell && leistung && start ? brauchtZustimmungVorzeitig(e, leistung, start, now) : false
if (vorzeitigNoetig && zustimmungen.vorzeitig !== true) fehl('vorzeitig', MELDUNG.vorzeitig)
+856
View File
@@ -0,0 +1,856 @@
import { config } from '../../config.js'
import { TICKET_DETAILS_MAX, TICKET_GEKUERZT, anleitungsLink, kuerze } from '../featureRegistry.js'
import { gesundheitsWarnungen } from './fragebogen.js'
import {
VORGABE_FARBE, fehlendePflichtangaben, hatPreis, preisHinweis, preisText, sichtbareLeistungen, text as bereinigterText, zahlungText, zeigtWiderruf,
} from './einstellungen.js'
import { RECHTSTEXTE_ENTWURF, datenschutzAbsatz } from './rechtstexte.js'
import { datumKurz, heute } from './zeit.js'
/**
* Einbau des Terminplaners auf der Website des Kunden (Spezifikation 2026-10-03-terminplaner-tp2-verkauf-design.md, Abschnitt
* Einbauanleitung; Anleitung fuer das Team: docs/funktionen/termine-einbau.md).
*
* Zwei Teile in einer Datei:
* - Bausteine (esc, einbauSnippet, Farbfunktionen, cssVorschlag): reine Funktionen ohne Appwrite und ohne config, die Adresse
* des Portals (`apiBase`) und alle Werte kommen als Parameter. Der Einbau-Code stand bis TP2 in routes/termine.js;
* GET /api/termine/einstellungen ruft ihn von hier auf (Modus inline, die Kundenansicht bleibt unveraendert).
* - Generator einbauanleitung() (ganz unten): setzt aus den Einstellungen des Betriebs die Anleitung fuer das Team zusammen.
* Auch er ist rein (kein I/O, keine Zufallswerte); config wird nur beim Aufruf gelesen (Portal-Adresse, Haltezeit, WOMS-Adresse,
* Preis). Er nutzt die reinen Hilfen gesundheitsWarnungen (fragebogen.js) und datenschutzAbsatz (rechtstexte.js).
*
* Die Farbfunktionen (luminanz, textFarbeAuf) sind 1:1 die aus public/embed/termine.js (Marken @farben-anfang/@farben-ende),
* bewusst kopiert und nicht importiert: das Widget ist ein eigenstaendiges Browser-Skript. test/termineEmbedStatic.test.js
* haelt beide fuer 4096 Farben gleich; wer eine Seite aendert, aendert die andere mit.
*/
const FARBE_RE = /^#[0-9a-fA-F]{6}$/
const KNOPF_MAX = 40
const MIN_RAHMEN = 3 // WCAG 2.1 AA fuer Bedienelemente und Rahmen (1.4.11): der Rahmen freier Tage steht in der Akzentfarbe
// ---------------------------------------------------------------- Text
/** Maskiert &, <, >, " fuer Text und Attributwerte in doppelten Anfuehrungszeichen. null und undefined werden zu ''. */
export function esc(text) {
return String(text ?? '').replace(/[&<>"]/g, (z) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[z])
}
/** Knopf-Text fuer data-knopf: Zeilenumbrueche und Steuerzeichen zu Leerzeichen, hoechstens 40 Zeichen (nie mitten in einem Zeichen). */
function knopfTextBereinigt(roh) {
if (typeof roh !== 'string') return ''
const eine = roh.replace(/\r\n/g, ' ').replace(/[\p{Cc}\p{Zl}\p{Zp}]/gu, ' ').trim()
return Array.from(eine).slice(0, KNOPF_MAX).join('')
}
// ---------------------------------------------------------------- Einbau-Code
/**
* Einbau-Code fuer die Website: Platzhalter mit Ausweichtext (bleibt ohne JavaScript stehen) und das Skript.
* modus: 'inline' (Standard, die Strecke steht in der Seite) | 'knopf' (ein Knopf oeffnet einen Dialog); jeder andere Wert gilt
* als 'inline'. knopfText gilt nur im Modus knopf und nur, wenn er nicht leer ist; sonst nimmt das Widget "Termin buchen".
*/
export function einbauSnippet({ siteKey, apiBase, telefon, modus = 'inline', knopfText } = {}) {
const knopf = modus === 'knopf' ? knopfTextBereinigt(knopfText) : ''
const ausweich = telefon ? `Termine telefonisch unter ${esc(telefon)}.` : 'Termine telefonisch vereinbaren.'
const attribute = [
`data-webklar-termine="${esc(siteKey)}"`,
`data-api="${esc(apiBase)}"`,
`data-modus="${modus === 'knopf' ? 'knopf' : 'inline'}"`,
...(knopf ? [`data-knopf="${esc(knopf)}"`] : []),
]
return [
`<div ${attribute.join(' ')}>`,
` <p>${ausweich}</p>`,
'</div>',
`<script src="${esc(apiBase)}/embed/termine.js" defer></script>`,
].join('\n')
}
// ---------------------------------------------------------------- Farben
/** Relative Leuchtdichte nach WCAG 2.1 fuer '#rrggbb' (wie im Widget; erwartet eine gueltige Farbe). */
export function luminanz(farbe) {
const rgb = [1, 3, 5].map((i) => {
const c = parseInt(farbe.slice(i, i + 2), 16) / 255
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)
})
return 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]
}
/** Kontrastverhaeltnis zweier Leuchtdichten (1 bis 21), wie kontrast() im Widget. */
const verhaeltnis = (a, b) => (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05)
/** Kontrastverhaeltnis zweier Farben '#rrggbb' (1 bis 21, Reihenfolge egal). */
export function kontrast(hexA, hexB) {
return verhaeltnis(luminanz(hexA), luminanz(hexB))
}
/**
* Schrift auf der Betriebsfarbe: Weiss oder das dunkle Grau, je nachdem was mehr Kontrast gibt; erreicht keines 4,5 : 1
* (mittlere Toene), nimmt sie Schwarz (mindestens 4,58 : 1 fuer jede Farbe). 1:1 aus dem Widget.
*/
export function textFarbeAuf(farbe) {
const l = luminanz(farbe)
const weiss = verhaeltnis(l, 1)
const dunkel = verhaeltnis(l, luminanz('#111827'))
const beste = Math.max(weiss, dunkel)
if (beste < 4.5 && verhaeltnis(l, 0) > beste) return '#000000'
return weiss >= dunkel ? '#ffffff' : '#111827'
}
/** Kontrast mit einer Nachkommastelle und deutschem Komma, abgerundet ("unter 3 : 1" bleibt auch im Text unter 3). */
const kontrastAnzeige = (wert) => (Math.floor(wert * 10) / 10).toFixed(1).replace('.', ',')
/**
* CSS-Vorschlag fuer die Website aus der Betriebsfarbe (Anleitung Abschnitt 3): Kommentar plus
* `[data-webklar-termine]{ --wkt-akzent; --wkt-akzent-text }`. Nur eine gueltige '#rrggbb' (gross oder klein) wird uebernommen;
* alles andere ersetzt VORGABE_FARBE, der Rohtext kommt nie in die Ausgabe.
* Rueckgabe: css, akzent (die verwendete Farbe, klein), akzentText (Schrift darauf), kontrastText (Akzent gegen Schrift),
* kontrastSeite (Akzent gegen Weiss), hinweise (Warnungen, leer wenn alles passt). Der Rahmen der freien Tage steht in der
* Akzentfarbe und braucht gegen den Hintergrund mindestens 3 : 1; das Widget hat standardmaessig einen weissen Hintergrund.
*/
export function cssVorschlag(farbe) {
const akzent = typeof farbe === 'string' && FARBE_RE.test(farbe) ? farbe.toLowerCase() : VORGABE_FARBE
const akzentText = textFarbeAuf(akzent)
const textKontrast = kontrast(akzent, akzentText)
const seitenKontrast = kontrast(akzent, '#ffffff')
const hinweise = []
if (seitenKontrast < MIN_RAHMEN) {
hinweise.push(
`Kontrast gegen Weiß: nur ${kontrastAnzeige(seitenKontrast)} : 1. Der Rahmen der freien Tage steht in der Akzentfarbe ${akzent} und braucht ` +
`mindestens ${MIN_RAHMEN} : 1 gegen den Hintergrund. Auf weißem Grund wähle eine dunklere Farbe, auf dunklem Grund setze --wkt-hintergrund passend.`
)
}
const css = [
`/* Terminplaner: Farbe der Knöpfe und Schrift darauf, aus der Betriebsfarbe. Kontrast der Schrift: ${kontrastAnzeige(textKontrast)} : 1. */`,
'[data-webklar-termine]{',
` --wkt-akzent:${akzent};`,
` --wkt-akzent-text:${akzentText};`,
'}',
].join('\n')
return { css, akzent, akzentText, kontrastText: textKontrast, kontrastSeite: seitenKontrast, hinweise }
}
// ================================================================ Generator einbauanleitung()
// Task D3a: Modell, Leistungen, Ressourcen, Regeln, Gastfelder, Benachrichtigungen. Task D3b: Einbau, Snippet, CSS, Datenschutz-Absaetze,
// Checkliste, Abrechnung, die Warnungen und der Volltext. Task D4: die Kurzfassung fuer das Einrichtungs-Ticket (`kurz`), ticketStand und driftWarnung.
//
// Feldweissliste: gelesen werden nur ausdruecklich benannte Felder, nie ganze Objekte. Nie ausgegeben werden feedToken (Kalender-
// Abo der Ressource), die Betriebs-E-Mail (sie steht nur in den Datenschutz-Absaetzen), Beschreibungen der Leistungen und alles,
// was sonst an den Objekten haengt. Kundentexte (Namen, Titel, Storno-Text, Wuensche zum Einbau) stehen unmaskiert als Text; wer sie
// anzeigt, setzt sie als Text ein. Einzeilige Werte werden auf eine Zeile gebracht, mehrzeilige eingerueckt, damit ein Kundentext
// keine Ueberschrift vortaeuschen kann. Wuensche des Kunden stehen nie im Snippet und nie im CSS (nur der Knopf-Text, maskiert,
// als data-knopf).
/** Kennzeichnung der Gaestetexte als Entwurf; erste Warnung, solange `entwurf` gilt (Standard RECHTSTEXTE_ENTWURF). */
export const ENTWURF_BANNER = 'ENTWURF – nur Pilot, vor Einsatz bei fremden Betrieben prüfen'
const WOCHENTAGE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
const TAG_RE = /^\d{4}-\d{2}-\d{2}$/
const MAX_ABWESENHEITEN = 20
const MAX_SONDEROEFFNUNGEN = 20
const NICHT_ANGEGEBEN = 'nicht angegeben'
const GAESTE = { verbraucher: 'Privatpersonen (Verbraucher)', unternehmer: 'Unternehmen', gemischt: 'Privatpersonen und Unternehmen' }
const BUCHUNGSART = { sofort: 'sofort buchbar', anfrage: 'nur als Anfrage' }
const GAST_FELD = { aus: 'aus', optional: 'optional', pflicht: 'Pflicht' }
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
const liste = (wert) => (Array.isArray(wert) ? wert : [])
const anzahl = (n, einzahl, mehrzahl) => `${n} ${n === 1 ? einzahl : mehrzahl}`
const imWort = (map, schluessel, vorgabe) => (typeof schluessel === 'string' && Object.hasOwn(map, schluessel) ? map[schluessel] : vorgabe)
/** Einzeiliger Text: Leerraum, Umbrueche und Steuerzeichen werden zu einem Leerzeichen, Rand getrimmt; Nicht-Text = ''. */
const zeile = (wert) => (typeof wert === 'string' ? wert.replace(/[\p{Cc}\p{Zl}\p{Zp}\s]+/gu, ' ').trim() : '')
/** '2026-10-05' => '05.10.2026'. */
const tagText = (tag) => tag.split('-').reverse().join('.')
/** [['09:00','12:30'],['14:00','18:00']] => '09:00–12:30, 14:00–18:00'; Unbrauchbares faellt weg. */
function fensterText(fenster) {
return liste(fenster)
.filter((f) => Array.isArray(f) && f.length === 2 && typeof f[0] === 'string' && typeof f[1] === 'string')
.map(([von, bis]) => `${zeile(von)}–${zeile(bis)}`)
.join(', ')
}
/** Wochenplan { 1: [['09:00','12:30'], …], … } (ISO-Wochentage) als Zeilen 'Mo 09:00–12:30, 14:00–18:00', Montag bis Sonntag, ohne leere Tage. */
export function wochenplanText(plan) {
const aus = []
for (let tag = 1; tag <= 7; tag += 1) {
const text = fensterText(istObjekt(plan) ? plan[tag] : null)
if (text) aus.push(`${WOCHENTAGE[tag - 1]} ${text}`)
}
return aus
}
/** Die ersten `max` Eintraege, bei mehr als `max` gefolgt von '… und N weitere'. Aendert die Liste nicht. */
export function kuerzeListe(eintraege, max) {
if (eintraege.length <= max) return [...eintraege]
return [...eintraege.slice(0, max), `… und ${eintraege.length - max} weitere`]
}
const neuerAbschnitt = (id, titel, text, kopierbar = false) => ({ id, titel, text, kopierbar })
/** Zeilen als Aufzaehlung: '- Eintrag'. */
const aufzaehlung = (eintraege) => eintraege.map((eintrag) => `- ${eintrag}`)
// ---------------------------------------------------------------- Abschnitt Modell
function standText(d) {
if (!Number.isInteger(d.version) || d.version < 1) return 'keine gespeicherte Version'
return `Version ${d.version}${d.geaendert ? `, zuletzt geändert ${d.geaendert}` : ''}`
}
function abschnittModell(d) {
const { e, betrieb: b } = d
const aktive = (eintraege) => eintraege.filter((x) => x.aktiv).length
const angabe = (wert) => zeile(wert) || NICHT_ANGEGEBEN
const gaeste = imWort(GAESTE, e.recht?.gaesteSind, GAESTE.verbraucher) + (e.recht?.freizeit === true ? ', Freizeitleistung' : '')
return neuerAbschnitt('modell', 'Modell', [
`Erzeugt am ${datumKurz(d.now)}`,
`Stand der Einstellungen: ${standText(d)}`,
...(d.kunde ? [`Kunde: ${d.kunde}`] : []),
...(d.projekt ? [`Projekt: ${d.projekt}`] : []),
`Betrieb: ${angabe(b.name)}`,
`Anschrift: ${angabe(b.adresse)}`,
`Telefon: ${angabe(b.telefon)}`,
`Impressum: ${angabe(b.impressumUrl)}`,
`Datenschutzerklärung: ${angabe(b.datenschutzUrl)}`,
...(zeile(b.agbUrl) ? [`AGB: ${zeile(b.agbUrl)}`] : []),
...(zeile(b.websiteUrl) ? [`Website: ${zeile(b.websiteUrl)}`] : []),
`Farbe: ${d.farbe}`,
`Anrede der Gäste: ${e.anrede === 'du' ? 'Du' : 'Sie'}`,
`Gäste: ${gaeste}`,
`Umfang: ${anzahl(d.ressourcen.length, 'Person oder Platz', 'Personen und Plätze')} (${aktive(d.ressourcen)} aktiv), `
+ `${anzahl(d.leistungen.length, 'Leistung', 'Leistungen')} (${aktive(d.leistungen)} aktiv)`,
`Portal-Adresse (data-api): ${d.portal}`,
].join('\n'))
}
// ---------------------------------------------------------------- Abschnitt Leistungen
/** 'Personen: Anna, Ben' | 'Plätze: …' | 'Personen und Plätze: …'; unbekannte Zuordnungen fallen weg. */
function zuordnungText(ids, nachId) {
const treffer = liste(ids).map((id) => nachId.get(id)).filter(Boolean)
if (treffer.length === 0) return 'keine Person und kein Platz zugeordnet'
const plaetze = treffer.filter((r) => r.typ === 'platz').length
const label = plaetze === 0 ? 'Personen' : plaetze === treffer.length ? 'Plätze' : 'Personen und Plätze'
return `${label}: ${treffer.map((r) => r.name).join(', ')}`
}
function leistungsZeile(l, nachId) {
const dauer = Number.isFinite(l.dauer) ? `${l.dauer} Min.` : 'Dauer fehlt'
const pause = Number.isFinite(l.pufferNach) && l.pufferNach > 0 ? ` (+ ${l.pufferNach} Min. Pause danach)` : ''
return `${zeile(l.name) || 'ohne Namen'}${l.aktiv ? '' : ' (inaktiv)'}: ${dauer}${pause}, ${preisText(l) || 'kein Preis'}, `
+ `${imWort(BUCHUNGSART, l.buchungsart, zeile(l.buchungsart) || BUCHUNGSART.sofort)}, ${zuordnungText(l.ressourcen, nachId)}`
}
function abschnittLeistungen(d) {
if (d.leistungen.length === 0) return neuerAbschnitt('leistungen', 'Leistungen', 'Keine Leistungen angelegt.')
const aktiv = d.leistungen.filter((l) => l.aktiv).length
return neuerAbschnitt('leistungen', 'Leistungen', [
`${anzahl(d.leistungen.length, 'Leistung', 'Leistungen')} (${aktiv} aktiv):`,
...aufzaehlung(d.leistungen.map((l) => leistungsZeile(l, d.nachId))),
].join('\n'))
}
// ---------------------------------------------------------------- Abschnitt Ressourcen
function ressourcenBlock(r) {
const plan = wochenplanText(r.plan)
return [
`- ${zeile(r.name) || 'ohne Namen'} (${r.typ === 'platz' ? 'Platz' : 'Person'}${r.aktiv ? '' : ', inaktiv'})`,
...(plan.length ? plan : ['keine Zeiten eingetragen']).map((z) => ` ${z}`),
].join('\n')
}
/** Abwesenheiten, die heute oder spaeter enden (laufende bleiben), nach Beginn sortiert. */
function abwesenheitsZeilen(d) {
const eintraege = liste(d.e.abwesenheiten)
.filter((a) => istObjekt(a) && TAG_RE.test(a.von) && TAG_RE.test(a.bis) && a.bis >= d.heuteTag)
.sort((a, b) => (a.von < b.von ? -1 : a.von > b.von ? 1 : a.bis < b.bis ? -1 : a.bis > b.bis ? 1 : 0))
.map((a) => {
const zeitraum = a.von === a.bis ? tagText(a.von) : `${tagText(a.von)}–${tagText(a.bis)}`
const wer = a.ressourceId ? d.nachId.get(a.ressourceId)?.name ?? 'unbekannt' : 'alle'
return `${zeitraum}: ${zeile(a.titel) || 'Abwesenheit'} (${wer})`
})
return aufzaehlung(kuerzeListe(eintraege, MAX_ABWESENHEITEN))
}
/** Sonderoeffnungszeiten ab heute ([] = an diesem Tag geschlossen), nach Tag sortiert. */
function sonderoeffnungsZeilen(d) {
const roh = istObjekt(d.e.sonderoeffnungen) ? d.e.sonderoeffnungen : {}
const eintraege = Object.keys(roh)
.filter((tag) => TAG_RE.test(tag) && tag >= d.heuteTag)
.sort()
.map((tag) => `${tagText(tag)}: ${fensterText(roh[tag]) || 'geschlossen'}`)
return aufzaehlung(kuerzeListe(eintraege, MAX_SONDEROEFFNUNGEN))
}
function abschnittRessourcen(d) {
const abwesenheiten = abwesenheitsZeilen(d)
const sonder = sonderoeffnungsZeilen(d)
return neuerAbschnitt('ressourcen', 'Personen und Plätze', [
d.ressourcen.length ? d.ressourcen.map(ressourcenBlock).join('\n') : 'Keine Personen oder Plätze angelegt.',
...(abwesenheiten.length ? [['Abwesenheiten (ab heute):', ...abwesenheiten].join('\n')] : []),
...(sonder.length ? [['Sonderöffnungszeiten (ab heute, für alle):', ...sonder].join('\n')] : []),
].join('\n\n'))
}
// ---------------------------------------------------------------- Abschnitt Regeln
/** Vorlauf in der groessten glatten Einheit: '45 Minuten', '2 Stunden', '1 Tag'; 0 = kein Vorlauf. */
function vorlaufText(minuten) {
if (!Number.isFinite(minuten)) return NICHT_ANGEGEBEN
if (minuten === 0) return 'kein Vorlauf'
if (minuten % 1440 === 0) return anzahl(minuten / 1440, 'Tag', 'Tage')
if (minuten % 60 === 0) return anzahl(minuten / 60, 'Stunde', 'Stunden')
return anzahl(minuten, 'Minute', 'Minuten')
}
function stornoZeilen(storno) {
const text = typeof storno?.text === 'string' ? storno.text.trim() : ''
if (!text) return ['Stornobedingungen: keine hinterlegt']
return [`Stornobedingungen (Fassung ${zeile(storno.version) || 'ohne Angabe'}):`, ...text.split(/\r\n|\r|\n/).map((z) => ` ${z}`)]
}
function abschnittRegeln(d) {
const { e } = d
const r = istObjekt(e.regeln) ? e.regeln : {}
const mit = (wert, f) => (Number.isFinite(wert) ? f(wert) : NICHT_ANGEGEBEN)
return neuerAbschnitt('regeln', 'Regeln', [
`Raster: ${mit(r.raster, (n) => anzahl(n, 'Minute', 'Minuten'))}`,
`Vorlauf: ${vorlaufText(r.vorlaufMin)}`,
`Buchungszeitraum: ${mit(r.horizontTage, (n) => `${anzahl(n, 'Tag', 'Tage')} im Voraus`)}`,
`Absage durch Gäste: ${mit(r.absageBisStd, (n) => (n === 0 ? 'bis zum Terminbeginn' : `bis ${anzahl(n, 'Stunde', 'Stunden')} vor dem Termin`))}`,
`Offene Termine je E-Mail-Adresse: ${mit(r.maxOffeneJeEmail, (n) => `höchstens ${n}`)}`,
`Person wählbar: ${r.personWaehlbar === false ? 'nein' : 'ja'}`,
`Feiertage: ${e.feiertageGeschlossen === false ? 'geöffnet wie im Wochenplan' : 'geschlossen'} (Bundesland ${zeile(e.bundesland) || NICHT_ANGEGEBEN})`,
zahlungText(e),
`Preishinweis: ${preisHinweis(e)}`,
zeigtWiderruf(e)
? 'Widerruf: Widerrufsbelehrung und Widerrufsfunktion werden Gästen angezeigt'
: 'Widerruf: keine Widerrufsbelehrung (Gäste sind keine Verbraucher oder es ist keine Leistung sichtbar)',
...stornoZeilen(e.storno),
].join('\n'))
}
// ---------------------------------------------------------------- Abschnitt Gastfelder
function abschnittGastfelder(d) {
const g = istObjekt(d.e.gastFelder) ? d.e.gastFelder : {}
const zusatz = liste(g.zusatz).filter(istObjekt).map((z) => {
const art = z.typ === 'auswahl' ? `Auswahl: ${liste(z.optionen).map(zeile).filter(Boolean).join(', ')}` : 'Text'
return `${zeile(z.label) || 'ohne Beschriftung'} (${art}; ${z.pflicht === true ? 'Pflicht' : 'optional'})`
})
return neuerAbschnitt('gastfelder', 'Angaben der Gäste', [
'Name: Pflicht',
'E-Mail-Adresse: Pflicht',
`Telefon: ${imWort(GAST_FELD, g.telefon, NICHT_ANGEGEBEN)}`,
`Nachricht: ${imWort(GAST_FELD, g.nachricht, NICHT_ANGEGEBEN)}`,
...(zusatz.length ? [`Zusatzfelder (${zusatz.length}):`, ...aufzaehlung(zusatz)] : ['Zusatzfelder: keine']),
].join('\n'))
}
// ---------------------------------------------------------------- Abschnitt Benachrichtigungen
function abschnittBenachrichtigungen(d) {
const { e } = d
const sichtbar = sichtbareLeistungen(e)
const sofort = sichtbar.some((l) => l.buchungsart !== 'anfrage')
const anfrage = sichtbar.some((l) => l.buchungsart === 'anfrage')
const widerruf = zeigtWiderruf(e)
const erinnerung = e.erinnerung?.anbieten === true && Number.isFinite(e.erinnerung.stundenVorher)
const haltezeit = Number.isFinite(d.haltezeit) ? ` (gültig ${anzahl(d.haltezeit, 'Minute', 'Minuten')})` : ''
return neuerAbschnitt('benachrichtigungen', 'Benachrichtigungen', [
`Gäste werden mit „${e.anrede === 'du' ? 'Du' : 'Sie'}“ angesprochen. Absender ist der Betriebsname (${zeile(d.betrieb.name) || NICHT_ANGEGEBEN}), `
+ 'Antworten gehen an den Betrieb.',
['Mails an Gäste:', ...aufzaehlung([
`Bestätigungslink zur E-Mail-Adresse${haltezeit}`,
...(sofort ? ['Terminbestätigung (sofort buchbare Leistungen)'] : []),
...(anfrage ? ['Eingang und Ablehnung von Terminanfragen (Leistungen mit Anfrage)'] : []),
...(erinnerung ? [`Erinnerung ${anzahl(e.erinnerung.stundenVorher, 'Stunde', 'Stunden')} vor dem Termin (nur wenn der Gast sie ankreuzt)`] : []),
'Absage und Verschiebung',
...(widerruf ? ['Eingangsbestätigung des Widerrufs'] : []),
])].join('\n'),
['Mails an den Betrieb (Adresse aus den Einstellungen, steht nicht in dieser Anleitung):', ...aufzaehlung([
...(sofort ? ['Neue Buchung'] : []),
...(anfrage ? ['Neue Terminanfrage'] : []),
'Absage oder Verschiebung durch den Gast',
...(widerruf ? ['Widerruf'] : []),
])].join('\n'),
'Kalender-Abo (ICS): je Person oder Platz im Portal unter Termine. Der Link ist geheim und steht nicht in dieser Anleitung.',
].join('\n\n'))
}
// ---------------------------------------------------------------- Wuensche des Kunden (Fragebogen, Schritt Einbau)
const EINBAU_MODI = ['inline', 'knopf']
const MODUS_TEXT = {
inline: 'inline – die Buchungsstrecke steht direkt in der Seite',
knopf: 'knopf – ein Knopf öffnet die Buchungsstrecke in einem Dialog',
}
// Grenzen wie in fragebogen.js (TEXTGRENZEN): hier erneut angewandt, die Anleitung traut dem gespeicherten Fragebogen nicht blind
const WUNSCH_GRENZEN = { knopfText: 40, stelle: 300, gestaltung: 1000, bemerkung: 1000 }
const WUNSCH_MEHRZEILIG = new Set(['stelle', 'gestaltung', 'bemerkung'])
/** Modus (ohne Fragebogen oder ohne gueltige Wahl: inline) und die vier Wunschtexte, bereinigt, gekuerzt und wohlgeformt. */
function kundenwunsch(fragebogen) {
const einbau = istObjekt(fragebogen) && istObjekt(fragebogen.einbau) ? fragebogen.einbau : {}
const modusGewaehlt = EINBAU_MODI.includes(einbau.modus)
const wunsch = { modus: modusGewaehlt ? einbau.modus : 'inline', modusGewaehlt }
for (const [feld, max] of Object.entries(WUNSCH_GRENZEN)) {
wunsch[feld] = bereinigterText(einbau[feld], max, { mehrzeilig: WUNSCH_MEHRZEILIG.has(feld) }).toWellFormed()
}
return wunsch
}
/** ' Stelle: erste Zeile' und weitere Zeilen um vier Leerzeichen eingerueckt (nie am Zeilenanfang, also nie eine Ueberschrift). */
function wunschZeilen(bezeichnung, wert) {
if (!wert) return []
const [erste, ...weitere] = wert.split('\n')
return [` ${bezeichnung}: ${erste}`, ...weitere.map((z) => (z === '' ? '' : ` ${z}`))]
}
function kundenwunschBlock(wunsch) {
const zeilen = [
...wunschZeilen('Knopf-Text', wunsch.knopfText),
...wunschZeilen('Stelle', wunsch.stelle),
...wunschZeilen('Gestaltung', wunsch.gestaltung),
...wunschZeilen('Bemerkung', wunsch.bemerkung),
]
return zeilen.length ? ['Kundenwunsch (Text des Kunden):', ...zeilen].join('\n') : 'Kundenwunsch (Text des Kunden): keine Angaben im Fragebogen'
}
// ---------------------------------------------------------------- Abschnitte Einbau, Snippet, CSS
function abschnittEinbau(d) {
const w = d.wunsch
const quelle = w.modusGewaehlt ? 'Wahl des Kunden im Fragebogen' : 'Standard, der Fragebogen nennt keinen Modus'
const telefon = zeile(d.betrieb.telefon) || NICHT_ANGEGEBEN
const impressum = zeile(d.betrieb.impressumUrl) || NICHT_ANGEGEBEN
const datenschutz = zeile(d.betrieb.datenschutzUrl) || NICHT_ANGEGEBEN
return neuerAbschnitt('einbau', 'Einbau', [
`siteKey: ${d.siteKey || NICHT_ANGEGEBEN}`,
`Modus: ${MODUS_TEXT[w.modus]} (${quelle})`,
['So wird eingebaut:', ...aufzaehlung([
'Platzhalter und Skript aus dem Abschnitt „Einbau-Code (Snippet)“ in die Seite setzen: den Platzhalter an die Stelle, die der Kunde nennt '
+ '(siehe Kundenwunsch), das Skript einmal pro Seite, am besten vor dem schließenden body-Tag. Das Skript hat keine Abhängigkeiten, '
+ 'setzt keine Cookies und lädt nichts von Dritten.',
'Der Ausweichtext im Platzhalter gehört der Website: Er bleibt ohne JavaScript, bei falschem siteKey, abgeschalteter Funktion, fehlenden '
+ `Pflichtangaben und Netzfehlern stehen. Telefon des Betriebs: ${telefon}. Gestalte ihn im Stil der Seite.`,
'Aussehen: den Abschnitt „Aussehen (CSS)“ in die CSS der Website übernehmen. Weitere Variablen stehen in docs/funktionen/termine-einbau.md, Abschnitt 3.',
'Datenschutz: den Absatz „Online-Terminbuchung“ (Abschnitte „Datenschutz-Absatz für die Website“) in die Datenschutzerklärung der Website aufnehmen, '
+ 'bevor das Widget freigeschaltet wird.',
`Impressum und Datenschutzerklärung: Die URLs stehen in den Betriebsangaben im Portal (Impressum: ${impressum}; Datenschutzerklärung: ${datenschutz}), `
+ 'das Widget verlinkt sie im Fuß.',
])].join('\n'),
kundenwunschBlock(w),
].join('\n\n'))
}
/** Einbau-Code aus den Angaben des Betriebs und dem Modus des Fragebogens; Wuensche ausser dem Knopf-Text kommen nie hinein. */
const baueSnippet = (d) =>
einbauSnippet({ siteKey: d.siteKey, apiBase: d.portal, telefon: d.telefon, modus: d.wunsch.modus, knopfText: d.wunsch.knopfText })
const abschnittSnippet = (d) => neuerAbschnitt('snippet', 'Einbau-Code (Snippet)', baueSnippet(d), true)
const abschnittCss = (d) => neuerAbschnitt('css', 'Aussehen (CSS)', cssVorschlag(d.farbe).css, true)
// ---------------------------------------------------------------- Abschnitte Datenschutz-Absatz (Sie, Du)
const DATENSCHUTZ_HINWEIS =
'Der Datenschutz-Absatz konnte nicht erzeugt werden: In den Einstellungen fehlt der Name oder die E-Mail-Adresse des Betriebs. '
+ 'Beides im Portal eintragen lassen und die Anleitung neu laden.'
/** { sie, du } oder null, wenn Name oder E-Mail-Adresse des Betriebs fehlen (datenschutzAbsatz wirft dann einen TypeError). */
function datenschutzTexte(einstellung) {
try {
return { sie: datenschutzAbsatz(einstellung, 'sie'), du: datenschutzAbsatz(einstellung, 'du') }
} catch (err) {
if (err instanceof TypeError) return null
throw err
}
}
/** Nur ein echter Absatz ist kopierbar; der Hinweis, dass er fehlt, nie (er gehoert nicht in eine Datenschutzerklaerung). */
const abschnittDatenschutz = (anrede) => (d) => {
const id = `datenschutz_${anrede}`
const titel = `Datenschutz-Absatz für die Website (${anrede === 'du' ? 'Du' : 'Sie'}-Form)`
const absatz = d.datenschutz?.[anrede]
return absatz ? neuerAbschnitt(id, titel, absatz, true) : neuerAbschnitt(id, titel, DATENSCHUTZ_HINWEIS)
}
// ---------------------------------------------------------------- Abschnitt Abnahme-Checkliste
// Wer den Datenschutz-Absatz einfuegt: die Regel der Leistungsbeschreibung (Nr. 4 Abs. 9); Checkliste und Schritt 4 der Kurzfassung teilen sie
const DATENSCHUTZ_REGEL =
'liegt die Datenschutzerklärung in der von WEBklar betreuten Website, fügen wir den Absatz beim Einbau ein, das gehört zur Einrichtung und wird nicht auf das Stundenkontingent angerechnet; '
+ 'sonst den Absatz als Textbaustein per Chat an den Kunden senden, der ihn selbst einfügt'
function abschnittCheckliste(d) {
const { e } = d
const widerruf = zeigtWiderruf(e)
const preis = sichtbareLeistungen(e).some(hatPreis)
const knopf = d.wunsch.modus === 'knopf'
const impressum = zeile(d.betrieb.impressumUrl) || 'fehlt'
const datenschutz = zeile(d.betrieb.datenschutzUrl) || 'fehlt'
const wo = d.anleitungsLink ? ` (Kunde → Reiter Funktionen, Zeile Terminplaner: ${d.anleitungsLink})` : ''
const punkte = [
// vor der Freischaltung
'Einbau-Code eingefügt (Abschnitt „Einbau-Code (Snippet)“). Das Widget bleibt unsichtbar bis zur Freischaltung: Bis dahin steht nur der Ausweichtext auf der Seite.',
...(knopf ? ['Modus knopf: --wkt-akzent immer setzen (Abschnitt „Aussehen (CSS)“). Der Knopf kennt die Betriebsfarbe erst nach dem Klick und nimmt sonst #1d4ed8.'] : []),
'Ausweichtext geprüft: JavaScript aus oder falscher siteKey: Der Text steht, nichts ist kaputt.',
`Datenschutz-Absatz „Online-Terminbuchung“ steht in der Datenschutzerklärung der Website (Fassung ${e.anrede === 'du' ? 'Du' : 'Sie'}; ${DATENSCHUTZ_REGEL}).`,
`URLs von Impressum (${impressum}) und Datenschutzerklärung (${datenschutz}) stimmen und beginnen mit https://, sonst fehlen die Links im Fuß des Widgets.`,
`„Freischalten (Abnahme)“ im Ticketsystem ausgelöst${wo}. Erst danach antwortet die öffentliche Schnittstelle (nur im Zustand active), und das Widget erscheint.`,
// am sichtbaren Widget
'Probebuchung bis „Fast geschafft“ durchgespielt: Die Bestätigungs-E-Mail kommt an, der Link bestätigt, die zweite E-Mail (mit Kalenderdatei) kommt an, im Portal steht der Termin. '
+ 'Danach den Testtermin stornieren (Verwaltungslink aus der E-Mail oder im Portal).',
...(widerruf ? ['Widerrufsbelehrung ist im Widget aufklappbar (Verbraucher-Betrieb, auch bei nur Anfrage-Leistungen).'] : []),
...(widerruf && preis ? ['Der Haken für den vorzeitigen Beginn erscheint bei Leistungen mit Preis für Termine in den nächsten 14 Tagen.'] : []),
...(widerruf ? ['Der Knopf „Vertrag widerrufen“ steht im Fuß, das Formular funktioniert.'] : []),
...(widerruf && knopf
? ['Modus knopf: Widerrufsknopf dauerhaft neben dem Buchungsknopf (nicht erst im Dialog); er öffnet den Dialog gleich beim Widerrufsformular.']
: []),
'Kontrast geprüft: Text auf dem Hintergrund und Schrift auf dem Akzent mindestens 4,5 : 1, Rand gegen den Hintergrund mindestens 3 : 1; globale Regeln des Themes (button, input) stören nicht.',
'Handy (390 px Breite): kein seitliches Scrollen, Knöpfe gut treffbar, der Dialog passt in den Bildschirm; Zoom 200 %: nichts wird abgeschnitten.',
'Nur mit der Tastatur bedienen: Fokus immer sichtbar, Monatsraster mit Pfeilen, Pos1/Ende und Bild ↑/↓, Esc schließt den Dialog.',
]
return neuerAbschnitt('checkliste', 'Abnahme-Checkliste', punkte.map((p) => `- [ ] ${p}`).join('\n'))
}
// ---------------------------------------------------------------- Abschnitt Abrechnung
const RECHNUNG_RE = /^https:\/\/\S+$/i
/** Preis der Einrichtung aus dem Register (config.features.termine.priceLabel), sonst „149 €“. */
function einrichtungsPreis() {
const preis = zeile(config.features?.termine?.priceLabel)
return preis || '149 €'
}
function abschnittAbrechnung(d) {
if (d.feature?.unlockedByPurchase !== true) {
return neuerAbschnitt('abrechnung', 'Abrechnung', 'Abrechnung: ohne Zahlung freigeschaltet (Pilot/Kulanz), keine Abrechnung.')
}
const rechnung = zeile(d.feature.invoiceUrl)
return neuerAbschnitt('abrechnung', 'Abrechnung', [
`Abrechnung: Einrichtung bezahlt (${einrichtungsPreis()}, Rechnung ${RECHNUNG_RE.test(rechnung) ? rechnung : 'folgt im Portal'}), Zeit im Arbeitsblatt, nicht auf Abo-Stunden.`,
'Leistungsumfang: ein Widget an einer Stelle, Gestaltung nach Fragebogen, Einbau; Mehrwünsche (zweites Widget, Knöpfe je Leistung, Sonderdesign) nach Stunden, vorher im Chat abstimmen.',
].join('\n'))
}
// ---------------------------------------------------------------- Warnungen
const WARNUNG_PFLICHT = {
'betrieb.telefon': 'Telefonnummer des Betriebs fehlt: Gäste können nicht buchen, das Widget zeigt nur den Ausweichtext.',
'betrieb.adresse':
'Anschrift des Betriebs fehlt (sie steht in der Widerrufsbelehrung): Gäste können nicht buchen, das Widget zeigt nur den Ausweichtext.',
}
const WARNUNG_OHNE_URL = 'fehlt: Im Fuß des Widgets fehlt der Link.'
const WARNUNG_OHNE_DATENSCHUTZ = 'Datenschutz-Absatz nicht erzeugbar: Name oder E-Mail-Adresse des Betriebs fehlt in den Einstellungen.'
/**
* Meldungen aus gesundheitsWarnungen (Block C) tragen Namen des Kunden: gekuerzt in UTF-16-Einheiten (ein halbes Zeichenpaar ist moeglich)
* und ungemaskiert. Hier werden sie wohlgeformt und einzeilig; sie bleiben Klartext, der Anzeiger setzt sie als Text ein (nie als HTML).
*/
const meldungText = (wert) => zeile(typeof wert === 'string' ? wert.toWellFormed() : '')
/**
* Die Gesundheitsmeldungen einer Einstellung (hoechstens 10, Pruefung aus Block C) in der Form, in der sie im Panel, im Ticket und in T16 stehen:
* wohlgeformt, einzeilig, Klartext. Ohne Einstellung (null) und ohne Treffer leer.
*/
export function gesundheitsMeldungen(einstellung) {
return gesundheitsWarnungen(einstellung).map(meldungText)
}
/**
* Warnungen fuer das Team in fester Reihenfolge, in drei Teilen: davor (Banner, Pflichtangaben, Impressum, Datenschutz-URL), die Gesundheits-
* meldungen, danach (Kontrast, Datenschutz-Absatz). warnungen = davor + gesundheit + danach; die Kurzfassung (Ticket) setzt die Gesundheit
* in einen eigenen Absatz und zaehlt sie nicht zu ihren fuenf Warnungen.
*/
function warnungsTeile(d) {
const davor = []
if (d.entwurf) davor.push(ENTWURF_BANNER)
for (const feld of fehlendePflichtangaben(d.e)) davor.push(WARNUNG_PFLICHT[feld] ?? `Pflichtangabe fehlt: ${feld}`)
if (!zeile(d.betrieb.impressumUrl)) davor.push(`Impressum-URL ${WARNUNG_OHNE_URL}`)
if (!zeile(d.betrieb.datenschutzUrl)) davor.push(`Datenschutz-URL ${WARNUNG_OHNE_URL}`)
const danach = [...cssVorschlag(d.farbe).hinweise]
if (!d.datenschutz) danach.push(WARNUNG_OHNE_DATENSCHUTZ)
return { davor, gesundheit: gesundheitsMeldungen(d.e), danach }
}
// ---------------------------------------------------------------- Kurzfassung fuer das Einrichtungs-Ticket (Task D4)
// `kurz` steht in ticket.details (Klartext, das WOMS zeigt es mit pre-wrap) und ist die einzige Fassung, die ins Ticket geht: ohne Datenschutz-Absatz
// (4,8 k Zeichen je Variante), ohne Token. Absaetze durch eine Leerzeile; der erste ist der Kopf und endet mit der Konfigurationsstand-Zeile, die
// ticketStand liest. Platz: TICKET_DETAILS_MAX. Statt mitten im Satz abzuschneiden, ist es eine Budgetliste: Kopf, Gesundheitsabsatz, Snippet,
// Schritte und Verweis bleiben immer (eine Gesundheitsmeldung verschwindet nie unter Platzdruck); passt die Summe nicht, fallen erst die Zaehlungen
// weg, dann die uebrigen Warnungen (die letzte zuerst), dann der Kundenwunsch-Block. Nur wenn selbst die Pflichtteile nicht passen (ein unge-
// pruefter Wert sprengt sie), kuerzt kuerze() am Ende an einer Zeilengrenze mit der Schlusszeile des Tickets.
const KURZ_NAMEN = 8 // so viele Namen je Zaehlung, danach "… und N weitere"
const KURZ_NAME_MAX = 60
const KURZ_KOPF_MAX = 120
const KURZ_WARNUNGEN = 5
const GESUNDHEIT_ABSATZ = 'Gesundheitsbereich prüfen:'
const KURZ_OHNE_LINK = 'Verweis: im WOMS unter Kunden → Funktionen, Anleitung des Projekts'
const kopfWert = (wert) => kuerze(zeile(wert), KURZ_KOPF_MAX, { endzeile: '…' })
const kurzName = (name) => kuerze(zeile(name) || 'ohne Namen', KURZ_NAME_MAX, { endzeile: '…' })
/** 'Konfigurationsstand: Version N, TT.MM.JJJJ' | '… Version N' (ohne Datum) | '… Version 0 (noch keine Einstellungen gespeichert)' | '… (Einstellungen nicht lesbar)'. */
function standZeile(version, geaendert, hatEinstellung) {
const n = Number.isInteger(version) && version >= 1 ? version : 0
if (n === 0) return 'Konfigurationsstand: Version 0 (noch keine Einstellungen gespeichert)'
if (!hatEinstellung) return `Konfigurationsstand: Version ${n} (Einstellungen nicht lesbar)`
return `Konfigurationsstand: Version ${n}${geaendert ? `, ${geaendert}` : ''}`
}
/** Kopf: Funktion, Projekt, Domain (wie createFeatureTicket: liveDomain, sonst previewUrl, sonst subdomain), Kunde, siteKey, Stand. */
function kurzKopf(k) {
const domain = zeile(k.project?.liveDomain) || zeile(k.project?.previewUrl) || zeile(k.project?.subdomain)
return [
'Funktion: Terminplaner',
`Projekt: ${kopfWert(k.project?.projectName) || kopfWert(k.project?.subdomain)}`,
`Domain: ${kopfWert(domain)}`,
`Kunde: ${kopfWert(k.kunde)}`,
`siteKey: ${k.siteKey}`,
standZeile(k.version, k.geaendert, k.hatEinstellung),
].join('\n')
}
/** ' Stelle: erste Zeile' und die weiteren Zeilen um vier Leerzeichen eingerueckt; Leerzeilen fallen weg (nie ein Absatzumbruch im Kundentext). */
function kurzWunschZeilen(bezeichnung, wert) {
const [erste, ...weitere] = wert.split('\n').filter((z) => z !== '')
return erste === undefined ? [] : [` ${bezeichnung}: ${erste}`, ...weitere.map((z) => ` ${z}`)]
}
/** Kundenwunsch: Modus (ohne Wahl: Standard) und die vier Wunschtexte des Kunden (Text des Kunden, nie im Snippet ausser dem Knopf-Text). */
function kurzWunsch(w) {
const zeilen = [
...kurzWunschZeilen('Knopf-Text', w.knopfText),
...kurzWunschZeilen('Stelle', w.stelle),
...kurzWunschZeilen('Gestaltung', w.gestaltung),
...kurzWunschZeilen('Bemerkung', w.bemerkung),
]
return [
'Kundenwunsch (Text des Kunden):',
` Modus: ${w.modus}${w.modusGewaehlt ? '' : ' (Standard)'}`,
...(zeilen.length ? zeilen : [' Sonst keine Angaben im Fragebogen']),
].join('\n')
}
/** '- 12 Leistungen (11 aktiv): A, B, … und 4 weitere': Anzahl, aktive (nur wenn es welche gibt und gezaehlt wird) und die ersten 8 Namen. */
function kurzZaehlung(eintraege, einzahl, mehrzahl, { mitAktiv = false } = {}) {
const n = eintraege.length
const aktiv = mitAktiv && n > 0 ? ` (${eintraege.filter((x) => x.aktiv).length} aktiv)` : ''
const namen = kuerzeListe(eintraege.map((x) => kurzName(x.name)), KURZ_NAMEN).join(', ')
return `- ${anzahl(n, einzahl, mehrzahl)}${aktiv}${n > 0 ? `: ${namen}` : ''}`
}
function kurzUmfang(k) {
return [
'Umfang:',
kurzZaehlung(k.leistungen, 'Leistung', 'Leistungen', { mitAktiv: true }),
kurzZaehlung(k.ressourcen, 'Person oder Platz', 'Personen und Plätze', { mitAktiv: true }),
kurzZaehlung(k.zusatzfelder.map((z) => ({ name: z.label })), 'Zusatzfeld', 'Zusatzfelder'),
].join('\n')
}
/** Die sechs Schritte des Teams; Schritt 6 nennt den Preis nur, wenn die Einrichtung bezahlt ist (unlockedByPurchase === true). */
function kurzSchritte(k) {
const form = !k.hatEinstellung ? 'Sie- oder Du-Form nach der Anrede der Gäste' : k.anrede === 'du' ? 'Du-Form' : 'Sie-Form'
const bezahlt = k.feature?.unlockedByPurchase === true ? ` (Einrichtung bezahlt, ${einrichtungsPreis()})` : ''
return [
'Schritte:',
'1. WOMS-Panel öffnen (Kunde → Reiter Funktionen → Anleitung des Projekts): dort stehen alle Einstellungen, die Kopierknöpfe und die Abnahme-Checkliste.',
'2. Einbau-Code einfügen: den Code oben an der Stelle einsetzen, die der Kunde nennt (siehe Kundenwunsch), das Skript einmal pro Seite.',
'3. Gestaltung und Kontrast: das CSS aus der Anleitung (Abschnitt „Aussehen (CSS)“) in die Website übernehmen und den Kontrast prüfen.',
`4. Datenschutz-Absatz (${form}) aus der Anleitung in die Datenschutzerklärung der Website aufnehmen, bevor freigeschaltet wird (${DATENSCHUTZ_REGEL}).`,
'5. „Freischalten (Abnahme)“ im WOMS auslösen, danach eine Probebuchung durchspielen und den Testtermin wieder stornieren (Checkliste in der Anleitung).',
`6. Ticket erledigen und die Zeit im Arbeitsblatt buchen. Nicht auf das Stundenkontingent anrechnen${bezahlt}.`,
].join('\n')
}
/**
* Die Kurzfassung aus den vorbereiteten Teilen. k: feature, siteKey, project, kunde, version, geaendert, hatEinstellung, anrede, wunsch, leistungen,
* ressourcen, zusatzfelder, warnungen (ohne Gesundheit), gesundheit (Meldungen), snippet, anleitungsLink.
*/
function kurzfassung(k) {
const pflicht = {
kopf: kurzKopf(k),
gesundheit: k.gesundheit.length ? [GESUNDHEIT_ABSATZ, ...k.gesundheit.map((m) => `- ${m}`)].join('\n') : '',
snippet: `Einbau-Code (Snippet):\n${k.snippet}`,
schritte: kurzSchritte(k),
verweis: k.anleitungsLink ? `Verweis: ${k.anleitungsLink}` : KURZ_OHNE_LINK,
}
const umfang = k.hatEinstellung ? kurzUmfang(k) : ''
const wunsch = kurzWunsch(k.wunsch)
const warnungen = k.warnungen.slice(0, KURZ_WARNUNGEN).map((w) => `- ${w}`)
let mitUmfang = Boolean(umfang)
let mitWunsch = true
const zusammen = () =>
[
pflicht.kopf,
mitWunsch ? wunsch : '',
mitUmfang ? umfang : '',
warnungen.length ? ['Warnungen:', ...warnungen].join('\n') : '',
pflicht.gesundheit,
pflicht.snippet,
pflicht.schritte,
pflicht.verweis,
].filter(Boolean).join('\n\n').toWellFormed()
let text = zusammen()
if (text.length > TICKET_DETAILS_MAX) {
mitUmfang = false
text = zusammen()
}
while (text.length > TICKET_DETAILS_MAX && warnungen.length > 0) {
warnungen.pop()
text = zusammen()
}
if (text.length > TICKET_DETAILS_MAX) {
mitWunsch = false
text = zusammen()
}
return text.length > TICKET_DETAILS_MAX ? kuerze(text, TICKET_DETAILS_MAX, { anZeile: true, endzeile: TICKET_GEKUERZT }) : text
}
const STAND_RE = /^Konfigurationsstand: Version (\d{1,9})(?!\d)/m
/**
* Die Version der Einstellungen, auf der ein Ticket beruht: die erste Zeile "Konfigurationsstand: Version N" am Zeilenanfang (die Wuensche des
* Kunden stehen eingerueckt und koennen sie nicht nachahmen). Ohne die Zeile, mit mehr als neun Stellen oder bei Nicht-Text null; Version 0 ist ein Stand.
*/
export function ticketStand(details) {
if (typeof details !== 'string') return null
const treffer = STAND_RE.exec(details)
return treffer ? Number(treffer[1]) : null
}
/**
* Hinweis fuers Team, wenn die Einstellungen seit der Bestellung gespeichert wurden: ticketVersion aus ticketStand, jetzt die aktuelle Version.
* Nur bei zwei ganzen Zahlen und jetzt > ticketVersion, sonst null (auch ohne Stand im Ticket).
*/
export function driftWarnung(ticketVersion, jetzt) {
if (!Number.isInteger(ticketVersion) || !Number.isInteger(jetzt) || jetzt <= ticketVersion) return null
return `Einstellungen seit der Bestellung geändert (Ticket Version ${ticketVersion}, jetzt ${jetzt}).`
}
// ---------------------------------------------------------------- Zusammenbau
const ABSCHNITTE = [
abschnittModell, abschnittLeistungen, abschnittRessourcen, abschnittRegeln, abschnittGastfelder, abschnittBenachrichtigungen,
abschnittEinbau, abschnittSnippet, abschnittCss, abschnittDatenschutz('sie'), abschnittDatenschutz('du'), abschnittCheckliste, abschnittAbrechnung,
]
/** Alle Abschnitte mit Ueberschrift '## Titel', durch eine Leerzeile getrennt. */
const volltext = (abschnitte) => abschnitte.map((a) => `## ${a.titel}\n${a.text}`).join('\n\n')
/** 'Projektname (Domain)' | 'Projektname' | 'Domain' | '' (Domain = liveDomain, sonst previewUrl, sonst subdomain). */
function projektText(project) {
const name = zeile(project?.projectName) || zeile(project?.subdomain)
const domain = zeile(project?.liveDomain) || zeile(project?.previewUrl) || zeile(project?.subdomain)
return name && domain && name !== domain ? `${name} (${domain})` : name || domain
}
/**
* Anleitung fuer den Einbau des Terminplaners bei einem Betrieb (Team-Sicht): rein, kein I/O, gleiche Eingabe und gleiches `now`
* ergeben gleiche Ausgabe (deepEqual). config wird beim Aufruf gelesen.
*
* @param {object} p
* @param {object} [p.feature] portalFeatures-Dokument der Funktion (gelesen: siteKey, customerId, projectId, unlockedByPurchase,
* invoiceUrl; der Registereintrag config.features.termine wird nicht gebraucht)
* @param {object} [p.project] websiteProjects-Dokument (gelesen: projectName, subdomain, liveDomain, previewUrl)
* @param {object|null} p.einstellung gespeicherte Einstellung (termineConfig.einstellung); null = noch keine
* @param {object|null} [p.fragebogen] geparster Fragebogen oder null (gelesen: einbau.modus, knopfText, stelle, gestaltung, bemerkung)
* @param {number} p.version Version der Einstellungen (0 = noch nie gespeichert)
* @param {Date} p.now Zeitpunkt der Erzeugung; ein ungueltiges now wirft einen TypeError
* @param {string|null} [p.updatedAt] ISO-Zeitpunkt der letzten Aenderung der Einstellungen
* @param {{name?: string}|null} [p.kunde] gelesen wird nur der Name
* @param {boolean} [p.entwurf] Rechtstexte sind noch ENTWURF (erste Warnung ENTWURF_BANNER); Standard RECHTSTEXTE_ENTWURF
* @returns {{stand: {version: number, updatedAt: string|null}, warnungen: string[], abschnitte: Array<{id: string, titel: string, text: string, kopierbar: boolean}>, text: string, kurz: string}}
* abschnitte: 13 Stueck in fester Reihenfolge (modell bis abrechnung), kopierbar nur snippet, css und die beiden Datenschutz-Absaetze.
* text: alle Abschnitte mit Ueberschrift, aber nur die Datenschutz-Fassung, die zu einstellung.anrede passt ('du', sonst Sie).
* Ohne Einstellung (null): nur der Abschnitt snippet und die Warnung "Keine Einstellungen gespeichert".
* kurz: die Kurzfassung fuer ticket.details (Klartext, hoechstens TICKET_DETAILS_MAX Zeichen, erster Absatz = Kopf mit der Konfigurationsstand-Zeile,
* ohne Datenschutz-Absatz und ohne Token); auch ohne Einstellung gefuellt (Kopf mit Version 0, Snippet, Schritte, Verweis).
*/
export function einbauanleitung({ feature, project, einstellung, fragebogen, version, now, updatedAt = null, kunde = null, entwurf = RECHTSTEXTE_ENTWURF }) {
if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new TypeError('einbauanleitung: now fehlt')
const stand = { version, updatedAt }
const banner = entwurf ? [ENTWURF_BANNER] : []
const geaendertAm = updatedAt ? new Date(updatedAt) : null
const gemeinsam = {
feature,
project,
siteKey: zeile(feature?.siteKey),
portal: zeile(config.publicBaseUrl),
wunsch: kundenwunsch(fragebogen),
anleitungsLink: feature && zeile(feature.customerId) && zeile(feature.projectId) ? anleitungsLink(zeile(feature.customerId), zeile(feature.projectId)) : '',
version,
geaendert: geaendertAm && !Number.isNaN(geaendertAm.getTime()) ? datumKurz(geaendertAm) : '',
}
if (!istObjekt(einstellung)) {
const abschnitte = [abschnittSnippet({ ...gemeinsam, telefon: '' })]
const warnungen = [...banner, 'Keine Einstellungen gespeichert']
const kurz = kurzfassung({
...gemeinsam,
kunde: zeile(kunde?.name),
hatEinstellung: false,
anrede: null,
leistungen: [],
ressourcen: [],
zusatzfelder: [],
warnungen,
gesundheit: [],
snippet: abschnitte[0].text,
})
return { stand, warnungen, abschnitte, text: volltext(abschnitte), kurz }
}
const ressourcen = liste(einstellung.ressourcen).filter(istObjekt)
const betrieb = istObjekt(einstellung.betrieb) ? einstellung.betrieb : {}
const daten = {
...gemeinsam,
e: einstellung,
now,
entwurf: Boolean(entwurf),
heuteTag: heute(now),
betrieb,
telefon: zeile(betrieb.telefon),
kunde: zeile(kunde?.name),
projekt: projektText(project),
farbe: typeof einstellung.farbe === 'string' && FARBE_RE.test(einstellung.farbe) ? einstellung.farbe.toLowerCase() : VORGABE_FARBE,
haltezeit: config.termine?.holdMinutes,
ressourcen,
leistungen: liste(einstellung.leistungen).filter(istObjekt),
nachId: new Map(ressourcen.map((r) => [r.id, { typ: r.typ, name: zeile(r.name) || 'ohne Namen' }])),
datenschutz: datenschutzTexte(einstellung),
}
const abschnitte = ABSCHNITTE.map((baue) => baue(daten))
// der Volltext fuehrt nur die Datenschutz-Fassung, die zur Anrede der Gaeste passt
const andere = einstellung.anrede === 'du' ? 'datenschutz_sie' : 'datenschutz_du'
const teile = warnungsTeile(daten)
const kurz = kurzfassung({
...daten,
hatEinstellung: true,
anrede: einstellung.anrede,
zusatzfelder: liste(istObjekt(einstellung.gastFelder) ? einstellung.gastFelder.zusatz : null).filter(istObjekt),
warnungen: [...teile.davor, ...teile.danach],
gesundheit: teile.gesundheit,
snippet: abschnitte.find((a) => a.id === 'snippet').text,
})
return {
stand,
warnungen: [...teile.davor, ...teile.gesundheit, ...teile.danach],
abschnitte,
text: volltext(abschnitte.filter((a) => a.id !== andere)),
kurz,
}
}
+1 -1
View File
@@ -12,7 +12,7 @@ import { heute, zuUtc } from './zeit.js'
* Fehler sind { [pfad]: text } mit Pfaden wie 'betrieb.name' oder 'ressourcen.0.plan.1'; Texte in Du-Form (Portal).
*/
const VORGABE_FARBE = '#7c3aed'
export const VORGABE_FARBE = '#7c3aed'
const GRENZEN = { ressourcen: 20, leistungen: 50, abwesenheiten: 100, sonderoeffnungen: 200, zusatzfelder: 5, optionen: 10, json: 200000 }
const RASTER = [5, 10, 15, 20, 30, 60]
const ERINNERUNG_STUNDEN = [2, 12, 24, 48]
+57
View File
@@ -0,0 +1,57 @@
import { aboMemo } from '../aboStatus.js'
import { featureConfig } from '../featureRegistry.js'
import { sendCustomerMail } from '../mailer.js'
import { bereitMail, customerOf, projectName } from '../siteFeatures.js'
import { ladeEinstellung } from './kontext.js'
// Freischalt-Hindernisse und Freigabe-Mail des Terminplaners (TP2 D6, aus B11 herausgeloest). Beide Funktionen stehen hier,
// damit der Standardhaken freischaltbar (featureRegistry.js), der Freischalt-Job (siteFeatures.runActivation) und die
// Staff-Aktivierung (F13a) dieselbe Antwort und denselben Mailweg nutzen. siteFeatures.js und featureRegistry.js laden diese
// Datei erst im Aufruf (dynamisches import()), weil sie selbst siteFeatures.js importiert.
/**
* Was hindert die Freischaltung dieses Termine-Dokuments? null = nichts; sonst der Grund:
* 'kein_abo' kein laufendes Wartungs-Abo (inkl. beendet, Wechsel-Widerruf E1; past_due und vorgemerkte Kuendigung laufen noch)
* 'keine_einstellung' Abo laeuft, aber es ist noch keine Einstellung gespeichert
* 'abo_unbekannt' das Abo war nicht lesbar: nie freischalten, wenn etwas nicht lesbar war (der Fehler wird geloggt)
* Reihenfolge: Abo zuerst, dann Einstellung; ohne Abo wird die Einstellung gar nicht gelesen. Ein Lesefehler bei der
* Einstellung wirft (anders als beim Abo steht dafuer kein Grundcode bereit): der Aufrufer, activateClosedTickets, haelt das
* Dokument dann in 'setup'.
* abo: Fragen an das Abo, { laeuft(customerId) }; Standard aboMemo() je Aufruf, also frisch gelesen. Wer viele Dokumente
* im Listenlauf prueft, uebergibt einen gemeinsamen aboMemo(); Pruefungen unter einer Sperre lesen frisch (Standard).
* @param {{ $id: string, customerId: string, featureKey?: string }} doc portalFeatures-Dokument
* @returns {Promise<null | 'kein_abo' | 'keine_einstellung' | 'abo_unbekannt'>}
*/
export async function freischaltHindernis(doc, { abo = aboMemo() } = {}) {
let laeuft
try {
laeuft = await abo.laeuft(doc.customerId)
} catch (err) {
console.error(`[funktionen] Freischaltung ${doc.featureKey || 'termine'} (${doc.$id}) nicht prüfbar:`, err.message)
return 'abo_unbekannt'
}
if (!laeuft) return 'kein_abo'
if ((await ladeEinstellung(doc.$id)) === null) return 'keine_einstellung'
return null
}
/**
* K16 (Funktion bereit) an den Kunden eines freigeschalteten Dokuments, Werte aus dem Register-Eintrag der Funktion (bereitMail).
* Fehler (Kunde, Register, Versand) werden geloggt und nie geworfen: die Freischaltung ist dann schon geschehen, und eine Mail,
* die nicht rausgeht, darf die uebrigen Dokumente des Laufs nicht aufhalten. Kunde ohne Adresse: keine Mail, kein Fehler.
* attachments: Anhaenge der Mail, z. B. die PDFs der Staff-Aktivierung (F13a).
* @param {{ customerId: string, projectId: string, featureKey: string }} doc portalFeatures-Dokument
* @param {{ attachments?: object[] }} [optionen]
*/
export async function meldeFreigeschaltet(doc, { attachments = [] } = {}) {
try {
const customer = await customerOf(doc.customerId)
if (!customer?.email) return
const feature = featureConfig(doc.featureKey)
if (!feature) throw new Error(`Funktion ${doc.featureKey} nicht im Register`)
const mail = bereitMail(feature, { kundeName: customer.name || '', projekt: await projectName(doc.projectId) })
await sendCustomerMail(customer.email, { ...mail, attachments })
} catch (err) {
console.error(`[funktionen] K16 an Kunde ${doc.customerId} fehlgeschlagen:`, err.message)
}
}
+38
View File
@@ -106,6 +106,44 @@ export async function ladeEinstellung(featureId) {
return gelesen?.einstellung ? { ...gelesen, fragebogen: fragebogenAus(doc) } : null
}
/**
* Der Fragebogen aus einem termineConfig-Dokument, so wie er gespeichert ist (geparstes Objekt) oder null. Anders als fragebogenAus kein
* leerer Fragebogen als Ersatz: fehlt das Attribut, ist es leer oder kein JSON-Objekt, ist das Ergebnis null (kaputtes JSON mit einer Logzeile).
*/
function fragebogenRoh(doc) {
const roh = doc.fragebogen
if (roh === undefined || roh === null || roh === '') return null
try {
const fragebogen = JSON.parse(roh)
if (!istObjekt(fragebogen)) throw new Error('kein Objekt')
return fragebogen
} catch (err) {
console.error(`[termine-kontext] Fragebogen ${doc.$id} ist unlesbar: ${err.message}`)
return null
}
}
/**
* Der gespeicherte Stand einer Funktion fuer das Einrichtungs-Ticket und die Anleitung (TP2 D4): Einstellung, Version, Zeitpunkt der letzten
* Aenderung und Fragebogen, ohne Modus- und Zustandspruefung. Anders als ladeEinstellung unterscheidet er "noch nie gespeichert" von "unlesbar":
* null nur ohne termineConfig-Dokument; unlesbares JSON macht nur das jeweilige Feld null (die Version des Dokuments bleibt). Fehlt Collection
* oder Datenbank, wird der Fehler weitergereicht.
* @param {string} featureId ID des portalFeatures-Dokuments
* @returns {Promise<{ einstellung: object|null, version: number, updatedAt: string|null, fragebogen: object|null }|null>}
*/
export async function ladeStand(featureId) {
pruefeText(featureId, 'featureId')
const doc = await holeOderNull(config.collections.termineConfig, featureId)
if (!doc) return null
const { einstellung, version } = liesDokument(doc)
return {
einstellung,
version,
updatedAt: typeof doc.updatedAt === 'string' && doc.updatedAt !== '' ? doc.updatedAt : null,
fragebogen: fragebogenRoh(doc),
}
}
async function baueKontext(feature) {
const [project, doc] = await Promise.all([
feature.projectId ? holeOderNull(config.collections.websiteProjects, feature.projectId) : null,
+246 -15
View File
@@ -1,9 +1,9 @@
/**
* ENTWURF – nur Pilot, vor Einsatz bei fremden Betrieben prüfen.
*
* Rechtstext-Bausteine des Terminplaners fuer Gaeste (Spezifikation 2026-10-02-terminplaner-design.md, Abschnitt 11).
* Reine Funktionen und Konstanten, ohne Appwrite und ohne config. Die Texte sind Rechtstexte und stehen deshalb immer
* in der Sie-Form (auch bei Du-Betrieben); nur der Datenschutz-Hinweis unter dem Formular folgt der Anrede des Betriebs.
* Reine Funktionen und Konstanten, ohne Appwrite; sie nutzen Hilfsfunktionen aus einstellungen.js (zeigtWiderruf) und lesen
* config.termine.holdMinutes (Gueltigkeit des Bestaetigungslinks im Datenschutz-Absatz, bei jedem Aufruf gelesen), sonst keine Konfiguration. Die Texte
* sind Rechtstexte und stehen deshalb immer in der Sie-Form (auch bei Du-Betrieben); nur der Datenschutz-Hinweis unter dem
* Formular und der Datenschutz-Absatz folgen der Anrede.
*
* widerrufsbelehrung: Muster der Anlage 1 und Muster-Widerrufsformular der Anlage 2 zu Art. 246a § 1 Abs. 2 Satz 2 EGBGB
* (docs/rechtliches/quellen/, abgerufen 2026-10-02), Gestaltungshinweise fuer einen Dienstleistungsvertrag angewendet:
@@ -13,10 +13,28 @@
* Absatz zum elektronischen Webformular (es gibt nur die Widerrufsfunktion). test/termineRechtstexte.test.js prueft
* den Wortlaut gegen die Quelldateien.
*
* datenschutzAbsatz: Absatz „Online-Terminbuchung“ fuer die Datenschutzerklaerung der Website des Betriebs (Betrieb = Verantwortlicher).
* Der Wortlaut steht auch in docs/rechtliches/termine-datenschutz-absatz.md (Fassung Sie / Fassung Du); test/termineRechtstexte.test.js
* haelt beide gleich. Aenderungen am Text immer in Markdown und Funktion gemeinsam.
*
* Die Rueckgabe ist reiner Text mit Zeilenumbruechen und NICHT HTML-maskiert: Die Mailvorlage maskiert beim Einsetzen.
*/
export const RECHTSTEXTE_VERSION = '2026-10-02'
import { config } from '../../config.js'
import { zeigtWiderruf } from './einstellungen.js'
/**
* Fassung der Gaestetexte (steht im Nachweis jeder Buchung, archiviert in docs/rechtliches/archiv/termine-gaestetexte-<Fassung>.md).
* Gleich config.termine.avvVersion (test/termineRechtstexte.test.js haelt beide gleich); eine Textaenderung nach der Auslieferung
* bekommt ein neues Datum, ein neues Archiv und einen neuen Hash-Pin. Buchungen aus aelteren Fassungen behalten ihre Fassung.
*/
export const RECHTSTEXTE_VERSION = '2026-10-05'
/**
* Kennzeichen, ob die Gaestetexte noch als Entwurf (nur Pilot, vor dem Einsatz bei fremden Betrieben zu pruefen) gelten. Ist es true,
* nennt die Einbauanleitung das als erste Warnung. Seit der Endfassung der Gaestetexte (Fassung 2026-10-05) false.
*/
export const RECHTSTEXTE_ENTWURF = false
const ZEILENUMBRUCH = '\n'
const ABSATZ = '\n\n'
@@ -48,12 +66,18 @@ const verbinde = (teile) => teile.filter(Boolean).join(', ')
const FRIST = 'des Vertragsabschlusses.' // Gestaltungshinweis a): Dienstleistungsvertrag
// Widerrufsfunktion: Satzrahmen des Gesetzes wörtlich („Sie können Ihr Widerrufsrecht auch online unter … ausüben.“); in die
// Lücke „… [Internetadresse oder anderen geeigneten Hinweis …]“ kommt ein geeigneter Hinweis statt einer Internetadresse.
// Lücke „… [Internetadresse oder anderen geeigneten Hinweis …]“ kommt bei den Gästen ein geeigneter Hinweis statt einer
// Internetadresse, bei der Bestellung einer Funktion (WEBklar als Unternehmer) die Adresse der Widerrufsseite.
const EINGANGSBESTAETIGUNG =
'Wenn Sie diese Online-Funktion nutzen, übermitteln wir Ihnen auf einem dauerhaften Datenträger (z. B. durch eine E-Mail) '
+ 'unverzüglich eine Eingangsbestätigung mit Informationen zum Inhalt der Widerrufserklärung sowie dem Datum und der Uhrzeit ihres Eingangs.'
const WIDERRUFSFUNKTION =
'Sie können Ihr Widerrufsrecht auch online unter „Vertrag widerrufen“ (Schaltfläche im Terminbereich auf unserer Website '
+ 'sowie Link „Termin verwalten“ in Ihrer Bestätigungs-E-Mail) ausüben. '
+ 'Wenn Sie diese Online-Funktion nutzen, übermitteln wir Ihnen auf einem dauerhaften Datenträger (z. B. durch eine E-Mail) '
+ 'unverzüglich eine Eingangsbestätigung mit Informationen zum Inhalt der Widerrufserklärung sowie dem Datum und der Uhrzeit ihres Eingangs.'
+ EINGANGSBESTAETIGUNG
const WIDERRUFSFUNKTION_WEBKLAR = `Sie können Ihr Widerrufsrecht auch online unter https://project.webklar.com/widerrufen.html ausüben. ${EINGANGSBESTAETIGUNG}`
// Wertersatz bei Dienstleistungen; „oder Lieferung von Wasser/Gas/Strom/Fernwärme [Unzutreffendes streichen]“ ist gestrichen.
const WERTERSATZ =
@@ -66,7 +90,7 @@ function kontakt({ name, adresse, telefon, email }) {
return verbinde([name, adresse, `Telefon: ${telefon}`, `E-Mail: ${email}`])
}
function belehrungsAbsaetze(daten) {
function belehrungsAbsaetze(daten, widerrufsfunktion = WIDERRUFSFUNKTION) {
return [
'Widerrufsbelehrung',
'Widerrufsrecht',
@@ -75,7 +99,7 @@ function belehrungsAbsaetze(daten) {
`Um Ihr Widerrufsrecht auszuüben, müssen Sie uns (${kontakt(daten)}) mittels einer eindeutigen Erklärung `
+ '(z. B. ein mit der Post versandter Brief oder eine E-Mail) über Ihren Entschluss, diesen Vertrag zu widerrufen, informieren. '
+ 'Sie können dafür das beigefügte Muster-Widerrufsformular verwenden, das jedoch nicht vorgeschrieben ist.',
WIDERRUFSFUNKTION,
widerrufsfunktion,
'Zur Wahrung der Widerrufsfrist reicht es aus, dass Sie die Mitteilung über die Ausübung des Widerrufsrechts vor Ablauf der Widerrufsfrist absenden.',
'Folgen des Widerrufs',
'Wenn Sie diesen Vertrag widerrufen, haben wir Ihnen alle Zahlungen, die wir von Ihnen erhalten haben, einschließlich der Lieferkosten '
@@ -88,9 +112,9 @@ function belehrungsAbsaetze(daten) {
]
}
function formularAbsaetze({ name, adresse, email }) {
function formularAbsaetze({ name, adresse, email }, anZeile = `An ${verbinde([name, adresse, email])}:`) {
const zeilen = [
`An ${verbinde([name, adresse, email])}:`,
anZeile,
'Hiermit widerrufe(n) ich/wir (*) den von mir/uns (*) abgeschlossenen Vertrag über den Kauf der folgenden Waren (*)/die Erbringung der folgenden Dienstleistung (*)',
'Bestellt am (*)/erhalten am (*)',
'Name des/der Verbraucher(s)',
@@ -117,12 +141,41 @@ export function widerrufsbelehrung(betrieb) {
return [...belehrungsAbsaetze(daten), ...formularAbsaetze(daten)].join(ABSATZ)
}
/** WEBklar als Unternehmer (Bestellung einer Funktion im Kundenportal): Kontaktangaben der Widerrufsbelehrung. */
export const WEBKLAR_KONTAKT = Object.freeze({
name: 'WEBklar GbR',
adresse: 'Am Schwimmbad 10, 67722 Winnweiler',
telefon: '0176 23726355',
email: 'support@webklar.com',
})
/**
* Widerrufsbelehrung und Muster-Widerrufsformular fuer die Bestellung der Einrichtung einer Funktion (Terminplaner, News):
* WEBklar ist der Unternehmer, Sie-Form, Text mit Zeilenumbruechen wie widerrufsbelehrung. Gleiche Bausteine wie bei den
* Gaesten (Frist „des Vertragsabschlusses.“, Wertersatz fuer Dienstleistungen), aber die Widerrufsfunktion nennt die Adresse
* der Widerrufsseite (Wortlaut wie in den Abo-Bedingungen, Abschnitt Widerrufsbelehrung) und die „An“-Zeile des Formulars nennt
* die E-Mail-Adresse mit „E-Mail:“. Quelle des Dokuments docs/rechtliches/widerrufsbelehrung-funktionen.md; test/termineRechtstexte.test.js
* haelt beide gleich und gleicht die Belehrung mit public/abo-bedingungen.html ab.
*/
export function widerrufsbelehrungFunktion() {
const daten = betriebsdaten(WEBKLAR_KONTAKT, { vollstaendig: true })
const an = `An ${verbinde([daten.name, daten.adresse, `E-Mail: ${daten.email}`])}:`
return [...belehrungsAbsaetze(daten, WIDERRUFSFUNKTION_WEBKLAR), ...formularAbsaetze(daten, an)].join(ABSATZ)
}
// ---------------------------------------------------------------- Hinweis unter dem Buchungsformular (recherche-recht.md 11.2)
const LOESCHFRIST_ABGESAGT_TAGE = 30
const LOESCHFRIST_UNBESTAETIGT_STUNDEN = 48
const AUFBEWAHRUNG_VORGABE_TAGE = 90
/**
* Aufbewahrung vergangener Termine in Tagen; ein nicht ganzzahliger Wert ist die Vorgabe (90). Den Bereich 14 bis 365 stellt die
* Einstellung sicher (normalisiereEinstellung); der Loeschlauf (jobs.js) prueft ihn zusaetzlich beim Lesen.
*/
const aufbewahrungNachTermin = (einstellung) =>
Number.isInteger(einstellung?.aufbewahrungTage) ? einstellung.aufbewahrungTage : AUFBEWAHRUNG_VORGABE_TAGE
/**
* Kurzer Datenschutz-Hinweis (Art. 13 DSGVO gestuft) vor dem Absenden; Sie oder Du nach einstellung.anrede.
* Die Loeschfristen entsprechen dem Loeschlauf: nicht bestaetigt 48 Stunden nach Ablauf der Bestaetigungsfrist, abgesagt
@@ -132,7 +185,7 @@ const AUFBEWAHRUNG_VORGABE_TAGE = 90
export function formularHinweis(einstellung) {
const daten = betriebsdaten(einstellung?.betrieb)
const du = einstellung.anrede === 'du'
const tage = Number.isInteger(einstellung.aufbewahrungTage) ? einstellung.aufbewahrungTage : AUFBEWAHRUNG_VORGABE_TAGE
const tage = aufbewahrungNachTermin(einstellung)
const verantwortlich = verbinde([daten.name, daten.adresse])
const frist = `bis ${tage} Tage nach dem Termin, abgesagte Termine ${LOESCHFRIST_ABGESAGT_TAGE} Tage, `
+ `nicht bestätigte Buchungen ${LOESCHFRIST_UNBESTAETIGT_STUNDEN} Stunden nach Ablauf der Bestätigungsfrist.`
@@ -147,6 +200,180 @@ export function formularHinweis(einstellung) {
+ `Mehr erfahren Sie in unserer Datenschutzerklärung. Verantwortlich: ${verantwortlich}.`
}
// ---------------------------------------------------------------- Datenschutz-Absatz „Online-Terminbuchung“ (recherche-recht.md 11.1)
const DS_PROTOKOLL_TAGE = 14 // Server-Protokolle (Datenschutzerklaerung von WEBklar, Abschnitt 3)
const DS_EMAIL_WARTESCHLANGE =
'Bis zum Versand liegt die E-Mail in einer Warteschlange; nach dem Versand wird dort die Empfängeradresse samt Inhalt gelöscht, '
+ 'nicht zustellbare E-Mails nach 48 Stunden.'
/** Auftragsverarbeiter und Server: in beiden Anreden derselbe Text. */
const DS_AUFTRAGSVERARBEITER =
'Wer verarbeitet die Daten in unserem Auftrag? Den Terminkalender betreibt die WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, '
+ 'als unser Auftragsverarbeiter nach Art. 28 DSGVO. Die Daten liegen auf Servern der Hetzner Online GmbH, Industriestraße 25, '
+ '91710 Gunzenhausen, in Deutschland (Rechenzentren Falkenstein und Nürnberg). Der E-Mail-Versand läuft ebenfalls über Hetzner. '
+ 'Eine Übermittlung in Drittländer findet nicht statt.'
const wenn = (bedingung, text) => (bedingung ? text : '')
/** Sätze eines Absatzes, durch ein Leerzeichen getrennt; ein weggelassener Satz (leer) hinterlässt keine Lücke. */
const saetze = (...liste) => liste.filter(Boolean).join(' ')
/**
* Die fuenf bedingten Stellen des Absatzes (in der Markdown-Quelle „[[wenn …: …]]“): Telefon-Feld, Nachricht oder Zusatzfelder,
* Erinnerung, Widerruf (wie der Widerrufsknopf im Widget: zeigtWiderruf), Telefonnummer des Betriebs.
*/
function datenschutzBedingungen(einstellung, daten) {
const gast = einstellung.gastFelder || {}
return {
telefonFeld: gast.telefon !== 'aus',
freitext: gast.nachricht !== 'aus' || (Array.isArray(gast.zusatz) && gast.zusatz.length > 0),
erinnerung: Boolean(einstellung.erinnerung?.anbieten),
widerruf: zeigtWiderruf(einstellung),
telefon: daten.telefon !== '',
}
}
// Absaetze der Sie-Fassung, je Absatz eine kleine Funktion; d = { firma, verantwortlich, email, telefon, tage, haltMinuten, ...Bedingungen }
const DS_SIE = [
() => 'Online-Terminbuchung',
() => 'Auf unserer Website können Sie Termine online buchen. Dafür verarbeiten wir die folgenden Daten:',
(d) =>
'Welche Daten? Ihren Namen, Ihre E-Mail-Adresse, den gewählten Termin, die gewählte Leistung und die gewählte Person oder den gewählten Platz'
+ wenn(d.telefonFeld, ', auf Wunsch Ihre Telefonnummer')
+ wenn(d.freitext, ' und die Angaben, die Sie uns im Formular mitteilen')
+ '. Außerdem speichern wir, wann Sie den Termin bestätigt haben, welche Fassung der Hinweise Sie bei der Buchung gesehen haben '
+ 'und welche Zustimmungen Sie erteilt haben'
+ wenn(d.erinnerung, ', zum Beispiel ob Sie eine Erinnerung gewünscht haben')
+ '.',
(d) =>
saetze(
'Wofür und auf welcher Grundlage?',
'Wir brauchen die Daten, um Ihren Termin zu verwalten, Ihnen den Termin zu bestätigen und Sie über Änderungen oder Absagen zu informieren.',
'Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO (Vertrag über den gebuchten Termin, bei einer Terminanfrage vorvertragliche Maßnahmen auf Ihre Anfrage).',
wenn(
d.erinnerung,
'Wenn Sie eine Erinnerung vor dem Termin wünschen, beruht diese auf Ihrer Einwilligung nach Art. 6 Abs. 1 lit. a DSGVO. '
+ 'Sie können sie jederzeit mit Wirkung für die Zukunft widerrufen, indem Sie uns eine kurze Nachricht senden oder den Termin über den Link in der E-Mail absagen.',
),
wenn(
d.widerruf,
'Widerrufen Sie einen Vertrag über die Schaltfläche „Vertrag widerrufen“, speichern wir Ihre Erklärung mit Datum und Uhrzeit '
+ 'und senden Ihnen eine Eingangsbestätigung (Art. 6 Abs. 1 lit. c DSGVO in Verbindung mit § 356a BGB).',
),
'Zum Schutz vor Missbrauch (zum Beispiel automatisierten Buchungen) und um die Zahl der Anfragen zu begrenzen, merken wir uns Ihre IP-Adresse '
+ 'kurzzeitig nur im Arbeitsspeicher des Servers, nicht dauerhaft (Art. 6 Abs. 1 lit. f DSGVO; unser berechtigtes Interesse ist der sichere Betrieb der Terminbuchung).',
),
(d) =>
'E-Mails. Sie erhalten eine E-Mail, in der Sie Ihre E-Mail-Adresse und den Termin per Link bestätigen, danach eine Bestätigung'
+ wenn(d.erinnerung, ' und, wenn Sie das angekreuzt haben, eine Erinnerung')
+ `. Sagen wir den Termin ab oder verschieben ihn, informieren wir Sie ebenfalls per E-Mail. Die E-Mails enthalten keine Werbung und keine Tracking-Pixel. `
+ `Absender ist ${d.firma}, Ihre Antworten gehen an uns. ${DS_EMAIL_WARTESCHLANGE}`,
() => DS_AUFTRAGSVERARBEITER,
() =>
'Beim Aufruf der Terminbuchung. Beim Aufruf dieser Seite lädt Ihr Browser das Programm der Terminbuchung vom Server der WEBklar GbR (project.webklar.com), '
+ 'beim Öffnen der Terminbuchung zusätzlich die Termine und Texte. Dabei werden technisch bedingt Ihre IP-Adresse und Angaben zu Ihrem Browser übertragen. '
+ `Der Server speichert sie kurzzeitig in Protokollen und löscht sie nach ${DS_PROTOKOLL_TAGE} Tagen (Art. 6 Abs. 1 lit. f DSGVO; berechtigtes Interesse ist der sichere und funktionsfähige Betrieb). `
+ 'Wir setzen dafür keine Cookies und speichern nichts auf Ihrem Gerät.',
(d) =>
`Wie lange speichern wir? Nicht bestätigte Buchungen (der Bestätigungslink gilt ${d.haltMinuten} Minuten) löschen wir ${LOESCHFRIST_UNBESTAETIGT_STUNDEN} Stunden nach Ablauf dieser Frist. `
+ `Bestätigte Termine löschen wir ${d.tage} Tage nach dem Termin, abgesagte oder abgelehnte Termine ${LOESCHFRIST_ABGESAGT_TAGE} Tage nach der Absage oder Ablehnung. `
+ 'Terminanfragen, die wir bis zum Terminbeginn nicht beantworten, behandeln wir als abgelehnt. '
+ 'Wird die Online-Terminbuchung abgeschaltet, zum Beispiel weil unser Vertrag mit WEBklar endet, werden alle Termindaten 30 Tage nach dem Abschalten gelöscht. '
+ 'Gesetzliche Aufbewahrungspflichten für Geschäftsunterlagen (zum Beispiel Rechnungen) bleiben davon unberührt; sie erfüllen wir außerhalb des Terminkalenders.',
(d) =>
saetze(
'Pflicht zur Angabe. Ohne Name, E-Mail-Adresse und Terminwahl können wir keinen Termin online vergeben.',
wenn(d.telefon, `Sie können uns auch telefonisch erreichen: ${d.telefonnummer}.`),
'Eine automatisierte Entscheidungsfindung findet nicht statt. Bitte machen Sie bei der Buchung keine Angaben zu Ihrer Gesundheit.',
),
(d) =>
'Ihre Rechte. Sie haben das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und Datenübertragbarkeit sowie das Recht, '
+ 'der Verarbeitung auf Grundlage berechtigter Interessen zu widersprechen (Art. 15 bis 21 DSGVO). Eine erteilte Einwilligung können Sie jederzeit widerrufen. '
+ `Sie können sich bei einer Datenschutzaufsichtsbehörde beschweren. Verantwortlich ist ${d.verantwortlich}, E-Mail ${d.email}.`,
]
// Absaetze der Du-Fassung, gleicher Aufbau wie DS_SIE
const DS_DU = [
() => 'Online-Terminbuchung',
() => 'Auf unserer Website kannst du Termine online buchen. Dafür verarbeiten wir die folgenden Daten:',
(d) =>
'Welche Daten? Deinen Namen, deine E-Mail-Adresse, den gewählten Termin, die gewählte Leistung und die gewählte Person oder den gewählten Platz'
+ wenn(d.telefonFeld, ', auf Wunsch deine Telefonnummer')
+ wenn(d.freitext, ' und die Angaben, die du uns im Formular mitteilst')
+ '. Außerdem speichern wir, wann du den Termin bestätigt hast, welche Fassung der Hinweise du bei der Buchung gesehen hast '
+ 'und welche Zustimmungen du erteilt hast'
+ wenn(d.erinnerung, ', zum Beispiel ob du eine Erinnerung gewünscht hast')
+ '.',
(d) =>
saetze(
'Wofür und auf welcher Grundlage?',
'Wir brauchen die Daten, um deinen Termin zu verwalten, dir den Termin zu bestätigen und dich über Änderungen oder Absagen zu informieren.',
'Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO (Vertrag über den gebuchten Termin, bei einer Terminanfrage vorvertragliche Maßnahmen auf deine Anfrage).',
wenn(
d.erinnerung,
'Wenn du eine Erinnerung vor dem Termin wünschst, beruht diese auf deiner Einwilligung nach Art. 6 Abs. 1 lit. a DSGVO. '
+ 'Du kannst sie jederzeit mit Wirkung für die Zukunft widerrufen, indem du uns eine kurze Nachricht sendest oder den Termin über den Link in der E-Mail absagst.',
),
wenn(
d.widerruf,
'Widerrufst du einen Vertrag über die Schaltfläche „Vertrag widerrufen“, speichern wir deine Erklärung mit Datum und Uhrzeit '
+ 'und senden dir eine Eingangsbestätigung (Art. 6 Abs. 1 lit. c DSGVO in Verbindung mit § 356a BGB).',
),
'Zum Schutz vor Missbrauch (zum Beispiel automatisierten Buchungen) und um die Zahl der Anfragen zu begrenzen, merken wir uns deine IP-Adresse '
+ 'kurzzeitig nur im Arbeitsspeicher des Servers, nicht dauerhaft (Art. 6 Abs. 1 lit. f DSGVO; unser berechtigtes Interesse ist der sichere Betrieb der Terminbuchung).',
),
(d) =>
'E-Mails. Du erhältst eine E-Mail, in der du deine E-Mail-Adresse und den Termin per Link bestätigst, danach eine Bestätigung'
+ wenn(d.erinnerung, ' und, wenn du das angekreuzt hast, eine Erinnerung')
+ `. Sagen wir den Termin ab oder verschieben ihn, informieren wir dich ebenfalls per E-Mail. Die E-Mails enthalten keine Werbung und keine Tracking-Pixel. `
+ `Absender ist ${d.firma}, deine Antworten gehen an uns. ${DS_EMAIL_WARTESCHLANGE}`,
() => DS_AUFTRAGSVERARBEITER,
() =>
'Beim Aufruf der Terminbuchung. Beim Aufruf dieser Seite lädt dein Browser das Programm der Terminbuchung vom Server der WEBklar GbR (project.webklar.com), '
+ 'beim Öffnen der Terminbuchung zusätzlich die Termine und Texte. Dabei werden technisch bedingt deine IP-Adresse und Angaben zu deinem Browser übertragen. '
+ `Der Server speichert sie kurzzeitig in Protokollen und löscht sie nach ${DS_PROTOKOLL_TAGE} Tagen (Art. 6 Abs. 1 lit. f DSGVO; berechtigtes Interesse ist der sichere und funktionsfähige Betrieb). `
+ 'Wir setzen dafür keine Cookies und speichern nichts auf deinem Gerät.',
(d) =>
`Wie lange speichern wir? Nicht bestätigte Buchungen (der Bestätigungslink gilt ${d.haltMinuten} Minuten) löschen wir ${LOESCHFRIST_UNBESTAETIGT_STUNDEN} Stunden nach Ablauf dieser Frist. `
+ `Bestätigte Termine löschen wir ${d.tage} Tage nach dem Termin, abgesagte oder abgelehnte Termine ${LOESCHFRIST_ABGESAGT_TAGE} Tage nach der Absage oder Ablehnung. `
+ 'Terminanfragen, die wir bis zum Terminbeginn nicht beantworten, behandeln wir als abgelehnt. '
+ 'Wird die Online-Terminbuchung abgeschaltet, zum Beispiel weil unser Vertrag mit WEBklar endet, werden alle Termindaten 30 Tage nach dem Abschalten gelöscht. '
+ 'Gesetzliche Aufbewahrungspflichten für Geschäftsunterlagen (zum Beispiel Rechnungen) bleiben davon unberührt; sie erfüllen wir außerhalb des Terminkalenders.',
(d) =>
saetze(
'Pflicht zur Angabe. Ohne Name, E-Mail-Adresse und Terminwahl können wir keinen Termin online vergeben.',
wenn(d.telefon, `Du kannst uns auch telefonisch erreichen: ${d.telefonnummer}.`),
'Eine automatisierte Entscheidungsfindung findet nicht statt. Bitte mach bei der Buchung keine Angaben zu deiner Gesundheit.',
),
(d) =>
'Deine Rechte. Du hast das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und Datenübertragbarkeit sowie das Recht, '
+ 'der Verarbeitung auf Grundlage berechtigter Interessen zu widersprechen (Art. 15 bis 21 DSGVO). Eine erteilte Einwilligung kannst du jederzeit widerrufen. '
+ `Du kannst dich bei einer Datenschutzaufsichtsbehörde beschweren. Verantwortlich ist ${d.verantwortlich}, E-Mail ${d.email}.`,
]
/**
* Absatz „Online-Terminbuchung“ fuer die Datenschutzerklaerung der Website des Betriebs, als reiner Text (nicht HTML-maskiert):
* Absaetze durch eine Leerzeile, erste Zeile „Online-Terminbuchung“. anrede === 'du' ist die Du-Fassung, alles andere die Sie-Fassung
* (wie formularHinweis); die Anrede kommt aus dem Parameter, nicht aus einstellung.anrede. Sätze, die zu abgeschalteten Funktionen
* gehören, fehlen (Telefon-Feld, Nachricht oder Zusatzfelder, Erinnerung, Widerruf, Telefonnummer des Betriebs, siehe datenschutzBedingungen).
* Die Aufbewahrung nach dem Termin ist einstellung.aufbewahrungTage (ungültig: 90). Fehlt der Betriebsname oder die E-Mail-Adresse:
* TypeError (wie bei den anderen Rechtstexten); eine fehlende Anschrift steht einfach nicht im Text.
*/
export function datenschutzAbsatz(einstellung, anrede) {
const daten = betriebsdaten(einstellung?.betrieb)
const absaetze = anrede === 'du' ? DS_DU : DS_SIE
const d = {
...datenschutzBedingungen(einstellung, daten),
firma: daten.name,
verantwortlich: verbinde([daten.name, daten.adresse]),
email: daten.email,
telefonnummer: daten.telefon,
tage: aufbewahrungNachTermin(einstellung),
haltMinuten: config.termine.holdMinutes, // Gueltigkeit des Bestaetigungslinks, bei jedem Aufruf gelesen
}
return absaetze.map((absatz) => absatz(d)).join(ABSATZ)
}
// ---------------------------------------------------------------- Zustimmungen (Haken) und Hinweis zu Gesundheitsangaben
/**
@@ -162,8 +389,12 @@ export const ZUSTIMMUNG_VORZEITIG = (name) =>
/** Pflicht-Haken, wenn der Betrieb Stornobedingungen hat. */
export const ZUSTIMMUNG_STORNO = 'Ich habe die Stornobedingungen gelesen.'
/** Freiwilliger Haken (nie vorangekreuzt), nur wenn der Betrieb Erinnerungen anbietet; stunden = einstellung.erinnerung.stundenVorher. */
export const ZUSTIMMUNG_ERINNERUNG = (stunden) => `Ich möchte ${stunden} Stunden vor dem Termin eine Erinnerung per E-Mail erhalten.`
/**
* Freiwilliger Haken (nie vorangekreuzt), nur wenn der Betrieb Erinnerungen anbietet; stunden = einstellung.erinnerung.stundenVorher.
* Der zweite Satz weist auf das Widerrufsrecht der Einwilligung hin (Art. 7 Abs. 3 DSGVO); der Hash im Nachweis deckt beide Saetze.
*/
export const ZUSTIMMUNG_ERINNERUNG = (stunden) =>
`Ich möchte ${stunden} Stunden vor dem Termin eine Erinnerung per E-Mail erhalten. Diese Einwilligung kann ich jederzeit widerrufen.`
/** Hinweis ueber dem Nachrichtenfeld (Gesundheitsbereich ist ausgeschlossen, Spezifikation Abschnitt 4). */
export const GESUNDHEIT_HINWEIS = 'Bitte keine Gesundheitsangaben.'
+43 -5
View File
@@ -1,10 +1,48 @@
// STUB (Block B) – ersetzt Task D4
import { minimaleTicketDetails } from '../featureRegistry.js'
import { TICKET_DETAILS_MAX, kuerze, minimaleTicketDetails } from '../featureRegistry.js'
import { einbauanleitung } from './einbauanleitung.js'
import { ladeStand } from './kontext.js'
/**
* Details des Einrichtungs-Tickets fuer den Terminplaner (config.features.termine.ticket.details).
* ctx = { feature, doc, project, customer }. Block D4 ersetzt den Inhalt durch einbauanleitung(...).kurz; Signatur und ctx bleiben.
* Details des Einrichtungs-Tickets fuer den Terminplaner (config.features.termine.ticket.details, TP2 Task D4).
* ctx = { feature, doc, project, customer } wie ihn markPaid uebergibt: feature = Registereintrag (der Generator braucht ihn nicht),
* doc = portalFeatures-Dokument (siteKey, $id, customerId, projectId, unlockedByPurchase kommen von dort), project = websiteProjects-Dokument,
* customer = Kunde oder null (best effort).
*
* Inhalt: einbauanleitung(...).kurz, gebaut aus dem gespeicherten Stand der Einstellungen (ladeStand). Die Funktion wirft nie: bezahlt ist bezahlt,
* das Ticket entsteht immer. Scheitert das Lesen oder der Generator, stehen die Kopfzeilen, der Hinweis, dass die Anleitung nicht erzeugt werden
* konnte, und der Verweis auf das WOMS im Ticket (notfallDetails). Das Log nennt keine Daten des Betriebs.
*/
export async function termineTicketDetails(ctx) {
return `${minimaleTicketDetails(ctx)}\nEinrichtung bezahlt (149 €), Zeit nicht auf das Stundenkontingent anrechnen.`
try {
const { doc, project, customer } = ctx
const stand = await ladeStand(doc.$id)
const { kurz } = einbauanleitung({
feature: doc,
project,
einstellung: stand?.einstellung ?? null,
fragebogen: stand?.fragebogen ?? null,
version: stand?.version ?? 0,
updatedAt: stand?.updatedAt ?? null,
kunde: { name: customer?.name || doc.customerId || '' },
now: new Date(),
})
if (typeof kurz !== 'string' || kurz.trim() === '') throw new Error('Kurzfassung leer')
return kurz
} catch (err) {
console.error(`[termine-ticket] Kurzfassung der Einbauanleitung nicht erzeugbar, Notfall-Details: ${err?.message}`)
return notfallDetails(ctx)
}
}
const ANLEITUNG_FEHLT = 'Anleitung konnte nicht erzeugt werden, im WOMS öffnen (Verweis oben).'
const KONTINGENT = 'Nicht auf das Stundenkontingent anrechnen.'
/** Kopfzeilen und Verweis (minimaleTicketDetails, Funktion wie im Kopf der Kurzfassung) plus Hinweis; wirft nie, auch nicht bei einem unbrauchbaren ctx. */
function notfallDetails(ctx) {
try {
const kopf = minimaleTicketDetails({ ...ctx, feature: { key: 'Terminplaner' } })
return kuerze(`${kopf}\n${ANLEITUNG_FEHLT}\n${KONTINGENT}`.toWellFormed(), TICKET_DETAILS_MAX)
} catch {
return `Funktion: Terminplaner\nAnleitung konnte nicht erzeugt werden, im WOMS öffnen (Kunden → Funktionen).\n${KONTINGENT}`
}
}
+3 -2
View File
@@ -1,4 +1,4 @@
import { neueId, normalisiereEinstellung } from './einstellungen.js'
import { hatPreis, neueId, normalisiereEinstellung } from './einstellungen.js'
/**
* Branchen-Vorlagen des Terminplaners (Spezifikation Abschnitt 9, Erstes Oeffnen): Daten, kein Code je Nische.
@@ -206,7 +206,8 @@ export function ausVorlage(key, betrieb, now) {
regeln: { ...v.regeln, maxOffeneJeEmail: 3, personWaehlbar: true },
gastFelder: structuredClone(v.gastFelder),
erinnerung: { anbieten: true, stundenVorher: 24 },
storno: { text: v.storno },
// Stornobedingungen gelten nur fuer Leistungen mit Preis: hat keine Leistung der Vorlage einen, gibt es keinen Vorschlag
storno: { text: v.leistungen.some(hatPreis) ? v.storno : '' },
recht: { ...v.recht },
zahlung: { modus: 'vorort', vorOrtArten: [...ZAHLARTEN] },
}