From 006ffd5254c73b0a8b5fba9cfc098d198059399d Mon Sep 17 00:00:00 2001 From: CCS Admin Date: Tue, 24 Feb 2026 08:39:56 +0000 Subject: [PATCH] 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 --- frontend/src/App.tsx | 12 +- frontend/src/components/layout/Header.tsx | 108 +++++- frontend/src/components/ui/checkbox.tsx | 32 ++ frontend/src/components/ui/dialog.tsx | 156 +++++++++ frontend/src/components/ui/popover.tsx | 87 +++++ frontend/src/components/ui/switch.tsx | 35 ++ frontend/src/hooks/useNotifications.ts | 65 ++++ frontend/src/pages/AdminAuditPage.tsx | 272 +++++++++++++++ frontend/src/pages/AdminInvitationsPage.tsx | 300 +++++++++++++++++ frontend/src/pages/AdminUsersPage.tsx | 318 ++++++++++++++++++ frontend/src/pages/CodingPage.tsx | 349 ++++++++++++++++++++ frontend/src/pages/ReportsPage.tsx | 251 ++++++++++++++ frontend/src/types/index.ts | 72 ++++ 13 files changed, 2044 insertions(+), 13 deletions(-) create mode 100644 frontend/src/components/ui/checkbox.tsx create mode 100644 frontend/src/components/ui/dialog.tsx create mode 100644 frontend/src/components/ui/popover.tsx create mode 100644 frontend/src/components/ui/switch.tsx create mode 100644 frontend/src/hooks/useNotifications.ts create mode 100644 frontend/src/pages/AdminAuditPage.tsx create mode 100644 frontend/src/pages/AdminInvitationsPage.tsx create mode 100644 frontend/src/pages/AdminUsersPage.tsx create mode 100644 frontend/src/pages/CodingPage.tsx create mode 100644 frontend/src/pages/ReportsPage.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e123dfa..59a9483 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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

