workers: Reiter Bot-Leads mit Filter, Grund, Telefon und Quelle

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-20 10:20:51 +00:00
1 parent d1b7eddd10
commit 3d830ced38
12 files changed
+806 -5

No files matched your search

+143
View File
@@ -0,0 +1,143 @@
import { useEffect, useRef, useState } from 'react'
import EmptyState from '../ui/EmptyState'
import { ROOM_FILTERS, botleadRow, pageInfo } from '../../lib/botleadsView'
const SEARCH_DELAY_MS = 300 // so lange wartet die Suche nach dem letzten Tastendruck, bevor sie den Dienst fragt
const SEARCH_MAX = 100 // der Dienst kürzt die Suche auf 100 Zeichen
const UNKNOWN_ERROR = 'Unbekannter Fehler'
const COLUMNS = ['Betrieb', 'Raum', 'Punkte', 'Grund', 'Telefon', 'Quelle', 'Gefunden']
const START_VIEW = Object.freeze({ room: '', q: '', page: 1 }) // Raumfilter, geltende Suche, Seite: ein Wert, damit eine Änderung genau eine Anfrage auslöst
// Reiter „Bot-Leads“: Betriebe, die nicht angeschrieben werden (z. B. keine veröffentlichte Mail), mit Grund. Reine Liste, nichts wird gesendet oder angerufen.
// Die Liste holt sich ihre Daten bei jeder Änderung von Raum, Suche, Seite oder bot selbst; es gibt kein Polling. Nur die jüngste Anfrage zählt: eine späte
// Antwort (auch ein später Fehler) einer älteren Anfrage oder nach dem Abbau wird verworfen.
// bot (aus ?bot= der Adresse, Link aus dem Ereignisstrom): zeigt genau diesen Eintrag, hervorgehoben, mit „Alle anzeigen“ (onClearBot nimmt bot aus der Adresse).
// Alle Texte der Zeilen sind Textknoten; Links entstehen nur aus row.sourceHref (botleadRow prüft die Adresse mit safeHref: nur http und https, keine Zugangsdaten).
export default function BotLeadList({ api, bot, onClearBot }) {
const botId = typeof bot === 'string' ? bot : ''
const single = botId !== ''
const [view, setView] = useState(START_VIEW)
const [draft, setDraft] = useState('') // Inhalt des Suchfelds; in view.q steht erst die Suche, die nach der Verzögerung gilt
const [result, setResult] = useState({ key: null, items: [], total: 0, pageSize: undefined, error: null })
const timerRef = useRef(null)
const seqRef = useRef(0)
const key = JSON.stringify(single ? [botId] : [view.room, view.q, view.page])
useEffect(() => {
seqRef.current += 1
const mine = seqRef.current
// Ein früheres Ergebnis zu genau diesem Schlüssel (hin und zurück gewechselt, bevor der andere Schlüssel antwortete) ist veraltet: erst die neue Antwort zählt
setResult((prev) => (prev.key === key ? { ...prev, key: null } : prev))
const load = async () => {
try {
const data = await api.botleads(single ? { id: botId } : { room: view.room, q: view.q, page: view.page })
if (mine !== seqRef.current) return
const items = Array.isArray(data?.items) ? data.items : []
const total = Number.isFinite(data?.total) ? data.total : items.length
// Die Liste ist inzwischen kürzer (Kürzen über Nacht): statt einer leeren Seite auf die letzte Seite springen
const { pages } = pageInfo({ total, page: view.page, pageSize: data?.pageSize })
if (!single && items.length === 0 && total > 0 && view.page > pages) {
setView((v) => ({ ...v, page: pages }))
return
}
setResult({ key, items, total, pageSize: data?.pageSize, error: null })
} catch (err) {
if (mine !== seqRef.current) return
setResult({ key, items: [], total: 0, pageSize: undefined, error: err?.message || UNKNOWN_ERROR })
}
}
load()
return () => { seqRef.current += 1 } // eine noch offene Anfrage zählt nicht mehr
}, [api, key])
useEffect(() => () => window.clearTimeout(timerRef.current), [])
const onRoom = (e) => {
const room = e.target.value
setView((v) => ({ ...v, room, page: 1 }))
}
const onSearch = (e) => {
const value = e.target.value
setDraft(value)
window.clearTimeout(timerRef.current)
timerRef.current = window.setTimeout(() => setView((v) => ({ ...v, q: value.trim(), page: 1 })), SEARCH_DELAY_MS)
}
const loading = result.key !== key
const rows = result.items.map((item) => botleadRow(item))
const info = pageInfo({ total: result.total, page: view.page, pageSize: result.pageSize })
const filtered = view.room !== '' || view.q !== ''
let body
if (loading) {
body = <div className="loading" role="status">Wird geladen …</div>
} else if (result.error) {
body = <div className="msg err" role="alert">{result.error}</div>
} else if (rows.length === 0) {
if (single) body = <EmptyState title="Dieser Eintrag ist nicht (mehr) in der Liste." hint="Er wurde gekürzt oder ist kein Bot-Lead." />
else if (filtered) body = <EmptyState title="Keine Treffer." hint="Für diesen Raum und diese Suche gibt es keine Bot-Leads." />
else body = <EmptyState title="Noch keine Bot-Leads." hint="Betriebe, die nicht angeschrieben werden, erscheinen hier mit ihrem Grund." />
} else {
body = (
<div className="wk-bl-scroll" role="region" aria-label="Bot-Leads" tabIndex={0}>
<table className="table wk-bl-table">
<thead>
<tr>{COLUMNS.map((name) => <th key={name} scope="col">{name}</th>)}</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.id} className={row.gekuerzt ? 'wk-bl-cut' : undefined} aria-current={row.id === botId ? 'true' : undefined}>
<td>
<div className="cell-title">{row.title}{row.gekuerzt && <span className="wk-bl-tag">gekürzt</span>}</div>
{row.place && <div className="cell-sub">{row.place}</div>}
</td>
<td>{row.roomLabel}</td>
<td className="wk-bl-nowrap">{row.scoreLabel}</td>
<td>{row.reason}</td>
<td>{row.phone}</td>
<td className="wk-bl-nowrap">
{row.sourceHref
? <a href={row.sourceHref} target="_blank" rel="noopener noreferrer" aria-label={`Quelle öffnen: ${row.title}`}>Quelle öffnen</a>
: '–'}
</td>
<td className="wk-bl-nowrap">{row.found}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
return (
<div className="wk-bl">
<p className="wk-hint">Reine Liste: keine Anrufe, kein Mailversand.</p>
{single ? (
<div className="toolbar wk-bl-single">
<span className="wk-hint">Einzelner Eintrag aus dem Ereignisstrom</span>
<button type="button" className="btn" onClick={onClearBot}>Alle anzeigen</button>
</div>
) : (
<div className="toolbar">
<select className="select" aria-label="Raum" value={view.room} onChange={onRoom}>
{ROOM_FILTERS.map((f) => <option key={f.value} value={f.value}>{f.label}</option>)}
</select>
<input className="input search" type="search" aria-label="Betriebe suchen" placeholder="Name, Ort oder Grund" maxLength={SEARCH_MAX} value={draft} onChange={onSearch} />
</div>
)}
{body}
{!single && !loading && !result.error && result.total > 0 && (
<div className="table-foot">
<span>{info.label}</span>
{info.pages > 1 && (
<span className="wk-bl-pager">
<button type="button" className="btn" disabled={!info.hasPrev} onClick={() => setView((v) => ({ ...v, page: v.page - 1 }))}>Zurück</button>
<button type="button" className="btn" disabled={!info.hasNext} onClick={() => setView((v) => ({ ...v, page: v.page + 1 }))}>Weiter</button>
</span>
)}
</div>
)}
</div>
)
}