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) {
+432
View File
@@ -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 <b>fett</b>' })).requestText, 'Zeile 1\n Zeile 2 <b>fett</b>')
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:<id>" → 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 <i>X</i>', scoreReason: 'Passt <script>alert(1)</script>', requestText: 'Suche Webdesigner <img src=x onerror=alert(1)>\nZeile 2', phone: '0631 123' },
{ messages: [{ $id: 'm1', direction: 'in', author: 'kunde', body: 'Hallo <b>du</b>', 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, /<dl class="kv">/)
for (const t of ['<dt>Quelle</dt><dd>Kleinanzeigen</dd>', '<dt>Gefunden</dt><dd>vor 3 Std.</dd>', '<dt>Ort</dt><dd>66640 Namborn</dd>', '<dt>Status</dt><dd>Bewertet</dd>']) assert.ok(html.includes(t), t)
assert.ok(html.includes('Score 72')); assert.ok(html.includes('mittel')); assert.ok(html.includes('Passt &lt;script&gt;alert(1)&lt;/script&gt;'))
// Anfragetext und Nachrichten: Zeilenumbruch bleibt im Text (pre-wrap), Markup nie
assert.match(html, /<p class="wk-pult-text">Suche Webdesigner &lt;img src=x onerror=alert\(1\)&gt;\nZeile 2<\/p>/)
assert.ok(html.includes('Hallo &lt;b&gt;du&lt;/b&gt;')); assert.ok(html.includes('Clara')); assert.ok(html.includes('Interessent')); assert.ok(html.includes('vor 10 Min.'))
assert.ok(!html.includes('<script>') && !html.includes('<img') && !html.includes('<b>du</b>') && !html.includes('<i>X</i>'))
assert.match(html, /<a class="link" href="https:\/\/www\.kleinanzeigen\.de\/s-anzeige\/x\/1-2-3" target="_blank" rel="noopener noreferrer">Anfrage öffnen<\/a>/)
assert.ok(html.includes('Vorschau: wird gebaut · Runde 1 · erstellt vor 1 Std.')); assert.ok(!html.includes('0631'))
})
test('ProspectBody (gerendert): Leerzustände ohne Score, Anfragetext, Nachrichten, Vorschau und Adresse', async () => {
const { render } = await loadBody()
const html = render(view(detailOf({ score: undefined, scoreReason: undefined, requestText: undefined, sourceUrl: 'javascript:alert(1)' })))
assert.ok(html.includes('Noch nicht bewertet')); assert.ok(html.includes('Kein Anfragetext gespeichert.')); assert.ok(html.includes('Noch keine Nachrichten.'))
assert.ok(!html.includes('Anfrage öffnen')); assert.ok(!html.includes('href=')); assert.ok(!html.includes('Vorschau'))
})
// ---- ProspectPanel läuft wirklich: Mini-Läufer für Hooks statt React (useState, useEffect), JSX liefert einfache { type, props }-Bäume ----
const FAKE_REACT = `
let active = null
let late = 0
export const Fragment = Symbol.for('Fragment')
export const jsx = (type, props) => ({ type, props })
export const jsxs = jsx
export const lateUpdates = () => late
export function __mount(component, getProps) {
const run = { hooks: [], index: 0, effects: [], dirty: false, rendering: false, unmounted: false, tree: null }
run.render = () => {
if (run.rendering) { run.dirty = true; return }
run.rendering = true
do {
run.dirty = false
run.index = 0
active = run
run.tree = component(getProps())
active = null
for (const effect of run.effects.splice(0)) effect()
} while (run.dirty)
run.rendering = false
}
run.render()
return {
tree: () => run.tree,
rerender: () => run.render(),
unmount: () => {
run.unmounted = true
for (const h of run.hooks) if (h && typeof h.cleanup === 'function') h.cleanup()
},
}
}
export function useState(initial) {
const run = active
const i = run.index++
if (!run.hooks[i]) run.hooks[i] = { value: typeof initial === 'function' ? initial() : initial }
const slot = run.hooks[i]
return [slot.value, (next) => {
if (run.unmounted) { late += 1; return }
const value = typeof next === 'function' ? next(slot.value) : next
if (Object.is(value, slot.value)) return
slot.value = value
run.render()
}]
}
export function useEffect(fn, deps) {
const run = active
const i = run.index++
if (!run.hooks[i]) run.hooks[i] = { deps: undefined, cleanup: undefined }
const slot = run.hooks[i]
if (slot.deps && deps && deps.every((d, k) => Object.is(d, slot.deps[k]))) return
run.effects.push(() => {
if (typeof slot.cleanup === 'function') slot.cleanup()
slot.deps = deps
slot.cleanup = fn()
})
}
`
// Dialog, Button, Badge und die Mitarbeiterliste sind Stellvertreter: der Baum zeigt, was das Panel ihnen übergibt
const PANEL_STUBS = {
Dialog: 'export default function Dialog() {}',
Button: 'export default function Button() {}',
Badge: 'export default function Badge() {}',
useEmployees: 'export function useEmployees() { return { employees: globalThis.__panelEmployees ?? [], loading: globalThis.__panelEmployeesLoading === true } }',
}
async function loadPanel() {
const out = await esbuild.build({
stdin: {
contents: "export { default as ProspectPanel, ProspectBody } from './src/components/workers/ProspectPanel.jsx'\nexport { __mount, lateUpdates } from 'react'",
resolveDir: ROOT, loader: 'js',
},
bundle: true, format: 'esm', write: false, platform: 'neutral', jsx: 'automatic', loader: { '.jsx': 'jsx' },
plugins: [{
name: 'panel-fakes',
setup(build) {
build.onResolve({ filter: /^react(\/jsx-runtime)?$/ }, () => ({ path: 'react', namespace: 'panel-fake' }))
build.onResolve({ filter: /ui\/(Dialog|Button|Badge)$|hooks\/useEmployees$/ }, (a) => ({ path: a.path.split('/').pop(), namespace: 'panel-stub' }))
build.onLoad({ filter: /.*/, namespace: 'panel-fake' }, () => ({ contents: FAKE_REACT, loader: 'js' }))
build.onLoad({ filter: /.*/, namespace: 'panel-stub' }, (a) => ({ contents: PANEL_STUBS[a.path], loader: 'js' }))
},
}],
})
return import(`data:text/javascript;base64,${Buffer.from(out.outputFiles[0].text).toString('base64')}`)
}
const settle = () => new Promise((resolve) => setImmediate(resolve))
// Aufrufe von api.prospect, die der Test von Hand beantwortet
function fakeApi() {
const calls = []
return { calls, prospect: (id) => new Promise((resolve, reject) => calls.push({ id, resolve, reject })) }
}
test('ProspectPanel (läuft): Ladezustand, dann Interessent mit Namen aus der Mitarbeiterliste; Titel ist sein Name', async () => {
const { ProspectPanel, ProspectBody, __mount } = await loadPanel()
try {
const api = fakeApi()
const closed = []
const panel = __mount(ProspectPanel, () => ({ api, prospectId: 'p1', onClose: () => closed.push('zu') }))
assert.deepEqual(api.calls.map((c) => c.id), ['p1'])
let root = panel.tree()
assert.equal(root.props.title, 'Interessent'); assert.equal(root.props.wide, true); assert.equal(root.props.children.props.className, 'loading')
// der Schließen-Knopf im Fuß und das × des Dialogs rufen dasselbe onClose
assert.equal(root.props.onClose, root.props.footer.props.onClick); root.props.footer.props.onClick(); assert.deepEqual(closed, ['zu'])
globalThis.__panelEmployees = [{ userId: 'u2', displayName: 'Kenso' }]
api.calls[0].resolve(detailOf({ name: 'Verein X' }, { messages: [{ $id: 'm1', author: 'mensch:u2', body: 'Gesendet', createdAt: iso(-5 * MIN) }] }))
await settle(); panel.rerender()
root = panel.tree()
assert.equal(root.props.title, 'Verein X'); assert.equal(root.props.children.type, ProspectBody)
const shown = root.props.children.props.view
assert.deepEqual([shown.title, shown.score.band, shown.messages[0].who], ['Verein X', 'mittel', 'Kenso'])
panel.unmount()
} finally { delete globalThis.__panelEmployees }
})
test('ProspectPanel (läuft): solange die Mitarbeiterliste lädt, bleibt der Ladezustand (kein "Kollege" für alle)', async () => {
const { ProspectPanel, ProspectBody, __mount } = await loadPanel()
try {
globalThis.__panelEmployeesLoading = true
const api = fakeApi()
const panel = __mount(ProspectPanel, () => ({ api, prospectId: 'p1', onClose() {} }))
api.calls[0].resolve(detailOf()); await settle(); panel.rerender()
assert.equal(panel.tree().props.children.props.className, 'loading')
globalThis.__panelEmployeesLoading = false; panel.rerender()
assert.equal(panel.tree().props.children.type, ProspectBody)
panel.unmount()
} finally { delete globalThis.__panelEmployeesLoading }
})
test('ProspectPanel (läuft): Fehler des Dienstes als Satz mit "Neu laden"; kaputte Antwort ergibt die Standardmeldung; Wiederholung lädt neu', async () => {
const { ProspectPanel, ProspectBody, __mount } = await loadPanel()
const api = fakeApi()
const panel = __mount(ProspectPanel, () => ({ api, prospectId: 'p1', onClose() {} }))
const errorOf = () => {
const [message, action] = panel.tree().props.children.props.children
return { message, retry: action.props.children }
}
api.calls[0].reject(Object.assign(new Error('Interessent nicht gefunden.'), { status: 404 })); await settle()
let { message, retry } = errorOf()
assert.deepEqual([message.props.className, message.props.role, message.props.children], ['msg err', 'alert', 'Interessent nicht gefunden.'])
assert.deepEqual([retry.props.type, retry.props.children], ['button', 'Neu laden'])
retry.props.onClick() // Wiederholung: neue Abfrage, dazwischen wieder der Ladezustand
assert.equal(api.calls.length, 2); assert.equal(panel.tree().props.children.props.className, 'loading')
api.calls[1].resolve({ prospect: null }); await settle() // Antwort ohne Interessent
;({ message, retry } = errorOf())
assert.equal(message.props.children, 'Der Hustler-Dienst antwortet nicht richtig.')
retry.props.onClick(); api.calls[2].reject(new Error('')); await settle() // Fehler ohne Satz
assert.equal(errorOf().message.props.children, 'Der Hustler-Dienst antwortet nicht richtig.')
errorOf().retry.props.onClick(); api.calls[3].resolve(detailOf({ name: 'Jetzt da' })); await settle(); panel.rerender()
assert.equal(panel.tree().props.children.type, ProspectBody); assert.equal(panel.tree().props.title, 'Jetzt da')
panel.unmount()
})
test('ProspectPanel (läuft): späte Antworten nach dem Schließen oder nach einem Wechsel des Interessenten zählen nicht', async () => {
const { ProspectPanel, __mount, lateUpdates } = await loadPanel()
const api = fakeApi()
let prospectId = 'p1'
const panel = __mount(ProspectPanel, () => ({ api, prospectId, onClose() {} }))
prospectId = 'p2'; panel.rerender()
assert.deepEqual(api.calls.map((c) => c.id), ['p1', 'p2'])
api.calls[1].resolve(detailOf({ name: 'Zweiter' })); await settle(); panel.rerender()
api.calls[0].resolve(detailOf({ name: 'Erster' })); await settle(); panel.rerender() // die ältere Antwort kommt zuletzt
assert.equal(panel.tree().props.title, 'Zweiter')
panel.unmount()
const second = fakeApi()
const closed = __mount(ProspectPanel, () => ({ api: second, prospectId: 'p1', onClose() {} }))
closed.unmount()
second.calls[0].resolve(detailOf()); await settle()
const third = fakeApi()
const failing = __mount(ProspectPanel, () => ({ api: third, prospectId: 'p1', onClose() {} }))
failing.unmount()
third.calls[0].reject(new Error('zu spät')); await settle()
assert.equal(lateUpdates(), 0)
})
// ---- Quelltext: Panel, Einbau in die Liste, Stil ----
test('ProspectPanel: Dialog mit Ladezustand und Fehler, lädt über api.prospect, kein innerHTML, jeder Knopf mit type', () => {
const s = read('src/components/workers/ProspectPanel.jsx')
assert.match(s, /import Dialog from '\.\.\/ui\/Dialog'/); assert.match(s, /<Dialog\b/); assert.match(s, /api\.prospect\(prospectId\)/)
assert.match(s, /prospectView\(/); assert.match(s, /useEmployees\(/); assert.match(s, /export default function ProspectPanel\(\{ api, prospectId, onClose \}\)/)
assert.doesNotMatch(s, /innerHTML/)
for (const t of ['Wird geladen', 'Neu laden', 'Anfrage öffnen', 'Nachrichtenverlauf', 'Schließen']) assert.ok(s.includes(t), t)
assert.match(s, /className="wk-pult-text"/); assert.match(s, /rel="noopener noreferrer"/); assert.match(s, /target="_blank"/)
// die Adresse kommt aus der Ansicht (sicher geprüft), nie roh aus der Antwort
assert.match(s, /href=\{view\.sourceHref\}/); assert.doesNotMatch(s, /sourceUrl/)
// eine späte Antwort nach dem Schließen oder nach einem Wechsel des Interessenten zählt nicht
assert.match(s, /let alive = true/); assert.match(s, /alive = false/)
assert.equal((s.match(/<Button\b/g) || []).length, (s.match(/<Button\b[^>]*type="button"/g) || []).length)
})
test('PultList: "Details" öffnet das Interessenten-Panel, Schließen räumt die Auswahl weg', () => {
const s = read('src/components/workers/PultList.jsx')
assert.match(s, /import ProspectPanel from '\.\/ProspectPanel'/)
assert.match(s, /const \[detailId, setDetailId\] = useState\(null\)/)
assert.match(s, /onClick=\{\(\) => onDetails\(prospect\.\$id\)\}/)
assert.match(s, /\{detailId && <ProspectPanel api=\{api\} prospectId=\{detailId\} onClose=\{\(\) => setDetailId\(null\)\} \/>\}/)
})
test('CSS: wk-pult-text bricht Zeilen wie im Text (pre-wrap) und lange Wörter um; Panel-Klassen sind definiert', () => {
const css = read('src/styles/workers.css').replace(/\/\*[\s\S]*?\*\//g, '')
const rule = (selector) => css.match(new RegExp(`${selector.replace(/[.~]/g, '\\$&')}\\s*\\{([^}]*)\\}`))?.[1] ?? ''
assert.match(rule('.wk-pult-text'), /white-space:\s*pre-wrap/); assert.match(rule('.wk-pult-text'), /overflow-wrap:\s*anywhere/)
const jsx = read('src/components/workers/ProspectPanel.jsx')
for (const m of jsx.matchAll(/(?<![\w-])wk-[a-z0-9-]+/g)) assert.ok(css.includes('.' + m[0]), m[0])
})