Compare commits
9
Commits
603d08c9c2
...
4c3f67eadc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c3f67eadc | ||
|
|
878a530bd7 | ||
|
|
72eedeb59c | ||
|
|
89f5bd4bc6 | ||
|
|
225e2f1b74 | ||
|
|
45e5dc30dc | ||
|
|
857e001911 | ||
|
|
c0015544c4 | ||
|
|
6f0371283e |
No files matched your search
@@ -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));
|
||||
|
||||
@@ -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
@@ -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
@@ -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) ---
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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.' })
|
||||
})
|
||||
Reference in new issue
Block a user