Workers P3: Interessenten-Panel im Pult
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f8f8df2cbd
commit
686db61e1c
5 files changed
+701
-1
No files matched your search
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user