From 1b3fdf5d97a2485cb9df8b164400e6d3aa6dd6a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 08:53:56 +0000 Subject: [PATCH] =?UTF-8?q?Hustler-Pixelwelt=20T10:=20Keller=20(Szene=20mi?= =?UTF-8?q?t=20Arne=20nach=20Status,=20Register-Buch,=20Akte,=20Gr=C3=BCnd?= =?UTF-8?q?e)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/components/workers/BotLeadList.jsx | 160 ++-- src/components/workers/keller/Akte.jsx | 124 +++ src/components/workers/keller/KellerSzene.jsx | 74 ++ .../workers/keller/RegisterBuch.jsx | 152 ++++ src/lib/botleadGruende.js | 53 ++ src/lib/hustlerKeller.js | 147 ++++ src/styles/hustler/hustler-keller-ui.css | 142 ++++ tests/fixtures/botlead-gruende.json | 20 + tests/unit/botleadList.test.mjs | 348 ++++---- tests/unit/botleadsView.test.mjs | 56 +- tests/unit/helpers/kellerRunner.mjs | 197 +++++ tests/unit/hustlerGruende.test.mjs | 101 +++ tests/unit/hustlerKeller.test.mjs | 742 ++++++++++++++++++ tests/unit/hustlerKellerLib.test.mjs | 243 ++++++ 14 files changed, 2247 insertions(+), 312 deletions(-) create mode 100644 src/components/workers/keller/Akte.jsx create mode 100644 src/components/workers/keller/KellerSzene.jsx create mode 100644 src/components/workers/keller/RegisterBuch.jsx create mode 100644 src/lib/botleadGruende.js create mode 100644 src/lib/hustlerKeller.js create mode 100644 src/styles/hustler/hustler-keller-ui.css create mode 100644 tests/fixtures/botlead-gruende.json create mode 100644 tests/unit/helpers/kellerRunner.mjs create mode 100644 tests/unit/hustlerGruende.test.mjs create mode 100644 tests/unit/hustlerKeller.test.mjs create mode 100644 tests/unit/hustlerKellerLib.test.mjs diff --git a/src/components/workers/BotLeadList.jsx b/src/components/workers/BotLeadList.jsx index 2effb3c..2035ac3 100644 --- a/src/components/workers/BotLeadList.jsx +++ b/src/components/workers/BotLeadList.jsx @@ -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 , 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 =
Wird geladen …
- } else if (result.error) { - body = ( - <> -
{result.error}
-
- -
- - ) - } else if (rows.length === 0) { - if (single) body = - else if (filtered) body = - else body = - } else { - body = ( -
- - - {COLUMNS.map((name) => )} - - - {rows.map((row, index) => ( - - - - - - - - - - ))} - -
{name}
-
{row.title}{row.gekuerzt && gekürzt}
- {row.place &&
{row.place}
} -
{row.roomLabel}{row.scoreLabel}{row.reason}{row.phone} - {row.sourceHref - ? Quelle öffnen - : '–'} - {row.found}
-
- ) - } + const onOpen = (nr) => setAkte(nr) + const onBlaettern = (schritt) => setAkte((nr) => (nr === null || zeilen.length === 0 ? nr : (nr + schritt + zeilen.length) % zeilen.length)) return ( -
-

Reine Liste: keine Anrufe, kein Mailversand.

- {single ? ( -
- Einzelner Eintrag aus dem Ereignisstrom - +
+ + + +
+ ) +} diff --git a/src/components/workers/keller/KellerSzene.jsx b/src/components/workers/keller/KellerSzene.jsx new file mode 100644 index 0000000..df2c6c9 --- /dev/null +++ b/src/components/workers/keller/KellerSzene.jsx @@ -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 ( +