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:
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} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user