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>
This commit is contained in:
2026-06-23 13:17:32 +02:00
co-authored by Claude Opus 4.8
parent 2f110df619
commit eab41ede69
12 changed files with 198 additions and 7 deletions
+6
View File
@@ -477,6 +477,12 @@ export function AbsencesPage() {
<button onClick={() => approve(a.id)} className='text-xs px-2 py-1 bg-green-600 text-white rounded hover:bg-green-700'>Genehmigen</button>
<button onClick={() => { setShowReject(a.id); setRejectReason('') }} className='text-xs px-2 py-1 bg-red-600 text-white rounded hover:bg-red-700'>Ablehnen</button>
</>)}
{a.status === 'first_approved'
&& ['HR', 'COMPANY_ADMIN', 'SUPER_ADMIN'].includes(user.role)
&& a.first_approved_by !== user.id && (<>
<button onClick={() => approve(a.id)} className='text-xs px-2 py-1 bg-green-600 text-white rounded hover:bg-green-700' title='Finale Genehmigung'>Endgültig genehmigen</button>
<button onClick={() => { setShowReject(a.id); setRejectReason('') }} className='text-xs px-2 py-1 bg-red-600 text-white rounded hover:bg-red-700'>Ablehnen</button>
</>)}
{isManager && a.status === 'cancellation_requested' && (<>
<button onClick={() => approve(a.id)} className='text-xs px-2 py-1 bg-green-600 text-white rounded hover:bg-green-700' title='Stornierung genehmigen'>Storno </button>
<button onClick={() => { setShowReject(a.id); setRejectReason('') }} className='text-xs px-2 py-1 bg-red-600 text-white rounded hover:bg-red-700' title='Stornierung ablehnen'>Storno </button>
+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
+1
View File
@@ -47,6 +47,7 @@ export interface AbsenceOut {
working_days: number
status: string
approved_by: string | null
first_approved_by: string | null
substitute_id: string | null
note: string | null
correction_note: string | null
+2
View File
@@ -18,6 +18,7 @@ export const STATUS_LABELS: Record<string, string> = {
rejected: 'Abgelehnt',
cancelled: 'Storniert',
cancellation_requested: 'Storno beantragt',
first_approved: 'Wartet auf Endgenehmigung',
}
export const STATUS_COLORS: Record<string, string> = {
@@ -26,6 +27,7 @@ export const STATUS_COLORS: Record<string, string> = {
rejected: 'bg-red-100 text-red-700',
cancelled: 'bg-gray-100 text-gray-500',
cancellation_requested: 'bg-orange-100 text-orange-700',
first_approved: 'bg-sky-100 text-sky-700',
}
export const MANAGER_ROLES = ['COMPANY_ADMIN', 'SUPER_ADMIN', 'HR', 'MANAGER']