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>
270 lines
11 KiB
TypeScript
270 lines
11 KiB
TypeScript
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>
|
||
)
|
||
}
|