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