feat: Sondervertretungs-Faktoren (special_assignments)

- Neues Model SpecialAssignment mit AssignmentMode (fza|payroll|both)
- CRUD-Endpunkte unter /users/{id}/special-assignments
- Payroll-Report: GET /reports/special-assignments/payroll?year=&month=
- Migration 0029: special_assignments Tabelle + btree_gist Overlap-Constraint
- _recalculate_overtime_balance berücksichtigt FZA-Faktoren
- Frontend: Sondervertretungs-Zeiträume im UsersPage Edit-Modal
- Frontend: ReportsPage neuer Tab 'Sondervertretungen' mit Payroll-Tabelle + CSV-Export

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 00:55:47 +02:00
parent 1170e59e49
commit d60349df67
12 changed files with 837 additions and 39 deletions
+162 -32
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { api } from '../api/client'
import { Spinner } from '../components/Spinner'
import { Layout } from '../components/Layout'
import type { PayrollAssignmentReport } from '../types/specialAssignment'
interface UserOut {
id: string; first_name: string; last_name: string; email: string; role: string
@@ -68,7 +69,7 @@ interface SickStatsRow {
}
// ── Helpers ─────────────────────────────────────────────────────────────────
type ReportType = 'time' | 'absences' | 'overtime' | 'sick'
type ReportType = 'time' | 'absences' | 'overtime' | 'sick' | 'special'
const MANAGER_ROLES = ['COMPANY_ADMIN', 'SUPER_ADMIN', 'HR', 'MANAGER']
const STATUS_DE: Record<string, string> = {
@@ -132,6 +133,10 @@ export function ReportsPage() {
const [expandedUsers, setExpandedUsers] = useState<Set<string>>(new Set())
const [expandedWeeks, setExpandedWeeks] = useState<Set<string>>(new Set())
const [sickStats, setSickStats] = useState<SickStatsRow[] | null>(null)
const [payrollReport, setPayrollReport] = useState<PayrollAssignmentReport | null>(null)
// für Sondervertretungs-Tab: Jahr/Monat-Auswahl
const [specialYear, setSpecialYear] = useState(new Date().getFullYear())
const [specialMonth, setSpecialMonth] = useState(new Date().getMonth() + 1)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
@@ -162,6 +167,12 @@ export function ReportsPage() {
const stats = await api.get<SickStatsRow[]>(`/absences/sick-stats${params}`)
setSickStats(stats)
setTimeReport(null); setAbsenceReport(null); setOvertimeReport(null)
} else if (type === 'special') {
const report = await api.get<PayrollAssignmentReport>(
`/reports/special-assignments/payroll?year=${specialYear}&month=${specialMonth}`
)
setPayrollReport(report)
setTimeReport(null); setAbsenceReport(null); setOvertimeReport(null); setSickStats(null)
} else {
const [simple, detail] = await Promise.all([
api.get<OvertimeReport>(`/reports/overtime${p}`),
@@ -171,13 +182,14 @@ export function ReportsPage() {
setOvertimeDetail(detail)
setExpandedUsers(new Set())
setExpandedWeeks(new Set())
setTimeReport(null); setAbsenceReport(null); setSickStats(null)
setTimeReport(null); setAbsenceReport(null); setSickStats(null); setPayrollReport(null)
}
} catch (e: unknown) { setError(e instanceof Error ? e.message : 'Fehler') }
finally { setLoading(false) }
}
const download = async (format: 'csv' | 'xlsx' | 'pdf') => {
if (type === 'special' || type === 'sick') return // eigener Export in der Tabelle
const p = `?date_from=${dateFrom}&date_to=${dateTo}${filterUser ? `&user_id=${filterUser}` : ''}&format=${format}`
const ep = type === 'time' ? `/reports/time/export${p}` : type === 'absences' ? `/reports/absences/export${p}` : `/reports/overtime/export${p}`
const token = localStorage.getItem('access_token')
@@ -190,7 +202,7 @@ export function ReportsPage() {
const setQuick = (from: string, to: string) => { setDateFrom(from); setDateTo(to) }
const hasResult = !!(timeReport || absenceReport || overtimeReport || sickStats)
const hasResult = !!(timeReport || absenceReport || overtimeReport || sickStats || payrollReport)
return (
<Layout userRole={user?.role ?? ''} userName={user ? `${user.first_name} ${user.last_name}` : ''}>
@@ -206,7 +218,7 @@ export function ReportsPage() {
{/* Report type */}
<div className='flex rounded-lg border border-gray-300 overflow-hidden w-fit'>
{([['time','Zeiterfassung'],['absences','Abwesenheiten'],['overtime','Überstunden'],['sick','Krankmeldungen']] as [ReportType,string][]).map(([v,l]) => (
{([['time','Zeiterfassung'],['absences','Abwesenheiten'],['overtime','Überstunden'],['sick','Krankmeldungen'],['special','Sondervertretungen']] as [ReportType,string][]).map(([v,l]) => (
<button key={v} onClick={() => setType(v)}
className={`px-4 py-2 text-sm font-medium transition-colors ${type===v ? 'bg-blue-600 text-white' : 'text-gray-600 hover:bg-gray-50'}`}>
{l}
@@ -215,33 +227,56 @@ export function ReportsPage() {
</div>
<div className='flex flex-wrap gap-3 items-end'>
{/* Quick-select */}
<div>
<p className='text-xs font-medium text-gray-500 mb-1'>Schnellauswahl</p>
<div className='flex gap-1.5'>
{[['Dieser Monat', monthRange()],['Letzter Monat', monthRange(-1)],['Quartal', quarterRange()]] .map(([l, [f, t]]) => (
<button key={l as string} onClick={() => setQuick(f as string, t as string)}
className='px-3 py-1.5 text-xs border border-gray-300 rounded-lg text-gray-600 hover:bg-gray-50'>
{l as string}
</button>
))}
</div>
</div>
{type !== 'special' && (
<>
{/* Quick-select */}
<div>
<p className='text-xs font-medium text-gray-500 mb-1'>Schnellauswahl</p>
<div className='flex gap-1.5'>
{[['Dieser Monat', monthRange()],['Letzter Monat', monthRange(-1)],['Quartal', quarterRange()]] .map(([l, [f, t]]) => (
<button key={l as string} onClick={() => setQuick(f as string, t as string)}
className='px-3 py-1.5 text-xs border border-gray-300 rounded-lg text-gray-600 hover:bg-gray-50'>
{l as string}
</button>
))}
</div>
</div>
{/* Date range */}
<div>
<p className='text-xs font-medium text-gray-500 mb-1'>Von</p>
<input type='date' value={dateFrom} onChange={e => setDateFrom(e.target.value)}
className='border border-gray-300 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500' />
</div>
<div>
<p className='text-xs font-medium text-gray-500 mb-1'>Bis</p>
<input type='date' value={dateTo} min={dateFrom} onChange={e => setDateTo(e.target.value)}
className='border border-gray-300 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500' />
</div>
</>
)}
{/* Date range */}
<div>
<p className='text-xs font-medium text-gray-500 mb-1'>Von</p>
<input type='date' value={dateFrom} onChange={e => setDateFrom(e.target.value)}
className='border border-gray-300 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500' />
</div>
<div>
<p className='text-xs font-medium text-gray-500 mb-1'>Bis</p>
<input type='date' value={dateTo} min={dateFrom} onChange={e => setDateTo(e.target.value)}
className='border border-gray-300 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500' />
</div>
{type === 'special' && (
<>
<div>
<p className='text-xs font-medium text-gray-500 mb-1'>Jahr</p>
<input type='number' value={specialYear} min={2020} max={2100}
onChange={e => setSpecialYear(parseInt(e.target.value))}
className='border border-gray-300 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 w-28' />
</div>
<div>
<p className='text-xs font-medium text-gray-500 mb-1'>Monat</p>
<select value={specialMonth} onChange={e => setSpecialMonth(parseInt(e.target.value))}
className='border border-gray-300 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'>
{['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember'].map((m, i) => (
<option key={i+1} value={i+1}>{m}</option>
))}
</select>
</div>
</>
)}
{/* Employee filter (manager only) */}
{isManager && colleagues.length > 0 && (
{/* Employee filter (manager only) nicht für special */}
{isManager && colleagues.length > 0 && type !== 'special' && (
<div>
<p className='text-xs font-medium text-gray-500 mb-1'>Mitarbeiter</p>
<select value={filterUser} onChange={e => setFilterUser(e.target.value)}
@@ -269,15 +304,15 @@ export function ReportsPage() {
<div className='px-6 py-4 border-b border-gray-100 flex items-center justify-between flex-wrap gap-3'>
<div>
<h2 className='font-semibold text-gray-800'>
{type === 'time' ? 'Zeiterfassung' : type === 'absences' ? 'Abwesenheiten' : type === 'overtime' ? 'Überstunden' : 'Krankmeldungen'}
{type === 'time' ? 'Zeiterfassung' : type === 'absences' ? 'Abwesenheiten' : type === 'overtime' ? 'Überstunden' : type === 'special' ? 'Sondervertretungen' : 'Krankmeldungen'}
</h2>
<p className='text-xs text-gray-400 mt-0.5'>
{type === 'sick' ? 'Rolling 12 Monate ab heute' : `${fmtDate(dateFrom)} ${fmtDate(dateTo)}`}
{type === 'sick' ? 'Rolling 12 Monate ab heute' : type === 'special' ? `${specialYear}, Monat ${specialMonth}` : `${fmtDate(dateFrom)} ${fmtDate(dateTo)}`}
</p>
</div>
<div className='flex gap-2 no-print'>
<button onClick={() => window.print()} className='text-xs px-3 py-1.5 border border-gray-300 rounded-lg text-gray-600 hover:bg-gray-50'>Drucken</button>
{type !== 'sick' && (<>
{type !== 'sick' && type !== 'special' && (<>
<button onClick={() => download('csv')} className='text-xs px-3 py-1.5 border border-gray-300 rounded-lg text-gray-600 hover:bg-gray-50'>CSV</button>
<button onClick={() => download('xlsx')} className='text-xs px-3 py-1.5 bg-green-600 text-white rounded-lg hover:bg-green-700'>Excel</button>
<button onClick={() => download('pdf')} className='text-xs px-3 py-1.5 bg-red-600 text-white rounded-lg hover:bg-red-700'>PDF</button>
@@ -759,6 +794,101 @@ export function ReportsPage() {
</div>
)}
{/* ── Sondervertretungen Payroll-Report ── */}
{payrollReport && (
<div className='overflow-x-auto'>
{payrollReport.rows.length === 0 ? (
<p className='text-center text-gray-400 py-12 text-sm'>
Keine Sondervertretungs-Zuweisungen in {payrollReport.month}/{payrollReport.year}.
</p>
) : (
<>
<div className='flex justify-end px-4 py-2 no-print'>
<button
onClick={() => {
const rows = payrollReport.rows.flatMap(r =>
r.assignments.map(a => [
r.user_name, r.personnel_number ?? '',
a.label ?? '', a.date_from, a.date_to,
a.factor, a.normal_hours, a.factor_hours, a.extra_hours
])
)
const header = 'Mitarbeiter,Pers-Nr,Bezeichnung,Von,Bis,Faktor,Normal-Std,Faktor-Std,Extra-Std'
const csv = [header, ...rows.map(r => r.join(','))].join('\n')
const blob = new Blob([csv], { type: 'text/csv' })
const a = document.createElement('a'); a.href = URL.createObjectURL(blob)
a.download = `sondervertretung_${payrollReport.year}_${String(payrollReport.month).padStart(2,'0')}.csv`; a.click()
}}
className='px-3 py-1.5 text-xs border border-gray-300 rounded-lg text-gray-600 hover:bg-gray-50'
>
CSV Export
</button>
</div>
<table className='w-full text-sm'>
<thead className='bg-amber-50 text-xs text-amber-700 uppercase tracking-wide'>
<tr>
<th className='px-4 py-3 text-left'>Mitarbeiter</th>
<th className='px-4 py-3 text-left'>Pers.-Nr.</th>
<th className='px-4 py-3 text-left'>Bezeichnung</th>
<th className='px-4 py-3 text-left'>Zeitraum</th>
<th className='px-4 py-3 text-right'>Faktor</th>
<th className='px-4 py-3 text-right'>Normal-Std.</th>
<th className='px-4 py-3 text-right'>Faktor-Std.</th>
<th className='px-4 py-3 text-right font-bold'>Extra-Std.</th>
</tr>
</thead>
<tbody className='divide-y divide-gray-100'>
{payrollReport.rows.map(row => (
row.assignments.map((a, i) => (
<tr key={a.assignment_id} className='hover:bg-amber-50/30'>
{i === 0 && (
<>
<td className='px-4 py-3 font-medium text-gray-800' rowSpan={row.assignments.length}>
{row.user_name}
{row.assignments.length > 1 && (
<div className='text-xs text-gray-400 mt-0.5'>
Gesamt: +{row.total_extra_hours.toFixed(1)}h
</div>
)}
</td>
<td className='px-4 py-3 text-gray-500 font-mono text-xs' rowSpan={row.assignments.length}>
{row.personnel_number ?? ''}
</td>
</>
)}
<td className='px-4 py-3 text-gray-700'>{a.label ?? ''}</td>
<td className='px-4 py-3 text-gray-600 text-xs'>{a.date_from} {a.date_to}</td>
<td className='px-4 py-3 text-right font-semibold text-amber-700'>×{Number(a.factor).toFixed(2)}</td>
<td className='px-4 py-3 text-right text-gray-600'>{a.normal_hours.toFixed(1)}</td>
<td className='px-4 py-3 text-right text-gray-700'>{a.factor_hours.toFixed(1)}</td>
<td className='px-4 py-3 text-right font-semibold text-green-700'>+{a.extra_hours.toFixed(1)}</td>
</tr>
))
))}
</tbody>
<tfoot className='bg-gray-50 text-sm font-semibold'>
<tr>
<td colSpan={5} className='px-4 py-3 text-gray-700'>Gesamt</td>
<td className='px-4 py-3 text-right'>
{payrollReport.rows.reduce((s, r) => s + r.total_normal_hours, 0).toFixed(1)}
</td>
<td className='px-4 py-3 text-right'>
{payrollReport.rows.reduce((s, r) => s + r.total_factor_hours, 0).toFixed(1)}
</td>
<td className='px-4 py-3 text-right text-green-700'>
+{payrollReport.rows.reduce((s, r) => s + r.total_extra_hours, 0).toFixed(1)}
</td>
</tr>
</tfoot>
</table>
</>
)}
<p className='text-xs text-gray-400 px-4 py-3 border-t border-gray-100'>
Sondervertretungs-Faktor: Stunden × Faktor (z.B. ×1,5 für Schichtleiter-Vertretung). Extra-Stunden = Differenz zur Normarbeitszeit.
</p>
</div>
)}
{/* ── Sick stats table ── */}
{sickStats && (
<div className='overflow-x-auto'>
+111 -1
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
import type { SpecialAssignmentOut, SpecialAssignmentCreate, AssignmentMode } from '../types/specialAssignment'
import { api } from '../api/client'
import { Spinner } from '../components/Spinner'
import { Layout } from '../components/Layout'
@@ -134,6 +135,15 @@ export function UsersPage() {
// Company-Settings (für Auto-Modus / Pflicht-Anzeige)
const [company, setCompany] = useState<CompanyOut | null>(null)
// Sondervertretungs-Zuweisungen im Edit-Modal
const [assignments, setAssignments] = useState<SpecialAssignmentOut[]>([])
const [assignmentsLoading, setAssignmentsLoading] = useState(false)
const [newAssignment, setNewAssignment] = useState<SpecialAssignmentCreate>({
date_from: '', date_to: '', factor: 1.5, mode: 'both',
})
const [assignmentSaving, setAssignmentSaving] = useState(false)
const [assignmentError, setAssignmentError] = useState('')
// CSV-Import modal
const [showImport, setShowImport] = useState(false)
const [importFile, setImportFile] = useState<File | null>(null)
@@ -419,7 +429,17 @@ export function UsersPage() {
<td className='px-4 py-3'>
<div className='flex gap-2 justify-end'>
<button
onClick={() => { setEditUser(u); setEditRole(u.role); setEditScheduleId(u.work_schedule_id); setEditKuerzel(u.kuerzel ?? ''); setEditPersonnelNr(u.personnel_number ?? ''); setEditCanManual(u.can_manual_time_entry); setPersonnelCheck('idle') }}
onClick={() => {
setEditUser(u); setEditRole(u.role); setEditScheduleId(u.work_schedule_id)
setEditKuerzel(u.kuerzel ?? ''); setEditPersonnelNr(u.personnel_number ?? '')
setEditCanManual(u.can_manual_time_entry); setPersonnelCheck('idle')
setAssignmentError('')
setAssignmentsLoading(true)
api.get<SpecialAssignmentOut[]>(`/users/${u.id}/special-assignments`).then(r => {
setAssignments(r)
setAssignmentsLoading(false)
}).catch(() => setAssignmentsLoading(false))
}}
className='text-xs text-blue-600 hover:underline'
>
Bearbeiten
@@ -763,6 +783,96 @@ export function UsersPage() {
</div>
</label>
</div>
{/* ── Sondervertretungs-Zeiträume ── */}
<div className='border-t border-gray-100 pt-3'>
<h4 className='text-xs font-semibold text-gray-700 mb-2'>🏅 Sondervertretungs-Zeiträume</h4>
{assignmentsLoading ? (
<p className='text-xs text-gray-400'>Lade</p>
) : (
<>
{assignments.length === 0 && (
<p className='text-xs text-gray-400 mb-2'>Keine Zuweisungen vorhanden.</p>
)}
{assignments.map(a => (
<div key={a.id} className='flex items-center justify-between bg-amber-50 border border-amber-200 rounded-lg px-3 py-2 mb-1.5 text-xs'>
<div>
<span className='font-medium text-amber-800'>{a.label || 'Sondervertretung'}</span>
<span className='ml-2 text-amber-700'>{a.date_from} {a.date_to}</span>
<span className='ml-2 font-semibold text-amber-900'>×{Number(a.factor).toFixed(2)}</span>
<span className='ml-2 text-gray-500'>({a.mode})</span>
</div>
<button
onClick={async () => {
await api.del(`/users/${editUser!.id}/special-assignments/${a.id}`)
setAssignments(prev => prev.filter(x => x.id !== a.id))
}}
className='text-red-500 hover:text-red-700 ml-2 font-bold'
title='Löschen'
></button>
</div>
))}
{/* Neue Zuweisung anlegen */}
<div className='grid grid-cols-2 gap-2 mt-2'>
<div>
<label className='text-xs text-gray-600'>Von</label>
<input type='date' value={newAssignment.date_from}
onChange={e => setNewAssignment(p => ({ ...p, date_from: e.target.value }))}
className='w-full border border-gray-300 rounded-lg px-2 py-1.5 text-xs mt-0.5' />
</div>
<div>
<label className='text-xs text-gray-600'>Bis</label>
<input type='date' value={newAssignment.date_to}
onChange={e => setNewAssignment(p => ({ ...p, date_to: e.target.value }))}
className='w-full border border-gray-300 rounded-lg px-2 py-1.5 text-xs mt-0.5' />
</div>
<div>
<label className='text-xs text-gray-600'>Faktor (z.B. 1.5)</label>
<input type='number' step='0.1' min='0.1' max='10' value={newAssignment.factor}
onChange={e => setNewAssignment(p => ({ ...p, factor: parseFloat(e.target.value) }))}
className='w-full border border-gray-300 rounded-lg px-2 py-1.5 text-xs mt-0.5' />
</div>
<div>
<label className='text-xs text-gray-600'>Ziel</label>
<select value={newAssignment.mode}
onChange={e => setNewAssignment(p => ({ ...p, mode: e.target.value as AssignmentMode }))}
className='w-full border border-gray-300 rounded-lg px-2 py-1.5 text-xs mt-0.5'>
<option value='both'>FZA + Abrechnung</option>
<option value='fza'>Nur FZA</option>
<option value='payroll'>Nur Abrechnung</option>
</select>
</div>
<div className='col-span-2'>
<label className='text-xs text-gray-600'>Bezeichnung (optional)</label>
<input type='text' value={newAssignment.label ?? ''}
onChange={e => setNewAssignment(p => ({ ...p, label: e.target.value }))}
placeholder='z.B. Schichtleiter-Vertretung'
className='w-full border border-gray-300 rounded-lg px-2 py-1.5 text-xs mt-0.5' />
</div>
</div>
{assignmentError && <p className='text-xs text-red-600 mt-1'>{assignmentError}</p>}
<button
onClick={async () => {
setAssignmentError('')
setAssignmentSaving(true)
try {
const r = await api.post<SpecialAssignmentOut>(`/users/${editUser!.id}/special-assignments`, newAssignment)
setAssignments(prev => [...prev, r])
setNewAssignment({ date_from: '', date_to: '', factor: 1.5, mode: 'both' })
} catch (e: any) {
setAssignmentError(e?.detail || e?.message || 'Fehler beim Speichern')
} finally {
setAssignmentSaving(false)
}
}}
disabled={assignmentSaving || !newAssignment.date_from || !newAssignment.date_to}
className='mt-2 px-3 py-1.5 text-xs font-medium text-amber-700 border border-amber-300 rounded-lg hover:bg-amber-50 disabled:opacity-50'
>
{assignmentSaving ? 'Speichere…' : '+ Zeitraum hinzufügen'}
</button>
</>
)}
</div>
<div className='flex justify-end gap-2 pt-2'>
<button onClick={() => setEditUser(null)} className='px-4 py-2 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50'>Abbrechen</button>
<button onClick={handleEditRole} disabled={editLoading} className='px-4 py-2 text-sm font-semibold text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50'>
+51
View File
@@ -0,0 +1,51 @@
export type AssignmentMode = 'fza' | 'payroll' | 'both';
export interface SpecialAssignmentOut {
id: string;
user_id: string;
company_id: string;
date_from: string;
date_to: string;
factor: number;
mode: AssignmentMode;
description: string | null;
label: string | null;
}
export interface SpecialAssignmentCreate {
date_from: string;
date_to: string;
factor: number;
mode: AssignmentMode;
description?: string;
label?: string;
}
// ── Payroll Report ────────────────────────────────────────────────────────────
export interface PayrollAssignmentEntry {
assignment_id: string;
label: string | null;
date_from: string;
date_to: string;
factor: number;
normal_hours: number;
factor_hours: number;
extra_hours: number;
}
export interface PayrollAssignmentRow {
user_id: string;
user_name: string;
personnel_number: string | null;
assignments: PayrollAssignmentEntry[];
total_normal_hours: number;
total_factor_hours: number;
total_extra_hours: number;
}
export interface PayrollAssignmentReport {
year: number;
month: number;
rows: PayrollAssignmentRow[];
}