import { useCallback, useEffect, useRef, useState } from 'react' import { Link } from 'react-router-dom' interface HeartbeatResponse { server_time?: string server_timestamp?: number status?: string } // BroadcastChannel name – only one tab sends heartbeats const HEARTBEAT_CHANNEL = 'kiosk-heartbeat' const HEARTBEAT_INTERVAL_MS = 30_000 const CLIENT_VERSION = '1.0.0' // NFC keyboard-wedge readers "type" the card UID very fast, ending with Enter. // A human never types 10 digits in under this window, so a burst of digit // keys arriving within NFC_BURST_MS is treated as a card tap, not typing. const NFC_BURST_MS = 300 const NFC_MIN_LENGTH = 4 type Step = 'idle' | 'pin' | 'action' | 'done' interface StampStatus { stamped_in: boolean on_break: boolean } function formatTime(date: Date): string { return date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' }) } function formatDate(date: Date): string { return date.toLocaleDateString('de-DE', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', }) } async function sendMessageToSW( reg: ServiceWorkerRegistration, message: object ): Promise> { return new Promise((resolve, reject) => { const channel = new MessageChannel() channel.port1.onmessage = (e) => resolve(e.data as Record) channel.port1.onmessageerror = () => reject(new Error('MessageChannel-Fehler')) const sw = reg.active if (!sw) { reject(new Error('ServiceWorker nicht aktiv')); return } sw.postMessage({ ...message, port: channel.port2 }, [channel.port2]) }) } async function kioskFetch(path: string, body: object): Promise { const res = await fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) const data = await res.json().catch(() => ({})) if (!res.ok) { throw new Error(typeof data.detail === 'string' ? data.detail : `Fehler ${res.status}`) } return data as T } export function KioskStampPage() { const [displayTime, setDisplayTime] = useState(new Date()) const [serverTimeOffset, setServerTimeOffset] = useState(0) // ms offset from server const [isOnline, setIsOnline] = useState(navigator.onLine) const [heartbeatStatus, setHeartbeatStatus] = useState<'connected' | 'error' | 'pending'>('pending') const [heartbeatError, setHeartbeatError] = useState(null) const [hasCredentials, setHasCredentials] = useState(null) const [deviceId, setDeviceId] = useState(null) const [isLeaderTab, setIsLeaderTab] = useState(false) // ── Login/Stamp-Flow ──────────────────────────────────────────────────── const [step, setStep] = useState('idle') const [nfcUid, setNfcUid] = useState(null) const [pin, setPin] = useState('') const [sessionToken, setSessionToken] = useState(null) const [userName, setUserName] = useState(null) const [stampStatus, setStampStatus] = useState(null) const [flowError, setFlowError] = useState(null) const [busy, setBusy] = useState(false) const [doneMessage, setDoneMessage] = useState(null) const heartbeatIntervalRef = useRef | null>(null) const clockIntervalRef = useRef | null>(null) const broadcastRef = useRef(null) const swRegRef = useRef(null) const startTimeRef = useRef(Date.now()) const resetTimeoutRef = useRef | null>(null) // NFC keyboard-wedge capture buffer const nfcBufferRef = useRef('') const nfcLastKeyAtRef = useRef(0) const resetFlow = useCallback(() => { setStep('idle') setNfcUid(null) setPin('') setSessionToken(null) setUserName(null) setStampStatus(null) setFlowError(null) setDoneMessage(null) nfcBufferRef.current = '' }, []) function scheduleAutoReset(ms = 4000) { if (resetTimeoutRef.current) clearTimeout(resetTimeoutRef.current) resetTimeoutRef.current = setTimeout(resetFlow, ms) } // Live clock – uses server time offset useEffect(() => { clockIntervalRef.current = setInterval(() => { const localNow = Date.now() setDisplayTime(new Date(localNow + serverTimeOffset)) }, 1000) return () => { if (clockIntervalRef.current) clearInterval(clockIntervalRef.current) } }, [serverTimeOffset]) // Online/Offline events useEffect(() => { const onOnline = () => setIsOnline(true) const onOffline = () => setIsOnline(false) window.addEventListener('online', onOnline) window.addEventListener('offline', onOffline) return () => { window.removeEventListener('online', onOnline) window.removeEventListener('offline', onOffline) } }, []) // ServiceWorker + BroadcastChannel setup useEffect(() => { let cancelled = false async function init() { // Register / retrieve SW if ('serviceWorker' in navigator) { try { await navigator.serviceWorker.register('/kiosk-sw.js', { scope: '/' }) const ready = await navigator.serviceWorker.ready swRegRef.current = ready if (!cancelled) { const result = await sendMessageToSW(ready, { type: 'CHECK_CREDENTIALS' }) setHasCredentials(!!result.hasCredentials) setDeviceId(result.deviceId as string | null) } } catch { if (!cancelled) { setHasCredentials(false) } } } else { setHasCredentials(false) } if (cancelled) return // Leader-election via BroadcastChannel: // We announce ourselves; if we receive an announcement from another tab // we yield. Simple "last writer wins for 1s window" approach. const channel = new BroadcastChannel(HEARTBEAT_CHANNEL) broadcastRef.current = channel let isLeader = true channel.onmessage = (e) => { if (e.data?.type === 'HEARTBEAT_LEADER_ANNOUNCE') { // Another tab claims leadership – we yield isLeader = false setIsLeaderTab(false) if (heartbeatIntervalRef.current) { clearInterval(heartbeatIntervalRef.current) heartbeatIntervalRef.current = null } } if (e.data?.type === 'HEARTBEAT_LEADER_YIELD' && !isLeader) { // Previous leader stepped down – we take over isLeader = true setIsLeaderTab(true) startHeartbeatLoop() } } // Announce ourselves as leader after a short random delay // to avoid simultaneous announcements on page reload const delay = Math.random() * 500 await new Promise(r => setTimeout(r, delay)) if (!cancelled) { channel.postMessage({ type: 'HEARTBEAT_LEADER_ANNOUNCE' }) setIsLeaderTab(isLeader) if (isLeader) startHeartbeatLoop() } } init() return () => { cancelled = true if (heartbeatIntervalRef.current) clearInterval(heartbeatIntervalRef.current) if (broadcastRef.current) { broadcastRef.current.postMessage({ type: 'HEARTBEAT_LEADER_YIELD' }) broadcastRef.current.close() } } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) // NFC keyboard-wedge listener: only active on the idle screen. Cheap USB-HID // RFID readers "type" the UID as fast keystrokes ending with Enter - a human // typing on a real keyboard can't hit 4+ digits within NFC_BURST_MS. useEffect(() => { if (step !== 'idle' || hasCredentials !== true) return function onKeyDown(e: KeyboardEvent) { const now = Date.now() if (now - nfcLastKeyAtRef.current > NFC_BURST_MS) { nfcBufferRef.current = '' } nfcLastKeyAtRef.current = now if (e.key === 'Enter') { const uid = nfcBufferRef.current nfcBufferRef.current = '' if (uid.length >= NFC_MIN_LENGTH) { setNfcUid(uid) setStep('pin') setPin('') setFlowError(null) } return } if (/^[0-9A-Za-z]$/.test(e.key)) { nfcBufferRef.current += e.key } } window.addEventListener('keydown', onKeyDown) return () => window.removeEventListener('keydown', onKeyDown) }, [step, hasCredentials]) function startHeartbeatLoop() { // Send immediately, then on interval sendHeartbeat() if (heartbeatIntervalRef.current) clearInterval(heartbeatIntervalRef.current) heartbeatIntervalRef.current = setInterval(sendHeartbeat, HEARTBEAT_INTERVAL_MS) } async function sendHeartbeat() { if (!navigator.onLine) { setHeartbeatStatus('error') setHeartbeatError('Kein Netzwerk') return } const uptimeSeconds = Math.floor((Date.now() - startTimeRef.current) / 1000) const body = JSON.stringify({ uptime_seconds: uptimeSeconds, client_version: CLIENT_VERSION, queued_offline_entries: 0, current_user_id: null, }) try { const res = await fetch('/api/v1/kiosk/heartbeat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body, }) if (!res.ok) { const errData = await res.json().catch(() => ({ detail: res.statusText })) throw new Error( typeof errData.detail === 'string' ? errData.detail : res.statusText ) } const data: HeartbeatResponse = await res.json().catch(() => ({})) // Sync server time if provided if (data.server_timestamp) { const localNow = Date.now() const serverMs = data.server_timestamp * 1000 setServerTimeOffset(serverMs - localNow) } else if (data.server_time) { const localNow = Date.now() const serverMs = new Date(data.server_time).getTime() if (!isNaN(serverMs)) { setServerTimeOffset(serverMs - localNow) } } setHeartbeatStatus('connected') setHeartbeatError(null) } catch (e: unknown) { setHeartbeatStatus('error') setHeartbeatError(e instanceof Error ? e.message : 'Verbindungsfehler') } } async function submitPin() { if (!nfcUid || pin.length < 4) return setBusy(true) setFlowError(null) try { const login = await kioskFetch<{ session_token: string; user_name: string }>( '/api/v1/kiosk/auth/nfc', { nfc_uid: nfcUid, pin } ) setSessionToken(login.session_token) setUserName(login.user_name) const status = await kioskFetch( '/api/v1/kiosk/stamp/status', { session_token: login.session_token } ) setStampStatus(status) setStep('action') } catch (e: unknown) { setFlowError(e instanceof Error ? e.message : 'Anmeldung fehlgeschlagen') setPin('') } finally { setBusy(false) } } async function performAction(action: 'in' | 'out' | 'break-start' | 'break-end') { if (!sessionToken) return setBusy(true) setFlowError(null) try { const result = await kioskFetch<{ status: string; warnings?: string[] }>( `/api/v1/kiosk/stamp/${action}`, { session_token: sessionToken } ) const labels: Record = { stamped_in: 'Eingestempelt ✓', stamped_out: 'Ausgestempelt ✓', break_started: 'Pause gestartet ✓', break_ended: 'Pause beendet ✓', } setDoneMessage(labels[result.status] ?? 'Erledigt ✓') if (result.warnings?.length) { setFlowError(result.warnings.join(' · ')) } setStep('done') scheduleAutoReset() } catch (e: unknown) { setFlowError(e instanceof Error ? e.message : 'Aktion fehlgeschlagen') } finally { setBusy(false) } } return (
{/* Status bar */}
TimeMaster Kiosk {deviceId && ( {deviceId.slice(0, 8)}… )}
{/* Heartbeat / connection status */} {isLeaderTab && (
{heartbeatStatus === 'connected' && ( <> Verbunden )} {heartbeatStatus === 'error' && ( <> {heartbeatError ?? 'Verbindungsfehler'} )} {heartbeatStatus === 'pending' && ( <> Verbinde… )}
)} {!isLeaderTab && ( Heartbeat: anderer Tab aktiv )} {/* Online/Offline indicator */}
{isOnline ? Online : Offline }
{/* Main content */}
{/* Clock */}
{formatTime(displayTime)}
{formatDate(displayTime)}
{/* Credentials missing warning */} {hasCredentials === false && (
⚠️

Kiosk nicht eingerichtet

Dieses Geraet hat noch kein Ed25519-Schluesselpaar. Bitte zuerst den Setup-Assistenten durchlaufen.

Kiosk einrichten →
)} {/* ── Login/Stempel-Flow ─────────────────────────────────────────── */} {hasCredentials === true && step === 'idle' && (
💳

Bitte Karte auflegen

Kein Kartenleser-Fokus nötig – Eingabe wird automatisch erkannt.

{heartbeatStatus === 'error' && heartbeatError && (
Verbindungsfehler: {heartbeatError}
)}
)} {hasCredentials === true && step === 'pin' && (
🔑

PIN eingeben

{'•'.repeat(pin.length)}
{flowError && (
{flowError}
)}
{['1','2','3','4','5','6','7','8','9','','0','⌫'].map((k, i) => ( k === '' ?
: ( ) ))}
)} {hasCredentials === true && step === 'action' && stampStatus && (

Angemeldet als

{userName}

{flowError && (
{flowError}
)}
{!stampStatus.stamped_in && ( )} {stampStatus.stamped_in && !stampStatus.on_break && ( <> )} {stampStatus.stamped_in && stampStatus.on_break && ( )}
)} {hasCredentials === true && step === 'done' && (

{doneMessage}

{flowError && (

{flowError}

)}

Zurück zur Startseite in wenigen Sekunden…

)} {/* Loading state */} {hasCredentials === null && (
Initialisiere…
)}
{/* Footer */}
Kiosk-Einrichtung
) }