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', + ) +})