Files
timemaster/frontend/src/pages/CompanySettingsPage.tsx
T
patrickandClaude Opus 4.8 450dbdb64a
Security Audit / Python Dependency Audit (push) Has been cancelled
Security Audit / Node.js Dependency Audit (push) Has been cancelled
feat: agent-12 – Zwei-Stufen-Genehmigung für Abwesenheiten
Optionale zweistufige Freigabe (Feature-Parität mit Urlaubsverwaltung,
Second-Stage-Authority), ohne SSO:

- Firmen-Opt-in companies.two_stage_approval_enabled + two_stage_min_days
  (nur Anträge ab X Arbeitstagen brauchen Stufe 2; 0 = alle).
- Ablauf PENDING → FIRST_APPROVED → APPROVED: erste Stufe durch Manager-Rollen,
  finale Stufe nur HR/Admin und zwingend eine ANDERE Person als Stufe 1.
- Urlaubs-/FZA-Abzug, CalDAV-Sync und Vertreter-Mail erst bei finaler Genehmigung.
  Ablehnen in beiden Stufen möglich; Eigentümer darf FIRST_APPROVED noch stornieren.
- pending_days, Kalender und Reminder-Digest berücksichtigen FIRST_APPROVED.
- Neuer Status-Wert + absences.first_approved_by; System-Kommentar bei Stufe 1.

Frontend: CompanySettingsPage (Toggle + Schwellwert), AbsencesPage
("Endgültig genehmigen"/Ablehnen für HR/Admin ≠ Erstgenehmiger, Status-Badge).

Migration 0038. 190/190 Tests grün. Deployed 137 + 164.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 13:17:32 +02:00

969 lines
45 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('')
// 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<number | null>(null)
// 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; 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<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,
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 (
<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">
Jahresurlaub (Vollzeit)
</label>
<div className="flex items-center gap-2">
<input
type="number" min={0} max={365} value={vacationDefaultDays}
onChange={e => 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"
/>
<span className="text-sm text-gray-500">Tage/Jahr für neue Urlaubskonten</span>
</div>
</div>
<label className={`flex items-start gap-3 ${isAdmin ? 'cursor-pointer' : 'opacity-60'}`}>
<input type="checkbox" checked={vacationProrate} disabled={!isAdmin}
onChange={e => setVacationProrate(e.target.checked)} className="mt-0.5" />
<span className="text-sm text-gray-700">
Anteilig im Ein-/Austrittsjahr
<span className="block text-xs text-gray-400">Zwölftel-Regel anhand Ein-/Austrittsdatum des Mitarbeiters.</span>
</span>
</label>
<label className={`flex items-start gap-3 ${isAdmin ? 'cursor-pointer' : 'opacity-60'}`}>
<input type="checkbox" checked={vacationFromSchedule} disabled={!isAdmin}
onChange={e => setVacationFromSchedule(e.target.checked)} className="mt-0.5" />
<span className="text-sm text-gray-700">
Teilzeit-Anspruch aus Arbeitsplan ableiten
<span className="block text-xs text-gray-400">Anspruch = Jahresurlaub × Arbeitstage pro Woche ÷ 5.</span>
</span>
</label>
<div className="border-t border-gray-100 pt-4 space-y-3">
<label className={`flex items-start gap-3 ${isAdmin ? 'cursor-pointer' : 'opacity-60'}`}>
<input type="checkbox" checked={twoStageEnabled} disabled={!isAdmin}
onChange={e => setTwoStageEnabled(e.target.checked)} className="mt-0.5" />
<span className="text-sm text-gray-700">
Zwei-Stufen-Genehmigung
<span className="block text-xs text-gray-400">Anträge müssen erst von einem Manager und dann final von HR/Admin (anderer Person) genehmigt werden.</span>
</span>
</label>
{twoStageEnabled && (
<div className="flex items-center gap-2 pl-7">
<span className="text-sm text-gray-600">Nur ab</span>
<input type="number" min={0} max={365} value={twoStageMinDays} disabled={!isAdmin}
onChange={e => 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" />
<span className="text-sm text-gray-600">Arbeitstagen (0 = alle Anträge)</span>
</div>
)}
</div>
<div className="border-t border-gray-100 pt-4">
<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>
)}
{/* QR-Stempeln wurde in eine eigene Seite ausgelagert: /settings/qr-stamp */}
{/* 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>
{/* Überstunden-Konto-Regeln */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-5">
<div className="flex items-center gap-2">
<span className="text-lg">📊</span>
<h2 className="font-semibold text-gray-700">Überstunden-Konto</h2>
</div>
{/* Kappungsgrenze */}
<div className="space-y-3">
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium text-gray-800">Kappungsgrenze aktivieren</p>
<p className="text-xs text-gray-500 mt-0.5">
Das Überstunden-Konto kann nicht über diesen Wert anwachsen.
</p>
</div>
<button
type="button"
disabled={!isAdmin}
onClick={() => setOvertimeCapEnabled(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 ${
overtimeCapEnabled ? '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 ${
overtimeCapEnabled ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
{overtimeCapEnabled && (
<div className="flex items-center justify-between gap-4 pl-4 border-l-2 border-blue-100">
<p className="text-sm text-gray-700">Maximale Überstunden (Stunden)</p>
<input
type="number" min={1} max={9999} step={1}
value={overtimeCapHours}
onChange={e => 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"
/>
</div>
)}
</div>
{/* Verfall */}
<div className="space-y-3">
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium text-gray-800">Jahresverfall aktivieren</p>
<p className="text-xs text-gray-500 mt-0.5">
Nicht genommene Überstunden verfallen einmal jährlich zum konfigurierten Stichtag.
</p>
</div>
<button
type="button"
disabled={!isAdmin}
onClick={() => setOvertimeExpiryEnabled(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 ${
overtimeExpiryEnabled ? '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 ${
overtimeExpiryEnabled ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
{overtimeExpiryEnabled && (
<div className="pl-4 border-l-2 border-blue-100 space-y-3">
{/* Stichtag */}
<div className="flex items-center justify-between gap-4">
<p className="text-sm text-gray-700">Verfallsstichtag</p>
<div className="flex items-center gap-2">
<input
type="number" min={1} max={31} step={1}
value={overtimeExpiryDay}
onChange={e => 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"
/>
<span className="text-sm text-gray-500">.</span>
<select
value={overtimeExpiryMonth}
onChange={e => setOvertimeExpiryMonth(parseInt(e.target.value))}
disabled={!isAdmin}
className="border border-gray-300 rounded-lg px-2 py-1.5 text-sm disabled:bg-gray-50"
>
{['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez'].map((m,i) => (
<option key={i+1} value={i+1}>{m}</option>
))}
</select>
</div>
</div>
{/* Max. Übertrag */}
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm text-gray-700">Maximaler Übertrag (Stunden)</p>
<p className="text-xs text-gray-400">0 = alles verfällt · leer = alles übertragen</p>
</div>
<input
type="number" min={0} max={9999} step={1}
value={overtimeMaxCarryoverHours ?? ''}
placeholder="unbegrenzt"
onChange={e => 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"
/>
</div>
{/* Manueller Trigger */}
{isAdmin && (
<div className="flex items-center justify-between gap-4 pt-1">
<p className="text-xs text-gray-500">Verfall jetzt auf alle Mitarbeiter anwenden</p>
<button
type="button"
onClick={applyOvertimeExpiry}
className="px-3 py-1.5 text-xs font-medium bg-orange-50 border border-orange-200 text-orange-700 rounded-lg hover:bg-orange-100 transition-colors"
>
Verfall anwenden
</button>
</div>
)}
</div>
)}
</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>
)
}