Files
tickte-system/src/components/customer/CustomerFunktionen.jsx
T

233 lines
11 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}