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 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(null) const [company, setCompany] = useState(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('') // Urlaubsanspruch (PR2) const [vacationDefaultDays, setVacationDefaultDays] = useState(30) const [vacationProrate, setVacationProrate] = useState(true) const [vacationFromSchedule, setVacationFromSchedule] = useState(false) // Zwei-Stufen-Genehmigung (agent-12) const [twoStageEnabled, setTwoStageEnabled] = useState(false) const [twoStageMinDays, setTwoStageMinDays] = useState(0) // 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) // Überstunden-Kappung const [overtimeCapEnabled, setOvertimeCapEnabled] = useState(false) const [overtimeCapHours, setOvertimeCapHours] = useState(150) // Überstunden-Verfall const [overtimeExpiryEnabled, setOvertimeExpiryEnabled] = useState(false) const [overtimeExpiryMonth, setOvertimeExpiryMonth] = useState(3) const [overtimeExpiryDay, setOvertimeExpiryDay] = useState(31) const [overtimeMaxCarryoverHours, setOvertimeMaxCarryoverHours] = useState(null) // Busylight const [blStatus, setBlStatus] = useState<{ configured: boolean; created_at: string | null } | null>(null) const [blPlaintext, setBlPlaintext] = useState(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(null) useEffect(() => { api.get('/auth/me').then(setMe).catch(() => {}) api.get('/companies/me').then(c => { setCompany(c) setName(c.name) setState(c.state ?? '') const s = c.settings as Record 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; overtime_cap_hours?: number | null; overtime_expiry_enabled?: boolean; overtime_expiry_month?: number; overtime_expiry_day?: number; overtime_max_carryover_hours?: number | null } 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) setOvertimeCapEnabled(cc.overtime_cap_hours != null) setOvertimeCapHours(cc.overtime_cap_hours ?? 150) setOvertimeExpiryEnabled(cc.overtime_expiry_enabled ?? false) setOvertimeExpiryMonth(cc.overtime_expiry_month ?? 3) setOvertimeExpiryDay(cc.overtime_expiry_day ?? 31) setOvertimeMaxCarryoverHours(cc.overtime_max_carryover_hours ?? null) const vc = c as CompanyOut & { vacation_default_days?: number; vacation_prorate_first_year?: boolean; vacation_from_schedule?: boolean; two_stage_approval_enabled?: boolean; two_stage_min_days?: number } setVacationDefaultDays(vc.vacation_default_days ?? 30) setVacationProrate(vc.vacation_prorate_first_year ?? true) setVacationFromSchedule(vc.vacation_from_schedule ?? false) setTwoStageEnabled(vc.two_stage_approval_enabled ?? false) setTwoStageMinDays(vc.two_stage_min_days ?? 0) }).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('/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, vacation_default_days: vacationDefaultDays, vacation_prorate_first_year: vacationProrate, vacation_from_schedule: vacationFromSchedule, two_stage_approval_enabled: twoStageEnabled, two_stage_min_days: twoStageMinDays, overtime_overdraft_allowed: fzaOverdraftAllowed, overtime_warning_threshold_hours: fzaWarningThreshold, kiosk_require_approval: kioskRequireApproval, kiosk_track_current_user: kioskTrackCurrentUser, kiosk_heartbeat_interval_sec: kioskHeartbeatIntervalSec, overtime_cap_hours: overtimeCapEnabled ? overtimeCapHours : null, overtime_expiry_enabled: overtimeExpiryEnabled, overtime_expiry_month: overtimeExpiryMonth, overtime_expiry_day: overtimeExpiryDay, overtime_max_carryover_hours: overtimeExpiryEnabled ? overtimeMaxCarryoverHours : null, }) setCompany(updated) setSaved(true) setTimeout(() => setSaved(false), 3000) } catch (e: unknown) { setError(e instanceof Error ? e.message : 'Fehler beim Speichern') } finally { setSaving(false) } } async function applyOvertimeExpiry() { if (!confirm('Verfall jetzt auf alle Mitarbeiter anwenden? Nicht rückgängig zu machen.')) return try { await api.post('/absences/overtime-balance/apply-expiry', {}) setError(null) setSaved(true) setTimeout(() => setSaved(false), 3000) } catch (e: unknown) { setError(e instanceof Error ? e.message : 'Fehler beim Anwenden des Verfalls') } } const isAdmin = me?.role === 'COMPANY_ADMIN' || me?.role === 'SUPER_ADMIN' const hourOptions = Array.from({ length: 24 }, (_, i) => i.toString()) return (

Firmen-Einstellungen

{/* Allgemein */}

Allgemein

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" />
{state && (

Feiertage für {BUNDESLAENDER.find(b => b.code === state)?.name} werden im Kalender und bei der Stundenauswertung berücksichtigt.

)} {!state && (

Ohne Bundesland können keine Feiertage automatisch erkannt werden.

)}
{/* Arbeitszeitregeln */}

Arbeitszeitregeln

Grundlage für die Auswertung von Sonderstunden im Bericht (für den Steuerberater).

{/* Kernarbeitszeit */}
Von
bis

Stunden außerhalb dieser Zeit gelten als Überstunden/Nachtarbeit.

{/* Nachtarbeit – gesetzlich fixiert */}

Nachtarbeit – gesetzlich fixiert

20:00–24:00 Uhr (Mo–Sa) 25 % Zuschlag (§3b EStG)
00:00–04:00 Uhr (Mo–Sa) 40 % Zuschlag (§3b EStG)
04:00–06:00 Uhr (Mo–Sa) 25 % Zuschlag (§3b EStG)
Sonntagsarbeit (00:00–24:00) 50 % Zuschlag
Feiertagsarbeit (gesetzliche Feiertage) 125 % / 150 % Zuschlag

Diese Werte sind durch §3b EStG gesetzlich vorgegeben und können nicht geändert werden. Bei Überschneidung gilt stets der höchste Zuschlag.

{/* Urlaubsregeln */}

Urlaubsregeln

Nicht genommener Urlaub aus dem Vorjahr (Resturlaub) wird automatisch übertragen. Hier kannst du festlegen, bis wann er genommen werden muss.

setVacationDefaultDays(parseInt(e.target.value) || 0)} disabled={!isAdmin} className="w-24 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" /> Tage/Jahr für neue Urlaubskonten
{twoStageEnabled && (
Nur ab setTwoStageMinDays(parseInt(e.target.value) || 0)} className="w-20 border border-gray-300 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-50" /> Arbeitstagen (0 = alle Anträge)
)}
{(carryoverDay || carryoverMonth) && ( )}
{carryoverDay && carryoverMonth ? (

Resturlaub aus dem Vorjahr verfällt jährlich am {carryoverDay}. {['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember'][parseInt(carryoverMonth)-1]}. Im Urlaubskonto der Mitarbeiter wird das Datum und ob er bereits verfallen ist angezeigt.

) : (

Kein Verfall konfiguriert – Resturlaub wird unbegrenzt übertragen.

)}
{/* Personalnummern */}

