mirror of
https://github.com/complexcaresolutions/dak.c2s.git
synced 2026-03-17 21:53:41 +00:00
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:
parent
5cbef969fb
commit
006ffd5254
13 changed files with 2044 additions and 13 deletions
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
32
frontend/src/components/ui/checkbox.tsx
Normal file
32
frontend/src/components/ui/checkbox.tsx
Normal 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 }
|
||||
156
frontend/src/components/ui/dialog.tsx
Normal file
156
frontend/src/components/ui/dialog.tsx
Normal 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,
|
||||
}
|
||||
87
frontend/src/components/ui/popover.tsx
Normal file
87
frontend/src/components/ui/popover.tsx
Normal 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,
|
||||
}
|
||||
35
frontend/src/components/ui/switch.tsx
Normal file
35
frontend/src/components/ui/switch.tsx
Normal 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 }
|
||||
65
frontend/src/hooks/useNotifications.ts
Normal file
65
frontend/src/hooks/useNotifications.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
272
frontend/src/pages/AdminAuditPage.tsx
Normal file
272
frontend/src/pages/AdminAuditPage.tsx
Normal 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
|
||||
}
|
||||
}
|
||||
300
frontend/src/pages/AdminInvitationsPage.tsx
Normal file
300
frontend/src/pages/AdminInvitationsPage.tsx
Normal 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
|
||||
}
|
||||
}
|
||||
318
frontend/src/pages/AdminUsersPage.tsx
Normal file
318
frontend/src/pages/AdminUsersPage.tsx
Normal 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
|
||||
}
|
||||
}
|
||||
349
frontend/src/pages/CodingPage.tsx
Normal file
349
frontend/src/pages/CodingPage.tsx
Normal 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}`} — {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>
|
||||
)
|
||||
}
|
||||
251
frontend/src/pages/ReportsPage.tsx
Normal file
251
frontend/src/pages/ReportsPage.tsx
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue