Initial commit – TimeMaster Zeiterfassung & HR-Tool

Stand: agent-06 (Audit-Log), agent-05 (Krankmeldung), agent-07 Phase 1 (Personalnummer),
Busylight-Pull-Integration, TOTP/2FA, Abwesenheiten, Zeiterfassung, Kiosk-Grundgerüst.
Migrations 0001–0023 deployed auf 192.168.1.137 + .164.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-05-23 20:03:27 +02:00
commit 1fedd683e0
178 changed files with 29896 additions and 0 deletions
+808
View File
@@ -0,0 +1,808 @@
import { useEffect, useState } from 'react'
import { api } from '../api/client'
import { Spinner } from '../components/Spinner'
import { Layout } from '../components/Layout'
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'
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 [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 {
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)
}
} catch (e: unknown) { setError(e instanceof Error ? e.message : 'Fehler') }
finally { setLoading(false) }
}
const download = async (format: 'csv' | 'xlsx' | 'pdf') => {
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)
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']] 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'>
{/* 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>
{/* Employee filter (manager only) */}
{isManager && colleagues.length > 0 && (
<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' : 'Krankmeldungen'}
</h2>
<p className='text-xs text-gray-400 mt-0.5'>
{type === 'sick' ? 'Rolling 12 Monate ab heute' : `${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' && (<>
<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>
)}
{/* ── 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>
)
}