From 5049747696a390af6d023357b079af326141a16a Mon Sep 17 00:00:00 2001 From: patrick Date: Mon, 25 May 2026 01:13:03 +0200 Subject: [PATCH] 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 --- DEVLOG.md | 14 + frontend/src/App.tsx | 2 + frontend/src/components/Layout.tsx | 5 +- frontend/src/pages/SpecialAssignmentsPage.tsx | 480 ++++++++++++++++++ frontend/src/pages/UsersPage.tsx | 106 ---- 5 files changed, 499 insertions(+), 108 deletions(-) create mode 100644 frontend/src/pages/SpecialAssignmentsPage.tsx diff --git a/DEVLOG.md b/DEVLOG.md index 7d7a489..a0fa198 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -1242,3 +1242,17 @@ Keine Commits in dieser Session. - 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 +++++----- + +--- diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f8b8916..5e7cf1b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 9d80a73..b86df9a 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -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[] = [ diff --git a/frontend/src/pages/SpecialAssignmentsPage.tsx b/frontend/src/pages/SpecialAssignmentsPage.tsx new file mode 100644 index 0000000..54b2f9c --- /dev/null +++ b/frontend/src/pages/SpecialAssignmentsPage.tsx @@ -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 = { + fza: 'FZA', + payroll: 'Abrechnung', + both: 'Beides', +} + +const MODE_COLORS: Record = { + 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(null) + const [users, setUsers] = useState([]) + const [pageLoading, setPageLoading] = useState(true) + const [pageError, setPageError] = useState(null) + + // Filter state + const [filterUser, setFilterUser] = useState('') + const [filterMonth, setFilterMonth] = useState(currentYearMonth()) + + // Loaded assignments (flat list with user info) + const [rows, setRows] = useState([]) + const [tableLoading, setTableLoading] = useState(false) + + // Modal state + const [showModal, setShowModal] = useState(false) + const [editAssignment, setEditAssignment] = useState(null) + const [form, setForm] = useState({ + user_id: '', + date_from: '', + date_to: '', + factor: 1.5, + mode: 'both', + label: '', + }) + const [modalSaving, setModalSaving] = useState(false) + const [modalError, setModalError] = useState(null) + + // Initial load + useEffect(() => { + async function init() { + try { + const [meData, listData] = await Promise.all([ + api.get('/auth/me'), + api.get('/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(`/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( + `/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( + `/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 ( +
+ ) + if (pageError) return ( +
+

{pageError}

+
+ ) + + return ( + +
+ + {/* Header */} +
+
+

Sondervertretungen

+

Sondervertretungs-Zeiträume verwalten

+
+ +
+ + {/* Filter bar */} +
+
+
+ + +
+
+ + setFilterMonth(e.target.value)} + className={inputClass} + /> +
+ +
+
+ + {/* Table */} +
+ {tableLoading ? ( +
+ ) : ( +
+ + + + {['Mitarbeiter', 'Pers.-Nr.', 'Von', 'Bis', 'Faktor', 'Ziel', 'Bezeichnung', ''].map(h => ( + + ))} + + + + {filteredRows.map(row => ( + + + + + + + + + + + ))} + {filteredRows.length === 0 && ( + + + + )} + +
{h}
{row.user.full_name} + {row.user.personnel_number || '—'} + + {new Date(row.assignment.date_from).toLocaleDateString('de-DE')} + + {new Date(row.assignment.date_to).toLocaleDateString('de-DE')} + + + ×{Number(row.assignment.factor).toFixed(2)} + + + + {MODE_LABELS[row.assignment.mode]} + + {row.assignment.label || '—'} +
+ + +
+
+ Keine Sondervertretungs-Zuweisungen gefunden. +
+
+ )} +
+
+ + {/* Add/Edit Modal */} + {showModal && ( + setShowModal(false)} + > +
+ + +
+ + +
+ + + + + + + + {modalError && ( +
+ {modalError} +
+ )} + +
+ + +
+
+
+ )} +
+ ) +} diff --git a/frontend/src/pages/UsersPage.tsx b/frontend/src/pages/UsersPage.tsx index 7be9012..6c3746c 100644 --- a/frontend/src/pages/UsersPage.tsx +++ b/frontend/src/pages/UsersPage.tsx @@ -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(null) - // Sondervertretungs-Zuweisungen im Edit-Modal - const [assignments, setAssignments] = useState([]) - const [assignmentsLoading, setAssignmentsLoading] = useState(false) - const [newAssignment, setNewAssignment] = useState({ - 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(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(`/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() { - {/* ── Sondervertretungs-Zeiträume ── */} -
-

🏅 Sondervertretungs-Zeiträume

- {assignmentsLoading ? ( -

Lade…

- ) : ( - <> - {assignments.length === 0 && ( -

Keine Zuweisungen vorhanden.

- )} - {assignments.map(a => ( -
-
- {a.label || 'Sondervertretung'} - {a.date_from} – {a.date_to} - ×{Number(a.factor).toFixed(2)} - ({a.mode}) -
- -
- ))} - {/* Neue Zuweisung anlegen */} -
-
- - 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' /> -
-
- - 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' /> -
-
- - 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' /> -
-
- - -
-
- - 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' /> -
-
- {assignmentError &&

{assignmentError}

} - - - )} -
-