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)} />}
</>
)
}
+121
View File
@@ -0,0 +1,121 @@
// Reine Ansicht des Interessenten-Panels: macht aus der Antwort von GET /prospect/:id ({ prospect, thread, messages, preview, reply })
// das, was auf dem Bildschirm steht. Nur genannte Felder kommen durch (Whitelist): was der Dienst sonst noch mitschickte, auch
// eine Telefonnummer, wird nie angezeigt. Kein React, kein Appwrite, kein window/document: unter Node testbar (loadModule).
import { ageLabel, employeeName, safeExternalUrl } from './workersPult'
import { ROLES } from './workersRoster'
// Anzeigenamen der Plattformen, deren Alarm-Mails der Dienst liest; jede andere Quelle (z. B. eine Domain der Websuche) bleibt, wie sie ist
const SOURCE_NAMES = Object.freeze({
'kleinanzeigen-alarm': 'Kleinanzeigen',
'freelancermap-alarm': 'freelancermap',
'freelance-de-alarm': 'freelance.de',
})
// Status des Interessenten (Übersicht §4.2) auf Deutsch
const STATUS_NAMES = Object.freeze({
gefunden: 'Gefunden',
bewertet: 'Bewertet',
botlead: 'Bot-Lead',
kontaktiert: 'Kontaktiert',
gespraech: 'Im Gespräch',
ja: 'Zugesagt',
vorschau_bau: 'Vorschau im Bau',
vorschau_gesendet: 'Vorschau gesendet',
angebot: 'Angebot',
deal: 'Abschluss',
verloren: 'Verloren',
gesperrt: 'Gesperrt',
})
// Status der Vorschau (Übersicht §4.13) auf Deutsch
const PREVIEW_STATUS_NAMES = Object.freeze({
bau: 'wird gebaut',
pruefung: 'wird geprüft',
zurueck: 'geht zurück an den Bau',
fertig: 'fertig',
freigabe: 'wartet auf Freigabe',
gesendet: 'gesendet',
verworfen: 'verworfen',
gescheitert: 'gescheitert',
})
const CUSTOMER = 'kunde' // Autor einer eingegangenen Nachricht (Übersicht §4.10)
const MENSCH = 'mensch:' // Autor einer Nachricht, die ein Mitarbeiter abgeschickt hat: mensch:<userId>
const UNTITLED = 'Interessent'
const UNKNOWN_AUTHOR = 'Unbekannt'
const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
const text = (v) => (typeof v === 'string' ? v.trim() : '')
// Anzeigename aus einer Tabelle; nur eigene Einträge zählen ('constructor' oder 'toString' sind keine Namen)
const named = (table, key) => (typeof key === 'string' && Object.hasOwn(table, key) ? table[key] : null)
/** Band zum Score der Bewertung: ab 80 hoch, ab 60 mittel (die Schwelle für eine Antwort), darunter niedrig; ohne Zahl leer. */
function scoreBand(score) {
if (typeof score !== 'number' || !Number.isFinite(score)) return ''
if (score >= 80) return 'hoch'
if (score >= 60) return 'mittel'
return 'niedrig'
}
// Name zum Autor einer Nachricht: Mitarbeiter über die Mitarbeiterliste, Worker über das Rollen-Register, eingegangene Nachricht = Interessent
function authorName(author, employees) {
if (typeof author !== 'string' || author === '') return UNKNOWN_AUTHOR
if (author === CUSTOMER) return UNTITLED
if (author.startsWith(MENSCH)) return employeeName(author, employees)
return ROLES.find((role) => role.workerId === author)?.name ?? author
}
function factsOf(prospect, nowMs) {
const place = [text(prospect.postalCode), text(prospect.city)].filter(Boolean).join(' ')
const source = text(prospect.source)
const status = text(prospect.status)
return [
{ label: 'Quelle', value: named(SOURCE_NAMES, source) ?? source },
{ label: 'Gefunden', value: ageLabel(prospect.foundAt, nowMs) },
{ label: 'Ort', value: place },
{ label: 'Status', value: named(STATUS_NAMES, status) ?? status },
].filter((fact) => fact.value !== '')
}
function previewLineOf(preview, nowMs) {
if (!isRecord(preview)) return ''
const status = text(preview.status)
const age = ageLabel(preview.createdAt, nowMs)
const parts = [
named(PREVIEW_STATUS_NAMES, status) ?? status,
Number.isInteger(preview.round) && preview.round > 0 ? `Runde ${preview.round}` : '',
age ? `erstellt ${age}` : '',
text(preview.convertedProjectId) ? 'als Projekt übernommen' : '',
].filter(Boolean)
return parts.length > 0 ? `Vorschau: ${parts.join(' · ')}` : 'Vorschau vorhanden'
}
/**
* Ansicht des Panels. `detail` ist die Antwort von GET /prospect/:id (kaputte Teile ergeben leere Felder, nie einen Fehler),
* `nowMs` die Uhrzeit für „vor 3 Std.“ (Standard: jetzt), `employees` die Mitarbeiterliste ({ userId, displayName }) für die Namen der Absender.
* score.band ist hoch (ab 80), mittel (60 bis 79), niedrig (darunter) oder '' ohne Score; sourceHref ist null, wenn die Adresse
* fehlt oder nicht sicher zu öffnen ist (nur https ohne Zugangsdaten).
*/
export function prospectView(detail, { nowMs = Date.now(), employees } = {}) {
const prospect = isRecord(detail) && isRecord(detail.prospect) ? detail.prospect : {}
const messages = isRecord(detail) && Array.isArray(detail.messages) ? detail.messages : []
const preview = isRecord(detail) ? detail.preview : null
const requestText = typeof prospect.requestText === 'string' && prospect.requestText.trim() !== '' ? prospect.requestText : ''
const score = typeof prospect.score === 'number' && Number.isFinite(prospect.score) ? prospect.score : null
return {
title: text(prospect.name) || UNTITLED,
facts: factsOf(prospect, nowMs),
score: { value: score, band: scoreBand(score), reason: text(prospect.scoreReason) },
requestText,
sourceHref: safeExternalUrl(prospect.sourceUrl),
messages: messages.filter(isRecord).map((m) => ({
id: typeof m.$id === 'string' ? m.$id : '',
who: authorName(m.author, employees),
when: ageLabel(m.sentAt || m.createdAt, nowMs),
body: typeof m.body === 'string' ? m.body : '',
})),
previewLine: previewLineOf(preview, nowMs),
}
}
+14
View File
@@ -199,6 +199,20 @@
.wk-pult-row .wk-pult-draft { display: block; width: 100%; min-height: 140px; margin: 0 0 12px; }
.wk-pult-row .wk-pult-actions { align-items: center; gap: 8px 16px; }
/* ---------- Interessenten-Panel (Dialog) ---------- */
/* Fremdtext (Anfrage, Nachrichten): Zeilenumbrüche des Absenders bleiben, lange Wörter und Adressen brechen um */
.wk-pult-text { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
.wk-prospect { display: flex; flex-direction: column; gap: 16px; min-width: 0; }
.wk-prospect-score { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 12px; overflow-wrap: anywhere; }
.wk-prospect-section { display: flex; flex-direction: column; gap: 8px; min-width: 0; padding-top: 16px; border-top: 1px solid var(--line); }
.wk-prospect-section p { margin: 0; }
.wk-prospect-title { font-size: 13px; line-height: 20px; font-weight: 600; color: var(--text-2); }
.wk-prospect-msgs { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 12px; }
.wk-prospect-msg { min-width: 0; }
.wk-prospect-msg-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 12px; margin-bottom: 4px; font-size: 13px; color: var(--text-2); }
/* ---------- Schmale Bildschirme ---------- */
@media (max-width: 1100px) {