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>
830 lines
35 KiB
TypeScript
830 lines
35 KiB
TypeScript
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>
|
||
)
|
||
}
|
||
|