Initial commit – TimeMaster Zeiterfassung & HR-Tool

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-05-23 20:03:27 +02:00
commit 1fedd683e0
178 changed files with 29896 additions and 0 deletions
+333
View File
@@ -0,0 +1,333 @@
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 KioskDevice {
id: string
company_id: string
name: string
location: string | null
is_active: boolean
last_seen_at: string | null
created_at: string
}
interface Me {
first_name: string
last_name: string
role: string
}
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'
export function KioskDevicesPage() {
const [me, setMe] = useState<Me | null>(null)
const [devices, setDevices] = useState<KioskDevice[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
// Create modal
const [showCreate, setShowCreate] = useState(false)
const [createName, setCreateName] = useState('')
const [createLocation, setCreateLocation] = useState('')
const [createLoading, setCreateLoading] = useState(false)
const [createError, setCreateError] = useState('')
// Token display modal (after create / rotate)
const [shownToken, setShownToken] = useState<{ deviceName: string; token: string } | null>(null)
const [tokenCopied, setTokenCopied] = useState(false)
// Edit modal
const [editDevice, setEditDevice] = useState<KioskDevice | null>(null)
const [editName, setEditName] = useState('')
const [editLocation, setEditLocation] = useState('')
const [editActive, setEditActive] = useState(true)
const [editLoading, setEditLoading] = useState(false)
const [editError, setEditError] = useState('')
async function load() {
try {
const [meData, devicesData] = await Promise.all([
api.get<Me>('/auth/me'),
api.get<KioskDevice[]>('/kiosk/devices'),
])
setMe(meData)
setDevices(devicesData)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
} finally {
setLoading(false)
}
}
useEffect(() => { load() }, [])
async function handleCreate() {
setCreateError('')
if (!createName.trim()) { setCreateError('Name ist erforderlich.'); return }
setCreateLoading(true)
try {
const result = await api.post<KioskDevice & { token: string }>('/kiosk/devices', {
name: createName.trim(),
location: createLocation.trim() || null,
})
setShownToken({ deviceName: result.name, token: result.token })
setShowCreate(false)
setCreateName('')
setCreateLocation('')
load()
} catch (e: unknown) {
setCreateError(e instanceof Error ? e.message : 'Fehler')
} finally {
setCreateLoading(false)
}
}
async function handleEdit() {
if (!editDevice) return
setEditError('')
if (!editName.trim()) { setEditError('Name ist erforderlich.'); return }
setEditLoading(true)
try {
await api.patch(`/kiosk/devices/${editDevice.id}`, {
name: editName.trim(),
location: editLocation.trim() || null,
is_active: editActive,
})
setEditDevice(null)
load()
} catch (e: unknown) {
setEditError(e instanceof Error ? e.message : 'Fehler')
} finally {
setEditLoading(false)
}
}
async function handleRotateToken(device: KioskDevice) {
if (!confirm(`Token für „${device.name}" wirklich rotieren? Das alte Token wird sofort ungültig.`)) return
try {
const result = await api.post<KioskDevice & { token: string }>(`/kiosk/devices/${device.id}/rotate-token`, {})
setShownToken({ deviceName: result.name, token: result.token })
load()
} catch (e: unknown) {
alert(e instanceof Error ? e.message : 'Fehler')
}
}
async function handleDelete(device: KioskDevice) {
if (!confirm(`Gerät „${device.name}" wirklich löschen?`)) return
try {
await api.del(`/kiosk/devices/${device.id}`)
load()
} catch (e: unknown) {
alert(e instanceof Error ? e.message : 'Fehler')
}
}
function openEdit(device: KioskDevice) {
setEditDevice(device)
setEditName(device.name)
setEditLocation(device.location ?? '')
setEditActive(device.is_active)
setEditError('')
}
function copyToken(token: string) {
navigator.clipboard.writeText(token).then(() => {
setTokenCopied(true)
setTimeout(() => setTokenCopied(false), 2000)
})
}
function formatDate(iso: string | null) {
if (!iso) return '—'
return new Date(iso).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' })
}
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'>Kiosk-Geräte</h1>
<p className='text-sm text-gray-500 mt-0.5'>{devices.length} {devices.length === 1 ? 'Gerät' : 'Geräte'} registriert</p>
</div>
<button
onClick={() => { setShowCreate(true); setCreateName(''); setCreateLocation(''); setCreateError('') }}
className='px-4 py-2 bg-blue-600 text-white text-sm font-semibold rounded-lg hover:bg-blue-700 transition-colors'
>
+ Gerät hinzufügen
</button>
</div>
{/* Info-Hinweis */}
<div className='bg-blue-50 border border-blue-200 rounded-xl px-4 py-3 text-sm text-blue-800'>
Kiosk-Geräte authentifizieren sich per <code className='bg-blue-100 px-1 rounded text-xs font-mono'>X-Kiosk-Token</code> Header.
Der Token wird nur einmalig bei der Erstellung angezeigt bitte sofort sichern.
</div>
{/* Tabelle */}
<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>
{['Gerät', 'Standort', 'Status', 'Zuletzt gesehen', 'Angelegt', ''].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'>
{devices.map(d => (
<tr key={d.id} className='hover:bg-gray-50 transition-colors'>
<td className='px-4 py-3 font-medium text-gray-800'>{d.name}</td>
<td className='px-4 py-3 text-gray-500'>{d.location ?? '—'}</td>
<td className='px-4 py-3'>
<span className={`inline-block text-xs px-2 py-0.5 rounded-full font-medium ${
d.is_active ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-600'
}`}>
{d.is_active ? 'Aktiv' : 'Deaktiviert'}
</span>
</td>
<td className='px-4 py-3 text-gray-500'>{formatDate(d.last_seen_at)}</td>
<td className='px-4 py-3 text-gray-500'>{formatDate(d.created_at)}</td>
<td className='px-4 py-3'>
<div className='flex gap-3 justify-end'>
<button onClick={() => openEdit(d)} className='text-xs text-blue-600 hover:underline'>Bearbeiten</button>
<button onClick={() => handleRotateToken(d)} className='text-xs text-yellow-600 hover:underline'>Token rotieren</button>
<button onClick={() => handleDelete(d)} className='text-xs text-red-500 hover:underline'>Löschen</button>
</div>
</td>
</tr>
))}
{devices.length === 0 && (
<tr>
<td colSpan={6} className='px-4 py-12 text-center text-gray-400'>
Noch keine Kiosk-Geräte angelegt.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
{/* Gerät erstellen Modal */}
{showCreate && (
<Modal title='Neues Kiosk-Gerät' onClose={() => setShowCreate(false)}>
<div className='space-y-3'>
<label className='block'>
<span className='text-xs font-medium text-gray-700'>Name *</span>
<input
value={createName}
onChange={e => setCreateName(e.target.value)}
className={inputClass}
placeholder='z.B. Eingang Büro'
autoFocus
/>
</label>
<label className='block'>
<span className='text-xs font-medium text-gray-700'>Standort</span>
<input
value={createLocation}
onChange={e => setCreateLocation(e.target.value)}
className={inputClass}
placeholder='z.B. Erdgeschoss, Halle A'
/>
</label>
{createError && <p className='text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2'>{createError}</p>}
<div className='flex justify-end gap-2 pt-2'>
<button onClick={() => setShowCreate(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={handleCreate} disabled={createLoading} className='px-4 py-2 text-sm font-semibold text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50'>
{createLoading ? 'Erstelle…' : 'Erstellen'}
</button>
</div>
</div>
</Modal>
)}
{/* Token-Anzeige Modal */}
{shownToken && (
<Modal title='Gerät-Token jetzt sichern!' onClose={() => { setShownToken(null); setTokenCopied(false) }}>
<div className='space-y-4'>
<p className='text-sm text-gray-600'>
Token für <strong>{shownToken.deviceName}</strong>. Dieser Token wird <strong>nur einmalig</strong> angezeigt und kann danach nicht mehr abgerufen werden.
</p>
<div className='bg-gray-900 rounded-lg px-4 py-3 font-mono text-xs text-green-400 break-all select-all'>
{shownToken.token}
</div>
<button
onClick={() => copyToken(shownToken.token)}
className={`w-full py-2 text-sm font-semibold rounded-lg transition-colors ${
tokenCopied
? 'bg-green-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{tokenCopied ? 'Kopiert!' : 'In Zwischenablage kopieren'}
</button>
<div className='flex justify-end'>
<button
onClick={() => { setShownToken(null); setTokenCopied(false) }}
className='px-4 py-2 text-sm font-semibold text-white bg-blue-600 rounded-lg hover:bg-blue-700'
>
Token gesichert Schließen
</button>
</div>
</div>
</Modal>
)}
{/* Bearbeiten Modal */}
{editDevice && (
<Modal title={`Gerät bearbeiten ${editDevice.name}`} onClose={() => setEditDevice(null)}>
<div className='space-y-3'>
<label className='block'>
<span className='text-xs font-medium text-gray-700'>Name *</span>
<input
value={editName}
onChange={e => setEditName(e.target.value)}
className={inputClass}
autoFocus
/>
</label>
<label className='block'>
<span className='text-xs font-medium text-gray-700'>Standort</span>
<input
value={editLocation}
onChange={e => setEditLocation(e.target.value)}
className={inputClass}
placeholder='z.B. Erdgeschoss, Halle A'
/>
</label>
<label className='flex items-center gap-2 cursor-pointer'>
<input
type='checkbox'
checked={editActive}
onChange={e => setEditActive(e.target.checked)}
className='w-4 h-4 rounded border-gray-300 text-blue-600'
/>
<span className='text-sm text-gray-700'>Gerät aktiv</span>
</label>
{editError && <p className='text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2'>{editError}</p>}
<div className='flex justify-end gap-2 pt-2'>
<button onClick={() => setEditDevice(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={handleEdit} 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>
)
}