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 = { open: 'Offen', pending: 'Prüfung', approved: 'Genehmigt', rejected: 'Abgelehnt', auto: 'Auto', cancelled: 'Storniert', } const STATUS_COLORS: Record = { 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(rows: T[]): Map { const m = new Map() 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(null) const [colleagues, setColleagues] = useState([]) const [type, setType] = useState('time') const [dateFrom, setDateFrom] = useState(monthRange()[0]) const [dateTo, setDateTo] = useState(monthRange()[1]) const [filterUser, setFilterUser] = useState('') const [timeReport, setTimeReport] = useState(null) const [absenceReport, setAbsenceReport] = useState(null) const [overtimeReport, setOvertimeReport] = useState(null) const [overtimeDetail, setOvertimeDetail] = useState(null) const [overtimeView, setOvertimeView] = useState<'simple' | 'detail'>('simple') const [expandedUsers, setExpandedUsers] = useState>(new Set()) const [expandedWeeks, setExpandedWeeks] = useState>(new Set()) const [sickStats, setSickStats] = useState(null) const [payrollReport, setPayrollReport] = useState(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('/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(`/reports/time${p}`)) setAbsenceReport(null); setOvertimeReport(null); setSickStats(null) } else if (type === 'absences') { setAbsenceReport(await api.get(`/reports/absences${p}`)) setTimeReport(null); setOvertimeReport(null); setSickStats(null) } else if (type === 'sick') { const params = filterUser ? `?user_id=${filterUser}` : '' const stats = await api.get(`/absences/sick-stats${params}`) setSickStats(stats) setTimeReport(null); setAbsenceReport(null); setOvertimeReport(null) } else if (type === 'special') { const report = await api.get( `/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(`/reports/overtime${p}`), api.get(`/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 (

Berichte

{/* ── Controls ───────────────────────────────────────────────────── */}
{/* Report type */}
{([['time','Zeiterfassung'],['absences','Abwesenheiten'],['overtime','Überstunden'],['sick','Krankmeldungen'],['special','Sondervertretungen']] as [ReportType,string][]).map(([v,l]) => ( ))}
{type !== 'special' && ( <> {/* Quick-select */}

Schnellauswahl

{[['Dieser Monat', monthRange()],['Letzter Monat', monthRange(-1)],['Quartal', quarterRange()]] .map(([l, [f, t]]) => ( ))}
{/* Date range */}

Von

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' />

Bis

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' />
)} {type === 'special' && ( <>

Jahr

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' />

Monat

)} {/* Employee filter (manager only) – nicht für special */} {isManager && colleagues.length > 0 && type !== 'special' && (

Mitarbeiter

)}
{error &&
{error}
} {/* ── Results ────────────────────────────────────────────────────── */} {hasResult && (
{/* Header */}

{type === 'time' ? 'Zeiterfassung' : type === 'absences' ? 'Abwesenheiten' : type === 'overtime' ? 'Überstunden' : type === 'special' ? 'Sondervertretungen' : 'Krankmeldungen'}

{type === 'sick' ? 'Rolling 12 Monate ab heute' : type === 'special' ? `${specialYear}, Monat ${specialMonth}` : `${fmtDate(dateFrom)} – ${fmtDate(dateTo)}`}

{type !== 'sick' && type !== 'special' && (<> )}
{/* ── KPI row ── */} {timeReport && (

Einträge

{timeReport.total_rows}

Gesamt-Stunden

{fmtH(timeReport.total_hours)}

Mitarbeiter

{new Set(timeReport.rows.map(r => r.user_id)).size}

)} {absenceReport && (

Anträge

{absenceReport.total_rows}

Gesamt-Arbeitstage

{absenceReport.total_days}

Mitarbeiter betroffen

{new Set(absenceReport.rows.map(r => r.user_id)).size}

)} {overtimeReport && (

Mitarbeiter

{overtimeReport.total_employees}

Gesamt-Überstunden

= 0 ? 'text-green-600' : 'text-red-600'}`}>{fmtH(overtimeReport.total_overtime)}

Periode

{fmtDate(overtimeReport.date_from)} – {fmtDate(overtimeReport.date_to)}

)} {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 (

⌀ Bradford-Index

{avgBradford.toFixed(1)}

Σ Kranktage

{totalDays.toFixed(1)}

Σ Episoden

{totalEpisodes}

AU überfällig

0 ? 'text-red-600' : 'text-gray-400'}`}>{overdue}

) })()} {/* ── Time table (grouped by employee) ── */} {timeReport && (() => { const groups = groupBy(timeReport.rows) return (
{!filterUser && } {isManager && } {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 && ( 0 ? 'border-t-2 border-gray-200' : ''} bg-gray-50`}> )} {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 ( <> {!filterUser && } {isManager && } {hasSonder && bd && ( )} ) })} ) })}
DatumMitarbeiterAbteilungBeginn Ende Pause Netto Status Notiz
{rows[0].user_name} {rows[0].department && {rows[0].department}} {fmtH(subtotal)} gesamt
{new Date(row.date + 'T00:00:00').toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit' })} {row.user_name}{row.department ?? '–'}{fmt(row.start_time)} {fmt(row.end_time)} {row.break_minutes} min {row.worked_hours != null ? `${row.worked_hours.toFixed(2)}h` : '–'} {STATUS_DE[row.status] ?? row.status} {row.note ?? ''}
{bd.holiday_name && ( 🗓 {bd.holiday_name} )} {bd.normal_hours > 0 && ( Normal: {bd.normal_hours.toFixed(2)}h )} {bd.night_25_hours > 0 && ( Nacht 25%: {bd.night_25_hours.toFixed(2)}h )} {bd.night_40_hours > 0 && ( Nacht 40%: {bd.night_40_hours.toFixed(2)}h )} {bd.sunday_hours > 0 && ( Sonntag 50%: {bd.sunday_hours.toFixed(2)}h )} {bd.holiday_125_hours > 0 && ( Feiertag 125%: {bd.holiday_125_hours.toFixed(2)}h )} {bd.holiday_150_hours > 0 && ( Feiertag 150%: {bd.holiday_150_hours.toFixed(2)}h )}
Gesamt: {fmtH(timeReport.total_hours)}
) })()} {/* ── Absence table (grouped by employee) ── */} {absenceReport && (() => { const groups = groupBy(absenceReport.rows) return (
{!filterUser && } {isManager && } {Array.from(groups.entries()).map(([uid, rows], gi) => { const subtotal = rows.reduce((s, r) => s + r.working_days, 0) return ( <> {!filterUser && ( 0 ? 'border-t-2 border-gray-200' : ''} bg-gray-50`}> )} {rows.map((row, i) => ( {!filterUser && } {isManager && } ))} ) })}
MitarbeiterAbteilungArt Von Bis Tage Status Notiz
{rows[0].user_name} {rows[0].department && {rows[0].department}} {subtotal} Tage gesamt
{row.user_name}{row.department ?? '–'}{row.absence_type} {fmtDate(row.start_date)} {fmtDate(row.end_date)} {row.working_days} {STATUS_DE[row.status] ?? row.status} {row.note ?? ''}
Gesamt Arbeitstage: {absenceReport.total_days}
) })()} {/* ── Overtime table ── */} {overtimeReport && (
{/* Einfach / Erweitert Toggle */}
Ansicht:
{/* Einfache Ansicht */} {overtimeView === 'simple' && (
{isManager && } {overtimeReport.rows.map((row, i) => ( {isManager && } ))}
MitarbeiterAbteilungSoll Ist Überstunden
{row.user_name}{row.department ?? '–'}{fmtH(row.hours_expected)} {fmtH(row.hours_worked)} 0 ? 'text-green-600' : row.overtime_hours < 0 ? 'text-red-600' : 'text-gray-500'}`}> {row.overtime_hours > 0 ? '+' : ''}{fmtH(row.overtime_hours)}
{fmtH(overtimeReport.rows.reduce((s,r) => s + r.hours_expected, 0))} {fmtH(overtimeReport.rows.reduce((s,r) => s + r.hours_worked, 0))} = 0 ? 'text-green-700' : 'text-red-700'}`}> {overtimeReport.total_overtime > 0 ? '+' : ''}{fmtH(overtimeReport.total_overtime)}
)} {/* Erweiterte Ansicht */} {overtimeView === 'detail' && overtimeDetail && (
{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 (
{/* Mitarbeiter-Zeile (klickbar) */}
{row.user_name} {isManager && {row.department ?? '–'}} {row.arbzg_violation_days > 0 && ( {row.arbzg_violation_days}× ArbZG )} Soll {fmtH(row.hours_expected)} Ist {fmtH(row.hours_worked)} 0 ? 'text-green-600' : row.overtime_hours < 0 ? 'text-red-600' : 'text-gray-500'}`}> {row.overtime_hours > 0 ? '+' : ''}{fmtH(row.overtime_hours)}
{/* 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 ? (
Sonderstunden gesamt: {s.normal_hours > 0 && Normal: {s.normal_hours.toFixed(1)}h} {s.night_25_hours > 0 && Nacht 25%: {s.night_25_hours.toFixed(1)}h} {s.night_40_hours > 0 && Nacht 40%: {s.night_40_hours.toFixed(1)}h} {s.sunday_hours > 0 && Sonntag 50%: {s.sunday_hours.toFixed(1)}h} {s.holiday_125_hours > 0 && Feiertag 125%: {s.holiday_125_hours.toFixed(1)}h} {s.holiday_150_hours > 0 && Feiertag 150%: {s.holiday_150_hours.toFixed(1)}h}
) : 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 (
{/* Wochen-Zeile */}
KW {week.week_nr}  ·  {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' })} Soll {fmtH(week.hours_expected)} Ist {fmtH(week.hours_worked)} 0 ? 'text-green-600' : week.overtime < 0 ? 'text-red-500' : 'text-gray-400'}`}> {week.overtime > 0 ? '+' : ''}{fmtH(week.overtime)}
{/* Tages-Tabelle */} {weekOpen && (
{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) && ( )} {/* 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 ( <> {!multiEntry ? <> : <> {(hasWarnings || hasSonder) && ( )} ) })} ) })}
Tag Datum Beginn Ende Pause Ist Soll Diff Status
{day.weekday} {dateStr} {multiEntry ? `${day.entries.length} Einträge` : '–'} {hasEntries ? fmtH(day.hours_worked) : '–'} {day.hours_expected > 0 ? fmtH(day.hours_expected) : '–'} 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) : '–'}
{!multiEntry ? day.weekday : } {!multiEntry ? dateStr : ''} {fmt(entry.start_time)} {fmt(entry.end_time)} {entry.break_minutes > 0 ? `${entry.break_minutes} min` : '–'} {fmtH(entry.hours_worked)}{day.hours_expected > 0 ? fmtH(day.hours_expected) : '–'} 0 ? 'text-green-600' : day.overtime < 0 && day.hours_expected > 0 ? 'text-red-500' : 'text-gray-400'}`}> {(day.overtime > 0 ? '+' : '') + fmtH(day.overtime)} } {STATUS_DE[entry.status] ?? entry.status}
{hasWarnings && (
{entry.arbzg_warnings.map((w, wi) => ⚠ {w})}
)} {hasSonder && entry.breakdown && (
{entry.breakdown.night_25_hours > 0 && Nacht 25%: {entry.breakdown.night_25_hours.toFixed(2)}h} {entry.breakdown.night_40_hours > 0 && Nacht 40%: {entry.breakdown.night_40_hours.toFixed(2)}h} {entry.breakdown.sunday_hours > 0 && Sonntag: {entry.breakdown.sunday_hours.toFixed(2)}h} {entry.breakdown.holiday_125_hours > 0 && Feiertag 125%: {entry.breakdown.holiday_125_hours.toFixed(2)}h} {entry.breakdown.holiday_150_hours > 0 && Feiertag 150%: {entry.breakdown.holiday_150_hours.toFixed(2)}h} {entry.breakdown.holiday_name && {entry.breakdown.holiday_name}}
)}
)}
) })}
) })} {/* Footer */}
Gesamt {isManager && } {fmtH(overtimeDetail.rows.reduce((s,r) => s + r.hours_expected, 0))} {fmtH(overtimeDetail.rows.reduce((s,r) => s + r.hours_worked, 0))} = 0 ? 'text-green-700' : 'text-red-700'}`}> {overtimeDetail.total_overtime > 0 ? '+' : ''}{fmtH(overtimeDetail.total_overtime)}
)}
)} {/* ── Sondervertretungen Payroll-Report ── */} {payrollReport && (
{payrollReport.rows.length === 0 ? (

Keine Sondervertretungs-Zuweisungen in {payrollReport.month}/{payrollReport.year}.

) : ( <>
{payrollReport.rows.map(row => ( row.assignments.map((a, i) => ( {i === 0 && ( <> )} )) ))}
Mitarbeiter Pers.-Nr. Bezeichnung Zeitraum Faktor Normal-Std. Faktor-Std. Extra-Std.
{row.user_name} {row.assignments.length > 1 && (
Gesamt: +{row.total_extra_hours.toFixed(1)}h
)}
{row.personnel_number ?? '–'} {a.label ?? '–'} {a.date_from} – {a.date_to} ×{Number(a.factor).toFixed(2)} {a.normal_hours.toFixed(1)} {a.factor_hours.toFixed(1)} +{a.extra_hours.toFixed(1)}
Gesamt {payrollReport.rows.reduce((s, r) => s + r.total_normal_hours, 0).toFixed(1)} {payrollReport.rows.reduce((s, r) => s + r.total_factor_hours, 0).toFixed(1)} +{payrollReport.rows.reduce((s, r) => s + r.total_extra_hours, 0).toFixed(1)}
)}

Sondervertretungs-Faktor: Stunden × Faktor (z.B. ×1,5 für Schichtleiter-Vertretung). Extra-Stunden = Differenz zur Normarbeitszeit.

)} {/* ── Sick stats table ── */} {sickStats && (
{sickStats.length === 0 ? (

Keine Krankmeldungen im Zeitraum.

) : ( {[...sickStats].sort((a, b) => b.bradford_factor - a.bradford_factor).map(r => ( ))}
Mitarbeiter Pers.-Nr. Episoden Tage Bradford AU offen
{r.user_name} {r.personnel_number ?? '–'} {r.episodes} {r.total_days.toFixed(1)} = 200 ? 'text-red-600' : r.bradford_factor >= 50 ? 'text-orange-600' : 'text-gray-700'}`}> {r.bradford_factor.toFixed(1)} 0 ? 'text-red-600 font-bold' : 'text-gray-400'}`}> {r.certificates_overdue}
)}

Bradford-Faktor: Episoden² × Tage. Stufen: ab 50 = Aufmerksamkeit, ab 200 = Handlungsbedarf.

)}
)}
) }