Hustler-Pixelwelt T6: Kopfzeile mit Münzen und Icons, Meldungen, Pause mit Pixel-Rückfrage, Einstellungen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-22 08:20:19 +00:00
1 parent 3027f03dbe
commit 6f4350495f
3 files changed
+1110

No files matched your search

@@ -0,0 +1,220 @@
import { useEffect, useRef, useState } from 'react'
import { shouldCloseOnKey } from '../../../lib/dialogKeys'
import { hudWerte } from '../../../lib/hustlerSicht'
import { pauseLabel } from '../../../lib/workersView'
import WorkersSettingsDialog from '../WorkersSettingsDialog'
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
}
// Rückfrage „Alles anhalten?“ als Pixel-Dialog (Panel-Stil der Zeichenfläche). Fokus beim Öffnen auf „Abbrechen“ (die harmlose Wahl),
// Tab bleibt im Dialog, Esc schließt, beim Schließen geht der Fokus zurück: auf das Element, das ihn vorher hatte, sonst auf den
// Pause-Knopf (fallbackRef; Safari fokussiert Knöpfe beim Klicken nicht). Ein Klick auf den Hintergrund tut nichts und nimmt dem Dialog
// den Fokus nicht.
function Rueckfrage({ onCancel, onConfirm, fallbackRef }) {
const boxRef = useRef(null)
const cancelRef = useRef(null)
const confirmRef = useRef(null)
const openerRef = useRef(undefined)
if (openerRef.current === undefined) openerRef.current = document.activeElement
useEffect(() => {
cancelRef.current?.focus()
return () => {
const opener = openerRef.current
const usable = opener && opener !== document.body && opener.isConnected !== false && typeof opener.focus === 'function'
const target = usable ? opener : fallbackRef.current
if (target && target.isConnected !== false && typeof target.focus === 'function') target.focus()
}
}, [fallbackRef])
const onKeyDown = (e) => {
if (shouldCloseOnKey(e, false)) {
e.preventDefault()
onCancel()
return
}
if (e.key !== 'Tab') return
const items = [cancelRef.current, confirmRef.current].filter(Boolean)
if (items.length === 0) return
const first = items[0]
const last = items[items.length - 1]
const active = document.activeElement
const inside = items.includes(active)
if (e.shiftKey && (active === first || active === boxRef.current || !inside)) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && (active === last || !inside)) {
e.preventDefault()
first.focus()
}
}
return (
<div
className="hk-rueckfrage-hintergrund"
onKeyDown={onKeyDown}
onMouseDown={(e) => { if (e.target === e.currentTarget) e.preventDefault() }}
>
<section className="hk-panel hk-rueckfrage" role="dialog" aria-modal="true" aria-labelledby="hk-rueckfrage-titel" tabIndex={-1} ref={boxRef}>
<h2 className="hk-rueckfrage-titel" id="hk-rueckfrage-titel">Alles anhalten?</h2>
<p className="hk-rueckfrage-text">Die Worker hören sofort auf, bis jemand fortsetzt.</p>
<div className="hk-rueckfrage-knoepfe">
<button type="button" className="hk-knopf" ref={cancelRef} onClick={() => onCancel()}>Abbrechen</button>
<button type="button" className="hk-knopf" ref={confirmRef} onClick={() => onConfirm()}>Anhalten</button>
</div>
</section>
</div>
)
}
// Kopfzeile des Hochhauses (Spec E7): Münzen (Einnahmen, API-Kosten), fünf Kennzahlen mit Icon, rechts „Ganzes Haus zeigen“ (nur Tastatur),
// Pause/Fortsetzen und das Zahnrad (nur Admins), darunter die Meldungen als Pixel-Banner. Die Pause-Logik ist die des früheren WorkersBoard:
// der Dienst bekommt den Zielwert (true = anhalten, nie aus dem Zustand abgeleitet), danach wird der Zustand sofort neu geholt; nur das
// Anhalten fragt vorher nach. Ohne Auth-Bezug: isAdmin kommt von der Seite. state ist der Dienst-Zustand (bereits geladen), error der
// Verbindungsfehler des Feeds (leer, solange alles gut geht), refresh holt den Zustand neu, onGanz zeigt das ganze Haus.
export default function HochhausKopf({ api, isAdmin, state, error, refresh, onGanz }) {
const aliveRef = useRef(true)
const pauseRef = useRef(null)
const refocusPauseRef = useRef(false) // nach dem Umschalten bekommt der Pause-Knopf den Fokus zurück, falls er ihn verlor
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 }
}, [])
// Fokus nach dem Umschalten: Während pausing gilt, ist der Knopf gesperrt. Schließt die Rückfrage im selben Takt, in dem pausing beginnt,
// kann sie 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
pauseRef.current?.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])
const paused = haltedByState
const hud = hudWerte(state?.kpis)
// Pause für alle Mitarbeiter: Zielwert übergeben, danach den Zustand sofort neu holen. Ein offener Dialog darf nie fortsetzen, falls
// inzwischen jemand anderes angehalten hat (darum der feste Zielwert).
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="hk-kopf">
<div className="hk-kopf-zeile">
<section className="hk-panel hk-kopf-geld" aria-label="Geld">
<div className="hk-kopf-wert" title={hud.einnahmen.titel}>
<span className="hk-ic hk-muenze-gruen" aria-hidden="true" />
<span className="hk-kopf-betrag hk-kopf-gruen">
<span className="hk-kopf-sr">{hud.einnahmen.sr}</span>{hud.einnahmen.text}
</span>
</div>
<div className="hk-kopf-wert" title={hud.api.titel}>
<span className="hk-ic hk-muenze-rot hk-kopf-v-rot" aria-hidden="true" />
<span className={`hk-kopf-betrag hk-kopf-rot${hud.api.stufe ? ` hk-api-${hud.api.stufe}` : ''}`}>
<span className="hk-kopf-sr">{hud.api.sr}</span>{hud.api.text}
</span>
</div>
</section>
<section className="hk-panel hk-kopf-zahlen" aria-label="Kennzahlen">
{hud.zahlen.map((z) => (
<div className="hk-kopf-zahl" key={z.key} title={z.titel} style={{ color: z.farbe }}>
<span className={`${z.icon} hk-kopf-v-${z.key}`} aria-hidden="true" />
<span className="hk-kopf-sr">{z.titel}:</span>
<span>{z.text}</span>
</div>
))}
</section>
<span className="hk-kopf-abstand" />
<div className="hk-panel hk-kopf-rechts">
<button type="button" className="hk-nur-tastatur" onClick={() => onGanz?.()}>Ganzes Haus zeigen</button>
<button
type="button"
className="hk-hud-knopf"
aria-label={pauseLabel(paused)}
title={pauseLabel(paused)}
disabled={pausing}
onClick={requestPause}
ref={pauseRef}
data-hk-pause
>
<span className={paused ? 'hk-ic hk-pfeil-rechts' : 'hk-ic hk-pause'} aria-hidden="true" />
</button>
{isAdmin && (
<button type="button" className="hk-hud-knopf" aria-label="Einstellungen" title="Einstellungen" onClick={() => setSettingsOpen(true)}>
<span className="hk-ic hk-zahnrad hk-kopf-v-zahnrad" aria-hidden="true" />
</button>
)}
</div>
</div>
<div className="hk-meldungen">
{paused && <div className="hk-meldung hk-meldung-angehalten" role="status">{PAUSED_TEXT}</div>}
{pauseError !== '' && <div className="hk-meldung hk-meldung-umschalten" role="alert">{pauseError}</div>}
{state?.sendingHalted === true && (
<div className="hk-meldung hk-meldung-notbremse" role="alert">
Versand gestoppt: {haltReasonOf(state.sendingHaltedReason)}. Ein Admin kann ihn in den Einstellungen freigeben.
</div>
)}
{Boolean(error) && <div className="hk-meldung hk-meldung-verbindung" role="status">Verbindung gestört: {error} Es wird weiter versucht.</div>}
</div>
</div>
{isAdmin && settingsOpen && (
<div className="hk-woms">
<WorkersSettingsDialog api={api} onClose={() => setSettingsOpen(false)} onSaved={() => refresh()} />
</div>
)}
{confirmOpen && !paused && (
<Rueckfrage onCancel={() => setConfirmOpen(false)} onConfirm={confirmHalt} fallbackRef={pauseRef} />
)}
</>
)
}