Personalnummern

Eindeutige numerische ID pro Mitarbeiter. Nach Deaktivierung bleibt die Nummer reserviert (wird nie neu vergeben).

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" />

Nächste Vergabe: {String(pnNext).padStart(4, '0')}

{/* Busylight-Integration */} {isAdmin && (

Busylight-Integration

Externes Busylight-System ruft Anwesenheits-Daten via Bearer-Token ab. Endpoint:{' '} GET /api/v1/busylight/users.

Test-Befehl anzeigen (curl)
{`curl -H "Authorization: Bearer " \\\n ${window.location.origin}/api/v1/busylight/users`}

<TOKEN> durch den beim Generieren angezeigten Klartext-Token ersetzen.

{blStatus?.configured ? (
Token aktiv {blStatus.created_at && ( (seit {new Date(blStatus.created_at).toLocaleString('de-DE')}) )}
) : (
Kein Token konfiguriert
)}
{blPlaintext && (

⚠ Token jetzt kopieren – wird nur einmal angezeigt.

e.currentTarget.select()} className="flex-1 font-mono text-xs px-3 py-2 border border-amber-300 rounded bg-white" />

In busylight als Authorization: Bearer …-Header hinterlegen.

)}
{blStatus?.configured && ( )} {blStatus?.configured && ( Beim Neugenerieren wird der bisherige Token sofort ungültig. )}
)} {/* QR-Stempeln wurde in eine eigene Seite ausgelagert: /settings/qr-stamp */} {/* Mobile-Einstellungen */}
📱

