Workers P3: Interessenten-Panel im Pult

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-18 09:59:11 +00:00
1 parent f8f8df2cbd
commit 686db61e1c
5 files changed
+701 -1

No files matched your search

+131
View File
@@ -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>
)
}
+3 -1
View File
@@ -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)} />}
</>
)
}