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:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']
|
||||
|
||||
Reference in New Issue
Block a user