From 426a5d665465f916065d632eb84654e14d7b6e66 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 23:57:03 +0000 Subject: [PATCH] Funktionen-Reiter: News und Terminplaner je Projekt Co-Authored-By: Claude Sonnet 5.5 --- .../customer/CustomerFunktionen.jsx | 84 +++++++++-------- src/lib/funktionenView.js | 52 ++++++++++ src/lib/teamApi.js | 8 +- src/lib/viewLog.js | 3 + src/styles/admin-areas.css | 4 +- tests/unit/funktionenView.test.mjs | 94 +++++++++++++++++++ tests/unit/viewLog.test.mjs | 12 ++- 7 files changed, 209 insertions(+), 48 deletions(-) create mode 100644 src/lib/funktionenView.js create mode 100644 tests/unit/funktionenView.test.mjs diff --git a/src/components/customer/CustomerFunktionen.jsx b/src/components/customer/CustomerFunktionen.jsx index d0d4624..dac901d 100644 --- a/src/components/customer/CustomerFunktionen.jsx +++ b/src/components/customer/CustomerFunktionen.jsx @@ -1,27 +1,18 @@ -import { useCallback, useEffect, useState } from 'react' +import { Fragment, useCallback, useEffect, useState } from 'react' +import { Link } from 'react-router-dom' import { FaSpinner } from 'react-icons/fa6' import { Card, Badge, Button, EmptyState } from '../ui' -import { getFunktionen, setNewsFeature } from '../../lib/teamApi' +import { getFunktionen, setFeature } from '../../lib/teamApi' +import { FUNKTIONEN, ZUSTAND, funktionOf, aktionen, mergeFunktion } from '../../lib/funktionenView' import '../../styles/admin-areas.css' -// Reiter "Funktionen": News-Pinnwand je Projekt an/aus wie im frueheren Admin-Panel (Kundenliste, Zeile "Funktionen") -const STATE = { - off: ['muted', 'Aus'], - ordered: ['warn', 'Bestellt'], - setup: ['info', 'Einrichtung'], - active: ['ok', 'Aktiv'], - disabled: ['muted', 'Abgeschaltet'], -} -const CONFIRM = { - activate: 'News ohne Zahlung aktivieren?', - disable: 'News abschalten? Die Website zeigt dann keine Beiträge mehr.', -} +// Reiter "Funktionen": News-Pinnwand und Terminplaner je Projekt an/aus wie im frueheren Admin-Panel (Kundenliste, Zeile "Funktionen") const short = (text) => (text.length > 60 ? `${text.slice(0, 60)}…` : text) export default function CustomerFunktionen({ customerId }) { const [projects, setProjects] = useState(null) const [error, setError] = useState(null) - const [confirm, setConfirm] = useState(null) // { projectId, action } + const [confirm, setConfirm] = useState(null) // { projectId, key, action } const [note, setNote] = useState('') const [busy, setBusy] = useState(false) const [status, setStatus] = useState(null) // { ok, text } @@ -38,9 +29,10 @@ export default function CustomerFunktionen({ customerId }) { setBusy(true) setStatus(null) try { - const { projects: changed } = await setNewsFeature(customerId, { projectId: confirm.projectId, action: confirm.action, note: note.trim() }) + const { projects: changed } = await setFeature(customerId, confirm.key, { projectId: confirm.projectId, action: confirm.action, note: note.trim() }) + // Die Antwort enthaelt das ganze Projekt; uebernommen wird nur die geaenderte Funktion const updated = changed?.[0] - if (updated) setProjects((list) => list.map((p) => (p.projectId === updated.projectId ? updated : p))) + if (updated) setProjects((list) => mergeFunktion(list, updated.projectId, confirm.key, updated)) setConfirm(null) setNote('') setStatus({ ok: true, text: 'Gespeichert.' }) @@ -52,6 +44,8 @@ export default function CustomerFunktionen({ customerId }) { } } + const ask = (projectId, key, action) => { setNote(''); setConfirm({ projectId, key, action }) } + if (error) return
{error}
if (!projects) return if (!projects.length) return @@ -59,44 +53,52 @@ export default function CustomerFunktionen({ customerId }) { return (
-
ProjektNewsTicketAktionen
- {projects.map((p) => { - const news = p.features?.news || { state: 'off' } - const [tone, label] = STATE[news.state] || ['muted', news.state] - const isConfirming = confirm?.projectId === p.projectId +
ProjektFunktionStatusTicketAktionen
+ {projects.flatMap((p) => FUNKTIONEN.map((f, index) => { + 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 return ( -
- - {p.projectName} - {p.liveUrl && <>
{p.liveUrl.replace(/^https?:\/\//, '')}} -
+
+ {index === 0 ? ( + + {p.projectName} + {p.liveUrl && <>
{p.liveUrl.replace(/^https?:\/\//, '')}} +
+ ) : ( + + )} + {f.label} {label} - {news.adminNote && <>
{short(news.adminNote)}} + {feature.adminNote && <>
{short(feature.adminNote)}} +
+ + {feature.ticketMissing + ? Ticket fehlt + : feature.ticketWoid + ? #{feature.ticketWoid} + : '–'} - {news.ticketMissing ? Ticket fehlt : news.ticketWoid ? `#${news.ticketWoid}` : '–'} {isConfirming ? ( - <> - {CONFIRM[confirm.action]} + + {f.bestaetigen[confirm.action]} setNote(e.target.value)} style={{ maxWidth: 220 }} /> - + ) : ( - <> - {news.state !== 'active' && ( - - )} - {['ordered', 'setup', 'active'].includes(news.state) && ( - - )} - + aktionen(feature.state).map((action) => ( + + )) )}
) - })} + }))}
{status &&
{status.text}
} diff --git a/src/lib/funktionenView.js b/src/lib/funktionenView.js new file mode 100644 index 0000000..11c6aed --- /dev/null +++ b/src/lib/funktionenView.js @@ -0,0 +1,52 @@ +// Kunden-Reiter "Funktionen": Register der Funktionen je Projekt (News, Terminplaner), Aktionen je Zustand und +// Einarbeiten der Portal-Antwort. Rein, unter Node testbar. + +/** Funktionen in Anzeigereihenfolge; key = Schluessel im Portal (features[key], POST /funktionen/:customerId/:key). */ +export const FUNKTIONEN = [ + { + key: 'news', + label: 'News', + bestaetigen: { + activate: 'News ohne Zahlung aktivieren?', + disable: 'News abschalten? Die Website zeigt dann keine Beiträge mehr.', + }, + }, + { + key: 'termine', + label: 'Terminplaner', + bestaetigen: { + activate: 'Terminplaner ohne Zahlung aktivieren?', + disable: 'Terminplaner abschalten? Auf der Website sind dann keine Buchungen mehr möglich.', + }, + }, +] + +/** Zustand einer Funktion -> [Ton des Abzeichens, Beschriftung]. */ +export const ZUSTAND = { + off: ['muted', 'Aus'], + ordered: ['warn', 'Bestellt'], + setup: ['info', 'Einrichtung'], + active: ['ok', 'Aktiv'], + disabled: ['muted', 'Abgeschaltet'], +} + +/** Ansicht einer Funktion im Projekt (Portal: project.features[key]); ohne Eintrag gilt "off". */ +export const funktionOf = (project, key) => project?.features?.[key] || { state: 'off' } + +/** Moegliche Aktionen je Zustand: Aktivieren (ohne Zahlung) ausser bei "active", Abschalten solange bestellt, in Einrichtung oder aktiv. */ +export function aktionen(state) { + const list = [] + if (state !== 'active') list.push('activate') + if (['ordered', 'setup', 'active'].includes(state)) list.push('disable') + return list +} + +/** + * 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. + */ +export function mergeFunktion(projects, projectId, key, aktualisiert) { + const view = aktualisiert?.features?.[key] + if (!view) return projects + return projects.map((p) => (p.projectId === projectId ? { ...p, features: { ...p.features, [key]: view } } : p)) +} diff --git a/src/lib/teamApi.js b/src/lib/teamApi.js index d206ab0..f4e84f5 100644 --- a/src/lib/teamApi.js +++ b/src/lib/teamApi.js @@ -89,11 +89,13 @@ export const listStorageTasks = () => request('/storage-tasks') export const getStorageOpenCount = () => request('/storage-tasks/open-count') export const markStorageDone = (id) => request(`/storage-tasks/${enc(id)}/done`, { method: 'POST', body: {} }) -// --- Funktionen (News-Pinnwand je Projekt) --- +// --- Funktionen (News-Pinnwand, Terminplaner je Projekt) --- export const getFunktionen = (customerId) => request(`/funktionen/${enc(customerId)}`) -export const setNewsFeature = (customerId, { projectId, action, note }) => - request(`/funktionen/${enc(customerId)}/news`, { method: 'POST', body: { projectId, action, note } }) +/** 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 } }) +export const setNewsFeature = (customerId, body) => setFeature(customerId, 'news', body) // --- Abo-Status (alle Mitarbeiter) --- diff --git a/src/lib/viewLog.js b/src/lib/viewLog.js index 06970f1..4354158 100644 --- a/src/lib/viewLog.js +++ b/src/lib/viewLog.js @@ -8,6 +8,9 @@ const LABELS = { news_bild: 'News-Foto hochgeladen', news_geaendert: 'News geändert', news_geloescht: 'News gelöscht', + termin_einstellungen: 'Terminplaner-Einstellungen geändert', + termin_aktion: 'Termin bearbeitet', + termin_manuell: 'Termin eingetragen', } /** Deutsche Beschriftung einer Aktion; unbekannte Aktionen erscheinen roh. */ diff --git a/src/styles/admin-areas.css b/src/styles/admin-areas.css index 05b5e19..42d85d9 100644 --- a/src/styles/admin-areas.css +++ b/src/styles/admin-areas.css @@ -21,10 +21,11 @@ .area-filter { display: inline-flex; gap: 4px; margin-bottom: var(--sp-3); } .area-inline { display: flex; flex-wrap: wrap; gap: var(--sp-2); align-items: center; } .storage-row { grid-template-columns: 150px 220px minmax(0, 1fr) 150px 110px; } -.funktionen-row { grid-template-columns: minmax(0, 1.2fr) 200px 110px minmax(0, 1.4fr); } +.funktionen-row { grid-template-columns: minmax(0, 1.2fr) 110px 200px 110px minmax(0, 1.4fr); } @media (max-width: 720px) { .area-row { grid-template-columns: minmax(0, 1fr) !important; } .area-row.head { display: none; } + .funktionen-leer { display: none; } .area-kv { grid-template-columns: minmax(0, 1fr); } .area-kv dt { margin-top: 6px; } } @@ -48,4 +49,5 @@ @container (max-width: 960px) { .area-row { grid-template-columns: minmax(0, 1fr) !important; gap: 4px; } .area-row.head { display: none; } + .funktionen-leer { display: none; } } diff --git a/tests/unit/funktionenView.test.mjs b/tests/unit/funktionenView.test.mjs new file mode 100644 index 0000000..c344787 --- /dev/null +++ b/tests/unit/funktionenView.test.mjs @@ -0,0 +1,94 @@ +import test from 'node:test' +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 } = await loadModule('src/lib/funktionenView.js') + +test('FUNKTIONEN: News und Terminplaner mit den Bestätigungstexten', () => { + assert.deepEqual(FUNKTIONEN, [ + { + key: 'news', + label: 'News', + bestaetigen: { + activate: 'News ohne Zahlung aktivieren?', + disable: 'News abschalten? Die Website zeigt dann keine Beiträge mehr.', + }, + }, + { + key: 'termine', + label: 'Terminplaner', + bestaetigen: { + activate: 'Terminplaner ohne Zahlung aktivieren?', + disable: 'Terminplaner abschalten? Auf der Website sind dann keine Buchungen mehr möglich.', + }, + }, + ]) +}) + +test('ZUSTAND: Ton und Beschriftung je Zustand', () => { + assert.deepEqual(ZUSTAND, { + off: ['muted', 'Aus'], + ordered: ['warn', 'Bestellt'], + setup: ['info', 'Einrichtung'], + active: ['ok', 'Aktiv'], + disabled: ['muted', 'Abgeschaltet'], + }) +}) + +test('funktionOf: Ansicht der Funktion, ohne Eintrag "off"', () => { + const project = { projectId: 'p1', features: { news: { state: 'active', ticketWoid: '100' }, termine: { state: 'setup' } } } + assert.deepEqual(funktionOf(project, 'news'), { state: 'active', ticketWoid: '100' }) + assert.deepEqual(funktionOf(project, 'termine'), { state: 'setup' }) + assert.deepEqual(funktionOf({ projectId: 'p2', features: { news: { state: 'active' } } }, 'termine'), { state: 'off' }) + assert.deepEqual(funktionOf({ projectId: 'p3' }, 'news'), { state: 'off' }) + assert.deepEqual(funktionOf(null, 'news'), { state: 'off' }) +}) + +test('aktionen: Aktivieren außer bei "active", Abschalten bei Bestellt, Einrichtung und Aktiv', () => { + 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']) +}) + +const projects = () => [ + { projectId: 'p1', projectName: 'Eins', features: { news: { state: 'off' }, termine: { state: 'active', siteKey: 'abc' } } }, + { projectId: 'p2', projectName: 'Zwei', features: { news: { state: 'active' }, termine: { state: 'off' } } }, +] + +test('mergeFunktion: übernimmt nur die gewählte Funktion, die andere bleibt', () => { + const antwort = { + projectId: 'p1', + projectName: 'Eins (neu)', + features: { news: { state: 'setup', ticketWoid: '7' }, termine: { state: 'off' } }, + } + const merged = mergeFunktion(projects(), 'p1', 'news', antwort) + assert.deepEqual(merged[0].features.news, { state: 'setup', ticketWoid: '7' }) + assert.deepEqual(merged[0].features.termine, { state: 'active', siteKey: 'abc' }) + assert.equal(merged[0].projectName, 'Eins') + assert.deepEqual(merged[1], projects()[1]) +}) + +test('mergeFunktion: Terminplaner überschreibt News nicht', () => { + const antwort = { projectId: 'p2', features: { news: { state: 'off' }, termine: { state: 'ordered' } } } + const merged = mergeFunktion(projects(), 'p2', 'termine', antwort) + assert.deepEqual(merged[1].features, { news: { state: 'active' }, termine: { state: 'ordered' } }) +}) + +test('mergeFunktion: verändert die Eingabe nicht, ohne Antwort oder ohne Funktion bleibt alles', () => { + const list = projects() + const merged = mergeFunktion(list, 'p1', 'news', { projectId: 'p1', features: { news: { state: 'active' } } }) + assert.notEqual(merged, list) + assert.deepEqual(list, projects()) + assert.deepEqual(mergeFunktion(list, 'p1', 'news', undefined), projects()) + assert.deepEqual(mergeFunktion(list, 'p1', 'news', { projectId: 'p1', features: {} }), projects()) + assert.deepEqual(mergeFunktion(list, 'unbekannt', 'news', { features: { news: { state: 'active' } } }), projects()) +}) + +test('mergeFunktion: Projekt ohne bisherige features bekommt die Funktion', () => { + const merged = mergeFunktion([{ projectId: 'p9' }], 'p9', 'termine', { features: { termine: { state: 'setup' } } }) + assert.deepEqual(merged, [{ projectId: 'p9', features: { termine: { state: 'setup' } } }]) +}) diff --git a/tests/unit/viewLog.test.mjs b/tests/unit/viewLog.test.mjs index d453e7c..67c5e9f 100644 --- a/tests/unit/viewLog.test.mjs +++ b/tests/unit/viewLog.test.mjs @@ -5,10 +5,16 @@ import { loadModule } from './load.mjs' // Protokoll der Kundenansicht: Aktionen des Portals (customerViewLog.action) als deutsche Beschriftung const { viewLogLabel, formatViewLogTime } = await loadModule('src/lib/viewLog.js') -test('viewLogLabel: alle sieben Aktionen und Rohwert', () => { +test('viewLogLabel: alle zehn Aktionen und Rohwert', () => { assert.deepEqual( - ['geoeffnet', 'beendet', 'abgelaufen', 'news_angelegt', 'news_bild', 'news_geaendert', 'news_geloescht'].map(viewLogLabel), - ['Kundenansicht geöffnet', 'Kundenansicht beendet', 'Kundenansicht abgelaufen', 'News angelegt', 'News-Foto hochgeladen', 'News geändert', 'News gelöscht'] + [ + 'geoeffnet', 'beendet', 'abgelaufen', 'news_angelegt', 'news_bild', 'news_geaendert', 'news_geloescht', + 'termin_einstellungen', 'termin_aktion', 'termin_manuell', + ].map(viewLogLabel), + [ + 'Kundenansicht geöffnet', 'Kundenansicht beendet', 'Kundenansicht abgelaufen', 'News angelegt', 'News-Foto hochgeladen', 'News geändert', 'News gelöscht', + 'Terminplaner-Einstellungen geändert', 'Termin bearbeitet', 'Termin eingetragen', + ] ) assert.equal(viewLogLabel('neu_irgendwas'), 'neu_irgendwas') })