Files
tickte-system/src/components/workers/BotLeadList.jsx
T
ClaudeandClaude Sonnet 5.5 ed5dfa9c0e workers: Bot-Leads behält den Tastaturfokus beim Blättern und nach „Alle anzeigen“, aria-current nur in der Einzelansicht
Endprüfung P6 (C5, W1): Beim Blättern hängt die Seitenzeile beim Laden aus, und „Alle anzeigen“
wird durch den Raumfilter ersetzt; der Fokus fiel jeweils auf <body>.
- Blättern: gemerkt wird der gedrückte Knopf; nach dem Laden bekommt er den Fokus zurück, ist er
  gesperrt (erste oder letzte Seite), der andere. Raum oder Suche bedient der Nutzer inzwischen,
  geschieht nichts; scheitert die Anfrage, gibt es keine Seitenzeile und nichts geschieht.
- „Alle anzeigen“: der Raumfilter bekommt den Fokus, sobald die Seite bot aus der Adresse
  genommen hat. Nicht beim Einhängen (am Handy öffnete das die Auswahl) und nicht bei einem
  anderen Weg zur Liste (Zurück-Taste).
- aria-current nur mit bot (single) und passender Kennung; Zeilen ohne Kennung teilen sich keinen key mehr.
- Tests: botleadList.test.mjs (Fokus-Attrappe über __fakeRef, Mutationsprobe für die Reset-Zeilen),
  botleadsView.test.mjs (Quelltext-Probe); der Prüfstand prüft zusätzlich den Fokus nach „Alle anzeigen“.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 15:19:52 +00:00

182 lines
9.4 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
// Tastaturfokus: Wer „Alle anzeigen“ oder „Zurück“/„Weiter“ 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 selectRef = useRef(null) // Raumfilter: Ziel nach „Alle anzeigen“
const clearedRef = useRef(false) // „Alle anzeigen“ gedrückt: erst dann (nie schon beim Einhängen, das öffnete am Handy die Auswahl) bekommt der Raumfilter 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
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 onClear = () => {
clearedRef.current = true
onClearBot()
}
const onPage = (dir) => {
pagedRef.current = dir
setView((v) => ({ ...v, page: v.page + (dir === 'next' ? 1 : -1) }))
}
const onRoom = (e) => {
const room = e.target.value
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 = result.key !== key
// Die Einzelansicht ist weg (die Seite hat bot aus der Adresse genommen): der Raumfilter 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) selectRef.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.
// Scheiterte die Anfrage, gibt es keine Seitenzeile (die Refs sind null) und nichts geschieht.
useEffect(() => {
if (loading) return
const dir = pagedRef.current
pagedRef.current = ''
if (dir === '') return
const [first, second] = dir === 'next' ? [nextRef.current, prevRef.current] : [prevRef.current, nextRef.current]
const target = first && !first.disabled ? first : second
target?.focus()
}, [loading])
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, index) => (
<tr key={row.id || `ohne-id-${index}`} className={row.gekuerzt ? 'wk-bl-cut' : undefined} aria-current={single && 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={onClear}>Alle anzeigen</button>
</div>
) : (
<div className="toolbar">
<select className="select" aria-label="Raum" value={view.room} onChange={onRoom} ref={selectRef}>
{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" ref={prevRef} disabled={!info.hasPrev} onClick={() => onPage('prev')}>Zurück</button>
<button type="button" className="btn" ref={nextRef} disabled={!info.hasNext} onClick={() => onPage('next')}>Weiter</button>
</span>
)}
</div>
)}
</div>
)
}