Funktionen-Reiter: News und Terminplaner je Projekt

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-02 23:57:03 +00:00
1 parent 36eb55b81b
commit 426a5d6654
7 files changed
+209 -48

No files matched your search

+43 -41
View File
@@ -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 <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 +53,52 @@ 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></>}
</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>
+52
View File
@@ -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))
}
+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. */
+3 -1
View File
@@ -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; }
}