feat(overtime): Auszahlungs-Anträge durch Mitarbeiter (opt-in pro Firma)
Security Audit / Node.js Dependency Audit (push) Has been cancelled
Security Audit / Python Dependency Audit (push) Has been cancelled

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 2c57c36abd
commit 65596e80a4
15 changed files with 1134 additions and 96 deletions
@@ -0,0 +1,150 @@
import { useEffect, useState } from 'react'
import { api } from '../api/client'
import type { HoursPayoutOut, HoursPayoutListResponse } from '../types/hoursPayout'
const MONTH_NAMES = [
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
]
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 formatPeriod(year: number | null, month: number | null): string {
if (!year) return ''
if (!month) return String(year)
return `${MONTH_NAMES[month - 1]} ${year}`
}
const inputClass = 'w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-400 focus:border-transparent'
/**
* Selbstbedienung: Mitarbeiter beantragt Auszahlung eigener Überstunden.
* Rendert nur, wenn die Firma `payout_request_enabled` gesetzt hat.
*/
export function PayoutRequestCard({ availableHours }: { availableHours: number }) {
const [enabled, setEnabled] = useState<boolean | null>(null)
const [rows, setRows] = useState<HoursPayoutOut[]>([])
const [open, setOpen] = useState(false)
const [hours, setHours] = useState<number>(4)
const [note, setNote] = useState('')
const [saving, setSaving] = useState(false)
const [err, setErr] = useState('')
useEffect(() => {
api.get<{ payout_request_enabled: boolean }>('/companies/me')
.then(c => setEnabled(!!c.payout_request_enabled))
.catch(() => setEnabled(false))
}, [])
useEffect(() => {
if (enabled) load()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled])
async function load() {
try {
const data = await api.get<HoursPayoutListResponse>('/hr/payouts')
setRows(data.payouts)
} catch { /* ignore */ }
}
async function submit() {
if (!hours || hours <= 0) { setErr('Stunden müssen größer als 0 sein.'); return }
setSaving(true); setErr('')
try {
await api.post<HoursPayoutOut>('/hr/payouts/request', {
hours, period_year: null, period_month: null, note: note.trim() || null,
})
setOpen(false); setNote(''); setHours(4)
await load()
} catch (e: unknown) {
setErr(e instanceof Error ? e.message : 'Fehler beim Beantragen')
} finally { setSaving(false) }
}
async function cancel(id: string) {
if (!confirm('Antrag zurückziehen?')) return
try {
const updated = await api.post<HoursPayoutOut>(`/hr/payouts/${id}/cancel`, {})
setRows(prev => prev.map(p => (p.id === id ? updated : p)))
} catch (e: unknown) {
alert(e instanceof Error ? e.message : 'Fehler')
}
}
if (!enabled) return null
const overBudget = hours > availableHours
return (
<div className='bg-white rounded-xl shadow-sm border border-gray-200 p-5'>
<div className='flex items-center justify-between mb-3'>
<h2 className='text-sm font-semibold text-gray-500 uppercase tracking-wide'>Auszahlungs-Anträge</h2>
<button
onClick={() => { setOpen(o => !o); setErr('') }}
className='px-3 py-1.5 bg-purple-600 text-white text-xs font-semibold rounded-lg hover:bg-purple-700'
>
{open ? 'Abbrechen' : '💸 Auszahlung beantragen'}
</button>
</div>
{open && (
<div className='border border-gray-200 rounded-lg p-4 mb-4 space-y-3'>
<label className='block'>
<span className='text-xs font-medium text-gray-700'>Stunden *</span>
<input type='number' min='0.25' max='999' step='0.25' value={hours}
onChange={e => setHours(parseFloat(e.target.value))} className={inputClass} />
</label>
{overBudget && (
<p className='text-xs text-orange-600'> Überschreitet dein verfügbares Guthaben ({availableHours.toFixed(1)} h) Antrag ist trotzdem möglich.</p>
)}
<label className='block'>
<span className='text-xs font-medium text-gray-700'>Notiz</span>
<textarea value={note} onChange={e => setNote(e.target.value)} maxLength={500} rows={2}
placeholder='Optional…' className={inputClass + ' resize-none'} />
</label>
{err && <p className='text-sm text-red-600'>{err}</p>}
<div className='flex justify-end'>
<button onClick={submit} disabled={saving}
className='px-4 py-2 text-sm font-semibold text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50'>
{saving ? 'Sende…' : 'Antrag stellen'}
</button>
</div>
</div>
)}
{rows.length === 0 ? (
<p className='text-sm text-gray-400'>Noch keine Auszahlungs-Anträge.</p>
) : (
<div className='divide-y divide-gray-100'>
{rows.map(p => {
const b = STATUS_BADGE[p.status] ?? { label: p.status, cls: 'bg-gray-100 text-gray-500' }
const period = formatPeriod(p.period_year, p.period_month)
return (
<div key={p.id} className='flex items-center justify-between py-2 text-sm'>
<div>
<span className='font-semibold text-gray-800'>{Number(p.hours).toFixed(2)} h</span>
{period && <span className='text-gray-400 ml-2'>{period}</span>}
{p.status === 'rejected' && p.rejection_reason && (
<span className='text-gray-400 ml-2 italic'> {p.rejection_reason}</span>
)}
</div>
<div className='flex items-center gap-3'>
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${b.cls}`}>{b.label}</span>
{p.status === 'requested' && (
<button onClick={() => cancel(p.id)} className='text-xs text-red-500 hover:underline'>Zurückziehen</button>
)}
</div>
</div>
)
})}
</div>
)}
</div>
)
}