feat: Freizeitausgleich-Lücken geschlossen (Gap 1-3) + konfigurierbare Schwellwerte

Gap-1: Überziehschutz für Überstundenkonto
  - Company.overtime_overdraft_allowed (default: true) – blockiert FZA wenn deaktiviert
  - Company.overtime_warning_threshold_hours (default: 0) – Warnung wenn Konto unter Schwelle fällt
  - warnings[] jetzt in approve_absence Response (AbsenceApproveOut)
  - Migration 0028_overtime_fza_config.py

Gap-2: total_hours wird bei Zeiteintrag-Genehmigung neu berechnet
  - time_service.approve_entry() ruft _recalculate_overtime_balance() auf
  - last_calculated Timestamp wird gesetzt

Gap-3: Stornierung genehmigter FZA-Anträge bucht taken_hours zurück
  - _refund_overtime() Helfer hinzugefügt
  - cancel_absence() erlaubt jetzt HR/Admin auch genehmigte Abwesenheiten zu stornieren
  - DELETE /absences/{id} gibt jetzt AbsenceOut zurück (statt 204)
  - Mitarbeiter können genehmigte FZA-Anträge nicht selbst stornieren (409)

Frontend:
  - CompanySettingsPage: neuer Abschnitt 'Freizeitausgleich' mit Toggle + Schwellwert-Eingabe

Tests: backend/tests/test_fza.py mit 6 Tests (alle 3 Gaps)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 00:08:03 +02:00
parent 0ba16bb6af
commit 345002944e
9 changed files with 619 additions and 19 deletions
+60 -1
View File
@@ -55,6 +55,9 @@ export function CompanySettingsPage() {
const [pnNext, setPnNext] = useState(1)
// Mobile
const [mobileStamping, setMobileStamping] = useState(true)
// 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)
@@ -78,7 +81,10 @@ export function CompanySettingsPage() {
setPnRequired(c.personnel_number_required ?? false)
setPnMode(c.personnel_number_mode ?? 'manual')
setPnNext(c.personnel_number_next ?? 1)
setMobileStamping((c as CompanyOut & { mobile_stamping_enabled?: boolean }).mobile_stamping_enabled ?? true)
const cc = c as CompanyOut & { mobile_stamping_enabled?: boolean; overtime_overdraft_allowed?: boolean; overtime_warning_threshold_hours?: number }
setMobileStamping(cc.mobile_stamping_enabled ?? true)
setFzaOverdraftAllowed(cc.overtime_overdraft_allowed ?? true)
setFzaWarningThreshold(cc.overtime_warning_threshold_hours ?? 0)
}).catch(() => {})
api.get<{ configured: boolean; created_at: string | null }>('/companies/me/busylight-token')
.then(setBlStatus)
@@ -147,6 +153,8 @@ export function CompanySettingsPage() {
personnel_number_mode: pnMode,
personnel_number_next: pnNext,
mobile_stamping_enabled: mobileStamping,
overtime_overdraft_allowed: fzaOverdraftAllowed,
overtime_warning_threshold_hours: fzaWarningThreshold,
})
setCompany(updated)
setSaved(true)
@@ -554,6 +562,57 @@ export function CompanySettingsPage() {
</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">