Coding

} -function ReportsPage() { return

Berichte

} -function AdminUsersPage() { return

Benutzer

} -function AdminInvitationsPage() { return

Einladungen

} -function AdminAuditPage() { return

Audit-Log

} +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 ( diff --git a/frontend/src/components/layout/Header.tsx b/frontend/src/components/layout/Header.tsx index fbe477f..0f5e8df 100644 --- a/frontend/src/components/layout/Header.tsx +++ b/frontend/src/components/layout/Header.tsx @@ -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) {
- {/* Notification bell placeholder (Task 27) */} - + {/* Notification bell with dropdown */} + + + + + +
+

Benachrichtigungen

+ {unreadCount > 0 && ( + + )} +
+ + + {notifications.length === 0 ? ( +

+ Keine Benachrichtigungen +

+ ) : ( +
+ {notifications.map((n) => ( + + ))} +
+ )} +
+
+
{/* User menu */} @@ -83,3 +156,26 @@ export function Header({ onToggleSidebar }: HeaderProps) { ) } + +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 + } +} diff --git a/frontend/src/components/ui/checkbox.tsx b/frontend/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..a3ec184 --- /dev/null +++ b/frontend/src/components/ui/checkbox.tsx @@ -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) { + return ( + + + + + + ) +} + +export { Checkbox } diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx new file mode 100644 index 0000000..d8a06aa --- /dev/null +++ b/frontend/src/components/ui/dialog.tsx @@ -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) { + return +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogFooter({ + className, + showCloseButton = false, + children, + ...props +}: React.ComponentProps<"div"> & { + showCloseButton?: boolean +}) { + return ( +
+ {children} + {showCloseButton && ( + + + + )} +
+ ) +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/frontend/src/components/ui/popover.tsx b/frontend/src/components/ui/popover.tsx new file mode 100644 index 0000000..103bec3 --- /dev/null +++ b/frontend/src/components/ui/popover.tsx @@ -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) { + return +} + +function PopoverTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function PopoverContent({ + className, + align = "center", + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function PopoverAnchor({ + ...props +}: React.ComponentProps) { + return +} + +function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) { + return ( +
+ ) +} + +function PopoverDescription({ + className, + ...props +}: React.ComponentProps<"p">) { + return ( +

+ ) +} + +export { + Popover, + PopoverTrigger, + PopoverContent, + PopoverAnchor, + PopoverHeader, + PopoverTitle, + PopoverDescription, +} diff --git a/frontend/src/components/ui/switch.tsx b/frontend/src/components/ui/switch.tsx new file mode 100644 index 0000000..80dd45b --- /dev/null +++ b/frontend/src/components/ui/switch.tsx @@ -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 & { + size?: "sm" | "default" +}) { + return ( + + + + ) +} + +export { Switch } diff --git a/frontend/src/hooks/useNotifications.ts b/frontend/src/hooks/useNotifications.ts new file mode 100644 index 0000000..23589fa --- /dev/null +++ b/frontend/src/hooks/useNotifications.ts @@ -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([]) + const [unreadCount, setUnreadCount] = useState(0) + const [loading, setLoading] = useState(true) + const intervalRef = useRef | null>(null) + + const fetchNotifications = useCallback(async () => { + try { + const res = await api.get('/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(`/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, + } +} diff --git a/frontend/src/pages/AdminAuditPage.tsx b/frontend/src/pages/AdminAuditPage.tsx new file mode 100644 index 0000000..6e36224 --- /dev/null +++ b/frontend/src/pages/AdminAuditPage.tsx @@ -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([]) + const [loading, setLoading] = useState(true) + const [expandedIds, setExpandedIds] = useState>(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 = { 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('/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 ( +

+

Audit-Log

+ + {/* Filters */} + + + + + Filter + + + +
+
+ + setFilterUserId(e.target.value)} + placeholder="z.B. 1" + className="w-28" + /> +
+
+ + setFilterAction(e.target.value)} + placeholder="z.B. login" + className="w-40" + /> +
+
+ + setFilterDateFrom(e.target.value)} + className="w-40" + /> +
+
+ + setFilterDateTo(e.target.value)} + className="w-40" + /> +
+ + +
+
+
+ + {/* Audit table */} + {loading ? ( +
+ {Array.from({ length: 8 }).map((_, i) => ( + + ))} +
+ ) : entries.length > 0 ? ( + <> + + + + + Zeitpunkt + Benutzer-ID + Aktion + Entität + Details + + + + {entries.map((entry) => { + const isExpanded = expandedIds.has(entry.id) + const hasValues = entry.old_values || entry.new_values + return ( + <> + hasValues && toggleExpanded(entry.id)} + > + + {hasValues && ( + isExpanded + ? + : + )} + + + {formatDateTime(entry.created_at)} + + + {entry.user_id !== null ? entry.user_id : '-'} + + + {entry.action} + + + {entry.entity_type ? ( + + {entry.entity_type} + {entry.entity_id ? ` #${entry.entity_id}` : ''} + + ) : ( + '-' + )} + + + {entry.ip_address && ( + + {entry.ip_address} + + )} + + + {isExpanded && hasValues && ( + + +
+ {entry.old_values && ( +
+

Alte Werte

+
+                                  {JSON.stringify(entry.old_values, null, 2)}
+                                
+
+ )} + {entry.new_values && ( +
+

Neue Werte

+
+                                  {JSON.stringify(entry.new_values, null, 2)}
+                                
+
+ )} +
+
+
+ )} + + ) + })} +
+
+ + {/* Pagination */} +
+ + + Einträge {skip + 1} - {skip + entries.length} + + +
+ + ) : ( +

+ Keine Audit-Einträge gefunden. +

+ )} +
+ ) +} + +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 + } +} diff --git a/frontend/src/pages/AdminInvitationsPage.tsx b/frontend/src/pages/AdminInvitationsPage.tsx new file mode 100644 index 0000000..6f09ee1 --- /dev/null +++ b/frontend/src/pages/AdminInvitationsPage.tsx @@ -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([]) + const [loading, setLoading] = useState(true) + + // Create dialog + const [createOpen, setCreateOpen] = useState(false) + const [createForm, setCreateForm] = useState({ + email: '', role: 'dak_mitarbeiter', expires_in_days: 7, + }) + const [creating, setCreating] = useState(false) + const [createError, setCreateError] = useState('') + const [createdToken, setCreatedToken] = useState(null) + + // Copy feedback + const [copiedId, setCopiedId] = useState(null) + + useEffect(() => { + fetchInvitations() + }, []) + + const fetchInvitations = () => { + setLoading(true) + api.get('/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 = { + 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('/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 ( +
+
+

