Files
timemaster/frontend/src/pages/UsersPage.tsx
T
patrickandClaude Opus 4.8 2af226990a
Security Audit / Python Dependency Audit (push) Has been cancelled
Security Audit / Node.js Dependency Audit (push) Has been cancelled
feat(dsgvo): Art. 17 Anonymisierung von Mitarbeiterkonten
Statt Hard-Delete (kollidiert mit Lohn-/Steuer-Aufbewahrung) werden bei
DSGVO-Löschanfrage die personenbezogenen Felder der bestehenden User-Zeile
überschrieben; referenzierte Daten (Zeit/Abwesenheit/Auszahlung/AuditLog)
bleiben entkoppelt erhalten.

- users.anonymized_at (Migration 0040); UserOut.anonymized_at.
- user_service.anonymize_user: überschreibt Name/E-Mail (eindeutig anon-<id>@deleted.local),
  entfernt Auth-Material (Passwort/PIN/QR/NFC/TOTP/LDAP-DN), is_active=False, löscht Sessions,
  AuditLog 'user_anonymized'. Personalnummer bleibt reserviert (kein Personenbezug mehr).
  Selbst-Anonymisierung (400), Doppelanonymisierung/Reaktivierung anonymisierter Konten (409).
- POST /users/{id}/anonymize (COMPANY_ADMIN/SUPER_ADMIN, mandantengeprüft).
- UsersPage: "Anonymisieren"-Button mit Sicherheitsabfrage + "Anonymisiert"-Badge.

193/193 Tests grün. Deployed 137 + 164.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 14:31:14 +02:00

830 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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: '' }
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<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 [editEntryDate, setEditEntryDate] = useState<string>('')
const [editExitDate, setEditExitDate] = useState<string>('')
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)
// CSV-Import modal
const [showImport, setShowImport] = useState(false)
const [importFile, setImportFile] = useState<File | null>(null)
const [importPreview, setImportPreview] = useState<ImportResult | null>(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<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 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<string, unknown> = {
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<UserOut>(`/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<ImportResult>('/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<ImportResult>('/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 (
<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>
<div className='flex gap-2'>
<button
onClick={openImportModal}
className='px-4 py-2 bg-gray-100 text-gray-700 text-sm font-semibold rounded-lg hover:bg-gray-200 transition-colors border border-gray-200'
>
CSV Import
</button>
<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>
</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.anonymized_at ? 'Anonymisiert' : u.is_active ? 'Aktiv' : 'Inaktiv'}
color={u.anonymized_at ? 'bg-gray-200 text-gray-500' : 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'>
{u.anonymized_at ? (
<div className='flex justify-end text-xs text-gray-400 italic'>
anonymisiert am {new Date(u.anonymized_at).toLocaleDateString('de-DE')}
</div>
) : (
<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')
setEditEntryDate(u.entry_date ?? ''); setEditExitDate(u.exit_date ?? '')
}}
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>
)}
<button
onClick={() => handleAnonymize(u)}
className='text-xs text-gray-400 hover:text-red-600 hover:underline'
title='DSGVO Art. 17 Personenbezug entfernen (nicht umkehrbar)'
>
Anonymisieren
</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>
)}
{/* CSV Import Modal */}
{showImport && (
<Modal title='Mitarbeiter per CSV importieren' onClose={() => setShowImport(false)}>
<div className='space-y-4'>
<div className='flex items-center justify-between bg-blue-50 border border-blue-100 rounded-lg px-3 py-2'>
<span className='text-xs text-blue-700'>Vorlage mit Pflicht- und Optionalspalten herunterladen</span>
<a
href='/api/v1/users/import-template.csv'
download='import-vorlage.csv'
className='text-xs font-semibold text-blue-600 hover:underline ml-4 whitespace-nowrap'
>
Vorlage herunterladen
</a>
</div>
<div>
<label className='block text-xs font-medium text-gray-700 mb-1'>CSV-Datei auswählen</label>
<input
type='file'
accept='.csv,text/csv'
onChange={e => {
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'
/>
</div>
{importError && <p className='text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2'>{importError}</p>}
{importSuccess && <p className='text-sm text-green-700 bg-green-50 border border-green-200 rounded px-3 py-2'>{importSuccess}</p>}
{importPreview && (
<div className='space-y-2'>
<div className='flex gap-4 text-sm'>
<span className='text-green-700 font-medium'>{importPreview.created} neu</span>
<span className='text-blue-700 font-medium'>{importPreview.reactivated} reaktiviert</span>
<span className='text-red-600 font-medium'>{importPreview.errors} Fehler</span>
<span className='text-gray-500'>{importPreview.total_rows} Zeilen gesamt</span>
</div>
<div className='max-h-56 overflow-y-auto border border-gray-200 rounded-lg'>
<table className='w-full text-xs'>
<thead className='bg-gray-50 text-gray-500 sticky top-0'>
<tr>
{['Zeile', 'E-Mail', 'Pers.-Nr.', 'Aktion', 'Meldung'].map(h => (
<th key={h} className='px-3 py-2 text-left font-medium'>{h}</th>
))}
</tr>
</thead>
<tbody className='divide-y divide-gray-100'>
{importPreview.items.map((it, i) => (
<tr key={i} className={it.action === 'error' ? 'bg-red-50' : it.action === 'reactivated' ? 'bg-blue-50' : ''}>
<td className='px-3 py-1.5 text-gray-500'>{it.row || '—'}</td>
<td className='px-3 py-1.5 font-mono'>{it.email || '—'}</td>
<td className='px-3 py-1.5 font-mono'>{it.personnel_number || '—'}</td>
<td className='px-3 py-1.5'>
<span className={`font-semibold ${
it.action === 'error' ? 'text-red-600' :
it.action === 'reactivated' ? 'text-blue-600' :
'text-green-600'
}`}>
{it.action === 'error' ? 'Fehler' : it.action === 'reactivated' ? 'Reaktiviert' : 'Neu'}
</span>
</td>
<td className='px-3 py-1.5 text-gray-500'>{it.message || ''}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
<div className='flex justify-end gap-2 pt-1'>
<button onClick={() => setShowImport(false)} className='px-4 py-2 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50'>Schließen</button>
<button
onClick={handleImportPreview}
disabled={!importFile || importLoading}
className='px-4 py-2 text-sm font-semibold text-white bg-gray-600 rounded-lg hover:bg-gray-700 disabled:opacity-50'
>
{importLoading && !importPreview ? 'Prüfe…' : 'Vorschau prüfen'}
</button>
{importPreview && importPreview.errors === 0 && !importSuccess && (
<button
onClick={handleImportApply}
disabled={importLoading}
className='px-4 py-2 text-sm font-semibold text-white bg-green-600 rounded-lg hover:bg-green-700 disabled:opacity-50'
>
{importLoading ? 'Importiere…' : `${importPreview.created + importPreview.reactivated} Benutzer importieren`}
</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 className='grid grid-cols-2 gap-3'>
<div>
<label className='block text-xs font-medium text-gray-700 mb-1'>Eintrittsdatum</label>
<input type='date' value={editEntryDate} onChange={e => setEditEntryDate(e.target.value)} className={inputClass} />
</div>
<div>
<label className='block text-xs font-medium text-gray-700 mb-1'>Austrittsdatum</label>
<input type='date' value={editExitDate} onChange={e => setEditExitDate(e.target.value)} className={inputClass} />
</div>
<p className='col-span-2 -mt-1 text-xs text-gray-400'>Basis für anteilige Urlaubsberechnung (Zwölftel-Regel).</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>
)
}