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>
549 lines
20 KiB
TypeScript
549 lines
20 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
import type { HoursPayoutOut, HoursPayoutListResponse } from '../types/hoursPayout'
|
|
import { api } from '../api/client'
|
|
import { Spinner } from '../components/Spinner'
|
|
import { Layout } from '../components/Layout'
|
|
import { Modal } from '../components/Modal'
|
|
|
|
interface UserItem {
|
|
id: string
|
|
full_name: string
|
|
personnel_number: string | null
|
|
is_active: boolean
|
|
}
|
|
|
|
interface UserListResponse {
|
|
total: number
|
|
items: UserItem[]
|
|
}
|
|
|
|
interface Me {
|
|
first_name: string
|
|
last_name: string
|
|
role: string
|
|
}
|
|
|
|
interface OvertimeBalance {
|
|
balance_hours: number
|
|
}
|
|
|
|
const MONTH_NAMES = [
|
|
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
|
|
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
|
|
]
|
|
|
|
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'
|
|
|
|
function formatPeriod(year: number | null, month: number | null): string {
|
|
if (!year) return '—'
|
|
if (!month) return String(year)
|
|
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[]>([])
|
|
const [pageLoading, setPageLoading] = useState(true)
|
|
const [pageError, setPageError] = useState<string | null>(null)
|
|
|
|
// Filter state
|
|
const currentYear = new Date().getFullYear()
|
|
const [filterUser, setFilterUser] = useState('')
|
|
const [filterYear, setFilterYear] = useState<number>(currentYear)
|
|
const [filterMonth, setFilterMonth] = useState<number>(0)
|
|
|
|
// Table data
|
|
const [payouts, setPayouts] = useState<HoursPayoutOut[]>([])
|
|
const [totalCount, setTotalCount] = useState(0)
|
|
const [tableLoading, setTableLoading] = useState(false)
|
|
|
|
// Modal state
|
|
const [showModal, setShowModal] = useState(false)
|
|
const [formUserId, setFormUserId] = useState('')
|
|
const [formHours, setFormHours] = useState<number>(8)
|
|
const [formYear, setFormYear] = useState<number>(currentYear)
|
|
const [formMonth, setFormMonth] = useState<number>(new Date().getMonth() + 1)
|
|
const [formNote, setFormNote] = useState('')
|
|
const [formHasPeriod, setFormHasPeriod] = useState(true)
|
|
const [modalSaving, setModalSaving] = useState(false)
|
|
const [modalError, setModalError] = useState<string | null>(null)
|
|
|
|
// Overtime balance for selected user
|
|
const [overtimeBalance, setOvertimeBalance] = useState<number | null>(null)
|
|
const [overtimeLoading, setOvertimeLoading] = useState(false)
|
|
|
|
// Initial load
|
|
useEffect(() => {
|
|
async function init() {
|
|
try {
|
|
const [meData, listData] = await Promise.all([
|
|
api.get<Me>('/auth/me'),
|
|
api.get<UserListResponse>('/users/?limit=500'),
|
|
])
|
|
setMe(meData)
|
|
setUsers(listData.items.filter(u => u.is_active))
|
|
} catch (e: unknown) {
|
|
setPageError(e instanceof Error ? e.message : 'Fehler beim Laden')
|
|
} finally {
|
|
setPageLoading(false)
|
|
}
|
|
}
|
|
init()
|
|
}, [])
|
|
|
|
// Load on mount after users available
|
|
useEffect(() => {
|
|
if (!pageLoading) {
|
|
loadPayouts()
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [pageLoading])
|
|
|
|
async function loadPayouts() {
|
|
setTableLoading(true)
|
|
try {
|
|
const params: Record<string, string> = {}
|
|
if (filterUser) params.user_id = filterUser
|
|
if (filterYear) params.year = String(filterYear)
|
|
if (filterMonth > 0) params.month = String(filterMonth)
|
|
|
|
const data = await api.get<HoursPayoutListResponse>(
|
|
`/hr/payouts?${new URLSearchParams(params)}`
|
|
)
|
|
setPayouts(data.payouts)
|
|
setTotalCount(data.total_count)
|
|
} catch (e: unknown) {
|
|
setPageError(e instanceof Error ? e.message : 'Fehler beim Laden der Auszahlungen')
|
|
} finally {
|
|
setTableLoading(false)
|
|
}
|
|
}
|
|
|
|
function handleSearch() {
|
|
loadPayouts()
|
|
}
|
|
|
|
async function loadOvertimeBalance(userId: string) {
|
|
if (!userId) {
|
|
setOvertimeBalance(null)
|
|
return
|
|
}
|
|
setOvertimeLoading(true)
|
|
try {
|
|
const data = await api.get<OvertimeBalance>(`/absences/overtime-balance?user_id=${userId}`)
|
|
setOvertimeBalance(data.balance_hours)
|
|
} catch {
|
|
setOvertimeBalance(null)
|
|
} finally {
|
|
setOvertimeLoading(false)
|
|
}
|
|
}
|
|
|
|
function openNewModal() {
|
|
setFormUserId('')
|
|
setFormHours(8)
|
|
setFormYear(currentYear)
|
|
setFormMonth(new Date().getMonth() + 1)
|
|
setFormNote('')
|
|
setFormHasPeriod(true)
|
|
setOvertimeBalance(null)
|
|
setModalError(null)
|
|
setShowModal(true)
|
|
}
|
|
|
|
function handleUserChange(userId: string) {
|
|
setFormUserId(userId)
|
|
loadOvertimeBalance(userId)
|
|
}
|
|
|
|
async function handleSave() {
|
|
if (!formUserId) {
|
|
setModalError('Bitte einen Mitarbeiter auswählen.')
|
|
return
|
|
}
|
|
if (!formHours || formHours <= 0) {
|
|
setModalError('Stunden müssen größer als 0 sein.')
|
|
return
|
|
}
|
|
setModalSaving(true)
|
|
setModalError(null)
|
|
try {
|
|
await api.post<HoursPayoutOut>('/hr/payouts', {
|
|
user_id: formUserId,
|
|
hours: formHours,
|
|
period_year: formHasPeriod ? formYear : null,
|
|
period_month: formHasPeriod ? formMonth : null,
|
|
note: formNote.trim() || null,
|
|
})
|
|
setShowModal(false)
|
|
loadPayouts()
|
|
} catch (e: unknown) {
|
|
setModalError(e instanceof Error ? e.message : 'Fehler beim Anlegen')
|
|
} finally {
|
|
setModalSaving(false)
|
|
}
|
|
}
|
|
|
|
async function handleDelete(payout: HoursPayoutOut) {
|
|
if (!confirm('Auszahlung wirklich stornieren?')) return
|
|
try {
|
|
await api.del(`/hr/payouts/${payout.id}`)
|
|
setPayouts(prev => prev.filter(p => p.id !== payout.id))
|
|
setTotalCount(prev => prev - 1)
|
|
} catch (e: unknown) {
|
|
alert(e instanceof Error ? e.message : 'Fehler beim Stornieren')
|
|
}
|
|
}
|
|
|
|
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>
|
|
)
|
|
if (pageError) return (
|
|
<div className='min-h-screen bg-gray-50 flex items-center justify-center'>
|
|
<p className='text-red-600'>{pageError}</p>
|
|
</div>
|
|
)
|
|
|
|
const isOverBudget = overtimeBalance !== null && formHours > overtimeBalance
|
|
|
|
return (
|
|
<Layout userRole={me?.role ?? ''} userName={`${me?.first_name} ${me?.last_name}`}>
|
|
<div className='space-y-6'>
|
|
|
|
{/* Header */}
|
|
<div className='flex items-center justify-between'>
|
|
<div>
|
|
<h1 className='text-xl font-bold text-gray-800'>💸 Stunden-Auszahlung</h1>
|
|
<p className='text-sm text-gray-500 mt-0.5'>Überstunden-Auszahlungen verwalten</p>
|
|
</div>
|
|
<button
|
|
onClick={openNewModal}
|
|
className='px-4 py-2 bg-purple-600 text-white text-sm font-semibold rounded-lg hover:bg-purple-700 transition-colors'
|
|
>
|
|
+ Auszahlung anlegen
|
|
</button>
|
|
</div>
|
|
|
|
{/* Filter bar */}
|
|
<div className='bg-white rounded-xl border border-gray-200 shadow-sm p-4'>
|
|
<div className='flex flex-wrap gap-3 items-end'>
|
|
<div className='flex-1 min-w-48'>
|
|
<label className='block text-xs font-medium text-gray-700 mb-1'>Mitarbeiter</label>
|
|
<select
|
|
value={filterUser}
|
|
onChange={e => setFilterUser(e.target.value)}
|
|
className={inputClass}
|
|
>
|
|
<option value=''>Alle Mitarbeiter</option>
|
|
{users.map(u => (
|
|
<option key={u.id} value={u.id}>
|
|
{u.full_name}{u.personnel_number ? ` (${u.personnel_number})` : ''}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className='w-32'>
|
|
<label className='block text-xs font-medium text-gray-700 mb-1'>Jahr</label>
|
|
<input
|
|
type='number'
|
|
value={filterYear}
|
|
onChange={e => setFilterYear(Number(e.target.value))}
|
|
min={2000}
|
|
max={2100}
|
|
className={inputClass}
|
|
/>
|
|
</div>
|
|
<div className='w-44'>
|
|
<label className='block text-xs font-medium text-gray-700 mb-1'>Monat</label>
|
|
<select
|
|
value={filterMonth}
|
|
onChange={e => setFilterMonth(Number(e.target.value))}
|
|
className={inputClass}
|
|
>
|
|
<option value={0}>Alle Monate</option>
|
|
{MONTH_NAMES.map((name, i) => (
|
|
<option key={i + 1} value={i + 1}>{name}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<button
|
|
onClick={handleSearch}
|
|
disabled={tableLoading}
|
|
className='px-4 py-2 bg-purple-600 text-white text-sm font-semibold rounded-lg hover:bg-purple-700 disabled:opacity-50 transition-colors'
|
|
>
|
|
{tableLoading ? 'Lade…' : 'Suchen'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Table */}
|
|
<div className='bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden'>
|
|
{tableLoading ? (
|
|
<div className='flex items-center justify-center py-16'><Spinner /></div>
|
|
) : (
|
|
<>
|
|
<div className='overflow-x-auto'>
|
|
<table className='w-full text-sm'>
|
|
<thead className='bg-gray-50 text-gray-500 text-xs uppercase'>
|
|
<tr>
|
|
{['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>
|
|
</thead>
|
|
<tbody className='divide-y divide-gray-100'>
|
|
{payouts.map(payout => (
|
|
<tr key={payout.id} className='hover:bg-gray-50 transition-colors'>
|
|
<td className='px-4 py-3 font-medium text-gray-800'>{payout.user_name}</td>
|
|
<td className='px-4 py-3'>
|
|
<span className='font-bold text-purple-700'>
|
|
{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>
|
|
<td className='px-4 py-3 text-gray-500 max-w-xs truncate'>
|
|
{payout.note || '—'}
|
|
</td>
|
|
<td className='px-4 py-3 text-gray-500'>{payout.created_by_name}</td>
|
|
<td className='px-4 py-3 text-gray-500'>
|
|
{new Date(payout.created_at).toLocaleDateString('de-DE', {
|
|
day: '2-digit', month: '2-digit', year: 'numeric',
|
|
})}
|
|
</td>
|
|
<td className='px-4 py-3'>
|
|
{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={8} className='px-4 py-10 text-center text-gray-400'>
|
|
Keine Auszahlungen gefunden.
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{totalCount > 0 && (
|
|
<div className='px-4 py-3 border-t border-gray-100 text-xs text-gray-400'>
|
|
{totalCount} Einträge gesamt
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* New Payout Modal */}
|
|
{showModal && (
|
|
<Modal
|
|
title='Auszahlung anlegen'
|
|
onClose={() => setShowModal(false)}
|
|
>
|
|
<div className='space-y-4'>
|
|
|
|
{/* Mitarbeiter */}
|
|
<label className='block'>
|
|
<span className='text-xs font-medium text-gray-700'>Mitarbeiter *</span>
|
|
<select
|
|
value={formUserId}
|
|
onChange={e => handleUserChange(e.target.value)}
|
|
className={inputClass}
|
|
>
|
|
<option value=''>— bitte wählen —</option>
|
|
{users.map(u => (
|
|
<option key={u.id} value={u.id}>
|
|
{u.full_name}{u.personnel_number ? ` (${u.personnel_number})` : ''}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
|
|
{/* Overtime balance indicator */}
|
|
{formUserId && (
|
|
<div className='text-sm'>
|
|
{overtimeLoading ? (
|
|
<span className='text-gray-400'>Lade Überstundenguthaben…</span>
|
|
) : overtimeBalance !== null ? (
|
|
<span className={isOverBudget ? 'text-orange-600 font-medium' : 'text-green-700'}>
|
|
Verfügbares Überstundenguthaben: {Number(overtimeBalance).toFixed(2)} h
|
|
{isOverBudget && ' ⚠️ Auszahlung überschreitet verfügbares Guthaben'}
|
|
</span>
|
|
) : (
|
|
<span className='text-gray-400'>Guthaben nicht verfügbar</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Stunden */}
|
|
<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={formHours}
|
|
onChange={e => setFormHours(parseFloat(e.target.value))}
|
|
className={inputClass}
|
|
/>
|
|
</label>
|
|
|
|
{/* Abrechnungsmonat */}
|
|
<div>
|
|
<div className='flex items-center gap-2 mb-2'>
|
|
<span className='text-xs font-medium text-gray-700'>Abrechnungsmonat</span>
|
|
<label className='flex items-center gap-1 text-xs text-gray-500 cursor-pointer'>
|
|
<input
|
|
type='checkbox'
|
|
checked={formHasPeriod}
|
|
onChange={e => setFormHasPeriod(e.target.checked)}
|
|
className='rounded'
|
|
/>
|
|
angeben
|
|
</label>
|
|
</div>
|
|
{formHasPeriod && (
|
|
<div className='flex gap-3'>
|
|
<div className='flex-1'>
|
|
<label className='block text-xs text-gray-500 mb-1'>Jahr</label>
|
|
<input
|
|
type='number'
|
|
min={2000}
|
|
max={2100}
|
|
value={formYear}
|
|
onChange={e => setFormYear(Number(e.target.value))}
|
|
className={inputClass}
|
|
/>
|
|
</div>
|
|
<div className='flex-1'>
|
|
<label className='block text-xs text-gray-500 mb-1'>Monat</label>
|
|
<select
|
|
value={formMonth}
|
|
onChange={e => setFormMonth(Number(e.target.value))}
|
|
className={inputClass}
|
|
>
|
|
{MONTH_NAMES.map((name, i) => (
|
|
<option key={i + 1} value={i + 1}>{name}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Notiz */}
|
|
<label className='block'>
|
|
<span className='text-xs font-medium text-gray-700'>Notiz</span>
|
|
<textarea
|
|
value={formNote}
|
|
onChange={e => setFormNote(e.target.value)}
|
|
maxLength={500}
|
|
rows={3}
|
|
placeholder='Optionale Notiz zur Auszahlung…'
|
|
className={inputClass + ' resize-none'}
|
|
/>
|
|
<span className='text-xs text-gray-400'>{formNote.length}/500</span>
|
|
</label>
|
|
|
|
{modalError && (
|
|
<div className='text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2'>
|
|
{modalError}
|
|
</div>
|
|
)}
|
|
|
|
<div className='flex justify-end gap-2 pt-2'>
|
|
<button
|
|
onClick={() => setShowModal(false)}
|
|
className='px-4 py-2 text-sm text-gray-700 border border-gray-300 rounded-lg hover:bg-gray-50'
|
|
>
|
|
Abbrechen
|
|
</button>
|
|
<button
|
|
onClick={handleSave}
|
|
disabled={modalSaving}
|
|
className='px-4 py-2 text-sm font-semibold text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50'
|
|
>
|
|
{modalSaving ? 'Speichere…' : 'Auszahlung anlegen'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
)}
|
|
</Layout>
|
|
)
|
|
}
|