feat: coding queue, reports, notifications, and admin pages

- CodingPage: card-based queue with Fallgruppe filter, coding form per case
- ReportsPage: report generation (admin), download, report history table
- Notifications: real-time bell dropdown in Header with polling, mark-read
- AdminUsersPage: user list, create/edit dialogs with role & active toggle
- AdminInvitationsPage: create invitations, copy token, status badges
- AdminAuditPage: filterable log with expandable old/new values

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
CCS Admin 2026-02-24 08:39:56 +00:00
parent 5cbef969fb
commit 006ffd5254
13 changed files with 2044 additions and 13 deletions

View file

@ -8,13 +8,11 @@ import { DashboardPage } from '@/pages/DashboardPage'
import { CasesPage } from '@/pages/CasesPage'
import { ImportPage } from '@/pages/ImportPage'
import { IcdPage } from '@/pages/IcdPage'
// Placeholder pages for features not yet implemented
function CodingPage() { return <div className="p-6"><h1 className="text-2xl font-bold">Coding</h1></div> }
function ReportsPage() { return <div className="p-6"><h1 className="text-2xl font-bold">Berichte</h1></div> }
function AdminUsersPage() { return <div className="p-6"><h1 className="text-2xl font-bold">Benutzer</h1></div> }
function AdminInvitationsPage() { return <div className="p-6"><h1 className="text-2xl font-bold">Einladungen</h1></div> }
function AdminAuditPage() { return <div className="p-6"><h1 className="text-2xl font-bold">Audit-Log</h1></div> }
import { CodingPage } from '@/pages/CodingPage'
import { ReportsPage } from '@/pages/ReportsPage'
import { AdminUsersPage } from '@/pages/AdminUsersPage'
import { AdminInvitationsPage } from '@/pages/AdminInvitationsPage'
import { AdminAuditPage } from '@/pages/AdminAuditPage'
function App() {
return (

View file

@ -1,4 +1,5 @@
import { useAuth } from '@/context/AuthContext'
import { useNotifications } from '@/hooks/useNotifications'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
@ -10,7 +11,14 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Bell, LogOut, Menu } from 'lucide-react'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Separator } from '@/components/ui/separator'
import { Bell, CheckCheck, LogOut, Menu } from 'lucide-react'
interface HeaderProps {
onToggleSidebar: () => void
@ -18,6 +26,7 @@ interface HeaderProps {
export function Header({ onToggleSidebar }: HeaderProps) {
const { user, logout } = useAuth()
const { notifications, unreadCount, markAsRead, markAllAsRead } = useNotifications()
const initials = user?.username
? user.username
@ -45,11 +54,75 @@ export function Header({ onToggleSidebar }: HeaderProps) {
<div className="flex-1" />
{/* Notification bell placeholder (Task 27) */}
<Button variant="ghost" size="icon" className="relative">
<Bell className="h-5 w-5" />
<span className="sr-only">Benachrichtigungen</span>
</Button>
{/* Notification bell with dropdown */}
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" className="relative">
<Bell className="h-5 w-5" />
{unreadCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-bold text-destructive-foreground">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
<span className="sr-only">Benachrichtigungen</span>
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-80 p-0">
<div className="flex items-center justify-between px-4 py-3">
<h4 className="text-sm font-semibold">Benachrichtigungen</h4>
{unreadCount > 0 && (
<Button
variant="ghost"
size="sm"
className="h-auto px-2 py-1 text-xs"
onClick={markAllAsRead}
>
<CheckCheck className="mr-1 h-3 w-3" />
Alle als gelesen markieren
</Button>
)}
</div>
<Separator />
<ScrollArea className="max-h-80">
{notifications.length === 0 ? (
<p className="px-4 py-6 text-center text-sm text-muted-foreground">
Keine Benachrichtigungen
</p>
) : (
<div className="flex flex-col">
{notifications.map((n) => (
<button
key={n.id}
className={`flex flex-col gap-1 px-4 py-3 text-left transition-colors hover:bg-muted/50 ${
!n.is_read ? 'bg-muted/30' : ''
}`}
onClick={() => {
if (!n.is_read) markAsRead(n.id)
}}
>
<div className="flex items-start justify-between gap-2">
<span className="text-sm font-medium leading-tight">
{!n.is_read && (
<span className="mr-1.5 inline-block h-2 w-2 rounded-full bg-primary" />
)}
{n.title}
</span>
</div>
{n.message && (
<span className="text-xs text-muted-foreground line-clamp-2">
{n.message}
</span>
)}
<span className="text-[11px] text-muted-foreground">
{formatRelativeTime(n.created_at)}
</span>
</button>
))}
</div>
)}
</ScrollArea>
</PopoverContent>
</Popover>
{/* User menu */}
<DropdownMenu>
@ -83,3 +156,26 @@ export function Header({ onToggleSidebar }: HeaderProps) {
</header>
)
}
function formatRelativeTime(dateStr: string): string {
try {
const date = new Date(dateStr)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMin = Math.floor(diffMs / 60_000)
const diffHrs = Math.floor(diffMs / 3_600_000)
const diffDays = Math.floor(diffMs / 86_400_000)
if (diffMin < 1) return 'Gerade eben'
if (diffMin < 60) return `vor ${diffMin} Min.`
if (diffHrs < 24) return `vor ${diffHrs} Std.`
if (diffDays < 7) return `vor ${diffDays} Tag${diffDays > 1 ? 'en' : ''}`
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
} catch {
return dateStr
}
}

View file

@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import { CheckIcon } from "lucide-react"
import { Checkbox as CheckboxPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View file

@ -0,0 +1,156 @@
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View file

@ -0,0 +1,87 @@
import * as React from "react"
import { Popover as PopoverPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="popover-header"
className={cn("flex flex-col gap-1 text-sm", className)}
{...props}
/>
)
}
function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) {
return (
<div
data-slot="popover-title"
className={cn("font-medium", className)}
{...props}
/>
)
}
function PopoverDescription({
className,
...props
}: React.ComponentProps<"p">) {
return (
<p
data-slot="popover-description"
className={cn("text-muted-foreground", className)}
{...props}
/>
)
}
export {
Popover,
PopoverTrigger,
PopoverContent,
PopoverAnchor,
PopoverHeader,
PopoverTitle,
PopoverDescription,
}

View file

@ -0,0 +1,35 @@
"use client"
import * as React from "react"
import { Switch as SwitchPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Switch({
className,
size = "default",
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
size?: "sm" | "default"
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 group/switch inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block rounded-full ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

View file

@ -0,0 +1,65 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import api from '@/services/api'
import type { Notification, NotificationList } from '@/types'
const POLL_INTERVAL = 60_000 // 60 seconds
export function useNotifications() {
const [notifications, setNotifications] = useState<Notification[]>([])
const [unreadCount, setUnreadCount] = useState(0)
const [loading, setLoading] = useState(true)
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const fetchNotifications = useCallback(async () => {
try {
const res = await api.get<NotificationList>('/notifications')
setNotifications(res.data.items)
setUnreadCount(res.data.unread_count)
} catch {
// Silently fail for polling
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchNotifications()
intervalRef.current = setInterval(fetchNotifications, POLL_INTERVAL)
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
}
}
}, [fetchNotifications])
const markAsRead = useCallback(async (id: number) => {
try {
await api.put<Notification>(`/notifications/${id}/read`)
setNotifications((prev) =>
prev.map((n) => (n.id === id ? { ...n, is_read: true } : n))
)
setUnreadCount((prev) => Math.max(0, prev - 1))
} catch {
// ignore
}
}, [])
const markAllAsRead = useCallback(async () => {
try {
await api.put<{ marked_read: number }>('/notifications/read-all')
setNotifications((prev) => prev.map((n) => ({ ...n, is_read: true })))
setUnreadCount(0)
} catch {
// ignore
}
}, [])
return {
notifications,
unreadCount,
loading,
markAsRead,
markAllAsRead,
refresh: fetchNotifications,
}
}

View file

@ -0,0 +1,272 @@
import { useState, useEffect } from 'react'
import { ChevronDown, ChevronRight, Filter } from 'lucide-react'
import api from '@/services/api'
import type { AuditLogEntry } from '@/types'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
export function AdminAuditPage() {
const [entries, setEntries] = useState<AuditLogEntry[]>([])
const [loading, setLoading] = useState(true)
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set())
// Filters
const [filterUserId, setFilterUserId] = useState('')
const [filterAction, setFilterAction] = useState('')
const [filterDateFrom, setFilterDateFrom] = useState('')
const [filterDateTo, setFilterDateTo] = useState('')
// Pagination
const [skip, setSkip] = useState(0)
const limit = 50
const fetchEntries = () => {
setLoading(true)
const params: Record<string, string | number> = { skip, limit }
if (filterUserId) params.user_id = filterUserId
if (filterAction) params.action = filterAction
if (filterDateFrom) params.date_from = filterDateFrom
if (filterDateTo) params.date_to = filterDateTo
api.get<AuditLogEntry[]>('/admin/audit-log', { params })
.then((res) => setEntries(res.data))
.catch(() => setEntries([]))
.finally(() => setLoading(false))
}
useEffect(() => {
fetchEntries()
}, [skip]) // eslint-disable-line react-hooks/exhaustive-deps
const handleFilter = () => {
setSkip(0)
fetchEntries()
}
const toggleExpanded = (id: number) => {
setExpandedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}
const resetFilters = () => {
setFilterUserId('')
setFilterAction('')
setFilterDateFrom('')
setFilterDateTo('')
setSkip(0)
// Will trigger fetchEntries via useEffect since skip changes to 0
// But if already 0, manually trigger
setTimeout(fetchEntries, 0)
}
return (
<div className="p-6 space-y-4">
<h1 className="text-2xl font-bold">Audit-Log</h1>
{/* Filters */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Filter className="h-4 w-4" />
Filter
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap items-end gap-4">
<div className="space-y-1">
<Label htmlFor="filter-user">Benutzer-ID</Label>
<Input
id="filter-user"
value={filterUserId}
onChange={(e) => setFilterUserId(e.target.value)}
placeholder="z.B. 1"
className="w-28"
/>
</div>
<div className="space-y-1">
<Label htmlFor="filter-action">Aktion</Label>
<Input
id="filter-action"
value={filterAction}
onChange={(e) => setFilterAction(e.target.value)}
placeholder="z.B. login"
className="w-40"
/>
</div>
<div className="space-y-1">
<Label htmlFor="filter-from">Datum von</Label>
<Input
id="filter-from"
type="date"
value={filterDateFrom}
onChange={(e) => setFilterDateFrom(e.target.value)}
className="w-40"
/>
</div>
<div className="space-y-1">
<Label htmlFor="filter-to">Datum bis</Label>
<Input
id="filter-to"
type="date"
value={filterDateTo}
onChange={(e) => setFilterDateTo(e.target.value)}
className="w-40"
/>
</div>
<Button onClick={handleFilter}>Filtern</Button>
<Button variant="outline" onClick={resetFilters}>Zurücksetzen</Button>
</div>
</CardContent>
</Card>
{/* Audit table */}
{loading ? (
<div className="space-y-2">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
) : entries.length > 0 ? (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-8" />
<TableHead>Zeitpunkt</TableHead>
<TableHead>Benutzer-ID</TableHead>
<TableHead>Aktion</TableHead>
<TableHead>Entität</TableHead>
<TableHead>Details</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{entries.map((entry) => {
const isExpanded = expandedIds.has(entry.id)
const hasValues = entry.old_values || entry.new_values
return (
<>
<TableRow
key={entry.id}
className={hasValues ? 'cursor-pointer' : ''}
onClick={() => hasValues && toggleExpanded(entry.id)}
>
<TableCell>
{hasValues && (
isExpanded
? <ChevronDown className="h-4 w-4" />
: <ChevronRight className="h-4 w-4" />
)}
</TableCell>
<TableCell className="whitespace-nowrap">
{formatDateTime(entry.created_at)}
</TableCell>
<TableCell>
{entry.user_id !== null ? entry.user_id : '-'}
</TableCell>
<TableCell>
<Badge variant="outline">{entry.action}</Badge>
</TableCell>
<TableCell>
{entry.entity_type ? (
<span className="text-sm">
{entry.entity_type}
{entry.entity_id ? ` #${entry.entity_id}` : ''}
</span>
) : (
'-'
)}
</TableCell>
<TableCell>
{entry.ip_address && (
<span className="text-xs text-muted-foreground font-mono">
{entry.ip_address}
</span>
)}
</TableCell>
</TableRow>
{isExpanded && hasValues && (
<TableRow key={`${entry.id}-details`}>
<TableCell colSpan={6} className="bg-muted/30 p-4">
<div className="grid gap-4 md:grid-cols-2">
{entry.old_values && (
<div>
<p className="text-sm font-medium mb-1">Alte Werte</p>
<pre className="text-xs bg-background rounded p-2 overflow-auto max-h-48 border">
{JSON.stringify(entry.old_values, null, 2)}
</pre>
</div>
)}
{entry.new_values && (
<div>
<p className="text-sm font-medium mb-1">Neue Werte</p>
<pre className="text-xs bg-background rounded p-2 overflow-auto max-h-48 border">
{JSON.stringify(entry.new_values, null, 2)}
</pre>
</div>
)}
</div>
</TableCell>
</TableRow>
)}
</>
)
})}
</TableBody>
</Table>
{/* Pagination */}
<div className="flex items-center justify-center gap-2">
<Button
variant="outline"
size="sm"
disabled={skip === 0}
onClick={() => setSkip((s) => Math.max(0, s - limit))}
>
Zurück
</Button>
<span className="text-sm text-muted-foreground">
Einträge {skip + 1} - {skip + entries.length}
</span>
<Button
variant="outline"
size="sm"
disabled={entries.length < limit}
onClick={() => setSkip((s) => s + limit)}
>
Weiter
</Button>
</div>
</>
) : (
<p className="py-8 text-center text-muted-foreground">
Keine Audit-Einträge gefunden.
</p>
)}
</div>
)
}
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', second: '2-digit',
})
} catch {
return dateStr
}
}

View file

@ -0,0 +1,300 @@
import { useState, useEffect } from 'react'
import { Plus, Copy, Check, Loader2 } from 'lucide-react'
import api from '@/services/api'
import type { InvitationResponse, CreateInvitationPayload } from '@/types'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select'
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table'
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from '@/components/ui/dialog'
import { Skeleton } from '@/components/ui/skeleton'
import { Alert, AlertDescription } from '@/components/ui/alert'
import {
Tooltip, TooltipContent, TooltipProvider, TooltipTrigger,
} from '@/components/ui/tooltip'
export function AdminInvitationsPage() {
const [invitations, setInvitations] = useState<InvitationResponse[]>([])
const [loading, setLoading] = useState(true)
// Create dialog
const [createOpen, setCreateOpen] = useState(false)
const [createForm, setCreateForm] = useState<CreateInvitationPayload>({
email: '', role: 'dak_mitarbeiter', expires_in_days: 7,
})
const [creating, setCreating] = useState(false)
const [createError, setCreateError] = useState('')
const [createdToken, setCreatedToken] = useState<string | null>(null)
// Copy feedback
const [copiedId, setCopiedId] = useState<number | null>(null)
useEffect(() => {
fetchInvitations()
}, [])
const fetchInvitations = () => {
setLoading(true)
api.get<InvitationResponse[]>('/admin/invitations', { params: { skip: 0, limit: 200 } })
.then((res) => setInvitations(res.data))
.catch(() => setInvitations([]))
.finally(() => setLoading(false))
}
const handleCreate = async () => {
setCreating(true)
setCreateError('')
setCreatedToken(null)
try {
const payload: Record<string, unknown> = {
role: createForm.role,
expires_in_days: createForm.expires_in_days,
}
if (createForm.email && createForm.email.trim()) {
payload.email = createForm.email.trim()
}
const res = await api.post<InvitationResponse>('/admin/invitations', payload)
setCreatedToken(res.data.token)
fetchInvitations()
} catch (err: unknown) {
const msg = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail
setCreateError(msg || 'Fehler beim Erstellen der Einladung.')
} finally {
setCreating(false)
}
}
const copyToken = async (token: string, id: number) => {
try {
await navigator.clipboard.writeText(token)
setCopiedId(id)
setTimeout(() => setCopiedId(null), 2000)
} catch {
// Fallback
const textarea = document.createElement('textarea')
textarea.value = token
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
setCopiedId(id)
setTimeout(() => setCopiedId(null), 2000)
}
}
const getStatus = (inv: InvitationResponse): { label: string; variant: 'default' | 'secondary' | 'destructive' } => {
if (inv.used_at) return { label: 'Verwendet', variant: 'secondary' }
if (new Date(inv.expires_at) < new Date()) return { label: 'Abgelaufen', variant: 'destructive' }
return { label: 'Aktiv', variant: 'default' }
}
const resetCreateDialog = () => {
setCreateForm({ email: '', role: 'dak_mitarbeiter', expires_in_days: 7 })
setCreateError('')
setCreatedToken(null)
setCreateOpen(false)
}
return (
<div className="p-6 space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Einladungen</h1>
<Button onClick={() => { setCreateError(''); setCreatedToken(null); setCreateOpen(true) }}>
<Plus className="mr-2 h-4 w-4" />
Einladung erstellen
</Button>
</div>
{loading ? (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
) : invitations.length > 0 ? (
<TooltipProvider>
<Table>
<TableHeader>
<TableRow>
<TableHead>Token</TableHead>
<TableHead>E-Mail</TableHead>
<TableHead>Rolle</TableHead>
<TableHead>Erstellt</TableHead>
<TableHead>Gültig bis</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Aktion</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{invitations.map((inv) => {
const status = getStatus(inv)
return (
<TableRow key={inv.id}>
<TableCell className="font-mono text-xs max-w-[120px] truncate">
{inv.token.slice(0, 12)}...
</TableCell>
<TableCell>{inv.email || '-'}</TableCell>
<TableCell>
<Badge variant={inv.role === 'admin' ? 'default' : 'secondary'}>
{inv.role === 'admin' ? 'Admin' : 'DAK Mitarbeiter'}
</Badge>
</TableCell>
<TableCell>{formatDateTime(inv.created_at)}</TableCell>
<TableCell>{formatDateTime(inv.expires_at)}</TableCell>
<TableCell>
<Badge variant={status.variant}>{status.label}</Badge>
</TableCell>
<TableCell className="text-right">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => copyToken(inv.token, inv.id)}
>
{copiedId === inv.id ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{copiedId === inv.id ? 'Kopiert!' : 'Token kopieren'}
</TooltipContent>
</Tooltip>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</TooltipProvider>
) : (
<p className="py-8 text-center text-muted-foreground">
Keine Einladungen vorhanden.
</p>
)}
{/* Create invitation dialog */}
<Dialog open={createOpen} onOpenChange={(open) => { if (!open) resetCreateDialog() }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Einladung erstellen</DialogTitle>
<DialogDescription>
Erstellen Sie einen Einladungstoken für neue Benutzer.
</DialogDescription>
</DialogHeader>
{createdToken ? (
<div className="space-y-4">
<Alert>
<AlertDescription>
Einladung erfolgreich erstellt! Kopieren Sie den Token:
</AlertDescription>
</Alert>
<div className="flex items-center gap-2 rounded-md border p-3 bg-muted">
<code className="flex-1 text-sm break-all">{createdToken}</code>
<Button
variant="ghost"
size="sm"
onClick={() => copyToken(createdToken, -1)}
>
{copiedId === -1 ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
<DialogFooter>
<Button onClick={resetCreateDialog}>Schließen</Button>
</DialogFooter>
</div>
) : (
<>
<div className="space-y-4">
<div className="space-y-1">
<Label htmlFor="inv-email">E-Mail (optional)</Label>
<Input
id="inv-email"
type="email"
value={createForm.email ?? ''}
onChange={(e) => setCreateForm((f) => ({ ...f, email: e.target.value }))}
placeholder="Optional: E-Mail des Eingeladenen"
/>
</div>
<div className="space-y-1">
<Label>Rolle</Label>
<Select
value={createForm.role}
onValueChange={(v) => setCreateForm((f) => ({ ...f, role: v as 'admin' | 'dak_mitarbeiter' }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="dak_mitarbeiter">DAK Mitarbeiter</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="inv-days">Gültigkeit (Tage)</Label>
<Input
id="inv-days"
type="number"
value={createForm.expires_in_days}
onChange={(e) => setCreateForm((f) => ({ ...f, expires_in_days: Number(e.target.value) }))}
min={1}
max={90}
className="w-28"
/>
</div>
{createError && (
<Alert variant="destructive">
<AlertDescription>{createError}</AlertDescription>
</Alert>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={resetCreateDialog}>
Abbrechen
</Button>
<Button onClick={handleCreate} disabled={creating}>
{creating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Erstellen...
</>
) : (
'Erstellen'
)}
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
</div>
)
}
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
}
}

View file

@ -0,0 +1,318 @@
import { useState, useEffect } from 'react'
import { Plus, Pencil, Loader2 } from 'lucide-react'
import api from '@/services/api'
import type { UserResponse, CreateUserPayload, UpdateUserPayload } from '@/types'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select'
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table'
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from '@/components/ui/dialog'
import { Skeleton } from '@/components/ui/skeleton'
import { Alert, AlertDescription } from '@/components/ui/alert'
export function AdminUsersPage() {
const [users, setUsers] = useState<UserResponse[]>([])
const [loading, setLoading] = useState(true)
// Create dialog
const [createOpen, setCreateOpen] = useState(false)
const [createForm, setCreateForm] = useState<CreateUserPayload>({
username: '', email: '', password: '', role: 'dak_mitarbeiter',
})
const [creating, setCreating] = useState(false)
const [createError, setCreateError] = useState('')
// Edit dialog
const [editOpen, setEditOpen] = useState(false)
const [editUser, setEditUser] = useState<UserResponse | null>(null)
const [editForm, setEditForm] = useState<UpdateUserPayload>({})
const [editing, setEditing] = useState(false)
const [editError, setEditError] = useState('')
// Fetch users
useEffect(() => {
fetchUsers()
}, [])
const fetchUsers = () => {
setLoading(true)
api.get<UserResponse[]>('/admin/users', { params: { skip: 0, limit: 200 } })
.then((res) => setUsers(res.data))
.catch(() => setUsers([]))
.finally(() => setLoading(false))
}
// Create user
const handleCreate = async () => {
setCreating(true)
setCreateError('')
try {
await api.post<UserResponse>('/admin/users', createForm)
setCreateOpen(false)
setCreateForm({ username: '', email: '', password: '', role: 'dak_mitarbeiter' })
fetchUsers()
} catch (err: unknown) {
const msg = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail
setCreateError(msg || 'Fehler beim Erstellen des Benutzers.')
} finally {
setCreating(false)
}
}
// Edit user
const openEdit = (user: UserResponse) => {
setEditUser(user)
setEditForm({ username: user.username, email: user.email, role: user.role, is_active: user.is_active })
setEditError('')
setEditOpen(true)
}
const handleEdit = async () => {
if (!editUser) return
setEditing(true)
setEditError('')
try {
await api.put<UserResponse>(`/admin/users/${editUser.id}`, editForm)
setEditOpen(false)
setEditUser(null)
fetchUsers()
} catch (err: unknown) {
const msg = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail
setEditError(msg || 'Fehler beim Aktualisieren des Benutzers.')
} finally {
setEditing(false)
}
}
return (
<div className="p-6 space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Benutzer</h1>
<Button onClick={() => { setCreateError(''); setCreateOpen(true) }}>
<Plus className="mr-2 h-4 w-4" />
Neuen Benutzer erstellen
</Button>
</div>
{loading ? (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
) : users.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Benutzername</TableHead>
<TableHead>E-Mail</TableHead>
<TableHead>Rolle</TableHead>
<TableHead>Aktiv</TableHead>
<TableHead>Letzter Login</TableHead>
<TableHead className="text-right">Aktion</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((u) => (
<TableRow key={u.id}>
<TableCell className="font-medium">{u.username}</TableCell>
<TableCell>{u.email}</TableCell>
<TableCell>
<Badge variant={u.role === 'admin' ? 'default' : 'secondary'}>
{u.role === 'admin' ? 'Admin' : 'DAK Mitarbeiter'}
</Badge>
</TableCell>
<TableCell>
<Badge variant={u.is_active ? 'default' : 'destructive'}>
{u.is_active ? 'Aktiv' : 'Inaktiv'}
</Badge>
</TableCell>
<TableCell>
{u.last_login ? formatDateTime(u.last_login) : '-'}
</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm" onClick={() => openEdit(u)}>
<Pencil className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<p className="py-8 text-center text-muted-foreground">
Keine Benutzer gefunden.
</p>
)}
{/* Create user dialog */}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Neuen Benutzer erstellen</DialogTitle>
<DialogDescription>
Füllen Sie die Felder aus, um einen neuen Benutzer anzulegen.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1">
<Label htmlFor="create-username">Benutzername</Label>
<Input
id="create-username"
value={createForm.username}
onChange={(e) => setCreateForm((f) => ({ ...f, username: e.target.value }))}
/>
</div>
<div className="space-y-1">
<Label htmlFor="create-email">E-Mail</Label>
<Input
id="create-email"
type="email"
value={createForm.email}
onChange={(e) => setCreateForm((f) => ({ ...f, email: e.target.value }))}
/>
</div>
<div className="space-y-1">
<Label htmlFor="create-password">Passwort</Label>
<Input
id="create-password"
type="password"
value={createForm.password}
onChange={(e) => setCreateForm((f) => ({ ...f, password: e.target.value }))}
/>
</div>
<div className="space-y-1">
<Label>Rolle</Label>
<Select
value={createForm.role}
onValueChange={(v) => setCreateForm((f) => ({ ...f, role: v as 'admin' | 'dak_mitarbeiter' }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="dak_mitarbeiter">DAK Mitarbeiter</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
</div>
{createError && (
<Alert variant="destructive">
<AlertDescription>{createError}</AlertDescription>
</Alert>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>
Abbrechen
</Button>
<Button onClick={handleCreate} disabled={creating || !createForm.username || !createForm.email || !createForm.password}>
{creating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Erstellen...
</>
) : (
'Erstellen'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Edit user dialog */}
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Benutzer bearbeiten</DialogTitle>
<DialogDescription>
{editUser?.username} ({editUser?.email})
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1">
<Label htmlFor="edit-username">Benutzername</Label>
<Input
id="edit-username"
value={editForm.username ?? ''}
onChange={(e) => setEditForm((f) => ({ ...f, username: e.target.value }))}
/>
</div>
<div className="space-y-1">
<Label htmlFor="edit-email">E-Mail</Label>
<Input
id="edit-email"
type="email"
value={editForm.email ?? ''}
onChange={(e) => setEditForm((f) => ({ ...f, email: e.target.value }))}
/>
</div>
<div className="space-y-1">
<Label>Rolle</Label>
<Select
value={editForm.role ?? 'dak_mitarbeiter'}
onValueChange={(v) => setEditForm((f) => ({ ...f, role: v as 'admin' | 'dak_mitarbeiter' }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="dak_mitarbeiter">DAK Mitarbeiter</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="edit-active">Aktiv</Label>
<Switch
id="edit-active"
checked={editForm.is_active ?? true}
onCheckedChange={(checked) => setEditForm((f) => ({ ...f, is_active: checked }))}
/>
</div>
{editError && (
<Alert variant="destructive">
<AlertDescription>{editError}</AlertDescription>
</Alert>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditOpen(false)}>
Abbrechen
</Button>
<Button onClick={handleEdit} disabled={editing}>
{editing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Speichern...
</>
) : (
'Speichern'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
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
}
}

View file

@ -0,0 +1,349 @@
import { useState, useEffect } from 'react'
import { Save, CheckCircle, Loader2 } from 'lucide-react'
import api from '@/services/api'
import type { Case, CaseListResponse, CodingUpdatePayload } from '@/types'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select'
import { Checkbox } from '@/components/ui/checkbox'
import { Skeleton } from '@/components/ui/skeleton'
import { Alert, AlertDescription } from '@/components/ui/alert'
const FALLGRUPPEN_LABELS: Record<string, string> = {
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' },
]
interface CodingFormState {
gutachten_typ: string | null
therapieaenderung: string | null
ta_diagnosekorrektur: boolean
ta_unterversorgung: boolean
ta_uebertherapie: boolean
}
function getInitialFormState(c: Case): CodingFormState {
return {
gutachten_typ: c.gutachten_typ || null,
therapieaenderung: c.therapieaenderung || null,
ta_diagnosekorrektur: false,
ta_unterversorgung: false,
ta_uebertherapie: false,
}
}
export function CodingPage() {
const [fallgruppe, setFallgruppe] = useState<string>('__all__')
const [page, setPage] = useState(1)
const [data, setData] = useState<CaseListResponse | null>(null)
const [loading, setLoading] = useState(true)
const [formStates, setFormStates] = useState<Record<number, CodingFormState>>({})
const [savingIds, setSavingIds] = useState<Set<number>>(new Set())
const [savedIds, setSavedIds] = useState<Set<number>>(new Set())
const [errors, setErrors] = useState<Record<number, string>>({})
// Fetch coding queue
useEffect(() => {
setLoading(true)
const params: Record<string, string | number> = { page, per_page: 50 }
if (fallgruppe !== '__all__') params.fallgruppe = fallgruppe
api.get<CaseListResponse>('/coding/queue', { params })
.then((res) => {
setData(res.data)
// Initialize form states for each case
const states: Record<number, CodingFormState> = {}
for (const c of res.data.items) {
states[c.id] = getInitialFormState(c)
}
setFormStates(states)
setSavedIds(new Set())
setErrors({})
})
.catch(() => setData(null))
.finally(() => setLoading(false))
}, [page, fallgruppe])
const updateFormField = (caseId: number, field: keyof CodingFormState, value: unknown) => {
setFormStates((prev) => ({
...prev,
[caseId]: { ...prev[caseId], [field]: value },
}))
// Clear saved state when editing
setSavedIds((prev) => {
const next = new Set(prev)
next.delete(caseId)
return next
})
}
const saveCase = async (caseId: number) => {
const form = formStates[caseId]
if (!form) return
setSavingIds((prev) => new Set(prev).add(caseId))
setErrors((prev) => {
const next = { ...prev }
delete next[caseId]
return next
})
try {
const payload: CodingUpdatePayload = {
gutachten_typ: form.gutachten_typ,
therapieaenderung: form.therapieaenderung,
ta_diagnosekorrektur: form.ta_diagnosekorrektur,
ta_unterversorgung: form.ta_unterversorgung,
ta_uebertherapie: form.ta_uebertherapie,
}
await api.put(`/coding/${caseId}`, payload)
setSavedIds((prev) => new Set(prev).add(caseId))
} catch {
setErrors((prev) => ({ ...prev, [caseId]: 'Fehler beim Speichern.' }))
} finally {
setSavingIds((prev) => {
const next = new Set(prev)
next.delete(caseId)
return next
})
}
}
// Count coded cases
const codedCount = data
? data.items.filter((c) => savedIds.has(c.id) || c.gutachten_typ !== null).length
: 0
const totalCount = data?.items.length ?? 0
return (
<div className="p-6 space-y-4">
<div className="flex flex-wrap items-center justify-between gap-4">
<h1 className="text-2xl font-bold">Coding-Warteschlange</h1>
<div className="flex items-center gap-3">
<Badge variant="outline" className="text-sm py-1 px-3">
{codedCount} von {totalCount} Fälle codiert
</Badge>
<Select value={fallgruppe} onValueChange={(v) => { setFallgruppe(v); setPage(1) }}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Fallgruppe" />
</SelectTrigger>
<SelectContent>
{FALLGRUPPEN_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Progress bar */}
{data && totalCount > 0 && (
<div className="h-2 w-full rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-primary transition-all duration-300"
style={{ width: `${(codedCount / totalCount) * 100}%` }}
/>
</div>
)}
{/* Cards */}
{loading ? (
<div className="grid gap-4 md:grid-cols-2">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-64 w-full rounded-lg" />
))}
</div>
) : data && data.items.length > 0 ? (
<div className="grid gap-4 md:grid-cols-2">
{data.items.map((c) => {
const form = formStates[c.id]
if (!form) return null
const isSaving = savingIds.has(c.id)
const isSaved = savedIds.has(c.id)
const error = errors[c.id]
return (
<Card key={c.id} className={isSaved ? 'border-green-300' : ''}>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<CardTitle className="text-base">
{c.fall_id || `#${c.id}`} &mdash; {c.nachname}
{c.vorname ? `, ${c.vorname}` : ''}
</CardTitle>
{isSaved && (
<CheckCircle className="h-5 w-5 text-green-600 shrink-0" />
)}
</div>
<div className="flex flex-wrap gap-2 text-sm">
<Badge variant="outline">
{FALLGRUPPEN_LABELS[c.fallgruppe] || c.fallgruppe}
</Badge>
{c.icd && (
<Badge variant="secondary" className="font-mono">
{c.icd}
</Badge>
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Gutachten-Typ */}
<div className="space-y-1">
<label className="text-sm font-medium">Gutachten-Typ</label>
<Select
value={form.gutachten_typ ?? '__none__'}
onValueChange={(v) =>
updateFormField(c.id, 'gutachten_typ', v === '__none__' ? null : v)
}
>
<SelectTrigger>
<SelectValue placeholder="Auswählen..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">-- Auswählen --</SelectItem>
<SelectItem value="Bestätigung">Bestätigung</SelectItem>
<SelectItem value="Alternative">Alternative</SelectItem>
</SelectContent>
</Select>
</div>
{/* Therapieänderung */}
<div className="space-y-1">
<label className="text-sm font-medium">Therapieänderung</label>
<Select
value={form.therapieaenderung ?? '__none__'}
onValueChange={(v) =>
updateFormField(c.id, 'therapieaenderung', v === '__none__' ? null : v)
}
>
<SelectTrigger>
<SelectValue placeholder="Auswählen..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">-- Auswählen --</SelectItem>
<SelectItem value="Ja">Ja</SelectItem>
<SelectItem value="Nein">Nein</SelectItem>
</SelectContent>
</Select>
</div>
{/* Checkboxes */}
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Checkbox
id={`diag-${c.id}`}
checked={form.ta_diagnosekorrektur}
onCheckedChange={(checked) =>
updateFormField(c.id, 'ta_diagnosekorrektur', !!checked)
}
/>
<label htmlFor={`diag-${c.id}`} className="text-sm">
Diagnosekorrektur
</label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id={`unter-${c.id}`}
checked={form.ta_unterversorgung}
onCheckedChange={(checked) =>
updateFormField(c.id, 'ta_unterversorgung', !!checked)
}
/>
<label htmlFor={`unter-${c.id}`} className="text-sm">
Unterversorgung
</label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id={`ueber-${c.id}`}
checked={form.ta_uebertherapie}
onCheckedChange={(checked) =>
updateFormField(c.id, 'ta_uebertherapie', !!checked)
}
/>
<label htmlFor={`ueber-${c.id}`} className="text-sm">
Übertherapie
</label>
</div>
</div>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Button
className="w-full"
onClick={() => saveCase(c.id)}
disabled={isSaving}
>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Speichern...
</>
) : isSaved ? (
<>
<CheckCircle className="mr-2 h-4 w-4" />
Gespeichert
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
Speichern
</>
)}
</Button>
</CardContent>
</Card>
)
})}
</div>
) : (
<p className="text-muted-foreground py-8 text-center">
Keine Fälle in der Warteschlange.
</p>
)}
{/* Pagination */}
{data && data.total > 50 && (
<div className="flex items-center justify-center gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
Zurück
</Button>
<span className="text-sm text-muted-foreground">
Seite {page} von {Math.ceil(data.total / 50)}
</span>
<Button
variant="outline"
size="sm"
disabled={page >= Math.ceil(data.total / 50)}
onClick={() => setPage((p) => p + 1)}
>
Weiter
</Button>
</div>
)}
</div>
)
}

View file

@ -0,0 +1,251 @@
import { useState, useEffect } from 'react'
import { Download, FileSpreadsheet, Loader2, Plus } from 'lucide-react'
import api from '@/services/api'
import type { ReportMeta } from '@/types'
import { useAuth } from '@/context/AuthContext'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table'
import { Skeleton } from '@/components/ui/skeleton'
import { Alert, AlertDescription } from '@/components/ui/alert'
export function ReportsPage() {
const { isAdmin } = useAuth()
const currentYear = new Date().getFullYear()
const currentKw = getISOWeek(new Date())
const [reports, setReports] = useState<ReportMeta[]>([])
const [totalReports, setTotalReports] = useState(0)
const [loading, setLoading] = useState(true)
// Report generation state
const [genJahr, setGenJahr] = useState(currentYear)
const [genKw, setGenKw] = useState(currentKw)
const [generating, setGenerating] = useState(false)
const [genError, setGenError] = useState('')
const [genSuccess, setGenSuccess] = useState('')
// Fetch reports list
useEffect(() => {
setLoading(true)
api.get<{ items: ReportMeta[]; total: number }>('/reports/list')
.then((res) => {
setReports(res.data.items)
setTotalReports(res.data.total)
})
.catch(() => {
setReports([])
setTotalReports(0)
})
.finally(() => setLoading(false))
}, [])
const generateReport = async () => {
setGenerating(true)
setGenError('')
setGenSuccess('')
try {
const res = await api.post<ReportMeta>('/reports/generate', null, {
params: { jahr: genJahr, kw: genKw },
})
setGenSuccess(`Bericht für KW ${res.data.kw}/${res.data.jahr} wurde generiert.`)
// Refresh list
const listRes = await api.get<{ items: ReportMeta[]; total: number }>('/reports/list')
setReports(listRes.data.items)
setTotalReports(listRes.data.total)
} catch {
setGenError('Fehler beim Generieren des Berichts.')
} finally {
setGenerating(false)
}
}
const downloadReport = (reportId: number) => {
const token = localStorage.getItem('access_token')
const url = `/api/reports/download/${reportId}`
// Use a temporary link with token in header via fetch + blob
api.get(url, { responseType: 'blob' })
.then((res) => {
const blob = new Blob([res.data as BlobPart], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
})
const blobUrl = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = blobUrl
// Extract filename from content-disposition or use default
const contentDisposition = res.headers['content-disposition']
let filename = `bericht_${reportId}.xlsx`
if (contentDisposition) {
const match = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/)
if (match) filename = match[1].replace(/['"]/g, '')
}
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(blobUrl)
})
.catch(() => {
// Fallback: open in new tab with token param
if (token) {
window.open(`${url}?token=${encodeURIComponent(token)}`, '_blank')
}
})
}
return (
<div className="p-6 space-y-6">
<h1 className="text-2xl font-bold">Berichte</h1>
{/* Report generation (admin only) */}
{isAdmin && (
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<FileSpreadsheet className="h-5 w-5" />
Bericht generieren
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap items-end gap-4">
<div className="space-y-1">
<Label htmlFor="gen-jahr">Jahr</Label>
<Input
id="gen-jahr"
type="number"
value={genJahr}
onChange={(e) => setGenJahr(Number(e.target.value))}
className="w-28"
min={2020}
max={2030}
/>
</div>
<div className="space-y-1">
<Label htmlFor="gen-kw">Kalenderwoche</Label>
<Input
id="gen-kw"
type="number"
value={genKw}
onChange={(e) => setGenKw(Number(e.target.value))}
className="w-28"
min={1}
max={53}
/>
</div>
<Button onClick={generateReport} disabled={generating}>
{generating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Wird generiert...
</>
) : (
<>
<Plus className="mr-2 h-4 w-4" />
Bericht generieren
</>
)}
</Button>
</div>
{genError && (
<Alert variant="destructive">
<AlertDescription>{genError}</AlertDescription>
</Alert>
)}
{genSuccess && (
<Alert>
<AlertDescription>{genSuccess}</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
)}
{/* Reports table */}
<Card>
<CardHeader>
<CardTitle className="text-lg">
Bisherige Berichte ({totalReports})
</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
) : reports.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Berichtsdatum</TableHead>
<TableHead>Jahr</TableHead>
<TableHead>KW</TableHead>
<TableHead>Erstellt am</TableHead>
<TableHead className="text-right">Aktion</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{reports.map((r) => (
<TableRow key={r.id}>
<TableCell>{formatDate(r.report_date)}</TableCell>
<TableCell>{r.jahr}</TableCell>
<TableCell>KW {r.kw}</TableCell>
<TableCell>{formatDateTime(r.generated_at)}</TableCell>
<TableCell className="text-right">
<Button
variant="outline"
size="sm"
onClick={() => downloadReport(r.id)}
>
<Download className="mr-1.5 h-4 w-4" />
Download
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<p className="py-8 text-center text-muted-foreground">
Keine Berichte vorhanden.
</p>
)}
</CardContent>
</Card>
</div>
)
}
function getISOWeek(date: Date): number {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()))
const dayNum = d.getUTCDay() || 7
d.setUTCDate(d.getUTCDate() + 4 - dayNum)
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1))
return Math.ceil(((d.getTime() - yearStart.getTime()) / 86_400_000 + 1) / 7)
}
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
}
}

View file

@ -155,3 +155,75 @@ export interface ImportLogListResponse {
page: number
per_page: number
}
// Coding
export interface CodingUpdatePayload {
gutachten_typ: string | null
therapieaenderung: string | null
ta_diagnosekorrektur: boolean
ta_unterversorgung: boolean
ta_uebertherapie: boolean
}
// Notifications
export interface NotificationList {
items: Notification[]
unread_count: number
}
// Admin Users
export interface UserResponse {
id: number
username: string
email: string
role: 'admin' | 'dak_mitarbeiter'
is_active: boolean
mfa_enabled: boolean
last_login: string | null
created_at: string
}
export interface CreateUserPayload {
username: string
email: string
password: string
role: 'admin' | 'dak_mitarbeiter'
}
export interface UpdateUserPayload {
username?: string
email?: string
role?: 'admin' | 'dak_mitarbeiter'
is_active?: boolean
}
// Admin Invitations
export interface InvitationResponse {
id: number
token: string
email: string | null
role: 'admin' | 'dak_mitarbeiter'
created_at: string
expires_at: string
used_at: string | null
used_by: number | null
}
export interface CreateInvitationPayload {
email?: string
role: 'admin' | 'dak_mitarbeiter'
expires_in_days: number
}
// Admin Audit Log
export interface AuditLogEntry {
id: number
user_id: number | null
action: string
entity_type: string | null
entity_id: string | null
old_values: Record<string, unknown> | null
new_values: Record<string, unknown> | null
ip_address: string | null
created_at: string
}