Files
timemaster/frontend/src/pages/SpecialAssignmentsPage.tsx
T
patrick 5049747696 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>
2026-05-25 01:13:03 +02:00

481 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}