import { useCallback, useEffect, useState } from 'react' import { useSearchParams } from 'react-router-dom' const BASE = '/api/v1' // Öffentliche Endpunkte: KEIN Bearer-Token, daher nicht der api-Client (der // hängt Authorization an und triggert Token-Refresh). Schlankes fetch. async function publicPost(path: string, body: unknown): Promise { const res = await fetch(`${BASE}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) if (!res.ok) { const err = await res.json().catch(() => ({ detail: res.statusText })) const e = new Error(typeof err.detail === 'string' ? err.detail : res.statusText) ;(e as Error & { status?: number }).status = res.status throw e } return res.json() } interface TimeEntry { id: string; start_time: string; end_time: string | null } interface StampStatus { open: boolean; on_break: boolean; today: TimeEntry[] } interface AuthResponse extends StampStatus { session_token: string; user_name: string; expires_in_seconds: number } interface ActionResponse extends StampStatus { warnings: string[] } function fmtTime(iso: string | null): string { if (!iso) return '–' return iso.slice(0, 5) } export function PublicStampPage() { const [params] = useSearchParams() const token = params.get('t') ?? '' const [companyName, setCompanyName] = useState(null) const [enabled, setEnabled] = useState(true) const [loadingCompany, setLoadingCompany] = useState(true) const [personnelNumber, setPersonnelNumber] = useState('') const [pin, setPin] = useState('') const [sessionToken, setSessionToken] = useState(null) const [userName, setUserName] = useState('') const [status, setStatus] = useState(null) const [busy, setBusy] = useState(false) const [error, setError] = useState(null) const [warnings, setWarnings] = useState([]) const [info, setInfo] = useState(null) useEffect(() => { if (!token) { setLoadingCompany(false); return } fetch(`${BASE}/time/public/company?t=${encodeURIComponent(token)}`) .then(r => r.ok ? r.json() : Promise.reject(new Error('ungültig'))) .then((c: { company_name: string; enabled: boolean }) => { setCompanyName(c.company_name) setEnabled(c.enabled) }) .catch(() => setCompanyName(null)) .finally(() => setLoadingCompany(false)) }, [token]) const resetToLogin = useCallback((msg: string) => { setSessionToken(null) setStatus(null) setPin('') setInfo(msg) }, []) async function authenticate(e: React.FormEvent) { e.preventDefault() setBusy(true); setError(null); setWarnings([]); setInfo(null) try { const res = await publicPost('/time/public/auth', { token, personnel_number: personnelNumber, pin, }) setSessionToken(res.session_token) setUserName(res.user_name) setStatus({ open: res.open, on_break: res.on_break, today: res.today }) } catch (err: unknown) { setError(err instanceof Error ? err.message : 'Anmeldung fehlgeschlagen') } finally { setBusy(false) } } async function doAction(action: 'in' | 'out' | 'break_start' | 'break_end') { if (!sessionToken) return setBusy(true); setError(null); setWarnings([]) try { const res = await publicPost('/time/public/action', { session_token: sessionToken, action, }) setStatus({ open: res.open, on_break: res.on_break, today: res.today }) if (res.warnings.length) setWarnings(res.warnings) } catch (err: unknown) { const status = (err as Error & { status?: number }).status if (status === 401) { resetToLogin('Sitzung abgelaufen. Bitte erneut anmelden.') } else { setError(err instanceof Error ? err.message : 'Aktion fehlgeschlagen') } } finally { setBusy(false) } } // ── Render-Zustände ───────────────────────────────────────────────────────── if (loadingCompany) { return (

Wird geladen…

) } if (!token || companyName === null) { return (

🚫

QR-Code ungültig

Dieser QR-Code ist nicht (mehr) gültig. Bitte an die Verwaltung wenden.

) } if (!enabled) { return (

🔒

QR-Stempeln deaktiviert

Das mobile Stempeln per QR ist für dieses Unternehmen derzeit nicht aktiviert.

) } // Angemeldet → Stempel-Ansicht if (sessionToken && status) { const isOpen = status.open const onBreak = status.on_break return (

Angemeldet als

{userName}

{warnings.length > 0 && (
    {warnings.map((w, i) =>
  • {w}
  • )}
)} {error &&
{error}
}
{onBreak ? 'In Pause' : isOpen ? 'Eingestempelt' : 'Nicht eingestempelt'}
{!isOpen ? ( ) : onBreak ? ( ) : ( <> )} {status.today.length > 0 && (

Heute

    {status.today.map(e => (
  • {fmtTime(e.start_time)} – {fmtTime(e.end_time)}
  • ))}
)}
) } // PIN-Anmeldung return (
{info &&
{info}
} {error &&
{error}
}
setPersonnelNumber(e.target.value.replace(/\D/g, ''))} className='w-full text-center text-2xl tracking-widest font-mono border border-gray-300 rounded-xl px-3 py-3 focus:outline-none focus:ring-2 focus:ring-blue-500' required />
setPin(e.target.value.replace(/\D/g, '').slice(0, 6))} className='w-full text-center text-2xl tracking-widest font-mono border border-gray-300 rounded-xl px-3 py-3 focus:outline-none focus:ring-2 focus:ring-blue-500' required />

PIN im Mitarbeiter-Portal unter „Mein Profil“ setzen/ändern.

) } function Shell({ company, children }: { company?: string; children: React.ReactNode }) { return (

Zeiterfassung

{company ?? 'Stempeln'}

{children}
) }