diff --git a/src/components/workers/ProspectPanel.jsx b/src/components/workers/ProspectPanel.jsx
new file mode 100644
index 0000000..8c4dc26
--- /dev/null
+++ b/src/components/workers/ProspectPanel.jsx
@@ -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 (
+
+
+ {score.band
+ ? {`Score ${score.value} · ${score.band}`}
+ : Noch nicht bewertet }
+ {score.reason && {score.reason} }
+
+
+ {view.facts.length > 0 && (
+
+ {view.facts.map((fact) => (
+
+ {fact.label}
+ {fact.value}
+
+ ))}
+
+ )}
+
+
+ Anfrage
+ {view.requestText
+ ? {view.requestText}
+ : Kein Anfragetext gespeichert.
}
+ {view.sourceHref && (
+
+ Anfrage öffnen
+
+ )}
+
+
+
+ Nachrichtenverlauf
+ {view.messages.length > 0 ? (
+
+ ) : (
+ Noch keine Nachrichten.
+ )}
+ {view.previewLine && {view.previewLine}
}
+
+
+ )
+}
+
+// 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 = (
+ <>
+ {state.error}
+
+ setAttempt((n) => n + 1)}>Neu laden
+
+ >
+ )
+ } else if (view === null) {
+ body = Wird geladen …
+ } else {
+ body =
+ }
+
+ return (
+ Schließen}
+ >
+ {body}
+
+ )
+}
diff --git a/src/components/workers/PultList.jsx b/src/components/workers/PultList.jsx
index 7f563cc..34c578f 100644
--- a/src/components/workers/PultList.jsx
+++ b/src/components/workers/PultList.jsx
@@ -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 && {finished}
}
{error && rows.length > 0 && Verbindung gestört: {error} Es wird weiter versucht.
}
{body}
+ {detailId && setDetailId(null)} />}
>
)
}
diff --git a/src/lib/workersProspect.js b/src/lib/workersProspect.js
new file mode 100644
index 0000000..55d4216
--- /dev/null
+++ b/src/lib/workersProspect.js
@@ -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:
+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),
+ }
+}
diff --git a/src/styles/workers.css b/src/styles/workers.css
index dae8d73..59bdf39 100644
--- a/src/styles/workers.css
+++ b/src/styles/workers.css
@@ -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) {
diff --git a/tests/unit/workersProspect.test.mjs b/tests/unit/workersProspect.test.mjs
new file mode 100644
index 0000000..9f93f8a
--- /dev/null
+++ b/tests/unit/workersProspect.test.mjs
@@ -0,0 +1,432 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import fs from 'node:fs'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+import esbuild from 'esbuild'
+import { loadModule } from './load.mjs'
+
+// Reine Ansicht des Interessenten-Panels (Wirkung von GET /prospect/:id auf den Bildschirm): Whitelist der Felder, Score-Band,
+// Anzeigenamen, Nachrichtenverlauf, Vorschau-Zeile. Das Panel selbst wird weiter unten mit echtem React gerendert (Text, nie Markup).
+const { prospectView } = await loadModule('src/lib/workersProspect.js')
+
+const NOW = Date.parse('2026-10-19T09:00:00Z')
+const MIN = 60 * 1000
+const HOUR = 60 * MIN
+const iso = (offsetMs) => new Date(NOW + offsetMs).toISOString()
+const read = (p) => fs.readFileSync(new URL('../../' + p, import.meta.url), 'utf8')
+
+const PROSPECT = {
+ $id: 'p1', name: 'Verein X', source: 'kleinanzeigen-alarm', foundAt: '2026-10-19T06:00:00Z', city: 'Namborn', postalCode: '66640',
+ status: 'bewertet', score: 72, scoreReason: 'Passt.', requestText: 'Text', sourceUrl: 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3',
+}
+const detailOf = (prospect = {}, rest = {}) => ({ prospect: { ...PROSPECT, ...prospect }, thread: null, messages: [], preview: null, reply: null, ...rest })
+const view = (detail, employees = []) => prospectView(detail, { nowMs: NOW, employees })
+
+test('Fakten: Quelle, Fundzeit, Ort, Status; keine Telefon-Zeile, auch wenn das Feld mitkäme', () => {
+ const v = prospectView({ prospect: { name: 'Verein X', source: 'kleinanzeigen-alarm', foundAt: '2026-10-19T06:00:00Z', city: 'Namborn', postalCode: '66640', status: 'bewertet', score: 72, scoreReason: 'Passt.', requestText: 'Text', sourceUrl: 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3', phone: '0631 123' }, thread: null, messages: [], preview: null, reply: null }, { nowMs: NOW, employees: [] })
+ assert.deepEqual(v.facts.map((f) => f.label), ['Quelle', 'Gefunden', 'Ort', 'Status'])
+ assert.equal(JSON.stringify(v).includes('0631'), false)
+ assert.deepEqual([v.title, v.score.value, v.score.band, v.sourceHref], ['Verein X', 72, 'mittel', 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3'])
+})
+
+test('Fakten: Werte (Anzeigename der Plattform, Alter, PLZ vor Ort, deutscher Status); Felder ohne Wert entfallen', () => {
+ const v = view(detailOf())
+ assert.deepEqual(v.facts, [
+ { label: 'Quelle', value: 'Kleinanzeigen' },
+ { label: 'Gefunden', value: 'vor 3 Std.' },
+ { label: 'Ort', value: '66640 Namborn' },
+ { label: 'Status', value: 'Bewertet' },
+ ])
+ // nur die Stadt oder nur die PLZ; ohne beides gibt es keine Zeile „Ort“; ohne gültige Fundzeit keine Zeile „Gefunden“
+ assert.deepEqual(view(detailOf({ postalCode: undefined })).facts[2], { label: 'Ort', value: 'Namborn' })
+ assert.deepEqual(view(detailOf({ city: undefined })).facts[2], { label: 'Ort', value: '66640' })
+ assert.deepEqual(view(detailOf({ city: '', postalCode: ' ' })).facts.map((f) => f.label), ['Quelle', 'Gefunden', 'Status'])
+ assert.deepEqual(view(detailOf({ foundAt: 'gestern' })).facts.map((f) => f.label), ['Quelle', 'Ort', 'Status'])
+})
+
+test('Quelle: Anzeigenamen der drei Alarm-Plattformen, sonst der Rohwert (auch bei Namen wie "constructor")', () => {
+ const quelle = (source) => view(detailOf({ source })).facts[0].value
+ assert.equal(quelle('kleinanzeigen-alarm'), 'Kleinanzeigen')
+ assert.equal(quelle('freelancermap-alarm'), 'freelancermap')
+ assert.equal(quelle('freelance-de-alarm'), 'freelance.de')
+ assert.equal(quelle('gutefrage.net'), 'gutefrage.net')
+ assert.equal(quelle('constructor'), 'constructor')
+})
+
+test('Status: deutsche Bezeichnung für alle zwölf Status, unbekannte Werte bleiben stehen', () => {
+ const status = (s) => view(detailOf({ status: s })).facts.find((f) => f.label === 'Status').value
+ assert.deepEqual(
+ ['gefunden', 'bewertet', 'botlead', 'kontaktiert', 'gespraech', 'ja', 'vorschau_bau', 'vorschau_gesendet', 'angebot', 'deal', 'verloren', 'gesperrt'].map(status),
+ ['Gefunden', 'Bewertet', 'Bot-Lead', 'Kontaktiert', 'Im Gespräch', 'Zugesagt', 'Vorschau im Bau', 'Vorschau gesendet', 'Angebot', 'Abschluss', 'Verloren', 'Gesperrt'],
+ )
+ assert.equal(status('neu_erfunden'), 'neu_erfunden')
+ assert.equal(status('toString'), 'toString')
+})
+
+test('Score-Band: ≥ 80 hoch, 60–79 mittel, < 60 niedrig, ohne Score leer', () => {
+ const band = (score) => view(detailOf({ score })).score
+ assert.deepEqual([100, 80].map((s) => band(s).band), ['hoch', 'hoch'])
+ assert.deepEqual([79, 72, 60].map((s) => band(s).band), ['mittel', 'mittel', 'mittel'])
+ assert.deepEqual([59, 1, 0].map((s) => band(s).band), ['niedrig', 'niedrig', 'niedrig'])
+ // ohne brauchbare Zahl: kein Band, kein Wert (ein Text "72" ist keine Zahl des Dienstes)
+ for (const kaputt of [undefined, null, '72', NaN, Infinity, {}]) assert.deepEqual([band(kaputt).band, band(kaputt).value], ['', null], String(kaputt))
+ assert.deepEqual(band(72), { value: 72, band: 'mittel', reason: 'Passt.' })
+ assert.equal(view(detailOf({ scoreReason: undefined })).score.reason, '')
+})
+
+test('Anfragetext und Quelle: Wortlaut unverändert, Adresse nur als https ohne Zugangsdaten', () => {
+ assert.equal(view(detailOf({ requestText: 'Zeile 1\n Zeile 2 fett ' })).requestText, 'Zeile 1\n Zeile 2 fett ')
+ assert.equal(view(detailOf({ requestText: undefined })).requestText, '')
+ assert.equal(view(detailOf({ requestText: ' \n ' })).requestText, '')
+ for (const sourceUrl of ['javascript:alert(1)', 'http://www.kleinanzeigen.de/x', 'https://www.kleinanzeigen.de@evil.example/x', 'data:text/html,hi', '', undefined, 42]) {
+ assert.equal(view(detailOf({ sourceUrl })).sourceHref, null, String(sourceUrl))
+ }
+})
+
+test('Titel: Name des Interessenten, ohne Namen "Interessent"', () => {
+ assert.equal(view(detailOf({ name: ' Verein X ' })).title, 'Verein X')
+ assert.equal(view(detailOf({ name: undefined })).title, 'Interessent')
+ assert.equal(view(detailOf({ name: ' ' })).title, 'Interessent')
+})
+
+test('Whitelist: unbekannte Felder (Telefon, E-Mail-Nachweise, interne Hinweise) tauchen in der Ansicht nicht auf', () => {
+ const v = view(detailOf({ phone: '0631 123', phoneSource: 'https://x.example/tel', email: 'geheim@x.example', emailSource: 'https://x.example/mail', internal: 'INTERN-7', contactName: 'Frau Y' },
+ { thread: { $id: 't1', toEmail: 'thread@x.example' }, reply: { $id: 'r1', draft: 'ENTWURF-9' } }))
+ const text = JSON.stringify(v)
+ for (const leak of ['0631', 'geheim@x.example', 'x.example/tel', 'x.example/mail', 'INTERN-7', 'thread@x.example', 'ENTWURF-9']) assert.equal(text.includes(leak), false, leak)
+ assert.deepEqual(Object.keys(v), ['title', 'facts', 'score', 'requestText', 'sourceHref', 'messages', 'previewLine'])
+})
+
+test('Nachrichten: Autor "mensch:" → Name aus employees, "clara" → Clara; ohne Vorschau leere Zeile', () => {
+ const messages = [
+ { $id: 'm1', direction: 'out', author: 'clara', body: 'Guten Tag', sentAt: iso(-2 * HOUR), createdAt: iso(-2 * HOUR) },
+ { $id: 'm2', direction: 'out', author: 'mensch:u2', body: 'Gesendet', sentAt: iso(-90 * MIN), createdAt: iso(-90 * MIN) },
+ { $id: 'm3', direction: 'in', author: 'kunde', body: 'Ja gern', createdAt: iso(-10 * MIN) },
+ { $id: 'm4', direction: 'out', author: 'mensch:u9', body: 'Unbekannt', sentAt: iso(-5 * MIN), createdAt: iso(-5 * MIN) },
+ { $id: 'm5', direction: 'out', author: 'max', body: 'Mail', createdAt: iso(-1000) },
+ ]
+ const v = view(detailOf({}, { messages }), [{ userId: 'u2', displayName: 'Kenso' }])
+ assert.deepEqual(v.messages, [
+ { id: 'm1', who: 'Clara', when: 'vor 2 Std.', body: 'Guten Tag' },
+ { id: 'm2', who: 'Kenso', when: 'vor 1 Std.', body: 'Gesendet' },
+ { id: 'm3', who: 'Interessent', when: 'vor 10 Min.', body: 'Ja gern' },
+ { id: 'm4', who: 'Kollege', when: 'vor 5 Min.', body: 'Unbekannt' },
+ { id: 'm5', who: 'Max', when: 'gerade eben', body: 'Mail' },
+ ])
+ assert.equal(v.previewLine, '')
+})
+
+test('Nachrichten: Reihenfolge bleibt, fremde Autoren erscheinen als Text, Kaputtes wird übersprungen oder leer', () => {
+ const v = view(detailOf({}, { messages: [
+ { $id: 'a', author: 'info@firma.example', body: 'B1', createdAt: iso(-60 * MIN) },
+ null,
+ 'text',
+ { author: 'toString', body: 'B2' },
+ { $id: 'c', body: 42, createdAt: 'kaputt' },
+ ] }))
+ assert.deepEqual(v.messages, [
+ { id: 'a', who: 'info@firma.example', when: 'vor 1 Std.', body: 'B1' },
+ { id: '', who: 'toString', when: '', body: 'B2' },
+ { id: 'c', who: 'Unbekannt', when: '', body: '' },
+ ])
+ assert.deepEqual(view(detailOf({}, { messages: 'kaputt' })).messages, [])
+ assert.deepEqual(view(detailOf({}, { messages: undefined })).messages, [])
+})
+
+test('Mitarbeiterliste fehlt oder ist kaputt: Namen fallen auf "Kollege" zurück, nichts wirft', () => {
+ const messages = [{ $id: 'm', author: 'mensch:u2', body: 'x', createdAt: iso(-1000) }]
+ for (const employees of [undefined, null, 'kaputt', [null], [{ userId: 'u2' }]]) assert.equal(prospectView(detailOf({}, { messages }), { nowMs: NOW, employees }).messages[0].who, 'Kollege')
+})
+
+test('Vorschau-Zeile: ohne Vorschau leer, sonst Status, Runde und Alter; kein Token, keine Adresse', () => {
+ assert.equal(view(detailOf()).previewLine, '')
+ assert.equal(view(detailOf({}, { preview: undefined })).previewLine, '')
+ assert.equal(view(detailOf({}, { preview: 'kaputt' })).previewLine, '')
+ const preview = { $id: 'v1', prospectId: 'p1', builder: 'bob', status: 'freigabe', round: 2, createdAt: iso(-3 * HOUR), deleteAfter: iso(60 * HOUR), token: 'GEHEIM-TOKEN' }
+ assert.equal(view(detailOf({}, { preview })).previewLine, 'Vorschau: wartet auf Freigabe · Runde 2 · erstellt vor 3 Std.')
+ assert.equal(view(detailOf({}, { preview: { ...preview, status: 'gesendet', round: 1, convertedProjectId: 'proj1' } })).previewLine, 'Vorschau: gesendet · Runde 1 · erstellt vor 3 Std. · als Projekt übernommen')
+ assert.equal(view(detailOf({}, { preview: { status: 'sonstwas' } })).previewLine, 'Vorschau: sonstwas')
+ assert.equal(view(detailOf({}, { preview: {} })).previewLine, 'Vorschau vorhanden')
+ assert.equal(JSON.stringify(view(detailOf({}, { preview }))).includes('GEHEIM-TOKEN'), false)
+})
+
+test('Vorschau-Status: alle acht Status haben einen deutschen Text', () => {
+ const line = (status) => view(detailOf({}, { preview: { status } })).previewLine
+ assert.deepEqual(
+ ['bau', 'pruefung', 'zurueck', 'fertig', 'freigabe', 'gesendet', 'verworfen', 'gescheitert'].map(line),
+ ['Vorschau: wird gebaut', 'Vorschau: wird geprüft', 'Vorschau: geht zurück an den Bau', 'Vorschau: fertig', 'Vorschau: wartet auf Freigabe', 'Vorschau: gesendet', 'Vorschau: verworfen', 'Vorschau: gescheitert'],
+ )
+})
+
+test('Kaputte Antwort: ohne Interessent kommt eine leere, aber vollständige Ansicht', () => {
+ for (const detail of [{}, { prospect: null }, { prospect: 'x' }, null, undefined]) {
+ const v = view(detail)
+ assert.deepEqual([v.title, v.facts, v.score, v.requestText, v.sourceHref, v.messages, v.previewLine], ['Interessent', [], { value: null, band: '', reason: '' }, '', null, [], ''])
+ }
+})
+
+// ---- Das Panel wird mit echtem React gerendert (renderToStaticMarkup): Fremdtext erscheint nur als Text, Links tragen rel ----
+
+const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
+
+async function loadBody() {
+ const out = await esbuild.build({
+ stdin: {
+ contents: "import { createElement } from 'react'\nimport { renderToStaticMarkup } from 'react-dom/server'\nimport { ProspectBody } from './src/components/workers/ProspectPanel.jsx'\nexport const render = (view) => renderToStaticMarkup(createElement(ProspectBody, { view }))",
+ resolveDir: ROOT, loader: 'js',
+ },
+ bundle: true, format: 'esm', platform: 'node', write: false, jsx: 'automatic', loader: { '.jsx': 'jsx' },
+ // die Mitarbeiterliste käme aus Appwrite: für den Aufbau der Datei nur ein Stellvertreter
+ plugins: [{
+ name: 'prospect-stubs',
+ setup(build) {
+ build.onResolve({ filter: /hooks\/useEmployees$/ }, () => ({ path: 'useEmployees', namespace: 'prospect-stub' }))
+ build.onLoad({ filter: /.*/, namespace: 'prospect-stub' }, () => ({ contents: 'export function useEmployees() { return { employees: [], loading: false } }', loader: 'js' }))
+ },
+ }],
+ banner: { js: "import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(process.cwd() + '/');" },
+ })
+ return import(`data:text/javascript;base64,${Buffer.from(out.outputFiles[0].text).toString('base64')}`)
+}
+
+test('ProspectBody (gerendert): Fakten als .kv, Score und Begründung, Anfragetext und Nachrichten nur als Text, Link mit rel', async () => {
+ const { render } = await loadBody()
+ const v = view(
+ detailOf({ name: 'Verein X ', scoreReason: 'Passt ', requestText: 'Suche Webdesigner \nZeile 2', phone: '0631 123' },
+ { messages: [{ $id: 'm1', direction: 'in', author: 'kunde', body: 'Hallo du ', createdAt: iso(-10 * MIN) }, { $id: 'm2', direction: 'out', author: 'clara', body: 'Antwort', createdAt: iso(-5 * MIN) }], preview: { status: 'bau', round: 1, createdAt: iso(-HOUR) } }),
+ )
+ const html = render(v)
+ assert.match(html, //)
+ for (const t of ['Quelle Kleinanzeigen ', 'Gefunden vor 3 Std. ', 'Ort 66640 Namborn ', 'Status Bewertet ']) assert.ok(html.includes(t), t)
+ assert.ok(html.includes('Score 72')); assert.ok(html.includes('mittel')); assert.ok(html.includes('Passt <script>alert(1)</script>'))
+ // Anfragetext und Nachrichten: Zeilenumbruch bleibt im Text (pre-wrap), Markup nie
+ assert.match(html, /Suche Webdesigner <img src=x onerror=alert\(1\)>\nZeile 2<\/p>/)
+ assert.ok(html.includes('Hallo <b>du</b>')); assert.ok(html.includes('Clara')); assert.ok(html.includes('Interessent')); assert.ok(html.includes('vor 10 Min.'))
+ assert.ok(!html.includes('