Hustler-Pixelwelt T10: Keller (Szene mit Arne nach Status, Register-Buch, Akte, Gründe)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-22 08:53:56 +00:00
1 parent ec63e5a43d
commit 1b3fdf5d97
14 files changed
+2247 -312

No files matched your search

+70 -90
View File
@@ -1,34 +1,41 @@
import { useEffect, useRef, useState } from 'react'
import EmptyState from '../ui/EmptyState'
import { ROOM_FILTERS, botleadRow, pageInfo } from '../../lib/botleadsView'
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 { 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 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
const browserDocument = () => (typeof document === 'undefined' ? null : document) // unter Node (Tests, Server) gibt es keins, dann bleibt der Fokus unangetastet
// Reiter „Bot-Leads“: Betriebe, die nicht angeschrieben werden (z. B. keine veröffentlichte Mail), mit Grund. Reine Liste, nichts wird gesendet oder angerufen.
// 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, bot, onClearBot, kpis (botLeads für die Zahl am Schild), workers (Hustler-Tabelle der Seite: Arnes Status), onBack (◂ Hochhaus).
// 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).
export default function BotLeadList({ api, bot, onClearBot }) {
// 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, bot, onClearBot, kpis, workers, onBack }) {
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)
// Tastaturfokus: Wer „Alle anzeigen“ oder „Zurück“/„Weiter“ drückt, verliert den Knopf mit dem Fokus (Einzelansicht und Seitenzeile werden ausgetauscht oder
// 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 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 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“)
@@ -40,6 +47,7 @@ export default function BotLeadList({ api, bot, onClearBot }) {
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 })
@@ -73,8 +81,7 @@ export default function BotLeadList({ api, bot, onClearBot }) {
pagedRef.current = dir
setView((v) => ({ ...v, page: v.page + (dir === 'next' ? 1 : -1) }))
}
const onRoom = (e) => {
const room = e.target.value
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 }))
}
@@ -88,17 +95,17 @@ export default function BotLeadList({ api, bot, onClearBot }) {
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.
// 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) selectRef.current?.focus()
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, Raumfilter), wird
// nicht gestört. Ohne preventScroll spränge die Seite ans Ende der neuen Seite, denn die Seitenzeile steht unter der Tabelle (restoreFocus in workersFocus.js).
// 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
@@ -108,86 +115,59 @@ export default function BotLeadList({ api, bot, onClearBot }) {
restoreFocus(browserDocument(), dir === 'next' ? [nextRef.current, prevRef.current] : [prevRef.current, nextRef.current])
}, [loading, result.error])
const rows = result.items.map((item) => botleadRow(item))
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) : ''
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>
<div className="toolbar wk-bl-retry">
<button type="button" className="btn" onClick={onRetry}>Erneut versuchen</button>
</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>
)
}
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="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 className="hk-keller">
<KellerSzene workers={workers} />
<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">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>
) : (
<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>
)}
{zu ? null : (
<RegisterBuch
einzeln={single}
botId={botId}
room={view.room}
draft={draft}
filtered={filtered}
laden={loading}
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>
)
}
+124
View File
@@ -0,0 +1,124 @@
// Die Akte zu einem Bot-Lead (Hustler-Pixelwelt T10, Spec E12/E13/E14): Dialog auf der Manila-Karte (9-slice hk-akte der Vorlage), Aufbau und Klassen wie
// keller/entwurf/BotLeads.dc.html mit hk- davor, die Inline-Stile der Vorlage stehen als Klassen in styles/hustler/hustler-keller-ui.css.
// Inhalt: Raum und Scout, Name, Ort, Punkte (Balken und scoreLabel), Grund mit Symbol und Erklärung (grundInfo), Telefon (Regel aus botleadRow: Heilberufe nie, ohne Quelle
// „nicht verfügbar“), Quelle als Link nur über row.sourceHref (botleadRow prüft mit safeHref: nur http und https, keine Zugangsdaten; neuer Tab ohne Rückbezug),
// gefunden am und, bei einem gekürzten Eintrag, „gekürzt“. Blättern ◂ ▸ innerhalb der Seite (im Kreis), Esc schließt, ←/→ blättern, Tab bleibt im Dialog.
// Fokus: beim Öffnen auf „Schließen“; beim Schließen zurück auf die Zeile des zuletzt gezeigten Eintrags ([data-hk-zeile="<Nr>"], die setzt das Buch), sonst auf das
// Element, das beim Öffnen den Fokus hatte (wenn es noch im Dokument steht). Alle Texte sind Textknoten, kein HTML-Einschub, kein CSS-Import, keine Bilder.
import { useEffect, useRef } from 'react'
// Props: eintrag (Ergebnis von eintragModell), index (Nr. in der Seite, ab 0), gesamt (Einträge der Seite), onClose(), onBlaettern(schritt: -1 | 1)
export default function Akte({ eintrag: e, index, gesamt, onClose, onBlaettern }) {
const ersterRef = useRef(null) // Knopf „Schließen“ (Fokus beim Öffnen, Anfang der Fokusfalle)
const letzterRef = useRef(null) // Knopf „Nächste Akte“ (Ende der Fokusfalle)
const sectionRef = useRef(null)
const hintenRef = useRef(null)
const openerRef = useRef(null)
const indexRef = useRef(index)
indexRef.current = index
// Öffnen: merken, wer den Fokus hatte, und den Fokus auf „Schließen“ setzen; Schließen (oder Abbau): Fokus zurückgeben
useEffect(() => {
const aktiv = typeof document !== 'undefined' ? document.activeElement : null
openerRef.current = aktiv && aktiv !== document.body ? aktiv : null
const erster = ersterRef.current
if (erster && typeof erster.focus === 'function') erster.focus({ preventScroll: true })
return () => {
const opener = openerRef.current
openerRef.current = null
let ziel = null
if (typeof document !== 'undefined' && typeof document.querySelector === 'function' && Number.isInteger(indexRef.current)) {
ziel = document.querySelector(`[data-hk-zeile="${indexRef.current}"]`)
}
if (!ziel && opener && opener.isConnected !== false) ziel = opener
if (ziel && typeof ziel.focus === 'function') ziel.focus()
}
}, [])
// Tasten (am äußeren Rahmen, damit sie auch gelten, wenn der Fokus auf dem Hintergrund oder der Karte selbst liegt):
// Esc schließt, ←/→ blättern, Tab wandert im Kreis zwischen „Schließen“ und „Nächste Akte“
const taste = (ev) => {
if (ev.key === 'Escape') {
ev.preventDefault()
ev.stopPropagation()
onClose()
return
}
if (ev.key === 'ArrowRight' || ev.key === 'ArrowLeft') {
if (ev.altKey || ev.ctrlKey || ev.metaKey) return // Alt+← ist „Zurück“ im Browser
ev.preventDefault()
ev.stopPropagation()
onBlaettern(ev.key === 'ArrowRight' ? 1 : -1)
return
}
if (ev.key !== 'Tab') return
const aktiv = typeof document !== 'undefined' ? document.activeElement : null
const erster = ersterRef.current
const letzter = letzterRef.current
if (!ev.shiftKey && aktiv === letzter && erster) {
ev.preventDefault()
erster.focus()
} else if (ev.shiftKey && letzter && (aktiv === erster || aktiv === sectionRef.current || aktiv === hintenRef.current || aktiv === ev.currentTarget)) {
ev.preventDefault()
letzter.focus()
}
}
const p = e.punkte
return (
<div className="hk-kl-akte-ueber" onKeyDown={taste}>
<button type="button" tabIndex={-1} className="hk-kl-akte-hinten" aria-label="Akte schließen" ref={hintenRef} onClick={() => onClose()} />
<section role="dialog" aria-modal="true" aria-label={`Akte ${e.title}`} tabIndex={-1} className="hk-akte hk-kl-akte" ref={sectionRef}>
<span className="hk-kl-sr" aria-live="polite">{`Akte ${index + 1} von ${gesamt}: ${e.title}`}</span>
<div className="hk-kl-akte-inhalt">
<div className="hk-kl-akte-kopf">
<div className="hk-kl-akte-titelbox">
<div className="hk-kl-akte-raum">
<span className={e.raumIcon} aria-hidden="true" />
{e.scout ? `${e.roomLabel} · gefunden von ${e.scout}` : e.roomLabel}
</div>
<h2 className="hk-kl-akte-name">{e.title}</h2>
{e.place ? <div className="hk-kl-akte-ort">{e.place}</div> : null}
</div>
<button type="button" className="hk-knopf hk-kl-knopf-icon" ref={ersterRef} aria-label="Schließen" onClick={() => onClose()}>
<span className="hk-ic hk-ic-schliessen" aria-hidden="true" />
</button>
</div>
<dl className="hk-kl-akte-dl">
<dt>Punkte</dt>
<dd className="hk-kl-akte-zeile">
<span className="hk-kl-balken hk-kl-balken-gross" aria-hidden="true"><span className={`hk-kl-balken-fuell hk-kl-farbe-${p.farbe}`} style={{ width: p.breiteGross }} /></span>
<span className="hk-kl-punkte-text">{e.scoreLabel}</span>
</dd>
<dt>Grund</dt>
<dd>
<span className="hk-kl-akte-zeile hk-kl-akte-grund"><span className={e.grundIcon} aria-hidden="true" /><span className="hk-kl-akte-grundtext">{e.reason}</span></span>
<span className="hk-kl-akte-erklaerung">{e.erklaerung}</span>
</dd>
<dt>Telefon</dt>
<dd className="hk-kl-akte-zeile"><span className={e.telefonIcon} aria-hidden="true" />{e.phone}</dd>
<dt>Quelle</dt>
<dd>
{e.sourceHref
? <a className="hk-akte-link" href={e.sourceHref} target="_blank" rel="noopener noreferrer" aria-label={`Quelle öffnen: ${e.title}`}><span className="hk-ic hk-ic-quelle" aria-hidden="true" />Quelle öffnen</a>
: '–'}
</dd>
<dt>Gefunden</dt>
<dd className="hk-kl-akte-zeile"><span className="hk-ic hk-ic-kalender" aria-hidden="true" />{e.found}</dd>
{e.gekuerzt ? <dt>Kürzung</dt> : null}
{e.gekuerzt ? <dd className="hk-kl-akte-zeile"><span className="hk-ic hk-ic-schere" aria-hidden="true" />gekürzt</dd> : null}
</dl>
<div className="hk-kl-akte-fuss">
<button type="button" className="hk-knopf hk-kl-knopf-icon" aria-label="Vorherige Akte" onClick={() => onBlaettern(-1)}>
<span className="hk-ic hk-ic-pfeil-links" aria-hidden="true" />
</button>
<span className="hk-kl-akte-hinweis">Nur zum Nachsehen, hier wird nichts verschickt.</span>
<button type="button" className="hk-knopf hk-kl-knopf-icon" ref={letzterRef} aria-label="Nächste Akte" onClick={() => onBlaettern(1)}>
<span className="hk-ic hk-ic-pfeil-rechts" aria-hidden="true" />
</button>
</div>
</div>
</section>
</div>
)
}
@@ -0,0 +1,74 @@
// Die Szene des Kellers (Hustler-Pixelwelt T10, Spec E12/E13): der Archiv-Raum aus keller/entwurf/BotLeads.dc.html, Klassen und Inline-Geometrie 1:1 mit hk- davor
// (Lage der Dinge: src/lib/hustlerKeller.js). Keller-Raum, Licht mit Flackern, Staub, Schnee im Fenster mit Passant, Maus, Blinkpunkte, Einpassen „cover“ links
// verankert (ResizeObserver, wie die Vorlage).
// Arne hängt am Status des Hustlers (arneAnsicht über figurZustand): bei „arbeitet“ läuft seine Ablauf-Schleife samt Rohrpost, Stempel und Schublade; sonst steht oder
// sitzt er als Haus-Figur der passenden Pose (warten, schlafen, nicht) an seinem Pult und die drei ruhen; bei „fehler“ trägt er den roten Marker hk-alarm.
// Die ganze Szene ist Zierde (aria-hidden). Kein CSS-Import (WorkersPage hängt keller.css und hustler-keller-ui.css ein), keine Bilder, kein HTML-Einschub.
import { useEffect, useRef, useState } from 'react'
import { ARNE_RUHE, BUCH_STEMPEL, DAMPF, FENSTER, LEDS, MAUS_STREIFEN, ROHRPOST, SCHUBLADE, arneAnsicht, kellerEinpassen } from '../../../lib/hustlerKeller'
// Props: workers = Hustler-Tabelle der Seite (Zustandseinträge nach workerId, aus indexWorkers/reduceEvents)
export default function KellerSzene({ workers }) {
const rahmenRef = useRef(null)
const [fit, setFit] = useState(() => kellerEinpassen(0, 0))
const arne = arneAnsicht(workers)
// Einpassen: bei jeder Größenänderung des Rahmens neu (ohne Messung bleibt die Vorgabe der Vorlage, Maßstab 2)
useEffect(() => {
const el = rahmenRef.current
if (!el || typeof el.getBoundingClientRect !== 'function') return undefined
const passe = () => {
const r = el.getBoundingClientRect()
if (!(r.width > 0 && r.height > 0)) return
const next = kellerEinpassen(r.width, r.height)
setFit((prev) => (prev.s === next.s && prev.x === next.x && prev.y === next.y ? prev : next))
}
passe()
if (typeof ResizeObserver !== 'undefined') {
const ro = new ResizeObserver(passe)
ro.observe(el)
return () => ro.disconnect()
}
if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
window.addEventListener('resize', passe)
return () => window.removeEventListener('resize', passe)
}
return undefined
}, [])
const ruht = arne.arbeitet ? '' : ' hk-kl-ruht'
return (
<div className="hk-kl-szene" aria-hidden="true" ref={rahmenRef} data-hk-arne={arne.status}>
<div className="hk-kl-buehne" style={{ transform: `translate(${fit.x.toFixed(1)}px, ${fit.y.toFixed(1)}px) scale(${fit.s.toFixed(4)})` }}>
<div className="hk-sz-ebene hk-sz-bild" />
<div style={FENSTER}>
<div className="hk-sz-fensterschnee" />
<div className="hk-sz-passant-weg"><span className="hk-spr hk-sz-passant" /></div>
<div className="hk-sz-kreuz" />
</div>
<span className="hk-spr hk-sz-dampf" style={DAMPF} />
<span className={`hk-spr hk-sz-buch-stempel${ruht}`} style={BUCH_STEMPEL} />
<span className={`hk-spr hk-sz-rohrpost${ruht}`} style={ROHRPOST} />
<span className={`hk-spr hk-sz-schublade${ruht}`} style={SCHUBLADE} />
{arne.arbeitet ? (
<div className="hk-arne-weg"><span className="hk-arne-bild" /></div>
) : (
<span className="hk-kl-arne" style={ARNE_RUHE}>
<span className={`hk-spr hk-kl-fig hk-kl-fig-${arne.pose}`} />
{arne.punkte ? <span className="hk-spr hk-kl-punkte" /> : null}
{arne.zzz ? <span className="hk-spr hk-kl-zzz" /> : null}
{arne.alarm ? <span className="hk-alarm">!</span> : null}
</span>
)}
<div style={MAUS_STREIFEN}><div className="hk-sz-maus-weg"><span className="hk-spr hk-sz-maus" /></div></div>
{LEDS.map((l) => <span key={l.name} className="hk-sz-led" style={l.stil} />)}
<div className="hk-sz-ebene hk-sz-licht-tisch" />
<div className="hk-sz-ebene hk-sz-licht-92" />
<div className="hk-sz-ebene hk-sz-licht-236" />
<div className="hk-sz-ebene hk-sz-licht-halle" />
<div className="hk-sz-ebene hk-sz-staub" />
</div>
</div>
)
}
@@ -0,0 +1,152 @@
// Das Register-Buch des Kellers (Hustler-Pixelwelt T10, Spec E12/E13): Raum-Reiter, das aufgeschlagene Buch mit Titel, Hinweis, Suchfeld (oder, bei einem einzelnen
// Eintrag, „Alle anzeigen“), Kopfzeile, den Einträgen der Dienst-Seite (im Buch scrollbar), Leer-, Lade- und Fehlerzustand und der Seitenzeile mit ◂ ▸.
// Aufbau und Klassen wie keller/entwurf/BotLeads.dc.html mit hk- davor; die Inline-Stile der Vorlage stehen als Klassen in styles/hustler/hustler-keller-ui.css.
// Rein darstellend: Abruf, Suche, Seiten und Fokus gehören BotLeadList. Alle Texte der Zeilen sind Textknoten; Links gibt es hier nicht (der Link zur Quelle steht in der
// Akte und kommt nur aus row.sourceHref). Kein CSS-Import, keine Bilder, kein HTML-Einschub.
import { REITER } from '../../../lib/hustlerKeller'
import { formatCount } from '../../../lib/workersView'
const SEARCH_MAX = 100 // der Dienst kürzt die Suche auf 100 Zeichen
// Eine Zeile des Buchs: ein Knopf, der die Akte öffnet. Spalten: Raum-Symbol, Name (+ „gekürzt“) mit Ort und Funddatum, Punkte, Grund mit Symbol, Telefon.
function Zeile({ z, nr, aktuell, onOpen }) {
return (
<li>
<button
type="button"
className={z.gekuerzt ? 'hk-zeile hk-zeile-alt' : 'hk-zeile'}
data-hk-zeile={nr}
aria-label={z.label}
aria-current={aktuell ? 'true' : undefined}
onClick={(ev) => onOpen(nr, ev)}
>
<span className={z.raumIcon} aria-hidden="true" />
<span className="hk-kl-namebox">
<span className="hk-kl-namezeile">
<span className="hk-zelle-text hk-kl-name" title={z.title}>{z.title}</span>
{z.gekuerzt ? <span className="hk-kl-tag"><span className="hk-ic hk-ic-schere hk-kl-tag-icon" aria-hidden="true" />gekürzt</span> : null}
</span>
<span className="hk-klein">{[z.place, z.found].filter((t) => t !== '').join(' · ')}</span>
</span>
<span className="hk-zelle hk-kl-spalte-punkte">
<span className="hk-kl-balken" aria-hidden="true"><span className={`hk-kl-balken-fuell hk-kl-farbe-${z.punkte.farbe}`} style={{ width: z.punkte.breite }} /></span>
<span className="hk-kl-punkte-text">{z.punkte.text}</span>
</span>
<span className="hk-zelle"><span className={z.grundIcon} aria-hidden="true" /><span className="hk-zwei-zeilen">{z.reason}</span></span>
<span className="hk-zelle hk-kl-spalte-telefon"><span className={z.telefonIcon} aria-hidden="true" /><span className="hk-zwei-zeilen">{z.phone}</span></span>
</button>
</li>
)
}
// Props:
// einzeln (Einzelansicht über ?bot=), botId, room (gewählter Raum-Schlüssel), draft (Inhalt des Suchfelds), filtered (Raum oder Suche gesetzt)
// laden, fehler (Satz des Dienstes oder null), zeilen (eintragModell je Eintrag der Seite), total, info (pageInfo)
// onRoom(value), onSearch(ev), onClear(), onPage('prev' | 'next'), onRetry(), onOpen(nr, ev)
// reiterRef (am aktiven Reiter), zurueckRef, weiterRef (Blätterknöpfe): Fokus-Ziele von BotLeadList
export default function RegisterBuch({ einzeln, botId, room, draft, filtered, laden, fehler, zeilen, total, info, onRoom, onSearch, onClear, onPage, onRetry, onOpen, reiterRef, zurueckRef, weiterRef }) {
const zahl = laden || fehler ? null : total // die Zahl steht nur am aktiven Reiter und erst, wenn die Antwort da ist
let koerper
if (laden) {
koerper = <p className="hk-kl-laden" role="status">Wird geladen …</p>
} else if (fehler) {
koerper = (
<div className="hk-kl-fehlerbox">
<p className="hk-kl-fehler" role="alert">{fehler}</p>
<button type="button" className="hk-knopf hk-kl-knopf-text" onClick={() => onRetry()}>Erneut versuchen</button>
</div>
)
} else if (zeilen.length === 0) {
const leer = einzeln
? ['Dieser Eintrag ist nicht (mehr) in der Liste.', 'Er wurde gekürzt oder ist kein Bot-Lead.']
: filtered
? ['Keine Treffer.', 'Für diesen Raum und diese Suche gibt es keine Bot-Leads.']
: ['Noch keine Bot-Leads.', 'Betriebe, die nicht angeschrieben werden, erscheinen hier mit ihrem Grund.']
koerper = (
<div className="hk-kl-leer">
<p className="hk-kl-leer-titel">{leer[0]}</p>
<p className="hk-kl-leer-text">{leer[1]}</p>
</div>
)
} else {
koerper = (
<ol className="hk-liste" aria-label="Bot-Leads">
{zeilen.map((z, i) => <Zeile key={z.id || `ohne-id-${i}`} z={z} nr={i} aktuell={einzeln && z.id === botId} onOpen={onOpen} />)}
</ol>
)
}
const mehrere = info.pages > 1
return (
<>
{einzeln ? null : (
<div className="hk-kl-reiter" role="group" aria-label="Räume filtern">
{REITER.map((r) => {
const aktiv = r.value === room
const mitZahl = aktiv && zahl !== null
return (
<button
key={r.value || 'alle'}
type="button"
className={aktiv ? `${r.klasse} hk-reiter-an` : r.klasse}
aria-pressed={aktiv}
aria-label={mitZahl ? `${r.label}, ${formatCount(zahl)} ${zahl === 1 ? 'Eintrag' : 'Einträge'}` : r.label}
ref={aktiv ? reiterRef : undefined}
onClick={() => onRoom(r.value)}
>
<span className={r.icon} aria-hidden="true" />
<span className="hk-zelle-text">{r.kurz}</span>
{mitZahl ? <span className="hk-kl-reiter-zahl">{formatCount(zahl)}</span> : null}
</button>
)
})}
</div>
)}
<section className="hk-buch hk-kl-buch" aria-label="Nie-doppelt-Register">
<div className="hk-kl-buchkopf">
<div className="hk-kl-buchtitelbox">
<h2 className="hk-kl-buchtitel">Nie-doppelt-Register</h2>
<p className="hk-kl-hinweis">Reine Liste: keine Anrufe, kein Mailversand.</p>
</div>
{einzeln ? (
<div className="hk-kl-einzeln">
<span className="hk-kl-einzeln-text">Einzelner Eintrag aus dem Ereignisstrom</span>
<button type="button" className="hk-knopf hk-kl-knopf-text" onClick={() => onClear()}>Alle anzeigen</button>
</div>
) : (
<div className="hk-kl-suchfeld">
<span className="hk-ic hk-ic-lupe hk-kl-lupe" aria-hidden="true" />
<input className="hk-suche" type="search" aria-label="Betriebe suchen" placeholder="Name, Ort oder Grund" maxLength={SEARCH_MAX} value={draft} onChange={onSearch} />
</div>
)}
</div>
{laden || fehler || zeilen.length === 0 ? null : (
<div className="hk-kopfzeile" aria-hidden="true">
<span />
<span>Betrieb · Ort · gefunden</span>
<span className="hk-kl-spalte-punkte">Punkte</span>
<span>Grund</span>
<span className="hk-kl-spalte-telefon">Telefon</span>
</div>
)}
{koerper}
{einzeln || laden || fehler || total <= 0 ? null : (
<div className="hk-kl-fuss">
{mehrere ? (
<button type="button" className="hk-knopf hk-kl-knopf-icon" ref={zurueckRef} aria-label="Vorherige Seite" disabled={!info.hasPrev} onClick={() => onPage('prev')}>
<span className="hk-ic hk-ic-pfeil-links" aria-hidden="true" />
</button>
) : null}
<span className="hk-kl-seiten" aria-live="polite">{info.label}</span>
{mehrere ? (
<button type="button" className="hk-knopf hk-kl-knopf-icon" ref={weiterRef} aria-label="Nächste Seite" disabled={!info.hasNext} onClick={() => onPage('next')}>
<span className="hk-ic hk-ic-pfeil-rechts" aria-hidden="true" />
</button>
) : null}
</div>
)}
</section>
</>
)
}