From 45e5dc30dcb60e3f71c15803f58225b72594f9f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 05:07:37 +0000 Subject: [PATCH] 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 --- .../customer/CustomerFunktionen.jsx | 72 ++++++++++- src/lib/funktionenView.js | 61 +++++++++ src/lib/teamApi.js | 11 +- tests/unit/funktionenView.test.mjs | 121 ++++++++++++++++++ 4 files changed, 256 insertions(+), 9 deletions(-) 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 ( +
+ +
+ + + Weg: + {CHANNELS.map((c) => ( + + ))} + +
+ {vorlage ? ( +
+ Textvorlage für den Chat +

{vorlage}

+ +
+ ) : ( +
Die Adressen der Unterlagen fehlen; die Chat-Vorlage ist nicht verfügbar.
+ )} +
+ ) +} + +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', + ) +})