feat: Stunden-Auszahlung Feature (/hr/payouts)

- Backend: Model HoursPayout, Schema, Router GET/POST/DELETE
- GET /hr/payouts: HR/Admin sehen alle, Employee/Manager nur eigene
- POST /hr/payouts: reduziert OvertimeBalance.taken_hours sofort
- DELETE /hr/payouts/{id}: storniert und bucht Stunden zurück
- AuditLog-Einträge bei Anlegen und Stornieren
- Migration 0030: hours_payouts Tabelle
- Frontend: /hr/payouts Seite (lila, 💸) mit Filter, Tabelle, Modal
- Modal zeigt verfügbares Überstundenguthaben + Warnung bei Überziehung
- Navigation: Stunden-Auszahlung (HR/COMPANY_ADMIN/SUPER_ADMIN)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 22:17:52 +02:00
parent e83a3fbbdd
commit a63b0e835f
11 changed files with 956 additions and 0 deletions
+2
View File
@@ -27,6 +27,7 @@ import { KioskStampPage } from './pages/KioskStampPage'
import { MobilePage } from './pages/mobile/MobilePage'
import { MobileLoginPage } from './pages/mobile/MobileLoginPage'
import { SpecialAssignmentsPage } from './pages/SpecialAssignmentsPage'
import { HoursPayoutPage } from './pages/HoursPayoutPage'
export default function App() {
return (
@@ -59,6 +60,7 @@ export default function App() {
<Route path='/settings/kiosk' element={<KioskDevicesPage />} />
<Route path='/settings/audit-log' element={<AuditLogPage />} />
<Route path='/hr/special-assignments' element={<SpecialAssignmentsPage />} />
<Route path='/hr/payouts' element={<HoursPayoutPage />} />
<Route path='/profile' element={<ProfilePage />} />
</Route>
<Route path='*' element={<Navigate to='/login' replace />} />
+1
View File
@@ -21,6 +21,7 @@ const MAIN_NAV: NavItem[] = [
{ path: '/calendar', label: 'Kalender' },
{ path: '/reports', label: 'Berichte', roles: ['COMPANY_ADMIN', 'SUPER_ADMIN', 'HR', 'MANAGER'] },
{ path: '/hr/special-assignments', label: 'Sondervertretungen', roles: ['COMPANY_ADMIN', 'SUPER_ADMIN', 'HR', 'MANAGER'] },
{ path: '/hr/payouts', label: 'Stunden-Auszahlung', roles: ['COMPANY_ADMIN', 'SUPER_ADMIN', 'HR'] },
{ path: '/users', label: 'Mitarbeiter', roles: ['COMPANY_ADMIN', 'SUPER_ADMIN', 'HR'] },
]
+484
View File
@@ -0,0 +1,484 @@
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}`
}
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')
}
}
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', '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 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'>
<button
onClick={() => handleDelete(payout)}
className='text-xs text-red-500 hover:underline'
title='Stornieren'
>
🗑 Stornieren
</button>
</td>
</tr>
))}
{payouts.length === 0 && (
<tr>
<td colSpan={7} 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>
)
}
+26
View File
@@ -0,0 +1,26 @@
export interface HoursPayoutCreate {
user_id: string
hours: number
period_year?: number | null
period_month?: number | null
note?: string | null
}
export interface HoursPayoutOut {
id: string
company_id: string
user_id: string
user_name: string
hours: number
period_year: number | null
period_month: number | null
note: string | null
created_by: string
created_by_name: string
created_at: string
}
export interface HoursPayoutListResponse {
payouts: HoursPayoutOut[]
total_count: number
}