import { useCallback, useEffect, useRef, useState } from 'react' import Button from '../ui/Button' import Dialog from '../ui/Dialog' import EmptyState from '../ui/EmptyState' import { useWorkerTrace, useWorkersFeed } from '../../hooks/useWorkersFeed' import { indexWorkers, reduceEvents } from '../../lib/workersEvents' import { pauseLabel } from '../../lib/workersView' import WorkerCard from './WorkerCard' import WorkerList from './WorkerList' import WorkersKpis from './WorkersKpis' import WorkersSettingsDialog from './WorkersSettingsDialog' import WorkersStage from './WorkersStage' const RUNNING_TEXT = 'Läuft' const PAUSED_TEXT = 'Angehalten: Es wird nichts gesendet und nichts gebaut.' const PAUSE_FAILED_TEXT = 'Umschalten fehlgeschlagen.' const NO_REASON_TEXT = 'Grund nicht angegeben' // Grund der Notbremse für den Satz „Versand gestoppt: . …“: getrimmt, ohne abschließende Punkte (sonst entstünde „..“) function haltReasonOf(reason) { const text = typeof reason === 'string' ? reason.trim().replace(/[.\s]+$/, '') : '' return text || NO_REASON_TEXT } // Inhalt des Reiters „Hochhaus“: Kopfzeile mit Pausenschalter, Kennzahlen, Hochhaus mit Seitenspalte (Karte und Liste). // Ohne Auth-Bezug: isAdmin kommt von der Seite. stageRef ist optional (nur der Prüfstand reicht eine durch); ohne eins legt das Board // selbst eins an, damit pushEvents die Bühne immer erreicht. export default function WorkersBoard({ api, isAdmin, stageRef }) { const ownRef = useRef(null) const ref = stageRef ?? ownRef const aliveRef = useRef(true) const sideRef = useRef(null) const toolbarRef = useRef(null) const refocusPauseRef = useRef(false) // nach dem Umschalten bekommt der Knopf „Alles anhalten“ / „Fortsetzen“ den Fokus zurück, falls er ihn verlor const [workers, setWorkers] = useState({}) const [selectedId, setSelectedId] = useState(null) const [settingsOpen, setSettingsOpen] = useState(false) const [pausing, setPausing] = useState(false) const [pauseError, setPauseError] = useState('') const [confirmOpen, setConfirmOpen] = useState(false) useEffect(() => { aliveRef.current = true return () => { aliveRef.current = false } }, []) // Neue Ereignisse: Tabelle fortschreiben und der Bühne vorspielen (die Bühne kennt den Rückstau selbst) const onEvents = useCallback((events) => { setWorkers((w) => reduceEvents(w, events)) if (ref.current) ref.current.pushEvents(events) }, [ref]) const { state, error, loaded, refresh } = useWorkersFeed(api, { onEvents }) useEffect(() => { if (state) setWorkers(indexWorkers(state)) }, [state]) const { trace, loading: traceLoading, error: traceError } = useWorkerTrace(api, selectedId) // Fokus nach dem Umschalten: Während pausing gilt, ist der Knopf der Leiste gesperrt. Schließt der Dialog im selben Takt, in dem pausing // beginnt, kann useDialogFocus den Fokus nicht auf den gesperrten Knopf zurückgeben, und er bliebe für immer auf . Deshalb // wird er erst hier gesetzt, wenn pausing wieder endet (auch nach einem Fehler) und der Knopf wieder bedienbar ist. Hat der Mensch // inzwischen woanders hin gewechselt (Fokus nicht auf ), bleibt es dabei. useEffect(() => { if (pausing || !refocusPauseRef.current) return refocusPauseRef.current = false const active = document.activeElement if (active && active !== document.body) return const button = toolbarRef.current?.querySelector('[data-wk-pause]') if (button) button.focus() }, [pausing]) // Hat inzwischen jemand anderes angehalten, entfällt die Frage; sie darf nach einem späteren Fortsetzen nicht wieder auftauchen const haltedByState = Boolean(state?.paused) useEffect(() => { if (haltedByState) setConfirmOpen(false) }, [haltedByState]) if (!loaded || !state) { if (error) { return ( {error}

} action={} /> ) } return
Wird geladen …
} const paused = Boolean(state.paused) const worker = selectedId && Object.hasOwn(workers, selectedId) ? workers[selectedId] : undefined // Karte schließen: der Fokus geht zurück auf den Listeneintrag des Workers (sonst fiele er mit dem Knopf „ד ins Leere) const closeCard = () => { const id = selectedId setSelectedId(null) if (!id || !sideRef.current) return for (const button of sideRef.current.querySelectorAll('[data-wk-worker]')) { if (button.getAttribute('data-wk-worker') === id) { button.focus() return } } } // Pause für alle Mitarbeiter: der Dienst bekommt den Zielwert (true = anhalten), danach wird der Zustand sofort neu geholt. // Der Zielwert wird übergeben und nicht aus dem Zustand abgeleitet: ein offener Dialog darf nie fortsetzen, falls inzwischen // jemand anderes angehalten hat. const setPause = async (target) => { if (pausing) return refocusPauseRef.current = true setPausing(true) setPauseError('') try { await api.pause(target) refresh() } catch (err) { if (aliveRef.current) setPauseError(err?.message || PAUSE_FAILED_TEXT) } if (aliveRef.current) setPausing(false) } // Anhalten betrifft alle Mitarbeiter sofort und fragt deshalb vorher nach; Fortsetzen ist harmlos und geht ohne Rückfrage const requestPause = () => { if (paused) setPause(false) else setConfirmOpen(true) } const confirmHalt = () => { setConfirmOpen(false) setPause(true) } return ( <>
{paused ? PAUSED_TEXT : RUNNING_TEXT} {isAdmin && }
{pauseError &&
{pauseError}
} {state.sendingHalted === true && (
Versand gestoppt: {haltReasonOf(state.sendingHaltedReason)}. Ein Admin kann ihn in den Einstellungen freigeben.
)} {error &&
Verbindung gestört: {error} Es wird weiter versucht.
}
{isAdmin && settingsOpen && ( setSettingsOpen(false)} onSaved={() => refresh()} /> )} {confirmOpen && !paused && ( setConfirmOpen(false)} footer={ <> } >

Die Worker hören sofort auf, bis jemand fortsetzt.

)} ) }