Compare commits

...
9 Commits
Author SHA1 Message Date
ClaudeandClaude Opus 5.5 4c3f67eadc Merge origin/test in feature/terminplaner-tp2 (Projekte: Anlegen, Archiv, Vorlage)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:12:04 +00:00
ClaudeandClaude Opus 5.5 878a530bd7 Terminplaner: Warnung im Dialog, wenn K16 mit den Unterlagen nicht rausging
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:05:44 +00:00
ClaudeandClaude Sonnet 5.5 72eedeb59c Terminplaner: Abschalten-Dialog nennt Löschung nach 30 Tagen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 06:36:12 +00:00
ClaudeandClaude Sonnet 5.5 89f5bd4bc6 Rechtliches: Schlüssel höchstens 36 Zeichen
Der Test der Karten-Schlüssel prüft jetzt die Appwrite-Grenze für eigene
Dokument-IDs (36 Zeichen) statt 64.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 05:45:27 +00:00
ClaudeandClaude Sonnet 5.5 225e2f1b74 Rechtliches: Karten für Leistungsbeschreibung, AVV und Widerrufsbelehrung des Terminplaners
Das Setup-Skript legt drei weitere Karten an (sort 7 bis 9, liveUrl = Alias-Seiten
ohne Fassungsdatum). Es legt nur Karten an (idempotent); Ausfuehrung mit API-Key
und Upload der Versionen macht der Inhaber.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 05:08:42 +00:00
ClaudeandClaude Sonnet 5.5 45e5dc30dc Terminplaner: Staff-Aktivierung nur mit Zustimmung in Textform (Modus on), WOMS
Im Dialog "Terminplaner ohne Zahlung aktivieren?" erscheinen, wenn das Portal
termine.textformNoetig meldet, der Haken (Leistungsbeschreibung und AVV in
der Fassung, Ausschluss Gesundheitsbereich), das Datum der Zustimmung und der
Weg (Chat | E-Mail) sowie die Textvorlage fuer den Chat mit Kopierknopf
(versionierte Adressen aus dem Portal). "Ja" bleibt gesperrt, bis alles
stimmt (canSubmitTextform mit minDate = Fassung); der Rumpf traegt
textformDate und textformChannel. News und Abschalten bleiben unveraendert.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 05:07:37 +00:00
ClaudeandClaude Sonnet 5.5 857e001911 WOMS: Einbauanleitung als Panel mit Kopierknoepfen, Abnahme-Knopf und Ticket nachlegen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 02:08:04 +00:00
ClaudeandClaude Sonnet 5.5 c0015544c4 WOMS: Aktionen und Anleitungs-Logik fuer den Terminplaner
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 01:54:24 +00:00
ClaudeandClaude Sonnet 5.5 6f0371283e Funktionen-Reiter: Abschaltgrund und Löschdatum des Terminplaners
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 18:44:23 +00:00
7 changed files with 810 additions and 24 deletions

No files matched your search

