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