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:
1 parent
d1b7eddd10
commit
3d830ced38
12 files changed
+806
-5
No files matched your search
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 ---------- */
|
||||
|
||||
Reference in new issue
Block a user