Files
tickte-system/src/components/workers/BotLeadList.jsx
T
ClaudeandClaude Sonnet 5.5 1ab760be0c Hustler-Keller: unter Stufe 6 leeres Register ohne Abruf, mit ehrlichem Text
BotLeadList bekommt die Stufe (WorkersPage reicht sie durch). Unterhalb von BOTLEADS_AB
(Stufe 6) ruft sie weder api.botleads noch ein JWT, zeigt keine Ladeanzeige und keinen
Fehlerzustand: Szene mit Arne, Register-Buch, Reiter und Suche stehen da, das Register ist leer
("Noch keine Bot-Leads. Hier legt Arne Betriebe ohne Website ab, die Bea nicht fuer eine Mail
ausgewaehlt hat. Die Branchen-Raeume ... suchen noch nicht."), ohne Zahl am Reiter. Reiter,
Suche, Zuklappen und Alle anzeigen bleiben bedienbar und loesen nie einen Abruf aus. Ab Stufe 6
unveraendert (Live-Daten); der Test-Laeufer startet den Keller standardmaessig mit Stufe 6.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 12:23:03 +00:00

190 lines
12 KiB
React

import { useEffect, useRef, useState } from 'react'
import Akte from './keller/Akte'
import KellerSzene from './keller/KellerSzene'
import RegisterBuch from './keller/RegisterBuch'
import { pageInfo } from '../../lib/botleadsView'
import { eintragModell } from '../../lib/hustlerKeller'
import { HUSTLER_STUFE, botleadsAbrufbar } from '../../lib/hustlerStufe'
import { formatCount } from '../../lib/workersView'
import { restoreFocus } from '../../lib/workersFocus'
const SEARCH_DELAY_MS = 300 // so lange wartet die Suche nach dem letzten Tastendruck, bevor sie den Dienst fragt
const UNKNOWN_ERROR = 'Unbekannter Fehler'
const START_VIEW = Object.freeze({ room: '', q: '', page: 1 }) // Raumfilter, geltende Suche, Seite: ein Wert, damit eine Änderung genau eine Anfrage auslöst
const browserDocument = () => (typeof document === 'undefined' ? null : document) // unter Node (Tests, Server) gibt es keins, dann bleibt der Fokus unangetastet
// Bildschirm „Keller“ (Hustler-Pixelwelt T10, Spec E12/E13; Dateiname bleibt BotLeadList): die Archiv-Szene mit Arne (keller/KellerSzene), davor Arnes Register-Buch
// (keller/RegisterBuch) mit den Betrieben, die nicht angeschrieben werden, samt Grund, und die Akte zu einem Eintrag (keller/Akte). Reine Liste, nichts wird gesendet
// oder angerufen. Props: api, stufe (Standard HUSTLER_STUFE), bot, onClearBot, kpis (botLeads für die Zahl am Schild), workers (Hustler-Tabelle der Seite: Arnes Status),
// paused (Hustler angehalten: die Szene steht still, Klasse hk-steht), onBack (◂ Hochhaus). Beim Einhängen nimmt die Überschrift „Bot-Leads“ (tabIndex -1) den Fokus,
// ohne dass die Seite springt (T13 M2).
// Stufen-Sperre (hustlerStufe.js): unterhalb von Stufe 6 (BOTLEADS_AB) gibt es GET /botleads im Dienst noch nicht. Der Keller steht dann trotzdem da (Arne, Regale, Register-Buch,
// Reiter, Suche), das Register ist leer und sagt ehrlich warum (RegisterBuch, ohneDienst); es gibt keinen Abruf (auch kein JWT), keine Ladeanzeige, keinen Fehler. Reiter und Suche
// bleiben bedienbar und ändern nur die Anzeige (es gibt nichts zu filtern); die Bedienung entspricht sonst dem Keller mit Daten, also ohne Fokusfalle.
// 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. Scheitert eine Anfrage, steht der Satz des Dienstes mit „Erneut versuchen“,
// das genau diese Anfrage (Raum, Suche, Seite oder Eintrag) noch einmal stellt; Raum und Suche gehen weiterhin.
// 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), und zwar
// nur in der Akte. Kein CSS-Import (WorkersPage hängt die Stile ein), keine Bilder, kein HTML-Einschub.
export default function BotLeadList({ api, stufe = HUSTLER_STUFE, bot, onClearBot, kpis, workers, paused = false, onBack }) {
const abruf = botleadsAbrufbar(stufe)
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 [attempt, setAttempt] = useState(0) // zählt „Erneut versuchen“: gehört zum Schlüssel, damit dieselbe Anfrage noch einmal läuft und „Wird geladen“ zeigt
const [zu, setZu] = useState(false) // „Register zuklappen“: nur die Szene bleibt
const [akte, setAkte] = useState(null) // Nr. des Eintrags in der gezeigten Seite, dessen Akte offen ist
const timerRef = useRef(null)
const seqRef = useRef(0)
const h1Ref = useRef(null) // Überschrift „Bot-Leads“: nimmt beim Einhängen den Fokus (Bildschirmwechsel)
// Tastaturfokus: Wer „Alle anzeigen“ oder ◂/▸ drückt, verliert den Knopf mit dem Fokus (Einzelansicht und Seitenzeile werden ausgetauscht oder
// hängen beim Laden aus); ohne Zutun fiele der Fokus auf <body>, an den Seitenanfang. Gemerkt wird nur, was der Nutzer als Letztes bedient hat.
const reiterRef = useRef(null) // aktiver Raum-Reiter: Ziel nach „Alle anzeigen“
const clearedRef = useRef(false) // „Alle anzeigen“ gedrückt: erst dann (nie schon beim Einhängen) bekommt der Reiter den Fokus
const prevRef = useRef(null)
const nextRef = useRef(null)
const pagedRef = useRef('') // 'prev' oder 'next': welcher Blätterknopf zuletzt gedrückt wurde; nach dem Laden bekommt die Seitenzeile den Fokus zurück (auch nach „Erneut versuchen“)
const key = JSON.stringify(single ? [botId, attempt] : [view.room, view.q, view.page, attempt])
useEffect(() => {
if (!abruf) return undefined // Stufe unter 6: kein Abruf, das Register bleibt leer
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))
setAkte(null) // eine neue Anfrage tauscht die Seite aus: die Akte zu einem Eintrag der alten Seite ist zu
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, abruf])
useEffect(() => () => window.clearTimeout(timerRef.current), [])
// Bildschirmwechsel (M2): Tastatur und Screenreader beginnen in diesem Bildschirm, nicht auf <body> oder dem ausgehängten „Hochhaus“-Knopf des alten
useEffect(() => {
const h1 = h1Ref.current
if (h1 && typeof h1.focus === 'function') h1.focus({ preventScroll: true })
}, [])
const onRetry = () => setAttempt((n) => n + 1)
const onClear = () => {
clearedRef.current = true
onClearBot()
}
const onPage = (dir) => {
pagedRef.current = dir
setView((v) => ({ ...v, page: v.page + (dir === 'next' ? 1 : -1) }))
}
const onRoom = (room) => {
pagedRef.current = '' // wer den Raum wählt, hat den Blätterknopf verlassen: der Fokus springt nicht zurück
setView((v) => ({ ...v, room, page: 1 }))
}
const onSearch = (e) => {
const value = e.target.value
pagedRef.current = ''
setDraft(value)
window.clearTimeout(timerRef.current)
timerRef.current = window.setTimeout(() => setView((v) => ({ ...v, q: value.trim(), page: 1 })), SEARCH_DELAY_MS)
}
const loading = abruf && result.key !== key // ohne Abruf gibt es nichts zu laden
// Die Einzelansicht ist weg (die Seite hat bot aus der Adresse genommen): der Raum-Reiter ersetzt den gedrückten Knopf und bekommt den Fokus.
// Ohne den Knopf (Zurück-Taste des Browsers) bleibt der Fokus, wo er ist.
useEffect(() => {
const wanted = clearedRef.current
clearedRef.current = false
if (wanted && !single) reiterRef.current?.focus()
}, [single])
// Die neue Seite ist da und die Seitenzeile wieder eingehängt: der Fokus geht an den gedrückten Knopf, ist er gesperrt (erste oder letzte Seite) an den anderen.
// Nur wenn er wirklich verloren ging (body, nirgends, der alte Knopf ist ausgehängt): wer inzwischen selbst woandershin gegangen ist (Suchfeld, Reiter), wird
// nicht gestört. Ohne preventScroll spränge die Seite ans Ende der neuen Seite (restoreFocus in workersFocus.js).
// Scheiterte die Anfrage, gibt es keine Seitenzeile; die Merkung bleibt dann für „Erneut versuchen“ stehen (Raum, Suche und neues Blättern setzen sie neu oder zurück).
useEffect(() => {
if (loading || result.error) return
const dir = pagedRef.current
pagedRef.current = ''
if (dir === '') return
restoreFocus(browserDocument(), dir === 'next' ? [nextRef.current, prevRef.current] : [prevRef.current, nextRef.current])
}, [loading, result.error])
const zeilen = result.items.map((item) => eintragModell(item))
const info = pageInfo({ total: result.total, page: view.page, pageSize: result.pageSize })
const filtered = view.room !== '' || view.q !== ''
const zeigeAkte = akte !== null && !loading && !result.error && akte < zeilen.length
const plakette = kpis && Number.isFinite(kpis.botLeads) ? formatCount(kpis.botLeads) : ''
const onOpen = (nr) => setAkte(nr)
const onBlaettern = (schritt) => setAkte((nr) => (nr === null || zeilen.length === 0 ? nr : (nr + schritt + zeilen.length) % zeilen.length))
return (
<div className="hk-keller">
<KellerSzene workers={workers} steht={paused === true} />
<div className="hk-kl-vignette" aria-hidden="true" />
<div className="hk-kl-dunkel" aria-hidden="true" />
<div className="hk-kl-ui">
<div className="hk-kl-kopf">
<button type="button" className="hk-knopf hk-kl-knopf-klein" aria-label="Zurück zum Hochhaus" onClick={() => { if (typeof onBack === 'function') onBack() }}>
<span className="hk-ic hk-ic-zurueck" aria-hidden="true" /><span className="hk-kl-label">Hochhaus</span>
</button>
<div className="hk-messing hk-kl-schild">
<span className="hk-ic hk-ic-robo" aria-hidden="true" />
<h1 className="hk-kl-titel" tabIndex={-1} ref={h1Ref}>Bot-Leads</h1>
{plakette ? <span className="hk-kl-zahl">{plakette}</span> : null}
</div>
<button type="button" className="hk-knopf hk-kl-knopf-klein hk-kl-zuklappen" aria-expanded={!zu} aria-label={zu ? 'Register aufklappen' : 'Register zuklappen'} onClick={() => setZu((z) => !z)}>
<span className="hk-ic hk-ic-akte" aria-hidden="true" /><span className="hk-kl-label">{zu ? 'Register aufklappen' : 'Register zuklappen'}</span>
</button>
</div>
{zu ? null : (
<RegisterBuch
einzeln={single}
botId={botId}
room={view.room}
draft={draft}
filtered={filtered}
laden={loading}
ohneDienst={!abruf}
fehler={result.error}
zeilen={zeilen}
total={result.total}
info={info}
onRoom={onRoom}
onSearch={onSearch}
onClear={onClear}
onPage={onPage}
onRetry={onRetry}
onOpen={onOpen}
reiterRef={reiterRef}
zurueckRef={prevRef}
weiterRef={nextRef}
/>
)}
</div>
{zeigeAkte ? <Akte eintrag={zeilen[akte]} index={akte} gesamt={zeilen.length} onClose={() => setAkte(null)} onBlaettern={onBlaettern} /> : null}
</div>
)
}