233 lines
11 KiB
React
233 lines
11 KiB
React
import { Fragment, useCallback, useEffect, useRef, useState } from 'react'
|
||
import { Link } from 'react-router-dom'
|
||
import { FaCheck, FaCopy, FaRotateRight, FaSpinner } from 'react-icons/fa6'
|
||
import { Card, Badge, Button, EmptyState } from '../ui'
|
||
import { copyText } from '../CopyableCredential'
|
||
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")
|
||
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>
|
||
)
|
||
}
|
||
|
||
// 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)
|
||
const [confirm, setConfirm] = useState(null) // { projectId, key, action }
|
||
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)
|
||
getFunktionen(customerId)
|
||
.then(({ projects: list }) => setProjects(list || []))
|
||
.catch((err) => setError(err.message || 'Funktionen konnten nicht geladen werden.'))
|
||
}, [customerId])
|
||
useEffect(() => { load() }, [load])
|
||
|
||
const save = async () => {
|
||
const { projectId, key, action } = confirm
|
||
setBusy(true)
|
||
setStatus(null)
|
||
try {
|
||
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, key, updated))
|
||
setConfirm(null)
|
||
setNote('')
|
||
setStatus({ ok: true, text: 'Gespeichert.' })
|
||
} catch (err) {
|
||
setConfirm(null)
|
||
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>
|
||
if (!projects.length) return <Card><EmptyState title="Keine Projekte für diesen Kunden." /></Card>
|
||
|
||
return (
|
||
<Card title="Funktionen" pad={false}>
|
||
<div className="area-rows">
|
||
<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
|
||
const hatAnleitung = f.key === 'termine' && feature.state !== 'off'
|
||
const anleitungOffenHier = hatAnleitung && panelOffen(p.projectId)
|
||
return (
|
||
<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>
|
||
) : (
|
||
<span className="funktionen-leer" />
|
||
)}
|
||
<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>
|
||
{status && <div className={status.ok ? 'area-message' : 'area-error'} style={{ margin: 12 }}>{status.text}</div>}
|
||
</Card>
|
||
)
|
||
}
|