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:
1 parent
c0015544c4
commit
857e001911
2 files changed
+155
-43
No files matched your search
@@ -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>
|
||||
|
||||
@@ -30,6 +30,17 @@
|
||||
}
|
||||
.funktionen-sitekey-copy:hover { background: var(--surface-2); color: var(--text); }
|
||||
.funktionen-sitekey-ok { color: var(--ok); }
|
||||
.funktionen-panel { grid-template-columns: minmax(0, 1fr); gap: var(--sp-2); background: var(--surface-2); }
|
||||
.funktionen-panel-head { display: flex; flex-wrap: wrap; gap: var(--sp-2) var(--sp-3); align-items: center; }
|
||||
.funktionen-panel-warnungen { margin: 0; padding-left: 32px; }
|
||||
.funktionen-panel .area-summary { margin-bottom: 0; }
|
||||
.funktionen-panel-abschnitt { border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--surface-1); min-width: 0; }
|
||||
.funktionen-panel-abschnitt > summary { padding: 8px 12px; cursor: pointer; font-weight: 600; }
|
||||
.funktionen-panel-abschnitt pre {
|
||||
margin: 0; padding: 10px 12px; border-top: 1px solid var(--border); max-width: 100%;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; color: var(--text);
|
||||
white-space: pre-wrap; overflow-wrap: anywhere; user-select: text;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.area-row { grid-template-columns: minmax(0, 1fr) !important; }
|
||||
.area-row.head { display: none; }
|
||||
|
||||
Reference in new issue
Block a user