Mobile-Ansicht

Einstempeln über Mobile erlauben

Wenn deaktiviert, sehen Mitarbeiter unter /mobile keinen Stempel-Button. Nützlich wenn Zeiterfassung nur über Kiosk-Terminals erfolgen soll.

{/* Kiosk-Terminal-Einstellungen */}
🖥️

Kiosk-Terminals

{/* kiosk_require_approval */}

Neue Geräte benötigen Admin-Freigabe

Wenn aktiv, muss jedes neue Kiosk-Gerät erst durch einen Admin freigeschaltet werden, bevor es Stempelungen akzeptiert.

{/* kiosk_track_current_user */}

Aktuellen Benutzer am Terminal speichern (DSGVO)

Wenn aktiv, wird der zuletzt eingestempelte Mitarbeiter am Gerät gespeichert. Deaktivieren für DSGVO-konformeren Betrieb.

{/* kiosk_heartbeat_interval_sec */}

Heartbeat-Intervall (Sekunden)

Wie oft Kiosk-Terminals ihren Status an den Server melden. Minimum 10s, Maximum 120s.

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" />
{/* Freizeitausgleich-Einstellungen */}
⏱️

Freizeitausgleich

{/* Überziehen erlauben */}

Überziehen des Überstundenkontos erlauben

Wenn deaktiviert, wird FZA-Genehmigung abgelehnt falls nicht genug Überstunden vorhanden sind.

{/* Warnschwelle */}

Warnschwelle Überstundenkonto (Stunden)

Warnung beim Genehmigen wenn das Konto unter diesen Wert fällt. 0 = keine Warnung.

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" />
{/* Überstunden-Konto-Regeln */}
📊

Überstunden-Konto

{/* Kappungsgrenze */}

Kappungsgrenze aktivieren

Das Überstunden-Konto kann nicht über diesen Wert anwachsen.

{overtimeCapEnabled && (

Maximale Überstunden (Stunden)

setOvertimeCapHours(Math.max(1, parseInt(e.target.value) || 1))} disabled={!isAdmin} className="w-24 border border-gray-300 rounded-lg px-3 py-1.5 text-sm text-right disabled:bg-gray-50" />
)}
{/* Verfall */}

Jahresverfall aktivieren

Nicht genommene Überstunden verfallen einmal jährlich zum konfigurierten Stichtag.

{overtimeExpiryEnabled && (
{/* Stichtag */}

Verfallsstichtag

setOvertimeExpiryDay(Math.min(31, Math.max(1, parseInt(e.target.value) || 1)))} disabled={!isAdmin} className="w-16 border border-gray-300 rounded-lg px-2 py-1.5 text-sm text-right disabled:bg-gray-50" /> .
{/* Max. Übertrag */}

Maximaler Übertrag (Stunden)

0 = alles verfällt · leer = alles übertragen

setOvertimeMaxCarryoverHours(e.target.value === '' ? null : Math.max(0, parseInt(e.target.value) || 0))} disabled={!isAdmin} className="w-28 border border-gray-300 rounded-lg px-3 py-1.5 text-sm text-right disabled:bg-gray-50 placeholder-gray-400" />
{/* Manueller Trigger */} {isAdmin && (

Verfall jetzt auf alle Mitarbeiter anwenden

)}
)}
{/* Firmen-Info (readonly) */} {company && (

Firmen-Info

Firmen-ID
{company.id}
Slug
{company.slug}
Plan
{company.plan}
Land
{company.country}
)} {error && (
{error}
)} {isAdmin && (
{saved && ( Gespeichert )}
)}
) }