Initial commit – TimeMaster Zeiterfassung & HR-Tool

Stand: agent-06 (Audit-Log), agent-05 (Krankmeldung), agent-07 Phase 1 (Personalnummer),
Busylight-Pull-Integration, TOTP/2FA, Abwesenheiten, Zeiterfassung, Kiosk-Grundgerüst.
Migrations 0001–0023 deployed auf 192.168.1.137 + .164.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-05-23 20:03:27 +02:00
co-authored by Claude Sonnet 4.6
commit 1fedd683e0
178 changed files with 29896 additions and 0 deletions
+604
View File
@@ -0,0 +1,604 @@
import { useEffect, useState } from 'react'
import { api } from '../api/client'
import { Spinner } from '../components/Spinner'
import { Layout } from '../components/Layout'
import { Modal } from '../components/Modal'
interface UserOut {
id: string
email: string
first_name: string
last_name: string
full_name: string
role: string
is_active: boolean
auth_provider: string
department_id: string | null
work_schedule_id: string | null
kuerzel: string | null
personnel_number: string | null
can_manual_time_entry: boolean
last_login: string | null
created_at: string
}
interface CompanyOut {
id: string
personnel_number_required: boolean
personnel_number_mode: 'manual' | 'auto'
personnel_number_next: number
}
interface WorkScheduleOut {
id: string
name: string
mon_h: number
tue_h: number
wed_h: number
thu_h: number
fri_h: number
sat_h: number
sun_h: number
}
interface Me {
first_name: string
last_name: string
role: string
}
interface UserListResponse {
total: number
items: UserOut[]
}
const ROLES = ['EMPLOYEE', 'MANAGER', 'HR', 'COMPANY_ADMIN']
const ROLE_LABELS: Record<string, string> = {
COMPANY_ADMIN: 'Administrator',
HR: 'HR',
MANAGER: 'Manager',
EMPLOYEE: 'Mitarbeiter',
}
const ROLE_DESCRIPTIONS: Record<string, string> = {
EMPLOYEE: 'Kann eigene Zeiten erfassen, Abwesenheiten beantragen und den eigenen Kalender einsehen.',
MANAGER: 'Wie Mitarbeiter, zusätzlich: kann Abwesenheiten seines Teams genehmigen oder ablehnen und Berichte einsehen.',
HR: 'Wie Manager, zusätzlich: kann Mitarbeiter verwalten, alle Abwesenheiten bearbeiten und Berichte für alle einsehen.',
COMPANY_ADMIN: 'Vollzugriff: alle Einstellungen, Mitarbeiterverwaltung, Arbeitspläne, LDAP, SMTP und CalDAV konfigurieren.',
}
function Badge({ text, color }: { text: string; color: string }) {
return (
<span className={`inline-block text-xs px-2 py-0.5 rounded-full font-medium ${color}`}>
{text}
</span>
)
}
interface InviteForm {
email: string
first_name: string
last_name: string
role: string
personnel_number: string
initial_password: string
}
const EMPTY_INVITE: InviteForm = { email: '', first_name: '', last_name: '', role: 'EMPLOYEE', personnel_number: '', initial_password: '' }
export function UsersPage() {
const [me, setMe] = useState<Me | null>(null)
const [users, setUsers] = useState<UserOut[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [search, setSearch] = useState('')
// Invite modal
const [showInvite, setShowInvite] = useState(false)
const [invite, setInvite] = useState<InviteForm>(EMPTY_INVITE)
const [inviteLoading, setInviteLoading] = useState(false)
const [inviteError, setInviteError] = useState('')
const [inviteSuccess, setInviteSuccess] = useState('')
// Work schedules
const [workSchedules, setWorkSchedules] = useState<WorkScheduleOut[]>([])
// Edit role modal
const [editUser, setEditUser] = useState<UserOut | null>(null)
const [editRole, setEditRole] = useState('')
const [editScheduleId, setEditScheduleId] = useState<string | null>(null)
const [editKuerzel, setEditKuerzel] = useState<string>('')
const [editPersonnelNr, setEditPersonnelNr] = useState<string>('')
const [editCanManual, setEditCanManual] = useState(false)
const [editLoading, setEditLoading] = useState(false)
// Personalnummer-Verfügbarkeitsprüfung
const [personnelCheck, setPersonnelCheck] = useState<'idle' | 'checking' | 'free' | 'taken' | 'invalid'>('idle')
// Company-Settings (für Auto-Modus / Pflicht-Anzeige)
const [company, setCompany] = useState<CompanyOut | null>(null)
async function load() {
try {
const [meData, listData, schedulesData] = await Promise.all([
api.get<Me>('/auth/me'),
api.get<UserListResponse>('/users'),
api.get<WorkScheduleOut[]>('/time/schedules').catch(() => [] as WorkScheduleOut[]),
])
setMe(meData)
setUsers(listData.items)
setTotal(listData.total)
setWorkSchedules(schedulesData)
// Company-Settings laden (für Auto-Modus + Pflicht)
try {
const meFull = await api.get<{ company_id: string }>('/users/me')
if (meFull.company_id) {
const c = await api.get<CompanyOut>(`/companies/${meFull.company_id}`)
setCompany(c)
}
} catch { /* nicht kritisch */ }
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
} finally {
setLoading(false)
}
}
function weeklyHours(s: WorkScheduleOut): number {
return Number(s.mon_h) + Number(s.tue_h) + Number(s.wed_h) + Number(s.thu_h) + Number(s.fri_h) + Number(s.sat_h) + Number(s.sun_h)
}
useEffect(() => { load() }, [])
async function handleInvite() {
setInviteError('')
setInviteSuccess('')
if (!invite.email || !invite.first_name || !invite.last_name) {
setInviteError('Alle Felder sind erforderlich.')
return
}
setInviteLoading(true)
try {
const payload = { ...invite, initial_password: invite.initial_password || undefined }
await api.post('/users/invite', payload)
const msg = invite.initial_password
? `Benutzer ${invite.email} wurde direkt angelegt und ist sofort aktiv.`
: `Einladung an ${invite.email} gesendet.`
setInviteSuccess(msg)
setInvite(EMPTY_INVITE)
load()
} catch (e: unknown) {
setInviteError(e instanceof Error ? e.message : 'Fehler')
} finally {
setInviteLoading(false)
}
}
async function handleDeactivate(user: UserOut) {
if (!confirm(`${user.full_name} wirklich deaktivieren?`)) return
try {
await api.patch(`/users/${user.id}`, { is_active: false })
load()
} catch (e: unknown) {
alert(e instanceof Error ? e.message : 'Fehler')
}
}
async function handleReactivate(user: UserOut) {
try {
await api.patch(`/users/${user.id}`, { is_active: true })
load()
} catch (e: unknown) {
alert(e instanceof Error ? e.message : 'Fehler')
}
}
async function handleEditRole() {
if (!editUser) return
setEditLoading(true)
try {
const payload: Record<string, unknown> = {
role: editRole,
work_schedule_id: editScheduleId || null,
kuerzel: editKuerzel.trim() || null,
can_manual_time_entry: editCanManual,
}
// Personalnr. nur senden wenn geändert
const newPn = editPersonnelNr.trim()
if (newPn && newPn !== (editUser.personnel_number || '')) {
payload.personnel_number = newPn
}
await api.patch(`/users/${editUser.id}`, payload)
setEditUser(null)
load()
} catch (e: unknown) {
alert(e instanceof Error ? e.message : 'Fehler')
} finally {
setEditLoading(false)
}
}
async function suggestPersonnelNumber(target: 'invite' | 'edit') {
try {
const r = await api.get<{ next: string }>('/users/next-personnel-number')
if (target === 'invite') setInvite(f => ({ ...f, personnel_number: r.next }))
else setEditPersonnelNr(r.next)
} catch (e: unknown) {
alert(e instanceof Error ? e.message : 'Fehler')
}
}
// Debounced Live-Verfügbarkeitsprüfung
function checkPersonnelNumber(value: string, currentUserId?: string) {
setPersonnelCheck('idle')
const v = value.trim()
if (!v) return
if (!/^[0-9]+$/.test(v)) {
setPersonnelCheck('invalid')
return
}
setPersonnelCheck('checking')
const handle = setTimeout(async () => {
try {
const u = await api.get<UserOut>(`/users/by-personnel/${encodeURIComponent(v)}`)
if (u.id === currentUserId) setPersonnelCheck('free')
else setPersonnelCheck('taken')
} catch {
// 404 = frei
setPersonnelCheck('free')
}
}, 400)
return () => clearTimeout(handle)
}
const filtered = users.filter(u =>
`${u.full_name} ${u.email} ${u.personnel_number ?? ''}`.toLowerCase().includes(search.toLowerCase())
)
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'
if (loading) return (
<div className='min-h-screen bg-gray-50 flex items-center justify-center'><Spinner /></div>
)
if (error) return (
<div className='min-h-screen bg-gray-50 flex items-center justify-center'>
<p className='text-red-600'>{error}</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'>Mitarbeiterverwaltung</h1>
<p className='text-sm text-gray-500 mt-0.5'>{total} Mitarbeiter gesamt</p>
</div>
<button
onClick={() => { setShowInvite(true); setInviteSuccess(''); setInviteError('') }}
className='px-4 py-2 bg-blue-600 text-white text-sm font-semibold rounded-lg hover:bg-blue-700 transition-colors'
>
+ Einladen
</button>
</div>
{/* Search */}
<input
type='text'
placeholder='Name oder E-Mail suchen…'
value={search}
onChange={e => setSearch(e.target.value)}
className={inputClass}
/>
{/* Table */}
<div className='bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden'>
<div className='overflow-x-auto'>
<table className='w-full text-sm'>
<thead className='bg-gray-50 text-gray-500 text-xs uppercase'>
<tr>
{['Name', 'Pers.-Nr.', 'E-Mail', 'Rolle', 'Auth', 'Status', 'Letzter Login', ''].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'>
{filtered.map(u => (
<tr key={u.id} className='hover:bg-gray-50 transition-colors'>
<td className='px-4 py-3'>
<div className='font-medium text-gray-800'>{u.full_name}</div>
{u.work_schedule_id && (
<div className='text-xs text-gray-400 mt-0.5'>
{workSchedules.find(s => s.id === u.work_schedule_id)
? `${workSchedules.find(s => s.id === u.work_schedule_id)!.name} (${weeklyHours(workSchedules.find(s => s.id === u.work_schedule_id)!)}h/Wo.)`
: ''}
</div>
)}
</td>
<td className='px-4 py-3 text-gray-600 font-mono text-xs'>{u.personnel_number || '—'}</td>
<td className='px-4 py-3 text-gray-600'>{u.email}</td>
<td className='px-4 py-3'>
<Badge
text={ROLE_LABELS[u.role] ?? u.role}
color={u.role === 'COMPANY_ADMIN' ? 'bg-purple-100 text-purple-700' : 'bg-gray-100 text-gray-600'}
/>
</td>
<td className='px-4 py-3'>
<Badge
text={u.auth_provider === 'ldap' ? 'LDAP' : 'Lokal'}
color={u.auth_provider === 'ldap' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-500'}
/>
</td>
<td className='px-4 py-3'>
<Badge
text={u.is_active ? 'Aktiv' : 'Inaktiv'}
color={u.is_active ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-600'}
/>
</td>
<td className='px-4 py-3 text-gray-500'>
{u.last_login ? new Date(u.last_login).toLocaleDateString('de-DE') : '—'}
</td>
<td className='px-4 py-3'>
<div className='flex gap-2 justify-end'>
<button
onClick={() => { 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') }}
className='text-xs text-blue-600 hover:underline'
>
Bearbeiten
</button>
{u.is_active ? (
<button
onClick={() => handleDeactivate(u)}
className='text-xs text-red-500 hover:underline'
>
Deaktivieren
</button>
) : (
<button
onClick={() => handleReactivate(u)}
className='text-xs text-green-600 hover:underline'
>
Reaktivieren
</button>
)}
</div>
</td>
</tr>
))}
{filtered.length === 0 && (
<tr>
<td colSpan={8} className='px-4 py-8 text-center text-gray-400'>
Keine Mitarbeiter gefunden.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
{/* Invite Modal */}
{showInvite && (
<Modal title='Mitarbeiter einladen' onClose={() => setShowInvite(false)}>
<div className='space-y-3'>
<div className='flex gap-3'>
<label className='block flex-1'>
<span className='text-xs font-medium text-gray-700'>Vorname *</span>
<input value={invite.first_name} onChange={e => setInvite(f => ({ ...f, first_name: e.target.value }))} className={inputClass} placeholder='Max' />
</label>
<label className='block flex-1'>
<span className='text-xs font-medium text-gray-700'>Nachname *</span>
<input value={invite.last_name} onChange={e => setInvite(f => ({ ...f, last_name: e.target.value }))} className={inputClass} placeholder='Mustermann' />
</label>
</div>
<label className='block'>
<span className='text-xs font-medium text-gray-700'>E-Mail *</span>
<input type='email' value={invite.email} onChange={e => setInvite(f => ({ ...f, email: e.target.value }))} className={inputClass} placeholder='max@firma.de' />
</label>
<label className='block'>
<span className='text-xs font-medium text-gray-700'>Rolle</span>
<select value={invite.role} onChange={e => setInvite(f => ({ ...f, role: e.target.value }))} className={inputClass}>
{ROLES.map(r => <option key={r} value={r}>{ROLE_LABELS[r] ?? r}</option>)}
</select>
{ROLE_DESCRIPTIONS[invite.role] && (
<p className='mt-1.5 text-xs text-gray-500 bg-gray-50 border border-gray-100 rounded-lg px-3 py-2 leading-relaxed'>
{ROLE_DESCRIPTIONS[invite.role]}
</p>
)}
</label>
<label className='block'>
<span className='text-xs font-medium text-gray-700'>
Personalnummer {company?.personnel_number_required && <span className='text-red-500'>*</span>}
<span className='ml-1 font-normal text-gray-400'>(nur Ziffern)</span>
</span>
<div className='flex gap-2 items-stretch'>
<input
type='text'
inputMode='numeric'
value={invite.personnel_number}
onChange={e => {
const v = e.target.value.replace(/[^0-9]/g, '')
setInvite(f => ({ ...f, personnel_number: v }))
checkPersonnelNumber(v)
}}
className={inputClass}
placeholder={company?.personnel_number_mode === 'auto' ? 'Leer = Auto-Vergabe' : 'z.B. 1042'}
maxLength={50}
/>
<button
type='button'
onClick={() => suggestPersonnelNumber('invite')}
className='px-3 py-2 text-xs font-medium text-blue-600 border border-blue-200 rounded-lg hover:bg-blue-50 whitespace-nowrap'
>
Vorschlagen
</button>
</div>
{invite.personnel_number && (
<p className={`mt-1 text-xs ${
personnelCheck === 'taken' ? 'text-red-600' :
personnelCheck === 'invalid' ? 'text-red-600' :
personnelCheck === 'free' ? 'text-green-600' :
'text-gray-400'
}`}>
{personnelCheck === 'checking' && 'Prüfe Verfügbarkeit…'}
{personnelCheck === 'free' && '✓ Personalnr. frei'}
{personnelCheck === 'taken' && '✗ Personalnr. bereits vergeben'}
{personnelCheck === 'invalid' && 'Nur Ziffern erlaubt'}
</p>
)}
</label>
<label className='block'>
<span className='text-xs font-medium text-gray-700'>
Passwort direkt setzen
<span className='ml-1 font-normal text-gray-400'>(optional leer = E-Mail-Einladung)</span>
</span>
<input
type='password'
value={invite.initial_password}
onChange={e => setInvite(f => ({ ...f, initial_password: e.target.value }))}
className={inputClass}
placeholder='Min. 8 Zeichen, 1 Großbuchstabe, 1 Zahl'
autoComplete='new-password'
/>
{invite.initial_password && (
<p className='mt-1 text-xs text-blue-600'>
Benutzer wird sofort aktiv angelegt, kein E-Mail-Versand nötig.
</p>
)}
</label>
{inviteError && <p className='text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2'>{inviteError}</p>}
{inviteSuccess && <p className='text-sm text-green-700 bg-green-50 border border-green-200 rounded px-3 py-2'>{inviteSuccess}</p>}
<div className='flex justify-end gap-2 pt-2'>
<button onClick={() => setShowInvite(false)} className='px-4 py-2 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50'>Abbrechen</button>
<button onClick={handleInvite} disabled={inviteLoading} className='px-4 py-2 text-sm font-semibold text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50'>
{inviteLoading ? 'Sende…' : 'Einladen'}
</button>
</div>
</div>
</Modal>
)}
{/* Edit User Modal */}
{editUser && (
<Modal title={`Mitarbeiter bearbeiten ${editUser.full_name}`} onClose={() => setEditUser(null)}>
<div className='space-y-3'>
<label className='block'>
<span className='text-xs font-medium text-gray-700'>Rolle</span>
<select value={editRole} onChange={e => setEditRole(e.target.value)} className={inputClass}>
{ROLES.map(r => <option key={r} value={r}>{ROLE_LABELS[r] ?? r}</option>)}
</select>
{ROLE_DESCRIPTIONS[editRole] && (
<p className='mt-1.5 text-xs text-gray-500 bg-gray-50 border border-gray-100 rounded-lg px-3 py-2 leading-relaxed'>
{ROLE_DESCRIPTIONS[editRole]}
</p>
)}
</label>
<div>
<label className='block text-xs font-medium text-gray-700 mb-1'>Arbeitszeitmodell</label>
<select
value={editScheduleId ?? ''}
onChange={e => setEditScheduleId(e.target.value || null)}
className={inputClass}
>
<option value=''> Kein Modell zugewiesen </option>
{workSchedules.map(s => (
<option key={s.id} value={s.id}>
{s.name} ({weeklyHours(s)}h/Woche)
</option>
))}
</select>
{workSchedules.length === 0 && (
<p className='mt-1 text-xs text-gray-400'>Noch keine Arbeitszeitmodelle angelegt.</p>
)}
</div>
<div>
<label className='block text-xs font-medium text-gray-700 mb-1'>
Kürzel
<span className='ml-1 font-normal text-gray-400'>(für CalDAV-Kalender, Platzhalter $kuerzel)</span>
</label>
<input
type='text'
value={editKuerzel}
onChange={e => setEditKuerzel(e.target.value)}
placeholder={`${editUser?.first_name?.[0] ?? ''}${editUser?.last_name?.[0] ?? ''}`}
maxLength={20}
className={inputClass}
/>
<p className='mt-1 text-xs text-gray-400'>
Frei wählbar, z.B. "PP" oder "PatP". Leer = Initialen werden automatisch verwendet.
</p>
</div>
<div>
<label className='block text-xs font-medium text-gray-700 mb-1'>
Personalnummer
<span className='ml-1 font-normal text-gray-400'>(nur Ziffern, eindeutig pro Firma)</span>
</label>
<div className='flex gap-2 items-stretch'>
<input
type='text'
inputMode='numeric'
value={editPersonnelNr}
onChange={e => {
const v = e.target.value.replace(/[^0-9]/g, '')
setEditPersonnelNr(v)
checkPersonnelNumber(v, editUser?.id)
}}
className={inputClass}
maxLength={50}
/>
<button
type='button'
onClick={() => suggestPersonnelNumber('edit')}
className='px-3 py-2 text-xs font-medium text-blue-600 border border-blue-200 rounded-lg hover:bg-blue-50 whitespace-nowrap'
>
Vorschlagen
</button>
</div>
{editPersonnelNr && editPersonnelNr !== (editUser?.personnel_number ?? '') && (
<p className={`mt-1 text-xs ${
personnelCheck === 'taken' ? 'text-red-600' :
personnelCheck === 'invalid' ? 'text-red-600' :
personnelCheck === 'free' ? 'text-green-600' :
'text-gray-400'
}`}>
{personnelCheck === 'checking' && 'Prüfe Verfügbarkeit…'}
{personnelCheck === 'free' && '✓ Personalnr. frei'}
{personnelCheck === 'taken' && '✗ Personalnr. bereits vergeben'}
{personnelCheck === 'invalid' && 'Nur Ziffern erlaubt'}
</p>
)}
{editUser?.personnel_number && (
<p className='mt-1 text-xs text-gray-400'>
Aktuelle Nr.: {editUser.personnel_number} (bleibt nach Deaktivierung reserviert)
</p>
)}
</div>
<div className='border-t border-gray-100 pt-3'>
<label className='flex items-center gap-3 cursor-pointer select-none'>
<input
type='checkbox'
checked={editCanManual}
onChange={e => setEditCanManual(e.target.checked)}
className='w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500'
/>
<div>
<span className='text-sm font-medium text-gray-800'>Manuelle Zeiterfassung erlaubt</span>
<p className='text-xs text-gray-400 mt-0.5'>Mitarbeiter kann eigene Zeiteinträge manuell anlegen (nicht nur stempeln).</p>
</div>
</label>
</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'>
{editLoading ? 'Speichere…' : 'Speichern'}
</button>
</div>
</div>
</Modal>
)}
</Layout>
)
}