feat: Sondervertretungs-Faktoren (special_assignments)

- Neues Model SpecialAssignment mit AssignmentMode (fza|payroll|both)
- CRUD-Endpunkte unter /users/{id}/special-assignments
- Payroll-Report: GET /reports/special-assignments/payroll?year=&month=
- Migration 0029: special_assignments Tabelle + btree_gist Overlap-Constraint
- _recalculate_overtime_balance berücksichtigt FZA-Faktoren
- Frontend: Sondervertretungs-Zeiträume im UsersPage Edit-Modal
- Frontend: ReportsPage neuer Tab 'Sondervertretungen' mit Payroll-Tabelle + CSV-Export

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 00:55:47 +02:00
parent 1170e59e49
commit d60349df67
12 changed files with 837 additions and 39 deletions
+162 -32
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { api } from '../api/client'
import { Spinner } from '../components/Spinner'
import { Layout } from '../components/Layout'
import type { PayrollAssignmentReport } from '../types/specialAssignment'
interface UserOut {
id: string; first_name: string; last_name: string; email: string; role: string
@@ -68,7 +69,7 @@ interface SickStatsRow {
}
// ── Helpers ─────────────────────────────────────────────────────────────────
type ReportType = 'time' | 'absences' | 'overtime' | 'sick'
type ReportType = 'time' | 'absences' | 'overtime' | 'sick' | 'special'
const MANAGER_ROLES = ['COMPANY_ADMIN', 'SUPER_ADMIN', 'HR', 'MANAGER']
const STATUS_DE: Record<string, string> = {
@@ -132,6 +133,10 @@ export function ReportsPage() {
const [expandedUsers, setExpandedUsers] = useState<Set<string>>(new Set())
const [expandedWeeks, setExpandedWeeks] = useState<Set<string>>(new Set())
const [sickStats, setSickStats] = useState<SickStatsRow[] | null>(null)
const [payrollReport, setPayrollReport] = useState<PayrollAssignmentReport | null>(null)
// für Sondervertretungs-Tab: Jahr/Monat-Auswahl
const [specialYear, setSpecialYear] = useState(new Date().getFullYear())
const [specialMonth, setSpecialMonth] = useState(new Date().getMonth() + 1)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
@@ -162,6 +167,12 @@ export function ReportsPage() {
const stats = await api.get<SickStatsRow[]>(`/absences/sick-stats${params}`)
setSickStats(stats)
setTimeReport(null); setAbsenceReport(null); setOvertimeReport(null)
} else if (type === 'special') {
const report = await api.get<PayrollAssignmentReport>(
`/reports/special-assignments/payroll?year=${specialYear}&month=${specialMonth}`
)
setPayrollReport(report)
setTimeReport(null); setAbsenceReport(null); setOvertimeReport(null); setSickStats(null)
} else {
const [simple, detail] = await Promise.all([
api.get<OvertimeReport>(`/reports/overtime${p}`),
@@ -171,13 +182,14 @@ export function ReportsPage() {
setOvertimeDetail(detail)
setExpandedUsers(new Set())
setExpandedWeeks(new Set())
setTimeReport(null); setAbsenceReport(null); setSickStats(null)
setTimeReport(null); setAbsenceReport(null); setSickStats(null); setPayrollReport(null)
}
} catch (e: unknown) { setError(e instanceof Error ? e.message : 'Fehler') }
finally { setLoading(false) }
}
const download = async (format: 'csv' | 'xlsx' | 'pdf') => {
if (type === 'special' || type === 'sick') return // eigener Export in der Tabelle
const p = `?date_from=${dateFrom}&date_to=${dateTo}${filterUser ? `&user_id=${filterUser}` : ''}&format=${format}`
const ep = type === 'time' ? `/reports/time/export${p}` : type === 'absences' ? `/reports/absences/export${p}` : `/reports/overtime/export${p}`
const token = localStorage.getItem('access_token')
@@ -190,7 +202,7 @@ export function ReportsPage() {
const setQuick = (from: string, to: string) => { setDateFrom(from); setDateTo(to) }
const hasResult = !!(timeReport || absenceReport || overtimeReport || sickStats)
const hasResult = !!(timeReport || absenceReport || overtimeReport || sickStats || payrollReport)
return (
<Layout userRole={user?.role ?? ''} userName={user ? `${user.first_name} ${user.last_name}` : ''}>
@@ -206,7 +218,7 @@ export function ReportsPage() {
{/* Report type */}
<div className='flex rounded-lg border border-gray-300 overflow-hidden w-fit'>
{([['time','Zeiterfassung'],['absences','Abwesenheiten'],['overtime','Überstunden'],['sick','Krankmeldungen']] as [ReportType,string][]).map(([v,l]) => (
{([['time','Zeiterfassung'],['absences','Abwesenheiten'],['overtime','Überstunden'],['sick','Krankmeldungen'],['special','Sondervertretungen']] as [ReportType,string][]).map(([v,l]) => (
<button key={v} onClick={() => setType(v)}
className={`px-4 py-2 text-sm font-medium transition-colors ${type===v ? 'bg-blue-600 text-white' : 'text-gray-600 hover:bg-gray-50'}`}>
{l}
@@ -215,33 +227,56 @@ export function ReportsPage() {
</div>
<div className='flex flex-wrap gap-3 items-end'>
{/* Quick-select */}
<div>
<p className='text-xs font-medium text-gray-500 mb-1'>Schnellauswahl</p>
<div className='flex gap-1.5'>
{[['Dieser Monat', monthRange()],['Letzter Monat', monthRange(-1)],['Quartal', quarterRange()]] .map(([l, [f, t]]) => (
<button key={l as string} onClick={() => setQuick(f as string, t as string)}
className='px-3 py-1.5 text-xs border border-gray-300 rounded-lg text-gray-600 hover:bg-gray-50'>
{l as string}
</button>
))}
</div>
</div>
{type !== 'special' && (
<>
{/* Quick-select */}
<div>
<p className='text-xs font-medium text-gray-500 mb-1'>Schnellauswahl</p>
<div className='flex gap-1.5'>
{[['Dieser Monat', monthRange()],['Letzter Monat', monthRange(-1)],['Quartal', quarterRange()]] .map(([l, [f, t]]) => (
<button key={l as string} onClick={() => setQuick(f as string, t as string)}
className='px-3 py-1.5 text-xs border border-gray-300 rounded-lg text-gray-600 hover:bg-gray-50'>
{l as string}
</button>
))}
</div>
</div>
{/* Date range */}
<div>
<p className='text-xs font-medium text-gray-500 mb-1'>Von</p>
<input type='date' value={dateFrom} onChange={e => setDateFrom(e.target.value)}
className='border border-gray-300 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500' />
</div>
<div>
<p className='text-xs font-medium text-gray-500 mb-1'>Bis</p>
<input type='date' value={dateTo} min={dateFrom} onChange={e => setDateTo(e.target.value)}
className='border border-gray-300 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500' />
</div>
</>
)}
{/* Date range */}
<div>
<p className='text-xs font-medium text-gray-500 mb-1'>Von</p>
<input type='date' value={dateFrom} onChange={e => setDateFrom(e.target.value)}
className='border border-gray-300 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500' />
</div>
<div>
<p className='text-xs font-medium text-gray-500 mb-1'>Bis</p>
<input type='date' value={dateTo} min={dateFrom} onChange={e => setDateTo(e.target.value)}
className='border border-gray-300 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500' />
</div>
{type === 'special' && (
<>
<div>
<p className='text-xs font-medium text-gray-500 mb-1'>Jahr</p>
<input type='number' value={specialYear} min={2020} max={2100}
onChange={e => setSpecialYear(parseInt(e.target.value))}
className='border border-gray-300 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 w-28' />
</div>
<div>
<p className='text-xs font-medium text-gray-500 mb-1'>Monat</p>
<select value={specialMonth} onChange={e => setSpecialMonth(parseInt(e.target.value))}
className='border border-gray-300 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'>
{['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember'].map((m, i) => (
<option key={i+1} value={i+1}>{m}</option>
))}
</select>
</div>
</>
)}
{/* Employee filter (manager only) */}
{isManager && colleagues.length > 0 && (
{/* Employee filter (manager only) nicht für special */}
{isManager && colleagues.length > 0 && type !== 'special' && (
<div>
<p className='text-xs font-medium text-gray-500 mb-1'>Mitarbeiter</p>
<select value={filterUser} onChange={e => setFilterUser(e.target.value)}
@@ -269,15 +304,15 @@ export function ReportsPage() {
<div className='px-6 py-4 border-b border-gray-100 flex items-center justify-between flex-wrap gap-3'>
<div>
<h2 className='font-semibold text-gray-800'>
{type === 'time' ? 'Zeiterfassung' : type === 'absences' ? 'Abwesenheiten' : type === 'overtime' ? 'Überstunden' : 'Krankmeldungen'}
{type === 'time' ? 'Zeiterfassung' : type === 'absences' ? 'Abwesenheiten' : type === 'overtime' ? 'Überstunden' : type === 'special' ? 'Sondervertretungen' : 'Krankmeldungen'}
</h2>
<p className='text-xs text-gray-400 mt-0.5'>
{type === 'sick' ? 'Rolling 12 Monate ab heute' : `${fmtDate(dateFrom)} ${fmtDate(dateTo)}`}
{type === 'sick' ? 'Rolling 12 Monate ab heute' : type === 'special' ? `${specialYear}, Monat ${specialMonth}` : `${fmtDate(dateFrom)} ${fmtDate(dateTo)}`}
</p>
</div>
<div className='flex gap-2 no-print'>
<button onClick={() => window.print()} className='text-xs px-3 py-1.5 border border-gray-300 rounded-lg text-gray-600 hover:bg-gray-50'>Drucken</button>
{type !== 'sick' && (<>
{type !== 'sick' && type !== 'special' && (<>
<button onClick={() => download('csv')} className='text-xs px-3 py-1.5 border border-gray-300 rounded-lg text-gray-600 hover:bg-gray-50'>CSV</button>
<button onClick={() => download('xlsx')} className='text-xs px-3 py-1.5 bg-green-600 text-white rounded-lg hover:bg-green-700'>Excel</button>
<button onClick={() => download('pdf')} className='text-xs px-3 py-1.5 bg-red-600 text-white rounded-lg hover:bg-red-700'>PDF</button>
@@ -759,6 +794,101 @@ export function ReportsPage() {
</div>
)}
{/* ── Sondervertretungen Payroll-Report ── */}
{payrollReport && (
<div className='overflow-x-auto'>
{payrollReport.rows.length === 0 ? (
<p className='text-center text-gray-400 py-12 text-sm'>
Keine Sondervertretungs-Zuweisungen in {payrollReport.month}/{payrollReport.year}.
</p>
) : (
<>
<div className='flex justify-end px-4 py-2 no-print'>
<button
onClick={() => {
const rows = payrollReport.rows.flatMap(r =>
r.assignments.map(a => [
r.user_name, r.personnel_number ?? '',
a.label ?? '', a.date_from, a.date_to,
a.factor, a.normal_hours, a.factor_hours, a.extra_hours
])
)
const header = 'Mitarbeiter,Pers-Nr,Bezeichnung,Von,Bis,Faktor,Normal-Std,Faktor-Std,Extra-Std'
const csv = [header, ...rows.map(r => r.join(','))].join('\n')
const blob = new Blob([csv], { type: 'text/csv' })
const a = document.createElement('a'); a.href = URL.createObjectURL(blob)
a.download = `sondervertretung_${payrollReport.year}_${String(payrollReport.month).padStart(2,'0')}.csv`; a.click()
}}
className='px-3 py-1.5 text-xs border border-gray-300 rounded-lg text-gray-600 hover:bg-gray-50'
>
CSV Export
</button>
</div>
<table className='w-full text-sm'>
<thead className='bg-amber-50 text-xs text-amber-700 uppercase tracking-wide'>
<tr>
<th className='px-4 py-3 text-left'>Mitarbeiter</th>
<th className='px-4 py-3 text-left'>Pers.-Nr.</th>
<th className='px-4 py-3 text-left'>Bezeichnung</th>
<th className='px-4 py-3 text-left'>Zeitraum</th>
<th className='px-4 py-3 text-right'>Faktor</th>
<th className='px-4 py-3 text-right'>Normal-Std.</th>
<th className='px-4 py-3 text-right'>Faktor-Std.</th>
<th className='px-4 py-3 text-right font-bold'>Extra-Std.</th>
</tr>
</thead>
<tbody className='divide-y divide-gray-100'>
{payrollReport.rows.map(row => (
row.assignments.map((a, i) => (
<tr key={a.assignment_id} className='hover:bg-amber-50/30'>
{i === 0 && (
<>
<td className='px-4 py-3 font-medium text-gray-800' rowSpan={row.assignments.length}>
{row.user_name}
{row.assignments.length > 1 && (
<div className='text-xs text-gray-400 mt-0.5'>
Gesamt: +{row.total_extra_hours.toFixed(1)}h
</div>
)}
</td>
<td className='px-4 py-3 text-gray-500 font-mono text-xs' rowSpan={row.assignments.length}>
{row.personnel_number ?? ''}
</td>
</>
)}
<td className='px-4 py-3 text-gray-700'>{a.label ?? ''}</td>
<td className='px-4 py-3 text-gray-600 text-xs'>{a.date_from} {a.date_to}</td>
<td className='px-4 py-3 text-right font-semibold text-amber-700'>×{Number(a.factor).toFixed(2)}</td>
<td className='px-4 py-3 text-right text-gray-600'>{a.normal_hours.toFixed(1)}</td>
<td className='px-4 py-3 text-right text-gray-700'>{a.factor_hours.toFixed(1)}</td>
<td className='px-4 py-3 text-right font-semibold text-green-700'>+{a.extra_hours.toFixed(1)}</td>
</tr>
))
))}
</tbody>
<tfoot className='bg-gray-50 text-sm font-semibold'>
<tr>
<td colSpan={5} className='px-4 py-3 text-gray-700'>Gesamt</td>
<td className='px-4 py-3 text-right'>
{payrollReport.rows.reduce((s, r) => s + r.total_normal_hours, 0).toFixed(1)}
</td>
<td className='px-4 py-3 text-right'>
{payrollReport.rows.reduce((s, r) => s + r.total_factor_hours, 0).toFixed(1)}
</td>
<td className='px-4 py-3 text-right text-green-700'>
+{payrollReport.rows.reduce((s, r) => s + r.total_extra_hours, 0).toFixed(1)}
</td>
</tr>
</tfoot>
</table>
</>
)}
<p className='text-xs text-gray-400 px-4 py-3 border-t border-gray-100'>
Sondervertretungs-Faktor: Stunden × Faktor (z.B. ×1,5 für Schichtleiter-Vertretung). Extra-Stunden = Differenz zur Normarbeitszeit.
</p>
</div>
)}
{/* ── Sick stats table ── */}
{sickStats && (
<div className='overflow-x-auto'>