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 entry_date: string | null exit_date: string | null anonymized_at: string | null 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 = { COMPANY_ADMIN: 'Administrator', HR: 'HR', MANAGER: 'Manager', EMPLOYEE: 'Mitarbeiter', } const ROLE_DESCRIPTIONS: Record = { 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 ( {text} ) } 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: '' } interface ImportRowResult { row: number email: string personnel_number: string | null action: string message: string | null } interface ImportResult { total_rows: number created: number reactivated: number errors: number items: ImportRowResult[] } export function UsersPage() { const [me, setMe] = useState(null) const [users, setUsers] = useState([]) 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(EMPTY_INVITE) const [inviteLoading, setInviteLoading] = useState(false) const [inviteError, setInviteError] = useState('') const [inviteSuccess, setInviteSuccess] = useState('') // Work schedules const [workSchedules, setWorkSchedules] = useState([]) // Edit role modal const [editUser, setEditUser] = useState(null) const [editRole, setEditRole] = useState('') const [editScheduleId, setEditScheduleId] = useState(null) const [editKuerzel, setEditKuerzel] = useState('') const [editPersonnelNr, setEditPersonnelNr] = useState('') const [editCanManual, setEditCanManual] = useState(false) const [editEntryDate, setEditEntryDate] = useState('') const [editExitDate, setEditExitDate] = useState('') 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(null) // CSV-Import modal const [showImport, setShowImport] = useState(false) const [importFile, setImportFile] = useState(null) const [importPreview, setImportPreview] = useState(null) const [importLoading, setImportLoading] = useState(false) const [importError, setImportError] = useState('') const [importSuccess, setImportSuccess] = useState('') async function load() { try { const [meData, listData, schedulesData] = await Promise.all([ api.get('/auth/me'), api.get('/users'), api.get('/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(`/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 handleAnonymize(user: UserOut) { if (!confirm( `DSGVO-Anonymisierung von ${user.full_name}?\n\n` + `Name und E-Mail werden unwiderruflich entfernt, das Konto wird deaktiviert. ` + `Zeit-, Abwesenheits- und Auszahlungsdaten bleiben (gesetzliche Aufbewahrung), ` + `aber ohne Personenbezug. Nicht umkehrbar.` )) return try { await api.post(`/users/${user.id}/anonymize`, {}) load() } catch (e: unknown) { alert(e instanceof Error ? e.message : 'Fehler') } } async function handleEditRole() { if (!editUser) return setEditLoading(true) try { const payload: Record = { role: editRole, work_schedule_id: editScheduleId || null, kuerzel: editKuerzel.trim() || null, can_manual_time_entry: editCanManual, entry_date: editEntryDate || null, exit_date: editExitDate || null, } // 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(`/users/by-personnel/${encodeURIComponent(v)}`) if (u.id === currentUserId) setPersonnelCheck('free') else setPersonnelCheck('taken') } catch { // 404 = frei setPersonnelCheck('free') } }, 400) return () => clearTimeout(handle) } async function handleImportPreview() { if (!importFile) return setImportLoading(true) setImportError('') setImportPreview(null) try { const form = new FormData() form.append('file', importFile) const result = await api.postForm('/users/import/preview', form) setImportPreview(result) } catch (e: unknown) { setImportError(e instanceof Error ? e.message : 'Fehler beim Prüfen') } finally { setImportLoading(false) } } async function handleImportApply() { if (!importFile) return setImportLoading(true) setImportError('') setImportSuccess('') try { const form = new FormData() form.append('file', importFile) const result = await api.postForm('/users/import/apply', form) setImportSuccess(`Import abgeschlossen: ${result.created} angelegt, ${result.reactivated} reaktiviert, ${result.errors} Fehler.`) setImportPreview(result) setImportFile(null) load() } catch (e: unknown) { setImportError(e instanceof Error ? e.message : 'Fehler beim Import') } finally { setImportLoading(false) } } function openImportModal() { setShowImport(true) setImportFile(null) setImportPreview(null) setImportError('') setImportSuccess('') } 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 (
) if (error) return (

{error}

) return (
{/* Header */}

Mitarbeiterverwaltung

{total} Mitarbeiter gesamt

{/* Search */} setSearch(e.target.value)} className={inputClass} /> {/* Table */}
{['Name', 'Pers.-Nr.', 'E-Mail', 'Rolle', 'Auth', 'Status', 'Letzter Login', ''].map(h => ( ))} {filtered.map(u => ( ))} {filtered.length === 0 && ( )}
{h}
{u.full_name}
{u.work_schedule_id && (
{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.)` : ''}
)}
{u.personnel_number || '—'} {u.email} {u.last_login ? new Date(u.last_login).toLocaleDateString('de-DE') : '—'} {u.anonymized_at ? (
anonymisiert am {new Date(u.anonymized_at).toLocaleDateString('de-DE')}
) : (
{u.is_active ? ( ) : ( )}
)}
Keine Mitarbeiter gefunden.
{/* Invite Modal */} {showInvite && ( setShowInvite(false)}>
{inviteError &&

{inviteError}

} {inviteSuccess &&

{inviteSuccess}

}
)} {/* CSV Import Modal */} {showImport && ( setShowImport(false)}>
Vorlage mit Pflicht- und Optionalspalten herunterladen Vorlage herunterladen
{ setImportFile(e.target.files?.[0] ?? null) setImportPreview(null) setImportError('') setImportSuccess('') }} className='block w-full text-sm text-gray-600 file:mr-3 file:py-1.5 file:px-3 file:rounded file:border-0 file:text-xs file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100' />
{importError &&

{importError}

} {importSuccess &&

{importSuccess}

} {importPreview && (
{importPreview.created} neu {importPreview.reactivated} reaktiviert {importPreview.errors} Fehler {importPreview.total_rows} Zeilen gesamt
{['Zeile', 'E-Mail', 'Pers.-Nr.', 'Aktion', 'Meldung'].map(h => ( ))} {importPreview.items.map((it, i) => ( ))}
{h}
{it.row || '—'} {it.email || '—'} {it.personnel_number || '—'} {it.action === 'error' ? 'Fehler' : it.action === 'reactivated' ? 'Reaktiviert' : 'Neu'} {it.message || ''}
)}
{importPreview && importPreview.errors === 0 && !importSuccess && ( )}
)} {/* Edit User Modal */} {editUser && ( setEditUser(null)}>
{workSchedules.length === 0 && (

Noch keine Arbeitszeitmodelle angelegt.

)}
setEditEntryDate(e.target.value)} className={inputClass} />
setEditExitDate(e.target.value)} className={inputClass} />

Basis für anteilige Urlaubsberechnung (Zwölftel-Regel).

setEditKuerzel(e.target.value)} placeholder={`${editUser?.first_name?.[0] ?? ''}${editUser?.last_name?.[0] ?? ''}`} maxLength={20} className={inputClass} />

Frei wählbar, z.B. "PP" oder "PatP". Leer = Initialen werden automatisch verwendet.

{ const v = e.target.value.replace(/[^0-9]/g, '') setEditPersonnelNr(v) checkPersonnelNumber(v, editUser?.id) }} className={inputClass} maxLength={50} />
{editPersonnelNr && editPersonnelNr !== (editUser?.personnel_number ?? '') && (

{personnelCheck === 'checking' && 'Prüfe Verfügbarkeit…'} {personnelCheck === 'free' && '✓ Personalnr. frei'} {personnelCheck === 'taken' && '✗ Personalnr. bereits vergeben'} {personnelCheck === 'invalid' && 'Nur Ziffern erlaubt'}

)} {editUser?.personnel_number && (

Aktuelle Nr.: {editUser.personnel_number} (bleibt nach Deaktivierung reserviert)

)}
)}
) }