diff --git a/src/components/customer/CustomerFunktionen.jsx b/src/components/customer/CustomerFunktionen.jsx
index 7283a3a..7ea749f 100644
--- a/src/components/customer/CustomerFunktionen.jsx
+++ b/src/components/customer/CustomerFunktionen.jsx
@@ -6,8 +6,10 @@ import { copyText } from '../CopyableCredential'
import { getFunktionen, setFeature, getTermineAnleitung } from '../../lib/teamApi'
import {
FUNKTIONEN, ZUSTAND, AKTION_LABEL, funktionOf, aktionen, mergeFunktion, siteKeyOf, abgeschaltetText,
- hindernisText, panelModell, anleitungOffen,
+ hindernisText, panelModell, anleitungOffen, textformNoetig, textformAnsicht, textformBereit,
} 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")
@@ -111,8 +113,57 @@ function AnleitungPanel({ customerId, projectId, feature, reloadKey }) {
)
}
+// 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 (
+
+ )
+}
+
+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('')
@@ -124,7 +175,7 @@ export default function CustomerFunktionen({ customerId }) {
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])
@@ -134,12 +185,17 @@ export default function CustomerFunktionen({ customerId }) {
setBusy(true)
setStatus(null)
try {
- const { projects: changed } = await setFeature(customerId, key, { projectId, action, note: note.trim() })
+ const mitTextform = textformNoetig(key, action, termine)
+ const { projects: changed } = 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, key, updated))
setConfirm(null)
setNote('')
+ setTextform(LEERE_TEXTFORM)
setStatus({ ok: true, text: 'Gespeichert.' })
} catch (err) {
setConfirm(null)
@@ -151,7 +207,8 @@ export default function CustomerFunktionen({ customerId }) {
}
}
- 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) }))
@@ -169,6 +226,8 @@ export default function CustomerFunktionen({ customerId }) {
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 (
@@ -200,7 +259,7 @@ export default function CustomerFunktionen({ customerId }) {
{f.bestaetigen[confirm.action]}
setNote(e.target.value)} style={{ maxWidth: 220 }} />
-
+
) : (
@@ -219,6 +278,9 @@ export default function CustomerFunktionen({ customerId }) {
)}
+ {braucheTextform && (
+
+ )}
{anleitungOffenHier && (
)}
diff --git a/src/lib/funktionenView.js b/src/lib/funktionenView.js
index f4b8dd3..ea36fe5 100644
--- a/src/lib/funktionenView.js
+++ b/src/lib/funktionenView.js
@@ -2,6 +2,7 @@
// 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 = [
@@ -162,3 +163,63 @@ 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
+}
diff --git a/src/lib/teamApi.js b/src/lib/teamApi.js
index 74789ad..a201c38 100644
--- a/src/lib/teamApi.js
+++ b/src/lib/teamApi.js
@@ -1,7 +1,7 @@
import { account } from './appwrite'
import { createFinanceRequest } from './financeRequest'
import { createJwtCache } from './serverpulsAuth'
-import { anleitungPfad } from './funktionenView'
+import { anleitungPfad, featureBody } from './funktionenView'
/**
* Client fuer den Team-Eingang des Kundenportals (/api/team): alle Mitarbeiter, Appwrite-JWT (Bearer).
@@ -93,9 +93,12 @@ 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))
diff --git a/tests/unit/funktionenView.test.mjs b/tests/unit/funktionenView.test.mjs
index 55d9be5..4c5ff3b 100644
--- a/tests/unit/funktionenView.test.mjs
+++ b/tests/unit/funktionenView.test.mjs
@@ -17,6 +17,12 @@ const {
anleitungPfad,
panelModell,
anleitungOffen,
+ textformNoetig,
+ termineTextformLabel,
+ termineChatTemplate,
+ textformAnsicht,
+ textformBereit,
+ featureBody,
} = await loadModule('src/lib/funktionenView.js')
test('FUNKTIONEN: News und Terminplaner mit den Bestätigungstexten', () => {
@@ -329,3 +335,118 @@ test('abgeschaltetText: außerhalb von "disabled" leer, auch mit Grund und Datum
}
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',
+ )
+})