Einladungen

+ +
+ + {loading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) : invitations.length > 0 ? ( + + + + + Token + E-Mail + Rolle + Erstellt + Gültig bis + Status + Aktion + + + + {invitations.map((inv) => { + const status = getStatus(inv) + return ( + + + {inv.token.slice(0, 12)}... + + {inv.email || '-'} + + + {inv.role === 'admin' ? 'Admin' : 'DAK Mitarbeiter'} + + + {formatDateTime(inv.created_at)} + {formatDateTime(inv.expires_at)} + + {status.label} + + + + + + + + {copiedId === inv.id ? 'Kopiert!' : 'Token kopieren'} + + + + + ) + })} + +
+
+ ) : ( +

+ Keine Einladungen vorhanden. +

+ )} + + {/* Create invitation dialog */} + { if (!open) resetCreateDialog() }}> + + + Einladung erstellen + + Erstellen Sie einen Einladungstoken für neue Benutzer. + + + + {createdToken ? ( +
+ + + Einladung erfolgreich erstellt! Kopieren Sie den Token: + + +
+ {createdToken} + +
+ + + +
+ ) : ( + <> +
+
+ + setCreateForm((f) => ({ ...f, email: e.target.value }))} + placeholder="Optional: E-Mail des Eingeladenen" + /> +
+
+ + +
+
+ + setCreateForm((f) => ({ ...f, expires_in_days: Number(e.target.value) }))} + min={1} + max={90} + className="w-28" + /> +
+ {createError && ( + + {createError} + + )} +
+ + + + + + )} +
+
+
+ ) +} + +function formatDateTime(dateStr: string): string { + try { + return new Date(dateStr).toLocaleString('de-DE', { + day: '2-digit', month: '2-digit', year: 'numeric', + hour: '2-digit', minute: '2-digit', + }) + } catch { + return dateStr + } +} diff --git a/frontend/src/pages/AdminUsersPage.tsx b/frontend/src/pages/AdminUsersPage.tsx new file mode 100644 index 0000000..89cf99f --- /dev/null +++ b/frontend/src/pages/AdminUsersPage.tsx @@ -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([]) + const [loading, setLoading] = useState(true) + + // Create dialog + const [createOpen, setCreateOpen] = useState(false) + const [createForm, setCreateForm] = useState({ + 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(null) + const [editForm, setEditForm] = useState({}) + const [editing, setEditing] = useState(false) + const [editError, setEditError] = useState('') + + // Fetch users + useEffect(() => { + fetchUsers() + }, []) + + const fetchUsers = () => { + setLoading(true) + api.get('/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('/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(`/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 ( +
+
+

Benutzer

