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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user