WOMS: Einbauanleitung als Panel mit Kopierknoepfen, Abnahme-Knopf und Ticket nachlegen

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-05 02:08:04 +00:00
1 parent c0015544c4
commit 857e001911
2 files changed
+155 -43

No files matched your search

+144 -43
View File
@@ -1,10 +1,13 @@
import { Fragment, useCallback, useEffect, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import { FaCheck, FaCopy, FaSpinner } from 'react-icons/fa6'
import { FaCheck, FaCopy, FaRotateRight, FaSpinner } from 'react-icons/fa6'
import { Card, Badge, Button, EmptyState } from '../ui'
import { copyText } from '../CopyableCredential'
import { getFunktionen, setFeature } from '../../lib/teamApi'
import { FUNKTIONEN, ZUSTAND, funktionOf, aktionen, mergeFunktion, siteKeyOf, abgeschaltetText } from '../../lib/funktionenView'
import { getFunktionen, setFeature, getTermineAnleitung } from '../../lib/teamApi'
import {
FUNKTIONEN, ZUSTAND, AKTION_LABEL, funktionOf, aktionen, mergeFunktion, siteKeyOf, abgeschaltetText,
hindernisText, panelModell, anleitungOffen,
} from '../../lib/funktionenView'
import '../../styles/admin-areas.css'
// Reiter "Funktionen": News-Pinnwand und Terminplaner je Projekt an/aus wie im frueheren Admin-Panel (Kundenliste, Zeile "Funktionen")
@@ -32,6 +35,82 @@ function SiteKey({ value }) {
)
}
// Kopierknopf mit "Kopiert"-Rueckmeldung (wie SiteKey)
function KopierKnopf({ text, label }) {
const [copied, setCopied] = useState(false)
const timer = useRef(null)
useEffect(() => () => clearTimeout(timer.current), [])
const copy = async () => {
await copyText(text)
setCopied(true)
clearTimeout(timer.current)
timer.current = setTimeout(() => setCopied(false), 1800)
}
return (
<Button size="sm" variant="ghost" onClick={copy} title={label}>
{copied ? <><FaCheck className="funktionen-sitekey-ok" /> Kopiert</> : <><FaCopy /> {label}</>}
</Button>
)
}
// Einbauanleitung des Terminplaners unter der Zeile. Inhalte und Entscheidungen kommen aus funktionenView (panelModell); Texte
// stehen ausschliesslich als React-Textknoten da (nie als HTML oder Link). Laedt beim Oeffnen und bei jedem neuen reloadKey
// (nach einer Aktion aendern sich Version und Hindernis) neu.
function AnleitungPanel({ customerId, projectId, feature, reloadKey }) {
const [antwort, setAntwort] = useState(null)
const [fehler, setFehler] = useState(null)
const [retry, setRetry] = useState(0)
useEffect(() => {
let aktuell = true
setAntwort(null)
setFehler(null)
getTermineAnleitung(customerId, projectId)
.then((res) => { if (aktuell) setAntwort(res) })
.catch((err) => { if (aktuell) setFehler(err?.message || 'Anleitung konnte nicht geladen werden.') })
return () => { aktuell = false }
}, [customerId, projectId, reloadKey, retry])
const hindernis = hindernisText(feature.hindernis)
if (fehler || !antwort) {
return (
<div className="area-row funktionen-panel">
{fehler ? <div className="area-error">{fehler}</div> : <FaSpinner className="spinner" />}
{hindernis && <div className="area-summary warn">{hindernis}</div>}
{fehler && <div><Button size="sm" variant="ghost" onClick={() => setRetry((n) => n + 1)}><FaRotateRight /> Erneut laden</Button></div>}
</div>
)
}
const modell = panelModell(antwort, feature)
return (
<div className="area-row funktionen-panel">
<div className="funktionen-panel-head">
<strong>Einbauanleitung</strong>
{modell.kopf && <span className="area-muted">{modell.kopf}</span>}
{modell.ticketPfad && <Link to={modell.ticketPfad}>Zum Ticket</Link>}
<Button size="sm" variant="ghost" onClick={() => setRetry((n) => n + 1)} title="Anleitung neu laden" aria-label="Anleitung neu laden"><FaRotateRight /></Button>
</div>
{modell.warnungen.length > 0 && (
<ul className="area-error funktionen-panel-warnungen">
{modell.warnungen.map((text, i) => <li key={i}>{text}</li>)}
</ul>
)}
{modell.hinweis && <div className="area-summary warn">{modell.hinweis}</div>}
{modell.kopien.length > 0 && (
<div className="area-inline">
{modell.kopien.map((k) => <KopierKnopf key={k.id} text={k.text} label={k.label} />)}
</div>
)}
{modell.abschnitte.map((a) => (
<details key={a.id} className="funktionen-panel-abschnitt">
<summary>{a.titel}</summary>
<pre>{a.text}</pre>
</details>
))}
</div>
)
}
export default function CustomerFunktionen({ customerId }) {
const [projects, setProjects] = useState(null)
const [error, setError] = useState(null)
@@ -39,6 +118,8 @@ export default function CustomerFunktionen({ customerId }) {
const [note, setNote] = useState('')
const [busy, setBusy] = useState(false)
const [status, setStatus] = useState(null) // { ok, text }
const [panel, setPanel] = useState({}) // projectId -> true/false (Knopf "Anleitung"); ohne Eintrag entscheidet die Adresse (?anleitung=<projectId>)
const [panelReload, setPanelReload] = useState({}) // projectId -> Zaehler, nach jeder Aktion hochgezaehlt
const load = useCallback(() => {
setError(null)
@@ -49,13 +130,14 @@ export default function CustomerFunktionen({ customerId }) {
useEffect(() => { load() }, [load])
const save = async () => {
const { projectId, key, action } = confirm
setBusy(true)
setStatus(null)
try {
const { projects: changed } = await setFeature(customerId, confirm.key, { projectId: confirm.projectId, action: confirm.action, note: note.trim() })
const { projects: changed } = await setFeature(customerId, key, { projectId, action, note: note.trim() })
// Die Antwort enthaelt das ganze Projekt; uebernommen wird nur die geaenderte Funktion
const updated = changed?.[0]
if (updated) setProjects((list) => mergeFunktion(list, updated.projectId, confirm.key, updated))
if (updated) setProjects((list) => mergeFunktion(list, updated.projectId, key, updated))
setConfirm(null)
setNote('')
setStatus({ ok: true, text: 'Gespeichert.' })
@@ -64,10 +146,14 @@ export default function CustomerFunktionen({ customerId }) {
setStatus({ ok: false, text: err.message })
} finally {
setBusy(false)
// Version und Hindernis der Anleitung haengen vom Zustand ab: Panel dieser Zeile neu laden
if (key === 'termine') setPanelReload((r) => ({ ...r, [projectId]: (r[projectId] || 0) + 1 }))
}
}
const ask = (projectId, key, action) => { setNote(''); setConfirm({ projectId, key, action }) }
const panelOffen = (projectId) => (Object.prototype.hasOwnProperty.call(panel, projectId) ? panel[projectId] : anleitungOffen(window.location.search, projectId))
const togglePanel = (projectId) => setPanel((p) => ({ ...p, [projectId]: !panelOffen(projectId) }))
if (error) return <Card><div className="area-error">{error}</div></Card>
if (!projects) return <Card><FaSpinner className="spinner" /></Card>
@@ -81,47 +167,62 @@ export default function CustomerFunktionen({ customerId }) {
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
const hatAnleitung = f.key === 'termine' && feature.state !== 'off'
const anleitungOffenHier = hatAnleitung && panelOffen(p.projectId)
return (
<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>
{abgeschaltetText(feature) && <><br /><span className="area-muted">{abgeschaltetText(feature)}</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 className="area-inline">
{isConfirming ? (
<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>
<Fragment key={`${p.projectId}:${f.key}`}>
<div 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>
) : (
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 className="funktionen-leer" />
)}
</span>
</div>
<span>{f.label}</span>
<span>
<Badge tone={tone}>{label}</Badge>
{feature.state === 'setup' && hindernisText(feature.hindernis) && <><br /><span className="area-warn">{hindernisText(feature.hindernis)}</span></>}
{abgeschaltetText(feature) && <><br /><span className="area-muted">{abgeschaltetText(feature)}</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 className="area-inline">
{isConfirming ? (
<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>
) : (
<Fragment>
{aktionen(feature.state, f.key, { ticketMissing: feature.ticketMissing }).map((action) => (
<Button key={action} size="sm" variant="ghost" onClick={() => ask(p.projectId, f.key, action)}>
{AKTION_LABEL[action]}
</Button>
))}
{hatAnleitung && (
<Button size="sm" variant="ghost" onClick={() => togglePanel(p.projectId)} aria-expanded={anleitungOffenHier}>
Anleitung
</Button>
)}
</Fragment>
)}
</span>
</div>
{anleitungOffenHier && (
<AnleitungPanel customerId={customerId} projectId={p.projectId} feature={feature} reloadKey={panelReload[p.projectId] || 0} />
)}
</Fragment>
)
}))}
</div>