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
co-authored by Claude Sonnet 4.6
commit 1fedd683e0
178 changed files with 29896 additions and 0 deletions
+269
View File
@@ -0,0 +1,269 @@
import { useEffect, useState } from 'react'
import { api } from '../api/client'
import { Layout } from '../components/Layout'
interface UserOut {
id: string
first_name: string
last_name: string
email: string
role: string
}
interface PreviewEntry {
kind: 'time' | 'absence'
date_from: string
date_to: string
start: string | null
end: string | null
break_minutes: number
worked_hours: number | null
absence_type: string | null
note: string | null
skipped: boolean
skip_reason: string | null
}
interface PreviewResponse {
preview: PreviewEntry[]
time_count: number
absence_count: number
skip_count: number
errors: string[]
}
interface RunResponse {
time_imported: number
absence_imported: number
skipped: number
errors: string[]
}
export default function ImportPage() {
const [me, setMe] = useState<UserOut | null>(null)
const [users, setUsers] = useState<UserOut[]>([])
const [targetUserId, setTargetUserId] = useState('')
const [file, setFile] = useState<File | null>(null)
const [preview, setPreview] = useState<PreviewResponse | null>(null)
const [result, setResult] = useState<RunResponse | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
api.get<UserOut>('/auth/me').then(setMe).catch(() => {})
api.get<{ total: number; items: UserOut[] }>('/users/?limit=500')
.then(r => setUsers(r.items)).catch(() => {})
}, [])
async function loadPreview() {
if (!targetUserId || !file) return
setLoading(true)
setError(null)
setPreview(null)
setResult(null)
try {
const form = new FormData()
form.append('user_id', targetUserId)
form.append('file', file)
const r = await api.postForm<PreviewResponse>('/import/kimai/preview', form)
setPreview(r)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler beim Laden der Vorschau')
} finally {
setLoading(false)
}
}
async function runImport() {
if (!targetUserId || !file) return
setLoading(true)
setError(null)
setResult(null)
try {
const form = new FormData()
form.append('user_id', targetUserId)
form.append('file', file)
const r = await api.postForm<RunResponse>('/import/kimai/run', form)
setResult(r)
setPreview(null)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler beim Import')
} finally {
setLoading(false)
}
}
const selectedUser = users.find(u => u.id === targetUserId)
return (
<Layout userRole={me?.role ?? ''} userName={me ? `${me.first_name} ${me.last_name}` : ''}>
<div className="max-w-5xl mx-auto space-y-6">
<h1 className="text-2xl font-bold text-gray-900">Kimai-Import</h1>
{/* Konfiguration */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4">
<h2 className="font-semibold text-gray-700">Einstellungen</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Mitarbeiter (Ziel)
</label>
<select
value={targetUserId}
onChange={e => { setTargetUserId(e.target.value); setPreview(null); setResult(null) }}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value=""> Mitarbeiter wählen </option>
{users.map(u => (
<option key={u.id} value={u.id}>
{u.first_name} {u.last_name} ({u.email})
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Kimai CSV-Datei
</label>
<input
type="file"
accept=".csv"
onChange={e => { setFile(e.target.files?.[0] ?? null); setPreview(null); setResult(null) }}
className="w-full text-sm text-gray-700 border border-gray-300 rounded-lg px-3 py-2 file:mr-3 file:py-1 file:px-3 file:rounded file:border-0 file:text-sm file:bg-blue-50 file:text-blue-700"
/>
</div>
</div>
<div className="flex gap-3 pt-2">
<button
onClick={loadPreview}
disabled={!targetUserId || !file || loading}
className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium disabled:opacity-50 hover:bg-blue-700"
>
{loading && !preview ? 'Lade Vorschau…' : 'Vorschau laden'}
</button>
{preview && (
<button
onClick={runImport}
disabled={loading || (preview.time_count + preview.absence_count === 0)}
className="px-4 py-2 bg-green-600 text-white rounded-lg text-sm font-medium disabled:opacity-50 hover:bg-green-700"
>
{loading ? 'Importiere…' : `Import starten (${preview.time_count + preview.absence_count} Einträge)`}
</button>
)}
</div>
</div>
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-700">{error}</div>
)}
{/* Ergebnis nach Import */}
{result && (
<div className="bg-green-50 border border-green-200 rounded-xl p-6 space-y-2">
<h2 className="font-semibold text-green-800 text-lg">Import abgeschlossen</h2>
<div className="flex gap-6 text-sm text-green-700">
<span><strong>{result.time_imported}</strong> Zeiteinträge importiert</span>
<span><strong>{result.absence_imported}</strong> Abwesenheiten importiert</span>
{result.skipped > 0 && <span className="text-amber-600"><strong>{result.skipped}</strong> übersprungen</span>}
</div>
{result.errors.length > 0 && (
<ul className="mt-2 text-sm text-red-600 space-y-1">
{result.errors.map((e, i) => <li key={i}> {e}</li>)}
</ul>
)}
</div>
)}
{/* Vorschau-Tabelle */}
{preview && (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between">
<h2 className="font-semibold text-gray-700">
Vorschau für <span className="text-blue-600">
{selectedUser ? `${selectedUser.first_name} ${selectedUser.last_name}` : ''}
</span>
</h2>
<div className="flex gap-4 text-sm text-gray-500">
<span className="text-blue-600 font-medium">{preview.time_count} Zeiteinträge</span>
<span className="text-purple-600 font-medium">{preview.absence_count} Abwesenheiten</span>
{preview.skip_count > 0 && (
<span className="text-amber-600 font-medium">{preview.skip_count} übersprungen</span>
)}
</div>
</div>
{preview.errors.length > 0 && (
<div className="px-6 py-3 bg-red-50 border-b border-red-100">
{preview.errors.map((e, i) => (
<p key={i} className="text-sm text-red-600"> {e}</p>
))}
</div>
)}
<div className="overflow-x-auto max-h-[60vh] overflow-y-auto">
<table className="w-full text-sm">
<thead className="bg-gray-50 sticky top-0">
<tr>
<th className="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase">Typ</th>
<th className="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase">Datum</th>
<th className="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase">Beginn</th>
<th className="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase">Ende</th>
<th className="text-right px-4 py-3 text-xs font-medium text-gray-500 uppercase">Stunden</th>
<th className="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase">Notiz</th>
<th className="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{preview.preview.map((row, i) => (
<tr key={i} className={row.skipped ? 'opacity-40 bg-gray-50' : ''}>
<td className="px-4 py-2">
{row.kind === 'time' ? (
<span className="px-2 py-0.5 rounded-full text-xs bg-blue-100 text-blue-700">Zeit</span>
) : (
<span className="px-2 py-0.5 rounded-full text-xs bg-purple-100 text-purple-700">
{row.absence_type ?? 'Abwesenheit'}
</span>
)}
</td>
<td className="px-4 py-2 text-gray-700">
{row.date_from}
{row.date_to !== row.date_from && (
<span className="text-gray-400"> {row.date_to}</span>
)}
</td>
<td className="px-4 py-2 text-gray-700">{row.start ?? ''}</td>
<td className="px-4 py-2 text-gray-700">
{row.end ?? ''}
{row.break_minutes > 0 && (
<span className="ml-1 text-xs text-gray-400">({row.break_minutes}m Pause)</span>
)}
</td>
<td className="px-4 py-2 text-right text-gray-700">
{row.worked_hours != null ? `${row.worked_hours.toFixed(2)} h` : ''}
</td>
<td className="px-4 py-2 text-gray-500 max-w-xs truncate" title={row.note ?? ''}>
{row.note ?? ''}
</td>
<td className="px-4 py-2">
{row.skipped ? (
<span className="text-xs text-amber-600" title={row.skip_reason ?? ''}>
Übersprungen
</span>
) : (
<span className="text-xs text-green-600">Import</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
</Layout>
)
}