+ +
+ + {loading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) : users.length > 0 ? ( + + + + Benutzername + E-Mail + Rolle + Aktiv + Letzter Login + Aktion + + + + {users.map((u) => ( + + {u.username} + {u.email} + + + {u.role === 'admin' ? 'Admin' : 'DAK Mitarbeiter'} + + + + + {u.is_active ? 'Aktiv' : 'Inaktiv'} + + + + {u.last_login ? formatDateTime(u.last_login) : '-'} + + + + + + ))} + +
+ ) : ( +

+ Keine Benutzer gefunden. +

+ )} + + {/* Create user dialog */} + + + + Neuen Benutzer erstellen + + Füllen Sie die Felder aus, um einen neuen Benutzer anzulegen. + + +
+
+ + setCreateForm((f) => ({ ...f, username: e.target.value }))} + /> +
+
+ + setCreateForm((f) => ({ ...f, email: e.target.value }))} + /> +
+
+ + setCreateForm((f) => ({ ...f, password: e.target.value }))} + /> +
+
+ + +
+ {createError && ( + + {createError} + + )} +
+ + + + +
+
+ + {/* Edit user dialog */} + + + + Benutzer bearbeiten + + {editUser?.username} ({editUser?.email}) + + +
+
+ + setEditForm((f) => ({ ...f, username: e.target.value }))} + /> +
+
+ + setEditForm((f) => ({ ...f, email: e.target.value }))} + /> +
+
+ + +
+
+ + setEditForm((f) => ({ ...f, is_active: checked }))} + /> +
+ {editError && ( + + {editError} + + )} +
+ + + + +
+
+
+ ) +} + +function formatDateTime(dateStr: string): string { + try { + return new Date(dateStr).toLocaleString('de-DE', { + day: '2-digit', month: '2-digit', year: 'numeric', + hour: '2-digit', minute: '2-digit', + }) + } catch { + return dateStr + } +} diff --git a/frontend/src/pages/CodingPage.tsx b/frontend/src/pages/CodingPage.tsx new file mode 100644 index 0000000..6bcf98a --- /dev/null +++ b/frontend/src/pages/CodingPage.tsx @@ -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 = { + 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('__all__') + const [page, setPage] = useState(1) + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [formStates, setFormStates] = useState>({}) + const [savingIds, setSavingIds] = useState>(new Set()) + const [savedIds, setSavedIds] = useState>(new Set()) + const [errors, setErrors] = useState>({}) + + // Fetch coding queue + useEffect(() => { + setLoading(true) + const params: Record = { page, per_page: 50 } + if (fallgruppe !== '__all__') params.fallgruppe = fallgruppe + + api.get('/coding/queue', { params }) + .then((res) => { + setData(res.data) + // Initialize form states for each case + const states: Record = {} + 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 ( +
+
+

Coding-Warteschlange

+
+ + {codedCount} von {totalCount} Fälle codiert + + +
+
+ + {/* Progress bar */} + {data && totalCount > 0 && ( +
+
+
+ )} + + {/* Cards */} + {loading ? ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ ) : data && data.items.length > 0 ? ( +
+ {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 ( + + +
+ + {c.fall_id || `#${c.id}`} — {c.nachname} + {c.vorname ? `, ${c.vorname}` : ''} + + {isSaved && ( + + )} +
+
+ + {FALLGRUPPEN_LABELS[c.fallgruppe] || c.fallgruppe} + + {c.icd && ( + + {c.icd} + + )} +
+
+ + {/* Gutachten-Typ */} +
+ + +
+ + {/* Therapieänderung */} +
+ + +
+ + {/* Checkboxes */} +
+
+ + updateFormField(c.id, 'ta_diagnosekorrektur', !!checked) + } + /> + +
+
+ + updateFormField(c.id, 'ta_unterversorgung', !!checked) + } + /> + +
+
+ + updateFormField(c.id, 'ta_uebertherapie', !!checked) + } + /> + +
+
+ + {error && ( + + {error} + + )} + + +
+
+ ) + })} +
+ ) : ( +

+ Keine Fälle in der Warteschlange. +

+ )} + + {/* Pagination */} + {data && data.total > 50 && ( +
+ + + Seite {page} von {Math.ceil(data.total / 50)} + + +
+ )} +
+ ) +} diff --git a/frontend/src/pages/ReportsPage.tsx b/frontend/src/pages/ReportsPage.tsx new file mode 100644 index 0000000..83ec8da --- /dev/null +++ b/frontend/src/pages/ReportsPage.tsx @@ -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([]) + 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('/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 ( +
+

Berichte

+ + {/* Report generation (admin only) */} + {isAdmin && ( + + + + + Bericht generieren + + + +
+
+ + setGenJahr(Number(e.target.value))} + className="w-28" + min={2020} + max={2030} + /> +
+
+ + setGenKw(Number(e.target.value))} + className="w-28" + min={1} + max={53} + /> +
+ +
+ {genError && ( + + {genError} + + )} + {genSuccess && ( + + {genSuccess} + + )} +
+
+ )} + + {/* Reports table */} + + + + Bisherige Berichte ({totalReports}) + + + + {loading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) : reports.length > 0 ? ( + + + + Berichtsdatum + Jahr + KW + Erstellt am + Aktion + + + + {reports.map((r) => ( + + {formatDate(r.report_date)} + {r.jahr} + KW {r.kw} + {formatDateTime(r.generated_at)} + + + + + ))} + +
+ ) : ( +

+ Keine Berichte vorhanden. +

+ )} +
+
+
+ ) +} + +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 + } +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index e83daea..d4a5e43 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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 | null + new_values: Record | null + ip_address: string | null + created_at: string +}