From 5cbef969fb1bf4cb46a26852ef901d54e65a491b Mon Sep 17 00:00:00 2001 From: CCS Admin Date: Tue, 24 Feb 2026 08:32:18 +0000 Subject: [PATCH] feat: dashboard, cases, import, and ICD pages with full functionality Implement the four core frontend pages for the DAK Zweitmeinungs-Portal: - DashboardPage: KPI cards, weekly stacked bar chart, fallgruppen donut chart, year selector - CasesPage: filterable/searchable paginated table with detail slide-out and inline ICD editing - ImportPage: CSV upload with preview/confirm, ICD Excel upload, import history log - IcdPage: reuses CasesPage with pending-icd-only filter Also adds shadcn/ui components (table, select, tabs, skeleton, scroll-area) and new TypeScript types for import log and ICD import responses. Co-Authored-By: Claude Opus 4.6 --- frontend/src/App.tsx | 10 +- frontend/src/components/ui/scroll-area.tsx | 58 +++ frontend/src/components/ui/select.tsx | 190 +++++++ frontend/src/components/ui/skeleton.tsx | 13 + frontend/src/components/ui/table.tsx | 114 +++++ frontend/src/components/ui/tabs.tsx | 89 ++++ frontend/src/pages/CasesPage.tsx | 461 +++++++++++++++++ frontend/src/pages/DashboardPage.tsx | 220 +++++++++ frontend/src/pages/IcdPage.tsx | 5 + frontend/src/pages/ImportPage.tsx | 550 +++++++++++++++++++++ frontend/src/types/index.ts | 24 + 11 files changed, 1729 insertions(+), 5 deletions(-) create mode 100644 frontend/src/components/ui/scroll-area.tsx create mode 100644 frontend/src/components/ui/select.tsx create mode 100644 frontend/src/components/ui/skeleton.tsx create mode 100644 frontend/src/components/ui/table.tsx create mode 100644 frontend/src/components/ui/tabs.tsx create mode 100644 frontend/src/pages/CasesPage.tsx create mode 100644 frontend/src/pages/DashboardPage.tsx create mode 100644 frontend/src/pages/IcdPage.tsx create mode 100644 frontend/src/pages/ImportPage.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3008021..e123dfa 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,12 +4,12 @@ import { ProtectedRoute } from '@/components/layout/ProtectedRoute' import { AppLayout } from '@/components/layout/AppLayout' import { LoginPage } from '@/pages/LoginPage' import { RegisterPage } from '@/pages/RegisterPage' +import { DashboardPage } from '@/pages/DashboardPage' +import { CasesPage } from '@/pages/CasesPage' +import { ImportPage } from '@/pages/ImportPage' +import { IcdPage } from '@/pages/IcdPage' -// Placeholder pages for now -function DashboardPage() { return

Dashboard

} -function CasesPage() { return

Faelle

} -function ImportPage() { return

Import

} -function IcdPage() { return

ICD-Eingabe

} +// Placeholder pages for features not yet implemented function CodingPage() { return

