Files
timemaster/frontend/src/pages/ReportsPage.tsx
T
patrick d60349df67 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>
2026-05-25 00:55:47 +02:00

939 lines
61 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
interface UserListItem { id: string; full_name: string; email: string }
// ── Backend shapes ──────────────────────────────────────────────────────────
interface HoursBreakdown {
normal_hours: number
night_25_hours: number
night_40_hours: number
sunday_hours: number
holiday_125_hours: number
holiday_150_hours: number
holiday_name: string | null
}
interface TimeReportRow {
date: string; user_id: string; user_name: string; personnel_number: string | null; department: string | null
start_time: string; end_time: string | null; break_minutes: number
worked_hours: number | null; status: string; source: string; note: string | null
breakdown: HoursBreakdown | null
}
interface TimeReport { date_from: string; date_to: string; total_rows: number; total_hours: number; rows: TimeReportRow[] }
interface AbsenceReportRow {
user_id: string; user_name: string; personnel_number: string | null; department: string | null
absence_type: string; start_date: string; end_date: string
working_days: number; status: string; note: string | null
}
interface AbsenceReport { date_from: string; date_to: string; total_rows: number; total_days: number; rows: AbsenceReportRow[] }
interface OvertimeReportRow {
user_id: string; user_name: string; personnel_number: string | null; department: string | null
hours_worked: number; hours_expected: number; overtime_hours: number
}
interface OvertimeReport { date_from: string; date_to: string; total_employees: number; total_overtime: number; rows: OvertimeReportRow[] }
interface DayEntry {
start_time: string; end_time: string; break_minutes: number
hours_worked: number; status: string; arbzg_warnings: string[]
breakdown: HoursBreakdown | null
}
interface OvertimeDay {
date: string; weekday: string
hours_worked: number; hours_expected: number; overtime: number
entries: DayEntry[]
}
interface OvertimeWeek {
week_nr: number; week_start: string; week_end: string
hours_worked: number; hours_expected: number; overtime: number
days: OvertimeDay[]
}
interface OvertimeReportRowDetailed extends OvertimeReportRow {
weeks: OvertimeWeek[]
arbzg_violation_days: number
special_hours_total: HoursBreakdown | null
}
interface OvertimeReportDetailed { date_from: string; date_to: string; total_employees: number; total_overtime: number; rows: OvertimeReportRowDetailed[] }
interface SickStatsRow {
user_id: string; user_name: string; personnel_number: string | null
episodes: number; total_days: number; bradford_factor: number; certificates_overdue: number
}
// ── Helpers ─────────────────────────────────────────────────────────────────
type ReportType = 'time' | 'absences' | 'overtime' | 'sick' | 'special'
const MANAGER_ROLES = ['COMPANY_ADMIN', 'SUPER_ADMIN', 'HR', 'MANAGER']
const STATUS_DE: Record<string, string> = {
open: 'Offen', pending: 'Prüfung', approved: 'Genehmigt',
rejected: 'Abgelehnt', auto: 'Auto', cancelled: 'Storniert',
}
const STATUS_COLORS: Record<string, string> = {
open: 'bg-yellow-100 text-yellow-700', pending: 'bg-blue-100 text-blue-700',
approved: 'bg-green-100 text-green-700', rejected: 'bg-red-100 text-red-700',
auto: 'bg-gray-100 text-gray-600', cancelled: 'bg-gray-100 text-gray-400',
}
function fmt(t: string | null): string {
if (!t) return ''
if (/^\d{2}:\d{2}(:\d{2})?$/.test(t)) return t.slice(0, 5)
const d = new Date(t)
return isNaN(d.getTime()) ? t.slice(0, 5) : d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
}
function fmtDate(s: string) { return new Date(s + 'T00:00:00').toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) }
function fmtH(h: number) {
const hrs = Math.floor(Math.abs(h)); const min = Math.round((Math.abs(h) - hrs) * 60)
return `${h < 0 ? '-' : ''}${hrs}h ${min.toString().padStart(2, '0')}m`
}
function monthRange(offset = 0): [string, string] {
const d = new Date(); d.setMonth(d.getMonth() + offset, 1)
const from = d.toISOString().slice(0, 8) + '01'
const last = new Date(d.getFullYear(), d.getMonth() + 1, 0)
return [from, last.toISOString().slice(0, 10)]
}
function quarterRange(): [string, string] {
const d = new Date(); const q = Math.floor(d.getMonth() / 3)
const from = new Date(d.getFullYear(), q * 3, 1).toISOString().slice(0, 10)
const to = new Date(d.getFullYear(), q * 3 + 3, 0).toISOString().slice(0, 10)
return [from, to]
}
// Group rows by user
function groupBy<T extends { user_id: string; user_name: string }>(rows: T[]): Map<string, T[]> {
const m = new Map<string, T[]>()
for (const r of rows) {
if (!m.has(r.user_id)) m.set(r.user_id, [])
m.get(r.user_id)!.push(r)
}
return m
}
export function ReportsPage() {
const [user, setUser] = useState<UserOut | null>(null)
const [colleagues, setColleagues] = useState<UserListItem[]>([])
const [type, setType] = useState<ReportType>('time')
const [dateFrom, setDateFrom] = useState(monthRange()[0])
const [dateTo, setDateTo] = useState(monthRange()[1])
const [filterUser, setFilterUser] = useState('')
const [timeReport, setTimeReport] = useState<TimeReport | null>(null)
const [absenceReport, setAbsenceReport] = useState<AbsenceReport | null>(null)
const [overtimeReport, setOvertimeReport] = useState<OvertimeReport | null>(null)
const [overtimeDetail, setOvertimeDetail] = useState<OvertimeReportDetailed | null>(null)
const [overtimeView, setOvertimeView] = useState<'simple' | 'detail'>('simple')
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('')
const isManager = MANAGER_ROLES.includes(user?.role ?? '')
useEffect(() => {
api.get<UserOut>('/auth/me').then(me => {
setUser(me)
if (MANAGER_ROLES.includes(me.role)) {
api.get<{ items: UserListItem[] }>('/users/?limit=500').then(r => setColleagues(r.items)).catch(() => {})
}
}).catch(() => {})
}, [])
const run = async () => {
setLoading(true); setError('')
const p = `?date_from=${dateFrom}&date_to=${dateTo}${filterUser ? `&user_id=${filterUser}` : ''}`
try {
if (type === 'time') {
setTimeReport(await api.get<TimeReport>(`/reports/time${p}`))
setAbsenceReport(null); setOvertimeReport(null); setSickStats(null)
} else if (type === 'absences') {
setAbsenceReport(await api.get<AbsenceReport>(`/reports/absences${p}`))
setTimeReport(null); setOvertimeReport(null); setSickStats(null)
} else if (type === 'sick') {
const params = filterUser ? `?user_id=${filterUser}` : ''
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}`),
api.get<OvertimeReportDetailed>(`/reports/overtime/detail${p}`),
])
setOvertimeReport(simple)
setOvertimeDetail(detail)
setExpandedUsers(new Set())
setExpandedWeeks(new Set())
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')
const res = await fetch(`/api/v1${ep}`, { headers: { Authorization: `Bearer ${token}` } })
if (!res.ok) { setError('Export fehlgeschlagen'); return }
const blob = await res.blob()
const a = document.createElement('a'); a.href = URL.createObjectURL(blob)
a.download = `${type}_${dateFrom}_${dateTo}.${format}`; a.click()
}
const setQuick = (from: string, to: string) => { setDateFrom(from); setDateTo(to) }
const hasResult = !!(timeReport || absenceReport || overtimeReport || sickStats || payrollReport)
return (
<Layout userRole={user?.role ?? ''} userName={user ? `${user.first_name} ${user.last_name}` : ''}>
<style>{`@media print { header,nav,.no-print{display:none!important} body{font-size:11px} }`}</style>
<div className='space-y-5'>
<div className='flex items-center justify-between no-print'>
<h1 className='text-2xl font-bold text-gray-900'>Berichte</h1>
</div>
{/* ── Controls ───────────────────────────────────────────────────── */}
<div className='bg-white rounded-xl shadow-sm border border-gray-200 p-5 no-print space-y-4'>
{/* Report type */}
<div className='flex rounded-lg border border-gray-300 overflow-hidden w-fit'>
{([['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}
</button>
))}
</div>
<div className='flex flex-wrap gap-3 items-end'>
{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>
</>
)}
{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) 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)}
className='border border-gray-300 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'>
<option value=''>Alle</option>
{colleagues.map(c => <option key={c.id} value={c.id}>{c.full_name}</option>)}
</select>
</div>
)}
<button onClick={run} disabled={loading}
className='px-5 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50 flex items-center gap-2'>
{loading && <Spinner />} Auswerten
</button>
</div>
</div>
{error && <div className='bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700 no-print'>{error}</div>}
{/* ── Results ────────────────────────────────────────────────────── */}
{hasResult && (
<div className='bg-white rounded-xl shadow-sm border border-gray-200'>
{/* Header */}
<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' : type === 'special' ? 'Sondervertretungen' : 'Krankmeldungen'}
</h2>
<p className='text-xs text-gray-400 mt-0.5'>
{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 !== '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>
</>)}
</div>
</div>
{/* ── KPI row ── */}
{timeReport && (
<div className='grid grid-cols-3 divide-x divide-gray-100 border-b border-gray-100'>
<div className='px-6 py-4'><p className='text-xs text-gray-500'>Einträge</p><p className='text-2xl font-bold text-gray-800'>{timeReport.total_rows}</p></div>
<div className='px-6 py-4'><p className='text-xs text-gray-500'>Gesamt-Stunden</p><p className='text-2xl font-bold text-blue-700'>{fmtH(timeReport.total_hours)}</p></div>
<div className='px-6 py-4'><p className='text-xs text-gray-500'>Mitarbeiter</p><p className='text-2xl font-bold text-gray-800'>{new Set(timeReport.rows.map(r => r.user_id)).size}</p></div>
</div>
)}
{absenceReport && (
<div className='grid grid-cols-3 divide-x divide-gray-100 border-b border-gray-100'>
<div className='px-6 py-4'><p className='text-xs text-gray-500'>Anträge</p><p className='text-2xl font-bold text-gray-800'>{absenceReport.total_rows}</p></div>
<div className='px-6 py-4'><p className='text-xs text-gray-500'>Gesamt-Arbeitstage</p><p className='text-2xl font-bold text-orange-600'>{absenceReport.total_days}</p></div>
<div className='px-6 py-4'><p className='text-xs text-gray-500'>Mitarbeiter betroffen</p><p className='text-2xl font-bold text-gray-800'>{new Set(absenceReport.rows.map(r => r.user_id)).size}</p></div>
</div>
)}
{overtimeReport && (
<div className='grid grid-cols-3 divide-x divide-gray-100 border-b border-gray-100'>
<div className='px-6 py-4'><p className='text-xs text-gray-500'>Mitarbeiter</p><p className='text-2xl font-bold text-gray-800'>{overtimeReport.total_employees}</p></div>
<div className='px-6 py-4'><p className='text-xs text-gray-500'>Gesamt-Überstunden</p>
<p className={`text-2xl font-bold ${overtimeReport.total_overtime >= 0 ? 'text-green-600' : 'text-red-600'}`}>{fmtH(overtimeReport.total_overtime)}</p>
</div>
<div className='px-6 py-4'><p className='text-xs text-gray-500'>Periode</p><p className='text-sm font-medium text-gray-700 mt-1'>{fmtDate(overtimeReport.date_from)} {fmtDate(overtimeReport.date_to)}</p></div>
</div>
)}
{sickStats && (() => {
const totalDays = sickStats.reduce((s, r) => s + r.total_days, 0)
const totalEpisodes = sickStats.reduce((s, r) => s + r.episodes, 0)
const overdue = sickStats.reduce((s, r) => s + r.certificates_overdue, 0)
const avgBradford = sickStats.length ? sickStats.reduce((s, r) => s + r.bradford_factor, 0) / sickStats.length : 0
return (
<div className='grid grid-cols-4 divide-x divide-gray-100 border-b border-gray-100'>
<div className='px-6 py-4'><p className='text-xs text-gray-500'> Bradford-Index</p><p className='text-2xl font-bold text-orange-600'>{avgBradford.toFixed(1)}</p></div>
<div className='px-6 py-4'><p className='text-xs text-gray-500'>Σ Kranktage</p><p className='text-2xl font-bold text-gray-800'>{totalDays.toFixed(1)}</p></div>
<div className='px-6 py-4'><p className='text-xs text-gray-500'>Σ Episoden</p><p className='text-2xl font-bold text-gray-800'>{totalEpisodes}</p></div>
<div className='px-6 py-4'><p className='text-xs text-gray-500'>AU überfällig</p><p className={`text-2xl font-bold ${overdue > 0 ? 'text-red-600' : 'text-gray-400'}`}>{overdue}</p></div>
</div>
)
})()}
{/* ── Time table (grouped by employee) ── */}
{timeReport && (() => {
const groups = groupBy(timeReport.rows)
return (
<div className='overflow-x-auto'>
<table className='w-full text-sm'>
<thead className='bg-gray-50 text-xs text-gray-500 uppercase tracking-wide sticky top-0'>
<tr>
<th className='px-4 py-3 text-left'>Datum</th>
{!filterUser && <th className='px-4 py-3 text-left'>Mitarbeiter</th>}
{isManager && <th className='px-4 py-3 text-left'>Abteilung</th>}
<th className='px-4 py-3 text-left'>Beginn</th>
<th className='px-4 py-3 text-left'>Ende</th>
<th className='px-4 py-3 text-right'>Pause</th>
<th className='px-4 py-3 text-right'>Netto</th>
<th className='px-4 py-3 text-left'>Status</th>
<th className='px-4 py-3 text-left'>Notiz</th>
</tr>
</thead>
<tbody>
{Array.from(groups.entries()).map(([uid, rows], gi) => {
const subtotal = rows.reduce((s, r) => s + (r.worked_hours ?? 0), 0)
return (
<>
{/* Employee header row */}
{!filterUser && (
<tr key={`hdr-${uid}`} className={`${gi > 0 ? 'border-t-2 border-gray-200' : ''} bg-gray-50`}>
<td colSpan={isManager ? 9 : 8} className='px-4 py-2'>
<span className='font-semibold text-gray-700 text-sm'>{rows[0].user_name}</span>
{rows[0].department && <span className='ml-2 text-xs text-gray-400'>{rows[0].department}</span>}
<span className='ml-3 text-xs font-medium text-blue-600'>{fmtH(subtotal)} gesamt</span>
</td>
</tr>
)}
{rows.map((row, i) => {
const bd = row.breakdown
const hasSonder = bd && (
bd.night_25_hours > 0 || bd.night_40_hours > 0 ||
bd.sunday_hours > 0 || bd.holiday_125_hours > 0 || bd.holiday_150_hours > 0
)
const colSpan = (isManager ? 2 : 1) + (!filterUser ? 1 : 0) + 6
return (
<>
<tr key={`${uid}-${i}`} className='border-t border-gray-50 hover:bg-gray-50/50'>
<td className='px-4 py-2 text-gray-700 whitespace-nowrap'>
{new Date(row.date + 'T00:00:00').toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit' })}
</td>
{!filterUser && <td className='px-4 py-2 text-gray-600 text-xs'>{row.user_name}</td>}
{isManager && <td className='px-4 py-2 text-gray-400 text-xs'>{row.department ?? ''}</td>}
<td className='px-4 py-2 text-gray-700'>{fmt(row.start_time)}</td>
<td className='px-4 py-2 text-gray-700'>{fmt(row.end_time)}</td>
<td className='px-4 py-2 text-right text-gray-600'>{row.break_minutes} min</td>
<td className='px-4 py-2 text-right font-medium text-gray-800'>{row.worked_hours != null ? `${row.worked_hours.toFixed(2)}h` : ''}</td>
<td className='px-4 py-2'>
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${STATUS_COLORS[row.status] ?? 'bg-gray-100 text-gray-600'}`}>
{STATUS_DE[row.status] ?? row.status}
</span>
</td>
<td className='px-4 py-2 text-gray-400 text-xs max-w-xs truncate'>{row.note ?? ''}</td>
</tr>
{hasSonder && bd && (
<tr key={`${uid}-${i}-bd`} className='bg-amber-50 border-t border-amber-100'>
<td colSpan={colSpan} className='px-6 py-1.5'>
<div className='flex flex-wrap gap-x-5 gap-y-0.5 text-xs text-amber-800'>
{bd.holiday_name && (
<span className='font-semibold text-red-700'>🗓 {bd.holiday_name}</span>
)}
{bd.normal_hours > 0 && (
<span>Normal: <strong>{bd.normal_hours.toFixed(2)}h</strong></span>
)}
{bd.night_25_hours > 0 && (
<span className='text-blue-700'>Nacht 25%: <strong>{bd.night_25_hours.toFixed(2)}h</strong></span>
)}
{bd.night_40_hours > 0 && (
<span className='text-blue-900 font-semibold'>Nacht 40%: <strong>{bd.night_40_hours.toFixed(2)}h</strong></span>
)}
{bd.sunday_hours > 0 && (
<span className='text-purple-700'>Sonntag 50%: <strong>{bd.sunday_hours.toFixed(2)}h</strong></span>
)}
{bd.holiday_125_hours > 0 && (
<span className='text-red-700'>Feiertag 125%: <strong>{bd.holiday_125_hours.toFixed(2)}h</strong></span>
)}
{bd.holiday_150_hours > 0 && (
<span className='text-red-700 font-semibold'>Feiertag 150%: <strong>{bd.holiday_150_hours.toFixed(2)}h</strong></span>
)}
</div>
</td>
</tr>
)}
</>
)
})}
</>
)
})}
</tbody>
<tfoot className='bg-gray-50 border-t-2 border-gray-200 font-semibold'>
<tr>
<td colSpan={isManager ? (filterUser ? 4 : 5) : (filterUser ? 3 : 4)} className='px-4 py-3 text-right text-gray-700'>Gesamt:</td>
<td className='px-4 py-3 text-right text-gray-900'>{fmtH(timeReport.total_hours)}</td>
<td colSpan={2} />
</tr>
</tfoot>
</table>
</div>
)
})()}
{/* ── Absence table (grouped by employee) ── */}
{absenceReport && (() => {
const groups = groupBy(absenceReport.rows)
return (
<div className='overflow-x-auto'>
<table className='w-full text-sm'>
<thead className='bg-gray-50 text-xs text-gray-500 uppercase tracking-wide'>
<tr>
{!filterUser && <th className='px-4 py-3 text-left'>Mitarbeiter</th>}
{isManager && <th className='px-4 py-3 text-left'>Abteilung</th>}
<th className='px-4 py-3 text-left'>Art</th>
<th className='px-4 py-3 text-left'>Von</th>
<th className='px-4 py-3 text-left'>Bis</th>
<th className='px-4 py-3 text-right'>Tage</th>
<th className='px-4 py-3 text-left'>Status</th>
<th className='px-4 py-3 text-left'>Notiz</th>
</tr>
</thead>
<tbody>
{Array.from(groups.entries()).map(([uid, rows], gi) => {
const subtotal = rows.reduce((s, r) => s + r.working_days, 0)
return (
<>
{!filterUser && (
<tr key={`hdr-${uid}`} className={`${gi > 0 ? 'border-t-2 border-gray-200' : ''} bg-gray-50`}>
<td colSpan={isManager ? 8 : 7} className='px-4 py-2'>
<span className='font-semibold text-gray-700 text-sm'>{rows[0].user_name}</span>
{rows[0].department && <span className='ml-2 text-xs text-gray-400'>{rows[0].department}</span>}
<span className='ml-3 text-xs font-medium text-orange-600'>{subtotal} Tage gesamt</span>
</td>
</tr>
)}
{rows.map((row, i) => (
<tr key={`${uid}-${i}`} className='border-t border-gray-50 hover:bg-gray-50/50'>
{!filterUser && <td className='px-4 py-2 text-gray-600 text-xs'>{row.user_name}</td>}
{isManager && <td className='px-4 py-2 text-gray-400 text-xs'>{row.department ?? ''}</td>}
<td className='px-4 py-2 font-medium text-gray-800'>{row.absence_type}</td>
<td className='px-4 py-2 text-gray-700 whitespace-nowrap'>{fmtDate(row.start_date)}</td>
<td className='px-4 py-2 text-gray-700 whitespace-nowrap'>{fmtDate(row.end_date)}</td>
<td className='px-4 py-2 text-right font-medium text-gray-800'>{row.working_days}</td>
<td className='px-4 py-2'>
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${STATUS_COLORS[row.status] ?? 'bg-gray-100 text-gray-600'}`}>
{STATUS_DE[row.status] ?? row.status}
</span>
</td>
<td className='px-4 py-2 text-gray-400 text-xs'>{row.note ?? ''}</td>
</tr>
))}
</>
)
})}
</tbody>
<tfoot className='bg-gray-50 border-t-2 border-gray-200 font-semibold'>
<tr>
<td colSpan={isManager ? (filterUser ? 4 : 5) : (filterUser ? 3 : 4)} className='px-4 py-3 text-right text-gray-700'>Gesamt Arbeitstage:</td>
<td className='px-4 py-3 text-right text-gray-900'>{absenceReport.total_days}</td>
<td colSpan={2} />
</tr>
</tfoot>
</table>
</div>
)
})()}
{/* ── Overtime table ── */}
{overtimeReport && (
<div>
{/* Einfach / Erweitert Toggle */}
<div className='px-6 py-3 border-b border-gray-100 flex items-center gap-2 no-print'>
<span className='text-xs text-gray-500 font-medium'>Ansicht:</span>
<div className='flex rounded-lg border border-gray-300 overflow-hidden'>
<button onClick={() => setOvertimeView('simple')}
className={`px-3 py-1 text-xs font-medium transition-colors ${overtimeView === 'simple' ? 'bg-blue-600 text-white' : 'text-gray-600 hover:bg-gray-50'}`}>
Einfach
</button>
<button onClick={() => setOvertimeView('detail')}
className={`px-3 py-1 text-xs font-medium transition-colors ${overtimeView === 'detail' ? 'bg-blue-600 text-white' : 'text-gray-600 hover:bg-gray-50'}`}>
Erweitert
</button>
</div>
</div>
{/* Einfache Ansicht */}
{overtimeView === 'simple' && (
<div className='overflow-x-auto'>
<table className='w-full text-sm'>
<thead className='bg-gray-50 text-xs text-gray-500 uppercase tracking-wide'>
<tr>
<th className='px-4 py-3 text-left'>Mitarbeiter</th>
{isManager && <th className='px-4 py-3 text-left'>Abteilung</th>}
<th className='px-4 py-3 text-right'>Soll</th>
<th className='px-4 py-3 text-right'>Ist</th>
<th className='px-4 py-3 text-right'>Überstunden</th>
</tr>
</thead>
<tbody className='divide-y divide-gray-50'>
{overtimeReport.rows.map((row, i) => (
<tr key={i} className='hover:bg-gray-50/50'>
<td className='px-4 py-3 font-medium text-gray-800'>{row.user_name}</td>
{isManager && <td className='px-4 py-3 text-gray-400 text-xs'>{row.department ?? ''}</td>}
<td className='px-4 py-3 text-right text-gray-600'>{fmtH(row.hours_expected)}</td>
<td className='px-4 py-3 text-right text-gray-800 font-medium'>{fmtH(row.hours_worked)}</td>
<td className='px-4 py-3 text-right'>
<span className={`font-bold ${row.overtime_hours > 0 ? 'text-green-600' : row.overtime_hours < 0 ? 'text-red-600' : 'text-gray-500'}`}>
{row.overtime_hours > 0 ? '+' : ''}{fmtH(row.overtime_hours)}
</span>
</td>
</tr>
))}
</tbody>
<tfoot className='bg-gray-50 border-t-2 border-gray-200 font-semibold'>
<tr>
<td colSpan={isManager ? 2 : 1} className='px-4 py-3'></td>
<td className='px-4 py-3 text-right text-gray-700'>{fmtH(overtimeReport.rows.reduce((s,r) => s + r.hours_expected, 0))}</td>
<td className='px-4 py-3 text-right text-gray-900'>{fmtH(overtimeReport.rows.reduce((s,r) => s + r.hours_worked, 0))}</td>
<td className={`px-4 py-3 text-right font-bold ${overtimeReport.total_overtime >= 0 ? 'text-green-700' : 'text-red-700'}`}>
{overtimeReport.total_overtime > 0 ? '+' : ''}{fmtH(overtimeReport.total_overtime)}
</td>
</tr>
</tfoot>
</table>
</div>
)}
{/* Erweiterte Ansicht */}
{overtimeView === 'detail' && overtimeDetail && (
<div className='divide-y divide-gray-100'>
{overtimeDetail.rows.map(row => {
const userOpen = expandedUsers.has(row.user_id)
const toggleUser = () => setExpandedUsers(prev => {
const next = new Set(prev)
next.has(row.user_id) ? next.delete(row.user_id) : next.add(row.user_id)
return next
})
return (
<div key={row.user_id}>
{/* Mitarbeiter-Zeile (klickbar) */}
<div
onClick={toggleUser}
className='flex items-center gap-3 px-4 py-3 cursor-pointer hover:bg-gray-50 transition-colors select-none'
>
<span className={`text-gray-400 transition-transform text-xs ${userOpen ? 'rotate-90' : ''}`}></span>
<span className='font-semibold text-gray-800 flex-1'>{row.user_name}</span>
{isManager && <span className='text-xs text-gray-400 w-28'>{row.department ?? ''}</span>}
{row.arbzg_violation_days > 0 && (
<span className='text-xs bg-red-100 text-red-700 px-2 py-0.5 rounded-full font-medium'>
{row.arbzg_violation_days}× ArbZG
</span>
)}
<span className='text-xs text-gray-500 w-20 text-right'>Soll {fmtH(row.hours_expected)}</span>
<span className='text-xs text-gray-700 font-medium w-20 text-right'>Ist {fmtH(row.hours_worked)}</span>
<span className={`text-sm font-bold w-24 text-right ${row.overtime_hours > 0 ? 'text-green-600' : row.overtime_hours < 0 ? 'text-red-600' : 'text-gray-500'}`}>
{row.overtime_hours > 0 ? '+' : ''}{fmtH(row.overtime_hours)}
</span>
</div>
{/* Sonderstunden-Zusammenfassung */}
{userOpen && row.special_hours_total && (
(() => {
const s = row.special_hours_total
const hasSonder = s.night_25_hours > 0 || s.night_40_hours > 0 || s.sunday_hours > 0 || s.holiday_125_hours > 0 || s.holiday_150_hours > 0
return hasSonder ? (
<div className='mx-4 mb-2 bg-amber-50 border border-amber-200 rounded-lg px-4 py-2 flex flex-wrap gap-x-5 gap-y-1 text-xs text-amber-800'>
<span className='font-semibold'>Sonderstunden gesamt:</span>
{s.normal_hours > 0 && <span>Normal: <strong>{s.normal_hours.toFixed(1)}h</strong></span>}
{s.night_25_hours > 0 && <span className='text-blue-700'>Nacht 25%: <strong>{s.night_25_hours.toFixed(1)}h</strong></span>}
{s.night_40_hours > 0 && <span className='text-blue-900 font-semibold'>Nacht 40%: <strong>{s.night_40_hours.toFixed(1)}h</strong></span>}
{s.sunday_hours > 0 && <span className='text-purple-700'>Sonntag 50%: <strong>{s.sunday_hours.toFixed(1)}h</strong></span>}
{s.holiday_125_hours > 0 && <span className='text-red-700'>Feiertag 125%: <strong>{s.holiday_125_hours.toFixed(1)}h</strong></span>}
{s.holiday_150_hours > 0 && <span className='text-red-700 font-semibold'>Feiertag 150%: <strong>{s.holiday_150_hours.toFixed(1)}h</strong></span>}
</div>
) : null
})()
)}
{/* Wochen */}
{userOpen && row.weeks.map(week => {
const weekKey = `${row.user_id}-${week.week_nr}`
const weekOpen = expandedWeeks.has(weekKey)
const toggleWeek = (e: React.MouseEvent) => {
e.stopPropagation()
setExpandedWeeks(prev => {
const next = new Set(prev)
next.has(weekKey) ? next.delete(weekKey) : next.add(weekKey)
return next
})
}
return (
<div key={week.week_nr} className='ml-8 border-l-2 border-gray-100'>
{/* Wochen-Zeile */}
<div
onClick={toggleWeek}
className='flex items-center gap-3 px-4 py-2 cursor-pointer hover:bg-blue-50/40 transition-colors select-none'
>
<span className={`text-gray-300 transition-transform text-xs ${weekOpen ? 'rotate-90' : ''}`}></span>
<span className='text-xs font-medium text-gray-600 flex-1'>
KW {week.week_nr} &nbsp;·&nbsp;
{new Date(week.week_start + 'T00:00:00').toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })} {new Date(week.week_end + 'T00:00:00').toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })}
</span>
<span className='text-xs text-gray-400 w-20 text-right'>Soll {fmtH(week.hours_expected)}</span>
<span className='text-xs text-gray-600 font-medium w-20 text-right'>Ist {fmtH(week.hours_worked)}</span>
<span className={`text-xs font-bold w-24 text-right ${week.overtime > 0 ? 'text-green-600' : week.overtime < 0 ? 'text-red-500' : 'text-gray-400'}`}>
{week.overtime > 0 ? '+' : ''}{fmtH(week.overtime)}
</span>
</div>
{/* Tages-Tabelle */}
{weekOpen && (
<div className='ml-4 mb-2 overflow-x-auto'>
<table className='w-full text-xs'>
<thead className='text-gray-400 uppercase tracking-wide'>
<tr>
<th className='px-3 py-1.5 text-left'>Tag</th>
<th className='px-3 py-1.5 text-left'>Datum</th>
<th className='px-3 py-1.5 text-right'>Beginn</th>
<th className='px-3 py-1.5 text-right'>Ende</th>
<th className='px-3 py-1.5 text-right'>Pause</th>
<th className='px-3 py-1.5 text-right'>Ist</th>
<th className='px-3 py-1.5 text-right'>Soll</th>
<th className='px-3 py-1.5 text-right'>Diff</th>
<th className='px-3 py-1.5 text-left'>Status</th>
</tr>
</thead>
<tbody>
{week.days.map((day, di) => {
const isWeekend = ['Sa', 'So'].includes(day.weekday)
const hasEntries = day.entries.length > 0
const multiEntry = day.entries.length > 1
const dateStr = new Date(day.date + 'T00:00:00').toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })
return (
<>
{/* Tageszeile (Summary wenn mehrere Einträge, sonst direkt Daten) */}
{(!hasEntries || multiEntry) && (
<tr key={`day-${di}`} className={`border-t-2 border-gray-200 ${isWeekend ? 'bg-gray-50/50' : 'bg-gray-50/30'}`}>
<td className={`px-3 py-1.5 font-semibold ${isWeekend ? 'text-gray-400' : 'text-gray-700'}`}>{day.weekday}</td>
<td className='px-3 py-1.5 text-gray-500'>{dateStr}</td>
<td className='px-3 py-1.5 text-right text-gray-400 italic text-xs' colSpan={3}>{multiEntry ? `${day.entries.length} Einträge` : ''}</td>
<td className='px-3 py-1.5 text-right font-semibold text-gray-700'>{hasEntries ? fmtH(day.hours_worked) : ''}</td>
<td className='px-3 py-1.5 text-right text-gray-400'>{day.hours_expected > 0 ? fmtH(day.hours_expected) : ''}</td>
<td className={`px-3 py-1.5 text-right font-bold ${day.overtime > 0 ? 'text-green-600' : day.overtime < 0 && day.hours_expected > 0 ? 'text-red-500' : 'text-gray-400'}`}>
{hasEntries || day.hours_expected > 0 ? (day.overtime > 0 ? '+' : '') + fmtH(day.overtime) : ''}
</td>
<td />
</tr>
)}
{/* Einzelne Einträge */}
{day.entries.map((entry, ei) => {
const hasWarnings = entry.arbzg_warnings.length > 0
const hasSonder = entry.breakdown && (
entry.breakdown.night_25_hours > 0 || entry.breakdown.night_40_hours > 0 ||
entry.breakdown.sunday_hours > 0 || entry.breakdown.holiday_125_hours > 0 || entry.breakdown.holiday_150_hours > 0
)
return (
<>
<tr key={`${di}-${ei}`} className={`border-t border-gray-50 ${isWeekend ? 'bg-gray-50/40' : ''} ${hasWarnings ? 'bg-red-50/20' : ''} ${multiEntry ? 'pl-4' : ''}`}>
<td className={`px-3 py-1.5 font-medium ${isWeekend ? 'text-gray-400' : 'text-gray-600'}`}>
{!multiEntry ? day.weekday : <span className='text-gray-300 pl-2'></span>}
</td>
<td className='px-3 py-1.5 text-gray-500'>{!multiEntry ? dateStr : ''}</td>
<td className='px-3 py-1.5 text-right text-gray-600'>{fmt(entry.start_time)}</td>
<td className='px-3 py-1.5 text-right text-gray-600'>{fmt(entry.end_time)}</td>
<td className='px-3 py-1.5 text-right text-gray-500'>{entry.break_minutes > 0 ? `${entry.break_minutes} min` : ''}</td>
<td className='px-3 py-1.5 text-right font-medium text-gray-700'>{fmtH(entry.hours_worked)}</td>
{!multiEntry ? <>
<td className='px-3 py-1.5 text-right text-gray-400'>{day.hours_expected > 0 ? fmtH(day.hours_expected) : ''}</td>
<td className={`px-3 py-1.5 text-right font-semibold ${day.overtime > 0 ? 'text-green-600' : day.overtime < 0 && day.hours_expected > 0 ? 'text-red-500' : 'text-gray-400'}`}>
{(day.overtime > 0 ? '+' : '') + fmtH(day.overtime)}
</td>
</> : <><td /><td /></>}
<td className='px-3 py-1.5'>
<span className={`px-1.5 py-0.5 rounded-full text-xs font-medium ${STATUS_COLORS[entry.status] ?? 'bg-gray-100 text-gray-600'}`}>
{STATUS_DE[entry.status] ?? entry.status}
</span>
</td>
</tr>
{(hasWarnings || hasSonder) && (
<tr key={`${di}-${ei}-meta`} className='bg-amber-50/60'>
<td colSpan={9} className='px-3 py-1 text-xs'>
{hasWarnings && (
<div className='flex flex-wrap gap-2 text-red-600 font-medium'>
{entry.arbzg_warnings.map((w, wi) => <span key={wi}> {w}</span>)}
</div>
)}
{hasSonder && entry.breakdown && (
<div className='flex flex-wrap gap-x-4 text-amber-700 mt-0.5'>
{entry.breakdown.night_25_hours > 0 && <span>Nacht 25%: {entry.breakdown.night_25_hours.toFixed(2)}h</span>}
{entry.breakdown.night_40_hours > 0 && <span>Nacht 40%: {entry.breakdown.night_40_hours.toFixed(2)}h</span>}
{entry.breakdown.sunday_hours > 0 && <span>Sonntag: {entry.breakdown.sunday_hours.toFixed(2)}h</span>}
{entry.breakdown.holiday_125_hours > 0 && <span>Feiertag 125%: {entry.breakdown.holiday_125_hours.toFixed(2)}h</span>}
{entry.breakdown.holiday_150_hours > 0 && <span>Feiertag 150%: {entry.breakdown.holiday_150_hours.toFixed(2)}h</span>}
{entry.breakdown.holiday_name && <span className='text-red-600 font-semibold'>{entry.breakdown.holiday_name}</span>}
</div>
)}
</td>
</tr>
)}
</>
)
})}
</>
)
})}
</tbody>
</table>
</div>
)}
</div>
)
})}
</div>
)
})}
{/* Footer */}
<div className='flex items-center px-4 py-3 bg-gray-50 font-semibold text-sm'>
<span className='flex-1 text-gray-700'>Gesamt</span>
{isManager && <span className='w-28'></span>}
<span className='text-gray-600 w-20 text-right'>{fmtH(overtimeDetail.rows.reduce((s,r) => s + r.hours_expected, 0))}</span>
<span className='text-gray-900 w-20 text-right'>{fmtH(overtimeDetail.rows.reduce((s,r) => s + r.hours_worked, 0))}</span>
<span className={`font-bold w-24 text-right ${overtimeDetail.total_overtime >= 0 ? 'text-green-700' : 'text-red-700'}`}>
{overtimeDetail.total_overtime > 0 ? '+' : ''}{fmtH(overtimeDetail.total_overtime)}
</span>
</div>
</div>
)}
</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'>
{sickStats.length === 0 ? (
<p className='text-center text-gray-400 py-12 text-sm'>Keine Krankmeldungen im Zeitraum.</p>
) : (
<table className='w-full text-sm'>
<thead className='bg-gray-50 text-xs text-gray-500 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-right'>Episoden</th>
<th className='px-4 py-3 text-right'>Tage</th>
<th className='px-4 py-3 text-right'>Bradford</th>
<th className='px-4 py-3 text-right'>AU offen</th>
</tr>
</thead>
<tbody className='divide-y divide-gray-100'>
{[...sickStats].sort((a, b) => b.bradford_factor - a.bradford_factor).map(r => (
<tr key={r.user_id} className='hover:bg-gray-50'>
<td className='px-4 py-3 font-medium text-gray-800'>{r.user_name}</td>
<td className='px-4 py-3 text-gray-500'>{r.personnel_number ?? ''}</td>
<td className='px-4 py-3 text-right text-gray-700'>{r.episodes}</td>
<td className='px-4 py-3 text-right text-gray-700'>{r.total_days.toFixed(1)}</td>
<td className={`px-4 py-3 text-right font-semibold ${r.bradford_factor >= 200 ? 'text-red-600' : r.bradford_factor >= 50 ? 'text-orange-600' : 'text-gray-700'}`}>
{r.bradford_factor.toFixed(1)}
</td>
<td className={`px-4 py-3 text-right ${r.certificates_overdue > 0 ? 'text-red-600 font-bold' : 'text-gray-400'}`}>
{r.certificates_overdue}
</td>
</tr>
))}
</tbody>
</table>
)}
<p className='text-xs text-gray-400 px-4 py-3 border-t border-gray-100'>
Bradford-Faktor: Episoden² × Tage. Stufen: ab 50 = Aufmerksamkeit, ab 200 = Handlungsbedarf.
</p>
</div>
)}
</div>
)}
</div>
</Layout>
)
}