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