Coding

} function ReportsPage() { return

Berichte

} function AdminUsersPage() { return

Benutzer

} diff --git a/frontend/src/components/ui/scroll-area.tsx b/frontend/src/components/ui/scroll-area.tsx new file mode 100644 index 0000000..0f873dc --- /dev/null +++ b/frontend/src/components/ui/scroll-area.tsx @@ -0,0 +1,58 @@ +"use client" + +import * as React from "react" +import { ScrollArea as ScrollAreaPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function ScrollArea({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + + ) +} + +function ScrollBar({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { ScrollArea, ScrollBar } diff --git a/frontend/src/components/ui/select.tsx b/frontend/src/components/ui/select.tsx new file mode 100644 index 0000000..fd01b74 --- /dev/null +++ b/frontend/src/components/ui/select.tsx @@ -0,0 +1,190 @@ +"use client" + +import * as React from "react" +import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react" +import { Select as SelectPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Select({ + ...props +}: React.ComponentProps) { + return +} + +function SelectGroup({ + ...props +}: React.ComponentProps) { + return +} + +function SelectValue({ + ...props +}: React.ComponentProps) { + return +} + +function SelectTrigger({ + className, + size = "default", + children, + ...props +}: React.ComponentProps & { + size?: "sm" | "default" +}) { + return ( + + {children} + + + + + ) +} + +function SelectContent({ + className, + children, + position = "item-aligned", + align = "center", + ...props +}: React.ComponentProps) { + return ( + + + + + {children} + + + + + ) +} + +function SelectLabel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function SelectSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectScrollUpButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +} diff --git a/frontend/src/components/ui/skeleton.tsx b/frontend/src/components/ui/skeleton.tsx new file mode 100644 index 0000000..32ea0ef --- /dev/null +++ b/frontend/src/components/ui/skeleton.tsx @@ -0,0 +1,13 @@ +import { cn } from "@/lib/utils" + +function Skeleton({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { Skeleton } diff --git a/frontend/src/components/ui/table.tsx b/frontend/src/components/ui/table.tsx new file mode 100644 index 0000000..5513a5c --- /dev/null +++ b/frontend/src/components/ui/table.tsx @@ -0,0 +1,114 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Table({ className, ...props }: React.ComponentProps<"table">) { + return ( +
+ + + ) +} + +function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { + return ( + + ) +} + +function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { + return ( + + ) +} + +function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { + return ( + tr]:last:border-b-0", + className + )} + {...props} + /> + ) +} + +function TableRow({ className, ...props }: React.ComponentProps<"tr">) { + return ( + + ) +} + +function TableHead({ className, ...props }: React.ComponentProps<"th">) { + return ( +
[role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> + ) +} + +function TableCell({ className, ...props }: React.ComponentProps<"td">) { + return ( + [role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> + ) +} + +function TableCaption({ + className, + ...props +}: React.ComponentProps<"caption">) { + return ( +
+ ) +} + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} diff --git a/frontend/src/components/ui/tabs.tsx b/frontend/src/components/ui/tabs.tsx new file mode 100644 index 0000000..7bf18aa --- /dev/null +++ b/frontend/src/components/ui/tabs.tsx @@ -0,0 +1,89 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { Tabs as TabsPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Tabs({ + className, + orientation = "horizontal", + ...props +}: React.ComponentProps) { + return ( + + ) +} + +const tabsListVariants = cva( + "rounded-lg p-[3px] group-data-[orientation=horizontal]/tabs:h-9 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col", + { + variants: { + variant: { + default: "bg-muted", + line: "gap-1 bg-transparent", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function TabsList({ + className, + variant = "default", + ...props +}: React.ComponentProps & + VariantProps) { + return ( + + ) +} + +function TabsTrigger({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function TabsContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants } diff --git a/frontend/src/pages/CasesPage.tsx b/frontend/src/pages/CasesPage.tsx new file mode 100644 index 0000000..f4e6c8f --- /dev/null +++ b/frontend/src/pages/CasesPage.tsx @@ -0,0 +1,461 @@ +import { useState, useEffect, useCallback, useRef } from 'react' +import { + Search, ChevronLeft, ChevronRight, Save, CheckCircle, XCircle, +} from 'lucide-react' +import api from '@/services/api' +import type { Case, CaseListResponse } from '@/types' +import { Input } from '@/components/ui/input' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from '@/components/ui/select' +import { + Table, TableBody, TableCell, TableHead, TableHeader, TableRow, +} from '@/components/ui/table' +import { + Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, +} from '@/components/ui/sheet' +import { Skeleton } from '@/components/ui/skeleton' +import { Alert, AlertDescription } from '@/components/ui/alert' + +const FALLGRUPPEN_LABELS: Record = { + onko: 'Onkologie', + kardio: 'Kardiologie', + intensiv: 'Intensivmedizin', + galle: 'Gallenblase', + sd: 'Schilddrüse', +} + +const FALLGRUPPEN_OPTIONS = [ + { value: '__all__', label: 'Alle Fallgruppen' }, + { value: 'onko', label: 'Onkologie' }, + { value: 'kardio', label: 'Kardiologie' }, + { value: 'intensiv', label: 'Intensivmedizin' }, + { value: 'galle', label: 'Gallenblase' }, + { value: 'sd', label: 'Schilddrüse' }, +] + +const ICD_OPTIONS = [ + { value: '__all__', label: 'Alle' }, + { value: 'true', label: 'Mit ICD' }, + { value: 'false', label: 'Ohne ICD' }, +] + +interface CasesPageProps { + /** When true, fetches from pending-icd endpoint instead */ + pendingIcdOnly?: boolean +} + +export function CasesPage({ pendingIcdOnly = false }: CasesPageProps) { + const currentYear = new Date().getFullYear() + const [search, setSearch] = useState('') + const [debouncedSearch, setDebouncedSearch] = useState('') + const [jahr, setJahr] = useState('__all__') + const [fallgruppe, setFallgruppe] = useState('__all__') + const [hasIcd, setHasIcd] = useState('__all__') + const [page, setPage] = useState(1) + const [perPage] = useState(50) + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [selectedCase, setSelectedCase] = useState(null) + const [sheetOpen, setSheetOpen] = useState(false) + + // Debounce search + const debounceRef = useRef | null>(null) + const handleSearchChange = useCallback((val: string) => { + setSearch(val) + if (debounceRef.current) clearTimeout(debounceRef.current) + debounceRef.current = setTimeout(() => { + setDebouncedSearch(val) + setPage(1) + }, 300) + }, []) + + useEffect(() => { + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current) + } + }, []) + + // Fetch cases + useEffect(() => { + setLoading(true) + + if (pendingIcdOnly) { + api.get('/cases/pending-icd', { + params: { page, per_page: perPage }, + }) + .then((res) => setData(res.data)) + .catch(() => setData(null)) + .finally(() => setLoading(false)) + } else { + const params: Record = { page, per_page: perPage } + if (debouncedSearch) params.search = debouncedSearch + if (jahr !== '__all__') params.jahr = Number(jahr) + if (fallgruppe !== '__all__') params.fallgruppe = fallgruppe + if (hasIcd !== '__all__') params.has_icd = hasIcd + + api.get('/cases/', { params }) + .then((res) => setData(res.data)) + .catch(() => setData(null)) + .finally(() => setLoading(false)) + } + }, [page, perPage, debouncedSearch, jahr, fallgruppe, hasIcd, pendingIcdOnly]) + + const totalPages = data ? Math.ceil(data.total / perPage) : 0 + const years = Array.from({ length: 5 }, (_, i) => currentYear - i) + + const openDetail = (c: Case) => { + setSelectedCase(c) + setSheetOpen(true) + } + + const handleIcdSaved = (updated: Case) => { + setSelectedCase(updated) + // Refresh list + setData((prev) => + prev + ? { + ...prev, + items: prev.items.map((c) => (c.id === updated.id ? updated : c)), + } + : prev, + ) + } + + return ( +
+

+ {pendingIcdOnly ? 'ICD-Eingabe' : 'Fälle'} +

+ + {/* Filter bar */} + {!pendingIcdOnly && ( +
+
+ + handleSearchChange(e.target.value)} + className="pl-9" + /> +
+ + + +
+ )} + + {/* Table */} + {loading ? ( +
+ {Array.from({ length: 8 }).map((_, i) => ( + + ))} +
+ ) : data && data.items.length > 0 ? ( + <> + + + + Fall-ID + Datum + Nachname + Vorname + Fallgruppe + ICD + Gutachten + Status + + + + {data.items.map((c) => ( + openDetail(c)} + > + + {c.fall_id || '-'} + + {formatDate(c.datum)} + {c.nachname} + {c.vorname || '-'} + + + {FALLGRUPPEN_LABELS[c.fallgruppe] || c.fallgruppe} + + + + {c.icd ? ( + {c.icd} + ) : ( + - + )} + + + {c.gutachten ? ( + + ) : ( + + )} + + + + + + ))} + +
+ + {/* Pagination */} +
+

+ {data.total} Fälle insgesamt +

+
+ + + Seite {page} von {totalPages || 1} + + +
+
+ + ) : ( +

+ Keine Fälle gefunden. +

+ )} + + {/* Detail Sheet */} + + + {selectedCase && ( + + )} + + +
+ ) +} + +function StatusBadges({ c }: { c: Case }) { + return ( +
+ {c.unterlagen && Unterlagen} + {c.gutachten && Gutachten} + {c.abgerechnet && Abgerechnet} + {c.ablehnung && Abgelehnt} + {c.abbruch && Abbruch} +
+ ) +} + +function CaseDetail({ + caseData, + onIcdSaved, +}: { + caseData: Case + onIcdSaved: (updated: Case) => void +}) { + const [icdValue, setIcdValue] = useState(caseData.icd || '') + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + const [success, setSuccess] = useState(false) + + // Reset on case change + useEffect(() => { + setIcdValue(caseData.icd || '') + setError('') + setSuccess(false) + }, [caseData.id, caseData.icd]) + + const saveIcd = async () => { + if (!icdValue.trim()) return + setSaving(true) + setError('') + setSuccess(false) + try { + const res = await api.put(`/cases/${caseData.id}/icd`, { icd: icdValue.trim() }) + onIcdSaved(res.data) + setSuccess(true) + } catch { + setError('Fehler beim Speichern des ICD-Codes.') + } finally { + setSaving(false) + } + } + + return ( + <> + + + Fall {caseData.fall_id || `#${caseData.id}`} + + + {FALLGRUPPEN_LABELS[caseData.fallgruppe] || caseData.fallgruppe} — KW {caseData.kw}/{caseData.jahr} + + + +
+ {/* Status badges */} +
+ +
+ + {/* Patient info */} +
+ + + + + + +
+ + {/* Case details */} +
+ + + + + + +
+ + {/* ICD edit */} +
+ +
+ { setIcdValue(e.target.value); setSuccess(false) }} + placeholder="z.B. C50.9" + className="flex-1" + /> + +
+ {error && ( + + {error} + + )} + {success && ( +

ICD-Code gespeichert.

+ )} +
+ + {/* Description fields */} + {caseData.kurzbeschreibung && ( +
+ +

{caseData.kurzbeschreibung}

+
+ )} + {caseData.fragestellung && ( +
+ +

{caseData.fragestellung}

+
+ )} + {caseData.kommentar && ( +
+ +

{caseData.kommentar}

+
+ )} + +
+

Importiert: {formatDateTime(caseData.imported_at)}

+

Aktualisiert: {formatDateTime(caseData.updated_at)}

+ {caseData.import_source &&

Quelle: {caseData.import_source}

} +
+
+ + ) +} + +function DetailField({ label, value }: { label: string; value: string | null | undefined }) { + return ( +
+ {label} +

{value || '-'}

+
+ ) +} + +function formatDate(dateStr: string): string { + try { + return new Date(dateStr).toLocaleDateString('de-DE', { + day: '2-digit', month: '2-digit', year: 'numeric', + }) + } catch { + return dateStr + } +} + +function formatDateTime(dateStr: string): string { + try { + return new Date(dateStr).toLocaleString('de-DE', { + day: '2-digit', month: '2-digit', year: 'numeric', + hour: '2-digit', minute: '2-digit', + }) + } catch { + return dateStr + } +} diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx new file mode 100644 index 0000000..458a153 --- /dev/null +++ b/frontend/src/pages/DashboardPage.tsx @@ -0,0 +1,220 @@ +import { useState, useEffect } from 'react' +import { + BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, + PieChart, Pie, Cell, ResponsiveContainer, +} from 'recharts' +import { FileText, Clock, Code, Stethoscope } from 'lucide-react' +import api from '@/services/api' +import type { DashboardResponse } from '@/types' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from '@/components/ui/select' +import { Skeleton } from '@/components/ui/skeleton' + +const FALLGRUPPEN_LABELS: Record = { + onko: 'Onkologie', + kardio: 'Kardiologie', + intensiv: 'Intensivmedizin', + galle: 'Gallenblase', + sd: 'Schilddrüse', +} + +const CHART_COLORS = [ + 'var(--chart-1)', + 'var(--chart-2)', + 'var(--chart-3)', + 'var(--chart-4)', + 'var(--chart-5)', +] + +export function DashboardPage() { + const currentYear = new Date().getFullYear() + const [jahr, setJahr] = useState(currentYear) + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + setLoading(true) + api.get('/reports/dashboard', { params: { jahr } }) + .then((res) => setData(res.data)) + .catch(() => setData(null)) + .finally(() => setLoading(false)) + }, [jahr]) + + const fallgruppenData = data + ? Object.entries(data.kpis.fallgruppen).map(([key, value]) => ({ + name: FALLGRUPPEN_LABELS[key] || key, + value, + })) + : [] + + const years = Array.from({ length: 5 }, (_, i) => currentYear - i) + + return ( +
+ {/* Header with year selector */} +
+

Dashboard

+ +
+ + {/* KPI Cards */} + {loading ? ( +
+ {[1, 2, 3, 4].map((i) => ( + + + + + + + + + ))} +
+ ) : data ? ( +
+ } + /> + } + /> + } + /> + } + /> +
+ ) : ( +

Keine Daten verfügbar.

+ )} + + {/* Charts row */} + {data && ( +
+ {/* Weekly bar chart - takes 2 cols */} + + + Wöchentliche Übersicht + + + + + + `KW ${v}`} + className="text-xs" + /> + + `KW ${v}`} + contentStyle={{ + backgroundColor: 'var(--popover)', + border: '1px solid var(--border)', + borderRadius: '8px', + color: 'var(--popover-foreground)', + }} + /> + + + + + + + + + + {/* Fallgruppen pie chart */} + + + Fallgruppen + + + + + `${name}: ${value}`} + > + {fallgruppenData.map((_, idx) => ( + + ))} + + + + + + +
+ )} +
+ ) +} + +function KpiCard({ title, value, icon }: { title: string; value: number; icon: React.ReactNode }) { + return ( + + + {title} + {icon} + + +
{value.toLocaleString('de-DE')}
+
+
+ ) +} diff --git a/frontend/src/pages/IcdPage.tsx b/frontend/src/pages/IcdPage.tsx new file mode 100644 index 0000000..8bb5950 --- /dev/null +++ b/frontend/src/pages/IcdPage.tsx @@ -0,0 +1,5 @@ +import { CasesPage } from './CasesPage' + +export function IcdPage() { + return +} diff --git a/frontend/src/pages/ImportPage.tsx b/frontend/src/pages/ImportPage.tsx new file mode 100644 index 0000000..3c66876 --- /dev/null +++ b/frontend/src/pages/ImportPage.tsx @@ -0,0 +1,550 @@ +import { useState, useEffect, useCallback, useRef } from 'react' +import { + Upload, FileUp, Check, AlertTriangle, ChevronLeft, ChevronRight, History, +} from 'lucide-react' +import api from '@/services/api' +import type { + ImportPreview, ImportResult, ICDImportResponse, + ImportLogEntry, ImportLogListResponse, +} from '@/types' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { + Table, TableBody, TableCell, TableHead, TableHeader, TableRow, +} from '@/components/ui/table' +import { + Tabs, TabsContent, TabsList, TabsTrigger, +} from '@/components/ui/tabs' +import { Skeleton } from '@/components/ui/skeleton' + +export function ImportPage() { + return ( +
+

Import

+ + + + + CSV-Import + + + + ICD-Import + + + + Import-Verlauf + + + + + + + + + + + + + +
+ ) +} + +// --------------------------------------------------------------------------- +// CSV Import Tab +// --------------------------------------------------------------------------- + +function CsvImportTab() { + const [file, setFile] = useState(null) + const [uploading, setUploading] = useState(false) + const [confirming, setConfirming] = useState(false) + const [preview, setPreview] = useState(null) + const [result, setResult] = useState(null) + const [error, setError] = useState('') + const fileInputRef = useRef(null) + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault() + const f = e.dataTransfer.files[0] + if (f && f.name.toLowerCase().endsWith('.csv')) { + setFile(f) + setPreview(null) + setResult(null) + setError('') + } + }, []) + + const handleFileSelect = (e: React.ChangeEvent) => { + const f = e.target.files?.[0] + if (f) { + setFile(f) + setPreview(null) + setResult(null) + setError('') + } + } + + const uploadPreview = async () => { + if (!file) return + setUploading(true) + setError('') + try { + const formData = new FormData() + formData.append('file', file) + const res = await api.post('/import/csv', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }) + setPreview(res.data) + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : 'Upload fehlgeschlagen' + setError(msg) + } finally { + setUploading(false) + } + } + + const confirmImport = async () => { + if (!file) return + setConfirming(true) + setError('') + try { + const formData = new FormData() + formData.append('file', file) + const res = await api.post('/import/csv/confirm', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }) + setResult(res.data) + setPreview(null) + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : 'Import fehlgeschlagen' + setError(msg) + } finally { + setConfirming(false) + } + } + + const reset = () => { + setFile(null) + setPreview(null) + setResult(null) + setError('') + if (fileInputRef.current) fileInputRef.current.value = '' + } + + return ( +
+ {/* Drop zone */} + {!preview && !result && ( +
e.preventDefault()} + onDrop={handleDrop} + className="border-2 border-dashed rounded-lg p-10 text-center cursor-pointer hover:border-primary/50 transition-colors" + onClick={() => fileInputRef.current?.click()} + > + + + {file ? ( +

+ {file.name} + + ({(file.size / 1024).toFixed(1)} KB) + +

+ ) : ( +
+

CSV-Datei hierhin ziehen

+

+ oder klicken zum Auswählen +

+
+ )} +
+ )} + + {file && !preview && !result && ( +
+ + +
+ )} + + {error && ( + + + {error} + + )} + + {/* Preview */} + {preview && ( +
+ + + + Vorschau: {preview.filename} + + + +
+
+ Zeilen gesamt:{' '} + {preview.total_rows} +
+
+ Neue Fälle:{' '} + {preview.new_cases} +
+
+ Duplikate:{' '} + {preview.duplicates} +
+ {preview.errors.length > 0 && ( +
+ Fehler:{' '} + {preview.errors.length} +
+ )} +
+
+
+ + {preview.errors.length > 0 && ( + + + +
    + {preview.errors.map((e, i) =>
  • {e}
  • )} +
+
+
+ )} + + {/* Preview table */} + + + + # + Nachname + Vorname + Fallgruppe + Datum + Status + + + + {preview.rows.map((row) => ( + + {row.row_number} + {row.nachname} + {row.vorname || '-'} + {row.fallgruppe} + {row.datum} + + {row.is_duplicate ? ( + + Duplikat + + ) : ( + + Neu + + )} + + + ))} + +
+ +
+ + +
+
+ )} + + {/* Result */} + {result && ( +
+ + + + Import abgeschlossen: {result.imported} importiert, {result.skipped} übersprungen, {result.updated} aktualisiert. + + + {result.errors.length > 0 && ( + + + +
    + {result.errors.map((e, i) =>
  • {e}
  • )} +
+
+
+ )} + +
+ )} +
+ ) +} + +// --------------------------------------------------------------------------- +// ICD Import Tab +// --------------------------------------------------------------------------- + +function IcdImportTab() { + const [file, setFile] = useState(null) + const [uploading, setUploading] = useState(false) + const [result, setResult] = useState(null) + const [error, setError] = useState('') + const fileInputRef = useRef(null) + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault() + const f = e.dataTransfer.files[0] + if (f && (f.name.toLowerCase().endsWith('.xlsx') || f.name.toLowerCase().endsWith('.xls'))) { + setFile(f) + setResult(null) + setError('') + } + }, []) + + const handleFileSelect = (e: React.ChangeEvent) => { + const f = e.target.files?.[0] + if (f) { + setFile(f) + setResult(null) + setError('') + } + } + + const upload = async () => { + if (!file) return + setUploading(true) + setError('') + try { + const formData = new FormData() + formData.append('file', file) + const res = await api.post('/import/icd-xlsx', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }) + setResult(res.data) + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : 'Upload fehlgeschlagen' + setError(msg) + } finally { + setUploading(false) + } + } + + const reset = () => { + setFile(null) + setResult(null) + setError('') + if (fileInputRef.current) fileInputRef.current.value = '' + } + + return ( +
+ {!result && ( +
e.preventDefault()} + onDrop={handleDrop} + className="border-2 border-dashed rounded-lg p-10 text-center cursor-pointer hover:border-primary/50 transition-colors" + onClick={() => fileInputRef.current?.click()} + > + + + {file ? ( +

+ {file.name} + + ({(file.size / 1024).toFixed(1)} KB) + +

+ ) : ( +
+

Excel-Datei (.xlsx) hierhin ziehen

+

+ oder klicken zum Auswählen +

+
+ )} +
+ )} + + {file && !result && ( +
+ + +
+ )} + + {error && ( + + + {error} + + )} + + {result && ( +
+ + + + ICD-Import abgeschlossen: {result.updated} Fälle aktualisiert. + + + {result.errors.length > 0 && ( + + + +
    + {result.errors.map((e, i) =>
  • {e}
  • )} +
+
+
+ )} + +
+ )} +
+ ) +} + +// --------------------------------------------------------------------------- +// Import Log Tab +// --------------------------------------------------------------------------- + +function ImportLogTab() { + const [logs, setLogs] = useState([]) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [loading, setLoading] = useState(true) + const perPage = 20 + + useEffect(() => { + setLoading(true) + api.get('/import/log', { + params: { page, per_page: perPage }, + }) + .then((res) => { + setLogs(res.data.items) + setTotal(res.data.total) + }) + .catch(() => { + setLogs([]) + setTotal(0) + }) + .finally(() => setLoading(false)) + }, [page]) + + const totalPages = Math.ceil(total / perPage) + + if (loading) { + return ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) + } + + if (logs.length === 0) { + return

