feat: Überstunden-Kappung + Jahresverfall pro Firma konfigurierbar
Backend: - Company: overtime_cap_hours, overtime_expiry_enabled/month/day, overtime_max_carryover_hours - OvertimeBalance: last_expiry_applied_at - Migration 0031: neue Spalten in companies + overtime_balances - _recalculate_overtime_balance: Kappung direkt nach Berechnung - apply_overtime_expiry_if_needed(): lazy Verfall beim Balance-Abruf - GET /absences/overtime-balance: prüft + wendet Verfall automatisch an - POST /absences/overtime-balance/apply-expiry: manueller Trigger (Admin) Frontend: - CompanySettingsPage: neuer Block 'Überstunden-Konto' - Toggle Kappungsgrenze + Stunden-Input - Toggle Jahresverfall + Stichtag (Tag/Monat) + max. Übertrag - 'Verfall anwenden'-Button für Admins Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -62,6 +62,14 @@ export function CompanySettingsPage() {
|
||||
// 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)
|
||||
@@ -85,13 +93,19 @@ export function CompanySettingsPage() {
|
||||
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 }
|
||||
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)
|
||||
}).catch(() => {})
|
||||
api.get<{ configured: boolean; created_at: string | null }>('/companies/me/busylight-token')
|
||||
.then(setBlStatus)
|
||||
@@ -165,6 +179,11 @@ export function CompanySettingsPage() {
|
||||
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)
|
||||
@@ -176,6 +195,18 @@ export function CompanySettingsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
@@ -699,6 +730,134 @@ export function CompanySettingsPage() {
|
||||
</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">
|
||||
|
||||
Reference in New Issue
Block a user