From 686db61e1cb7c3dd3e3a8b73449886398c7ad438 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 09:59:11 +0000 Subject: [PATCH] Workers P3: Interessenten-Panel im Pult Co-Authored-By: Claude Sonnet 5.5 --- src/components/workers/ProspectPanel.jsx | 131 +++++++ src/components/workers/PultList.jsx | 4 +- src/lib/workersProspect.js | 121 +++++++ src/styles/workers.css | 14 + tests/unit/workersProspect.test.mjs | 432 +++++++++++++++++++++++ 5 files changed, 701 insertions(+), 1 deletion(-) create mode 100644 src/components/workers/ProspectPanel.jsx create mode 100644 src/lib/workersProspect.js create mode 100644 tests/unit/workersProspect.test.mjs 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 ? ( +
    + {view.messages.map((message, index) => ( +
  • +
    + {message.who} + {message.when && {message.when}} +
    +

    {message.body}

    +
  • + ))} +
+ ) : ( +

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}
+
+ +
+ + ) + } 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('