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 { Fragment, useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
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 { Card, Badge, Button, EmptyState } from '../ui'
|
||||||
import { copyText } from '../CopyableCredential'
|
import { copyText } from '../CopyableCredential'
|
||||||
import { getFunktionen, setFeature } from '../../lib/teamApi'
|
import { getFunktionen, setFeature, getTermineAnleitung } from '../../lib/teamApi'
|
||||||
import { FUNKTIONEN, ZUSTAND, funktionOf, aktionen, mergeFunktion, siteKeyOf, abgeschaltetText } from '../../lib/funktionenView'
|
import {
|
||||||
|
FUNKTIONEN, ZUSTAND, AKTION_LABEL, funktionOf, aktionen, mergeFunktion, siteKeyOf, abgeschaltetText,
|
||||||
|
hindernisText, panelModell, anleitungOffen,
|
||||||
|
} from '../../lib/funktionenView'
|
||||||
import '../../styles/admin-areas.css'
|
import '../../styles/admin-areas.css'
|
||||||
|
|
||||||
// Reiter "Funktionen": News-Pinnwand und Terminplaner je Projekt an/aus wie im frueheren Admin-Panel (Kundenliste, Zeile "Funktionen")
|
// 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 }) {
|
export default function CustomerFunktionen({ customerId }) {
|
||||||
const [projects, setProjects] = useState(null)
|
const [projects, setProjects] = useState(null)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
@@ -39,6 +118,8 @@ export default function CustomerFunktionen({ customerId }) {
|
|||||||
const [note, setNote] = useState('')
|
const [note, setNote] = useState('')
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [status, setStatus] = useState(null) // { ok, text }
|
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(() => {
|
const load = useCallback(() => {
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -49,13 +130,14 @@ export default function CustomerFunktionen({ customerId }) {
|
|||||||
useEffect(() => { load() }, [load])
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
|
const { projectId, key, action } = confirm
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
setStatus(null)
|
setStatus(null)
|
||||||
try {
|
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
|
// Die Antwort enthaelt das ganze Projekt; uebernommen wird nur die geaenderte Funktion
|
||||||
const updated = changed?.[0]
|
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)
|
setConfirm(null)
|
||||||
setNote('')
|
setNote('')
|
||||||
setStatus({ ok: true, text: 'Gespeichert.' })
|
setStatus({ ok: true, text: 'Gespeichert.' })
|
||||||
@@ -64,10 +146,14 @@ export default function CustomerFunktionen({ customerId }) {
|
|||||||
setStatus({ ok: false, text: err.message })
|
setStatus({ ok: false, text: err.message })
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false)
|
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 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 (error) return <Card><div className="area-error">{error}</div></Card>
|
||||||
if (!projects) return <Card><FaSpinner className="spinner" /></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 feature = funktionOf(p, f.key)
|
||||||
const [tone, label] = ZUSTAND[feature.state] || ['muted', feature.state]
|
const [tone, label] = ZUSTAND[feature.state] || ['muted', feature.state]
|
||||||
const isConfirming = confirm?.projectId === p.projectId && confirm?.key === f.key
|
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 (
|
return (
|
||||||
<div key={`${p.projectId}:${f.key}`} className="area-row funktionen-row">
|
<Fragment key={`${p.projectId}:${f.key}`}>
|
||||||
{index === 0 ? (
|
<div className="area-row funktionen-row">
|
||||||
<span>
|
{index === 0 ? (
|
||||||
<strong>{p.projectName}</strong>
|
<span>
|
||||||
{p.liveUrl && <><br /><a href={p.liveUrl} target="_blank" rel="noreferrer" className="area-muted">{p.liveUrl.replace(/^https?:\/\//, '')}</a></>}
|
<strong>{p.projectName}</strong>
|
||||||
</span>
|
{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>
|
|
||||||
) : (
|
) : (
|
||||||
aktionen(feature.state).map((action) => (
|
<span className="funktionen-leer" />
|
||||||
<Button key={action} size="sm" variant="ghost" onClick={() => ask(p.projectId, f.key, action)}>
|
|
||||||
{action === 'activate' ? 'Aktivieren (ohne Zahlung)' : 'Abschalten'}
|
|
||||||
</Button>
|
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
</span>
|
<span>{f.label}</span>
|
||||||
</div>
|
<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>
|
</div>
|
||||||
|
|||||||
@@ -30,6 +30,17 @@
|
|||||||
}
|
}
|
||||||
.funktionen-sitekey-copy:hover { background: var(--surface-2); color: var(--text); }
|
.funktionen-sitekey-copy:hover { background: var(--surface-2); color: var(--text); }
|
||||||
.funktionen-sitekey-ok { color: var(--ok); }
|
.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) {
|
@media (max-width: 720px) {
|
||||||
.area-row { grid-template-columns: minmax(0, 1fr) !important; }
|
.area-row { grid-template-columns: minmax(0, 1fr) !important; }
|
||||||
.area-row.head { display: none; }
|
.area-row.head { display: none; }
|
||||||
|
|||||||
Reference in new issue
Block a user