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