feat: agent-12 – Zwei-Stufen-Genehmigung für Abwesenheiten
Security Audit / Python Dependency Audit (push) Has been cancelled
Security Audit / Node.js Dependency Audit (push) Has been cancelled

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>
This commit is contained in:
2026-06-23 13:17:32 +02:00
co-authored by Claude Opus 4.8
parent c83fb31408
commit 450dbdb64a
12 changed files with 198 additions and 7 deletions
+27 -1
View File
@@ -53,6 +53,9 @@ export function CompanySettingsPage() {
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')
@@ -110,10 +113,12 @@ export function CompanySettingsPage() {
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 }
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)
@@ -185,6 +190,8 @@ export function CompanySettingsPage() {
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,
@@ -387,6 +394,25 @@ export function CompanySettingsPage() {
<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