5049747696
- 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>
481 lines
17 KiB
TypeScript
481 lines
17 KiB
TypeScript
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>
|
||
)
|
||
}
|