Files
timemaster/frontend/src/pages/ImportPage.tsx
T
sysopsandClaude Sonnet 4.6 45218cc744 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>
2026-05-23 20:03:27 +02:00

270 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}