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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user