From c0015544c419ea0fae4572421ee401540adfa1fb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 01:54:24 +0000 Subject: [PATCH] WOMS: Aktionen und Anleitungs-Logik fuer den Terminplaner Co-Authored-By: Claude Sonnet 5.5 --- src/lib/funktionenView.js | 88 ++++++++++++- src/lib/teamApi.js | 3 + tests/unit/funktionenView.test.mjs | 198 ++++++++++++++++++++++++++++- 3 files changed, 284 insertions(+), 5 deletions(-) diff --git a/src/lib/funktionenView.js b/src/lib/funktionenView.js index 37627e6..f4b8dd3 100644 --- a/src/lib/funktionenView.js +++ b/src/lib/funktionenView.js @@ -1,7 +1,7 @@ // Kunden-Reiter "Funktionen": Register der Funktionen je Projekt (News, Terminplaner), Aktionen je Zustand und // Einarbeiten der Portal-Antwort. Rein, unter Node testbar. -import { fmtYmd } from './format' +import { fmtYmd, fmtBerlin } from './format' /** Funktionen in Anzeigereihenfolge; key = Schluessel im Portal (features[key], POST /funktionen/:customerId/:key). */ export const FUNKTIONEN = [ @@ -19,6 +19,8 @@ export const FUNKTIONEN = [ bestaetigen: { activate: 'Terminplaner ohne Zahlung aktivieren?', disable: 'Terminplaner abschalten? Auf der Website sind dann keine Buchungen mehr möglich.', + abnahme: 'Terminplaner freischalten? Der Kunde bekommt die Mail „bereit“ und Gäste können buchen.', + ticket: 'Fehlendes Einrichtungs-Ticket neu anlegen?', }, }, ] @@ -63,14 +65,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 " 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= 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. diff --git a/src/lib/teamApi.js b/src/lib/teamApi.js index f4e84f5..74789ad 100644 --- a/src/lib/teamApi.js +++ b/src/lib/teamApi.js @@ -1,6 +1,7 @@ import { account } from './appwrite' import { createFinanceRequest } from './financeRequest' import { createJwtCache } from './serverpulsAuth' +import { anleitungPfad } from './funktionenView' /** * Client fuer den Team-Eingang des Kundenportals (/api/team): alle Mitarbeiter, Appwrite-JWT (Bearer). @@ -96,6 +97,8 @@ export const getFunktionen = (customerId) => request(`/funktionen/${enc(customer export const setFeature = (customerId, key, { projectId, action, note }) => request(`/funktionen/${enc(customerId)}/${enc(key)}`, { method: 'POST', body: { projectId, action, note } }) 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) --- diff --git a/tests/unit/funktionenView.test.mjs b/tests/unit/funktionenView.test.mjs index 63ed0f5..55d9be5 100644 --- a/tests/unit/funktionenView.test.mjs +++ b/tests/unit/funktionenView.test.mjs @@ -3,7 +3,21 @@ 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, abgeschaltetText } = await loadModule('src/lib/funktionenView.js') +const { + FUNKTIONEN, + ZUSTAND, + AKTION_LABEL, + funktionOf, + aktionen, + mergeFunktion, + siteKeyOf, + abgeschaltetText, + hindernisText, + anleitungKopien, + anleitungPfad, + panelModell, + anleitungOffen, +} = await loadModule('src/lib/funktionenView.js') test('FUNKTIONEN: News und Terminplaner mit den Bestätigungstexten', () => { assert.deepEqual(FUNKTIONEN, [ @@ -21,6 +35,8 @@ test('FUNKTIONEN: News und Terminplaner mit den Bestätigungstexten', () => { bestaetigen: { activate: 'Terminplaner ohne Zahlung aktivieren?', disable: 'Terminplaner abschalten? Auf der Website sind dann keine Buchungen mehr möglich.', + abnahme: 'Terminplaner freischalten? Der Kunde bekommt die Mail „bereit“ und Gäste können buchen.', + ticket: 'Fehlendes Einrichtungs-Ticket neu anlegen?', }, }, ]) @@ -45,13 +61,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: '', 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: '' }, + { 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 " 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: '' }, + { 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= ö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 = () => [