Workers P3: Interessenten-Panel im Pult
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f8f8df2cbd
commit
686db61e1c
5 files changed
+701
-1
No files matched your search
@@ -0,0 +1,131 @@
|
||||
import { Fragment, useEffect, useState } from 'react'
|
||||
import Badge from '../ui/Badge'
|
||||
import Button from '../ui/Button'
|
||||
import Dialog from '../ui/Dialog'
|
||||
import { useEmployees } from '../../hooks/useEmployees'
|
||||
import { BAD_REPLY_TEXT } from '../../lib/workersApi'
|
||||
import { prospectView } from '../../lib/workersProspect'
|
||||
|
||||
const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
|
||||
|
||||
// Ton des Score-Bands: ab 80 grün, 60 bis 79 orange (reicht für eine Antwort), darunter rot
|
||||
const BAND_TONES = { hoch: 'green', mittel: 'orange', niedrig: 'red' }
|
||||
|
||||
// Inhalt des Panels aus der fertigen Ansicht (prospectView). Alles Fremde (Name, Anfragetext, Nachrichten, Begründung) steht nur als
|
||||
// Textknoten da; Zeilenumbrüche bleiben über .wk-pult-text (pre-wrap) erhalten. Die Adresse der Anfrage ist schon als sicher geprüft.
|
||||
export function ProspectBody({ view }) {
|
||||
const { score } = view
|
||||
return (
|
||||
<div className="wk-prospect">
|
||||
<div className="wk-prospect-score">
|
||||
{score.band
|
||||
? <Badge tone={BAND_TONES[score.band]}>{`Score ${score.value} · ${score.band}`}</Badge>
|
||||
: <span className="muted">Noch nicht bewertet</span>}
|
||||
{score.reason && <span>{score.reason}</span>}
|
||||
</div>
|
||||
|
||||
{view.facts.length > 0 && (
|
||||
<dl className="kv">
|
||||
{view.facts.map((fact) => (
|
||||
<Fragment key={fact.label}>
|
||||
<dt>{fact.label}</dt>
|
||||
<dd>{fact.value}</dd>
|
||||
</Fragment>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
|
||||
<section className="wk-prospect-section">
|
||||
<h4 className="wk-prospect-title">Anfrage</h4>
|
||||
{view.requestText
|
||||
? <p className="wk-pult-text">{view.requestText}</p>
|
||||
: <p className="muted">Kein Anfragetext gespeichert.</p>}
|
||||
{view.sourceHref && (
|
||||
<p>
|
||||
<a className="link" href={view.sourceHref} target="_blank" rel="noopener noreferrer">Anfrage öffnen</a>
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="wk-prospect-section">
|
||||
<h4 className="wk-prospect-title">Nachrichtenverlauf</h4>
|
||||
{view.messages.length > 0 ? (
|
||||
<ul className="wk-prospect-msgs">
|
||||
{view.messages.map((message, index) => (
|
||||
<li key={message.id !== '' ? message.id : `m${index}`} className="wk-prospect-msg">
|
||||
<div className="wk-prospect-msg-head">
|
||||
<strong>{message.who}</strong>
|
||||
{message.when && <span>{message.when}</span>}
|
||||
</div>
|
||||
<p className="wk-pult-text">{message.body}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="muted">Noch keine Nachrichten.</p>
|
||||
)}
|
||||
{view.previewLine && <p className="muted">{view.previewLine}</p>}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Interessenten-Panel (Dialog): lädt den Interessenten mit Faden, Nachrichten und Vorschau vom Dienst (api.prospect) und zeigt ihn nur
|
||||
// zum Lesen. Ein Schließen oder Wechsel während des Ladens verwirft die Antwort. Die Namen der Absender kommen aus der Mitarbeiterliste;
|
||||
// bis sie da ist, bleibt der Ladezustand stehen (sonst stünde kurz überall „Kollege“).
|
||||
export default function ProspectPanel({ api, prospectId, onClose }) {
|
||||
const [state, setState] = useState({ phase: 'loading', detail: null, error: '' })
|
||||
const [attempt, setAttempt] = useState(0) // „Neu laden“ zählt hoch und startet die Abfrage noch einmal
|
||||
const { employees, loading: employeesLoading } = useEmployees()
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
setState((s) => (s.phase === 'loading' ? s : { phase: 'loading', detail: null, error: '' }))
|
||||
const load = async () => {
|
||||
try {
|
||||
const detail = await api.prospect(prospectId)
|
||||
if (!alive) return
|
||||
if (!isRecord(detail) || !isRecord(detail.prospect)) {
|
||||
setState({ phase: 'error', detail: null, error: BAD_REPLY_TEXT })
|
||||
return
|
||||
}
|
||||
setState({ phase: 'ready', detail, error: '' })
|
||||
} catch (err) {
|
||||
if (alive) setState({ phase: 'error', detail: null, error: err?.message || BAD_REPLY_TEXT })
|
||||
}
|
||||
}
|
||||
load()
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [api, prospectId, attempt])
|
||||
|
||||
const view = state.phase === 'ready' && !employeesLoading ? prospectView(state.detail, { nowMs: Date.now(), employees }) : null
|
||||
|
||||
let body
|
||||
if (state.phase === 'error') {
|
||||
body = (
|
||||
<>
|
||||
<div className="msg err" role="alert">{state.error}</div>
|
||||
<div>
|
||||
<Button type="button" onClick={() => setAttempt((n) => n + 1)}>Neu laden</Button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
} else if (view === null) {
|
||||
body = <div className="loading">Wird geladen …</div>
|
||||
} else {
|
||||
body = <ProspectBody view={view} />
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
title={view ? view.title : 'Interessent'}
|
||||
onClose={onClose}
|
||||
wide
|
||||
footer={<Button type="button" onClick={onClose}>Schließen</Button>}
|
||||
>
|
||||
{body}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { copyText } from '../CopyableCredential'
|
||||
import { useEmployees } from '../../hooks/useEmployees'
|
||||
import { usePultFeed } from '../../hooks/usePultFeed'
|
||||
import { PULT_FILTERS, copyAndOpen, mailtoHref, rowModel } from '../../lib/workersPult'
|
||||
import ProspectPanel from './ProspectPanel'
|
||||
|
||||
const CONFIRM_MS = 4000 // so lange gilt die Rückfrage „Wirklich verwerfen?“ nach dem ersten Klick
|
||||
const ACTION_FAILED = 'Das hat nicht geklappt.'
|
||||
@@ -207,7 +208,7 @@ export default function PultList({ api, me }) {
|
||||
const [filterId, setFilterId] = useState('offen')
|
||||
const [drafts, setDrafts] = useState({}) // bearbeitete Texte je Eintrag; was fehlt, zeigt den Entwurf des Dienstes
|
||||
const [finished, setFinished] = useState('') // Meldung über den zuletzt abgeschlossenen Eintrag (seine Zeile ist dann schon weg)
|
||||
const [, setDetailId] = useState(null) // „Details“ öffnet ab P3-T20 das Interessenten-Panel
|
||||
const [detailId, setDetailId] = useState(null) // Interessent, dessen Panel offen ist (null = keins)
|
||||
const filter = PULT_FILTERS.find((f) => f.id === filterId) ?? PULT_FILTERS[0]
|
||||
const { items, loading, error, refresh } = usePultFeed({ api, statuses: filter.statuses })
|
||||
const { employees } = useEmployees()
|
||||
@@ -282,6 +283,7 @@ export default function PultList({ api, me }) {
|
||||
{finished && <div className="msg ok" role="status">{finished}</div>}
|
||||
{error && rows.length > 0 && <div className="msg warn" role="status">Verbindung gestört: {error} Es wird weiter versucht.</div>}
|
||||
{body}
|
||||
{detailId && <ProspectPanel api={api} prospectId={detailId} onClose={() => setDetailId(null)} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user