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