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:
@@ -1242,3 +1242,17 @@ Keine Commits in dieser Session.
|
|||||||
- backend/app/services/absence_service.py | 5 ++++-
|
- 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 +++++-----
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { KioskSetupPage } from './pages/KioskSetupPage'
|
|||||||
import { KioskStampPage } from './pages/KioskStampPage'
|
import { KioskStampPage } from './pages/KioskStampPage'
|
||||||
import { MobilePage } from './pages/mobile/MobilePage'
|
import { MobilePage } from './pages/mobile/MobilePage'
|
||||||
import { MobileLoginPage } from './pages/mobile/MobileLoginPage'
|
import { MobileLoginPage } from './pages/mobile/MobileLoginPage'
|
||||||
|
import { SpecialAssignmentsPage } from './pages/SpecialAssignmentsPage'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
@@ -57,6 +58,7 @@ export default function App() {
|
|||||||
<Route path='/settings/company' element={<CompanySettingsPage />} />
|
<Route path='/settings/company' element={<CompanySettingsPage />} />
|
||||||
<Route path='/settings/kiosk' element={<KioskDevicesPage />} />
|
<Route path='/settings/kiosk' element={<KioskDevicesPage />} />
|
||||||
<Route path='/settings/audit-log' element={<AuditLogPage />} />
|
<Route path='/settings/audit-log' element={<AuditLogPage />} />
|
||||||
|
<Route path='/hr/special-assignments' element={<SpecialAssignmentsPage />} />
|
||||||
<Route path='/profile' element={<ProfilePage />} />
|
<Route path='/profile' element={<ProfilePage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path='*' element={<Navigate to='/login' replace />} />
|
<Route path='*' element={<Navigate to='/login' replace />} />
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const MAIN_NAV: NavItem[] = [
|
|||||||
{ path: '/absences', label: 'Abwesenheiten' },
|
{ path: '/absences', label: 'Abwesenheiten' },
|
||||||
{ path: '/calendar', label: 'Kalender' },
|
{ path: '/calendar', label: 'Kalender' },
|
||||||
{ path: '/reports', label: 'Berichte', roles: ['COMPANY_ADMIN', 'SUPER_ADMIN', 'HR', 'MANAGER'] },
|
{ 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'] },
|
{ path: '/users', label: 'Mitarbeiter', roles: ['COMPANY_ADMIN', 'SUPER_ADMIN', 'HR'] },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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 { useEffect, useState } from 'react'
|
||||||
import type { SpecialAssignmentOut, SpecialAssignmentCreate, AssignmentMode } from '../types/specialAssignment'
|
|
||||||
import { api } from '../api/client'
|
import { api } from '../api/client'
|
||||||
import { Spinner } from '../components/Spinner'
|
import { Spinner } from '../components/Spinner'
|
||||||
import { Layout } from '../components/Layout'
|
import { Layout } from '../components/Layout'
|
||||||
@@ -135,15 +134,6 @@ export function UsersPage() {
|
|||||||
// Company-Settings (für Auto-Modus / Pflicht-Anzeige)
|
// Company-Settings (für Auto-Modus / Pflicht-Anzeige)
|
||||||
const [company, setCompany] = useState<CompanyOut | null>(null)
|
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
|
// CSV-Import modal
|
||||||
const [showImport, setShowImport] = useState(false)
|
const [showImport, setShowImport] = useState(false)
|
||||||
const [importFile, setImportFile] = useState<File | null>(null)
|
const [importFile, setImportFile] = useState<File | null>(null)
|
||||||
@@ -433,12 +423,6 @@ export function UsersPage() {
|
|||||||
setEditUser(u); setEditRole(u.role); setEditScheduleId(u.work_schedule_id)
|
setEditUser(u); setEditRole(u.role); setEditScheduleId(u.work_schedule_id)
|
||||||
setEditKuerzel(u.kuerzel ?? ''); setEditPersonnelNr(u.personnel_number ?? '')
|
setEditKuerzel(u.kuerzel ?? ''); setEditPersonnelNr(u.personnel_number ?? '')
|
||||||
setEditCanManual(u.can_manual_time_entry); setPersonnelCheck('idle')
|
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'
|
className='text-xs text-blue-600 hover:underline'
|
||||||
>
|
>
|
||||||
@@ -783,96 +767,6 @@ export function UsersPage() {
|
|||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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'>
|
<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={() => 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'>
|
<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