+4
View File
@@ -95,6 +95,10 @@ export const DOCUMENTS = [
{ key: "datenschutz", title: "Datenschutzerklärung (Website und Portal)", liveUrl: "https://project.webklar.com/datenschutz.html" },
{ key: "impressum", title: "Impressum", liveUrl: "https://webklar.com/impressum" },
{ key: "widerruf", title: "Widerrufsbelehrung und -formular", liveUrl: "https://project.webklar.com/widerrufen.html" },
// Terminplaner (TP2): live ist die Alias-Seite ohne Fassungsdatum (Portal, Aufgabe F1); die Versionen (PDF/HTML der Fassung) laedt der Inhaber hoch
{ key: "leistungsbeschreibung-terminplaner", title: "Leistungsbeschreibung Terminplaner", liveUrl: "https://project.webklar.com/rechtliches/leistungsbeschreibung-terminplaner.html" },
{ key: "avv-terminplaner", title: "AVV Terminplaner (Auftragsverarbeitung)", liveUrl: "https://project.webklar.com/rechtliches/avv-terminplaner.html" },
{ key: "widerrufsbelehrung-funktionen", title: "Widerrufsbelehrung und Muster-Widerrufsformular (Funktionen)", liveUrl: "https://project.webklar.com/rechtliches/widerrufsbelehrung-funktionen.html" },
].map((d, i) => ({ ...d, sort: i + 1 }));
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+178 -14
View File
@@ -1,10 +1,15 @@
import { Fragment, useCallback, useEffect, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import { FaCheck, FaCopy, FaSpinner } from 'react-icons/fa6'
import { FaCheck, FaCopy, FaRotateRight, FaSpinner } from 'react-icons/fa6'
import { Card, Badge, Button, EmptyState } from '../ui'
import { copyText } from '../CopyableCredential'
import { getFunktionen, setFeature } from '../../lib/teamApi'
import { FUNKTIONEN, ZUSTAND, funktionOf, aktionen, mergeFunktion, siteKeyOf } from '../../lib/funktionenView'
import { getFunktionen, setFeature, getTermineAnleitung } from '../../lib/teamApi'
import {
FUNKTIONEN, ZUSTAND, AKTION_LABEL, funktionOf, aktionen, mergeFunktion, siteKeyOf, abgeschaltetText,
hindernisText, panelModell, anleitungOffen, textformNoetig, textformAnsicht, textformBereit, speicherStatus,
} from '../../lib/funktionenView'
import { CHANNELS, channelLabel } from '../../lib/analyseView'
import { berlinYmd } from '../../lib/format'
import '../../styles/admin-areas.css'
// Reiter "Funktionen": News-Pinnwand und Terminplaner je Projekt an/aus wie im frueheren Admin-Panel (Kundenliste, Zeile "Funktionen")
@@ -32,42 +37,180 @@ function SiteKey({ value }) {
)
}
// Kopierknopf mit "Kopiert"-Rueckmeldung (wie SiteKey)
function KopierKnopf({ text, label }) {
const [copied, setCopied] = useState(false)
const timer = useRef(null)
useEffect(() => () => clearTimeout(timer.current), [])
const copy = async () => {
await copyText(text)
setCopied(true)
clearTimeout(timer.current)
timer.current = setTimeout(() => setCopied(false), 1800)
}
return (
<Button size="sm" variant="ghost" onClick={copy} title={label}>
{copied ? <><FaCheck className="funktionen-sitekey-ok" /> Kopiert</> : <><FaCopy /> {label}</>}
</Button>
)
}
// Einbauanleitung des Terminplaners unter der Zeile. Inhalte und Entscheidungen kommen aus funktionenView (panelModell); Texte
// stehen ausschliesslich als React-Textknoten da (nie als HTML oder Link). Laedt beim Oeffnen und bei jedem neuen reloadKey
// (nach einer Aktion aendern sich Version und Hindernis) neu.
function AnleitungPanel({ customerId, projectId, feature, reloadKey }) {
const [antwort, setAntwort] = useState(null)
const [fehler, setFehler] = useState(null)
const [retry, setRetry] = useState(0)
useEffect(() => {
let aktuell = true
setAntwort(null)
setFehler(null)
getTermineAnleitung(customerId, projectId)
.then((res) => { if (aktuell) setAntwort(res) })
.catch((err) => { if (aktuell) setFehler(err?.message || 'Anleitung konnte nicht geladen werden.') })
return () => { aktuell = false }
}, [customerId, projectId, reloadKey, retry])
const hindernis = hindernisText(feature.hindernis)
if (fehler || !antwort) {
return (
<div className="area-row funktionen-panel">
{fehler ? <div className="area-error">{fehler}</div> : <FaSpinner className="spinner" />}
{hindernis && <div className="area-summary warn">{hindernis}</div>}
{fehler && <div><Button size="sm" variant="ghost" onClick={() => setRetry((n) => n + 1)}><FaRotateRight /> Erneut laden</Button></div>}
</div>
)
}
const modell = panelModell(antwort, feature)
return (
<div className="area-row funktionen-panel">
<div className="funktionen-panel-head">
<strong>Einbauanleitung</strong>
{modell.kopf && <span className="area-muted">{modell.kopf}</span>}
{modell.ticketPfad && <Link to={modell.ticketPfad}>Zum Ticket</Link>}
<Button size="sm" variant="ghost" onClick={() => setRetry((n) => n + 1)} title="Anleitung neu laden" aria-label="Anleitung neu laden"><FaRotateRight /></Button>
</div>
{modell.warnungen.length > 0 && (
<ul className="area-error funktionen-panel-warnungen">
{modell.warnungen.map((text, i) => <li key={i}>{text}</li>)}
</ul>
)}
{modell.hinweis && <div className="area-summary warn">{modell.hinweis}</div>}
{modell.kopien.length > 0 && (
<div className="area-inline">
{modell.kopien.map((k) => <KopierKnopf key={k.id} text={k.text} label={k.label} />)}
</div>
)}
{modell.abschnitte.map((a) => (
<details key={a.id} className="funktionen-panel-abschnitt">
<summary>{a.titel}</summary>
<pre>{a.text}</pre>
</details>
))}
</div>
)
}
// Zustimmung des Kunden in Textform (Terminplaner 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({ termine, value, onChange, projectId, today, disabled }) {
const ansicht = textformAnsicht(termine)
const { label, links, vorlage } = ansicht
return (
<div className="area-row funktionen-panel">
<label className="area-inline" style={{ alignItems: 'flex-start', flexWrap: 'nowrap' }}>
<input type="checkbox" style={{ marginTop: 3 }} checked={value.confirmed} disabled={disabled} onChange={(e) => onChange({ ...value, confirmed: e.target.checked })} />
<span>
{label.before}
{links.lb ? <a href={links.lb} target="_blank" rel="noopener noreferrer">{label.lb}</a> : label.lb}
{label.between}
{links.avv ? <a href={links.avv} target="_blank" rel="noopener noreferrer">{label.avv}</a> : label.avv}
{label.after}
</span>
</label>
<div className="area-inline">
<label>
Datum der Zustimmung:{' '}
<input type="date" required className="form-control" value={value.date} min={ansicht.version} max={today} disabled={disabled} onChange={(e) => onChange({ ...value, date: e.target.value })} />
</label>
<span role="radiogroup" aria-label="Weg der Zustimmung" className="area-inline">
<span>Weg:</span>
{CHANNELS.map((c) => (
<label key={c} className="area-inline">
<input type="radio" name={`termine-weg-${projectId}`} value={c} checked={value.channel === c} disabled={disabled} onChange={() => onChange({ ...value, channel: c })} />
{channelLabel(c)}
</label>
))}
</span>
</div>
{vorlage ? (
<details>
<summary style={{ cursor: 'pointer' }}>Textvorlage für den Chat</summary>
<p style={{ margin: '6px 0', whiteSpace: 'pre-wrap' }}>{vorlage}</p>
<KopierKnopf text={vorlage} label="Vorlage kopieren" />
</details>
) : (
<div className="area-summary warn">Die Adressen der Unterlagen fehlen; die Chat-Vorlage ist nicht verfügbar.</div>
)}
</div>
)
}
const LEERE_TEXTFORM = { confirmed: false, date: '', channel: '' }
export default function CustomerFunktionen({ customerId }) {
const [projects, setProjects] = useState(null)
const [termine, setTermine] = useState(null) // Portal-Feld termine: { textformNoetig, avvVersion, links }
const [textform, setTextform] = useState(LEERE_TEXTFORM)
const [error, setError] = useState(null)
const [confirm, setConfirm] = useState(null) // { projectId, key, action }
const [note, setNote] = useState('')
const [busy, setBusy] = useState(false)
const [status, setStatus] = useState(null) // { ok, text }
const [panel, setPanel] = useState({}) // projectId -> true/false (Knopf "Anleitung"); ohne Eintrag entscheidet die Adresse (?anleitung=<projectId>)
const [panelReload, setPanelReload] = useState({}) // projectId -> Zaehler, nach jeder Aktion hochgezaehlt
const load = useCallback(() => {
setError(null)
getFunktionen(customerId)
.then(({ projects: list }) => setProjects(list || []))
.then(({ projects: list, termine: feld }) => { setProjects(list || []); setTermine(feld || null) })
.catch((err) => setError(err.message || 'Funktionen konnten nicht geladen werden.'))
}, [customerId])
useEffect(() => { load() }, [load])
const save = async () => {
const { projectId, key, action } = confirm
setBusy(true)
setStatus(null)
try {
const { projects: changed } = await setFeature(customerId, confirm.key, { projectId: confirm.projectId, action: confirm.action, note: note.trim() })
const mitTextform = textformNoetig(key, action, termine)
const antwort = await setFeature(customerId, key, {
projectId, action, note: note.trim(),
...(mitTextform ? { textformDate: textform.date, textformChannel: textform.channel } : {}),
})
// Die Antwort enthaelt das ganze Projekt; uebernommen wird nur die geaenderte Funktion
const updated = changed?.[0]
if (updated) setProjects((list) => mergeFunktion(list, updated.projectId, confirm.key, updated))
const updated = antwort?.projects?.[0]
if (updated) setProjects((list) => mergeFunktion(list, updated.projectId, key, updated))
setConfirm(null)
setNote('')
setStatus({ ok: true, text: 'Gespeichert.' })
setTextform(LEERE_TEXTFORM)
setStatus(speicherStatus(antwort))
} catch (err) {
setConfirm(null)
setStatus({ ok: false, text: err.message })
} finally {
setBusy(false)
// Version und Hindernis der Anleitung haengen vom Zustand ab: Panel dieser Zeile neu laden
if (key === 'termine') setPanelReload((r) => ({ ...r, [projectId]: (r[projectId] || 0) + 1 }))
}
}
const ask = (projectId, key, action) => { setNote(''); setConfirm({ projectId, key, action }) }
const ask = (projectId, key, action) => { setNote(''); setTextform(LEERE_TEXTFORM); setConfirm({ projectId, key, action }) }
const heute = berlinYmd(new Date())
const panelOffen = (projectId) => (Object.prototype.hasOwnProperty.call(panel, projectId) ? panel[projectId] : anleitungOffen(window.location.search, projectId))
const togglePanel = (projectId) => setPanel((p) => ({ ...p, [projectId]: !panelOffen(projectId) }))
if (error) return <Card><div className="area-error">{error}</div></Card>
if (!projects) return <Card><FaSpinner className="spinner" /></Card>
@@ -81,8 +224,13 @@ export default function CustomerFunktionen({ customerId }) {
const feature = funktionOf(p, f.key)
const [tone, label] = ZUSTAND[feature.state] || ['muted', feature.state]
const isConfirming = confirm?.projectId === p.projectId && confirm?.key === f.key
const hatAnleitung = f.key === 'termine' && feature.state !== 'off'
const anleitungOffenHier = hatAnleitung && panelOffen(p.projectId)
const braucheTextform = isConfirming && textformNoetig(f.key, confirm.action, termine)
const textformFehlt = braucheTextform && !textformBereit({ ...textform, today: heute }, termine)
return (
<div key={`${p.projectId}:${f.key}`} className="area-row funktionen-row">
<Fragment key={`${p.projectId}:${f.key}`}>
<div className="area-row funktionen-row">
{index === 0 ? (
<span>
<strong>{p.projectName}</strong>
@@ -94,6 +242,8 @@ export default function CustomerFunktionen({ customerId }) {
<span>{f.label}</span>
<span>
<Badge tone={tone}>{label}</Badge>
{feature.state === 'setup' && hindernisText(feature.hindernis) && <><br /><span className="area-warn">{hindernisText(feature.hindernis)}</span></>}
{abgeschaltetText(feature) && <><br /><span className="area-muted">{abgeschaltetText(feature)}</span></>}
{feature.adminNote && <><br /><span className="area-muted" title={feature.adminNote}>{short(feature.adminNote)}</span></>}
{siteKeyOf(feature, f.key) && <><br /><SiteKey value={siteKeyOf(feature, f.key)} /></>}
</span>
@@ -109,18 +259,32 @@ export default function CustomerFunktionen({ customerId }) {
<Fragment>
<span>{f.bestaetigen[confirm.action]}</span>
<input className="form-control" placeholder="Notiz (optional)" maxLength={120} value={note} onChange={(e) => setNote(e.target.value)} style={{ maxWidth: 220 }} />
<Button size="sm" variant="primary" onClick={save} disabled={busy}>{busy ? <FaSpinner className="spinner" /> : 'Ja'}</Button>
<Button size="sm" variant="primary" onClick={save} disabled={busy || textformFehlt}>{busy ? <FaSpinner className="spinner" /> : 'Ja'}</Button>
<Button size="sm" variant="ghost" onClick={() => setConfirm(null)} disabled={busy}>Abbrechen</Button>
</Fragment>
) : (
aktionen(feature.state).map((action) => (
<Fragment>
{aktionen(feature.state, f.key, { ticketMissing: feature.ticketMissing }).map((action) => (
<Button key={action} size="sm" variant="ghost" onClick={() => ask(p.projectId, f.key, action)}>
{action === 'activate' ? 'Aktivieren (ohne Zahlung)' : 'Abschalten'}
{AKTION_LABEL[action]}
</Button>
))
))}
{hatAnleitung && (
<Button size="sm" variant="ghost" onClick={() => togglePanel(p.projectId)} aria-expanded={anleitungOffenHier}>
Anleitung
</Button>
)}
</Fragment>
)}
</span>
</div>
{braucheTextform && (
<TextformPanel termine={termine} value={textform} onChange={setTextform} projectId={p.projectId} today={heute} disabled={busy} />
)}
{anleitungOffenHier && (
<AnleitungPanel customerId={customerId} projectId={p.projectId} feature={feature} reloadKey={panelReload[p.projectId] || 0} />
)}
</Fragment>
)
}))}
</div>
+173 -3
View File
@@ -1,6 +1,9 @@
// Kunden-Reiter "Funktionen": Register der Funktionen je Projekt (News, Terminplaner), Aktionen je Zustand und
// Einarbeiten der Portal-Antwort. Rein, unter Node testbar.
import { fmtYmd, fmtBerlin } from './format'
import { canSubmitTextform } from './analyseView'
/** Funktionen in Anzeigereihenfolge; key = Schluessel im Portal (features[key], POST /funktionen/:customerId/:key). */
export const FUNKTIONEN = [
{
@@ -16,7 +19,9 @@ export const FUNKTIONEN = [
label: 'Terminplaner',
bestaetigen: {
activate: 'Terminplaner ohne Zahlung aktivieren?',
disable: 'Terminplaner abschalten? Auf der Website sind dann keine Buchungen mehr möglich.',
disable: 'Terminplaner abschalten? Auf der Website sind dann keine Buchungen mehr möglich. Damit beginnt die Löschung: Nach 30 Tagen werden alle Termine und Einstellungen gelöscht. Wieder einschalten ist nach diesen 30 Tagen nur mit einer neuen Bestellung (149 €) möglich.',
abnahme: 'Terminplaner freischalten? Der Kunde bekommt die Mail „bereit“ und Gäste können buchen.',
ticket: 'Fehlendes Einrichtungs-Ticket neu anlegen?',
},
},
]
@@ -30,6 +35,25 @@ export const ZUSTAND = {
disabled: ['muted', 'Abgeschaltet'],
}
/** Abschaltgrund (Portal: features[key].disabledReason) -> Zusatz hinter "Abgeschaltet"; unbekannte Gruende gelten als "ohne Grund". */
const ABGESCHALTET_GRUND = {
abo_ende: ' (Abo beendet)',
abo_ende_vor_einrichtung: ' (Abo endete vor Einrichtung, Erstattung prüfen)',
team: ' (Team)',
kunde_geloescht: ' (Kunde gelöscht)',
}
/**
* Zeile unter dem Abzeichen "Abgeschaltet": Grund und, falls das Portal ein Loeschdatum nennt (feature.loeschungAm, Berliner
* Kalendertag; nur Terminplaner), "Löschung am TT.MM.JJJJ". Ausserhalb von "disabled" ''.
*/
export function abgeschaltetText(feature) {
if (feature?.state !== 'disabled') return ''
const grund = Object.prototype.hasOwnProperty.call(ABGESCHALTET_GRUND, feature.disabledReason) ? ABGESCHALTET_GRUND[feature.disabledReason] : ''
const loeschung = feature.loeschungAm ? `, Löschung am ${fmtYmd(feature.loeschungAm)}` : ''
return `Abgeschaltet${grund}${loeschung}`
}
/** Ansicht einer Funktion im Projekt (Portal: project.features[key]); ohne Eintrag gilt "off". */
export const funktionOf = (project, key) => project?.features?.[key] || { state: 'off' }
@@ -42,14 +66,94 @@ export function siteKeyOf(feature, key) {
return key === 'termine' && typeof wert === 'string' ? wert.trim() : ''
}
/** Moegliche Aktionen je Zustand: Aktivieren (ohne Zahlung) ausser bei "active", Abschalten solange bestellt, in Einrichtung oder aktiv. */
export function aktionen(state) {
/** Beschriftung der Aktionsknoepfe (Aktion = action im POST /funktionen/:customerId/:key). */
export const AKTION_LABEL = {
activate: 'Aktivieren (ohne Zahlung)',
disable: 'Abschalten',
abnahme: 'Freischalten (Abnahme)',
ticket: 'Ticket nachlegen',
}
/**
* Moegliche Aktionen je Zustand. News (und ohne key): Aktivieren (ohne Zahlung) ausser bei "active", Abschalten solange bestellt, in
* Einrichtung oder aktiv. Terminplaner: "ordered" nur Abschalten (Aktivieren ohne Zahlung wuerde eine spaetere Zahlung im Portal
* verschlucken), "setup" Freischalten nach der Abnahme (mit ticketMissing zuerst "Ticket nachlegen"), "active" nur Abschalten.
*/
export function aktionen(state, key, { ticketMissing = false } = {}) {
if (key === 'termine') {
if (state === 'ordered' || state === 'active') return ['disable']
if (state === 'setup') return ticketMissing ? ['ticket', 'abnahme', 'disable'] : ['abnahme', 'disable']
return ['activate']
}
const list = []
if (state !== 'active') list.push('activate')
if (['ordered', 'setup', 'active'].includes(state)) list.push('disable')
return list
}
/** Warum der Terminplaner in "setup" noch nicht freigeschaltet werden kann (Portal: feature.hindernis) -> Hinweistext; sonst ''. */
const HINDERNIS_TEXT = {
kein_abo: 'Kein laufendes Wartungs-Abo: Freischalten ist gesperrt.',
keine_einstellung: 'Noch keine Einstellungen gespeichert.',
abo_unbekannt: 'Abo gerade nicht prüfbar.',
}
export const hindernisText = (code) => (Object.prototype.hasOwnProperty.call(HINDERNIS_TEXT, code) ? HINDERNIS_TEXT[code] : '')
/** Feste Beschriftungen der Teilkopien (Abschnitt-id -> Knopftext); jeder andere kopierbare Abschnitt heisst "<Titel> kopieren". */
const KOPIE_LABEL = {
snippet: 'Einbau-Code kopieren',
css: 'CSS-Variablen kopieren',
datenschutz_sie: 'Datenschutz-Absatz (Sie) kopieren',
datenschutz_du: 'Datenschutz-Absatz (Du) kopieren',
}
const istText = (wert) => typeof wert === 'string' && wert !== ''
/** Kopierknoepfe des Anleitungs-Panels: zuerst "alles" (antwort.text), dann jeder kopierbare Abschnitt mit Text. Ohne Antwort oder ohne Text []. */
export function anleitungKopien(antwort) {
if (!istText(antwort?.text)) return []
const kopien = [{ id: 'alles', label: 'Alles kopieren', text: antwort.text }]
for (const a of Array.isArray(antwort.abschnitte) ? antwort.abschnitte : []) {
if (!a?.kopierbar || !istText(a.text)) continue
const label = Object.prototype.hasOwnProperty.call(KOPIE_LABEL, a.id) ? KOPIE_LABEL[a.id] : `${a.titel || a.id} kopieren`
kopien.push({ id: a.id, label, text: a.text })
}
return kopien
}
/** Pfad der Team-Route mit der Einbauanleitung des Terminplaners (relativ zu /api/team). */
export const anleitungPfad = (customerId, projectId) =>
`/funktionen/${encodeURIComponent(customerId)}/termine/anleitung?projectId=${encodeURIComponent(projectId)}`
/** "Stand Version N, TT.MM.JJJJ" (Berliner Kalendertag) bzw. "Stand Version N" ohne brauchbares Datum; ohne Stand ''. */
function standKopf(stand) {
if (!Number.isInteger(stand?.version)) return ''
const datum = stand.updatedAt && !Number.isNaN(Date.parse(stand.updatedAt)) ? fmtBerlin(stand.updatedAt) : ''
return `Stand Version ${stand.version}${datum ? `, ${datum}` : ''}`
}
/**
* Alles, was das Anleitungs-Panel anzeigt, aus der Portal-Antwort (GET .../termine/anleitung) und der Funktionsansicht (feature.hindernis):
* { kopf, ticketPfad (/tickets?woid=… | null), warnungen, hinweis (Hindernis-Text), kopien, abschnitte [{ id, titel, text }] }. Ohne Antwort alles leer.
*/
export function panelModell(antwort, feature) {
if (!antwort) return { kopf: '', ticketPfad: null, warnungen: [], hinweis: '', kopien: [], abschnitte: [] }
return {
kopf: standKopf(antwort.stand),
ticketPfad: antwort.ticketWoid ? `/tickets?woid=${encodeURIComponent(antwort.ticketWoid)}` : null,
warnungen: Array.isArray(antwort.warnungen) ? antwort.warnungen : [],
hinweis: hindernisText(feature?.hindernis),
kopien: anleitungKopien(antwort),
abschnitte: (Array.isArray(antwort.abschnitte) ? antwort.abschnitte : []).map(({ id, titel, text }) => ({ id, titel, text })),
}
}
/** true, wenn die Adresse (location.search) ?anleitung=<projectId> traegt (kodiert oder nicht): das Panel dieses Projekts startet offen. */
export function anleitungOffen(search, projectId) {
if (typeof search !== 'string' || typeof projectId !== 'string' || projectId === '') return false
return new URLSearchParams(search).get('anleitung') === projectId
}
/**
* Antwort des Portals (das eine geaenderte Projekt) in die Projektliste einarbeiten: uebernommen wird nur
* aktualisiert.features[key]; Projektfelder und die anderen Funktionen bleiben. Gibt eine neue Liste zurueck.
@@ -59,3 +163,69 @@ export function mergeFunktion(projects, projectId, key, aktualisiert) {
if (!view) return projects
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; Portal: GET /funktionen/:customerId => termine) ---
/** true, wenn diese Aktion die Zustimmung in Textform braucht: nur "Aktivieren (ohne Zahlung)" des Terminplaners und nur, wenn das Portal es verlangt (termine.textformNoetig === true). */
export const textformNoetig = (key, action, termine) => key === 'termine' && action === 'activate' && termine?.textformNoetig === true
/** Kaestchentext (wie im Portal-Nachweis, ohne Datum und Weg); die beiden Links setzt die Komponente. */
export const termineTextformLabel = (version) => ({
before: 'Der Kunde hat der ',
lb: 'Leistungsbeschreibung Terminplaner',
between: ' und dem ',
avv: 'Auftragsverarbeitungsvertrag Terminplaner',
after: ` (Fassung ${version}) in Textform zugestimmt und bestätigt, dass sein Angebot nicht im Gesundheitsbereich liegt.`,
})
/** Textvorlage fuer den Chat (Kopierknopf); links = die versionierten Adressen aus dem Portal (termine.links). */
export const termineChatTemplate = (version, links) =>
`Damit wir den Terminplaner für Dich einschalten können, brauchen wir Deine Zustimmung in Textform. Es gelten die Leistungsbeschreibung Terminplaner und der Auftragsverarbeitungsvertrag Terminplaner in der Fassung ${version}: ${links.lb} · ${links.avv}. Bitte antworte hier mit „Ich stimme zu“ und bestätige, dass Dein Angebot nicht im Gesundheitsbereich liegt.`
/** Nur geprüfte https-Adressen werden Links; alles andere ''. */
const httpsUrl = (wert) => {
if (typeof wert !== 'string') return ''
try {
return new URL(wert).protocol === 'https:' ? wert : ''
} catch {
return ''
}
}
/**
* Alles, was der Bestaetigungsdialog zeigt, aus dem Portal-Feld termine: { 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(termine) {
const version = typeof termine?.avvVersion === 'string' ? termine.avvVersion : ''
const links = { lb: httpsUrl(termine?.links?.lb), avv: httpsUrl(termine?.links?.avv) }
return {
version,
links,
label: termineTextformLabel(version),
vorlage: version && links.lb && links.avv ? termineChatTemplate(version, links) : '',
}
}
/** Alle drei Pflichtangaben (Haken, Datum zwischen Fassung und heute, Weg); ohne Fassung im Portal-Feld nie bereit. */
export function textformBereit({ confirmed, date, channel, today }, termine) {
const minDate = typeof termine?.avvVersion === 'string' ? termine.avvVersion : ''
if (!minDate) return false
return canSubmitTextform({ confirmed, date, channel, today, minDate })
}
/** Rumpf fuer POST /funktionen/:customerId/:key; Datum und Weg der Textform nur beim Aktivieren und nur, wenn beide da sind. */
export function featureBody({ projectId, action, note, textformDate, textformChannel }) {
const body = { projectId, action, note }
if (action === 'activate' && textformDate && textformChannel) {
body.textformDate = textformDate
body.textformChannel = textformChannel
}
return body
}
/** K16 (Unterlagen an den Kunden) ging beim Aktivieren nicht raus: Antwort mit warnung: true (Portal, funktionenAdmin). */
export const K16_WARNUNG = 'Eingeschaltet, aber die E-Mail mit den Unterlagen (K16) ging nicht raus. Bitte die Leistungsbeschreibung und den AVV von Hand an den Kunden senden.'
/** Statuszeile nach dem Speichern einer Aktion: Warnung statt "Gespeichert.", wenn die Antwort warnung: true traegt. */
export const speicherStatus = (antwort) => (antwort?.warnung === true ? { ok: false, text: K16_WARNUNG } : { ok: true, text: 'Gespeichert.' })
+9 -3
View File
@@ -1,6 +1,7 @@
import { account } from './appwrite'
import { createFinanceRequest } from './financeRequest'
import { createJwtCache } from './serverpulsAuth'
import { anleitungPfad, featureBody } from './funktionenView'
/**
* Client fuer den Team-Eingang des Kundenportals (/api/team): alle Mitarbeiter, Appwrite-JWT (Bearer).
@@ -92,10 +93,15 @@ export const markStorageDone = (id) => request(`/storage-tasks/${enc(id)}/done`,
// --- Funktionen (News-Pinnwand, Terminplaner je Projekt) ---
export const getFunktionen = (customerId) => request(`/funktionen/${enc(customerId)}`)
/** Aktivieren (ohne Zahlung) oder Abschalten einer Funktion (key: news | termine); Antwort { projects: [das eine Projekt] }. */
export const setFeature = (customerId, key, { projectId, action, note }) =>
request(`/funktionen/${enc(customerId)}/${enc(key)}`, { method: 'POST', body: { projectId, action, note } })
/**
* Aktivieren (ohne Zahlung) oder Abschalten einer Funktion (key: news | termine); Antwort { projects: [das eine Projekt] }.
* Terminplaner im Modus on: beim Aktivieren zusaetzlich textformDate (JJJJ-MM-TT) und textformChannel (chat | email).
*/
export const setFeature = (customerId, key, { projectId, action, note, textformDate, textformChannel }) =>
request(`/funktionen/${enc(customerId)}/${enc(key)}`, { method: 'POST', body: featureBody({ projectId, action, note, textformDate, textformChannel }) })
export const setNewsFeature = (customerId, body) => setFeature(customerId, 'news', body)
/** Einbauanleitung des Terminplaners: { stand, warnungen, abschnitte, text, state, ticketWoid, ticketVersion, hindernis } (Aufbereitung: panelModell). */
export const getTermineAnleitung = (customerId, projectId) => request(anleitungPfad(customerId, projectId))
// --- Abo-Status (alle Mitarbeiter) ---
+11
View File
@@ -30,6 +30,17 @@
}
.funktionen-sitekey-copy:hover { background: var(--surface-2); color: var(--text); }
.funktionen-sitekey-ok { color: var(--ok); }
.funktionen-panel { grid-template-columns: minmax(0, 1fr); gap: var(--sp-2); background: var(--surface-2); }
.funktionen-panel-head { display: flex; flex-wrap: wrap; gap: var(--sp-2) var(--sp-3); align-items: center; }
.funktionen-panel-warnungen { margin: 0; padding-left: 32px; }
.funktionen-panel .area-summary { margin-bottom: 0; }
.funktionen-panel-abschnitt { border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--surface-1); min-width: 0; }
.funktionen-panel-abschnitt > summary { padding: 8px 12px; cursor: pointer; font-weight: 600; }
.funktionen-panel-abschnitt pre {
margin: 0; padding: 10px 12px; border-top: 1px solid var(--border); max-width: 100%;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; color: var(--text);
white-space: pre-wrap; overflow-wrap: anywhere; user-select: text;
}
@media (max-width: 720px) {
.area-row { grid-template-columns: minmax(0, 1fr) !important; }
.area-row.head { display: none; }
+63 -1
View File
@@ -1,6 +1,10 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { COLLECTIONS, waitForAttributes } from '../scripts/setup-legal.mjs'
import { spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { COLLECTIONS, DOCUMENTS, waitForAttributes } from '../scripts/setup-legal.mjs'
const SCRIPT = fileURLToPath(new URL('../scripts/setup-legal.mjs', import.meta.url))
const indexOn = (id, attr) =>
COLLECTIONS.find((c) => c.id === id).indexes?.some((i) => i.attributes.includes(attr))
@@ -28,3 +32,61 @@ test('waitForAttributes bricht bei fehlgeschlagenem Attribut ab', async () => {
const db = { listAttributes: async () => ({ attributes: [{ key: 'b', status: 'failed', error: 'kaputt' }] }) }
await assert.rejects(waitForAttributes(db, 'x', { intervalMs: 1, timeoutMs: 1000 }), /b/)
})
test('documents_keys_eindeutig_und_sort_lueckenlos', () => {
const keys = DOCUMENTS.map((d) => d.key)
// Appwrite erlaubt als eigene Dokument-ID höchstens 36 Zeichen (a-z, 0-9, Bindestrich)
for (const k of keys) assert.match(k, /^[a-z0-9-]{1,36}$/, k)
assert.equal(new Set(keys).size, keys.length, 'Schluessel eindeutig')
assert.deepEqual(DOCUMENTS.map((d) => d.sort), DOCUMENTS.map((_, i) => i + 1), 'sort 1..n in Reihenfolge, ohne Luecke')
for (const d of DOCUMENTS) {
assert.ok(d.title.length > 0 && d.title.length <= 200, `${d.key}: Titel (Spalte title: 200 Zeichen)`)
assert.ok(d.liveUrl.length <= 500, `${d.key}: liveUrl (Spalte liveUrl: 500 Zeichen)`)
assert.match(d.liveUrl, /^https:\/\//, `${d.key}: liveUrl https`)
}
})
test('terminplaner_karten_vorhanden', () => {
const base = 'https://project.webklar.com/rechtliches/'
const erwartet = [
{ key: 'leistungsbeschreibung-terminplaner', title: 'Leistungsbeschreibung Terminplaner', sort: 7 },
{ key: 'avv-terminplaner', title: 'AVV Terminplaner (Auftragsverarbeitung)', sort: 8 },
{ key: 'widerrufsbelehrung-funktionen', title: 'Widerrufsbelehrung und Muster-Widerrufsformular (Funktionen)', sort: 9 },
]
for (const e of erwartet) {
const d = DOCUMENTS.find((x) => x.key === e.key)
assert.ok(d, e.key)
assert.equal(d.title, e.title)
assert.equal(d.sort, e.sort)
assert.equal(d.liveUrl, `${base}${e.key}.html`)
assert.ok(d.liveUrl.startsWith(base))
assert.ok(!/\d{4}-\d{2}-\d{2}/.test(d.liveUrl), 'Alias-Seite ohne Fassungsdatum')
}
assert.equal(DOCUMENTS.length, 9, 'keine Analyse-Karten und keine weiteren')
})
test('bestehende_karten_unveraendert', () => {
assert.deepEqual(DOCUMENTS.slice(0, 6), [
{ key: 'agb', title: 'AGB', liveUrl: 'https://webklar.com/agb', sort: 1 },
{ key: 'abo-bedingungen', title: 'Abo-Bedingungen', liveUrl: 'https://project.webklar.com/abo-bedingungen.html', sort: 2 },
{ key: 'avv', title: 'AVV (Auftragsverarbeitung)', liveUrl: 'https://project.webklar.com/abo-bedingungen.html', sort: 3 },
{ key: 'datenschutz', title: 'Datenschutzerklärung (Website und Portal)', liveUrl: 'https://project.webklar.com/datenschutz.html', sort: 4 },
{ key: 'impressum', title: 'Impressum', liveUrl: 'https://webklar.com/impressum', sort: 5 },
{ key: 'widerruf', title: 'Widerrufsbelehrung und -formular', liveUrl: 'https://project.webklar.com/widerrufen.html', sort: 6 },
])
})
test('dry_run_listet_karten', () => {
// Kein API-Key, Endpunkt auf einen geschlossenen Port: ein Netzaufruf wuerde scheitern, das Skript muss trotzdem sauber enden
const env = { ...process.env, APPWRITE_ENDPOINT: 'http://127.0.0.1:9/v1' }
delete env.APPWRITE_API_KEY
const r = spawnSync(process.execPath, ['--max-old-space-size=256', SCRIPT, '--dry-run'], { env, encoding: 'utf8', timeout: 30000 })
assert.equal(r.status, 0, r.stderr)
assert.ok(!/ECONNREFUSED|fetch failed|APPWRITE_API_KEY required/.test(r.stderr), r.stderr)
const zeilen = r.stdout.split('\n')
for (const d of DOCUMENTS) assert.ok(zeilen.includes(`Rechtstext ${d.key}: ${d.title}`), d.key)
assert.ok(zeilen.some((z) => z.startsWith('Rechtstext leistungsbeschreibung-terminplaner:')))
assert.ok(zeilen.some((z) => z.startsWith('Rechtstext avv-terminplaner:')))
assert.ok(zeilen.some((z) => z.startsWith('Rechtstext widerrufsbelehrung-funktionen:')))
assert.ok(!/angelegt|vorhanden|Fertig/.test(r.stdout), 'Trockenlauf legt nichts an')
})
+372 -3
View File
@@ -3,7 +3,29 @@ import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
// Kunden-Reiter "Funktionen": Register der Funktionen (News, Terminplaner), Aktionen je Zustand, Antwort einarbeiten
const { FUNKTIONEN, ZUSTAND, funktionOf, aktionen, mergeFunktion, siteKeyOf } = await loadModule('src/lib/funktionenView.js')
const {
FUNKTIONEN,
ZUSTAND,
AKTION_LABEL,
funktionOf,
aktionen,
mergeFunktion,
siteKeyOf,
abgeschaltetText,
hindernisText,
anleitungKopien,
anleitungPfad,
panelModell,
anleitungOffen,
textformNoetig,
termineTextformLabel,
termineChatTemplate,
textformAnsicht,
textformBereit,
featureBody,
speicherStatus,
K16_WARNUNG,
} = await loadModule('src/lib/funktionenView.js')
test('FUNKTIONEN: News und Terminplaner mit den Bestätigungstexten', () => {
assert.deepEqual(FUNKTIONEN, [
@@ -20,12 +42,24 @@ test('FUNKTIONEN: News und Terminplaner mit den Bestätigungstexten', () => {
label: 'Terminplaner',
bestaetigen: {
activate: 'Terminplaner ohne Zahlung aktivieren?',
disable: 'Terminplaner abschalten? Auf der Website sind dann keine Buchungen mehr möglich.',
disable: 'Terminplaner abschalten? Auf der Website sind dann keine Buchungen mehr möglich. Damit beginnt die Löschung: Nach 30 Tagen werden alle Termine und Einstellungen gelöscht. Wieder einschalten ist nach diesen 30 Tagen nur mit einer neuen Bestellung (149 €) möglich.',
abnahme: 'Terminplaner freischalten? Der Kunde bekommt die Mail „bereit“ und Gäste können buchen.',
ticket: 'Fehlendes Einrichtungs-Ticket neu anlegen?',
},
},
])
})
test('W1: der Abschalten-Dialog des Terminplaners nennt Buchungsstopp, Löschung nach 30 Tagen und die neue Bestellung (149 €)', () => {
const text = FUNKTIONEN.find((f) => f.key === 'termine').bestaetigen.disable
assert.match(text, /^Terminplaner abschalten\?/)
assert.match(text, /keine Buchungen mehr möglich/, 'Buchungen stehen')
assert.match(text, /Nach 30 Tagen werden alle Termine und Einstellungen gelöscht/, 'Löschung nach 30 Tagen')
assert.match(text, /nur mit einer neuen Bestellung \(149 €\)/, 'danach nur mit neuer Bestellung, mit Preis')
// News bleibt unverändert: dort wird nichts gelöscht
assert.equal(FUNKTIONEN.find((f) => f.key === 'news').bestaetigen.disable, 'News abschalten? Die Website zeigt dann keine Beiträge mehr.')
})
test('ZUSTAND: Ton und Beschriftung je Zustand', () => {
assert.deepEqual(ZUSTAND, {
off: ['muted', 'Aus'],
@@ -45,13 +79,191 @@ test('funktionOf: Ansicht der Funktion, ohne Eintrag "off"', () => {
assert.deepEqual(funktionOf(null, 'news'), { state: 'off' })
})
test('aktionen: Aktivieren außer bei "active", Abschalten bei Bestellt, Einrichtung und Aktiv', () => {
test('aktionen: News unverändert – Aktivieren außer bei "active", Abschalten bei Bestellt, Einrichtung und Aktiv', () => {
assert.deepEqual(aktionen('off', 'news'), ['activate'])
assert.deepEqual(aktionen('ordered', 'news'), ['activate', 'disable'])
assert.deepEqual(aktionen('setup', 'news'), ['activate', 'disable'])
assert.deepEqual(aktionen('active', 'news'), ['disable'])
assert.deepEqual(aktionen('disabled', 'news'), ['activate'])
assert.deepEqual(aktionen(undefined, 'news'), ['activate'])
})
test('aktionen: ohne Schlüssel wie News (auch mit undefiniertem Zustand)', () => {
assert.deepEqual(aktionen('off'), ['activate'])
assert.deepEqual(aktionen('ordered'), ['activate', 'disable'])
assert.deepEqual(aktionen('setup'), ['activate', 'disable'])
assert.deepEqual(aktionen('active'), ['disable'])
assert.deepEqual(aktionen('disabled'), ['activate'])
assert.deepEqual(aktionen(undefined), ['activate'])
assert.deepEqual(aktionen('setup', undefined, { ticketMissing: true }), ['activate', 'disable'], 'ticketMissing betrifft nur den Terminplaner')
})
test('aktionen: News ignoriert ticketMissing in jedem Zustand', () => {
for (const state of ['off', 'ordered', 'setup', 'active', 'disabled', undefined]) {
assert.deepEqual(aktionen(state, 'news', { ticketMissing: true }), aktionen(state, 'news'), String(state))
}
})
test('aktionen: Terminplaner – bestellt nur Abschalten (kein Aktivieren ohne Zahlung), Einrichtung mit Abnahme', () => {
assert.deepEqual(aktionen('off', 'termine'), ['activate'])
assert.deepEqual(aktionen('ordered', 'termine'), ['disable'], 'Aktivieren würde eine spätere Zahlung im Portal verschlucken')
assert.deepEqual(aktionen('setup', 'termine'), ['abnahme', 'disable'])
assert.deepEqual(aktionen('active', 'termine'), ['disable'])
assert.deepEqual(aktionen('disabled', 'termine'), ['activate'])
assert.deepEqual(aktionen(undefined, 'termine'), ['activate'])
})
test('aktionen: Terminplaner mit fehlendem Ticket bietet in der Einrichtung zuerst "ticket" an', () => {
assert.deepEqual(aktionen('setup', 'termine', { ticketMissing: true }), ['ticket', 'abnahme', 'disable'])
assert.deepEqual(aktionen('setup', 'termine', { ticketMissing: false }), ['abnahme', 'disable'])
assert.deepEqual(aktionen('setup', 'termine', {}), ['abnahme', 'disable'])
for (const state of ['off', 'ordered', 'active', 'disabled', undefined]) {
assert.deepEqual(aktionen(state, 'termine', { ticketMissing: true }), aktionen(state, 'termine'), `${String(state)}: ticketMissing nur in setup`)
}
})
test('AKTION_LABEL: Beschriftung je Aktion', () => {
assert.deepEqual(AKTION_LABEL, {
activate: 'Aktivieren (ohne Zahlung)',
disable: 'Abschalten',
abnahme: 'Freischalten (Abnahme)',
ticket: 'Ticket nachlegen',
})
})
test('hindernisText: Text je Code, sonst leer', () => {
assert.equal(hindernisText('kein_abo'), 'Kein laufendes Wartungs-Abo: Freischalten ist gesperrt.')
assert.equal(hindernisText('keine_einstellung'), 'Noch keine Einstellungen gespeichert.')
assert.equal(hindernisText('abo_unbekannt'), 'Abo gerade nicht prüfbar.')
for (const code of ['x', '', null, undefined, 5, 'constructor', 'toString']) assert.equal(hindernisText(code), '', String(code))
})
// Antwort von GET /funktionen/:customerId/termine/anleitung (Portal, D8) in Kurzform
const antwort = () => ({
stand: { version: 3, updatedAt: '2026-10-03T10:15:00.000Z' },
warnungen: ['Datenschutz-Entwurf', 'Ticket ist älter als der Stand'],
abschnitte: [
{ id: 'modell', titel: 'Modell', text: 'Modell-Text', kopierbar: false },
{ id: 'snippet', titel: 'Einbau-Code (Snippet)', text: '<script></script>', kopierbar: true },
{ id: 'css', titel: 'Aussehen (CSS)', text: ':root {}', kopierbar: true },
{ id: 'checkliste', titel: 'Abnahme-Checkliste', text: '- [ ] Punkt', kopierbar: false },
{ id: 'datenschutz_sie', titel: 'Datenschutz (Sie)', text: 'Absatz Sie', kopierbar: true },
{ id: 'datenschutz_du', titel: 'Datenschutz (Du)', text: 'Absatz Du', kopierbar: true },
],
text: 'GANZE ANLEITUNG',
state: 'setup',
ticketWoid: '100123',
ticketVersion: 3,
hindernis: null,
})
test('anleitungKopien: zuerst "alles", dann die kopierbaren Abschnitte in Antwortreihenfolge mit festen Beschriftungen', () => {
assert.deepEqual(anleitungKopien(antwort()), [
{ id: 'alles', label: 'Alles kopieren', text: 'GANZE ANLEITUNG' },
{ id: 'snippet', label: 'Einbau-Code kopieren', text: '<script></script>' },
{ id: 'css', label: 'CSS-Variablen kopieren', text: ':root {}' },
{ id: 'datenschutz_sie', label: 'Datenschutz-Absatz (Sie) kopieren', text: 'Absatz Sie' },
{ id: 'datenschutz_du', label: 'Datenschutz-Absatz (Du) kopieren', text: 'Absatz Du' },
])
})
test('anleitungKopien: nicht kopierbare Abschnitte fehlen, unbekannte kopierbare heißen "<Titel> kopieren"', () => {
const a = antwort()
a.abschnitte = [
{ id: 'modell', titel: 'Modell', text: 'x', kopierbar: false },
{ id: 'neu', titel: 'Neuer Teil', text: 'Inhalt', kopierbar: true },
{ id: 'constructor', titel: 'Eigener Titel', text: 'y', kopierbar: true },
]
assert.deepEqual(anleitungKopien(a), [
{ id: 'alles', label: 'Alles kopieren', text: 'GANZE ANLEITUNG' },
{ id: 'neu', label: 'Neuer Teil kopieren', text: 'Inhalt' },
{ id: 'constructor', label: 'Eigener Titel kopieren', text: 'y' },
])
})
test('anleitungKopien: kopierbarer Abschnitt ohne Text entfällt, null und Antwort ohne Text ergeben []', () => {
const a = antwort()
a.abschnitte = [{ id: 'snippet', titel: 'Einbau-Code (Snippet)', text: '', kopierbar: true }]
assert.deepEqual(anleitungKopien(a), [{ id: 'alles', label: 'Alles kopieren', text: 'GANZE ANLEITUNG' }])
assert.deepEqual(anleitungKopien(null), [])
assert.deepEqual(anleitungKopien(undefined), [])
assert.deepEqual(anleitungKopien({ ...antwort(), text: '' }), [])
assert.deepEqual(anleitungKopien({ ...antwort(), text: undefined }), [])
assert.deepEqual(anleitungKopien({ abschnitte: antwort().abschnitte }), [])
})
test('anleitungPfad: Kunde und Projekt kodiert', () => {
assert.equal(anleitungPfad('c1', 'p1'), '/funktionen/c1/termine/anleitung?projectId=p1')
assert.equal(anleitungPfad('c/1', 'p&x=1 ü'), '/funktionen/c%2F1/termine/anleitung?projectId=p%26x%3D1%20%C3%BC')
})
test('panelModell: Beispielantwort liefert alle sechs Felder', () => {
const modell = panelModell(antwort(), { state: 'setup', hindernis: 'kein_abo' })
assert.deepEqual(Object.keys(modell).sort(), ['abschnitte', 'hinweis', 'kopf', 'kopien', 'ticketPfad', 'warnungen'])
assert.equal(modell.kopf, 'Stand Version 3, 03.10.2026')
assert.equal(modell.ticketPfad, '/tickets?woid=100123')
assert.deepEqual(modell.warnungen, ['Datenschutz-Entwurf', 'Ticket ist älter als der Stand'])
assert.equal(modell.hinweis, 'Kein laufendes Wartungs-Abo: Freischalten ist gesperrt.')
assert.deepEqual(modell.abschnitte, [
{ id: 'modell', titel: 'Modell', text: 'Modell-Text' },
{ id: 'snippet', titel: 'Einbau-Code (Snippet)', text: '<script></script>' },
{ id: 'css', titel: 'Aussehen (CSS)', text: ':root {}' },
{ id: 'checkliste', titel: 'Abnahme-Checkliste', text: '- [ ] Punkt' },
{ id: 'datenschutz_sie', titel: 'Datenschutz (Sie)', text: 'Absatz Sie' },
{ id: 'datenschutz_du', titel: 'Datenschutz (Du)', text: 'Absatz Du' },
])
assert.deepEqual(modell.kopien, anleitungKopien(antwort()))
})
test('panelModell: Kopf nach Berliner Kalendertag, ohne Datum nur die Version', () => {
const kopf = (stand) => panelModell({ ...antwort(), stand }, {}).kopf
assert.equal(kopf({ version: 12, updatedAt: '2026-10-03T23:30:00.000Z' }), 'Stand Version 12, 04.10.2026', 'nach Mitternacht in Berlin schon der nächste Tag')
assert.equal(kopf({ version: 2, updatedAt: null }), 'Stand Version 2')
assert.equal(kopf({ version: 2 }), 'Stand Version 2')
assert.equal(kopf({ version: 2, updatedAt: 'kein Datum' }), 'Stand Version 2')
assert.equal(panelModell({ ...antwort(), stand: undefined }, {}).kopf, '')
assert.equal(panelModell({ ...antwort(), stand: {} }, {}).kopf, '', 'ohne Version kein Kopf')
})
test('panelModell: null-Antwort ergibt ein leeres Modell', () => {
const leer = { kopf: '', ticketPfad: null, warnungen: [], hinweis: '', kopien: [], abschnitte: [] }
assert.deepEqual(panelModell(null), leer)
assert.deepEqual(panelModell(undefined, undefined), leer)
assert.deepEqual(panelModell(null, { hindernis: 'kein_abo' }), leer)
})
test('panelModell: ohne ticketWoid kein Ticketpfad, ohne Hindernis kein Hinweis, Woid wird kodiert', () => {
const ohne = panelModell({ ...antwort(), ticketWoid: null }, { state: 'setup' })
assert.equal(ohne.ticketPfad, null)
assert.equal(ohne.hinweis, '')
assert.equal(panelModell({ ...antwort(), ticketWoid: undefined }, null).ticketPfad, null)
assert.equal(panelModell({ ...antwort(), ticketWoid: '' }, null).ticketPfad, null)
assert.equal(panelModell({ ...antwort(), ticketWoid: 'a b/1' }, null).ticketPfad, '/tickets?woid=a%20b%2F1')
assert.equal(panelModell(antwort(), { hindernis: 'unbekannt' }).hinweis, '')
})
test('panelModell: fehlende Listen werden zu leeren Listen', () => {
const modell = panelModell({ stand: { version: 1, updatedAt: null }, state: 'active' }, {})
assert.deepEqual(modell.warnungen, [])
assert.deepEqual(modell.abschnitte, [])
assert.deepEqual(modell.kopien, [])
})
test('anleitungOffen: nur die Adresse mit ?anleitung=<projectId> öffnet das Panel', () => {
assert.equal(anleitungOffen('?tab=funktionen&anleitung=p%2F1', 'p/1'), true, 'kodiert')
assert.equal(anleitungOffen('?tab=funktionen&anleitung=p/1', 'p/1'), true, 'unkodiert')
assert.equal(anleitungOffen('?anleitung=p1', 'p1'), true)
assert.equal(anleitungOffen('anleitung=p1', 'p1'), true, 'ohne Fragezeichen')
assert.equal(anleitungOffen('?tab=funktionen&anleitung=p2', 'p1'), false, 'anderes Projekt')
assert.equal(anleitungOffen('?anleitung=p10', 'p1'), false, 'kein Präfix-Treffer')
assert.equal(anleitungOffen('?tab=funktionen', 'p1'), false)
assert.equal(anleitungOffen('?anleitung=', 'p1'), false)
assert.equal(anleitungOffen('', 'p1'), false)
assert.equal(anleitungOffen(undefined, 'p1'), false)
assert.equal(anleitungOffen(null, 'p1'), false)
assert.equal(anleitungOffen('?anleitung=p1', ''), false)
assert.equal(anleitungOffen('?anleitung=', ''), false, 'leeres Projekt öffnet nichts')
assert.equal(anleitungOffen('?anleitung=p1', undefined), false)
})
const projects = () => [
@@ -101,3 +313,160 @@ test('siteKeyOf: nur der Terminplaner zeigt seinen Site-Key, nur wenn das Portal
assert.equal(siteKeyOf(feature, 'termine'), '', JSON.stringify(feature))
}
})
test('abgeschaltetText: Grund und Löschdatum im Zustand "disabled"', () => {
const text = (disabledReason, loeschungAm) => abgeschaltetText({ state: 'disabled', disabledReason, loeschungAm })
assert.equal(text('abo_ende', '2026-11-04'), 'Abgeschaltet (Abo beendet), Löschung am 04.11.2026')
assert.equal(text('abo_ende', undefined), 'Abgeschaltet (Abo beendet)')
assert.equal(text('abo_ende_vor_einrichtung', '2026-11-04'), 'Abgeschaltet (Abo endete vor Einrichtung, Erstattung prüfen), Löschung am 04.11.2026')
assert.equal(text('abo_ende_vor_einrichtung', null), 'Abgeschaltet (Abo endete vor Einrichtung, Erstattung prüfen)')
assert.equal(text('team', '2026-12-01'), 'Abgeschaltet (Team), Löschung am 01.12.2026')
assert.equal(text('team', null), 'Abgeschaltet (Team)')
assert.equal(text('kunde_geloescht', '2026-12-31'), 'Abgeschaltet (Kunde gelöscht), Löschung am 31.12.2026')
assert.equal(text('kunde_geloescht', ''), 'Abgeschaltet (Kunde gelöscht)')
})
test('abgeschaltetText: ohne Grund nur "Abgeschaltet", mit Datum "Abgeschaltet, Löschung am …"', () => {
assert.equal(abgeschaltetText({ state: 'disabled' }), 'Abgeschaltet')
assert.equal(abgeschaltetText({ state: 'disabled', disabledReason: null, loeschungAm: null }), 'Abgeschaltet')
assert.equal(abgeschaltetText({ state: 'disabled', disabledReason: '', loeschungAm: '' }), 'Abgeschaltet')
assert.equal(abgeschaltetText({ state: 'disabled', disabledReason: null, loeschungAm: '2026-11-04' }), 'Abgeschaltet, Löschung am 04.11.2026')
assert.equal(abgeschaltetText({ state: 'disabled', loeschungAm: '2026-01-09' }), 'Abgeschaltet, Löschung am 09.01.2026')
})
test('abgeschaltetText: unbekannter Grund wird wie "ohne Grund" behandelt', () => {
assert.equal(abgeschaltetText({ state: 'disabled', disabledReason: 'frist' }), 'Abgeschaltet')
assert.equal(abgeschaltetText({ state: 'disabled', disabledReason: 'frist', loeschungAm: '2026-11-04' }), 'Abgeschaltet, Löschung am 04.11.2026')
assert.equal(abgeschaltetText({ state: 'disabled', disabledReason: 'constructor' }), 'Abgeschaltet', 'kein Treffer über die Prototypenkette')
assert.equal(abgeschaltetText({ state: 'disabled', disabledReason: 5 }), 'Abgeschaltet')
})
test('abgeschaltetText: außerhalb von "disabled" leer, auch mit Grund und Datum', () => {
for (const state of ['active', 'setup', 'ordered', 'off', undefined, null]) {
assert.equal(abgeschaltetText({ state, disabledReason: 'abo_ende', loeschungAm: '2026-11-04' }), '', String(state))
}
for (const feature of [{}, null, undefined]) assert.equal(abgeschaltetText(feature), '', JSON.stringify(feature))
})
// --- Staff-Aktivierung des Terminplaners nur mit Zustimmung in Textform (F13b; Portal F13a: GET ... => termine) ---
// Feld "termine" der Portal-Antwort GET /funktionen/:customerId
const termine = (extra = {}) => ({
textformNoetig: true,
avvVersion: '2026-10-05',
links: {
lb: 'https://project.webklar.com/rechtliches/leistungsbeschreibung-terminplaner-2026-10-05.html',
avv: 'https://project.webklar.com/rechtliches/avv-terminplaner-2026-10-05.html',
},
...extra,
})
test('textformNoetig: nur "Aktivieren (ohne Zahlung)" des Terminplaners und nur, wenn das Portal es verlangt', () => {
assert.equal(textformNoetig('termine', 'activate', termine()), true)
assert.equal(textformNoetig('termine', 'activate', termine({ textformNoetig: false })), false)
assert.equal(textformNoetig('termine', 'activate', undefined), false, 'altes Portal ohne Feld: wie bisher')
assert.equal(textformNoetig('termine', 'activate', null), false)
assert.equal(textformNoetig('termine', 'activate', termine({ textformNoetig: 'true' })), false, 'nur echtes true')
for (const action of ['disable', 'abnahme', 'ticket']) assert.equal(textformNoetig('termine', action, termine()), false, action)
for (const action of ['activate', 'disable']) assert.equal(textformNoetig('news', action, termine()), false, `News ${action}`)
assert.equal(textformNoetig(undefined, 'activate', termine()), false)
})
test('termineChatTemplate: wörtlich, mit Fassung und den beiden versionierten Adressen', () => {
const { lb, avv } = termine().links
assert.equal(
termineChatTemplate('2026-10-05', { lb, avv }),
'Damit wir den Terminplaner für Dich einschalten können, brauchen wir Deine Zustimmung in Textform. ' +
'Es gelten die Leistungsbeschreibung Terminplaner und der Auftragsverarbeitungsvertrag Terminplaner in der Fassung 2026-10-05: ' +
`${lb} · ${avv}. ` +
'Bitte antworte hier mit „Ich stimme zu“ und bestätige, dass Dein Angebot nicht im Gesundheitsbereich liegt.',
)
const text = termineChatTemplate('2026-10-05', { lb, avv })
assert.match(text, /leistungsbeschreibung-terminplaner-2026-10-05\.html/)
assert.match(text, /avv-terminplaner-2026-10-05\.html/)
assert.ok(!text.includes('undefined') && !text.includes('{'))
})
test('termineTextformLabel: Dialogtext des Kästchens nennt beide Unterlagen, die Fassung und den Ausschluss des Gesundheitsbereichs', () => {
const l = termineTextformLabel('2026-10-05')
assert.equal(l.lb, 'Leistungsbeschreibung Terminplaner')
assert.equal(l.avv, 'Auftragsverarbeitungsvertrag Terminplaner')
assert.equal(
l.before + l.lb + l.between + l.avv + l.after,
'Der Kunde hat der Leistungsbeschreibung Terminplaner und dem Auftragsverarbeitungsvertrag Terminplaner (Fassung 2026-10-05) ' +
'in Textform zugestimmt und bestätigt, dass sein Angebot nicht im Gesundheitsbereich liegt.',
)
})
test('textformAnsicht: Fassung, geprüfte https-Adressen, Label und Vorlage aus dem Portal-Feld', () => {
const a = textformAnsicht(termine())
assert.equal(a.version, '2026-10-05')
assert.deepEqual(a.links, termine().links)
assert.deepEqual(a.label, termineTextformLabel('2026-10-05'))
assert.equal(a.vorlage, termineChatTemplate('2026-10-05', termine().links))
})
test('textformAnsicht: Adressen ohne https werden nie Link, ohne beide Adressen keine Vorlage', () => {
const schlecht = textformAnsicht(termine({ links: { lb: 'javascript:alert(1)', avv: 'http://project.webklar.com/x.html' } }))
assert.deepEqual(schlecht.links, { lb: '', avv: '' })
assert.equal(schlecht.vorlage, '', 'eine Vorlage mit leeren Adressen geht nie an den Kunden')
const halb = textformAnsicht(termine({ links: { lb: termine().links.lb } }))
assert.equal(halb.links.avv, '')
assert.equal(halb.vorlage, '')
for (const leer of [undefined, null, {}, { links: null }]) {
const a = textformAnsicht(leer)
assert.equal(a.version, '')
assert.deepEqual(a.links, { lb: '', avv: '' })
assert.equal(a.vorlage, '')
}
})
test('textformBereit: canSubmitTextform mit minDate = Fassung (Grenzen eingeschlossen), today = heute in Berlin', () => {
const t = termine()
const ok = { confirmed: true, date: '2026-10-06', channel: 'chat', today: '2026-10-07' }
assert.equal(textformBereit(ok, t), true)
assert.equal(textformBereit({ ...ok, date: '2026-10-05' }, t), true, 'Tag der Fassung selbst')
assert.equal(textformBereit({ ...ok, date: '2026-10-07' }, t), true, 'heute')
assert.equal(textformBereit({ ...ok, channel: 'email' }, t), true)
assert.equal(textformBereit({ ...ok, date: '2026-10-04' }, t), false, 'vor der Fassung')
assert.equal(textformBereit({ ...ok, date: '2026-10-08' }, t), false, 'Zukunft')
assert.equal(textformBereit({ ...ok, confirmed: false }, t), false)
assert.equal(textformBereit({ ...ok, date: '' }, t), false)
assert.equal(textformBereit({ ...ok, date: 'morgen' }, t), false)
assert.equal(textformBereit({ ...ok, channel: '' }, t), false)
assert.equal(textformBereit({ ...ok, channel: 'telefon' }, t), false)
})
test('textformBereit: ohne Fassung im Portal-Feld nie bereit (fail closed)', () => {
const ok = { confirmed: true, date: '2026-10-06', channel: 'chat', today: '2026-10-07' }
assert.equal(textformBereit(ok, termine({ avvVersion: '' })), false)
assert.equal(textformBereit(ok, undefined), false)
assert.equal(textformBereit(ok, null), false)
})
test('featureBody: Aktivieren mit Textform trägt Datum und Weg, sonst bleibt der Rumpf wie bisher', () => {
assert.deepEqual(
featureBody({ projectId: 'p1', action: 'activate', note: 'Pilot', textformDate: '2026-10-06', textformChannel: 'chat' }),
{ projectId: 'p1', action: 'activate', note: 'Pilot', textformDate: '2026-10-06', textformChannel: 'chat' },
)
assert.deepEqual(featureBody({ projectId: 'p1', action: 'activate', note: '' }), { projectId: 'p1', action: 'activate', note: '' })
assert.deepEqual(featureBody({ projectId: 'p1', action: 'disable', note: 'x' }), { projectId: 'p1', action: 'disable', note: 'x' })
assert.deepEqual(
featureBody({ projectId: 'p1', action: 'disable', note: 'x', textformDate: '2026-10-06', textformChannel: 'chat' }),
{ projectId: 'p1', action: 'disable', note: 'x' },
'Abschalten sendet nie Textform-Angaben',
)
assert.deepEqual(
featureBody({ projectId: 'p1', action: 'activate', note: '', textformDate: '2026-10-06', textformChannel: '' }),
{ projectId: 'p1', action: 'activate', note: '' },
'halbe Angaben gehen nicht mit',
)
})
test('speicherStatus: warnung true (K16 nicht gesendet) wird zur Warnung, sonst "Gespeichert."', () => {
assert.deepEqual(speicherStatus({ projects: [], warnung: true }), { ok: false, text: K16_WARNUNG })
assert.match(K16_WARNUNG, /K16/)
assert.match(K16_WARNUNG, /von Hand/)
assert.deepEqual(speicherStatus({ projects: [] }), { ok: true, text: 'Gespeichert.' })
assert.deepEqual(speicherStatus(undefined), { ok: true, text: 'Gespeichert.' })
})