d60349df67
- 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>
939 lines
61 KiB
TypeScript
939 lines
61 KiB
TypeScript
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} ·
|
||
{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>
|
||
)
|
||
}
|