Der Dialog schloss im selben Takt, in dem pausing den Knopf der Leiste sperrte; useDialogFocus konnte den Fokus deshalb nicht zurückgeben, und er blieb für Tastatur- und Screenreader-Nutzer dauerhaft auf <body>. Das Board setzt ihn jetzt selbst, sobald pausing endet (auch nach einem Fehler), und nur dann, wenn der Fokus verloren ist. Der Prüfstand-Check (Szenario I) ist grün. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
191 lines
7.6 KiB
React
191 lines
7.6 KiB
React
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: <Grund>. …“: 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 <body>. 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 <body>), 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 (
|
|
<EmptyState
|
|
title="Der Hustler-Dienst ist noch nicht erreichbar"
|
|
hint={<p>{error}</p>}
|
|
action={<Button type="button" onClick={() => refresh()}>Neu laden</Button>}
|
|
/>
|
|
)
|
|
}
|
|
return <div className="loading">Wird geladen …</div>
|
|
}
|
|
|
|
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 (
|
|
<>
|
|
<div className="wk-toolbar" ref={toolbarRef}>
|
|
<span className="wk-status">{paused ? PAUSED_TEXT : RUNNING_TEXT}</span>
|
|
<span className="grow" />
|
|
<Button type="button" onClick={requestPause} disabled={pausing} data-wk-pause>{pauseLabel(paused)}</Button>
|
|
{isAdmin && <Button type="button" onClick={() => setSettingsOpen(true)}>Einstellungen</Button>}
|
|
</div>
|
|
|
|
{pauseError && <div className="msg err" role="alert">{pauseError}</div>}
|
|
{state.sendingHalted === true && (
|
|
<div className="msg err" role="alert">
|
|
Versand gestoppt: {haltReasonOf(state.sendingHaltedReason)}. Ein Admin kann ihn in den Einstellungen freigeben.
|
|
</div>
|
|
)}
|
|
{error && <div className="msg warn" role="status">Verbindung gestört: {error} Es wird weiter versucht.</div>}
|
|
|
|
<WorkersKpis kpis={state.kpis} />
|
|
|
|
<div className="wk-main">
|
|
<WorkersStage ref={ref} state={state} selectedId={selectedId} onSelect={setSelectedId} />
|
|
<div className="wk-side" ref={sideRef}>
|
|
<WorkerCard
|
|
workerId={selectedId}
|
|
worker={worker}
|
|
trace={trace}
|
|
loading={traceLoading}
|
|
error={traceError}
|
|
onClose={closeCard}
|
|
/>
|
|
<WorkerList workers={workers} selectedId={selectedId} onSelect={setSelectedId} />
|
|
</div>
|
|
</div>
|
|
|
|
{isAdmin && settingsOpen && (
|
|
<WorkersSettingsDialog api={api} onClose={() => setSettingsOpen(false)} onSaved={() => refresh()} />
|
|
)}
|
|
|
|
{confirmOpen && !paused && (
|
|
<Dialog
|
|
title="Alles anhalten?"
|
|
onClose={() => setConfirmOpen(false)}
|
|
footer={
|
|
<>
|
|
<span className="grow" />
|
|
<Button type="button" onClick={() => setConfirmOpen(false)}>Abbrechen</Button>
|
|
<Button variant="primary" type="button" onClick={confirmHalt}>Anhalten</Button>
|
|
</>
|
|
}
|
|
>
|
|
<p>Die Worker hören sofort auf, bis jemand fortsetzt.</p>
|
|
</Dialog>
|
|
)}
|
|
</>
|
|
)
|
|
}
|