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>
)
}
+66
View File
@@ -0,0 +1,66 @@
// Reine Anzeige-Ableitungen des Reiters „Bot-Leads“ der Hustler-Seite: Raumfilter, sichere Quelle, Telefon, Zeilenmodell, Seitenzeile.
// Kein React, kein Appwrite, kein window/document: unter Node testbar (loadModule). Die Daten liefert GET /api/workers/botleads (Übersicht §6.2).
import { dateDe } from './financeFormat'
import { safeUrl } from './financeView'
import { formatCount } from './workersView'
const DASH = '–'
const NOT_AVAILABLE = 'nicht verfügbar'
const NOT_STORED = 'bewusst nicht gespeichert' // Heilberuf: der Dienst speichert und liefert nie eine Nummer
const DEFAULT_PAGE_SIZE = 50 // Rückfall, falls die Antwort des Dienstes keine pageSize nennt
/** Filter nach Raum: value ist der Raum-Schlüssel der API ('' = alle Räume). */
export const ROOM_FILTERS = Object.freeze([
{ value: '', label: 'Alle Räume' },
{ value: 'aerzte', label: 'Ärzte & Praxen' },
{ value: 'handwerk', label: 'Handwerk' },
{ value: 'selbst', label: 'Selbstständige' },
{ value: 'laden', label: 'Läden & Gastro' },
])
const text = (v) => (typeof v === 'string' ? v.trim() : '')
/**
* http(s)-Adresse oder '': schützt vor `javascript:`, `data:` und Ähnlichem aus den Daten des Dienstes. Adressen mit Zugangsdaten
* (https://user:pw@host/, https://betrieb.de@evil.example/) fallen ebenfalls weg, denn sie sähen nach dem Betrieb aus und führten woandershin.
*/
export function safeHref(url) {
const href = safeUrl(url)
if (!href) return ''
const { username, password } = new URL(href)
return username || password ? '' : href
}
/** Telefon-Anzeige: Heilberuf nie, sonst die Nummer nur mit Quelle (ohne Quelle gilt sie als nicht belegt). */
export function phoneLabel(item) {
if (item?.isHealth) return NOT_STORED
const phone = text(item?.phone)
return phone && text(item?.phoneSource) ? phone : NOT_AVAILABLE
}
/** Zeilenmodell eines Eintrags der Liste; fehlende Werte ergeben Platzhalter, nie undefined oder NaN. */
export function botleadRow(item) {
const it = item && typeof item === 'object' ? item : {}
return {
id: typeof it.id === 'string' ? it.id : '',
title: text(it.name) || NOT_AVAILABLE,
place: [text(it.postalCode), text(it.city)].filter(Boolean).join(' '),
roomLabel: text(it.roomLabel) || DASH,
scoreLabel: Number.isFinite(it.score) ? `${Math.round(it.score)} Punkte` : DASH,
reason: text(it.reason) || DASH,
phone: phoneLabel(it),
sourceHref: safeHref(it.sourceUrl),
found: dateDe(it.foundAt), // Berliner Datum TT.MM.JJJJ
gekuerzt: Boolean(it.gekuerzt),
}
}
/** Seitenzeile unter der Liste: Seitenzahl, Blättern und Beschriftung. pageSize kommt aus der Antwort, sonst 50. */
export function pageInfo({ total, page, pageSize } = {}) {
const count = Number.isFinite(total) && total > 0 ? Math.floor(total) : 0
const size = Number.isFinite(pageSize) && pageSize >= 1 ? Math.floor(pageSize) : DEFAULT_PAGE_SIZE
const current = Number.isFinite(page) && page >= 1 ? Math.floor(page) : 1
const pages = Math.max(1, Math.ceil(count / size))
const label = count === 0 ? 'Keine Einträge' : `Seite ${current} von ${pages} · ${formatCount(count)} ${count === 1 ? 'Eintrag' : 'Einträge'}`
return { pages, hasPrev: current > 1, hasNext: current < pages, label }
}
+6 -1
View File
@@ -1,6 +1,7 @@
import { useEffect } from 'react'
import { useSearchParams } from 'react-router-dom'
import { PageHeader, Tabs } from '../components/ui'
import BotLeadList from '../components/workers/BotLeadList'
import PultList from '../components/workers/PultList'
import WorkersBoard from '../components/workers/WorkersBoard'
import { useAuth } from '../context/AuthContext'
@@ -8,20 +9,23 @@ import { workersApi } from '../lib/workersClient'
import { pickTab } from '../lib/workersView'
import '../styles/workers.css'
// Reiter der Seite. Später kommt botleads (P6) dazu; die Reiterleiste erscheint erst ab zwei Reitern.
// Reiter der Seite (Hochhaus, Freigabe von P3, Bot-Leads von P6); die Reiterleiste erscheint erst ab zwei Reitern.
const WORKERS_TABS = [
{ id: 'hochhaus', label: 'Hochhaus' },
{ id: 'freigabe', label: 'Freigabe' },
{ id: 'botleads', label: 'Bot-Leads' },
]
const api = workersApi
// Seite „Hustler“ (Dateiname bleibt WorkersPage): Titel, Reiterwahl über ?tab= und der Inhalt des gewählten Reiters.
// ?tab=botleads&bot=<Id> (Link aus dem Ereignisstrom) zeigt einen einzelnen Bot-Lead; ein Reiterwechsel lässt bot weg.
// Auth und Dienst-API kommen von hier; das Board selbst kennt weder Anmeldung noch Appwrite.
export default function WorkersPage() {
const { isAdmin, user } = useAuth()
const [params, setParams] = useSearchParams()
const active = pickTab(WORKERS_TABS, params.get('tab'))
const bot = params.get('bot') ?? ''
// Tab-Titel nur solange die Seite offen ist; beim Verlassen kommt der vorige Titel zurück
useEffect(() => {
@@ -38,6 +42,7 @@ export default function WorkersPage() {
)}
{active.id === 'hochhaus' && <WorkersBoard api={api} isAdmin={isAdmin} />}
{active.id === 'freigabe' && <PultList api={api} me={{ id: user?.$id, name: user?.name }} />}
{active.id === 'botleads' && <BotLeadList api={api} bot={bot} onClearBot={() => setParams({ tab: 'botleads' })} />}
</div>
)
}
+29
View File
@@ -235,6 +235,32 @@
.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); }
/* ---------- Bot-Leads ---------- */
.wk-bl { min-width: 0; }
.wk-bl > .wk-hint { margin: 0 0 12px; }
.wk-bl .search { max-width: 100%; }
.wk-bl-single { gap: 8px 16px; }
/* Die Tabelle scrollt auf dem Handy waagerecht in ihrem Rahmen, nicht die Seite */
.wk-bl-scroll { max-width: 100%; overflow-x: auto; }
.wk-bl-table { min-width: 760px; }
/* break-word statt anywhere: anywhere ließe die Spalten auf ein Zeichen schrumpfen („Handwer-k“); lange Namen brechen, der Rahmen scrollt */
.wk-bl-table td { overflow-wrap: break-word; }
.wk-bl-nowrap { white-space: nowrap; font-variant-numeric: tabular-nums; }
/* Gekürzte Einträge (Daten nach der Frist entfernt) bleiben als graue Zeile stehen */
.wk-bl-cut td { color: var(--text-3); }
.wk-bl-tag {
display: inline-block;
padding: 0 6px;
border-radius: var(--radius);
font-size: 12px;
line-height: 20px;
font-weight: 500;
background: var(--grey-bg);
color: var(--grey-fg);
}
.wk-bl-pager { display: flex; gap: 8px; }
/* ---------- Schmale Bildschirme ---------- */
@media (max-width: 1100px) {
@@ -263,6 +289,9 @@
Der Dialog hängt nicht unter einer wk-Klasse, die Regel beginnt deshalb mit der Klasse des Knopfs. Mit (0,3,0) (Klasse, Klasse, Attribut) gewinnt sie
gegen (0,2,0), gleich in welcher Reihenfolge die Dateien geladen werden. */
.wk-prospect-close.btn[type="button"] { width: 100%; min-height: 40px; justify-content: center; }
/* Bot-Leads: Blätterknöpfe wie die anderen Knöpfe der Seite mit 36 px, Zeile darf umbrechen */
.wk-bl-pager .btn { min-height: 36px; }
.wk-bl .table-foot { flex-wrap: wrap; gap: 8px 16px; }
}
/* ---------- Weniger Bewegung ---------- */