Compare commits

..
2 Commits
Author SHA1 Message Date
ClaudeandClaude Sonnet 5.5 65e9931f2c Terminplaner: Abschluss – Site-Key des Terminplaners im Funktionen-Reiter mit Kopierknopf
Die Zeile Terminplaner zeigt den siteKey aus der Antwort des Portals in kleiner Schrift mit fester
Breite und einem Kopierknopf (copyText aus CopyableCredential, mit Ersatzweg ohne Zwischenablage).
Damit braucht der Einbau (Schritt L4 des Pilots) keinen Datenbankzugriff mehr.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 01:49:23 +00:00
ClaudeandClaude Sonnet 5.5 426a5d6654 Funktionen-Reiter: News und Terminplaner je Projekt
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 23:57:03 +00:00
8 changed files with 261 additions and 50 deletions

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react'
import { FaCheck, FaCopy, FaEye, FaEyeSlash } from 'react-icons/fa6'
async function copyText(text) {
export async function copyText(text) {
try {
await navigator.clipboard.writeText(text)
} catch {
+68 -42
View File
@@ -1,27 +1,41 @@
import { useCallback, useEffect, useState } from 'react'
import { FaSpinner } from 'react-icons/fa6'
import { Fragment, useCallback, useEffect, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import { FaCheck, FaCopy, FaSpinner } from 'react-icons/fa6'
import { Card, Badge, Button, EmptyState } from '../ui'
import { getFunktionen, setNewsFeature } from '../../lib/teamApi'
import { copyText } from '../CopyableCredential'
import { getFunktionen, setFeature } from '../../lib/teamApi'
import { FUNKTIONEN, ZUSTAND, funktionOf, aktionen, mergeFunktion, siteKeyOf } 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)
// Site-Key des Terminplaners (steht im Einbau-Code der Kundenwebsite): kleine Schrift mit fester Breite und Kopierknopf
function SiteKey({ value }) {
const [copied, setCopied] = useState(false)
const timer = useRef(null)
useEffect(() => () => clearTimeout(timer.current), [])
const copy = async () => {
await copyText(value)
setCopied(true)
clearTimeout(timer.current)
timer.current = setTimeout(() => setCopied(false), 1800)
}
return (
<span className="funktionen-sitekey">
<span className="area-muted">Site-Key</span>
<code>{value}</code>
<button type="button" className="funktionen-sitekey-copy" onClick={copy} title={copied ? 'Kopiert' : 'Site-Key kopieren'} aria-label={copied ? 'Site-Key kopiert' : 'Site-Key kopieren'}>
{copied ? <FaCheck className="funktionen-sitekey-ok" /> : <FaCopy />}
</button>
</span>
)
}
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 +52,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 +67,8 @@ export default function CustomerFunktionen({ customerId }) {
}
}
const ask = (projectId, key, action) => { setNote(''); setConfirm({ projectId, key, action }) }
if (error) return <Card><div className="area-error">{error}</div></Card>
if (!projects) return <Card><FaSpinner className="spinner" /></Card>
if (!projects.length) return <Card><EmptyState title="Keine Projekte für diesen Kunden." /></Card>
@@ -59,44 +76,53 @@ export default function CustomerFunktionen({ customerId }) {
return (
<Card title="Funktionen" pad={false}>
<div className="area-rows">
<div className="area-row funktionen-row head"><span>Projekt</span><span>News</span><span>Ticket</span><span>Aktionen</span></div>
{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
<div className="area-row funktionen-row head"><span>Projekt</span><span>Funktion</span><span>Status</span><span>Ticket</span><span>Aktionen</span></div>
{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 (
<div key={p.projectId} className="area-row funktionen-row">
<span>
<strong>{p.projectName}</strong>
{p.liveUrl && <><br /><a href={p.liveUrl} target="_blank" rel="noreferrer" className="area-muted">{p.liveUrl.replace(/^https?:\/\//, '')}</a></>}
</span>
<div key={`${p.projectId}:${f.key}`} className="area-row funktionen-row">
{index === 0 ? (
<span>
<strong>{p.projectName}</strong>
{p.liveUrl && <><br /><a href={p.liveUrl} target="_blank" rel="noreferrer" className="area-muted">{p.liveUrl.replace(/^https?:\/\//, '')}</a></>}
</span>
) : (
<span className="funktionen-leer" />
)}
<span>{f.label}</span>
<span>
<Badge tone={tone}>{label}</Badge>
{news.adminNote && <><br /><span className="area-muted" title={news.adminNote}>{short(news.adminNote)}</span></>}
{feature.adminNote && <><br /><span className="area-muted" title={feature.adminNote}>{short(feature.adminNote)}</span></>}
{siteKeyOf(feature, f.key) && <><br /><SiteKey value={siteKeyOf(feature, f.key)} /></>}
</span>
<span>
{feature.ticketMissing
? <Badge tone="danger">Ticket fehlt</Badge>
: feature.ticketWoid
? <Link to={`/tickets?woid=${encodeURIComponent(feature.ticketWoid)}`}>#{feature.ticketWoid}</Link>
: '–'}
</span>
<span>{news.ticketMissing ? <Badge tone="danger">Ticket fehlt</Badge> : news.ticketWoid ? `#${news.ticketWoid}` : '–'}</span>
<span className="area-inline">
{isConfirming ? (
<>
<span>{CONFIRM[confirm.action]}</span>
<Fragment>
<span>{f.bestaetigen[confirm.action]}</span>
<input className="form-control" placeholder="Notiz (optional)" maxLength={120} value={note} onChange={(e) => setNote(e.target.value)} style={{ maxWidth: 220 }} />
<Button size="sm" variant="primary" onClick={save} disabled={busy}>{busy ? <FaSpinner className="spinner" /> : 'Ja'}</Button>
<Button size="sm" variant="ghost" onClick={() => setConfirm(null)} disabled={busy}>Abbrechen</Button>
</>
</Fragment>
) : (
<>
{news.state !== 'active' && (
<Button size="sm" variant="ghost" onClick={() => { setNote(''); setConfirm({ projectId: p.projectId, action: 'activate' }) }}>Aktivieren (ohne Zahlung)</Button>
)}
{['ordered', 'setup', 'active'].includes(news.state) && (
<Button size="sm" variant="ghost" onClick={() => { setNote(''); setConfirm({ projectId: p.projectId, action: 'disable' }) }}>Abschalten</Button>
)}
</>
aktionen(feature.state).map((action) => (
<Button key={action} size="sm" variant="ghost" onClick={() => ask(p.projectId, f.key, action)}>
{action === 'activate' ? 'Aktivieren (ohne Zahlung)' : 'Abschalten'}
</Button>
))
)}
</span>
</div>
)
})}
}))}
</div>
{status && <div className={status.ok ? 'area-message' : 'area-error'} style={{ margin: 12 }}>{status.text}</div>}
</Card>
+61
View File
@@ -0,0 +1,61 @@
// 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' }
/**
* Site-Key (Schluessel fuer den Einbau auf der Kundenwebsite) einer Funktion: nur beim Terminplaner und nur, wenn das Portal
* einen nennt (feature.siteKey); sonst ''.
*/
export function siteKeyOf(feature, key) {
const wert = feature?.siteKey
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) {
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))
}
+5 -3
View File
@@ -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) ---
+3
View File
@@ -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. */
+11 -1
View File
@@ -21,10 +21,19 @@
.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); }
.funktionen-sitekey { display: inline-flex; align-items: center; gap: 6px; margin-top: 4px; }
.funktionen-sitekey code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; color: var(--text); user-select: all; }
.funktionen-sitekey-copy {
display: inline-flex; align-items: center; justify-content: center; width: 25px; height: 25px; flex: 0 0 25px;
border: 0; border-radius: 4px; background: transparent; color: var(--text-faint); cursor: pointer;
}
.funktionen-sitekey-copy:hover { background: var(--surface-2); color: var(--text); }
.funktionen-sitekey-ok { color: var(--ok); }
@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 +57,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; }
}
+103
View File
@@ -0,0 +1,103 @@
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, siteKeyOf } = 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' } } }])
})
test('siteKeyOf: nur der Terminplaner zeigt seinen Site-Key, nur wenn das Portal einen nennt', () => {
assert.equal(siteKeyOf({ state: 'active', siteKey: 'abcdefghijklmnop' }, 'termine'), 'abcdefghijklmnop')
assert.equal(siteKeyOf({ state: 'active', siteKey: ' abcdefghijklmnop ' }, 'termine'), 'abcdefghijklmnop', 'getrimmt')
assert.equal(siteKeyOf({ state: 'active', siteKey: 'abcdefghijklmnop' }, 'news'), '', 'News: kein Site-Key in der Tabelle')
for (const feature of [{ state: 'off' }, { state: 'active', siteKey: null }, { state: 'active', siteKey: '' }, { state: 'active', siteKey: 5 }, null, undefined]) {
assert.equal(siteKeyOf(feature, 'termine'), '', JSON.stringify(feature))
}
})
+9 -3
View File
@@ -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')
})