Files
timemaster/frontend/src/pages/CompanySettingsPage.tsx
T
patrickandClaude Sonnet 4.6 6d5bbe27b2 fix: agent-08 Kiosk-Härtung + 24h-Zeiteintrag-Bug
- fix: worked_minutes nutzt jetzt Sekunden statt Minuten für Overnight-Vergleich
  (end < start statt end <= start) – verhindert 24h-Anzeige bei Schnell-Stempel
  in derselben Minute (z.B. 23:34:46 → 23:34:48)
- fix: _check_arbzg() gleicher Sec-basierter Fix
- fix: KioskDeviceStatus Enum values_callable → kiosk list crasht nicht mehr
- feat: kiosk rotate-key CLI-Kommando (Status→pending, Re-Enrollment)
- feat: Kiosk-Settings in CompanyOut/CompanyUpdate Schema (require_approval,
  track_current_user, heartbeat_interval_sec)
- feat: Kiosk-Terminal-Einstellungsblock in CompanySettingsPage (🖥️)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 01:42:08 +02:00

741 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from 'react'
import { api } from '../api/client'
import { Layout } from '../components/Layout'
interface UserOut {
id: string; first_name: string; last_name: string; email: string; role: string
}
interface CompanyOut {
id: string
name: string
slug: string
plan: string
country: string
state: string | null
settings: Record<string, unknown>
personnel_number_required: boolean
personnel_number_mode: 'manual' | 'auto'
personnel_number_next: number
}
const BUNDESLAENDER = [
{ code: 'BB', name: 'Brandenburg' },
{ code: 'BE', name: 'Berlin' },
{ code: 'BW', name: 'Baden-Württemberg' },
{ code: 'BY', name: 'Bayern' },
{ code: 'HB', name: 'Bremen' },
{ code: 'HE', name: 'Hessen' },
{ code: 'HH', name: 'Hamburg' },
{ code: 'MV', name: 'Mecklenburg-Vorpommern' },
{ code: 'NI', name: 'Niedersachsen' },
{ code: 'NW', name: 'Nordrhein-Westfalen' },
{ code: 'RP', name: 'Rheinland-Pfalz' },
{ code: 'SH', name: 'Schleswig-Holstein' },
{ code: 'SL', name: 'Saarland' },
{ code: 'SN', name: 'Sachsen' },
{ code: 'ST', name: 'Sachsen-Anhalt' },
{ code: 'TH', name: 'Thüringen' },
]
export function CompanySettingsPage() {
const [me, setMe] = useState<UserOut | null>(null)
const [company, setCompany] = useState<CompanyOut | null>(null)
const [name, setName] = useState('')
const [state, setState] = useState('')
// Arbeitszeitregeln
const [coreStart, setCoreStart] = useState('8')
const [coreEnd, setCoreEnd] = useState('17')
// Resturlaub-Verfall
const [carryoverMonth, setCarryoverMonth] = useState('')
const [carryoverDay, setCarryoverDay] = useState('')
// Personalnummern
const [pnRequired, setPnRequired] = useState(false)
const [pnMode, setPnMode] = useState<'manual' | 'auto'>('manual')
const [pnNext, setPnNext] = useState(1)
// Mobile
const [mobileStamping, setMobileStamping] = useState(true)
// Kiosk
const [kioskRequireApproval, setKioskRequireApproval] = useState(true)
const [kioskTrackCurrentUser, setKioskTrackCurrentUser] = useState(true)
const [kioskHeartbeatIntervalSec, setKioskHeartbeatIntervalSec] = useState(30)
// Freizeitausgleich
const [fzaOverdraftAllowed, setFzaOverdraftAllowed] = useState(true)
const [fzaWarningThreshold, setFzaWarningThreshold] = useState(0)
// Busylight
const [blStatus, setBlStatus] = useState<{ configured: boolean; created_at: string | null } | null>(null)
const [blPlaintext, setBlPlaintext] = useState<string | null>(null)
const [blBusy, setBlBusy] = useState(false)
const [blCopied, setBlCopied] = useState(false)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
api.get<UserOut>('/auth/me').then(setMe).catch(() => {})
api.get<CompanyOut>('/companies/me').then(c => {
setCompany(c)
setName(c.name)
setState(c.state ?? '')
const s = c.settings as Record<string, string>
setCoreStart(s.core_start ?? '8')
setCoreEnd(s.core_end ?? '17')
setCarryoverMonth(s.carryover_expires_month?.toString() ?? '')
setCarryoverDay(s.carryover_expires_day?.toString() ?? '')
setPnRequired(c.personnel_number_required ?? false)
setPnMode(c.personnel_number_mode ?? 'manual')
setPnNext(c.personnel_number_next ?? 1)
const cc = c as CompanyOut & { mobile_stamping_enabled?: boolean; overtime_overdraft_allowed?: boolean; overtime_warning_threshold_hours?: number; kiosk_require_approval?: boolean; kiosk_track_current_user?: boolean; kiosk_heartbeat_interval_sec?: number }
setMobileStamping(cc.mobile_stamping_enabled ?? true)
setFzaOverdraftAllowed(cc.overtime_overdraft_allowed ?? true)
setFzaWarningThreshold(cc.overtime_warning_threshold_hours ?? 0)
setKioskRequireApproval(cc.kiosk_require_approval ?? true)
setKioskTrackCurrentUser(cc.kiosk_track_current_user ?? true)
setKioskHeartbeatIntervalSec(cc.kiosk_heartbeat_interval_sec ?? 30)
}).catch(() => {})
api.get<{ configured: boolean; created_at: string | null }>('/companies/me/busylight-token')
.then(setBlStatus)
.catch(() => {})
}, [])
async function rotateBusylightToken() {
setBlBusy(true)
setError(null)
try {
const res = await api.post<{ token: string; created_at: string }>(
'/companies/me/busylight-token/rotate', {}
)
setBlPlaintext(res.token)
setBlStatus({ configured: true, created_at: res.created_at })
setBlCopied(false)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler beim Generieren des Tokens')
} finally {
setBlBusy(false)
}
}
async function revokeBusylightToken() {
if (!confirm('Token wirklich deaktivieren? busylight kann danach keine Daten mehr abrufen.')) return
setBlBusy(true)
setError(null)
try {
await api.del('/companies/me/busylight-token')
setBlStatus({ configured: false, created_at: null })
setBlPlaintext(null)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler beim Deaktivieren')
} finally {
setBlBusy(false)
}
}
async function copyBusylightToken() {
if (!blPlaintext) return
try {
await navigator.clipboard.writeText(blPlaintext)
setBlCopied(true)
setTimeout(() => setBlCopied(false), 2000)
} catch {
/* clipboard nicht verfügbar User kann manuell markieren */
}
}
async function save() {
setSaving(true)
setError(null)
setSaved(false)
try {
const updated = await api.patch<CompanyOut>('/companies/me', {
name: name.trim() || undefined,
state: state || null,
settings: {
...(company?.settings ?? {}),
core_start: coreStart,
core_end: coreEnd,
carryover_expires_month: carryoverMonth ? parseInt(carryoverMonth) : null,
carryover_expires_day: carryoverDay ? parseInt(carryoverDay) : null,
},
personnel_number_required: pnRequired,
personnel_number_mode: pnMode,
personnel_number_next: pnNext,
mobile_stamping_enabled: mobileStamping,
overtime_overdraft_allowed: fzaOverdraftAllowed,
overtime_warning_threshold_hours: fzaWarningThreshold,
kiosk_require_approval: kioskRequireApproval,
kiosk_track_current_user: kioskTrackCurrentUser,
kiosk_heartbeat_interval_sec: kioskHeartbeatIntervalSec,
})
setCompany(updated)
setSaved(true)
setTimeout(() => setSaved(false), 3000)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler beim Speichern')
} finally {
setSaving(false)
}
}
const isAdmin = me?.role === 'COMPANY_ADMIN' || me?.role === 'SUPER_ADMIN'
const hourOptions = Array.from({ length: 24 }, (_, i) => i.toString())
return (
<Layout userRole={me?.role ?? ''} userName={me ? `${me.first_name} ${me.last_name}` : ''}>
<div className="max-w-2xl mx-auto space-y-6">
<h1 className="text-2xl font-bold text-gray-900">Firmen-Einstellungen</h1>
{/* Allgemein */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-5">
<h2 className="font-semibold text-gray-700">Allgemein</h2>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Firmenname</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
disabled={!isAdmin}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-50 disabled:text-gray-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Bundesland
<span className="ml-1 text-xs text-gray-400 font-normal">(für Feiertagskalender)</span>
</label>
<select
value={state}
onChange={e => setState(e.target.value)}
disabled={!isAdmin}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-50 disabled:text-gray-500"
>
<option value=""> Bundesland wählen </option>
{BUNDESLAENDER.map(bl => (
<option key={bl.code} value={bl.code}>{bl.name}</option>
))}
</select>
{state && (
<p className="mt-1 text-xs text-blue-600">
Feiertage für <strong>{BUNDESLAENDER.find(b => b.code === state)?.name}</strong> werden im Kalender und bei der Stundenauswertung berücksichtigt.
</p>
)}
{!state && (
<p className="mt-1 text-xs text-amber-600">
Ohne Bundesland können keine Feiertage automatisch erkannt werden.
</p>
)}
</div>
</div>
{/* Arbeitszeitregeln */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-5">
<div>
<h2 className="font-semibold text-gray-700">Arbeitszeitregeln</h2>
<p className="text-xs text-gray-400 mt-0.5">
Grundlage für die Auswertung von Sonderstunden im Bericht (für den Steuerberater).
</p>
</div>
{/* Kernarbeitszeit */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Kernarbeitszeit</label>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500">Von</span>
<select
value={coreStart}
onChange={e => setCoreStart(e.target.value)}
disabled={!isAdmin}
className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-50"
>
{hourOptions.map(h => (
<option key={h} value={h}>{h.padStart(2, '0')}:00 Uhr</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500">bis</span>
<select
value={coreEnd}
onChange={e => setCoreEnd(e.target.value)}
disabled={!isAdmin}
className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-50"
>
{hourOptions.map(h => (
<option key={h} value={h}>{h.padStart(2, '0')}:00 Uhr</option>
))}
</select>
</div>
</div>
<p className="mt-1 text-xs text-gray-400">
Stunden außerhalb dieser Zeit gelten als Überstunden/Nachtarbeit.
</p>
</div>
{/* Nachtarbeit gesetzlich fixiert */}
<div className="rounded-lg bg-gray-50 border border-gray-200 p-4 space-y-2">
<p className="text-sm font-medium text-gray-700">Nachtarbeit gesetzlich fixiert</p>
<div className="grid grid-cols-1 gap-1 text-xs text-gray-600">
<div className="flex justify-between">
<span>20:0024:00 Uhr (MoSa)</span>
<span className="font-medium text-blue-700">25 % Zuschlag (§3b EStG)</span>
</div>
<div className="flex justify-between">
<span>00:0004:00 Uhr (MoSa)</span>
<span className="font-medium text-blue-700">40 % Zuschlag (§3b EStG)</span>
</div>
<div className="flex justify-between">
<span>04:0006:00 Uhr (MoSa)</span>
<span className="font-medium text-blue-700">25 % Zuschlag (§3b EStG)</span>
</div>
<div className="flex justify-between mt-1">
<span>Sonntagsarbeit (00:0024:00)</span>
<span className="font-medium text-purple-700">50 % Zuschlag</span>
</div>
<div className="flex justify-between">
<span>Feiertagsarbeit (gesetzliche Feiertage)</span>
<span className="font-medium text-red-700">125 % / 150 % Zuschlag</span>
</div>
</div>
<p className="text-xs text-gray-400 pt-1">
Diese Werte sind durch §3b EStG gesetzlich vorgegeben und können nicht geändert werden.
Bei Überschneidung gilt stets der höchste Zuschlag.
</p>
</div>
</div>
{/* Urlaubsregeln */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-5">
<div>
<h2 className="font-semibold text-gray-700">Urlaubsregeln</h2>
<p className="text-xs text-gray-400 mt-0.5">
Nicht genommener Urlaub aus dem Vorjahr (Resturlaub) wird automatisch übertragen.
Hier kannst du festlegen, bis wann er genommen werden muss.
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Resturlaub verfällt am
</label>
<div className="flex items-center gap-3">
<select
value={carryoverDay}
onChange={e => setCarryoverDay(e.target.value)}
disabled={!isAdmin}
className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-50"
>
<option value=""> Tag </option>
{Array.from({ length: 31 }, (_, i) => i + 1).map(d => (
<option key={d} value={d}>{d}.</option>
))}
</select>
<select
value={carryoverMonth}
onChange={e => setCarryoverMonth(e.target.value)}
disabled={!isAdmin}
className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-50"
>
<option value=""> Monat </option>
{['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember'].map((m, i) => (
<option key={i+1} value={i+1}>{m}</option>
))}
</select>
{(carryoverDay || carryoverMonth) && (
<button
onClick={() => { setCarryoverDay(''); setCarryoverMonth('') }}
className="text-xs text-gray-400 hover:text-red-500"
title="Verfall deaktivieren"
>
Kein Verfall
</button>
)}
</div>
{carryoverDay && carryoverMonth ? (
<p className="mt-2 text-xs text-blue-700 bg-blue-50 border border-blue-200 rounded-lg px-3 py-2">
Resturlaub aus dem Vorjahr verfällt jährlich am <strong>{carryoverDay}. {['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember'][parseInt(carryoverMonth)-1]}</strong>.
Im Urlaubskonto der Mitarbeiter wird das Datum und ob er bereits verfallen ist angezeigt.
</p>
) : (
<p className="mt-2 text-xs text-gray-400">
Kein Verfall konfiguriert Resturlaub wird unbegrenzt übertragen.
</p>
)}
</div>
</div>
{/* Personalnummern */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-5">
<div>
<h2 className="font-semibold text-gray-700">Personalnummern</h2>
<p className="text-xs text-gray-400 mt-0.5">
Eindeutige numerische ID pro Mitarbeiter. Nach Deaktivierung bleibt die Nummer reserviert (wird nie neu vergeben).
</p>
</div>
<label className="flex items-start gap-3 cursor-pointer select-none">
<input
type="checkbox"
checked={pnRequired}
onChange={e => setPnRequired(e.target.checked)}
disabled={!isAdmin}
className="mt-1 w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<div>
<span className="text-sm font-medium text-gray-800">Personalnummer ist Pflicht</span>
<p className="text-xs text-gray-400 mt-0.5">
Gilt nur für neu angelegte Mitarbeiter. Bestandsuser ohne Nummer bleiben weiterhin nutzbar.
</p>
</div>
</label>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Vergabe-Modus</label>
<div className="space-y-2">
<label className="flex items-start gap-3 cursor-pointer">
<input
type="radio"
name="pn_mode"
value="manual"
checked={pnMode === 'manual'}
onChange={() => setPnMode('manual')}
disabled={!isAdmin}
className="mt-1 w-4 h-4 text-blue-600"
/>
<div>
<span className="text-sm text-gray-800">Manuell</span>
<p className="text-xs text-gray-400">Admin gibt Personalnr. beim Anlegen manuell ein.</p>
</div>
</label>
<label className="flex items-start gap-3 cursor-pointer">
<input
type="radio"
name="pn_mode"
value="auto"
checked={pnMode === 'auto'}
onChange={() => setPnMode('auto')}
disabled={!isAdmin}
className="mt-1 w-4 h-4 text-blue-600"
/>
<div>
<span className="text-sm text-gray-800">Automatisch (fortlaufend)</span>
<p className="text-xs text-gray-400">Server vergibt nächste freie Nummer (4-stellig zero-padded). Manueller Override bleibt möglich.</p>
</div>
</label>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Nächste Auto-Nummer
<span className="ml-1 text-xs text-gray-400 font-normal">(nur Auto-Modus)</span>
</label>
<input
type="number"
min={1}
value={pnNext}
onChange={e => setPnNext(parseInt(e.target.value) || 1)}
disabled={!isAdmin || pnMode !== 'auto'}
className="w-32 border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-50 disabled:text-gray-500"
/>
<p className="mt-1 text-xs text-gray-400">
Nächste Vergabe: <strong className="font-mono">{String(pnNext).padStart(4, '0')}</strong>
</p>
</div>
</div>
{/* Busylight-Integration */}
{isAdmin && (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-5">
<div>
<h2 className="font-semibold text-gray-700">Busylight-Integration</h2>
<p className="text-xs text-gray-400 mt-0.5">
Externes Busylight-System ruft Anwesenheits-Daten via Bearer-Token ab. Endpoint:{' '}
<code className="font-mono text-[11px] bg-gray-100 px-1 py-0.5 rounded">GET /api/v1/busylight/users</code>.
</p>
</div>
<details className="text-xs">
<summary className="cursor-pointer text-gray-500 hover:text-gray-700 select-none">
Test-Befehl anzeigen (curl)
</summary>
<div className="mt-2 rounded-lg bg-gray-900 text-gray-100 px-3 py-2 font-mono text-[11px] leading-relaxed whitespace-pre overflow-x-auto">
{`curl -H "Authorization: Bearer <TOKEN>" \\\n ${window.location.origin}/api/v1/busylight/users`}
</div>
<p className="mt-1 text-gray-400">
<code className="font-mono">&lt;TOKEN&gt;</code> durch den beim Generieren angezeigten Klartext-Token ersetzen.
</p>
</details>
<div className="text-sm">
{blStatus?.configured ? (
<div className="flex items-center gap-2 text-green-700">
<span className="inline-block w-2 h-2 rounded-full bg-green-500" />
<span>
Token aktiv
{blStatus.created_at && (
<span className="text-gray-400 ml-1">
(seit {new Date(blStatus.created_at).toLocaleString('de-DE')})
</span>
)}
</span>
</div>
) : (
<div className="flex items-center gap-2 text-gray-500">
<span className="inline-block w-2 h-2 rounded-full bg-gray-300" />
<span>Kein Token konfiguriert</span>
</div>
)}
</div>
{blPlaintext && (
<div className="rounded-lg border-2 border-amber-300 bg-amber-50 p-4 space-y-2">
<p className="text-xs font-semibold text-amber-800">
Token jetzt kopieren wird nur einmal angezeigt.
</p>
<div className="flex items-stretch gap-2">
<input
type="text"
readOnly
value={blPlaintext}
onFocus={e => e.currentTarget.select()}
className="flex-1 font-mono text-xs px-3 py-2 border border-amber-300 rounded bg-white"
/>
<button
onClick={copyBusylightToken}
className="px-3 py-2 bg-amber-600 text-white text-xs font-medium rounded hover:bg-amber-700"
>
{blCopied ? '✓ Kopiert' : 'Kopieren'}
</button>
</div>
<p className="text-xs text-amber-700">
In busylight als <code className="font-mono">Authorization: Bearer </code>-Header hinterlegen.
</p>
</div>
)}
<div className="flex flex-wrap items-center gap-3">
<button
onClick={rotateBusylightToken}
disabled={blBusy}
className="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{blStatus?.configured ? 'Neuen Token generieren' : 'Token generieren'}
</button>
{blStatus?.configured && (
<button
onClick={revokeBusylightToken}
disabled={blBusy}
className="px-4 py-2 bg-white border border-red-300 text-red-700 text-sm font-medium rounded-lg hover:bg-red-50 disabled:opacity-50"
>
Deaktivieren
</button>
)}
{blStatus?.configured && (
<span className="text-xs text-gray-400">
Beim Neugenerieren wird der bisherige Token sofort ungültig.
</span>
)}
</div>
</div>
)}
{/* Mobile-Einstellungen */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4">
<div className="flex items-center gap-2">
<span className="text-lg">📱</span>
<h2 className="font-semibold text-gray-700">Mobile-Ansicht</h2>
</div>
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium text-gray-800">Einstempeln über Mobile erlauben</p>
<p className="text-xs text-gray-500 mt-0.5">
Wenn deaktiviert, sehen Mitarbeiter unter <code className="bg-gray-100 px-1 rounded">/mobile</code> keinen Stempel-Button.
Nützlich wenn Zeiterfassung nur über Kiosk-Terminals erfolgen soll.
</p>
</div>
<button
type="button"
disabled={!isAdmin}
onClick={() => setMobileStamping(v => !v)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 rounded-full border-2 border-transparent transition-colors duration-200 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed ${
mobileStamping ? 'bg-blue-600' : 'bg-gray-300'
}`}
>
<span
className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform transition-transform duration-200 ${
mobileStamping ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
</div>
{/* Kiosk-Terminal-Einstellungen */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4">
<div className="flex items-center gap-2">
<span className="text-lg">🖥️</span>
<h2 className="font-semibold text-gray-700">Kiosk-Terminals</h2>
</div>
{/* kiosk_require_approval */}
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium text-gray-800">Neue Geräte benötigen Admin-Freigabe</p>
<p className="text-xs text-gray-500 mt-0.5">
Wenn aktiv, muss jedes neue Kiosk-Gerät erst durch einen Admin freigeschaltet werden, bevor es Stempelungen akzeptiert.
</p>
</div>
<button
type="button"
disabled={!isAdmin}
onClick={() => setKioskRequireApproval(v => !v)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 rounded-full border-2 border-transparent transition-colors duration-200 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed ${
kioskRequireApproval ? 'bg-blue-600' : 'bg-gray-300'
}`}
>
<span
className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform transition-transform duration-200 ${
kioskRequireApproval ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
{/* kiosk_track_current_user */}
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium text-gray-800">Aktuellen Benutzer am Terminal speichern (DSGVO)</p>
<p className="text-xs text-gray-500 mt-0.5">
Wenn aktiv, wird der zuletzt eingestempelte Mitarbeiter am Gerät gespeichert. Deaktivieren für DSGVO-konformeren Betrieb.
</p>
</div>
<button
type="button"
disabled={!isAdmin}
onClick={() => setKioskTrackCurrentUser(v => !v)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 rounded-full border-2 border-transparent transition-colors duration-200 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed ${
kioskTrackCurrentUser ? 'bg-blue-600' : 'bg-gray-300'
}`}
>
<span
className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform transition-transform duration-200 ${
kioskTrackCurrentUser ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
{/* kiosk_heartbeat_interval_sec */}
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium text-gray-800">Heartbeat-Intervall (Sekunden)</p>
<p className="text-xs text-gray-500 mt-0.5">
Wie oft Kiosk-Terminals ihren Status an den Server melden. Minimum 10s, Maximum 120s.
</p>
</div>
<input
type="number"
min={10}
max={120}
step={5}
disabled={!isAdmin}
value={kioskHeartbeatIntervalSec}
onChange={e => setKioskHeartbeatIntervalSec(Math.min(120, Math.max(10, parseInt(e.target.value) || 30)))}
className="w-20 border border-gray-300 rounded-lg px-3 py-1.5 text-sm text-right disabled:bg-gray-50 disabled:cursor-not-allowed"
/>
</div>
</div>
{/* Freizeitausgleich-Einstellungen */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4">
<div className="flex items-center gap-2">
<span className="text-lg">⏱️</span>
<h2 className="font-semibold text-gray-700">Freizeitausgleich</h2>
</div>
{/* Überziehen erlauben */}
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium text-gray-800">Überziehen des Überstundenkontos erlauben</p>
<p className="text-xs text-gray-500 mt-0.5">
Wenn deaktiviert, wird FZA-Genehmigung abgelehnt falls nicht genug Überstunden vorhanden sind.
</p>
</div>
<button
type="button"
disabled={!isAdmin}
onClick={() => setFzaOverdraftAllowed(v => !v)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 rounded-full border-2 border-transparent transition-colors duration-200 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed ${
fzaOverdraftAllowed ? 'bg-blue-600' : 'bg-gray-300'
}`}
>
<span
className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform transition-transform duration-200 ${
fzaOverdraftAllowed ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
{/* Warnschwelle */}
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium text-gray-800">Warnschwelle Überstundenkonto (Stunden)</p>
<p className="text-xs text-gray-500 mt-0.5">
Warnung beim Genehmigen wenn das Konto unter diesen Wert fällt. 0 = keine Warnung.
</p>
</div>
<input
type="number"
min={0}
step={1}
disabled={!isAdmin}
value={fzaWarningThreshold}
onChange={e => setFzaWarningThreshold(Math.max(0, parseInt(e.target.value) || 0))}
className="w-20 border border-gray-300 rounded-lg px-3 py-1.5 text-sm text-right disabled:bg-gray-50 disabled:cursor-not-allowed"
/>
</div>
</div>
{/* Firmen-Info (readonly) */}
{company && (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 className="font-semibold text-gray-700 mb-4">Firmen-Info</h2>
<dl className="grid grid-cols-2 gap-x-6 gap-y-3 text-sm">
<dt className="text-gray-500">Firmen-ID</dt>
<dd className="text-gray-700 font-mono text-xs">{company.id}</dd>
<dt className="text-gray-500">Slug</dt>
<dd className="text-gray-700">{company.slug}</dd>
<dt className="text-gray-500">Plan</dt>
<dd className="text-gray-700 capitalize">{company.plan}</dd>
<dt className="text-gray-500">Land</dt>
<dd className="text-gray-700">{company.country}</dd>
</dl>
</div>
)}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-700">{error}</div>
)}
{isAdmin && (
<div className="flex items-center gap-3">
<button
onClick={save}
disabled={saving}
className="px-5 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50"
>
{saving ? 'Speichern…' : 'Speichern'}
</button>
{saved && (
<span className="text-sm text-green-600 font-medium">Gespeichert</span>
)}
</div>
)}
</div>
</Layout>
)
}