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 ( +