feat: Sondervertretungen als eigene HR-Seite (/hr/special-assignments)

- Neue Seite SpecialAssignmentsPage mit Filter, Tabelle, Add/Edit-Modal
- Farbcodierung: Faktor >1.0 amber, <1.0 blau, =1.0 grau
- Monat-Filterung client-seitig, paralleles Laden in Batches
- Layout.tsx: Nav-Eintrag in Hauptnavigation
- App.tsx: Route /hr/special-assignments
- UsersPage: Sondervertretungs-Block aus Edit-Modal entfernt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 01:13:03 +02:00
parent 0dd736c220
commit 5049747696
5 changed files with 499 additions and 108 deletions
+14
View File
@@ -1242,3 +1242,17 @@ Keine Commits in dieser Session.
- backend/app/services/absence_service.py | 5 ++++-
---
## 2026-05-25 00:45 01:00 (14m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
- 0dd736c fix: require_role in special_assignments router ohne extra Depends() wrapping
- 767ff9f fix: migration 0029 enum DO-Block statt CREATE TYPE IF NOT EXISTS
- 82ce592 fix: migration 0029 idempotent (IF NOT EXISTS für Enum + Tabelle)
- d60349d feat: Sondervertretungs-Faktoren (special_assignments)
### Geänderte Dateien
- backend/app/routers/special_assignments.py | 10 +++++-----
---
+2
View File
@@ -26,6 +26,7 @@ import { KioskSetupPage } from './pages/KioskSetupPage'
import { KioskStampPage } from './pages/KioskStampPage'
import { MobilePage } from './pages/mobile/MobilePage'
import { MobileLoginPage } from './pages/mobile/MobileLoginPage'
import { SpecialAssignmentsPage } from './pages/SpecialAssignmentsPage'
export default function App() {
return (
@@ -57,6 +58,7 @@ export default function App() {
<Route path='/settings/company' element={<CompanySettingsPage />} />
<Route path='/settings/kiosk' element={<KioskDevicesPage />} />
<Route path='/settings/audit-log' element={<AuditLogPage />} />
<Route path='/hr/special-assignments' element={<SpecialAssignmentsPage />} />
<Route path='/profile' element={<ProfilePage />} />
</Route>
<Route path='*' element={<Navigate to='/login' replace />} />
+3 -2
View File
@@ -19,8 +19,9 @@ const MAIN_NAV: NavItem[] = [
{ path: '/time', label: 'Zeiterfassung' },
{ path: '/absences', label: 'Abwesenheiten' },
{ path: '/calendar', label: 'Kalender' },
{ path: '/reports', label: 'Berichte', roles: ['COMPANY_ADMIN', 'SUPER_ADMIN', 'HR', 'MANAGER'] },
{ path: '/users', label: 'Mitarbeiter', roles: ['COMPANY_ADMIN', 'SUPER_ADMIN', 'HR'] },
{ path: '/reports', label: 'Berichte', roles: ['COMPANY_ADMIN', 'SUPER_ADMIN', 'HR', 'MANAGER'] },
{ path: '/hr/special-assignments', label: 'Sondervertretungen', roles: ['COMPANY_ADMIN', 'SUPER_ADMIN', 'HR', 'MANAGER'] },
{ path: '/users', label: 'Mitarbeiter', roles: ['COMPANY_ADMIN', 'SUPER_ADMIN', 'HR'] },
]
const SETTINGS_NAV: NavItem[] = [
@@ -0,0 +1,480 @@
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'
import { Modal } from '../components/Modal'
interface UserItem {
id: string
full_name: string
personnel_number: string | null
}
interface UserListResponse {
total: number
items: UserItem[]
}
interface Me {
first_name: string
last_name: string
role: string
}
interface AssignmentRow {
assignment: SpecialAssignmentOut
user: UserItem
}
const MODE_LABELS: Record<AssignmentMode, string> = {
fza: 'FZA',
payroll: 'Abrechnung',
both: 'Beides',
}
const MODE_COLORS: Record<AssignmentMode, string> = {
fza: 'bg-green-100 text-green-700',
payroll: 'bg-blue-100 text-blue-700',
both: 'bg-amber-100 text-amber-700',
}
function factorColor(factor: number): string {
if (factor > 1.0) return 'text-amber-700 font-semibold'
if (factor < 1.0) return 'text-blue-600 font-semibold'
return 'text-gray-400'
}
function getMonthRange(yearMonth: string): { first: string; last: string } {
const [y, m] = yearMonth.split('-').map(Number)
const first = `${y}-${String(m).padStart(2, '0')}-01`
const lastDate = new Date(y, m, 0)
const last = `${y}-${String(m).padStart(2, '0')}-${String(lastDate.getDate()).padStart(2, '0')}`
return { first, last }
}
function overlapsMonth(dateFrom: string, dateTo: string, yearMonth: string): boolean {
const { first, last } = getMonthRange(yearMonth)
return dateFrom <= last && dateTo >= first
}
function currentYearMonth(): string {
const now = new Date()
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
}
const BATCH_SIZE = 20
const inputClass = 'w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 focus:border-transparent'
export function SpecialAssignmentsPage() {
const [me, setMe] = useState<Me | null>(null)
const [users, setUsers] = useState<UserItem[]>([])
const [pageLoading, setPageLoading] = useState(true)
const [pageError, setPageError] = useState<string | null>(null)
// Filter state
const [filterUser, setFilterUser] = useState('')
const [filterMonth, setFilterMonth] = useState(currentYearMonth())
// Loaded assignments (flat list with user info)
const [rows, setRows] = useState<AssignmentRow[]>([])
const [tableLoading, setTableLoading] = useState(false)
// Modal state
const [showModal, setShowModal] = useState(false)
const [editAssignment, setEditAssignment] = useState<SpecialAssignmentOut | null>(null)
const [form, setForm] = useState<SpecialAssignmentCreate & { user_id: string }>({
user_id: '',
date_from: '',
date_to: '',
factor: 1.5,
mode: 'both',
label: '',
})
const [modalSaving, setModalSaving] = useState(false)
const [modalError, setModalError] = useState<string | null>(null)
// Initial load
useEffect(() => {
async function init() {
try {
const [meData, listData] = await Promise.all([
api.get<Me>('/auth/me'),
api.get<UserListResponse>('/users/?limit=500'),
])
setMe(meData)
setUsers(listData.items)
} catch (e: unknown) {
setPageError(e instanceof Error ? e.message : 'Fehler beim Laden')
} finally {
setPageLoading(false)
}
}
init()
}, [])
async function loadAssignments(userIdFilter: string, usersToQuery: UserItem[]) {
setTableLoading(true)
setRows([])
try {
const targets = userIdFilter
? usersToQuery.filter(u => u.id === userIdFilter)
: usersToQuery
const allRows: AssignmentRow[] = []
// Process in batches
for (let i = 0; i < targets.length; i += BATCH_SIZE) {
const batch = targets.slice(i, i + BATCH_SIZE)
const results = await Promise.all(
batch.map(u =>
api.get<SpecialAssignmentOut[]>(`/users/${u.id}/special-assignments`)
.then(assignments => assignments.map(a => ({ assignment: a, user: u })))
.catch(() => [] as AssignmentRow[])
)
)
for (const chunk of results) {
allRows.push(...chunk)
}
}
setRows(allRows)
} finally {
setTableLoading(false)
}
}
function handleSearch() {
if (users.length > 0) {
loadAssignments(filterUser, users)
}
}
// Load on mount once users are available
useEffect(() => {
if (users.length > 0) {
loadAssignments(filterUser, users)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [users])
// Filter rows client-side by month
const filteredRows = rows
.filter(r => overlapsMonth(r.assignment.date_from, r.assignment.date_to, filterMonth))
.sort((a, b) => a.user.full_name.localeCompare(b.user.full_name))
function openNewModal() {
setEditAssignment(null)
setForm({ user_id: filterUser, date_from: '', date_to: '', factor: 1.5, mode: 'both', label: '' })
setModalError(null)
setShowModal(true)
}
function openEditModal(row: AssignmentRow) {
setEditAssignment(row.assignment)
setForm({
user_id: row.assignment.user_id,
date_from: row.assignment.date_from,
date_to: row.assignment.date_to,
factor: row.assignment.factor,
mode: row.assignment.mode,
label: row.assignment.label ?? '',
})
setModalError(null)
setShowModal(true)
}
async function handleSave() {
if (!form.user_id || !form.date_from || !form.date_to) {
setModalError('Mitarbeiter, Von und Bis sind Pflichtfelder.')
return
}
setModalSaving(true)
setModalError(null)
try {
const payload: SpecialAssignmentCreate = {
date_from: form.date_from,
date_to: form.date_to,
factor: form.factor,
mode: form.mode,
label: form.label || undefined,
}
if (editAssignment) {
const updated = await api.patch<SpecialAssignmentOut>(
`/users/${form.user_id}/special-assignments/${editAssignment.id}`,
payload
)
const user = users.find(u => u.id === form.user_id)!
setRows(prev => prev.map(r =>
r.assignment.id === editAssignment.id ? { assignment: updated, user } : r
))
} else {
const created = await api.post<SpecialAssignmentOut>(
`/users/${form.user_id}/special-assignments`,
payload
)
const user = users.find(u => u.id === form.user_id)!
setRows(prev => [...prev, { assignment: created, user }])
}
setShowModal(false)
} catch (e: unknown) {
setModalError(e instanceof Error ? e.message : 'Fehler beim Speichern')
} finally {
setModalSaving(false)
}
}
async function handleDelete(row: AssignmentRow) {
if (!confirm(`Zuweisung für ${row.user.full_name} wirklich löschen?`)) return
try {
await api.del(`/users/${row.user.id}/special-assignments/${row.assignment.id}`)
setRows(prev => prev.filter(r => r.assignment.id !== row.assignment.id))
} catch (e: unknown) {
alert(e instanceof Error ? e.message : 'Fehler beim Löschen')
}
}
if (pageLoading) return (
<div className='min-h-screen bg-gray-50 flex items-center justify-center'><Spinner /></div>
)
if (pageError) return (
<div className='min-h-screen bg-gray-50 flex items-center justify-center'>
<p className='text-red-600'>{pageError}</p>
</div>
)
return (
<Layout userRole={me?.role ?? ''} userName={`${me?.first_name} ${me?.last_name}`}>
<div className='space-y-6'>
{/* Header */}
<div className='flex items-center justify-between'>
<div>
<h1 className='text-xl font-bold text-gray-800'>Sondervertretungen</h1>
<p className='text-sm text-gray-500 mt-0.5'>Sondervertretungs-Zeiträume verwalten</p>
</div>
<button
onClick={openNewModal}
className='px-4 py-2 bg-blue-600 text-white text-sm font-semibold rounded-lg hover:bg-blue-700 transition-colors'
>
+ Neu
</button>
</div>
{/* Filter bar */}
<div className='bg-white rounded-xl border border-gray-200 shadow-sm p-4'>
<div className='flex flex-wrap gap-3 items-end'>
<div className='flex-1 min-w-48'>
<label className='block text-xs font-medium text-gray-700 mb-1'>Mitarbeiter</label>
<select
value={filterUser}
onChange={e => setFilterUser(e.target.value)}
className={inputClass}
>
<option value=''>Alle Mitarbeiter</option>
{users.map(u => (
<option key={u.id} value={u.id}>
{u.full_name}{u.personnel_number ? ` (${u.personnel_number})` : ''}
</option>
))}
</select>
</div>
<div className='w-44'>
<label className='block text-xs font-medium text-gray-700 mb-1'>Monat</label>
<input
type='month'
value={filterMonth}
onChange={e => setFilterMonth(e.target.value)}
className={inputClass}
/>
</div>
<button
onClick={handleSearch}
disabled={tableLoading}
className='px-4 py-2 bg-blue-600 text-white text-sm font-semibold rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors'
>
{tableLoading ? 'Lade…' : 'Suchen'}
</button>
</div>
</div>
{/* Table */}
<div className='bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden'>
{tableLoading ? (
<div className='flex items-center justify-center py-16'><Spinner /></div>
) : (
<div className='overflow-x-auto'>
<table className='w-full text-sm'>
<thead className='bg-gray-50 text-gray-500 text-xs uppercase'>
<tr>
{['Mitarbeiter', 'Pers.-Nr.', 'Von', 'Bis', 'Faktor', 'Ziel', 'Bezeichnung', ''].map(h => (
<th key={h} className='px-4 py-3 text-left font-medium'>{h}</th>
))}
</tr>
</thead>
<tbody className='divide-y divide-gray-100'>
{filteredRows.map(row => (
<tr key={row.assignment.id} className='hover:bg-gray-50 transition-colors'>
<td className='px-4 py-3 font-medium text-gray-800'>{row.user.full_name}</td>
<td className='px-4 py-3 text-gray-500 font-mono text-xs'>
{row.user.personnel_number || '—'}
</td>
<td className='px-4 py-3 text-gray-600'>
{new Date(row.assignment.date_from).toLocaleDateString('de-DE')}
</td>
<td className='px-4 py-3 text-gray-600'>
{new Date(row.assignment.date_to).toLocaleDateString('de-DE')}
</td>
<td className='px-4 py-3'>
<span className={factorColor(row.assignment.factor)}>
×{Number(row.assignment.factor).toFixed(2)}
</span>
</td>
<td className='px-4 py-3'>
<span className={`inline-block text-xs px-2 py-0.5 rounded-full font-medium ${MODE_COLORS[row.assignment.mode]}`}>
{MODE_LABELS[row.assignment.mode]}
</span>
</td>
<td className='px-4 py-3 text-gray-500'>{row.assignment.label || '—'}</td>
<td className='px-4 py-3'>
<div className='flex gap-2 justify-end'>
<button
onClick={() => openEditModal(row)}
className='text-xs text-blue-600 hover:underline'
>
Bearbeiten
</button>
<button
onClick={() => handleDelete(row)}
className='text-xs text-red-500 hover:underline'
>
Löschen
</button>
</div>
</td>
</tr>
))}
{filteredRows.length === 0 && (
<tr>
<td colSpan={8} className='px-4 py-10 text-center text-gray-400'>
Keine Sondervertretungs-Zuweisungen gefunden.
</td>
</tr>
)}
</tbody>
</table>
</div>
)}
</div>
</div>
{/* Add/Edit Modal */}
{showModal && (
<Modal
title={editAssignment ? 'Zuweisung bearbeiten' : 'Zuweisung hinzufügen'}
onClose={() => setShowModal(false)}
>
<div className='space-y-4'>
<label className='block'>
<span className='text-xs font-medium text-gray-700'>Mitarbeiter *</span>
<select
value={form.user_id}
onChange={e => setForm(f => ({ ...f, user_id: e.target.value }))}
className={inputClass}
disabled={!!editAssignment}
>
<option value=''> bitte wählen </option>
{users.map(u => (
<option key={u.id} value={u.id}>
{u.full_name}{u.personnel_number ? ` (${u.personnel_number})` : ''}
</option>
))}
</select>
</label>
<div className='flex gap-3'>
<label className='block flex-1'>
<span className='text-xs font-medium text-gray-700'>Von *</span>
<input
type='date'
value={form.date_from}
onChange={e => setForm(f => ({ ...f, date_from: e.target.value }))}
className={inputClass}
/>
</label>
<label className='block flex-1'>
<span className='text-xs font-medium text-gray-700'>Bis *</span>
<input
type='date'
value={form.date_to}
onChange={e => setForm(f => ({ ...f, date_to: e.target.value }))}
className={inputClass}
/>
</label>
</div>
<label className='block'>
<span className='text-xs font-medium text-gray-700'>Faktor</span>
<input
type='number'
min='0.1'
max='10'
step='0.1'
value={form.factor}
onChange={e => setForm(f => ({ ...f, factor: parseFloat(e.target.value) }))}
className={inputClass}
/>
</label>
<label className='block'>
<span className='text-xs font-medium text-gray-700'>Ziel</span>
<select
value={form.mode}
onChange={e => setForm(f => ({ ...f, mode: e.target.value as AssignmentMode }))}
className={inputClass}
>
<option value='fza'>Nur FZA</option>
<option value='payroll'>Nur Abrechnung</option>
<option value='both'>FZA + Abrechnung</option>
</select>
</label>
<label className='block'>
<span className='text-xs font-medium text-gray-700'>Bezeichnung</span>
<input
type='text'
value={form.label ?? ''}
onChange={e => setForm(f => ({ ...f, label: e.target.value }))}
placeholder='z.B. Schichtleiter-Vertretung'
className={inputClass}
/>
</label>
{modalError && (
<div className='text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2'>
{modalError}
</div>
)}
<div className='flex justify-end gap-2 pt-2'>
<button
onClick={() => setShowModal(false)}
className='px-4 py-2 text-sm text-gray-700 border border-gray-300 rounded-lg hover:bg-gray-50'
>
Abbrechen
</button>
<button
onClick={handleSave}
disabled={modalSaving}
className='px-4 py-2 text-sm font-semibold text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50'
>
{modalSaving ? 'Speichere…' : 'Speichern'}
</button>
</div>
</div>
</Modal>
)}
</Layout>
)
}
-106
View File
@@ -1,5 +1,4 @@
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'
@@ -135,15 +134,6 @@ 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)
@@ -433,12 +423,6 @@ export function UsersPage() {
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'
>
@@ -783,96 +767,6 @@ 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'>