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:
@@ -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 />} />
|
||||
|
||||
@@ -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'] },
|
||||
]
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user