From 5b5bb740c267204f98bbfc7f8f48bf009ab0a33b Mon Sep 17 00:00:00 2001
From: Claude
Date: Thu, 22 Oct 2026 08:54:39 +0000
Subject: [PATCH] =?UTF-8?q?Hustler-Pixelwelt=20T9:=20Dein=20Pult=20(Szene,?=
=?UTF-8?q?=20Klemmbrett=20mit=20Umschl=C3=A4gen,=20Brief=20mit=20den=20bi?=
=?UTF-8?q?sherigen=20Aktionen)?=
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/PultList.jsx | 576 ++++-------------
src/components/workers/pult/Brief.jsx | 504 +++++++++++++++
src/components/workers/pult/Klemmbrett.jsx | 94 +++
src/components/workers/pult/Marken.jsx | 21 +
src/components/workers/pult/PultSzene.jsx | 72 +++
src/lib/hustlerPult.js | 157 +++++
src/styles/hustler/hustler-pult-ui.css | 138 +++++
tests/unit/hustlerPult.test.mjs | 688 +++++++++++++++++++++
tests/unit/workersProspect.test.mjs | 8 +-
tests/unit/workersPultUi.test.mjs | 589 ++++++++++--------
10 files changed, 2146 insertions(+), 701 deletions(-)
create mode 100644 src/components/workers/pult/Brief.jsx
create mode 100644 src/components/workers/pult/Klemmbrett.jsx
create mode 100644 src/components/workers/pult/Marken.jsx
create mode 100644 src/components/workers/pult/PultSzene.jsx
create mode 100644 src/lib/hustlerPult.js
create mode 100644 src/styles/hustler/hustler-pult-ui.css
create mode 100644 tests/unit/hustlerPult.test.mjs
diff --git a/src/components/workers/PultList.jsx b/src/components/workers/PultList.jsx
index 61cce72..9fc8a89 100644
--- a/src/components/workers/PultList.jsx
+++ b/src/components/workers/PultList.jsx
@@ -1,439 +1,87 @@
+// „Dein Pult“ (Hustler-Pixelwelt T9, Spec E11/E13/E14): der Bildschirm Freigabe. Die Szene (pult/PultSzene.jsx) füllt den Raum, davor liegt das Klemmbrett
+// (pult/Klemmbrett.jsx) mit den Umschlägen der Einträge, die ein Mensch absendet, übernimmt oder abschließt; ein Klick auf einen Umschlag öffnet den Brief
+// (pult/Brief.jsx) mit den bisherigen Aktionen. Nichts hier versendet selbst etwas: „Kopieren & öffnen“ bringt die Antwort in die Zwischenablage und öffnet die
+// Anzeige, abgeschickt wird dort von Hand. Aufbau und Klassen wie Freigabe.dc.html (Zeichenfläche) mit hk- davor; Wurzel .hk-pult.
+// Die Datei bleibt PultList.jsx (Name und Default-Export wie bisher), sie hält Zustand und Fluss: Reiter, bearbeitete Texte und Feldtexte je Eintrag (sie überleben
+// Schließen des Briefs, Filterwechsel und Neuladen), die Meldung nach einem Abschluss samt Fokus, den offenen Brief, das Interessenten-Panel, „Pult zuklappen“.
+//
+// Props: api (Dienst-API), me = { id, name } des Angemeldeten, kpis (Kennzahlen aus dem Zustand: pultOpen steuert die Zahl am Reiter „Wartet“, im Kopf und die Boten-
+// Schleife), onBack() („◂ Hochhaus“). workers (Hustler-Tabelle) reicht die Seite durch, das Pult braucht sie nicht: ein Pult-Eintrag nennt keinen Hustler.
+// Kein CSS-Import und keine Bilder (die Stile hängt WorkersPage ein), Fremdtext nur als Textknoten.
import { useCallback, useEffect, useRef, useState } from 'react'
-import Badge from '../ui/Badge'
-import Button from '../ui/Button'
-import EmptyState from '../ui/EmptyState'
-import { copyTextChecked } from '../CopyableCredential'
import { useEmployees } from '../../hooks/useEmployees'
import { usePultFeed } from '../../hooks/usePultFeed'
+import { reiterZahl, wartetZahl, botenLaufen } from '../../lib/hustlerPult'
import { activeControl, restoreFocus } from '../../lib/workersFocus'
-import {
- ACTION_FAILED, PULT_FILTERS, REPLY_TEXT_MAX, actionErrorText, charCount, copyAndOpen, mailtoHref, pasteDoneText, rowModel, validateReplyText,
-} from '../../lib/workersPult'
+import { PULT_FILTERS, rowModel } from '../../lib/workersPult'
+import Brief from './pult/Brief'
+import Klemmbrett from './pult/Klemmbrett'
+import PultSzene from './pult/PultSzene'
import ProspectPanel from './ProspectPanel'
-const CONFIRM_MS = 4000 // so lange gilt die Rückfrage „Wirklich verwerfen?“, „Wirklich freigeben?“ bzw. „Wirklich bauen?“ nach dem ersten Klick
-// Kosten und Wirkung von „Vorschau bauen“ (Rückfrage und Hinweis am Knopf); bewusst ohne „nur wenn der Interessent Ja gesagt hat“: der Knopf gilt gerade für unklare Antworten
-const BUILD_HINT = 'Startet einen Opus-Lauf und baut die Vorschau, als hätte der Interessent Ja gesagt.'
const NO_FIELDS = Object.freeze({}) // Eintrag ohne bearbeitete Felder
// Das document des Browsers; unter Node (Tests, Server) gibt es keins, dann bleibt der Fokus unangetastet
const browserDocument = () => (typeof document === 'undefined' ? null : document)
-// Selektor des Textfelds eines aufgeklappten Feldes (Antwort oder Einfügen)
-const PANEL_FIELD = '.wk-pult-reply textarea, .wk-pult-paste textarea'
-// Leerzustand je Filter
-const EMPTY = {
- offen: { title: 'Nichts wartet auf dich', hint: 'Neue Antworten erscheinen hier, sobald ein Entwurf bereit ist.' },
- veraltet: { title: 'Nichts ist veraltet', hint: 'Einträge, die zu lange liegen, landen hier und lassen sich weiter bearbeiten.' },
- erledigt: { title: 'Noch nichts erledigt', hint: 'Gesendete, verworfene und erledigte Einträge stehen hier.' },
-}
-
-// Neuer Tab ohne Rückbezug auf diese Seite
-const openTab = (url) => window.open(url, '_blank', 'noopener,noreferrer')
-
-// Meldung nach „Kopieren & öffnen“: ok nur, wenn alles geklappt hat, was die Zeile anbietet (ohne Anzeigen-Adresse gibt es nur Kopieren)
-function copyNote(result, hasUrl) {
- if (!hasUrl) {
- return result.copied
- ? { kind: 'ok', text: 'Kopiert.' }
- : { kind: 'err', text: 'Kopieren hat nicht geklappt. Bitte den Text von Hand markieren und kopieren.' }
- }
- if (result.copied && result.opened) return { kind: 'ok', text: 'Kopiert und geöffnet.' }
- if (result.copied) return { kind: 'err', text: 'Kopiert, aber die Anzeige ließ sich nicht öffnen. Ein Pop-up-Blocker kann der Grund sein.' }
- if (result.opened) return { kind: 'err', text: 'Geöffnet, aber das Kopieren hat nicht geklappt. Bitte den Text von Hand kopieren.' }
- return { kind: 'err', text: 'Kopieren und Öffnen haben nicht geklappt.' }
-}
-
-// Zugänglicher Name für ein Bedienelement, das in jeder Zeile wiederkehrt: das sichtbare Wort steht vorn (WCAG 2.5.3 „Beschriftung im
-// Namen“), dahinter der Titel der Zeile, damit eine Liste aller „Details“-Knöpfe im Screenreader unterscheidbar ist. Ohne Titel
-// (leer oder fehlend) gilt der schlichte Name, nie „… zu undefined“.
-function rowName(label, title, joiner = ': ') {
- const t = typeof title === 'string' ? title.trim() : ''
- return t === '' ? label : `${label}${joiner}${t}`
-}
-
-// Aufgeklapptes Feld unter den Aktionen: „Als Mensch antworten“ (.wk-pult-reply) und „Antwort des Kunden einfügen“ (.wk-pult-paste).
-// Textfeld, Zähler (Zeichen wie der Dienst sie zählt, höchstens REPLY_TEXT_MAX) und zwei Knöpfe; die Prüfung macht die Zeile beim Absenden.
-// Esc (aus dem ganzen Feld heraus, nicht nur aus dem Textfeld) und „Abbrechen“ schließen das Feld, der eingegebene Text bleibt in der Liste erhalten.
-// Während einer Anfrage ist beides gesperrt: das Feld soll nicht mitten im Senden verschwinden.
-function TextPanel({ className, label, hint, value, onChange, submitLabel, onSubmit, onClose, busy }) {
- const count = charCount(value)
- // preventDefault: die Handy-Leiste (App.jsx) schließt bei Esc nur, wenn das Ereignis nicht behandelt wurde
- const onKeyDown = (e) => {
- if (e.key !== 'Escape' || busy) return
- e.preventDefault()
- e.stopPropagation()
- onClose()
- }
- return (
-
- {hint &&
{hint}
}
-
- )
-}
-
-// Eine Zeile des Pults. Die Zeile hält nur, was ihr gehört (läuft gerade etwas, Rückfrage, Meldung); der bearbeitete Text liegt in der
-// Liste, damit er einen Wechsel des Filters und das Neuladen überlebt. api.pultAction führt aus, der Dienst entscheidet über Rechte und
-// Reihenfolge (409 mit deutschem Satz); danach wird die Liste neu geholt.
-function PultRow({ api, item, row, text, fields, onText, onField, refresh, onFinished, onDetails }) {
- const [busy, setBusy] = useState(false)
- const [confirming, setConfirming] = useState(null) // null | 'discard' | 'approve' | 'build': welche Rückfrage gerade gilt
- const [open, setOpen] = useState(null) // null | 'reply' | 'paste': welches Feld aufgeklappt ist
- const [note, setNote] = useState(null) // { kind: 'ok' | 'err', text }
- const busyRef = useRef(false) // sperrt schon im selben Takt, in dem busy noch nicht greift (Doppelklick)
- const aliveRef = useRef(true)
- const actionsRef = useRef(null) // die Aktionsgruppe: darin sucht close() den Knopf, der ein Feld geöffnet hat
- const rowRef = useRef(null) // die Zeile selbst (tabIndex -1): letzter Ort für den Fokus, wenn der Knopf nach der Aktion weg ist
- const returnTo = useRef(null) // { control, panel }: wer beim Start der Aktion den Fokus hatte und welches Feld damals offen war
- const wasBusy = useRef(false)
-
- useEffect(() => {
- aliveRef.current = true
- return () => { aliveRef.current = false }
- }, [])
-
- // Die Rückfrage vor dem Verwerfen, Freigeben und Bauen verfällt nach 4 s
- useEffect(() => {
- if (confirming === null) return undefined
- const timer = window.setTimeout(() => setConfirming(null), CONFIRM_MS)
- return () => window.clearTimeout(timer)
- }, [confirming])
-
- // Fokus nach einer Aktion (Endprüfung W3): die gesperrten Knöpfe verlieren den Fokus an . Ist die Anfrage vorbei (busy wieder false, die Knöpfe sind
- // frei), geht er zurück: an den Knopf, der ihn hatte; ist der weg, an das offene Feld, an den Knopf, der das Feld öffnete, sonst an die Zeile.
- // Hat die Person den Fokus inzwischen selbst woanders, bleibt er dort. Eine Zeile, die die Liste verlassen hat, gibt ihn nicht mehr zurück: das tut die
- // Meldung oben (PultList).
- useEffect(() => {
- if (busy) {
- wasBusy.current = true
- return
- }
- if (!wasBusy.current) return
- wasBusy.current = false
- const memory = returnTo.current
- returnTo.current = null
- if (!memory?.control) return // beim Start hatte nichts den Fokus: es ging nichts verloren
- const row = rowRef.current
- restoreFocus(browserDocument(), [
- memory.control,
- row?.querySelector(PANEL_FIELD),
- memory.panel ? actionsRef.current?.querySelector(`[data-opens="${memory.panel}"]`) : null,
- row,
- ])
- }, [busy])
-
- const begin = () => {
- if (busyRef.current) return false
- busyRef.current = true
- returnTo.current = { control: activeControl(browserDocument()), panel: open } // ohne Fokus beim Start (Safari fokussiert Knöpfe nicht) gibt es nichts zurückzugeben
- setBusy(true)
- setConfirming(null)
- setNote(null)
- return true
- }
- const end = () => {
- busyRef.current = false
- if (aliveRef.current) setBusy(false)
- }
-
- // Aktion am Dienst. finished: der Eintrag verlässt die Liste, die Erfolgsmeldung steht deshalb oben bei der Liste und nicht in der Zeile.
- // done: Meldung bei Erfolg, als Text oder als Funktion der Antwort des Dienstes. action: Name der Aktion für den Fehlertext (bei reply,
- // approve-preview und start-build heißt eine Zeitüberschreitung oder eine Fehlerseite des Proxys (502/504) „Kann gesendet sein – Pult neu laden.“, D14).
- // Ergebnis: ob es geklappt hat.
- const run = async (call, done, finished, action) => {
- if (!begin()) return false
- let ok = false
- try {
- const result = await call()
- const message = typeof done === 'function' ? done(result) : done
- if (finished) onFinished(row.id, `„${row.title}“ ${message}`, returnTo.current?.control != null)
- else if (aliveRef.current) setNote({ kind: 'ok', text: message })
- ok = true
- } catch (err) {
- if (aliveRef.current) setNote({ kind: 'err', text: actionErrorText(err, action) })
- }
- await refresh()
- end()
- return ok
- }
-
- // Kopieren & öffnen: copyAndOpen steht ganz vorn und ohne await davor, nur im Zug des Klicks darf der Browser den Tab öffnen.
- // Kopiert wird mit der strengen Fassung (copyTextChecked): scheitern Clipboard-API und Notweg, meldet die Zeile das ehrlich,
- // statt „Kopiert“ zu zeigen, während noch der alte Inhalt der Zwischenablage drinliegt.
- // Danach wird der Eintrag best-effort übernommen; ein Kollege, der schon dran ist, erscheint als Hinweis.
- const copyOpen = async () => {
- if (!begin()) return
- const result = await copyAndOpen({ text, url: row.sourceHref, copy: copyTextChecked, open: openTab })
- let next = copyNote(result, row.sourceHref !== null)
- if (!row.claimedByMe) {
- try {
- await api.pultAction(row.id, 'claim')
- } catch (err) {
- const hint = err?.status === 409 && row.claimedByOther ? `Wird schon von ${row.claimedByLabel} bearbeitet.` : err?.message || ACTION_FAILED
- next = { kind: 'err', text: `${next.text} ${hint}` }
- }
- await refresh()
- }
- if (aliveRef.current) setNote(next)
- end()
- }
-
- const discard = () => {
- if (confirming !== 'discard') {
- setConfirming('discard')
- return
- }
- run(() => api.pultAction(row.id, 'discard', {}), 'verworfen.', true)
- }
-
- // Vorschau freigeben: der erste Klick fragt nach, der zweite binnen 4 s reiht den Link an den Interessenten ein (Max sendet im Sendefenster).
- // Nur mit gültiger Vorschau-Adresse (https, Vorschau-Server): was der Mensch nicht ansehen kann, gibt er nicht frei (Endprüfung W5).
- const approve = () => {
- if (!row.previewHref) return // ohne gültige Adresse sieht der Mensch die Vorschau nicht: der Knopf ist gesperrt, das hier fängt jeden anderen Weg ab
- if (confirming !== 'approve') {
- setConfirming('approve')
- return
- }
- run(() => api.pultAction(row.id, 'approve-preview'), 'freigegeben. Max sendet den Link im nächsten Sendefenster.', true, 'approve-preview')
- }
-
- // Bau starten wie bei einem „Ja“ des Interessenten (für Einträge „unsicher“): ein Opus-Lauf, der sich nicht zurücknehmen lässt. Wie beim Freigeben
- // fragt der erste Klick nach, der zweite binnen 4 s startet den Bau.
- const startBuild = () => {
- if (confirming !== 'build') {
- setConfirming('build')
- return
- }
- run(() => api.pultAction(row.id, 'start-build'), 'wird gebaut.', true, 'start-build')
- }
-
- // Die Felder „Als Mensch antworten“ und „Antwort einfügen“ sind getrennt: die Antwort ist mit dem Entwurf (oder dem bearbeiteten Text)
- // vorbelegt, das Einfüge-Feld beginnt leer. Beide liegen in der Liste, damit der Text einen Wechsel des Filters überlebt.
- const replyValue = fields.reply ?? text
- const pasteValue = fields.paste ?? ''
- const toggle = (which) => setOpen((current) => (current === which ? null : which))
- // Feld schließen (Esc, „Abbrechen“) und den Fokus an den Knopf zurückgeben, der es geöffnet hat: der bleibt eingehängt, der Fokus kann sofort dorthin.
- // Der Knopf trägt seine Kennung in data-opens (Button reicht sie weiter; ein ref ginge an Button vorbei).
- const close = (which) => {
- setOpen(null)
- actionsRef.current?.querySelector(`[data-opens="${which}"]`)?.focus()
- }
-
- const sendReply = () => {
- const check = validateReplyText(replyValue)
- if (!check.ok) {
- setNote({ kind: 'err', text: check.error })
- return
- }
- run(() => api.pultAction(row.id, 'reply', { text: check.text }), 'gesendet.', true, 'reply')
- }
-
- // Eingefügte Kundenantwort: bei Erfolg wird das Feld geleert und zugeklappt (der Eintrag bleibt in der Liste, Nora sortiert die Antwort ein)
- const sendPaste = () => {
- const check = validateReplyText(pasteValue)
- if (!check.ok) {
- setNote({ kind: 'err', text: check.error })
- return
- }
- run(async () => {
- const result = await api.pultAction(row.id, 'paste', { text: check.text })
- onField(row.id, 'paste', '')
- if (aliveRef.current) setOpen(null)
- return result
- }, pasteDoneText, false, 'paste')
- }
-
- const hasActions = row.canSend || row.canClaim || row.canDone || row.canDiscard || row.canReply || row.canApprovePreview || row.canStartBuild || row.canPaste
-
- const { prospect } = row
- const place = prospect ? [prospect.postalCode, prospect.city].filter(Boolean).join(' ') : ''
- const info = [place, prospect && prospect.score != null ? `Score ${prospect.score}` : ''].filter(Boolean).join(' · ')
- // Der Mail-Link trägt den Text aus dem Feld (auch Bearbeitetes), nicht den gespeicherten Entwurf
- const mail = row.kind === 'mail_antwort' && prospect && prospect.email
- ? mailtoHref({ to: prospect.email, subject: prospect.name ? `Re: ${prospect.name}` : '', body: text })
- : ''
-
- return (
-
- {row.stale.soon && {row.stale.label}}
- {row.claimedByLabel && übernommen von {row.claimedByLabel}}
- {/* Abgeschlossen: was daraus wurde, wer es tat und wann (das Alter ab Anlage führte dort in die Irre) */}
- {row.doneLine ? {row.doneLine} : row.ageLabel && {row.ageLabel}}
- {info && {info}}
- {row.sourceHref && (
- Anzeige öffnen
- )}
- {prospect && (
-
- )}
-
-
- {item.originalText && (
-
- Originalanfrage
-
{item.originalText}
-
- )}
-
- {row.canSend ? (
-
- )
-}
-
-// Reiter „Freigabe“: die Einträge des Pults, die ein Mensch absendet, übernimmt oder abschließt. Nichts hier versendet selbst etwas:
-// „Kopieren & öffnen“ bringt die Antwort in die Zwischenablage und öffnet die Anzeige, abgeschickt wird dort von Hand.
-// me = { id, name } des Angemeldeten.
-export default function PultList({ api, me }) {
+export default function PultList({ api, me, kpis, onBack }) {
const [filterId, setFilterId] = useState('offen')
const [drafts, setDrafts] = useState({}) // bearbeitete Texte je Eintrag; was fehlt, zeigt den Entwurf des Dienstes
const [fields, setFields] = useState({}) // Texte der Felder „Als Mensch antworten“ (reply) und „Antwort einfügen“ (paste) je Eintrag
- const [finished, setFinished] = useState('') // Meldung über den zuletzt abgeschlossenen Eintrag (seine Zeile ist dann schon weg)
+ const [finished, setFinished] = useState('') // Meldung über den zuletzt abgeschlossenen Eintrag (sein Umschlag ist dann schon weg)
+ const [spaet, setSpaet] = useState(null) // { kind, text }: Ergebnis einer Aktion, die erst nach dem Schließen ihres Briefs antwortete
const [focusAsk, setFocusAsk] = useState(0) // zählt die Abschlüsse, bei denen die Person den Fokus auf einem Knopf hatte: auch eine gleiche Meldung zweimal nacheinander holt ihn
const finishedRef = useRef(null)
+ const [briefId, setBriefId] = useState(null) // Eintrag, dessen Brief offen ist (null = keiner)
+ const openerRef = useRef(null) // der Umschlag, der den Brief geöffnet hat: dorthin geht der Fokus beim Schließen
+ const backFocus = useRef(false) // true: die Person hat den Brief geschlossen, der Fokus soll an den Umschlag zurück
const [detailId, setDetailId] = useState(null) // Interessent, dessen Panel offen ist (null = keins)
+ const [zu, setZu] = useState(false) // „Pult zuklappen“: nur die Szene bleibt
+ const listeRef = useRef(null)
const filter = PULT_FILTERS.find((f) => f.id === filterId) ?? PULT_FILTERS[0]
const { items, loading, error, refresh } = usePultFeed({ api, statuses: filter.statuses })
const { employees } = useEmployees()
const nowMs = Date.now()
- // Verlässt eine Zeile die Liste, hängt ihr Knopf mit dem Fokus ab. Die Meldung oben nimmt ihn (tabIndex -1), wenn er sonst nirgends liegt:
+ // Verlässt ein Eintrag die Liste, schließt sein Brief und die Knöpfe mit dem Fokus hängen ab. Die Meldung nimmt ihn (tabIndex -1), wenn er sonst nirgends liegt:
// Tastatur- und Screenreader-Nutzer bleiben in der Nähe der Liste und hören die Bestätigung (Endprüfung W3).
useEffect(() => {
if (focusAsk === 0) return
restoreFocus(browserDocument(), [finishedRef.current])
}, [focusAsk])
+ // Schließt die Person den Brief selbst (Schließen, Esc, Hintergrund), geht der Fokus an den Umschlag zurück (sonst an den ersten Umschlag der Liste)
+ useEffect(() => {
+ if (briefId !== null || !backFocus.current) return
+ backFocus.current = false
+ const opener = openerRef.current
+ openerRef.current = null
+ const target = opener && opener.isConnected !== false && typeof opener.focus === 'function'
+ ? opener
+ : listeRef.current?.querySelector?.('.hk-umschlag')
+ if (target && typeof target.focus === 'function') target.focus({ preventScroll: true })
+ }, [briefId])
+
const chooseFilter = (id) => {
setFilterId(id)
setFinished('')
+ setSpaet(null)
}
const setText = useCallback((id, value) => setDrafts((d) => ({ ...d, [id]: value })), [])
const setField = useCallback((id, which, value) => setFields((f) => ({ ...f, [id]: { ...f[id], [which]: value } })), [])
+ const oeffne = (id, event) => {
+ openerRef.current = event?.currentTarget ?? activeControl(browserDocument())
+ setSpaet(null)
+ setBriefId(id)
+ }
+ const schliesse = () => {
+ backFocus.current = true
+ setBriefId(null)
+ }
+ // Der Eintrag verlässt die Liste: sein Brief schließt (war inzwischen ein anderer offen, bleibt der), die Meldung steht oben und holt sich den Fokus
const onFinished = useCallback((id, message, hadFocus) => {
+ backFocus.current = false
+ setBriefId((current) => (current === id ? null : current))
setFinished(message)
if (hadFocus) setFocusAsk((n) => n + 1)
setDrafts((d) => {
@@ -445,66 +93,84 @@ export default function PultList({ api, me }) {
return rest
})
}, [])
+ const onSpaet = useCallback((note) => setSpaet(note), [])
const rows = items.map((item) => ({ item, row: rowModel(item, { me, nowMs, employees }) }))
+ const offen = briefId === null ? null : rows.find(({ row }) => row.id === briefId) ?? null
+ // Ein Brief, dessen Eintrag nicht mehr in der Liste steht (ein Kollege war schneller), schließt sich: auf einem alten Stand wird nichts mehr gesendet
+ useEffect(() => {
+ if (briefId !== null && !loading && offen === null) {
+ backFocus.current = true
+ setBriefId(null)
+ }
+ })
- let body
- if (loading) {
- body =
Wird geladen …
- } else if (rows.length === 0 && error) {
- body = (
- {error}
)
}
diff --git a/src/components/workers/pult/Brief.jsx b/src/components/workers/pult/Brief.jsx
new file mode 100644
index 0000000..9f10595
--- /dev/null
+++ b/src/components/workers/pult/Brief.jsx
@@ -0,0 +1,504 @@
+// Der Brief des Pults (Hustler-Pixelwelt T9, Spec E11/E13/E14): Dialog über dem Klemmbrett mit dem Inhalt des bisherigen Zeilenbausteins PultRow – alle Zustände
+// (busy, confirming, open, note), alle Aktionen über api.pultAction, alle Texte, Rückfragen (4 s), Kopieren & öffnen, Mail-Link, Antworten-/Einfügen-Feld mit Zähler
+// und 8000-Grenze, Original und Entwurf – nur mit neuem Markup (Brief-Papier, Stempel-Knöpfe, Klassen der Vorlage Freigabe.dc.html mit hk-). Welche Knöpfe erscheinen,
+// entscheidet allein rowModel (src/lib/workersPult.js): canClaim, canSend, canReply, canPaste, canApprovePreview, canStartBuild, canDone, canDiscard,
+// sourceHref, previewHref, mailtoHref, claimedByOther. Der Brief hält nur, was ihm gehört (läuft gerade etwas, Rückfrage, offenes Feld, Meldung); der bearbeitete
+// Text und die Feldtexte liegen in PultList, damit sie Schließen, Filterwechsel und Neuladen überleben. api.pultAction führt aus, der Dienst entscheidet über Rechte
+// und Reihenfolge (409 mit deutschem Satz); danach wird die Liste neu geholt.
+//
+// Dialog: role="dialog" aria-modal, Fokus beim Öffnen auf den ersten Knopf („Schließen“), Tab bleibt im Brief, Esc schließt erst ein offenes Feld, dann den Brief
+// (während einer Anfrage bleibt ein offenes Feld stehen). Schließt der Brief nach einem Abschluss (onFinished), steht die Meldung in der Liste und nimmt den Fokus;
+// schließt die Person ihn selbst (onClose), gibt PultList den Fokus an den Umschlag zurück. Eine Aktion, die nach dem Schließen noch antwortet, meldet ihr Ergebnis
+// über onSpaet an die Liste (sonst ginge ein Fehler wie „Kann gesendet sein – Pult neu laden.“ verloren).
+// Kein CSS-Import (die Stile hängt WorkersPage ein), keine Bilder, Fremdtext nur als Textknoten.
+import { useEffect, useRef, useState } from 'react'
+import { copyTextChecked } from '../../CopyableCredential'
+import { umschlagModell } from '../../../lib/hustlerPult'
+import { activeControl, restoreFocus } from '../../../lib/workersFocus'
+import {
+ ACTION_FAILED, REPLY_TEXT_MAX, actionErrorText, charCount, copyAndOpen, mailtoHref, pasteDoneText, validateReplyText,
+} from '../../../lib/workersPult'
+import Marken from './Marken'
+
+const CONFIRM_MS = 4000 // so lange gilt die Rückfrage „Wirklich verwerfen?“, „Wirklich freigeben?“ bzw. „Wirklich bauen?“ nach dem ersten Klick
+// Kosten und Wirkung von „Vorschau bauen“ (Rückfrage und Hinweis am Knopf); bewusst ohne „nur wenn der Interessent Ja gesagt hat“: der Knopf gilt gerade für unklare Antworten
+const BUILD_HINT = 'Startet einen Opus-Lauf und baut die Vorschau, als hätte der Interessent Ja gesagt.'
+
+// Das document des Browsers; unter Node (Tests, Server) gibt es keins, dann bleibt der Fokus unangetastet
+const browserDocument = () => (typeof document === 'undefined' ? null : document)
+// Selektor des Textfelds eines aufgeklappten Feldes (Antwort oder Einfügen)
+const PANEL_FIELD = '.hk-feld-panel textarea'
+
+// Neuer Tab ohne Rückbezug auf diese Seite
+const openTab = (url) => window.open(url, '_blank', 'noopener,noreferrer')
+
+// Meldung nach „Kopieren & öffnen“: ok nur, wenn alles geklappt hat, was der Brief anbietet (ohne Anzeigen-Adresse gibt es nur Kopieren)
+function copyNote(result, hasUrl) {
+ if (!hasUrl) {
+ return result.copied
+ ? { kind: 'ok', text: 'Kopiert.' }
+ : { kind: 'err', text: 'Kopieren hat nicht geklappt. Bitte den Text von Hand markieren und kopieren.' }
+ }
+ if (result.copied && result.opened) return { kind: 'ok', text: 'Kopiert und geöffnet.' }
+ if (result.copied) return { kind: 'err', text: 'Kopiert, aber die Anzeige ließ sich nicht öffnen. Ein Pop-up-Blocker kann der Grund sein.' }
+ if (result.opened) return { kind: 'err', text: 'Geöffnet, aber das Kopieren hat nicht geklappt. Bitte den Text von Hand kopieren.' }
+ return { kind: 'err', text: 'Kopieren und Öffnen haben nicht geklappt.' }
+}
+
+// Zugänglicher Name für ein Bedienelement, das in jedem Brief wiederkehrt: das sichtbare Wort steht vorn (WCAG 2.5.3 „Beschriftung im
+// Namen“), dahinter der Titel des Eintrags, damit eine Liste aller „Anzeige öffnen“-Links im Screenreader unterscheidbar ist. Ohne Titel
+// (leer oder fehlend) gilt der schlichte Name, nie „… zu undefined“.
+function rowName(label, title, joiner = ': ') {
+ const t = typeof title === 'string' ? title.trim() : ''
+ return t === '' ? label : `${label}${joiner}${t}`
+}
+
+// Stempel-Knopf der Vorlage (stempel + Art: haupt | normal | gruen | rot, stempel-frage während der Rückfrage, Icon ic-…). Alles Weitere (onClick, disabled, title,
+// aria-expanded, data-opens) geht unverändert an den Knopf.
+function Stempel({ art, icon, frage = false, aus = false, children, ...rest }) {
+ const klasse = `hk-stempel hk-stempel-${art}${frage ? ' hk-stempel-frage' : ''}${aus ? ' hk-stempel-aus' : ''}`
+ return (
+
+ )
+}
+
+// Aufgeklapptes Feld unter den Aktionen: „Als Mensch antworten“ (hk-feld-antwort) und „Antwort des Kunden einfügen“ (hk-feld-einfuegen).
+// Beschriftung, Textfeld, Hinweis, Zähler (Zeichen wie der Dienst sie zählt, höchstens REPLY_TEXT_MAX) und zwei Knöpfe; die Prüfung macht der Brief beim Absenden.
+// Esc (aus dem ganzen Feld heraus, nicht nur aus dem Textfeld) und „Abbrechen“ schließen das Feld, der eingegebene Text bleibt in der Liste erhalten.
+// Während einer Anfrage ist beides gesperrt: das Feld soll nicht mitten im Senden verschwinden.
+function TextPanel({ className, caption, label, hint, value, onChange, submitLabel, onSubmit, onClose, busy }) {
+ const count = charCount(value)
+ const fieldRef = useRef(null)
+ const panelRef = useRef(null)
+ // Fokus ins Textfeld beim Aufklappen, ohne dass die Seite springt; danach rollt das ganze Feld (mit Zähler und Knöpfen) nur bei Bedarf ins Bild
+ useEffect(() => {
+ const el = fieldRef.current
+ if (!el || typeof el.focus !== 'function') return
+ el.focus({ preventScroll: true })
+ const panel = panelRef.current
+ if (panel && typeof panel.scrollIntoView === 'function') panel.scrollIntoView({ block: 'nearest' })
+ }, [])
+ // preventDefault: die Handy-Leiste (App.jsx) schließt bei Esc nur, wenn das Ereignis nicht behandelt wurde
+ const onKeyDown = (e) => {
+ if (e.key !== 'Escape' || busy) return
+ e.preventDefault()
+ e.stopPropagation()
+ onClose()
+ }
+ return (
+
+ )
+}
+
+// Props: api (pultAction), item (Eintrag des Dienstes: Original, Entwurf), row (rowModel), text (Entwurf oder bearbeiteter Text), fields ({ reply, paste }),
+// onText(id, value), onField(id, which, value), refresh() (Liste neu holen), onFinished(id, message, hadFocus) (der Eintrag verlässt die Liste: PultList schließt den Brief,
+// zeigt die Meldung und nimmt den Fokus), onClose() (die Person schließt), onDetails(prospectId), onSpaet(note) (Ergebnis, das nach dem Schließen eintrifft)
+export default function Brief({ api, item, row, text, fields, onText, onField, refresh, onFinished, onClose, onDetails, onSpaet }) {
+ const [busy, setBusy] = useState(false)
+ const [confirming, setConfirming] = useState(null) // null | 'discard' | 'approve' | 'build': welche Rückfrage gerade gilt
+ const [open, setOpen] = useState(null) // null | 'reply' | 'paste': welches Feld aufgeklappt ist
+ const [note, setNote] = useState(null) // { kind: 'ok' | 'err', text }
+ const busyRef = useRef(false) // sperrt schon im selben Takt, in dem busy noch nicht greift (Doppelklick)
+ const aliveRef = useRef(true)
+ const actionsRef = useRef(null) // die Aktionsgruppe: darin sucht close() den Knopf, der ein Feld geöffnet hat
+ const briefRef = useRef(null) // der Brief selbst (tabIndex -1): letzter Ort für den Fokus, wenn der Knopf nach der Aktion weg ist
+ const ersterRef = useRef(null) // „Schließen“: Fokus beim Öffnen
+ const returnTo = useRef(null) // { control, panel }: wer beim Start der Aktion den Fokus hatte und welches Feld damals offen war
+ const wasBusy = useRef(false)
+
+ useEffect(() => {
+ aliveRef.current = true
+ return () => { aliveRef.current = false }
+ }, [])
+
+ // Beim Öffnen: Fokus auf den ersten Knopf (Spec E14)
+ useEffect(() => {
+ const erster = ersterRef.current
+ if (erster && typeof erster.focus === 'function') erster.focus({ preventScroll: true })
+ }, [])
+
+ // Die Rückfrage vor dem Verwerfen, Freigeben und Bauen verfällt nach 4 s
+ useEffect(() => {
+ if (confirming === null) return undefined
+ const timer = window.setTimeout(() => setConfirming(null), CONFIRM_MS)
+ return () => window.clearTimeout(timer)
+ }, [confirming])
+
+ // Fokus nach einer Aktion (Endprüfung W3): die gesperrten Knöpfe verlieren den Fokus an . Ist die Anfrage vorbei (busy wieder false, die Knöpfe sind
+ // frei), geht er zurück: an den Knopf, der ihn hatte; ist der weg, an das offene Feld, an den Knopf, der das Feld öffnete, sonst an den Brief.
+ // Hat die Person den Fokus inzwischen selbst woanders, bleibt er dort. Ein Brief, der nach einem Abschluss geschlossen hat, gibt ihn nicht mehr zurück:
+ // das tut die Meldung in der Liste (PultList).
+ useEffect(() => {
+ if (busy) {
+ wasBusy.current = true
+ return
+ }
+ if (!wasBusy.current) return
+ wasBusy.current = false
+ const memory = returnTo.current
+ returnTo.current = null
+ if (!memory?.control) return // beim Start hatte nichts den Fokus: es ging nichts verloren
+ const brief = briefRef.current
+ restoreFocus(browserDocument(), [
+ memory.control,
+ brief?.querySelector(PANEL_FIELD),
+ memory.panel ? actionsRef.current?.querySelector(`[data-opens="${memory.panel}"]`) : null,
+ brief,
+ ])
+ }, [busy])
+
+ const begin = () => {
+ if (busyRef.current) return false
+ busyRef.current = true
+ returnTo.current = { control: activeControl(browserDocument()), panel: open } // ohne Fokus beim Start (Safari fokussiert Knöpfe nicht) gibt es nichts zurückzugeben
+ setBusy(true)
+ setConfirming(null)
+ setNote(null)
+ return true
+ }
+ const end = () => {
+ busyRef.current = false
+ if (aliveRef.current) setBusy(false)
+ }
+ // Meldung des Briefs; ist er schon zu, geht sie an die Liste
+ const report = (next) => {
+ if (aliveRef.current) setNote(next)
+ else if (typeof onSpaet === 'function') onSpaet(next)
+ }
+
+ // Aktion am Dienst. finished: der Eintrag verlässt die Liste, die Erfolgsmeldung steht deshalb bei der Liste und nicht im Brief.
+ // done: Meldung bei Erfolg, als Text oder als Funktion der Antwort des Dienstes. action: Name der Aktion für den Fehlertext (bei reply,
+ // approve-preview und start-build heißt eine Zeitüberschreitung oder eine Fehlerseite des Proxys (502/504) „Kann gesendet sein – Pult neu laden.“, D14).
+ // Ergebnis: ob es geklappt hat.
+ const run = async (call, done, finished, action) => {
+ if (!begin()) return false
+ let ok = false
+ try {
+ const result = await call()
+ const message = typeof done === 'function' ? done(result) : done
+ if (finished) onFinished(row.id, `„${row.title}“ ${message}`, aliveRef.current && returnTo.current?.control != null)
+ else report({ kind: 'ok', text: message })
+ ok = true
+ } catch (err) {
+ report({ kind: 'err', text: actionErrorText(err, action) })
+ }
+ await refresh()
+ end()
+ return ok
+ }
+
+ // Kopieren & öffnen: copyAndOpen steht ganz vorn und ohne await davor, nur im Zug des Klicks darf der Browser den Tab öffnen.
+ // Kopiert wird mit der strengen Fassung (copyTextChecked): scheitern Clipboard-API und Notweg, meldet der Brief das ehrlich,
+ // statt „Kopiert“ zu zeigen, während noch der alte Inhalt der Zwischenablage drinliegt.
+ // Danach wird der Eintrag best-effort übernommen; ein Kollege, der schon dran ist, erscheint als Hinweis.
+ const copyOpen = async () => {
+ if (!begin()) return
+ const result = await copyAndOpen({ text, url: row.sourceHref, copy: copyTextChecked, open: openTab })
+ let next = copyNote(result, row.sourceHref !== null)
+ if (!row.claimedByMe) {
+ try {
+ await api.pultAction(row.id, 'claim')
+ } catch (err) {
+ const hint = err?.status === 409 && row.claimedByOther ? `Wird schon von ${row.claimedByLabel} bearbeitet.` : err?.message || ACTION_FAILED
+ next = { kind: 'err', text: `${next.text} ${hint}` }
+ }
+ await refresh()
+ }
+ report(next)
+ end()
+ }
+
+ const discard = () => {
+ if (confirming !== 'discard') {
+ setConfirming('discard')
+ return
+ }
+ run(() => api.pultAction(row.id, 'discard', {}), 'verworfen.', true)
+ }
+
+ // Vorschau freigeben: der erste Klick fragt nach, der zweite binnen 4 s reiht den Link an den Interessenten ein (Max sendet im Sendefenster).
+ // Nur mit gültiger Vorschau-Adresse (https, Vorschau-Server): was der Mensch nicht ansehen kann, gibt er nicht frei (Endprüfung W5).
+ const approve = () => {
+ if (!row.previewHref) return // ohne gültige Adresse sieht der Mensch die Vorschau nicht: der Knopf ist gesperrt, das hier fängt jeden anderen Weg ab
+ if (confirming !== 'approve') {
+ setConfirming('approve')
+ return
+ }
+ run(() => api.pultAction(row.id, 'approve-preview'), 'freigegeben. Max sendet den Link im nächsten Sendefenster.', true, 'approve-preview')
+ }
+
+ // Bau starten wie bei einem „Ja“ des Interessenten (für Einträge „unsicher“): ein Opus-Lauf, der sich nicht zurücknehmen lässt. Wie beim Freigeben
+ // fragt der erste Klick nach, der zweite binnen 4 s startet den Bau.
+ const startBuild = () => {
+ if (confirming !== 'build') {
+ setConfirming('build')
+ return
+ }
+ run(() => api.pultAction(row.id, 'start-build'), 'wird gebaut.', true, 'start-build')
+ }
+
+ // Die Felder „Als Mensch antworten“ und „Antwort einfügen“ sind getrennt: die Antwort ist mit dem Entwurf (oder dem bearbeiteten Text)
+ // vorbelegt, das Einfüge-Feld beginnt leer. Beide liegen in der Liste, damit der Text Schließen und einen Wechsel des Filters überlebt.
+ const replyValue = fields.reply ?? text
+ const pasteValue = fields.paste ?? ''
+ const toggle = (which) => setOpen((current) => (current === which ? null : which))
+ // Feld schließen (Esc, „Abbrechen“) und den Fokus an den Knopf zurückgeben, der es geöffnet hat: der bleibt eingehängt, der Fokus kann sofort dorthin.
+ // Der Knopf trägt seine Kennung in data-opens.
+ const close = (which) => {
+ setOpen(null)
+ actionsRef.current?.querySelector(`[data-opens="${which}"]`)?.focus()
+ }
+
+ const sendReply = () => {
+ const check = validateReplyText(replyValue)
+ if (!check.ok) {
+ setNote({ kind: 'err', text: check.error })
+ return
+ }
+ run(() => api.pultAction(row.id, 'reply', { text: check.text }), 'gesendet.', true, 'reply')
+ }
+
+ // Eingefügte Kundenantwort: bei Erfolg wird das Feld geleert und zugeklappt (der Eintrag bleibt in der Liste, Nora sortiert die Antwort ein)
+ const sendPaste = () => {
+ const check = validateReplyText(pasteValue)
+ if (!check.ok) {
+ setNote({ kind: 'err', text: check.error })
+ return
+ }
+ run(async () => {
+ const result = await api.pultAction(row.id, 'paste', { text: check.text })
+ onField(row.id, 'paste', '')
+ if (aliveRef.current) setOpen(null)
+ return result
+ }, pasteDoneText, false, 'paste')
+ }
+
+ const hasActions = row.canSend || row.canClaim || row.canDone || row.canDiscard || row.canReply || row.canApprovePreview || row.canStartBuild || row.canPaste
+
+ const { prospect } = row
+ const m = umschlagModell(row)
+ // Der Mail-Link trägt den Text aus dem Feld (auch Bearbeitetes), nicht den gespeicherten Entwurf
+ const mail = row.kind === 'mail_antwort' && prospect && prospect.email
+ ? mailtoHref({ to: prospect.email, subject: prospect.name ? `Re: ${prospect.name}` : '', body: text })
+ : ''
+ const original = typeof item.originalText === 'string' ? item.originalText : ''
+ const draft = typeof item.draft === 'string' ? item.draft : ''
+
+ // Tasten am Brief: Esc schließt erst ein offenes Feld (das Textfeld schließt sich selbst, hier gilt es für den Rest des Briefs), dann den Brief;
+ // Tab wandert im Kreis durch die Bedienelemente des Briefs
+ const taste = (e) => {
+ if (e.key === 'Escape') {
+ e.preventDefault()
+ e.stopPropagation()
+ if (open !== null) {
+ if (!busy) close(open)
+ return
+ }
+ onClose()
+ return
+ }
+ if (e.key !== 'Tab') return
+ const brief = briefRef.current
+ if (!brief || typeof brief.querySelectorAll !== 'function') return
+ const items = [...brief.querySelectorAll('button, textarea, a[href]')].filter((el) => !el.disabled)
+ if (items.length === 0) return
+ const doc = browserDocument()
+ const aktiv = doc ? doc.activeElement : null
+ if (!e.shiftKey && aktiv === items[items.length - 1]) {
+ e.preventDefault()
+ items[0].focus()
+ } else if (e.shiftKey && (aktiv === items[0] || aktiv === brief)) {
+ e.preventDefault()
+ items[items.length - 1].focus()
+ }
+ }
+
+ return (
+
+
+ )
+}
diff --git a/src/components/workers/pult/Klemmbrett.jsx b/src/components/workers/pult/Klemmbrett.jsx
new file mode 100644
index 0000000..d969689
--- /dev/null
+++ b/src/components/workers/pult/Klemmbrett.jsx
@@ -0,0 +1,94 @@
+// Das Klemmbrett des Pults (Hustler-Pixelwelt T9, Spec E11): Reiter Wartet / Veraltet / Erledigt (PULT_FILTERS) mit Zahl, das Brett mit Klammer, Untertitel,
+// Meldungen, Laden, Fehler mit „Neu laden“, Leerzustände und die Umschläge. Ein Umschlag ist ein Knopf und öffnet den Brief (Brief.jsx); er zeigt Art-Icon,
+// Titel, Art · Alter, Ort · Score und je nach Zustand Frist, „veraltet“, Halter oder den Ergebnis-Stempel. Reine Darstellung: Daten, Zustand und Klicks
+// kommen von PultList. Aufbau und Klassen wie Freigabe.dc.html (Block „Klemmbrett“) mit hk- davor. Kein CSS-Import, keine Bilder, Fremdtext nur als Textknoten.
+//
+// „von “ der Vorlage fehlt mit Absicht: ein Pult-Eintrag des Dienstes (Übersicht §4.11) trägt kein Feld, das den bringenden Hustler nennt
+// (addPult kennt workerId nur beim Anlegen, gespeichert wird es nicht); erfunden wird nichts.
+import { PULT_FILTERS } from '../../../lib/workersPult'
+import { LEER, REITER_KLASSE, UNTERTITEL, reiterName, umschlagModell } from '../../../lib/hustlerPult'
+import Marken from './Marken'
+
+function Umschlag({ row, onOeffne }) {
+ const m = umschlagModell(row)
+ return (
+
Verbindung gestört: {fehler} Es wird weiter versucht.
}
+ {inhalt}
+
+ >
+ )
+}
diff --git a/src/components/workers/pult/Marken.jsx b/src/components/workers/pult/Marken.jsx
new file mode 100644
index 0000000..80c8114
--- /dev/null
+++ b/src/components/workers/pult/Marken.jsx
@@ -0,0 +1,21 @@
+// Die kleinen Marken eines Pult-Eintrags (Frist, „veraltet“, Halter, Ergebnis-Stempel): im Umschlag der Liste und in der Kopfzeile des Briefs gleich.
+// Klassen und Aufbau wie Freigabe.dc.html (marke, ic-klein, abdruck) mit hk- davor; die Farben stehen in styles/hustler/hustler-pult-ui.css.
+// Ein Marken-Satz kommt aus umschlagModell (src/lib/hustlerPult.js). Kein CSS-Import, keine Bilder, Fremdtext nur als Textknoten.
+
+// Props: m = umschlagModell(row)
+export default function Marken({ m }) {
+ return (
+ <>
+ {m.frist && (
+ {m.frist}
+ )}
+ {m.gehalten && (
+ {m.gehalten}
+ )}
+ {m.veraltet && (
+ veraltet
+ )}
+ {m.fertig && {m.fertig.text}}
+ >
+ )
+}
diff --git a/src/components/workers/pult/PultSzene.jsx b/src/components/workers/pult/PultSzene.jsx
new file mode 100644
index 0000000..26cd959
--- /dev/null
+++ b/src/components/workers/pult/PultSzene.jsx
@@ -0,0 +1,72 @@
+// Die Szene hinter dem Klemmbrett (Hustler-Pixelwelt T9, Spec E11/E13/E14): 1:1 aus dem Markup der Zeichenfläche (pult/entwurf/Freigabe.dc.html, Block „Szene“),
+// Klassen mit hk- davor. Raum, Fensterschnee, Lichter (Flackern), Uhr mit Sekundenzeiger, Lichterkette, Aufzugtüren, Boten-Schleife (Clara, Nora), Briefkasten-Fähnchen
+// und Monitor sind reine CSS-Animationen aus styles/hustler/pult.css. Die 640 x 360 große Bühne wird „cover“ in die Fläche eingepasst (ResizeObserver, wie bindeRoot/passe
+// in logik.js). Die Boten-Schleife und das Fähnchen laufen nur, wenn `laeuft` gilt (kpis.pultOpen > 0); sonst steht die Ruhe-Klasse der Vorlage (hk-pult-ruhe) am
+// Rahmen und friert sie im Ruhebild ein. „Weniger Bewegung“ regelt das CSS der Vorlage. Dekoration: aria-hidden, keine Bedienelemente.
+// Kein CSS-Import (die Stile hängt WorkersPage ein), keine Bilder, Fremdtext nur als Textknoten.
+import { useEffect, useRef, useState } from 'react'
+import { SZENE_START, SZENE_TEILE as T, szenePassung } from '../../../lib/hustlerPult'
+
+const lage = (teil) => ({ left: teil.left, top: teil.top })
+const flaeche = (teil) => ({ left: teil.left, top: teil.top, width: teil.width, height: teil.height })
+
+// Props: laeuft (Boten-Schleife an, Standard aus)
+export default function PultSzene({ laeuft = false }) {
+ const rahmen = useRef(null)
+ const [pass, setPass] = useState(SZENE_START)
+
+ // Einpassen: beim Einhängen und bei jeder Größenänderung des Rahmens messen (ohne ResizeObserver nur einmal)
+ useEffect(() => {
+ const el = rahmen.current
+ if (!el || typeof el.getBoundingClientRect !== 'function') return undefined
+ const passe = () => {
+ const r = el.getBoundingClientRect()
+ const next = szenePassung(r.width, r.height)
+ if (next) setPass((prev) => (prev.transform === next.transform ? prev : next))
+ }
+ let beobachter = null
+ if (typeof ResizeObserver !== 'undefined') {
+ beobachter = new ResizeObserver(passe)
+ beobachter.observe(el)
+ }
+ passe()
+ return () => { if (beobachter) beobachter.disconnect() }
+ }, [])
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/lib/hustlerPult.js b/src/lib/hustlerPult.js
new file mode 100644
index 0000000..6ff8e6e
--- /dev/null
+++ b/src/lib/hustlerPult.js
@@ -0,0 +1,157 @@
+// Reine Ableitungen des Pults in der Hustler-Pixelwelt (Spec E11/E13/E14, Plan T9): Szene (Maße, Einpassen), Boten-Schleife, Reiter und ihre Zahlen,
+// Umschlag (Art-Icon, Alter, Frist, Halter, Ergebnis-Stempel, Name für Screenreader). Kein React, kein DOM, kein Appwrite: unter Node testbar (loadModule).
+// Die Klassen heißen genau wie in src/styles/hustler/pult.css (alle mit hk- davor). Fachlich bleibt alles bei workersPult.js (rowModel, Filter, Texte);
+// hier steht nur, wie ein schon fertiges Zeilenmodell aussieht. Keine Beispieldaten: die Texte sind UI-Text.
+
+import { PULT_KIND_LABELS } from './workersPult.js'
+
+const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
+const isCount = (n) => typeof n === 'number' && Number.isFinite(n) && n >= 0
+
+// ---------------------------------------------------------------------------------------------------------------
+// Szene (640 x 360 Bildpunkte, Vorlage Freigabe.dc.html): Lage der Einzelteile, soweit sie im Markup der Vorlage inline stehen
+
+export const SZENE = Object.freeze({ w: 640, h: 360 })
+
+/** Lage (left/top in Bildpunkten der Szene) und, wo die Vorlage sie nennt, Größe der Einzelteile. */
+export const SZENE_TEILE = Object.freeze({
+ fenster1: Object.freeze({ left: 106, top: 46, width: 79, height: 55 }),
+ fenster2: Object.freeze({ left: 553, top: 61, width: 47, height: 100 }),
+ auflicht: Object.freeze({ left: 1, top: 320 }),
+ tuer: Object.freeze({ left: 11, top: 219 }),
+ ruftaste: Object.freeze({ left: 66, top: 262 }),
+ funke: Object.freeze({ left: 211, top: 274 }),
+ ding: Object.freeze({ left: 14, top: 197 }),
+ briefkasten: Object.freeze({ left: 229, top: 232 }),
+ monitor: Object.freeze({ left: 158, top: 243 }),
+ dampf: Object.freeze({ left: 107, top: 244 }),
+ antenne: Object.freeze({ left: 167, top: 52 }),
+})
+
+/** Anfangsmaß, bevor gemessen wurde (Vorlage: Zoom 2, keine Verschiebung). */
+export const SZENE_START = Object.freeze({ s: 2, x: 0, y: 0, transform: 'translate(0.0px, 0.0px) scale(2.0000)' })
+
+/**
+ * Einpassen „cover“ (bindeRoot/passe in logik.js): Zoom so, dass die Szene die Fläche füllt. Ist die Szene breiter als die Fläche, wird sie um 15 %
+ * des Überstands nach links geschoben (x = min(0, 0,15 · (Breite − 640 s))); senkrecht bleibt der Raum unten sichtbar
+ * (y = min(Überstand / 2, Überstand + 36 s), Überstand = Höhe − 360 s). Ergebnis { s, x, y, transform } oder null bei leerer oder unbrauchbarer Fläche.
+ */
+export function szenePassung(width, height) {
+ if (!(typeof width === 'number' && typeof height === 'number' && width > 0 && height > 0 && Number.isFinite(width) && Number.isFinite(height))) return null
+ const s = Math.max(width / SZENE.w, height / SZENE.h)
+ const ueber = height - SZENE.h * s
+ const x = Math.min(0, (width - SZENE.w * s) * 0.15)
+ const y = Math.min(ueber / 2, ueber + 36 * s)
+ return { s, x, y, transform: `translate(${x.toFixed(1)}px, ${y.toFixed(1)}px) scale(${s.toFixed(4)})` }
+}
+
+/** Boten-Schleife (Clara, Nora, Aufzug, Fähnchen) läuft nur, solange etwas wartet: kpis.pultOpen > 0 (Spec E11); sonst ruht der Raum. */
+export function botenLaufen(kpis) {
+ return isRecord(kpis) && typeof kpis.pultOpen === 'number' && Number.isFinite(kpis.pultOpen) && kpis.pultOpen > 0
+}
+
+// ---------------------------------------------------------------------------------------------------------------
+// Klemmbrett: Reiter, Untertitel, Leerzustände
+
+/** Klasse des Reiters je Filter-Id (Farbe des Reiters in pult.css). */
+export const REITER_KLASSE = Object.freeze({ offen: 'hk-reiter-wartet', veraltet: 'hk-reiter-veraltet', erledigt: 'hk-reiter-erledigt' })
+
+/** Untertitel des Klemmbretts je Filter (Vorlage). */
+export const UNTERTITEL = Object.freeze({
+ offen: 'Hier wartet alles, was ein Mensch freigeben muss.',
+ veraltet: 'Diese Einträge liegen schon zu lange. Du kannst sie weiter bearbeiten.',
+ erledigt: 'Was du oder das Team schon abgeschlossen habt.',
+})
+
+/** Leerzustand je Filter (Titel und Hinweis, Vorlage = die bisherigen Texte der Liste). */
+export const LEER = Object.freeze({
+ offen: Object.freeze({ title: 'Nichts wartet auf dich', hint: 'Neue Antworten erscheinen hier, sobald ein Entwurf bereit ist.' }),
+ veraltet: Object.freeze({ title: 'Nichts ist veraltet', hint: 'Einträge, die zu lange liegen, landen hier und lassen sich weiter bearbeiten.' }),
+ erledigt: Object.freeze({ title: 'Noch nichts erledigt', hint: 'Gesendete, verworfene und erledigte Einträge stehen hier.' }),
+})
+
+/**
+ * „Wartet auf dich“ (Zahl im Kopf und am Reiter „Wartet“): kpis.pultOpen, wenn es eine Zahl ist; sonst, wenn der Reiter „Wartet“ offen und
+ * geladen ist, die Länge der Liste; sonst null (keine Zahl zeigen, kein „0“ erfinden).
+ */
+export function wartetZahl(kpis, aktivId, geladen) {
+ if (isRecord(kpis) && isCount(kpis.pultOpen)) return kpis.pultOpen
+ if (aktivId === 'offen' && isCount(geladen)) return geladen
+ return null
+}
+
+/**
+ * Zahl am Reiter (Spec E11): „Wartet“ zeigt kpis.pultOpen (dieselbe Zahl wie der Briefkasten im Haus und der Kopf des Pults; fehlt sie, am offenen Reiter die Länge
+ * der geladenen Liste), der offene Reiter „Veraltet“ oder „Erledigt“ die Länge seiner Liste (geladen = null, solange sie nicht da ist); die übrigen Reiter haben keine
+ * Zahl (sonst drei Abrufe alle 30 s). Ergebnis: Zahl oder null.
+ */
+export function reiterZahl(reiterId, { aktivId, kpis, geladen } = {}) {
+ if (reiterId === 'offen') return wartetZahl(kpis, aktivId, geladen)
+ if (reiterId === aktivId && isCount(geladen)) return geladen
+ return null
+}
+
+/** Name des Reiters für Screenreader: „Wartet, 3 Einträge“; ohne Zahl nur das Wort. */
+export function reiterName(label, zahl) {
+ if (!isCount(zahl)) return label
+ return `${label}, ${zahl} ${zahl === 1 ? 'Eintrag' : 'Einträge'}`
+}
+
+// ---------------------------------------------------------------------------------------------------------------
+// Umschlag
+
+/** Icon (Teil der Klasse hk-ic-) je Pult-Art; unbekannte Arten bekommen das Fragezeichen-Icon. */
+export const ART_ICONS = Object.freeze({
+ plattform_antwort: 'art-plattform',
+ mail_antwort: 'art-mail',
+ vorschau_freigabe: 'art-vorschau',
+ unsicher: 'art-unsicher',
+ beschwerde: 'art-beschwerde',
+ preisfrage: 'art-preisfrage',
+ mensch_gewuenscht: 'art-mensch',
+ bau_fehlgeschlagen: 'art-bau-kaputt',
+})
+const ART_UNBEKANNT = 'art-unsicher'
+
+/** Name und Icon einer Pult-Art. */
+export function artView(kind) {
+ return {
+ label: Object.hasOwn(PULT_KIND_LABELS, kind) ? PULT_KIND_LABELS[kind] : String(kind ?? ''),
+ icon: Object.hasOwn(ART_ICONS, kind) ? ART_ICONS[kind] : ART_UNBEKANNT,
+ }
+}
+
+export const DU_KUEMMERST = 'Du kümmerst dich darum.'
+
+/** „66640 Namborn · Score 72“ aus der Kurzfassung des Interessenten (leer, wenn nichts da ist). */
+export function prospectInfo(prospect) {
+ if (!isRecord(prospect)) return ''
+ const place = [prospect.postalCode, prospect.city].filter(Boolean).join(' ')
+ return [place, prospect.score != null ? `Score ${prospect.score}` : ''].filter(Boolean).join(' · ')
+}
+
+/**
+ * Was ein Umschlag (und die Kopfzeile des Briefs) zeigt, aus dem fertigen Zeilenmodell (rowModel):
+ * art/icon, titel, alter (bei Abgeschlossenem leer: das Alter ab Anlage führte dort in die Irre, der Stempel nennt Ergebnis, Person und Zeit),
+ * info (Ort · Score), frist („läuft in N Std. ab“), veraltet, gehalten („Du kümmerst dich darum.“ / „übernommen von …“), fertig { text, klasse } und
+ * label (zugänglicher Name des Umschlags: Art: Titel, Alter, Frist, veraltet, Halter, Ergebnis).
+ */
+export function umschlagModell(row) {
+ const art = artView(row.kind)
+ const done = typeof row.doneLine === 'string' && row.doneLine !== ''
+ const alter = done ? '' : (row.ageLabel || '')
+ const frist = row.stale?.soon ? row.stale.label : ''
+ const veraltet = row.stale?.stale === true
+ let gehalten = ''
+ if (row.claimedByMe) gehalten = DU_KUEMMERST
+ else if (row.claimedByLabel) gehalten = `übernommen von ${row.claimedByLabel}`
+ const fertig = done ? { text: row.doneLine, klasse: row.statusTone === 'green' ? 'hk-abdruck hk-stempel-gruen' : 'hk-abdruck hk-stempel-grau' } : null
+ const titel = typeof row.title === 'string' ? row.title : ''
+ const teile = [titel.trim() === '' ? art.label : `${art.label}: ${titel}`]
+ if (alter) teile.push(alter)
+ if (frist) teile.push(frist)
+ if (veraltet) teile.push('veraltet')
+ if (gehalten) teile.push(row.claimedByMe ? 'du kümmerst dich darum' : gehalten)
+ if (fertig) teile.push(fertig.text)
+ return { id: row.id, artLabel: art.label, icon: art.icon, titel, alter, info: prospectInfo(row.prospect), frist, veraltet, gehalten, fertig, label: teile.join(', ') }
+}
diff --git a/src/styles/hustler/hustler-pult-ui.css b/src/styles/hustler/hustler-pult-ui.css
new file mode 100644
index 0000000..67254a5
--- /dev/null
+++ b/src/styles/hustler/hustler-pult-ui.css
@@ -0,0 +1,138 @@
+/* Handgeschriebene Regeln des Pults (PultList.jsx, pult/*.jsx; Hustler-Pixelwelt T9, Spec E11/E13/E14). Nicht erzeugt: Szene, Reiter, Klemmbrett, Umschlag, Brief,
+ Stempel, Feld, Icons und Keyframes kommen aus pult.css (scripts/hustler-import.mjs, nie von Hand ändern). Hier stehen die Inline-Stile der Zeichenfläche
+ (pixelwelt-v2/pult/entwurf/Freigabe.dc.html) als Klassen sowie das, was die Vorlage nicht zeigt (Meldungen, Laden, Leerzustand, Handy). Alle Klassen beginnen mit hk-
+ und stehen unter der Wurzel .hk-pult. Die Stylesheets hängt WorkersPage ein (Komponenten importieren kein CSS); diese Datei kommt nach pult.css. */
+
+/* ---------- Wurzel: füllt den Inhaltsbereich (Vorlage: 100 % x 100vh, nichts scrollt außer der Liste und dem Brief) ---------- */
+.hk-pult { position: absolute; inset: 0; overflow: hidden; color: #2b2230; font-size: 15px; line-height: 20px; }
+
+/* ---------- Szene: Rahmen, Bühne (640 x 360, per transform eingepasst), Teile, Vignette ---------- */
+.hk-pult .hk-pult-szene { position: absolute; inset: 0; overflow: hidden; pointer-events: none; }
+.hk-pult .hk-pult-buehne { position: absolute; left: 0; top: 0; width: 640px; height: 360px; transform-origin: 0 0; image-rendering: pixelated; pointer-events: none; }
+.hk-pult .hk-pult-teil { position: absolute; }
+.hk-pult .hk-pult-fenster { overflow: hidden; }
+.hk-pult .hk-pult-antenne { width: 1px; height: 1px; background: #e2483d; }
+.hk-pult .hk-pult-vignette {
+ position: absolute; inset: 0; pointer-events: none;
+ background: radial-gradient(ellipse 80% 75% at 32% 52%, rgba(10, 12, 28, 0) 48%, rgba(10, 12, 28, 0.38) 80%, rgba(6, 7, 18, 0.72) 100%);
+}
+
+/* ---------- Oberfläche vor der Szene: Kopfzeile, Reiter, Klemmbrett ---------- */
+.hk-pult .hk-pult-ui {
+ position: absolute; top: 14px; right: 16px; bottom: 14px; width: min(716px, calc(100% - 32px));
+ display: flex; flex-direction: column;
+}
+.hk-pult .hk-pult-kopf { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 12px; margin-bottom: 6px; }
+.hk-pult .hk-pult-knopf { padding: 0 10px 4px 4px; }
+.hk-pult .hk-pult-zuklappen { margin-left: auto; }
+.hk-pult .hk-pult-schild { display: flex; align-items: center; gap: 10px; padding: 0 6px; color: #3a2410; }
+.hk-pult .hk-pult-h1 { margin: 0; font-family: 'Press Start 2P', 'Pixelify Sans', monospace; font-size: 16px; line-height: 16px; font-weight: 400; color: #3a2410; }
+.hk-pult .hk-pult-zahl { font-family: 'Press Start 2P', 'Pixelify Sans', monospace; font-size: 16px; line-height: 16px; }
+/* Nur für Screenreader (Beschriftung vor der Zahl) */
+.hk-pult .hk-nur-sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
+.hk-pult .hk-pult-unten { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
+
+.hk-pult .hk-reiter-leiste { position: relative; z-index: 1; display: flex; flex-wrap: nowrap; gap: 6px; padding: 0 0 0 22px; min-width: 0; }
+.hk-pult .hk-zahl { font-size: 13px; line-height: 14px; font-weight: 700; color: #5a4a3a; }
+
+/* Klemmbrett: das Brett ist ein flexibler Streifen, die Liste scrollt darin */
+.hk-pult .hk-brett { position: relative; z-index: 2; flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; gap: 8px; }
+.hk-pult .hk-brett-kopf { padding: 6px 4px; border-bottom: 2px solid rgba(90, 60, 176, 0.3); }
+.hk-pult .hk-brett-titel { margin: 0; font-size: 17px; line-height: 20px; font-weight: 700; color: #4a2fa0; }
+.hk-pult .hk-brett-unter { margin: 2px 0 0; font-size: 13px; line-height: 16px; color: #5a4a3a; }
+
+/* Meldungen auf dem Brett (Abschluss, später eintreffendes Ergebnis, Verbindung gestört) und im Brief */
+.hk-pult .hk-hinweis {
+ margin: 0 4px; padding: 6px 10px; font-size: 14px; line-height: 18px; color: #2b2230; background: #fff7d6;
+ box-shadow: 0 0 0 2px #2b2230, 2px 2px 0 2px rgba(43, 34, 48, 0.22); overflow-wrap: anywhere;
+}
+.hk-pult .hk-hinweis:focus-visible { outline: 2px solid #1a1630; box-shadow: 0 0 0 4px #ffd25a; }
+.hk-pult .hk-hinweis-ok { background: #e3f3d6; color: #1f5a2a; }
+.hk-pult .hk-hinweis-err { background: #fbe3e0; color: #8e1f17; }
+.hk-pult .hk-hinweis-warn { background: #fff3cf; color: #6b4a00; }
+.hk-pult .hk-brief .hk-hinweis { margin: 12px 0 0; }
+
+/* Laden und Leerzustand */
+.hk-pult .hk-laden { margin: 8px 4px; color: #5a4a3a; }
+.hk-pult .hk-leer { margin: 8px 4px; color: #5a4a3a; }
+.hk-pult .hk-leer-titel { margin: 0; font-weight: 700; overflow-wrap: anywhere; }
+.hk-pult .hk-leer-text { margin: 2px 0 0; font-size: 13px; overflow-wrap: anywhere; }
+.hk-pult .hk-leer-knopf { margin-top: 10px; }
+
+/* ---------- Umschlag (Knopf in der Liste): Icon links über drei Zeilen, rechts Titel, Art · Alter, Marken ---------- */
+.hk-pult .hk-liste .hk-umschlag { grid-template-columns: 32px minmax(0, 1fr); }
+.hk-pult .hk-umschlag-art { grid-area: 1 / 1 / 4 / 2; }
+.hk-pult .hk-umschlag-titel { grid-column: 2; grid-row: 1; font-size: 15px; line-height: 18px; font-weight: 700; }
+.hk-pult .hk-umschlag-art-alter { grid-column: 2; grid-row: 2; }
+.hk-pult .hk-umschlag-marken { grid-column: 2; grid-row: 3; display: flex; align-items: center; flex-wrap: wrap; gap: 2px 10px; min-height: 16px; }
+.hk-pult .hk-klein-inline { display: inline; }
+.hk-pult .hk-marke-frist { color: #9a4a10; }
+.hk-pult .hk-marke-halter { color: #4a2fa0; }
+.hk-pult .hk-marke-veraltet { color: #8a4a20; }
+
+/* ---------- Brief: Dunkelfläche mit Hintergrund-Knopf, Papier in der Mitte, Inhalt scrollt ---------- */
+.hk-pult .hk-brief-ueber { position: absolute; inset: 0; z-index: 20; display: flex; align-items: center; justify-content: center; box-sizing: border-box; padding: 16px; }
+.hk-pult .hk-brief-hinten {
+ position: absolute; inset: 0; margin: 0; padding: 0; border: 0; border-radius: 0; background: rgba(6, 7, 18, 0.6); cursor: default;
+ -webkit-appearance: none; appearance: none; -webkit-tap-highlight-color: transparent;
+}
+.hk-pult .hk-brief { position: relative; width: min(640px, 100%); max-height: 100%; display: flex; flex-direction: column; color: #2b2230; outline: none; }
+.hk-pult .hk-brief-inhalt { overflow: auto; min-height: 0; padding: 6px; scrollbar-width: thin; scrollbar-color: #5a3cb0 #e8dcc0; }
+.hk-pult .hk-brief-kopf { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
+.hk-pult .hk-brief-titelblock { flex: 1 1 auto; min-width: 0; order: 1; }
+.hk-pult .hk-brief-zu { order: 2; flex: none; }
+.hk-pult .hk-brief-art { display: flex; align-items: center; gap: 6px; font-size: 13px; color: #4a2fa0; }
+.hk-pult .hk-brief-titel { margin: 6px 0 0; font-size: 19px; line-height: 24px; font-weight: 700; overflow-wrap: anywhere; }
+.hk-pult .hk-brief-meta { margin-top: 4px; display: flex; align-items: center; flex-wrap: wrap; gap: 4px 12px; font-size: 13px; color: #5a4a3a; }
+.hk-pult .hk-link {
+ display: inline-flex; align-items: center; min-height: 24px; margin: 0; padding: 0; border: 0; background: none;
+ font: inherit; color: #4a2fa0; text-decoration: underline; text-underline-offset: 3px; cursor: pointer;
+}
+.hk-pult .hk-link:hover { color: #2f1a80; }
+.hk-pult .hk-link:focus-visible { outline: 2px solid #1a1630; outline-offset: 2px; box-shadow: 0 0 0 4px #ffd25a; }
+.hk-pult .hk-brief-h { margin: 14px 0 4px; font-size: 13px; line-height: 16px; font-weight: 700; color: #4a2fa0; }
+.hk-pult .hk-brief-h-aktionen { margin: 16px 0 6px; }
+.hk-pult .hk-brief-zitat {
+ margin: 0; padding: 8px 10px; background: #f1ead6; box-shadow: inset 4px 0 0 0 #c9a25c;
+ white-space: pre-line; font-size: 14px; line-height: 18px; overflow-wrap: anywhere;
+}
+.hk-pult .hk-feld-label { display: block; margin-top: 14px; }
+.hk-pult .hk-feld-titel { display: block; margin-bottom: 4px; font-size: 13px; line-height: 16px; color: #4a2fa0; }
+.hk-pult .hk-brief-entwurf { min-height: 96px; background: repeating-linear-gradient(#fffdf6 0 17px, rgba(90, 60, 176, 0.16) 17px 18px); }
+.hk-pult .hk-brief-lesen {
+ margin: 0; padding: 2px 10px 4px; white-space: pre-line; font-size: 15px; line-height: 20px; overflow-wrap: anywhere;
+ background: repeating-linear-gradient(transparent 0 19px, rgba(90, 60, 176, 0.18) 19px 20px);
+}
+.hk-pult .hk-aktionen { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 10px; }
+.hk-pult .hk-brief-hinweis { margin: 0; font-size: 13px; color: #5a4a3a; }
+.hk-pult .hk-brief-hinweis-bau { color: #7a2e3a; }
+.hk-pult .hk-stempel:disabled { opacity: 0.5; cursor: default; }
+.hk-pult a.hk-stempel { text-decoration: none; }
+.hk-pult .hk-feld-panel { margin-top: 14px; }
+.hk-pult .hk-feld-antwort,
+.hk-pult .hk-feld-einfuegen { padding-top: 6px; border-top: 2px dashed rgba(90, 60, 176, 0.25); }
+.hk-pult .hk-feld-panel .hk-feld-label { margin-top: 0; }
+.hk-pult .hk-feld-panel .hk-brief-hinweis { margin: 6px 0 0; }
+.hk-pult .hk-feld-fuss { margin-top: 8px; display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; }
+.hk-pult .hk-zaehler { font-size: 13px; line-height: 16px; color: #5a4a3a; }
+.hk-pult .hk-zaehler-ueber { color: #a8261d; font-weight: 700; }
+.hk-pult .hk-feld-knoepfe { display: flex; gap: 10px; }
+
+/* ---------- Handy (unter 720 px, Spec E13): Klemmbrett in voller Breite über der abgedunkelten Szene, Brief als Vollbild ---------- */
+@media (max-width: 719px) {
+ .hk-pult .hk-pult-vignette {
+ background: linear-gradient(rgba(6, 7, 18, 0.55), rgba(6, 7, 18, 0.55)), radial-gradient(ellipse 80% 75% at 32% 52%, rgba(10, 12, 28, 0) 48%, rgba(10, 12, 28, 0.38) 80%, rgba(6, 7, 18, 0.72) 100%);
+ }
+ .hk-pult .hk-pult-ui { left: 8px; right: 8px; top: 8px; bottom: 8px; width: auto; }
+ .hk-pult .hk-reiter-leiste { padding-left: 8px; }
+ /* Kopfzeile in zwei Reihen: Zurück und Zuklappen nebeneinander, darunter das Messingschild; die Klammer entfällt (sie läge auf dem dritten Reiter) */
+ .hk-pult .hk-pult-kopf { gap: 4px 8px; margin-bottom: 2px; }
+ .hk-pult .hk-pult-knopf { order: 1; }
+ .hk-pult .hk-pult-schild { order: 2; flex: 1 1 100%; }
+ .hk-pult .hk-klammer { display: none; }
+ .hk-pult .hk-brief-ueber { padding: 0; }
+ .hk-pult .hk-brief { width: 100%; height: 100%; max-height: 100%; }
+ .hk-pult .hk-feld-fuss { flex-direction: column; align-items: stretch; }
+ .hk-pult .hk-feld-knoepfe { flex-direction: column-reverse; }
+ .hk-pult .hk-feld-knoepfe .hk-stempel { justify-content: center; }
+}
diff --git a/tests/unit/hustlerPult.test.mjs b/tests/unit/hustlerPult.test.mjs
new file mode 100644
index 0000000..d8d6480
--- /dev/null
+++ b/tests/unit/hustlerPult.test.mjs
@@ -0,0 +1,688 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import fs from 'node:fs'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+import esbuild from 'esbuild'
+import { fakeReact } from './helpers/fakeReact.mjs'
+import { loadModule } from './load.mjs'
+
+// Dein Pult (Hustler-Pixelwelt T9, Spec E11/E13/E14): die reine Logik (src/lib/hustlerPult.js) und die Bauteile PultList, PultSzene, Klemmbrett, Brief im Mini-Läufer
+// anstelle von React (helpers/fakeReact.mjs). Die Aktionen des Briefs (Antworten, Freigeben, Bauen, Einfügen, Fokus nach Aktionen) prüft workersPultUi.test.mjs;
+// hier stehen Umschlag-Inhalt je Art und Status, Zahlen an den Reitern, Brief öffnen/schließen/Fokus, Boten nur bei wartenden Einträgen, „Pult zuklappen“, „◂ Hochhaus“,
+// der Weg zum Interessenten-Panel, die Klassen gegen das CSS und die Regeln der Architektur (kein CSS-/Bild-Import, kein innerHTML, sichere Links).
+
+const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
+const read = (p) => fs.readFileSync(path.join(ROOT, p), 'utf8')
+const importBundle = (out) => import(`data:text/javascript;base64,${Buffer.from(out.outputFiles[0].text).toString('base64')}`)
+const settle = () => new Promise((resolve) => setImmediate(resolve))
+
+const L = await loadModule('src/lib/hustlerPult.js')
+const P = await loadModule('src/lib/workersPult.js')
+const { PULT_KIND_LABELS, PULT_FILTERS, rowModel } = P
+
+const NOW = Date.now() // die Bauteile rechnen mit der Uhr des Rechners: Alter und Fristen hängen deshalb an dieser Zeit
+const iso = (ms) => new Date(ms).toISOString()
+const ME = { id: 'u1', name: 'Justin' }
+const EMPLOYEES = [{ userId: 'u2', displayName: 'Kenso' }]
+const rowOf = (item) => rowModel(item, { me: ME, nowMs: NOW, employees: EMPLOYEES })
+const entry = (extra = {}) => ({ $id: 'a', kind: 'plattform_antwort', status: 'offen', title: 'Suche Webdesigner', draft: 'Hallo', createdAt: iso(NOW - 3 * 3600e3), sourceUrl: 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3', prospect: { $id: 'p1', name: 'Verein', city: 'Namborn', postalCode: '66640', score: 72, email: 'x@verein.example' }, ...extra })
+
+// ---------------------------------------------------------------------------------------------------------------
+// Reine Logik
+
+test('szenePassung: „cover“ wie bindeRoot/passe der Vorlage, unbrauchbare Flächen ergeben null', () => {
+ const at = (w, h) => { const p = L.szenePassung(w, h); return p && [p.s, p.x, p.y, p.transform] }
+ assert.deepEqual(at(1280, 720), [2, 0, 0, 'translate(0.0px, 0.0px) scale(2.0000)'])
+ assert.deepEqual(at(1920, 1080), [3, 0, 0, 'translate(0.0px, 0.0px) scale(3.0000)'])
+ // breiter als hoch: die Szene ist höher als die Fläche, unten bleibt der Raum sichtbar (y = Überstand + 36 s, hier der kleinere von Überstand / 2 und Überstand + 36 s)
+ assert.deepEqual(at(2560, 1080), [4, 0, -216, 'translate(0.0px, -216.0px) scale(4.0000)'])
+ // höher als breit: die Szene ist breiter als die Fläche und rutscht um 15 % des Überstands nach links
+ assert.deepEqual(at(800, 900), [2.5, -120, 0, 'translate(-120.0px, 0.0px) scale(2.5000)'])
+ for (const [w, h] of [[0, 100], [100, 0], [-1, 5], [NaN, 5], [5, Infinity], ['5', 5], [undefined, undefined]]) assert.equal(L.szenePassung(w, h), null, `${w} x ${h}`)
+ assert.deepEqual({ ...L.SZENE }, { w: 640, h: 360 })
+ assert.equal(L.SZENE_START.transform, 'translate(0.0px, 0.0px) scale(2.0000)')
+})
+
+test('botenLaufen: nur bei kpis.pultOpen > 0 (Zahl), sonst ruht der Raum', () => {
+ assert.equal(L.botenLaufen({ pultOpen: 1 }), true)
+ assert.equal(L.botenLaufen({ pultOpen: 12 }), true)
+ for (const kpis of [undefined, null, {}, { pultOpen: 0 }, { pultOpen: -1 }, { pultOpen: '3' }, { pultOpen: NaN }, { pultOpen: null }, [], 'x']) assert.equal(L.botenLaufen(kpis), false, JSON.stringify(kpis))
+})
+
+test('Zahlen an den Reitern: „Wartet“ aus kpis.pultOpen, am offenen Reiter die Länge der Liste, die übrigen Reiter ohne Zahl', () => {
+ const z = (id, o) => L.reiterZahl(id, o)
+ // Wartet zeigt kpis.pultOpen (auch am offenen Reiter); ohne Zahl vom Dienst am offenen Reiter die Länge der Liste
+ assert.equal(z('offen', { aktivId: 'offen', kpis: { pultOpen: 5 }, geladen: 2 }), 5)
+ assert.equal(z('offen', { aktivId: 'offen', kpis: { pultOpen: 5 }, geladen: null }), 5)
+ assert.equal(z('offen', { aktivId: 'veraltet', kpis: { pultOpen: 5 }, geladen: 9 }), 5)
+ assert.equal(z('offen', { aktivId: 'veraltet', kpis: {}, geladen: 9 }), null)
+ assert.equal(z('offen', { aktivId: 'offen', kpis: undefined, geladen: 4 }), 4)
+ assert.equal(z('offen', { aktivId: 'offen', kpis: undefined, geladen: null }), null)
+ // Veraltet, Erledigt: nur wenn offen
+ assert.equal(z('veraltet', { aktivId: 'veraltet', kpis: { pultOpen: 5 }, geladen: 3 }), 3)
+ assert.equal(z('erledigt', { aktivId: 'veraltet', kpis: { pultOpen: 5 }, geladen: 3 }), null)
+ assert.equal(z('veraltet', { aktivId: 'offen', kpis: { pultOpen: 5 }, geladen: 3 }), null)
+ assert.equal(z('veraltet', { aktivId: 'veraltet', kpis: {}, geladen: null }), null)
+ // kaputte Zahlen sind keine Zahlen
+ assert.equal(z('offen', { aktivId: 'veraltet', kpis: { pultOpen: '5' }, geladen: 3 }), null)
+ assert.equal(z('offen', { aktivId: 'veraltet', kpis: { pultOpen: -2 }, geladen: 3 }), null)
+ assert.equal(z('offen', { aktivId: 'veraltet', kpis: { pultOpen: 0 }, geladen: 3 }), 0)
+ assert.equal(L.wartetZahl({ pultOpen: 3 }, 'erledigt', null), 3)
+ assert.equal(L.wartetZahl({}, 'offen', 7), 7)
+ assert.equal(L.wartetZahl({}, 'veraltet', 7), null)
+ assert.equal(L.reiterName('Wartet', 1), 'Wartet, 1 Eintrag')
+ assert.equal(L.reiterName('Wartet', 0), 'Wartet, 0 Einträge')
+ assert.equal(L.reiterName('Erledigt', 12), 'Erledigt, 12 Einträge')
+ assert.equal(L.reiterName('Veraltet', null), 'Veraltet')
+ assert.deepEqual(Object.keys(L.REITER_KLASSE), PULT_FILTERS.map((f) => f.id))
+ assert.deepEqual(Object.keys(L.UNTERTITEL), PULT_FILTERS.map((f) => f.id)); assert.deepEqual(Object.keys(L.LEER), PULT_FILTERS.map((f) => f.id))
+})
+
+test('artView: jede Pult-Art des Dienstes hat Name und Icon; Namen wie PULT_KIND_LABELS, unbekannte Art bekommt das Fragezeichen', () => {
+ assert.deepEqual(Object.keys(L.ART_ICONS).sort(), Object.keys(PULT_KIND_LABELS).sort())
+ for (const [kind, label] of Object.entries(PULT_KIND_LABELS)) assert.deepEqual({ ...L.artView(kind) }, { label, icon: L.ART_ICONS[kind] }, kind)
+ assert.deepEqual({ ...L.artView('neu_vom_dienst') }, { label: 'neu_vom_dienst', icon: 'art-unsicher' })
+ // jedes Icon gibt es im erzeugten CSS
+ const css = read('src/styles/hustler/pult.css')
+ for (const icon of Object.values(L.ART_ICONS)) assert.ok(css.includes(`.hk-ic-${icon}{`), icon)
+})
+
+test('umschlagModell: Inhalt je Art und Status – Titel, Alter, Ort · Score, Frist, veraltet, Halter, Ergebnis-Stempel, zugänglicher Name', () => {
+ const m = (extra) => L.umschlagModell(rowOf(entry(extra)))
+ // offene Plattform-Antwort: Art, Alter, Ort · Score, nichts sonst
+ let u = m()
+ assert.deepEqual({ ...u, fertig: u.fertig }, {
+ id: 'a', artLabel: 'Antwort auf Plattform-Anfrage', icon: 'art-plattform', titel: 'Suche Webdesigner', alter: 'vor 3 Std.', info: '66640 Namborn · Score 72',
+ frist: '', veraltet: false, gehalten: '', fertig: null, label: 'Antwort auf Plattform-Anfrage: Suche Webdesigner, vor 3 Std.',
+ })
+ // jede Art zeigt ihr Icon
+ for (const kind of Object.keys(PULT_KIND_LABELS)) assert.equal(m({ kind }).icon, L.ART_ICONS[kind], kind)
+ // läuft bald ab (< 24 h), veraltet (Status), veraltet durch staleAt
+ u = m({ staleAt: iso(NOW + 5 * 3600e3) })
+ assert.equal(u.frist, 'läuft in 5 Std. ab'); assert.equal(u.veraltet, false); assert.ok(u.label.endsWith('vor 3 Std., läuft in 5 Std. ab'))
+ u = m({ status: 'veraltet' })
+ assert.equal(u.veraltet, true); assert.equal(u.frist, ''); assert.ok(u.label.endsWith(', veraltet'))
+ assert.equal(m({ staleAt: iso(NOW - 1000) }).veraltet, true)
+ assert.equal(m({ staleAt: iso(NOW + 3 * 24 * 3600e3) }).frist, '')
+ // übernommen: von mir „Du kümmerst dich darum.“, von einem Kollegen mit Namen, von Max automatisch, unbekannt „Kollege“
+ u = m({ status: 'in_arbeit', claimedBy: 'mensch:u1' })
+ assert.equal(u.gehalten, 'Du kümmerst dich darum.'); assert.ok(u.label.endsWith(', du kümmerst dich darum'))
+ u = m({ status: 'in_arbeit', claimedBy: 'mensch:u2' })
+ assert.equal(u.gehalten, 'übernommen von Kenso'); assert.ok(u.label.endsWith(', übernommen von Kenso'))
+ assert.equal(m({ status: 'in_arbeit', claimedBy: 'max' }).gehalten, 'übernommen von Max (automatisch)')
+ assert.equal(m({ status: 'in_arbeit', claimedBy: 'mensch:u9' }).gehalten, 'übernommen von Kollege')
+ // bei veraltet hält niemand mehr, auch wenn der frühere Bearbeiter noch drinsteht
+ assert.equal(m({ status: 'veraltet', claimedBy: 'mensch:u2' }).gehalten, '')
+ // abgeschlossen: Ergebnis-Stempel statt Alter ab Anlage (gesendet grün, die übrigen grau)
+ const done = { createdAt: iso(NOW - 4 * 24 * 3600e3), doneBy: 'mensch:u2', doneAt: iso(NOW - 2 * 3600e3) }
+ u = m({ ...done, status: 'gesendet' })
+ assert.deepEqual({ ...u.fertig }, { text: 'Gesendet von Kenso · vor 2 Std.', klasse: 'hk-abdruck hk-stempel-gruen' }); assert.equal(u.alter, '')
+ assert.equal(u.label, 'Antwort auf Plattform-Anfrage: Suche Webdesigner, Gesendet von Kenso · vor 2 Std.')
+ assert.deepEqual({ ...m({ ...done, status: 'verworfen' }).fertig }, { text: 'Verworfen von Kenso · vor 2 Std.', klasse: 'hk-abdruck hk-stempel-grau' })
+ assert.deepEqual({ ...m({ ...done, status: 'erledigt', doneBy: 'max' }).fertig }, { text: 'Erledigt von Max (automatisch) · vor 2 Std.', klasse: 'hk-abdruck hk-stempel-grau' })
+ assert.equal(m({ ...done, status: 'erledigt', doneBy: undefined }).fertig.text, 'Erledigt · vor 2 Std.')
+ // ohne Interessent keine Kurzinfo, ohne Titel nur die Art im Namen, ohne Alter (kaputte Zeit) kein Alter
+ assert.equal(m({ prospect: null }).info, '')
+ assert.equal(m({ title: ' ' }).label, 'Antwort auf Plattform-Anfrage, vor 3 Std.')
+ assert.equal(m({ createdAt: 'kaputt' }).alter, '')
+ assert.equal(L.prospectInfo({ city: 'Worms' }), 'Worms'); assert.equal(L.prospectInfo({ score: 0 }), 'Score 0'); assert.equal(L.prospectInfo(null), '')
+})
+
+// ---------------------------------------------------------------------------------------------------------------
+// Bauteile im Mini-Läufer
+
+// Was das Pult von außen holt: Feed (je Filter eine Antwort), Mitarbeiterliste, Zwischenablage, das Interessenten-Panel (hält seine Eigenschaften fest)
+const STUBS = {
+ usePultFeed: 'export function usePultFeed({ statuses } = {}) { return globalThis.__pultFeedFor(statuses) }',
+ useEmployees: 'export function useEmployees() { return { employees: globalThis.__pultEmployees ?? [] } }',
+ CopyableCredential: 'export async function copyTextChecked() {}',
+ ProspectPanel: "import { jsx } from 'react'\nexport default function ProspectPanel(props) { globalThis.__panel = props; return jsx('div', { 'data-panel': props.prospectId }) }",
+}
+const stubs = {
+ name: 'pult-stubs',
+ setup(build) {
+ build.onResolve({ filter: /hooks\/(usePultFeed|useEmployees)$|CopyableCredential$|\/ProspectPanel$/ }, (a) => ({ path: a.path.split('/').pop(), namespace: 'pult-stub' }))
+ build.onLoad({ filter: /.*/, namespace: 'pult-stub' }, (a) => ({ contents: STUBS[a.path], loader: 'js' }))
+ },
+}
+const loadUi = () => esbuild.build({
+ stdin: {
+ contents: "export { __mount, jsx } from 'react'\nexport { default as PultList } from './src/components/workers/PultList.jsx'\nexport { default as PultSzene } from './src/components/workers/pult/PultSzene.jsx'",
+ resolveDir: ROOT, loader: 'js',
+ },
+ bundle: true, format: 'esm', platform: 'neutral', write: false, jsx: 'automatic', loader: { '.jsx': 'jsx' }, plugins: [fakeReact, stubs],
+}).then(importBundle)
+
+const nodes = (node, test, out = []) => {
+ if (node === null || typeof node === 'string') return out
+ if (test(node)) out.push(node)
+ for (const child of node.children) nodes(child, test, out)
+ return out
+}
+const textOf = (node) => (typeof node === 'string' ? node : node === null ? '' : node.children.map(textOf).join(''))
+const hasClass = (n, name) => n.tag !== undefined && String(n.props.className ?? '').split(' ').includes(name)
+const byClass = (tree, name) => nodes(tree, (n) => typeof n.tag === 'string' && hasClass(n, name))
+const btn = (tree, label) => nodes(tree, (n) => (n.tag === 'button' || n.tag === 'a') && textOf(n).trim() === label)
+const classesIn = (tree, into = new Set()) => {
+ for (const n of nodes(tree, (x) => typeof x.tag === 'string')) for (const c of String(n.props.className ?? '').split(' ')) if (c) into.add(c)
+ return into
+}
+
+function fakeFocus() {
+ const body = { name: 'body' }
+ const doc = { body, documentElement: { name: 'html' }, activeElement: body }
+ const f = { doc, body, found: new Map(), made: new Map(), all: [] }
+ f.el = (name, extra = {}) => ({
+ name, isConnected: true, disabled: false, focusCalls: 0, focus() { this.focusCalls += 1; doc.activeElement = this },
+ querySelector: (selector) => f.found.get(selector) ?? null, querySelectorAll: () => f.all, ...extra,
+ })
+ f.ref = (key) => f.made.get(key)
+ const had = Object.hasOwn(globalThis, 'document')
+ const saved = globalThis.document
+ globalThis.document = doc
+ globalThis.__fakeRef = (tag, props) => {
+ const key = String(props.className ?? tag).split(' ')[0]
+ if (!f.made.has(key)) f.made.set(key, f.el(key))
+ return f.made.get(key)
+ }
+ f.restore = () => {
+ if (had) globalThis.document = saved
+ else delete globalThis.document
+ delete globalThis.__fakeRef
+ }
+ return f
+}
+
+// Ein Lauf des Pults: items gelten für jeden Filter (der Test steuert über die Liste, was sichtbar ist); opts: kpis, onBack, api
+async function startPult(items, { api = { pultAction: async () => ({}) }, kpis, onBack, feed = {} } = {}) {
+ const { __mount, jsx, PultList } = await loadUi()
+ const timers = new Map()
+ let nextTimer = 1
+ const refreshes = []
+ const saved = { window: globalThis.window }
+ globalThis.window = {
+ setTimeout: (fn, ms) => { const id = nextTimer++; timers.set(id, { fn, ms }); return id },
+ clearTimeout: (id) => { timers.delete(id) },
+ open: () => {},
+ }
+ globalThis.__pultEmployees = EMPLOYEES
+ globalThis.__pultFeedFor = (statuses) => ({ items: feed.byStatus ? (feed.byStatus[statuses] ?? []) : items, loading: false, error: null, refresh: async () => { refreshes.push(statuses) }, ...feed.extra })
+ delete globalThis.__panel
+ const mounted = __mount(jsx(PultList, { api, me: ME, kpis, onBack }))
+ const view = {
+ timers, refreshes,
+ tree: () => mounted.tree(),
+ brief: () => nodes(view.tree(), (n) => n.tag === 'section' && hasClass(n, 'hk-brief'))[0] ?? null,
+ envelopes: () => byClass(view.tree(), 'hk-umschlag'),
+ async open(index = 0, currentTarget) {
+ const e = view.envelopes()[index]
+ assert.ok(e, `Umschlag ${index}`)
+ e.props.onClick({ preventDefault() {}, currentTarget })
+ await settle()
+ },
+ async press(node) { node.props.onClick?.({ preventDefault() {} }); await settle() },
+ async briefKey(key, extra = {}) {
+ const event = { key, defaultPrevented: false, stopped: false, preventDefault() { this.defaultPrevented = true }, stopPropagation() { this.stopped = true }, ...extra }
+ view.brief().props.onKeyDown(event)
+ await settle()
+ return event
+ },
+ close: () => view.press(nodes(view.brief(), (n) => n.props['aria-label'] === 'Schließen')[0]),
+ tab: async (label) => { await view.press(nodes(byClass(view.tree(), 'hk-reiter-leiste')[0], (n) => n.tag === 'button' && textOf(n).startsWith(label))[0]) },
+ end() { mounted.unmount(); globalThis.window = saved.window; delete globalThis.__pultFeedFor; delete globalThis.__pultEmployees; delete globalThis.__panel },
+ }
+ await settle()
+ return view
+}
+
+const ALLE_ARTEN = Object.keys(PULT_KIND_LABELS).map((kind, i) => entry({ $id: `k${i}`, kind, title: `Titel ${kind}`, draft: 'Entwurf', originalText: 'Anfrage des Kunden', sourceUrl: kind === 'vorschau_freigabe' ? 'https://entwurf.project.webklar.com/AAA/' : 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3', prospectId: 'p1' }))
+
+test('Pult: die Liste zeigt je Eintrag einen Umschlag mit Art-Icon, Titel, Art · Alter, Ort · Score und den Marken des Zustands', async () => {
+ const items = [
+ entry({ $id: 'a' }),
+ entry({ $id: 'b', kind: 'mail_antwort', title: 'Zweite', status: 'veraltet' }),
+ entry({ $id: 'c', kind: 'unsicher', title: 'Dritte', status: 'in_arbeit', claimedBy: 'mensch:u2', staleAt: iso(Date.now() + 5 * 3600e3) }),
+ entry({ $id: 'd', kind: 'preisfrage', title: 'Vierte', status: 'in_arbeit', claimedBy: 'mensch:u1' }),
+ entry({ $id: 'e', kind: 'beschwerde', title: 'Fünfte', status: 'gesendet', doneBy: 'mensch:u2', doneAt: iso(Date.now() - 2 * 3600e3) }),
+ ]
+ const ui = await startPult(items)
+ try {
+ const rows = ui.envelopes()
+ assert.equal(rows.length, 5)
+ const sig = (n) => ({ icon: byClass(n, 'hk-ic')[0].props.className, titel: textOf(byClass(n, 'hk-umschlag-titel')[0]), artAlter: textOf(byClass(n, 'hk-umschlag-art-alter')[0]), marken: byClass(n, 'hk-marke').map(textOf), stempel: byClass(n, 'hk-abdruck').map(textOf), info: byClass(n, 'hk-klein-inline').map(textOf) })
+ assert.deepEqual(sig(rows[0]), { icon: 'hk-ic hk-ic-art-plattform hk-umschlag-art', titel: 'Suche Webdesigner', artAlter: 'Antwort auf Plattform-Anfrage · vor 3 Std.', marken: [], stempel: [], info: ['66640 Namborn · Score 72'] })
+ assert.deepEqual([sig(rows[1]).icon, sig(rows[1]).marken], ['hk-ic hk-ic-art-mail hk-umschlag-art', ['veraltet']])
+ assert.deepEqual([sig(rows[2]).icon, sig(rows[2]).marken], ['hk-ic hk-ic-art-unsicher hk-umschlag-art', ['läuft in 5 Std. ab', 'übernommen von Kenso']])
+ assert.deepEqual([sig(rows[3]).icon, sig(rows[3]).marken], ['hk-ic hk-ic-art-preisfrage hk-umschlag-art', ['Du kümmerst dich darum.']])
+ assert.deepEqual([sig(rows[4]).icon, sig(rows[4]).marken, sig(rows[4]).stempel, sig(rows[4]).artAlter], ['hk-ic hk-ic-art-beschwerde hk-umschlag-art', [], ['Gesendet von Kenso · vor 2 Std.'], 'Beschwerde'])
+ // Knopf mit Namen, Fenster-Hinweis und ohne eigenen Fokus-Trick: ein Klick öffnet, sonst nichts
+ assert.ok(rows.every((r) => r.tag === 'button' && r.props.type === 'button' && r.props['aria-haspopup'] === 'dialog'))
+ assert.equal(rows[2].props['aria-label'], 'Unsicher – bitte prüfen: Dritte, vor 3 Std., läuft in 5 Std. ab, übernommen von Kenso')
+ assert.equal(ui.brief(), null)
+ // „von “ gibt es nicht: ein Pult-Eintrag nennt keinen Hustler
+ assert.ok(!ui.tree() || !textOf(ui.tree()).includes('Gebracht von'))
+ } finally { ui.end() }
+})
+
+test('Pult: Zahlen an den Reitern und im Kopf – Wartet aus kpis.pultOpen, am offenen Reiter die Länge der Liste, die anderen ohne Zahl', async () => {
+ const wartet = [entry({ $id: 'a' }), entry({ $id: 'b' })]
+ const byStatus = { 'offen,in_arbeit': wartet, veraltet: [entry({ $id: 'v', status: 'veraltet' })], 'gesendet,verworfen,erledigt': [entry({ $id: 'x', status: 'gesendet' }), entry({ $id: 'y', status: 'gesendet' }), entry({ $id: 'z', status: 'gesendet' })] }
+ const ui = await startPult([], { kpis: { pultOpen: 5 }, feed: { byStatus } })
+ try {
+ const tabs = () => byClass(ui.tree(), 'hk-reiter').map((t) => ({ text: textOf(t), name: t.props['aria-label'], an: t.props['aria-pressed'] }))
+ const kopf = () => textOf(byClass(ui.tree(), 'hk-pult-zahl')[0] ?? { children: [] })
+ // Wartet ist offen: die Liste hat 2 Einträge, Reiter und Kopf zeigen die Zahl des Dienstes (wie der Briefkasten im Haus)
+ assert.deepEqual(tabs(), [
+ { text: 'Wartet5', name: 'Wartet, 5 Einträge', an: true }, { text: 'Veraltet', name: undefined, an: false }, { text: 'Erledigt', name: undefined, an: false },
+ ])
+ assert.equal(kopf(), 'Wartet auf dich:5')
+ await ui.tab('Veraltet')
+ assert.deepEqual(tabs(), [
+ { text: 'Wartet5', name: 'Wartet, 5 Einträge', an: false }, { text: 'Veraltet1', name: 'Veraltet, 1 Eintrag', an: true }, { text: 'Erledigt', name: undefined, an: false },
+ ])
+ await ui.tab('Erledigt')
+ assert.deepEqual(tabs().map((t) => t.text), ['Wartet5', 'Veraltet', 'Erledigt3'])
+ assert.equal(kopf(), 'Wartet auf dich:5')
+ } finally { ui.end() }
+ // ohne Zahl vom Dienst: nur der offene Reiter „Wartet“ kennt seine Zahl (die Länge der Liste), im Kopf ebenso; in anderen Reitern fehlt sie überall
+ const ohne = await startPult([], { kpis: undefined, feed: { byStatus } })
+ try {
+ assert.deepEqual(byClass(ohne.tree(), 'hk-reiter').map(textOf), ['Wartet2', 'Veraltet', 'Erledigt'])
+ assert.equal(textOf(byClass(ohne.tree(), 'hk-pult-zahl')[0]), 'Wartet auf dich:2')
+ await ohne.tab('Veraltet')
+ assert.deepEqual(byClass(ohne.tree(), 'hk-reiter').map(textOf), ['Wartet', 'Veraltet1', 'Erledigt'])
+ assert.equal(byClass(ohne.tree(), 'hk-pult-zahl').length, 0)
+ } finally { ohne.end() }
+ // während des Ladens und bei einem Fehler ohne Einträge steht keine erfundene „0“
+ const laden = await startPult([], { kpis: {}, feed: { extra: { loading: true } } })
+ try {
+ assert.deepEqual(byClass(laden.tree(), 'hk-reiter').map(textOf), ['Wartet', 'Veraltet', 'Erledigt']); assert.equal(byClass(laden.tree(), 'hk-pult-zahl').length, 0)
+ assert.ok(textOf(laden.tree()).includes('Wird geladen …'))
+ } finally { laden.end() }
+ const kaputt = await startPult([], { kpis: {}, feed: { extra: { error: 'Der Hustler-Dienst ist gerade nicht erreichbar.' } } })
+ try {
+ assert.deepEqual(byClass(kaputt.tree(), 'hk-reiter').map(textOf), ['Wartet', 'Veraltet', 'Erledigt'])
+ assert.ok(textOf(kaputt.tree()).includes('Das Pult ist gerade nicht erreichbar'))
+ assert.equal(btn(kaputt.tree(), 'Neu laden').length, 1)
+ await kaputt.press(btn(kaputt.tree(), 'Neu laden')[0]); assert.equal(kaputt.refreshes.length, 1)
+ } finally { kaputt.end() }
+})
+
+test('Pult: Untertitel und Leerzustand je Reiter wie in der Vorlage', async () => {
+ const ui = await startPult([], { kpis: { pultOpen: 0 } })
+ try {
+ const sub = () => textOf(byClass(ui.tree(), 'hk-brett-unter')[0])
+ const leer = () => byClass(ui.tree(), 'hk-leer').map(textOf)
+ assert.equal(sub(), 'Hier wartet alles, was ein Mensch freigeben muss.'); assert.deepEqual(leer(), ['Nichts wartet auf dichNeue Antworten erscheinen hier, sobald ein Entwurf bereit ist.'])
+ await ui.tab('Veraltet')
+ assert.equal(sub(), 'Diese Einträge liegen schon zu lange. Du kannst sie weiter bearbeiten.'); assert.deepEqual(leer(), ['Nichts ist veraltetEinträge, die zu lange liegen, landen hier und lassen sich weiter bearbeiten.'])
+ await ui.tab('Erledigt')
+ assert.equal(sub(), 'Was du oder das Team schon abgeschlossen habt.'); assert.deepEqual(leer(), ['Noch nichts erledigtGesendete, verworfene und erledigte Einträge stehen hier.'])
+ // der Kopf zeigt bei pultOpen 0 die „0“ (eine Zahl vom Dienst), die Reiter-Leiste ist eine Gruppe mit Namen
+ assert.equal(textOf(byClass(ui.tree(), 'hk-pult-zahl')[0]), 'Wartet auf dich:0')
+ assert.equal(byClass(ui.tree(), 'hk-reiter-leiste')[0].props.role, 'group')
+ } finally { ui.end() }
+})
+
+test('Pult: die Boten-Schleife (Clara, Nora, Aufzug, Fähnchen) läuft nur bei kpis.pultOpen > 0, sonst steht die Ruhe-Klasse der Vorlage am Rahmen', async () => {
+ const klasse = async (kpis) => {
+ const ui = await startPult([], { kpis })
+ try {
+ const szene = byClass(ui.tree(), 'hk-pult-szene')[0]
+ assert.equal(szene.props['aria-hidden'], 'true')
+ return hasClass(szene, 'hk-pult-ruhe')
+ } finally { ui.end() }
+ }
+ assert.equal(await klasse({ pultOpen: 3 }), false)
+ assert.equal(await klasse({ pultOpen: 1 }), false)
+ for (const kpis of [undefined, {}, { pultOpen: 0 }, { pultOpen: '2' }]) assert.equal(await klasse(kpis), true, JSON.stringify(kpis))
+ // die Ruhe-Regeln der Vorlage hängen an .hk-pult-ruhe unterhalb der Wurzel .hk-pult, die Boten und der Aufzug darunter
+ const css = read('src/styles/hustler/pult.css')
+ assert.ok(css.includes('.hk-pult .hk-pult-ruhe .hk-boten-weg')); assert.ok(css.includes('.hk-pult .hk-pult-ruhe .hk-sz-briefkasten'))
+ // die Szene steckt innerhalb der Wurzel (PultList) und enthält Boten und Fähnchen
+ const ui = await startPult([], { kpis: { pultOpen: 1 } })
+ try {
+ assert.ok(hasClass(ui.tree(), 'hk-pult'))
+ const szene = byClass(ui.tree(), 'hk-pult-szene')[0]
+ for (const c of ['hk-boten-weg', 'hk-boten-bild', 'hk-sz-tuer', 'hk-sz-briefkasten', 'hk-sz-sekunde', 'hk-sz-schnee', 'hk-sz-kette0', 'hk-sz-lmond', 'hk-sz-monitor']) assert.ok(byClass(szene, c).length >= 1, c)
+ assert.match(byClass(szene, 'hk-pult-buehne')[0].props.style.transform, /^translate\(.*\) scale\(.*\)$/)
+ } finally { ui.end() }
+})
+
+test('PultSzene: passt sich der Fläche an (ResizeObserver, „cover“) und räumt den Beobachter auf', async () => {
+ const { __mount, jsx, PultSzene } = await loadUi()
+ const size = { width: 1920, height: 1080 }
+ const observed = []
+ const disconnected = []
+ const callbacks = []
+ globalThis.ResizeObserver = class { constructor(cb) { callbacks.push(cb) } observe(el) { observed.push(el) } disconnect() { disconnected.push(true) } }
+ globalThis.__fakeRef = () => ({ getBoundingClientRect: () => ({ ...size }) })
+ try {
+ const mounted = __mount(jsx(PultSzene, { laeuft: true }))
+ await settle()
+ const stage = () => nodes(mounted.tree(), (n) => hasClass(n, 'hk-pult-buehne'))[0]
+ assert.equal(stage().props.style.transform, 'translate(0.0px, 0.0px) scale(3.0000)')
+ assert.equal(observed.length, 1)
+ size.width = 2560; size.height = 1080
+ callbacks[0](); await settle()
+ assert.equal(stage().props.style.transform, 'translate(0.0px, -216.0px) scale(4.0000)')
+ size.width = 0; size.height = 0
+ callbacks[0](); await settle()
+ assert.equal(stage().props.style.transform, 'translate(0.0px, -216.0px) scale(4.0000)') // keine Fläche: das letzte Maß bleibt
+ mounted.unmount()
+ assert.equal(disconnected.length, 1)
+ } finally { delete globalThis.ResizeObserver; delete globalThis.__fakeRef }
+ // ohne ResizeObserver wird einmal gemessen; ohne Rahmen (Server, Node) passiert nichts
+ const einmal = __mount(jsx(PultSzene, {}))
+ await settle()
+ assert.equal(nodes(einmal.tree(), (n) => hasClass(n, 'hk-pult-buehne'))[0].props.style.transform, 'translate(0.0px, 0.0px) scale(2.0000)')
+ einmal.unmount()
+})
+
+test('Pult: „Pult zuklappen“ blendet Reiter und Klemmbrett aus (die Szene bleibt), „Pult aufklappen“ bringt sie zurück', async () => {
+ const ui = await startPult([entry()], { kpis: { pultOpen: 1 } })
+ try {
+ assert.equal(byClass(ui.tree(), 'hk-klemmbrett').length, 1); assert.equal(byClass(ui.tree(), 'hk-reiter-leiste').length, 1)
+ assert.equal(btn(ui.tree(), 'Pult zuklappen').length, 1)
+ await ui.press(btn(ui.tree(), 'Pult zuklappen')[0])
+ assert.equal(byClass(ui.tree(), 'hk-klemmbrett').length, 0); assert.equal(byClass(ui.tree(), 'hk-reiter-leiste').length, 0); assert.equal(byClass(ui.tree(), 'hk-umschlag').length, 0)
+ assert.equal(byClass(ui.tree(), 'hk-pult-szene').length, 1) // die Szene bleibt ganz zu sehen
+ assert.equal(btn(ui.tree(), 'Pult zuklappen').length, 0); assert.equal(btn(ui.tree(), 'Pult aufklappen').length, 1)
+ assert.equal(byClass(ui.tree(), 'hk-pult-zahl').length, 1) // Kopf mit Zahl bleibt
+ await ui.press(btn(ui.tree(), 'Pult aufklappen')[0])
+ assert.equal(byClass(ui.tree(), 'hk-klemmbrett').length, 1); assert.equal(byClass(ui.tree(), 'hk-umschlag').length, 1)
+ assert.equal(btn(ui.tree(), 'Pult zuklappen').length, 1)
+ } finally { ui.end() }
+})
+
+test('Pult: „◂ Hochhaus“ ruft onBack; ohne onBack passiert beim Klick nichts', async () => {
+ const calls = []
+ const ui = await startPult([], { onBack: () => calls.push('zurück') })
+ try {
+ const back = btn(ui.tree(), 'Hochhaus')
+ assert.equal(back.length, 1); assert.equal(back[0].tag, 'button'); assert.equal(back[0].props.type, 'button')
+ assert.ok(byClass(back[0], 'hk-ic-zurueck').length === 1 && byClass(back[0], 'hk-ic-zurueck')[0].props['aria-hidden'] === 'true')
+ await ui.press(back[0]); await ui.press(back[0])
+ assert.deepEqual(calls, ['zurück', 'zurück'])
+ } finally { ui.end() }
+ const stumm = await startPult([])
+ try { await stumm.press(btn(stumm.tree(), 'Hochhaus')[0]) } finally { stumm.end() }
+})
+
+// ---- Brief: öffnen, schließen, Fokus ----
+
+test('Pult: ein Klick auf den Umschlag öffnet den Brief als Dialog (role, aria-modal, Name), der Fokus geht auf den ersten Knopf „Schließen“', async () => {
+ const f = fakeFocus()
+ const ui = await startPult([entry({ title: 'Suche Webdesigner' })])
+ try {
+ const umschlag = f.el('umschlag')
+ f.doc.activeElement = umschlag
+ await ui.open(0, umschlag)
+ const brief = ui.brief()
+ assert.ok(brief)
+ assert.deepEqual([brief.props.role, brief.props['aria-modal'], brief.props['aria-label'], brief.props.tabIndex], ['dialog', 'true', 'Antwort auf Plattform-Anfrage: Suche Webdesigner', -1])
+ // der erste Knopf im Brief ist „Schließen“ und hat den Fokus
+ const buttons = nodes(brief, (n) => n.tag === 'button' || n.tag === 'a')
+ assert.equal(buttons[0].props['aria-label'], 'Schließen')
+ assert.deepEqual([f.ref('hk-knopf').focusCalls, f.doc.activeElement], [1, f.ref('hk-knopf')])
+ // Hintergrund-Knopf (nicht in der Tab-Reihenfolge) schließt
+ const hinten = byClass(ui.tree(), 'hk-brief-hinten')[0]
+ assert.deepEqual([hinten.tag, hinten.props.tabIndex, hinten.props['aria-label']], ['button', -1, 'Brief schließen'])
+ // Überschrift, Art, Original und Entwurf stehen im Brief
+ assert.equal(textOf(byClass(brief, 'hk-brief-titel')[0]), 'Suche Webdesigner')
+ assert.equal(byClass(brief, 'hk-brief-titel')[0].tag, 'h2')
+ } finally { ui.end(); f.restore() }
+})
+
+test('Pult: der Brief schließt mit „Schließen“, Hintergrund und Esc; der Fokus geht an den Umschlag zurück', async () => {
+ for (const weg of ['knopf', 'hintergrund', 'esc']) {
+ const f = fakeFocus()
+ const ui = await startPult([entry()])
+ try {
+ const umschlag = f.el('umschlag')
+ await ui.open(0, umschlag)
+ assert.ok(ui.brief(), weg)
+ if (weg === 'knopf') await ui.press(nodes(ui.brief(), (n) => n.props['aria-label'] === 'Schließen')[0])
+ if (weg === 'hintergrund') await ui.press(byClass(ui.tree(), 'hk-brief-hinten')[0])
+ if (weg === 'esc') { const event = await ui.briefKey('Escape'); assert.ok(event.defaultPrevented && event.stopped) }
+ assert.equal(ui.brief(), null, weg)
+ assert.deepEqual([umschlag.focusCalls, f.doc.activeElement], [1, umschlag], weg)
+ // eine andere Taste schließt nicht
+ } finally { ui.end(); f.restore() }
+ }
+ const ui = await startPult([entry()])
+ try {
+ await ui.open(0)
+ await ui.briefKey('a'); await ui.briefKey('Enter')
+ assert.ok(ui.brief())
+ } finally { ui.end() }
+})
+
+test('Pult: Esc schließt erst ein offenes Feld, dann den Brief; Esc bei laufender Anfrage lässt das Feld stehen', async () => {
+ const f = fakeFocus()
+ let finish
+ const api = { pultAction: () => new Promise((resolve) => { finish = resolve }) }
+ const ui = await startPult([entry({ kind: 'unsicher', draft: 'Entwurf' })], { api })
+ try {
+ const umschlag = f.el('umschlag')
+ const opener = f.el('opener')
+ f.found.set('[data-opens="reply"]', opener)
+ await ui.open(0, umschlag)
+ await ui.press(btn(ui.brief(), 'Als Mensch antworten')[0])
+ assert.equal(nodes(ui.brief(), (n) => n.tag === 'textarea').length, 1)
+ let event = await ui.briefKey('Escape') // der Fokus liegt außerhalb des Textfelds: der Brief schließt das Feld
+ assert.ok(event.defaultPrevented); assert.equal(nodes(ui.brief(), (n) => n.tag === 'textarea').length, 0); assert.ok(ui.brief()); assert.equal(opener.focusCalls, 1)
+ event = await ui.briefKey('Escape')
+ assert.equal(ui.brief(), null); assert.equal(umschlag.focusCalls, 1)
+ // während einer Anfrage: Feld bleibt (nur der Brief hat Esc behandelt)
+ await ui.open(0, umschlag)
+ await ui.press(btn(ui.brief(), 'Als Mensch antworten')[0])
+ await ui.press(btn(ui.brief(), 'Jetzt senden')[0])
+ event = await ui.briefKey('Escape')
+ assert.ok(event.defaultPrevented); assert.equal(nodes(ui.brief(), (n) => n.tag === 'textarea').length, 1); assert.ok(ui.brief())
+ finish({}); await settle()
+ } finally { ui.end(); f.restore() }
+})
+
+test('Pult: Tab bleibt im Brief (vom letzten Knopf zum ersten, Umschalt+Tab vom ersten oder vom Brief selbst zum letzten)', async () => {
+ const f = fakeFocus()
+ const ui = await startPult([entry()])
+ try {
+ const erster = f.el('erster'); const mitte = f.el('mitte'); const letzter = f.el('letzter'); const gesperrt = f.el('gesperrt', { disabled: true })
+ f.all = [erster, mitte, gesperrt, letzter]
+ await ui.open(0)
+ // Mitte: Tab geht seinen normalen Weg
+ f.doc.activeElement = mitte
+ let event = await ui.briefKey('Tab'); assert.equal(event.defaultPrevented, false)
+ event = await ui.briefKey('Tab', { shiftKey: true }); assert.equal(event.defaultPrevented, false)
+ // Ende -> Anfang
+ f.doc.activeElement = letzter
+ event = await ui.briefKey('Tab'); assert.ok(event.defaultPrevented); assert.equal(f.doc.activeElement, erster)
+ // Anfang (und der Brief selbst) mit Umschalt -> Ende; gesperrte Knöpfe zählen nicht
+ event = await ui.briefKey('Tab', { shiftKey: true }); assert.ok(event.defaultPrevented); assert.equal(f.doc.activeElement, letzter)
+ f.doc.activeElement = f.ref('hk-brief')
+ event = await ui.briefKey('Tab', { shiftKey: true }); assert.ok(event.defaultPrevented); assert.equal(f.doc.activeElement, letzter)
+ // Umschalt+Tab vom Ende: normal
+ event = await ui.briefKey('Tab', { shiftKey: true }); assert.equal(event.defaultPrevented, false)
+ } finally { ui.end(); f.restore() }
+})
+
+test('Pult: nach einem Abschluss schließt der Brief, die Meldung erscheint (role status) und nimmt den Fokus; der Umschlag bekommt ihn nicht', async () => {
+ const f = fakeFocus()
+ const ui = await startPult([entry({ kind: 'unsicher', title: 'Unklare Antwort', draft: '' })])
+ try {
+ const umschlag = f.el('umschlag')
+ await ui.open(0, umschlag)
+ const erledigt = f.el('erledigt-knopf', { disabled: true })
+ f.doc.activeElement = erledigt
+ await ui.press(btn(ui.brief(), 'Erledigt')[0])
+ assert.equal(ui.brief(), null)
+ const meldung = byClass(ui.tree(), 'hk-hinweis')[0]
+ assert.deepEqual([meldung.props.role, meldung.props.tabIndex, textOf(meldung)], ['status', -1, '„Unklare Antwort“ erledigt.'])
+ assert.deepEqual([f.ref('hk-hinweis').focusCalls, f.doc.activeElement, umschlag.focusCalls], [1, f.ref('hk-hinweis'), 0])
+ // ein Filterwechsel räumt die Meldung weg
+ await ui.tab('Veraltet')
+ assert.equal(byClass(ui.tree(), 'hk-hinweis').length, 0)
+ } finally { ui.end(); f.restore() }
+})
+
+test('Pult: verschwindet der Eintrag aus der Liste (ein Kollege war schneller), schließt sein offener Brief', async () => {
+ const f = fakeFocus()
+ const items = [entry({ $id: 'a', title: 'Eins' }), entry({ $id: 'b', title: 'Zwei' })]
+ const ui = await startPult(items)
+ try {
+ const umschlag = f.el('umschlag')
+ await ui.open(1, umschlag)
+ assert.equal(textOf(byClass(ui.brief(), 'hk-brief-titel')[0]), 'Zwei')
+ items.splice(1, 1) // der nächste Abruf des Feeds liefert die kürzere Liste (hier: beim nächsten Zeichnen, ausgelöst durch einen Reiterwechsel)
+ await ui.tab('Veraltet')
+ assert.equal(ui.brief(), null)
+ assert.equal(ui.envelopes().length, 1)
+ } finally { ui.end(); f.restore() }
+})
+
+test('Pult: ein Ergebnis, das nach dem Schließen des Briefs eintrifft, erscheint in der Liste (Fehler als Alarm); ein Abschluss danach schließt keinen anderen Brief', async () => {
+ const f = fakeFocus()
+ let reject
+ let finish
+ const api = { pultAction: (id, action) => (action === 'discard' ? new Promise((resolve) => { finish = resolve }) : new Promise((_, no) => { reject = no })) }
+ const items = [entry({ $id: 'a', title: 'Eins', kind: 'unsicher', prospectId: 'p1' }), entry({ $id: 'b', title: 'Zwei', kind: 'unsicher', prospectId: 'p1' })]
+ const ui = await startPult(items, { api })
+ try {
+ // 1. Fehler nach dem Schließen: „Kann gesendet sein“ geht nicht verloren
+ await ui.open(0, f.el('u0'))
+ await ui.press(btn(ui.brief(), 'Vorschau bauen')[0]); await ui.press(btn(ui.brief(), 'Wirklich bauen?')[0])
+ await ui.press(nodes(ui.brief(), (n) => n.props['aria-label'] === 'Schließen')[0])
+ assert.equal(ui.brief(), null)
+ reject(Object.assign(new Error('Der Hustler-Dienst ist gerade nicht erreichbar.'), { status: 0 })); await settle()
+ const alarm = byClass(ui.tree(), 'hk-hinweis-err')
+ assert.deepEqual(alarm.map((n) => [n.props.role, textOf(n)]), [['alert', 'Kann gesendet sein – Pult neu laden.']])
+ // ein neuer Brief räumt die späte Meldung weg
+ await ui.open(1)
+ assert.equal(byClass(ui.tree(), 'hk-hinweis-err').length, 0)
+ await ui.press(nodes(ui.brief(), (n) => n.props['aria-label'] === 'Schließen')[0])
+ // 2. Abschluss, nachdem ein anderer Brief offen ist: die Meldung kommt, der andere Brief bleibt, kein Fokus-Raub
+ await ui.open(0, f.el('u0b'))
+ await ui.press(btn(ui.brief(), 'Verwerfen')[0]); await ui.press(btn(ui.brief(), 'Wirklich verwerfen?')[0])
+ await ui.press(nodes(ui.brief(), (n) => n.props['aria-label'] === 'Schließen')[0])
+ await ui.open(1, f.el('u1'))
+ const vorher = f.doc.activeElement
+ finish({}); await settle()
+ assert.ok(ui.brief()); assert.equal(textOf(byClass(ui.brief(), 'hk-brief-titel')[0]), 'Zwei')
+ assert.deepEqual(byClass(ui.tree(), 'hk-hinweis-ok').map(textOf), ['„Eins“ verworfen.'])
+ assert.equal(f.doc.activeElement, vorher)
+ } finally { ui.end(); f.restore() }
+})
+
+test('Pult: „Interessent“ im Brief öffnet das Interessenten-Panel in der WOMS-Hülle, Schließen räumt es weg und gibt den Brief frei', async () => {
+ const ui = await startPult([entry({ prospect: { $id: 'p42', name: 'Verein' } })])
+ try {
+ await ui.open()
+ assert.equal(byClass(ui.tree(), 'hk-woms').length, 0)
+ const knopf = btn(ui.brief(), 'Interessent')
+ assert.equal(knopf.length, 1); assert.equal(knopf[0].props['aria-label'], 'Interessent zu Suche Webdesigner')
+ await ui.press(knopf[0])
+ const huelle = byClass(ui.tree(), 'hk-woms')
+ assert.equal(huelle.length, 1)
+ assert.deepEqual(nodes(huelle[0], (n) => n.props['data-panel'] !== undefined).map((n) => n.props['data-panel']), ['p42'])
+ assert.equal(globalThis.__panel.prospectId, 'p42'); assert.ok(ui.brief()) // der Brief bleibt darunter offen
+ globalThis.__panel.onClose(); await settle()
+ assert.equal(byClass(ui.tree(), 'hk-woms').length, 0); assert.ok(ui.brief())
+ } finally { ui.end() }
+ // ohne Interessent gibt es den Knopf nicht
+ const ohne = await startPult([entry({ prospect: null })])
+ try { await ohne.open(); assert.equal(btn(ohne.brief(), 'Interessent').length, 0) } finally { ohne.end() }
+})
+
+// ---- Klassen gegen das CSS, Architektur-Regeln ----
+
+const CSS_DATEIEN = ['pult.css', 'hustler-ui.css', 'hustler-pult-ui.css']
+const definiert = () => {
+ const out = new Set()
+ for (const f of CSS_DATEIEN) for (const m of read('src/styles/hustler/' + f).matchAll(/\.(hk-[a-z0-9-]+)/g)) out.add(m[1])
+ return out
+}
+
+test('Pult: jede gerenderte hk-Klasse steht in pult.css, hustler-ui.css oder hustler-pult-ui.css (Liste, Szene, Brief in allen Arten, Felder, Rückfragen, Meldungen)', async () => {
+ const css = definiert()
+ const seen = new Set()
+ const jeder = [...ALLE_ARTEN,
+ entry({ $id: 'v', status: 'veraltet', staleAt: iso(NOW - 1000) }), entry({ $id: 'h', status: 'in_arbeit', claimedBy: 'mensch:u1' }), entry({ $id: 's', status: 'in_arbeit', claimedBy: 'mensch:u2', staleAt: iso(Date.now() + 3600e3) }),
+ entry({ $id: 'g', status: 'gesendet', doneBy: 'mensch:u2', doneAt: iso(Date.now() - 3600e3) }), entry({ $id: 'x', status: 'verworfen', doneBy: 'max', doneAt: iso(Date.now() - 3600e3) }),
+ ]
+ const api = { pultAction: async () => { throw Object.assign(new Error('Fehler des Dienstes.'), { status: 409 }) } }
+ const ui = await startPult(jeder, { api, kpis: { pultOpen: 3 }, onBack() {} })
+ try {
+ classesIn(ui.tree(), seen)
+ for (let i = 0; i < jeder.length; i += 1) {
+ await ui.open(i)
+ classesIn(ui.tree(), seen)
+ // jedes Feld und jede Rückfrage einmal aufklappen, dann Fehler und Erfolg zeigen
+ for (const label of ['Als Mensch antworten', 'Antwort des Kunden einfügen']) {
+ const k = btn(ui.brief(), label)[0]
+ if (k) { await ui.press(k); classesIn(ui.tree(), seen); await ui.press(btn(ui.brief(), 'Abbrechen')[0]) }
+ }
+ for (const label of ['Verwerfen', 'Vorschau bauen', 'Freigeben & senden']) {
+ const k = btn(ui.brief(), label)[0]
+ if (k && !k.props.disabled) { await ui.press(k); classesIn(ui.tree(), seen); await ui.press(btn(ui.brief(), 'Wirklich verwerfen?')[0] ?? btn(ui.brief(), 'Wirklich bauen?')[0] ?? btn(ui.brief(), 'Wirklich freigeben?')[0]); classesIn(ui.tree(), seen) }
+ }
+ await ui.press(nodes(ui.brief(), (n) => n.props['aria-label'] === 'Schließen')[0])
+ }
+ // Meldung nach Abschluss, später Ergebnis, Panel
+ classesIn(ui.tree(), seen)
+ } finally { ui.end() }
+ for (const [name, extra] of [['laden', { loading: true }], ['fehler', { error: 'Der Hustler-Dienst ist gerade nicht erreichbar.' }]]) {
+ const z = await startPult([], { feed: { extra } })
+ try { classesIn(z.tree(), seen) } finally { z.end() }
+ assert.ok(seen.size > 0, name)
+ }
+ const mitFehler = await startPult(jeder.slice(0, 2), { feed: { extra: { error: 'Der Hustler-Dienst ist gerade nicht erreichbar.' } } })
+ try { classesIn(mitFehler.tree(), seen) } finally { mitFehler.end() }
+ const ohneBoten = await startPult([], { kpis: { pultOpen: 0 } })
+ try { classesIn(ohneBoten.tree(), seen) } finally { ohneBoten.end() }
+ const zu = await startPult([entry({ prospect: { $id: 'p42' } })])
+ try {
+ await zu.open(); await zu.press(btn(zu.brief(), 'Interessent')[0]); classesIn(zu.tree(), seen)
+ await zu.press(btn(zu.tree(), 'Pult zuklappen')[0]); classesIn(zu.tree(), seen)
+ } finally { zu.end() }
+
+ const fehlt = [...seen].filter((c) => c.startsWith('hk-') && !css.has(c))
+ assert.deepEqual(fehlt, [])
+ // gesehen sind die wichtigen Klassen wirklich (der Test prüft nicht ins Leere)
+ for (const c of ['hk-pult', 'hk-pult-szene', 'hk-umschlag', 'hk-brief', 'hk-brief-ueber', 'hk-stempel', 'hk-stempel-frage', 'hk-stempel-gruen', 'hk-stempel-rot', 'hk-stempel-haupt', 'hk-feld-panel', 'hk-feld-antwort', 'hk-feld-einfuegen',
+ 'hk-hinweis-err', 'hk-hinweis-warn', 'hk-leer', 'hk-laden', 'hk-reiter-an', 'hk-abdruck', 'hk-marke-frist', 'hk-marke-halter', 'hk-marke-veraltet', 'hk-woms', 'hk-brief-zitat', 'hk-brief-lesen', 'hk-link', 'hk-zaehler',
+ 'hk-ic-art-plattform', 'hk-ic-art-mail', 'hk-ic-art-vorschau', 'hk-ic-art-unsicher', 'hk-ic-art-beschwerde', 'hk-ic-art-preisfrage', 'hk-ic-art-mensch', 'hk-ic-art-bau-kaputt', 'hk-ic-bauen', 'hk-ic-verwerfen']) {
+ assert.ok(seen.has(c), `nie gerendert: ${c}`)
+ }
+})
+
+test('Pult: Quelltext – kein CSS- oder Bild-Import, kein innerHTML, Links nur mit rel, jeder Knopf mit type, Bilder und Dekoration aria-hidden', () => {
+ const dateien = ['src/components/workers/PultList.jsx', 'src/components/workers/pult/Brief.jsx', 'src/components/workers/pult/Klemmbrett.jsx', 'src/components/workers/pult/Marken.jsx', 'src/components/workers/pult/PultSzene.jsx', 'src/lib/hustlerPult.js']
+ for (const f of dateien) {
+ const s = read(f)
+ assert.doesNotMatch(s, /import\s[^\n]*\.(css|png|svg|jpe?g|gif|webp|woff2?)['"]/, `${f}: Import von CSS oder Bild`)
+ assert.doesNotMatch(s, /import\s*['"][^'"]*\.(css|png|svg)['"]/, `${f}: Import von CSS oder Bild`)
+ assert.doesNotMatch(s, /innerHTML|dangerouslySetInnerHTML/, f)
+ assert.doesNotMatch(s, /\burl\(/, `${f}: Bild im Quelltext`)
+ assert.equal((s.match(/]*\btype="button"/g) ?? []).length, `${f}: Knopf ohne type`)
+ // jeder externe Link öffnet ohne Rückbezug
+ for (const m of s.matchAll(/]*\btarget="_blank"[^>]*>/g)) assert.match(m[0], /rel="noopener noreferrer"/, f)
+ }
+ // Links aus Dienst-Daten laufen nur über rowModel (sourceHref/previewHref/mailtoHref), nie über den rohen Eintrag
+ const brief = read('src/components/workers/pult/Brief.jsx')
+ assert.doesNotMatch(brief, /href=\{item\./); assert.doesNotMatch(brief, /sourceUrl/)
+})
+
+test('Pult: Handy unter 720 px – Klemmbrett in voller Breite über abgedunkelter Szene, Brief als Vollbild (CSS)', () => {
+ const css = read('src/styles/hustler/hustler-pult-ui.css')
+ const phone = css.slice(css.indexOf('@media (max-width: 719px)'))
+ assert.ok(phone.length > 0 && css.includes('@media (max-width: 719px)'))
+ assert.match(phone, /\.hk-pult-ui\s*\{[^}]*width:\s*auto/); assert.match(phone, /\.hk-pult-ui\s*\{[^}]*left:\s*8px/)
+ assert.match(phone, /\.hk-pult-vignette\s*\{[^}]*rgba\(6, 7, 18, 0\.55\)/)
+ assert.match(phone, /\.hk-brief-ueber\s*\{[^}]*padding:\s*0/); assert.match(phone, /\.hk-brief\s*\{[^}]*width:\s*100%[^}]*height:\s*100%/)
+ // am Desktop: Klemmbrett rechts, höchstens 716 px breit; Brief höchstens 640 px
+ assert.match(css, /\.hk-pult \.hk-pult-ui\s*\{[^}]*width:\s*min\(716px, calc\(100% - 32px\)\)/); assert.match(css, /\.hk-pult \.hk-brief\s*\{[^}]*width:\s*min\(640px, 100%\)/)
+ // „weniger Bewegung“ steht im erzeugten CSS der Vorlage
+ assert.ok(read('src/styles/hustler/pult.css').includes('@media (prefers-reduced-motion: reduce)'))
+})
diff --git a/tests/unit/workersProspect.test.mjs b/tests/unit/workersProspect.test.mjs
index 726caf2..94a0ea9 100644
--- a/tests/unit/workersProspect.test.mjs
+++ b/tests/unit/workersProspect.test.mjs
@@ -415,12 +415,14 @@ test('ProspectPanel: Dialog mit Ladezustand und Fehler, lädt über api.prospect
assert.equal((s.match(/]*type="button"/g) || []).length)
})
-test('PultList: "Details" öffnet das Interessenten-Panel, Schließen räumt die Auswahl weg', () => {
+test('PultList: der Knopf „Interessent“ im Brief öffnet das Interessenten-Panel (in der WOMS-Hülle), Schließen räumt die Auswahl weg', () => {
const s = read('src/components/workers/PultList.jsx')
assert.match(s, /import ProspectPanel from '\.\/ProspectPanel'/)
assert.match(s, /const \[detailId, setDetailId\] = useState\(null\)/)
- assert.match(s, /onClick=\{\(\) => onDetails\(prospect\.\$id\)\}/)
- assert.match(s, /\{detailId && setDetailId\(null\)\} \/>\}/)
+ // der Brief (pult/Brief.jsx) öffnet mit dem Knopf „Interessent“ das Panel der Liste; es steht im Pult in der Hülle hk-woms (WOMS-Aussehen statt Pixel-Schrift)
+ assert.match(read('src/components/workers/pult/Brief.jsx'), /onClick=\{\(\) => onDetails\(prospect\.\$id\)\}/)
+ assert.match(s, /onDetails=\{setDetailId\}/)
+ assert.match(s, /\{detailId && \(\s*