Keine Import-Einträge vorhanden.

+ } + + return ( +
+ + + + Datum + Dateiname + Typ + Importiert + Übersprungen + Fehler + + + + {logs.map((log) => ( + + {formatDateTime(log.imported_at)} + {log.filename} + + {log.import_type} + + {log.imported_rows} + {log.skipped_rows} + + {log.error_count > 0 ? ( + {log.error_count} + ) : ( + '0' + )} + + + ))} + +
+ + {totalPages > 1 && ( +
+ + + Seite {page} von {totalPages} + + +
+ )} +
+ ) +} + +function formatDateTime(dateStr: string): string { + try { + return new Date(dateStr).toLocaleString('de-DE', { + day: '2-digit', month: '2-digit', year: 'numeric', + hour: '2-digit', minute: '2-digit', + }) + } catch { + return dateStr + } +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 52c53eb..e83daea 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -131,3 +131,27 @@ export interface ReportMeta { report_date: string generated_at: string } + +export interface ICDImportResponse { + updated: number + errors: string[] +} + +export interface ImportLogEntry { + id: number + filename: string + import_type: string + total_rows: number + imported_rows: number + skipped_rows: number + error_count: number + imported_by: number + imported_at: string +} + +export interface ImportLogListResponse { + items: ImportLogEntry[] + total: number + page: number + per_page: number +}