feat(overtime): Auszahlungs-Anträge durch Mitarbeiter (opt-in pro Firma)

Mitarbeiter beantragt Überstunden-Auszahlung, HR genehmigt/lehnt ab.
Firmen-Opt-in payout_request_enabled (Default aus - nur HR-Direktbuchung).
Saldo-Abzug erst bei Genehmigung; Reject/Cancel bucht nichts.

- Migration 0042: hours_payouts.status/decided_by/decided_at/rejection_reason
  + companies.payout_request_enabled (nur Spalten, RLS unveraendert)
- Router: POST /hr/payouts/request|{id}/approve|reject|cancel; list status-Filter;
  HR-Direktbuchung bleibt (status approved)
- Frontend: PayoutRequestCard (Selbstbedienung in AbsencesPage), HR-Page
  Status-Spalte + Genehmigen/Ablehnen, CompanySettings-Toggle
- 4 pytest-Cases

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-08 22:43:26 +02:00
co-authored by Claude Opus 4.8
parent 87806b4501
commit eb8dace0fd
15 changed files with 1134 additions and 96 deletions
+3
View File
@@ -8,6 +8,7 @@ import {
MONTHS, MONTHS_SHORT, STATUS_LABELS, STATUS_COLORS, MANAGER_ROLES,
} from '../utils/calendar'
import { useAbsences } from '../hooks/useAbsences'
import { PayoutRequestCard } from '../components/PayoutRequestCard'
import { usePlanerView } from '../hooks/usePlanerView'
import {
BalanceEditModal,
@@ -395,6 +396,8 @@ export function AbsencesPage() {
</div>
)}
<PayoutRequestCard availableHours={overtimeBalance?.available_hours ?? 0} />
<div className='flex gap-2 flex-wrap'>
{[['', 'Alle'], ['pending', 'Ausstehend'], ['approved', 'Genehmigt'], ['rejected', 'Abgelehnt']].map(([val, label]) => (
<button key={val} onClick={() => { setStatusFilter(val); if (val) setSearchParams({ status: val }); else setSearchParams({}) }}
+30 -1
View File
@@ -69,6 +69,8 @@ export function CompanySettingsPage() {
// Freizeitausgleich
const [fzaOverdraftAllowed, setFzaOverdraftAllowed] = useState(true)
const [fzaWarningThreshold, setFzaWarningThreshold] = useState(0)
// Auszahlungs-Anträge (Mitarbeiter-Selbstbedienung)
const [payoutRequestEnabled, setPayoutRequestEnabled] = useState(false)
// Überstunden-Kappung
const [overtimeCapEnabled, setOvertimeCapEnabled] = useState(false)
const [overtimeCapHours, setOvertimeCapHours] = useState(150)
@@ -100,10 +102,11 @@ 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; overtime_cap_hours?: number | null; overtime_expiry_enabled?: boolean; overtime_expiry_month?: number; overtime_expiry_day?: number; overtime_max_carryover_hours?: number | null }
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; payout_request_enabled?: boolean }
setMobileStamping(cc.mobile_stamping_enabled ?? true)
setFzaOverdraftAllowed(cc.overtime_overdraft_allowed ?? true)
setFzaWarningThreshold(cc.overtime_warning_threshold_hours ?? 0)
setPayoutRequestEnabled(cc.payout_request_enabled ?? false)
setKioskRequireApproval(cc.kiosk_require_approval ?? true)
setKioskTrackCurrentUser(cc.kiosk_track_current_user ?? true)
setKioskHeartbeatIntervalSec(cc.kiosk_heartbeat_interval_sec ?? 30)
@@ -194,6 +197,7 @@ export function CompanySettingsPage() {
two_stage_min_days: twoStageMinDays,
overtime_overdraft_allowed: fzaOverdraftAllowed,
overtime_warning_threshold_hours: fzaWarningThreshold,
payout_request_enabled: payoutRequestEnabled,
kiosk_require_approval: kioskRequireApproval,
kiosk_track_current_user: kioskTrackCurrentUser,
kiosk_heartbeat_interval_sec: kioskHeartbeatIntervalSec,
@@ -797,6 +801,31 @@ export function CompanySettingsPage() {
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>
{/* Auszahlungs-Anträge durch Mitarbeiter */}
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium text-gray-800">Auszahlungs-Anträge durch Mitarbeiter</p>
<p className="text-xs text-gray-500 mt-0.5">
Mitarbeiter können Überstunden-Auszahlungen selbst beantragen; HR genehmigt oder lehnt ab.
Wenn deaktiviert, bucht nur HR direkt.
</p>
</div>
<button
type="button"
disabled={!isAdmin}
onClick={() => setPayoutRequestEnabled(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 ${
payoutRequestEnabled ? '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 ${
payoutRequestEnabled ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
</div>
{/* Überstunden-Konto-Regeln */}
+73 -9
View File
@@ -40,6 +40,18 @@ function formatPeriod(year: number | null, month: number | null): string {
return `${MONTH_NAMES[month - 1]} ${year}`
}
const STATUS_BADGE: Record<string, { label: string; cls: string }> = {
requested: { label: 'Beantragt', cls: 'bg-amber-100 text-amber-700' },
approved: { label: 'Genehmigt', cls: 'bg-green-100 text-green-700' },
rejected: { label: 'Abgelehnt', cls: 'bg-red-100 text-red-700' },
cancelled: { label: 'Zurückgezogen', cls: 'bg-gray-100 text-gray-500' },
}
function StatusBadge({ status }: { status: string }) {
const b = STATUS_BADGE[status] ?? { label: status, cls: 'bg-gray-100 text-gray-500' }
return <span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${b.cls}`}>{b.label}</span>
}
export function HoursPayoutPage() {
const [me, setMe] = useState<Me | null>(null)
const [users, setUsers] = useState<UserItem[]>([])
@@ -195,6 +207,29 @@ export function HoursPayoutPage() {
}
}
async function handleApprove(payout: HoursPayoutOut) {
if (!confirm(`Auszahlung über ${Number(payout.hours).toFixed(2)} h für ${payout.user_name} genehmigen? Der Saldo wird abgezogen.`)) return
try {
const updated = await api.post<HoursPayoutOut>(`/hr/payouts/${payout.id}/approve`, {})
setPayouts(prev => prev.map(p => (p.id === payout.id ? updated : p)))
} catch (e: unknown) {
alert(e instanceof Error ? e.message : 'Fehler beim Genehmigen')
}
}
async function handleReject(payout: HoursPayoutOut) {
const reason = prompt('Grund der Ablehnung (optional):')
if (reason === null) return // Abbrechen
try {
const updated = await api.post<HoursPayoutOut>(`/hr/payouts/${payout.id}/reject`, {
rejection_reason: reason.trim() || null,
})
setPayouts(prev => prev.map(p => (p.id === payout.id ? updated : p)))
} catch (e: unknown) {
alert(e instanceof Error ? e.message : 'Fehler beim Ablehnen')
}
}
if (pageLoading) return (
<div className='min-h-screen bg-gray-50 flex items-center justify-center'><Spinner /></div>
)
@@ -286,7 +321,7 @@ export function HoursPayoutPage() {
<table className='w-full text-sm'>
<thead className='bg-gray-50 text-gray-500 text-xs uppercase'>
<tr>
{['Mitarbeiter', 'Stunden', 'Abrechnungsmonat', 'Notiz', 'Angelegt von', 'Datum', 'Aktion'].map(h => (
{['Mitarbeiter', 'Stunden', 'Status', 'Abrechnungsmonat', 'Notiz', 'Angelegt von', 'Datum', 'Aktion'].map(h => (
<th key={h} className='px-4 py-3 text-left font-medium'>{h}</th>
))}
</tr>
@@ -300,6 +335,14 @@ export function HoursPayoutPage() {
{Number(payout.hours).toFixed(2)} h
</span>
</td>
<td className='px-4 py-3'>
<StatusBadge status={payout.status} />
{payout.status === 'rejected' && payout.rejection_reason && (
<div className='text-xs text-gray-400 mt-0.5 max-w-[10rem] truncate' title={payout.rejection_reason}>
{payout.rejection_reason}
</div>
)}
</td>
<td className='px-4 py-3 text-gray-600'>
{formatPeriod(payout.period_year, payout.period_month)}
</td>
@@ -313,19 +356,40 @@ export function HoursPayoutPage() {
})}
</td>
<td className='px-4 py-3'>
<button
onClick={() => handleDelete(payout)}
className='text-xs text-red-500 hover:underline'
title='Stornieren'
>
🗑 Stornieren
</button>
{payout.status === 'requested' ? (
<div className='flex gap-2'>
<button
onClick={() => handleApprove(payout)}
className='text-xs text-green-600 hover:underline font-medium'
title='Genehmigen'
>
Genehmigen
</button>
<button
onClick={() => handleReject(payout)}
className='text-xs text-red-500 hover:underline'
title='Ablehnen'
>
Ablehnen
</button>
</div>
) : payout.status === 'approved' ? (
<button
onClick={() => handleDelete(payout)}
className='text-xs text-red-500 hover:underline'
title='Stornieren'
>
🗑 Stornieren
</button>
) : (
<span className='text-xs text-gray-300'></span>
)}
</td>
</tr>
))}
{payouts.length === 0 && (
<tr>
<td colSpan={7} className='px-4 py-10 text-center text-gray-400'>
<td colSpan={8} className='px-4 py-10 text-center text-gray-400'>
Keine Auszahlungen gefunden.
</td>
</tr>