mirror of
https://github.com/complexcaresolutions/dak.c2s.git
synced 2026-03-17 14:53:41 +00:00
feat: add KPI links, My Disclosures page, and extend Admin Disclosures
- Dashboard KPI cards are now clickable with role-based links
- New GutachtenStatistikPage placeholder at /gutachten-statistik
- New "Meine Freigaben" page for DAK-Mitarbeiter to view/revoke own disclosures
- Backend: GET /cases/my-disclosure-requests, PUT /cases/disclosure-requests/{id}/revoke
- Admin Disclosures page: full history with status tabs and revoke capability
- Backend: PUT /admin/disclosure-requests/{id}/revoke, default shows all statuses
- Sidebar: "Meine Freigaben" entry for dak_mitarbeiter role
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
4504d4300f
commit
32cee4d30d
13 changed files with 435 additions and 76 deletions
|
|
@ -19,7 +19,7 @@ from app.schemas.disclosure import (
|
|||
from app.schemas.user import UserCreate, UserResponse, UserUpdate
|
||||
from app.services.audit_service import log_action
|
||||
from app.services.disclosure_service import (
|
||||
get_pending_count, review_disclosure_request,
|
||||
get_pending_count, review_disclosure_request, revoke_disclosure,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
|
@ -316,12 +316,10 @@ def list_disclosure_requests(
|
|||
admin: User = Depends(require_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""List disclosure requests (default: pending only)."""
|
||||
"""List disclosure requests. Without status filter, returns all."""
|
||||
query = db.query(DisclosureRequest)
|
||||
if status_filter:
|
||||
query = query.filter(DisclosureRequest.status == status_filter)
|
||||
else:
|
||||
query = query.filter(DisclosureRequest.status == "pending")
|
||||
|
||||
requests = query.order_by(DisclosureRequest.created_at.desc()).all()
|
||||
|
||||
|
|
@ -382,3 +380,34 @@ def review_disclosure(
|
|||
reviewed_by=dr.reviewed_by, reviewed_at=dr.reviewed_at,
|
||||
expires_at=dr.expires_at, created_at=dr.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/disclosure-requests/{request_id}/revoke", response_model=DisclosureRequestResponse)
|
||||
def admin_revoke_disclosure(
|
||||
request_id: int,
|
||||
request: Request,
|
||||
admin: User = Depends(require_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Admin: revoke an active disclosure (sets expires_at to now)."""
|
||||
try:
|
||||
dr = revoke_disclosure(db, request_id, admin.id, admin=True)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
log_action(
|
||||
db, user_id=admin.id, action="disclosure_admin_revoked",
|
||||
entity_type="disclosure_request", entity_id=request_id,
|
||||
new_values={"expires_at": dr.expires_at.isoformat() if dr.expires_at else None},
|
||||
ip_address=request.client.host if request.client else None,
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
|
||||
return DisclosureRequestResponse(
|
||||
id=dr.id, case_id=dr.case_id, requester_id=dr.requester_id,
|
||||
requester_username=dr.requester.username if dr.requester else None,
|
||||
fall_id=dr.case.fall_id if dr.case else None,
|
||||
reason=dr.reason, status=dr.status,
|
||||
reviewed_by=dr.reviewed_by, reviewed_at=dr.reviewed_at,
|
||||
expires_at=dr.expires_at, created_at=dr.created_at,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -26,7 +26,8 @@ from app.config import get_settings
|
|||
from app.services.audit_service import log_action
|
||||
from app.services.import_service import has_random_suffix
|
||||
from app.services.disclosure_service import (
|
||||
create_disclosure_request, has_active_disclosure, has_pending_request,
|
||||
create_disclosure_request, get_my_disclosure_requests, has_active_disclosure,
|
||||
has_pending_request, revoke_disclosure,
|
||||
)
|
||||
from app.services.icd_service import generate_coding_template, get_pending_icd_cases, save_icd_for_case
|
||||
|
||||
|
|
@ -205,6 +206,62 @@ def request_disclosure(
|
|||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# My disclosure requests (for dak_mitarbeiter)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/my-disclosure-requests", response_model=list[DisclosureRequestResponse])
|
||||
def list_my_disclosure_requests(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Return all disclosure requests made by the current user."""
|
||||
requests = get_my_disclosure_requests(db, user.id)
|
||||
result = []
|
||||
for dr in requests:
|
||||
result.append(DisclosureRequestResponse(
|
||||
id=dr.id, case_id=dr.case_id, requester_id=dr.requester_id,
|
||||
requester_username=dr.requester.username if dr.requester else None,
|
||||
fall_id=dr.case.fall_id if dr.case else None,
|
||||
reason=dr.reason, status=dr.status,
|
||||
reviewed_by=dr.reviewed_by, reviewed_at=dr.reviewed_at,
|
||||
expires_at=dr.expires_at, created_at=dr.created_at,
|
||||
))
|
||||
return result
|
||||
|
||||
|
||||
@router.put("/disclosure-requests/{request_id}/revoke", response_model=DisclosureRequestResponse)
|
||||
def revoke_my_disclosure(
|
||||
request_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Revoke own active disclosure (sets expires_at to now)."""
|
||||
try:
|
||||
dr = revoke_disclosure(db, request_id, user.id, admin=False)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
log_action(
|
||||
db, user_id=user.id, action="disclosure_self_revoked",
|
||||
entity_type="disclosure_request", entity_id=request_id,
|
||||
new_values={"expires_at": dr.expires_at.isoformat() if dr.expires_at else None},
|
||||
ip_address=request.client.host if request.client else None,
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
|
||||
return DisclosureRequestResponse(
|
||||
id=dr.id, case_id=dr.case_id, requester_id=dr.requester_id,
|
||||
requester_username=dr.requester.username if dr.requester else None,
|
||||
fall_id=dr.case.fall_id if dr.case else None,
|
||||
reason=dr.reason, status=dr.status,
|
||||
reviewed_by=dr.reviewed_by, reviewed_at=dr.reviewed_at,
|
||||
expires_at=dr.expires_at, created_at=dr.created_at,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Paginated case list
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -113,6 +113,38 @@ def review_disclosure_request(
|
|||
return dr
|
||||
|
||||
|
||||
def revoke_disclosure(db: Session, request_id: int, user_id: int, *, admin: bool = False) -> DisclosureRequest:
|
||||
"""Revoke an active disclosure by setting expires_at to now.
|
||||
|
||||
If admin=False, only the original requester can revoke their own disclosure.
|
||||
If admin=True, any admin can revoke any disclosure.
|
||||
"""
|
||||
dr = db.query(DisclosureRequest).filter(DisclosureRequest.id == request_id).first()
|
||||
if not dr:
|
||||
raise ValueError("Disclosure request not found")
|
||||
if dr.status != "approved":
|
||||
raise ValueError("Only approved disclosures can be revoked")
|
||||
if dr.expires_at and dr.expires_at <= datetime.now(timezone.utc):
|
||||
raise ValueError("Disclosure already expired")
|
||||
if not admin and dr.requester_id != user_id:
|
||||
raise ValueError("Not authorized to revoke this disclosure")
|
||||
|
||||
dr.expires_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
db.refresh(dr)
|
||||
return dr
|
||||
|
||||
|
||||
def get_my_disclosure_requests(db: Session, user_id: int) -> list[DisclosureRequest]:
|
||||
"""Return all disclosure requests made by a specific user."""
|
||||
return (
|
||||
db.query(DisclosureRequest)
|
||||
.filter(DisclosureRequest.requester_id == user_id)
|
||||
.order_by(DisclosureRequest.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def get_pending_requests(db: Session) -> list[DisclosureRequest]:
|
||||
"""Return all pending disclosure requests."""
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ import { AdminInvitationsPage } from '@/pages/AdminInvitationsPage'
|
|||
import { AdminAuditPage } from '@/pages/AdminAuditPage'
|
||||
import { DisclosuresPage } from '@/pages/DisclosuresPage'
|
||||
import { AccountPage } from '@/pages/AccountPage'
|
||||
import { GutachtenStatistikPage } from '@/pages/GutachtenStatistikPage'
|
||||
import { MyDisclosuresPage } from '@/pages/MyDisclosuresPage'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
|
|
@ -43,7 +45,9 @@ function App() {
|
|||
<Route path="icd" element={<IcdPage />} />
|
||||
<Route path="coding" element={<ProtectedRoute requireAdmin><CodingPage /></ProtectedRoute>} />
|
||||
<Route path="reports" element={<ReportsPage />} />
|
||||
<Route path="gutachten-statistik" element={<GutachtenStatistikPage />} />
|
||||
<Route path="account" element={<AccountPage />} />
|
||||
<Route path="disclosures" element={<MyDisclosuresPage />} />
|
||||
<Route path="admin/users" element={<ProtectedRoute requireAdmin><AdminUsersPage /></ProtectedRoute>} />
|
||||
<Route path="admin/invitations" element={<ProtectedRoute requireAdmin><AdminInvitationsPage /></ProtectedRoute>} />
|
||||
<Route path="admin/audit" element={<ProtectedRoute requireAdmin><AdminAuditPage /></ProtectedRoute>} />
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ const mainNavItems: NavItem[] = [
|
|||
|
||||
const accountNavItems: NavItem[] = [
|
||||
{ label: 'Kontoverwaltung', to: '/account', icon: UserCog },
|
||||
{ label: 'Meine Freigaben', to: '/disclosures', icon: ShieldCheck, mitarbeiterOnly: true },
|
||||
]
|
||||
|
||||
const adminNavItems: NavItem[] = [
|
||||
|
|
@ -68,11 +69,15 @@ function NavItemLink({ item }: { item: NavItem }) {
|
|||
export function Sidebar({ className }: { className?: string }) {
|
||||
const { user, isAdmin } = useAuth()
|
||||
|
||||
const visibleMainItems = mainNavItems.filter((item) => {
|
||||
if (item.adminOnly && !isAdmin) return false
|
||||
if (item.mitarbeiterOnly && user?.role !== 'dak_mitarbeiter') return false
|
||||
return true
|
||||
})
|
||||
const filterItems = (items: NavItem[]) =>
|
||||
items.filter((item) => {
|
||||
if (item.adminOnly && !isAdmin) return false
|
||||
if (item.mitarbeiterOnly && user?.role !== 'dak_mitarbeiter') return false
|
||||
return true
|
||||
})
|
||||
|
||||
const visibleMainItems = filterItems(mainNavItems)
|
||||
const visibleAccountItems = filterItems(accountNavItems)
|
||||
|
||||
return (
|
||||
<aside className={cn('flex h-full flex-col gap-4 p-4', className)}>
|
||||
|
|
@ -88,7 +93,7 @@ export function Sidebar({ className }: { className?: string }) {
|
|||
|
||||
<Separator />
|
||||
<nav className="flex flex-col gap-1">
|
||||
{accountNavItems.map((item) => (
|
||||
{visibleAccountItems.map((item) => (
|
||||
<NavItemLink key={item.to} item={item} />
|
||||
))}
|
||||
</nav>
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ describe('useDisclosures', () => {
|
|||
expect(result.current.data).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('works without status parameter (uses default "pending")', async () => {
|
||||
it('works without status parameter (returns all)', async () => {
|
||||
let capturedStatus: string | null = null
|
||||
|
||||
server.use(
|
||||
|
|
@ -65,7 +65,7 @@ describe('useDisclosures', () => {
|
|||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
|
||||
expect(capturedStatus).toBe('pending')
|
||||
expect(capturedStatus).toBeNull()
|
||||
expect(result.current.data).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,13 +1,23 @@
|
|||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { getDisclosureRequests, reviewDisclosure } from '@/services/disclosureService'
|
||||
import {
|
||||
getDisclosureRequests, getMyDisclosureRequests,
|
||||
reviewDisclosure, revokeDisclosure, adminRevokeDisclosure,
|
||||
} from '@/services/disclosureService'
|
||||
|
||||
export function useDisclosures(status: string = 'pending') {
|
||||
export function useDisclosures(status?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['disclosures', status],
|
||||
queryKey: ['disclosures', status ?? 'all'],
|
||||
queryFn: () => getDisclosureRequests(status),
|
||||
})
|
||||
}
|
||||
|
||||
export function useMyDisclosures() {
|
||||
return useQuery({
|
||||
queryKey: ['my-disclosures'],
|
||||
queryFn: getMyDisclosureRequests,
|
||||
})
|
||||
}
|
||||
|
||||
export function useReviewDisclosure() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
|
|
@ -19,3 +29,25 @@ export function useReviewDisclosure() {
|
|||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useRevokeDisclosure() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => revokeDisclosure(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['my-disclosures'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['cases'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useAdminRevokeDisclosure() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => adminRevokeDisclosure(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['disclosures'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['notifications'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend,
|
||||
PieChart, Pie, Cell, ResponsiveContainer,
|
||||
} from 'recharts'
|
||||
import { FileText, Clock, Code, Stethoscope } from 'lucide-react'
|
||||
import { useAuth } from '@/context/AuthContext'
|
||||
import { useDashboard } from '@/hooks/useDashboard'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import {
|
||||
|
|
@ -31,6 +33,7 @@ export function DashboardPage() {
|
|||
const currentYear = new Date().getFullYear()
|
||||
const [jahr, setJahr] = useState(currentYear)
|
||||
const { data, isLoading: loading } = useDashboard(jahr)
|
||||
const { isAdmin } = useAuth()
|
||||
|
||||
const fallgruppenData = data
|
||||
? Object.entries(data.kpis.fallgruppen)
|
||||
|
|
@ -80,21 +83,25 @@ export function DashboardPage() {
|
|||
title="Fälle gesamt"
|
||||
value={data.kpis.total_cases}
|
||||
icon={<FileText className="size-5 text-muted-foreground" />}
|
||||
href="/cases"
|
||||
/>
|
||||
<KpiCard
|
||||
title="Offene ICD"
|
||||
value={data.kpis.pending_icd}
|
||||
icon={<Clock className="size-5 text-muted-foreground" />}
|
||||
href="/icd"
|
||||
/>
|
||||
<KpiCard
|
||||
title="Offene Codierung"
|
||||
value={data.kpis.pending_coding}
|
||||
icon={<Code className="size-5 text-muted-foreground" />}
|
||||
href={isAdmin ? '/coding' : undefined}
|
||||
/>
|
||||
<KpiCard
|
||||
title="Gutachten gesamt"
|
||||
value={data.kpis.total_gutachten}
|
||||
icon={<Stethoscope className="size-5 text-muted-foreground" />}
|
||||
href="/gutachten-statistik"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -205,9 +212,9 @@ export function DashboardPage() {
|
|||
)
|
||||
}
|
||||
|
||||
function KpiCard({ title, value, icon }: { title: string; value: number; icon: React.ReactNode }) {
|
||||
return (
|
||||
<Card>
|
||||
function KpiCard({ title, value, icon, href }: { title: string; value: number; icon: React.ReactNode; href?: string }) {
|
||||
const card = (
|
||||
<Card className={href ? 'transition-colors hover:border-primary/50 hover:shadow-md' : undefined}>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium">{title}</CardTitle>
|
||||
{icon}
|
||||
|
|
@ -217,4 +224,9 @@ function KpiCard({ title, value, icon }: { title: string; value: number; icon: R
|
|||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
if (href) {
|
||||
return <Link to={href} className="block">{card}</Link>
|
||||
}
|
||||
return card
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,43 @@
|
|||
import { Check, X } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { Check, X, ShieldOff } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useDisclosures, useReviewDisclosure } from '@/hooks/useDisclosures'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { useDisclosures, useReviewDisclosure, useAdminRevokeDisclosure } from '@/hooks/useDisclosures'
|
||||
import type { DisclosureRequest } from '@/types'
|
||||
|
||||
type StatusFilter = 'all' | 'pending' | 'approved' | 'rejected'
|
||||
|
||||
function statusBadge(dr: DisclosureRequest) {
|
||||
const now = new Date()
|
||||
if (dr.status === 'pending') {
|
||||
return <Badge variant="outline" className="bg-yellow-50 text-yellow-700 border-yellow-300">Ausstehend</Badge>
|
||||
}
|
||||
if (dr.status === 'rejected') {
|
||||
return <Badge variant="outline" className="bg-red-50 text-red-700 border-red-300">Abgelehnt</Badge>
|
||||
}
|
||||
if (dr.expires_at && new Date(dr.expires_at) <= now) {
|
||||
return <Badge variant="outline" className="bg-gray-50 text-gray-500 border-gray-300">Abgelaufen</Badge>
|
||||
}
|
||||
return <Badge variant="outline" className="bg-green-50 text-green-700 border-green-300">Genehmigt</Badge>
|
||||
}
|
||||
|
||||
function isActiveApproved(dr: DisclosureRequest): boolean {
|
||||
if (dr.status !== 'approved') return false
|
||||
if (!dr.expires_at) return false
|
||||
return new Date(dr.expires_at) > new Date()
|
||||
}
|
||||
|
||||
export function DisclosuresPage() {
|
||||
const { data: requests = [], isLoading } = useDisclosures('pending')
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
|
||||
const queryStatus = statusFilter === 'all' ? undefined : statusFilter
|
||||
const { data: requests = [], isLoading } = useDisclosures(queryStatus)
|
||||
const reviewMutation = useReviewDisclosure()
|
||||
const revokeMutation = useAdminRevokeDisclosure()
|
||||
|
||||
const handleReview = (id: number, status: 'approved' | 'rejected') => {
|
||||
reviewMutation.mutate({ id, status })
|
||||
|
|
@ -17,60 +47,91 @@ export function DisclosuresPage() {
|
|||
<div className="p-6 space-y-4">
|
||||
<h1 className="text-2xl font-bold">Freigabe-Anfragen</h1>
|
||||
|
||||
<Tabs value={statusFilter} onValueChange={(v) => setStatusFilter(v as StatusFilter)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="all">Alle</TabsTrigger>
|
||||
<TabsTrigger value="pending">Ausstehend</TabsTrigger>
|
||||
<TabsTrigger value="approved">Genehmigt</TabsTrigger>
|
||||
<TabsTrigger value="rejected">Abgelehnt</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-24 w-full" />
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : requests.length === 0 ? (
|
||||
<p className="text-muted-foreground">Keine offenen Anfragen.</p>
|
||||
<p className="text-muted-foreground">Keine Anfragen gefunden.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{requests.map((dr) => (
|
||||
<Card key={dr.id}>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">
|
||||
Fall {dr.fall_id || dr.case_id}
|
||||
</CardTitle>
|
||||
<Badge variant="outline">{dr.status}</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<p className="text-sm">
|
||||
<span className="text-muted-foreground">Angefragt von:</span>{' '}
|
||||
{dr.requester_username || `User #${dr.requester_id}`}
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
<span className="text-muted-foreground">Begründung:</span>{' '}
|
||||
{dr.reason}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{new Date(dr.created_at).toLocaleString('de-DE')}
|
||||
</p>
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={reviewMutation.isPending}
|
||||
onClick={() => handleReview(dr.id, 'approved')}
|
||||
>
|
||||
<Check className="size-4 mr-1" />
|
||||
Genehmigen
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={reviewMutation.isPending}
|
||||
onClick={() => handleReview(dr.id, 'rejected')}
|
||||
>
|
||||
<X className="size-4 mr-1" />
|
||||
Ablehnen
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Fall-ID</TableHead>
|
||||
<TableHead>Antragsteller</TableHead>
|
||||
<TableHead>Begründung</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Datum</TableHead>
|
||||
<TableHead>Gültig bis</TableHead>
|
||||
<TableHead className="text-right">Aktionen</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{requests.map((dr) => (
|
||||
<TableRow key={dr.id}>
|
||||
<TableCell className="font-medium">{dr.fall_id || `#${dr.case_id}`}</TableCell>
|
||||
<TableCell>{dr.requester_username || `User #${dr.requester_id}`}</TableCell>
|
||||
<TableCell className="max-w-xs truncate">{dr.reason}</TableCell>
|
||||
<TableCell>{statusBadge(dr)}</TableCell>
|
||||
<TableCell>{new Date(dr.created_at).toLocaleDateString('de-DE')}</TableCell>
|
||||
<TableCell>
|
||||
{dr.expires_at
|
||||
? new Date(dr.expires_at).toLocaleString('de-DE')
|
||||
: '—'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
{dr.status === 'pending' && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={reviewMutation.isPending}
|
||||
onClick={() => handleReview(dr.id, 'approved')}
|
||||
>
|
||||
<Check className="size-4 mr-1" />
|
||||
Genehmigen
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={reviewMutation.isPending}
|
||||
onClick={() => handleReview(dr.id, 'rejected')}
|
||||
>
|
||||
<X className="size-4 mr-1" />
|
||||
Ablehnen
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{isActiveApproved(dr) && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={revokeMutation.isPending}
|
||||
onClick={() => revokeMutation.mutate(dr.id)}
|
||||
>
|
||||
<ShieldOff className="size-4 mr-1" />
|
||||
Entziehen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
17
frontend/src/pages/GutachtenStatistikPage.tsx
Normal file
17
frontend/src/pages/GutachtenStatistikPage.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { BarChart3 } from 'lucide-react'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
|
||||
export function GutachtenStatistikPage() {
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<h1 className="text-2xl font-bold">Gutachten-Statistik</h1>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
||||
<BarChart3 className="size-12 mb-4" />
|
||||
<p className="text-lg font-medium">Kommt bald</p>
|
||||
<p className="text-sm">Diese Seite wird in Kürze mit detaillierten Gutachten-Statistiken verfügbar sein.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
94
frontend/src/pages/MyDisclosuresPage.tsx
Normal file
94
frontend/src/pages/MyDisclosuresPage.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { ShieldOff } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { useMyDisclosures, useRevokeDisclosure } from '@/hooks/useDisclosures'
|
||||
import type { DisclosureRequest } from '@/types'
|
||||
|
||||
function statusBadge(dr: DisclosureRequest) {
|
||||
const now = new Date()
|
||||
if (dr.status === 'pending') {
|
||||
return <Badge variant="outline" className="bg-yellow-50 text-yellow-700 border-yellow-300">Ausstehend</Badge>
|
||||
}
|
||||
if (dr.status === 'rejected') {
|
||||
return <Badge variant="outline" className="bg-red-50 text-red-700 border-red-300">Abgelehnt</Badge>
|
||||
}
|
||||
// approved
|
||||
if (dr.expires_at && new Date(dr.expires_at) <= now) {
|
||||
return <Badge variant="outline" className="bg-gray-50 text-gray-500 border-gray-300">Abgelaufen</Badge>
|
||||
}
|
||||
return <Badge variant="outline" className="bg-green-50 text-green-700 border-green-300">Genehmigt</Badge>
|
||||
}
|
||||
|
||||
function isActive(dr: DisclosureRequest): boolean {
|
||||
if (dr.status !== 'approved') return false
|
||||
if (!dr.expires_at) return false
|
||||
return new Date(dr.expires_at) > new Date()
|
||||
}
|
||||
|
||||
export function MyDisclosuresPage() {
|
||||
const { data: requests = [], isLoading } = useMyDisclosures()
|
||||
const revokeMutation = useRevokeDisclosure()
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<h1 className="text-2xl font-bold">Meine Freigaben</h1>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : requests.length === 0 ? (
|
||||
<p className="text-muted-foreground">Sie haben noch keine Freigaben angefragt.</p>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Fall-ID</TableHead>
|
||||
<TableHead>Begründung</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Angefragt am</TableHead>
|
||||
<TableHead>Gültig bis</TableHead>
|
||||
<TableHead className="text-right">Aktion</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{requests.map((dr) => (
|
||||
<TableRow key={dr.id}>
|
||||
<TableCell className="font-medium">{dr.fall_id || `#${dr.case_id}`}</TableCell>
|
||||
<TableCell className="max-w-xs truncate">{dr.reason}</TableCell>
|
||||
<TableCell>{statusBadge(dr)}</TableCell>
|
||||
<TableCell>{new Date(dr.created_at).toLocaleDateString('de-DE')}</TableCell>
|
||||
<TableCell>
|
||||
{dr.expires_at
|
||||
? new Date(dr.expires_at).toLocaleString('de-DE')
|
||||
: '—'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{isActive(dr) && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={revokeMutation.isPending}
|
||||
onClick={() => revokeMutation.mutate(dr.id)}
|
||||
>
|
||||
<ShieldOff className="size-4 mr-1" />
|
||||
Als erledigt markieren
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -15,7 +15,7 @@ describe('DisclosuresPage', () => {
|
|||
|
||||
await waitFor(() => {
|
||||
// The mock handler returns [mockDisclosureRequest] with fall_id '2026-06-onko-A123456789'
|
||||
expect(screen.getByText('Fall 2026-06-onko-A123456789')).toBeInTheDocument()
|
||||
expect(screen.getByText('2026-06-onko-A123456789')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Should show requester username
|
||||
|
|
@ -24,8 +24,9 @@ describe('DisclosuresPage', () => {
|
|||
// Should show the reason
|
||||
expect(screen.getByText('Benötige vollständige Patientendaten für Rückruf.')).toBeInTheDocument()
|
||||
|
||||
// Should show the status badge
|
||||
expect(screen.getByText('pending')).toBeInTheDocument()
|
||||
// Should show the status badge (translated) — multiple "Ausstehend" exist (tab + badge)
|
||||
const badges = screen.getAllByText('Ausstehend')
|
||||
expect(badges.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('shows approve and reject buttons', async () => {
|
||||
|
|
@ -48,7 +49,7 @@ describe('DisclosuresPage', () => {
|
|||
renderWithProviders(<DisclosuresPage />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Keine offenen Anfragen.')).toBeInTheDocument()
|
||||
expect(screen.getByText('Keine Anfragen gefunden.')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -66,8 +67,8 @@ describe('DisclosuresPage', () => {
|
|||
// The heading is always visible
|
||||
expect(screen.getByText('Freigabe-Anfragen')).toBeInTheDocument()
|
||||
|
||||
// During loading, the "Keine offenen Anfragen." message should not be visible yet
|
||||
expect(screen.queryByText('Keine offenen Anfragen.')).not.toBeInTheDocument()
|
||||
// During loading, the empty message should not be visible yet
|
||||
expect(screen.queryByText('Keine Anfragen gefunden.')).not.toBeInTheDocument()
|
||||
// Nor should any disclosure item be visible
|
||||
expect(screen.queryByText('Genehmigen')).not.toBeInTheDocument()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -21,3 +21,18 @@ export async function reviewDisclosure(requestId: number, status: 'approved' | '
|
|||
const res = await api.put<DisclosureRequest>(`/admin/disclosure-requests/${requestId}`, { status })
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function getMyDisclosureRequests(): Promise<DisclosureRequest[]> {
|
||||
const res = await api.get<DisclosureRequest[]>('/cases/my-disclosure-requests')
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function revokeDisclosure(requestId: number): Promise<DisclosureRequest> {
|
||||
const res = await api.put<DisclosureRequest>(`/cases/disclosure-requests/${requestId}/revoke`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function adminRevokeDisclosure(requestId: number): Promise<DisclosureRequest> {
|
||||
const res = await api.put<DisclosureRequest>(`/admin/disclosure-requests/${requestId}/revoke`)
|
||||
return res.data
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue