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