diff --git a/frontend/package.json b/frontend/package.json
index 1a34910..4d6ad42 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -10,6 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
+ "@hookform/resolvers": "^5.2.2",
"axios": "^1.13.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -17,9 +18,11 @@
"radix-ui": "^1.4.3",
"react": "^19.2.0",
"react-dom": "^19.2.0",
+ "react-hook-form": "^7.71.2",
"react-router-dom": "^7.13.1",
"recharts": "^3.7.0",
- "tailwind-merge": "^3.5.0"
+ "tailwind-merge": "^3.5.0",
+ "zod": "^4.3.6"
},
"pnpm": {
"onlyBuiltDependencies": [
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index e60e99a..217962c 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -8,6 +8,9 @@ importers:
.:
dependencies:
+ '@hookform/resolvers':
+ specifier: ^5.2.2
+ version: 5.2.2(react-hook-form@7.71.2(react@19.2.4))
axios:
specifier: ^1.13.5
version: 1.13.5
@@ -29,6 +32,9 @@ importers:
react-dom:
specifier: ^19.2.0
version: 19.2.4(react@19.2.4)
+ react-hook-form:
+ specifier: ^7.71.2
+ version: 7.71.2(react@19.2.4)
react-router-dom:
specifier: ^7.13.1
version: 7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -38,6 +44,9 @@ importers:
tailwind-merge:
specifier: ^3.5.0
version: 3.5.0
+ zod:
+ specifier: ^4.3.6
+ version: 4.3.6
devDependencies:
'@eslint/js':
specifier: ^9.39.1
@@ -460,6 +469,11 @@ packages:
peerDependencies:
hono: ^4
+ '@hookform/resolvers@5.2.2':
+ resolution: {integrity: sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==}
+ peerDependencies:
+ react-hook-form: ^7.55.0
+
'@humanfs/core@0.19.1':
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
engines: {node: '>=18.18.0'}
@@ -2905,6 +2919,12 @@ packages:
peerDependencies:
react: ^19.2.4
+ react-hook-form@7.71.2:
+ resolution: {integrity: sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==}
+ engines: {node: '>=18.0.0'}
+ peerDependencies:
+ react: ^16.8.0 || ^17 || ^18 || ^19
+
react-is@19.2.4:
resolution: {integrity: sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==}
@@ -3799,6 +3819,11 @@ snapshots:
dependencies:
hono: 4.12.2
+ '@hookform/resolvers@5.2.2(react-hook-form@7.71.2(react@19.2.4))':
+ dependencies:
+ '@standard-schema/utils': 0.3.0
+ react-hook-form: 7.71.2(react@19.2.4)
+
'@humanfs/core@0.19.1': {}
'@humanfs/node@0.16.7':
@@ -6224,6 +6249,10 @@ snapshots:
react: 19.2.4
scheduler: 0.27.0
+ react-hook-form@7.71.2(react@19.2.4):
+ dependencies:
+ react: 19.2.4
+
react-is@19.2.4: {}
react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1):
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index dc7e4a2..3008021 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,8 +1,43 @@
+import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
+import { AuthProvider } from '@/context/AuthContext'
+import { ProtectedRoute } from '@/components/layout/ProtectedRoute'
+import { AppLayout } from '@/components/layout/AppLayout'
+import { LoginPage } from '@/pages/LoginPage'
+import { RegisterPage } from '@/pages/RegisterPage'
+
+// Placeholder pages for now
+function DashboardPage() { return
Dashboard
}
+function CasesPage() { return Faelle
}
+function ImportPage() { return Import
}
+function IcdPage() { return ICD-Eingabe
}
+function CodingPage() { return Coding
}
+function ReportsPage() { return Berichte
}
+function AdminUsersPage() { return Benutzer
}
+function AdminInvitationsPage() { return Einladungen
}
+function AdminAuditPage() { return Audit-Log
}
+
function App() {
return (
-
-
DAK Zweitmeinungs-Portal
-
+
+
+
+ } />
+ } />
+ }>
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+
+
)
}
diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx
new file mode 100644
index 0000000..0694510
--- /dev/null
+++ b/frontend/src/components/layout/AppLayout.tsx
@@ -0,0 +1,34 @@
+import { useState } from 'react'
+import { Outlet } from 'react-router-dom'
+import { Sidebar } from './Sidebar'
+import { Header } from './Header'
+import { Sheet, SheetContent, SheetTitle } from '@/components/ui/sheet'
+
+export function AppLayout() {
+ const [sidebarOpen, setSidebarOpen] = useState(false)
+
+ return (
+
+ {/* Desktop sidebar */}
+
+
+
+
+ {/* Mobile sidebar */}
+
+
+ Navigation
+
+
+
+
+ {/* Main content */}
+
+ setSidebarOpen(true)} />
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/components/layout/Header.tsx b/frontend/src/components/layout/Header.tsx
new file mode 100644
index 0000000..fbe477f
--- /dev/null
+++ b/frontend/src/components/layout/Header.tsx
@@ -0,0 +1,85 @@
+import { useAuth } from '@/context/AuthContext'
+import { Avatar, AvatarFallback } from '@/components/ui/avatar'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu'
+import { Bell, LogOut, Menu } from 'lucide-react'
+
+interface HeaderProps {
+ onToggleSidebar: () => void
+}
+
+export function Header({ onToggleSidebar }: HeaderProps) {
+ const { user, logout } = useAuth()
+
+ const initials = user?.username
+ ? user.username
+ .split(/[\s._-]/)
+ .map((part) => part[0])
+ .join('')
+ .toUpperCase()
+ .slice(0, 2)
+ : '??'
+
+ const roleBadgeVariant = user?.role === 'admin' ? 'default' as const : 'secondary' as const
+ const roleLabel = user?.role === 'admin' ? 'Admin' : 'DAK Mitarbeiter'
+
+ return (
+
+
+
+
+
+ {/* Notification bell placeholder (Task 27) */}
+
+
+ {/* User menu */}
+
+
+
+
+
+
+
+
{user?.username}
+
{user?.email}
+
+ {roleLabel}
+
+
+
+
+ logout()} className="cursor-pointer">
+
+ Abmelden
+
+
+
+
+ )
+}
diff --git a/frontend/src/components/layout/ProtectedRoute.tsx b/frontend/src/components/layout/ProtectedRoute.tsx
new file mode 100644
index 0000000..9f0ff0f
--- /dev/null
+++ b/frontend/src/components/layout/ProtectedRoute.tsx
@@ -0,0 +1,15 @@
+import { Navigate } from 'react-router-dom'
+import { useAuth } from '@/context/AuthContext'
+
+export function ProtectedRoute({ children, requireAdmin = false }: {
+ children: React.ReactNode
+ requireAdmin?: boolean
+}) {
+ const { user, isLoading } = useAuth()
+
+ if (isLoading) return Laden...
+ if (!user) return
+ if (requireAdmin && user.role !== 'admin') return Zugriff verweigert
+
+ return <>{children}>
+}
diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx
new file mode 100644
index 0000000..0351154
--- /dev/null
+++ b/frontend/src/components/layout/Sidebar.tsx
@@ -0,0 +1,101 @@
+import { NavLink } from 'react-router-dom'
+import { useAuth } from '@/context/AuthContext'
+import { cn } from '@/lib/utils'
+import { Separator } from '@/components/ui/separator'
+import {
+ LayoutDashboard,
+ FolderOpen,
+ Upload,
+ FileEdit,
+ ClipboardCheck,
+ FileBarChart,
+ Users,
+ Mail,
+ History,
+} from 'lucide-react'
+
+interface NavItem {
+ label: string
+ to: string
+ icon: React.ComponentType<{ className?: string }>
+ adminOnly?: boolean
+ mitarbeiterOnly?: boolean
+}
+
+const mainNavItems: NavItem[] = [
+ { label: 'Dashboard', to: '/dashboard', icon: LayoutDashboard },
+ { label: 'Faelle', to: '/cases', icon: FolderOpen },
+ { label: 'Import', to: '/import', icon: Upload, adminOnly: true },
+ { label: 'ICD-Eingabe', to: '/icd', icon: FileEdit, mitarbeiterOnly: true },
+ { label: 'Coding', to: '/coding', icon: ClipboardCheck, adminOnly: true },
+ { label: 'Berichte', to: '/reports', icon: FileBarChart },
+]
+
+const adminNavItems: NavItem[] = [
+ { label: 'Benutzer', to: '/admin/users', icon: Users },
+ { label: 'Einladungen', to: '/admin/invitations', icon: Mail },
+ { label: 'Audit-Log', to: '/admin/audit', icon: History },
+]
+
+function NavItemLink({ item }: { item: NavItem }) {
+ const Icon = item.icon
+ return (
+
+ cn(
+ 'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
+ isActive
+ ? 'bg-primary text-primary-foreground'
+ : 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
+ )
+ }
+ >
+
+ {item.label}
+
+ )
+}
+
+export function Sidebar({ className }: { className?: string }) {
+ const { user, isAdmin } = useAuth()
+
+ const visibleMainItems = mainNavItems.filter((item) => {
+ if (item.adminOnly && !isAdmin) return false
+ if (item.mitarbeiterOnly && user?.role !== 'dak_mitarbeiter') return false
+ return true
+ })
+
+ return (
+
+ )
+}
diff --git a/frontend/src/components/ui/alert.tsx b/frontend/src/components/ui/alert.tsx
new file mode 100644
index 0000000..1421354
--- /dev/null
+++ b/frontend/src/components/ui/alert.tsx
@@ -0,0 +1,66 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const alertVariants = cva(
+ "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
+ {
+ variants: {
+ variant: {
+ default: "bg-card text-card-foreground",
+ destructive:
+ "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+function Alert({
+ className,
+ variant,
+ ...props
+}: React.ComponentProps<"div"> & VariantProps) {
+ return (
+
+ )
+}
+
+function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AlertDescription({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export { Alert, AlertTitle, AlertDescription }
diff --git a/frontend/src/components/ui/avatar.tsx b/frontend/src/components/ui/avatar.tsx
new file mode 100644
index 0000000..18f71e7
--- /dev/null
+++ b/frontend/src/components/ui/avatar.tsx
@@ -0,0 +1,107 @@
+import * as React from "react"
+import { Avatar as AvatarPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+function Avatar({
+ className,
+ size = "default",
+ ...props
+}: React.ComponentProps & {
+ size?: "default" | "sm" | "lg"
+}) {
+ return (
+
+ )
+}
+
+function AvatarImage({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AvatarFallback({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+ svg]:hidden",
+ "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
+ "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AvatarGroupCount({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+ svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+export {
+ Avatar,
+ AvatarImage,
+ AvatarFallback,
+ AvatarBadge,
+ AvatarGroup,
+ AvatarGroupCount,
+}
diff --git a/frontend/src/components/ui/badge.tsx b/frontend/src/components/ui/badge.tsx
new file mode 100644
index 0000000..beb56ed
--- /dev/null
+++ b/frontend/src/components/ui/badge.tsx
@@ -0,0 +1,48 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+import { Slot } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+const badgeVariants = cva(
+ "inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
+ secondary:
+ "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
+ destructive:
+ "bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+ outline:
+ "border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
+ ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
+ link: "text-primary underline-offset-4 [a&]:hover:underline",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+function Badge({
+ className,
+ variant = "default",
+ asChild = false,
+ ...props
+}: React.ComponentProps<"span"> &
+ VariantProps
& { asChild?: boolean }) {
+ const Comp = asChild ? Slot.Root : "span"
+
+ return (
+
+ )
+}
+
+export { Badge, badgeVariants }
diff --git a/frontend/src/components/ui/dropdown-menu.tsx b/frontend/src/components/ui/dropdown-menu.tsx
new file mode 100644
index 0000000..bffc327
--- /dev/null
+++ b/frontend/src/components/ui/dropdown-menu.tsx
@@ -0,0 +1,257 @@
+"use client"
+
+import * as React from "react"
+import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
+import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+function DropdownMenu({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DropdownMenuPortal({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuTrigger({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuContent({
+ className,
+ sideOffset = 4,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+function DropdownMenuGroup({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuItem({
+ className,
+ inset,
+ variant = "default",
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean
+ variant?: "default" | "destructive"
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuCheckboxItem({
+ className,
+ children,
+ checked,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function DropdownMenuRadioGroup({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuRadioItem({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function DropdownMenuLabel({
+ className,
+ inset,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuShortcut({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+ )
+}
+
+function DropdownMenuSub({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DropdownMenuSubTrigger({
+ className,
+ inset,
+ children,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean
+}) {
+ return (
+
+ {children}
+
+
+ )
+}
+
+function DropdownMenuSubContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ DropdownMenu,
+ DropdownMenuPortal,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuLabel,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubTrigger,
+ DropdownMenuSubContent,
+}
diff --git a/frontend/src/components/ui/form.tsx b/frontend/src/components/ui/form.tsx
new file mode 100644
index 0000000..b438928
--- /dev/null
+++ b/frontend/src/components/ui/form.tsx
@@ -0,0 +1,167 @@
+"use client"
+
+import * as React from "react"
+import type { Label as LabelPrimitive } from "radix-ui"
+import { Slot } from "radix-ui"
+import {
+ Controller,
+ FormProvider,
+ useFormContext,
+ useFormState,
+ type ControllerProps,
+ type FieldPath,
+ type FieldValues,
+} from "react-hook-form"
+
+import { cn } from "@/lib/utils"
+import { Label } from "@/components/ui/label"
+
+const Form = FormProvider
+
+type FormFieldContextValue<
+ TFieldValues extends FieldValues = FieldValues,
+ TName extends FieldPath = FieldPath,
+> = {
+ name: TName
+}
+
+const FormFieldContext = React.createContext(
+ {} as FormFieldContextValue
+)
+
+const FormField = <
+ TFieldValues extends FieldValues = FieldValues,
+ TName extends FieldPath = FieldPath,
+>({
+ ...props
+}: ControllerProps) => {
+ return (
+
+
+
+ )
+}
+
+const useFormField = () => {
+ const fieldContext = React.useContext(FormFieldContext)
+ const itemContext = React.useContext(FormItemContext)
+ const { getFieldState } = useFormContext()
+ const formState = useFormState({ name: fieldContext.name })
+ const fieldState = getFieldState(fieldContext.name, formState)
+
+ if (!fieldContext) {
+ throw new Error("useFormField should be used within ")
+ }
+
+ const { id } = itemContext
+
+ return {
+ id,
+ name: fieldContext.name,
+ formItemId: `${id}-form-item`,
+ formDescriptionId: `${id}-form-item-description`,
+ formMessageId: `${id}-form-item-message`,
+ ...fieldState,
+ }
+}
+
+type FormItemContextValue = {
+ id: string
+}
+
+const FormItemContext = React.createContext(
+ {} as FormItemContextValue
+)
+
+function FormItem({ className, ...props }: React.ComponentProps<"div">) {
+ const id = React.useId()
+
+ return (
+
+
+
+ )
+}
+
+function FormLabel({
+ className,
+ ...props
+}: React.ComponentProps) {
+ const { error, formItemId } = useFormField()
+
+ return (
+
+ )
+}
+
+function FormControl({ ...props }: React.ComponentProps) {
+ const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
+
+ return (
+
+ )
+}
+
+function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
+ const { formDescriptionId } = useFormField()
+
+ return (
+
+ )
+}
+
+function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
+ const { error, formMessageId } = useFormField()
+ const body = error ? String(error?.message ?? "") : props.children
+
+ if (!body) {
+ return null
+ }
+
+ return (
+
+ {body}
+
+ )
+}
+
+export {
+ useFormField,
+ Form,
+ FormItem,
+ FormLabel,
+ FormControl,
+ FormDescription,
+ FormMessage,
+ FormField,
+}
diff --git a/frontend/src/components/ui/separator.tsx b/frontend/src/components/ui/separator.tsx
new file mode 100644
index 0000000..7bd0d75
--- /dev/null
+++ b/frontend/src/components/ui/separator.tsx
@@ -0,0 +1,26 @@
+import * as React from "react"
+import { Separator as SeparatorPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+function Separator({
+ className,
+ orientation = "horizontal",
+ decorative = true,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Separator }
diff --git a/frontend/src/components/ui/sheet.tsx b/frontend/src/components/ui/sheet.tsx
new file mode 100644
index 0000000..5963090
--- /dev/null
+++ b/frontend/src/components/ui/sheet.tsx
@@ -0,0 +1,143 @@
+"use client"
+
+import * as React from "react"
+import { XIcon } from "lucide-react"
+import { Dialog as SheetPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+function Sheet({ ...props }: React.ComponentProps) {
+ return
+}
+
+function SheetTrigger({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function SheetClose({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function SheetPortal({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function SheetOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SheetContent({
+ className,
+ children,
+ side = "right",
+ showCloseButton = true,
+ ...props
+}: React.ComponentProps & {
+ side?: "top" | "right" | "bottom" | "left"
+ showCloseButton?: boolean
+}) {
+ return (
+
+
+
+ {children}
+ {showCloseButton && (
+
+
+ Close
+
+ )}
+
+
+ )
+}
+
+function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SheetTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SheetDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ Sheet,
+ SheetTrigger,
+ SheetClose,
+ SheetContent,
+ SheetHeader,
+ SheetFooter,
+ SheetTitle,
+ SheetDescription,
+}
diff --git a/frontend/src/components/ui/tooltip.tsx b/frontend/src/components/ui/tooltip.tsx
new file mode 100644
index 0000000..a3b416a
--- /dev/null
+++ b/frontend/src/components/ui/tooltip.tsx
@@ -0,0 +1,55 @@
+import * as React from "react"
+import { Tooltip as TooltipPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+function TooltipProvider({
+ delayDuration = 0,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function Tooltip({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function TooltipTrigger({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function TooltipContent({
+ className,
+ sideOffset = 0,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+ {children}
+
+
+
+ )
+}
+
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx
new file mode 100644
index 0000000..a16fb30
--- /dev/null
+++ b/frontend/src/context/AuthContext.tsx
@@ -0,0 +1,67 @@
+import { createContext, useContext, useState, useEffect, type ReactNode } from 'react'
+import type { User, LoginRequest, RegisterRequest } from '@/types'
+import * as authService from '@/services/authService'
+
+interface AuthContextType {
+ user: User | null
+ isLoading: boolean
+ isAdmin: boolean
+ login: (data: LoginRequest) => Promise
+ register: (data: RegisterRequest) => Promise
+ logout: () => Promise
+}
+
+const AuthContext = createContext(null)
+
+export function AuthProvider({ children }: { children: ReactNode }) {
+ const [user, setUser] = useState(null)
+ const [isLoading, setIsLoading] = useState(true)
+
+ useEffect(() => {
+ const token = localStorage.getItem('access_token')
+ if (token) {
+ authService.getMe()
+ .then(setUser)
+ .catch(() => {
+ localStorage.removeItem('access_token')
+ localStorage.removeItem('refresh_token')
+ })
+ .finally(() => setIsLoading(false))
+ } else {
+ setIsLoading(false)
+ }
+ }, [])
+
+ const loginFn = async (data: LoginRequest) => {
+ const result = await authService.login(data)
+ setUser(result.user)
+ }
+
+ const registerFn = async (data: RegisterRequest) => {
+ await authService.register(data)
+ }
+
+ const logoutFn = async () => {
+ await authService.logout()
+ setUser(null)
+ }
+
+ return (
+
+ {children}
+
+ )
+}
+
+export function useAuth() {
+ const context = useContext(AuthContext)
+ if (!context) throw new Error('useAuth must be used within AuthProvider')
+ return context
+}
diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx
new file mode 100644
index 0000000..3ff1678
--- /dev/null
+++ b/frontend/src/pages/LoginPage.tsx
@@ -0,0 +1,150 @@
+import { useState } from 'react'
+import { useForm } from 'react-hook-form'
+import { zodResolver } from '@hookform/resolvers/zod'
+import { z } from 'zod'
+import { Link, useNavigate } from 'react-router-dom'
+import { useAuth } from '@/context/AuthContext'
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
+import { Input } from '@/components/ui/input'
+import { Button } from '@/components/ui/button'
+import { Label } from '@/components/ui/label'
+import { Alert, AlertDescription } from '@/components/ui/alert'
+import { AlertCircle, Loader2 } from 'lucide-react'
+
+const loginSchema = z.object({
+ email: z.string().email('Bitte geben Sie eine gueltige E-Mail-Adresse ein'),
+ password: z.string().min(1, 'Passwort ist erforderlich'),
+ mfa_code: z.string().optional(),
+})
+
+type LoginFormValues = z.infer
+
+export function LoginPage() {
+ const { login } = useAuth()
+ const navigate = useNavigate()
+ const [error, setError] = useState(null)
+ const [showMfa, setShowMfa] = useState(false)
+ const [isSubmitting, setIsSubmitting] = useState(false)
+
+ const {
+ register,
+ handleSubmit,
+ formState: { errors },
+ } = useForm({
+ resolver: zodResolver(loginSchema),
+ defaultValues: {
+ email: '',
+ password: '',
+ mfa_code: '',
+ },
+ })
+
+ const onSubmit = async (data: LoginFormValues) => {
+ setError(null)
+ setIsSubmitting(true)
+ try {
+ await login({
+ email: data.email,
+ password: data.password,
+ mfa_code: data.mfa_code || undefined,
+ })
+ navigate('/dashboard')
+ } catch (err: unknown) {
+ const axiosError = err as { response?: { data?: { detail?: string }; status?: number } }
+ const detail = axiosError.response?.data?.detail || ''
+
+ if (detail.toLowerCase().includes('mfa') || detail.toLowerCase().includes('2fa')) {
+ setShowMfa(true)
+ setError('Bitte geben Sie Ihren MFA-Code ein')
+ } else if (axiosError.response?.status === 401) {
+ setError('Ungueltige Anmeldedaten')
+ } else if (axiosError.response?.status === 423) {
+ setError('Konto gesperrt. Bitte kontaktieren Sie den Administrator.')
+ } else {
+ setError(detail || 'Ein Fehler ist aufgetreten')
+ }
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ return (
+
+
+
+ DAK Portal
+ Melden Sie sich mit Ihrem Konto an
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/pages/RegisterPage.tsx b/frontend/src/pages/RegisterPage.tsx
new file mode 100644
index 0000000..cda678e
--- /dev/null
+++ b/frontend/src/pages/RegisterPage.tsx
@@ -0,0 +1,166 @@
+import { useState } from 'react'
+import { useForm } from 'react-hook-form'
+import { zodResolver } from '@hookform/resolvers/zod'
+import { z } from 'zod'
+import { Link, useNavigate, useSearchParams } from 'react-router-dom'
+import { useAuth } from '@/context/AuthContext'
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
+import { Input } from '@/components/ui/input'
+import { Button } from '@/components/ui/button'
+import { Label } from '@/components/ui/label'
+import { Alert, AlertDescription } from '@/components/ui/alert'
+import { AlertCircle, CheckCircle2, Loader2 } from 'lucide-react'
+
+const registerSchema = z.object({
+ username: z.string().min(3, 'Benutzername muss mindestens 3 Zeichen lang sein'),
+ email: z.string().email('Bitte geben Sie eine gueltige E-Mail-Adresse ein'),
+ password: z.string().min(8, 'Passwort muss mindestens 8 Zeichen lang sein'),
+})
+
+type RegisterFormValues = z.infer
+
+export function RegisterPage() {
+ const { register: registerUser } = useAuth()
+ const navigate = useNavigate()
+ const [searchParams] = useSearchParams()
+ const invitationToken = searchParams.get('token') || undefined
+ const [error, setError] = useState(null)
+ const [success, setSuccess] = useState(false)
+ const [isSubmitting, setIsSubmitting] = useState(false)
+
+ const {
+ register,
+ handleSubmit,
+ formState: { errors },
+ } = useForm({
+ resolver: zodResolver(registerSchema),
+ defaultValues: {
+ username: '',
+ email: '',
+ password: '',
+ },
+ })
+
+ const onSubmit = async (data: RegisterFormValues) => {
+ setError(null)
+ setIsSubmitting(true)
+ try {
+ await registerUser({
+ username: data.username,
+ email: data.email,
+ password: data.password,
+ invitation_token: invitationToken,
+ })
+ setSuccess(true)
+ setTimeout(() => navigate('/login'), 2000)
+ } catch (err: unknown) {
+ const axiosError = err as { response?: { data?: { detail?: string } } }
+ const detail = axiosError.response?.data?.detail || ''
+ setError(detail || 'Registrierung fehlgeschlagen')
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ return (
+
+
+
+ Konto erstellen
+
+ Registrieren Sie sich fuer das DAK Portal
+
+
+
+ {success ? (
+
+
+
+ Registrierung erfolgreich! Sie werden zur Anmeldung weitergeleitet...
+
+
+ ) : (
+
+ )}
+
+
+
+ )
+}
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts
new file mode 100644
index 0000000..3716eb2
--- /dev/null
+++ b/frontend/src/services/api.ts
@@ -0,0 +1,44 @@
+import axios from 'axios'
+
+const api = axios.create({
+ baseURL: '/api',
+})
+
+// Request interceptor: attach access token
+api.interceptors.request.use((config) => {
+ const token = localStorage.getItem('access_token')
+ if (token) {
+ config.headers.Authorization = `Bearer ${token}`
+ }
+ return config
+})
+
+// Response interceptor: handle 401 with token refresh
+api.interceptors.response.use(
+ (response) => response,
+ async (error) => {
+ const originalRequest = error.config
+ if (error.response?.status === 401 && !originalRequest._retry) {
+ originalRequest._retry = true
+ try {
+ const refreshToken = localStorage.getItem('refresh_token')
+ if (!refreshToken) throw new Error('No refresh token')
+
+ const { data } = await axios.post('/api/auth/refresh', {
+ refresh_token: refreshToken,
+ })
+ localStorage.setItem('access_token', data.access_token)
+ originalRequest.headers.Authorization = `Bearer ${data.access_token}`
+ return api(originalRequest)
+ } catch {
+ localStorage.removeItem('access_token')
+ localStorage.removeItem('refresh_token')
+ window.location.href = '/login'
+ return Promise.reject(error)
+ }
+ }
+ return Promise.reject(error)
+ }
+)
+
+export default api
diff --git a/frontend/src/services/authService.ts b/frontend/src/services/authService.ts
new file mode 100644
index 0000000..94c241e
--- /dev/null
+++ b/frontend/src/services/authService.ts
@@ -0,0 +1,31 @@
+import api from './api'
+import type { LoginRequest, RegisterRequest, TokenResponse, User } from '@/types'
+
+export async function login(data: LoginRequest): Promise {
+ const response = await api.post('/auth/login', data)
+ localStorage.setItem('access_token', response.data.access_token)
+ localStorage.setItem('refresh_token', response.data.refresh_token)
+ return response.data
+}
+
+export async function register(data: RegisterRequest): Promise<{ user: User }> {
+ const response = await api.post<{ user: User }>('/auth/register', data)
+ return response.data
+}
+
+export async function logout(): Promise {
+ try {
+ const refreshToken = localStorage.getItem('refresh_token')
+ if (refreshToken) {
+ await api.post('/auth/logout', { refresh_token: refreshToken })
+ }
+ } finally {
+ localStorage.removeItem('access_token')
+ localStorage.removeItem('refresh_token')
+ }
+}
+
+export async function getMe(): Promise {
+ const response = await api.get('/auth/me')
+ return response.data
+}
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
new file mode 100644
index 0000000..52c53eb
--- /dev/null
+++ b/frontend/src/types/index.ts
@@ -0,0 +1,133 @@
+export interface User {
+ id: number
+ username: string
+ email: string
+ role: 'admin' | 'dak_mitarbeiter'
+ mfa_enabled: boolean
+ is_active: boolean
+ last_login: string | null
+ created_at: string
+}
+
+export interface LoginRequest {
+ email: string
+ password: string
+ mfa_code?: string
+}
+
+export interface RegisterRequest {
+ username: string
+ email: string
+ password: string
+ invitation_token?: string
+}
+
+export interface TokenResponse {
+ access_token: string
+ refresh_token: string
+ token_type: string
+ user: User
+}
+
+export interface Case {
+ id: number
+ fall_id: string | null
+ crm_ticket_id: string | null
+ jahr: number
+ kw: number
+ datum: string
+ anrede: string | null
+ vorname: string | null
+ nachname: string
+ geburtsdatum: string | null
+ kvnr: string | null
+ versicherung: string
+ icd: string | null
+ fallgruppe: string
+ unterlagen: boolean
+ gutachten: boolean
+ gutachter: string | null
+ gutachten_typ: string | null
+ therapieaenderung: string | null
+ ablehnung: boolean
+ abbruch: boolean
+ kurzbeschreibung: string | null
+ fragestellung: string | null
+ kommentar: string | null
+ abgerechnet: boolean
+ import_source: string | null
+ imported_at: string
+ updated_at: string
+}
+
+export interface CaseListResponse {
+ items: Case[]
+ total: number
+ page: number
+ per_page: number
+}
+
+export interface DashboardKPIs {
+ total_cases: number
+ pending_icd: number
+ pending_coding: number
+ total_gutachten: number
+ fallgruppen: Record
+}
+
+export interface WeeklyDataPoint {
+ kw: number
+ erstberatungen: number
+ unterlagen: number
+ ablehnungen: number
+ keine_rm: number
+ gutachten: number
+}
+
+export interface DashboardResponse {
+ kpis: DashboardKPIs
+ weekly: WeeklyDataPoint[]
+}
+
+export interface Notification {
+ id: number
+ notification_type: string
+ title: string
+ message: string | null
+ is_read: boolean
+ created_at: string
+}
+
+export interface ImportPreview {
+ filename: string
+ total_rows: number
+ new_cases: number
+ duplicates: number
+ errors: string[]
+ rows: ImportRow[]
+}
+
+export interface ImportRow {
+ row_number: number
+ nachname: string
+ vorname: string | null
+ fallgruppe: string
+ datum: string
+ is_duplicate: boolean
+ fall_id: string | null
+}
+
+export interface ImportResult {
+ imported: number
+ skipped: number
+ updated: number
+ errors: string[]
+}
+
+export interface ReportMeta {
+ id: number
+ jahr: number
+ kw: number
+ report_date: string
+ generated_at: string
+}