mirror of
https://github.com/complexcaresolutions/dak.c2s.git
synced 2026-03-17 17:13:42 +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.schemas.user import UserCreate, UserResponse, UserUpdate
|
||||||
from app.services.audit_service import log_action
|
from app.services.audit_service import log_action
|
||||||
from app.services.disclosure_service import (
|
from app.services.disclosure_service import (
|
||||||
get_pending_count, review_disclosure_request,
|
get_pending_count, review_disclosure_request, revoke_disclosure,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
@ -316,12 +316,10 @@ def list_disclosure_requests(
|
||||||
admin: User = Depends(require_admin),
|
admin: User = Depends(require_admin),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""List disclosure requests (default: pending only)."""
|
"""List disclosure requests. Without status filter, returns all."""
|
||||||
query = db.query(DisclosureRequest)
|
query = db.query(DisclosureRequest)
|
||||||
if status_filter:
|
if status_filter:
|
||||||
query = query.filter(DisclosureRequest.status == 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()
|
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,
|
reviewed_by=dr.reviewed_by, reviewed_at=dr.reviewed_at,
|
||||||
expires_at=dr.expires_at, created_at=dr.created_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.audit_service import log_action
|
||||||
from app.services.import_service import has_random_suffix
|
from app.services.import_service import has_random_suffix
|
||||||
from app.services.disclosure_service import (
|
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
|
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
|
# Paginated case list
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,38 @@ def review_disclosure_request(
|
||||||
return dr
|
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]:
|
def get_pending_requests(db: Session) -> list[DisclosureRequest]:
|
||||||
"""Return all pending disclosure requests."""
|
"""Return all pending disclosure requests."""
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ import { AdminInvitationsPage } from '@/pages/AdminInvitationsPage'
|
||||||
import { AdminAuditPage } from '@/pages/AdminAuditPage'
|
import { AdminAuditPage } from '@/pages/AdminAuditPage'
|
||||||
import { DisclosuresPage } from '@/pages/DisclosuresPage'
|
import { DisclosuresPage } from '@/pages/DisclosuresPage'
|
||||||
import { AccountPage } from '@/pages/AccountPage'
|
import { AccountPage } from '@/pages/AccountPage'
|
||||||
|
import { GutachtenStatistikPage } from '@/pages/GutachtenStatistikPage'
|
||||||
|
import { MyDisclosuresPage } from '@/pages/MyDisclosuresPage'
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
|
|
@ -43,7 +45,9 @@ function App() {
|
||||||
<Route path="icd" element={<IcdPage />} />
|
<Route path="icd" element={<IcdPage />} />
|
||||||
<Route path="coding" element={<ProtectedRoute requireAdmin><CodingPage /></ProtectedRoute>} />
|
<Route path="coding" element={<ProtectedRoute requireAdmin><CodingPage /></ProtectedRoute>} />
|
||||||
<Route path="reports" element={<ReportsPage />} />
|
<Route path="reports" element={<ReportsPage />} />
|
||||||
|
<Route path="gutachten-statistik" element={<GutachtenStatistikPage />} />
|
||||||
<Route path="account" element={<AccountPage />} />
|
<Route path="account" element={<AccountPage />} />
|
||||||
|
<Route path="disclosures" element={<MyDisclosuresPage />} />
|
||||||
<Route path="admin/users" element={<ProtectedRoute requireAdmin><AdminUsersPage /></ProtectedRoute>} />
|
<Route path="admin/users" element={<ProtectedRoute requireAdmin><AdminUsersPage /></ProtectedRoute>} />
|
||||||
<Route path="admin/invitations" element={<ProtectedRoute requireAdmin><AdminInvitationsPage /></ProtectedRoute>} />
|
<Route path="admin/invitations" element={<ProtectedRoute requireAdmin><AdminInvitationsPage /></ProtectedRoute>} />
|
||||||
<Route path="admin/audit" element={<ProtectedRoute requireAdmin><AdminAuditPage /></ProtectedRoute>} />
|
<Route path="admin/audit" element={<ProtectedRoute requireAdmin><AdminAuditPage /></ProtectedRoute>} />
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ const mainNavItems: NavItem[] = [
|
||||||
|
|
||||||
const accountNavItems: NavItem[] = [
|
const accountNavItems: NavItem[] = [
|
||||||
{ label: 'Kontoverwaltung', to: '/account', icon: UserCog },
|
{ label: 'Kontoverwaltung', to: '/account', icon: UserCog },
|
||||||
|
{ label: 'Meine Freigaben', to: '/disclosures', icon: ShieldCheck, mitarbeiterOnly: true },
|
||||||
]
|
]
|
||||||
|
|
||||||
const adminNavItems: NavItem[] = [
|
const adminNavItems: NavItem[] = [
|
||||||
|
|
@ -68,11 +69,15 @@ function NavItemLink({ item }: { item: NavItem }) {
|
||||||
export function Sidebar({ className }: { className?: string }) {
|
export function Sidebar({ className }: { className?: string }) {
|
||||||
const { user, isAdmin } = useAuth()
|
const { user, isAdmin } = useAuth()
|
||||||
|
|
||||||
const visibleMainItems = mainNavItems.filter((item) => {
|
const filterItems = (items: NavItem[]) =>
|
||||||
if (item.adminOnly && !isAdmin) return false
|
items.filter((item) => {
|
||||||
if (item.mitarbeiterOnly && user?.role !== 'dak_mitarbeiter') return false
|
if (item.adminOnly && !isAdmin) return false
|
||||||
return true
|
if (item.mitarbeiterOnly && user?.role !== 'dak_mitarbeiter') return false
|
||||||
})
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
const visibleMainItems = filterItems(mainNavItems)
|
||||||
|
const visibleAccountItems = filterItems(accountNavItems)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className={cn('flex h-full flex-col gap-4 p-4', className)}>
|
<aside className={cn('flex h-full flex-col gap-4 p-4', className)}>
|
||||||
|
|
@ -88,7 +93,7 @@ export function Sidebar({ className }: { className?: string }) {
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
<nav className="flex flex-col gap-1">
|
<nav className="flex flex-col gap-1">
|
||||||
{accountNavItems.map((item) => (
|
{visibleAccountItems.map((item) => (
|
||||||
<NavItemLink key={item.to} item={item} />
|
<NavItemLink key={item.to} item={item} />
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ describe('useDisclosures', () => {
|
||||||
expect(result.current.data).toHaveLength(0)
|
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
|
let capturedStatus: string | null = null
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
|
|
@ -65,7 +65,7 @@ describe('useDisclosures', () => {
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
|
||||||
expect(capturedStatus).toBe('pending')
|
expect(capturedStatus).toBeNull()
|
||||||
expect(result.current.data).toHaveLength(1)
|
expect(result.current.data).toHaveLength(1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,23 @@
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
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({
|
return useQuery({
|
||||||
queryKey: ['disclosures', status],
|
queryKey: ['disclosures', status ?? 'all'],
|
||||||
queryFn: () => getDisclosureRequests(status),
|
queryFn: () => getDisclosureRequests(status),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useMyDisclosures() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['my-disclosures'],
|
||||||
|
queryFn: getMyDisclosureRequests,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function useReviewDisclosure() {
|
export function useReviewDisclosure() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
return useMutation({
|
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 { useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend,
|
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend,
|
||||||
PieChart, Pie, Cell, ResponsiveContainer,
|
PieChart, Pie, Cell, ResponsiveContainer,
|
||||||
} from 'recharts'
|
} from 'recharts'
|
||||||
import { FileText, Clock, Code, Stethoscope } from 'lucide-react'
|
import { FileText, Clock, Code, Stethoscope } from 'lucide-react'
|
||||||
|
import { useAuth } from '@/context/AuthContext'
|
||||||
import { useDashboard } from '@/hooks/useDashboard'
|
import { useDashboard } from '@/hooks/useDashboard'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import {
|
import {
|
||||||
|
|
@ -31,6 +33,7 @@ export function DashboardPage() {
|
||||||
const currentYear = new Date().getFullYear()
|
const currentYear = new Date().getFullYear()
|
||||||
const [jahr, setJahr] = useState(currentYear)
|
const [jahr, setJahr] = useState(currentYear)
|
||||||
const { data, isLoading: loading } = useDashboard(jahr)
|
const { data, isLoading: loading } = useDashboard(jahr)
|
||||||
|
const { isAdmin } = useAuth()
|
||||||
|
|
||||||
const fallgruppenData = data
|
const fallgruppenData = data
|
||||||
? Object.entries(data.kpis.fallgruppen)
|
? Object.entries(data.kpis.fallgruppen)
|
||||||
|
|
@ -80,21 +83,25 @@ export function DashboardPage() {
|
||||||
title="Fälle gesamt"
|
title="Fälle gesamt"
|
||||||
value={data.kpis.total_cases}
|
value={data.kpis.total_cases}
|
||||||
icon={<FileText className="size-5 text-muted-foreground" />}
|
icon={<FileText className="size-5 text-muted-foreground" />}
|
||||||
|
href="/cases"
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
title="Offene ICD"
|
title="Offene ICD"
|
||||||
value={data.kpis.pending_icd}
|
value={data.kpis.pending_icd}
|
||||||
icon={<Clock className="size-5 text-muted-foreground" />}
|
icon={<Clock className="size-5 text-muted-foreground" />}
|
||||||
|
href="/icd"
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
title="Offene Codierung"
|
title="Offene Codierung"
|
||||||
value={data.kpis.pending_coding}
|
value={data.kpis.pending_coding}
|
||||||
icon={<Code className="size-5 text-muted-foreground" />}
|
icon={<Code className="size-5 text-muted-foreground" />}
|
||||||
|
href={isAdmin ? '/coding' : undefined}
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
title="Gutachten gesamt"
|
title="Gutachten gesamt"
|
||||||
value={data.kpis.total_gutachten}
|
value={data.kpis.total_gutachten}
|
||||||
icon={<Stethoscope className="size-5 text-muted-foreground" />}
|
icon={<Stethoscope className="size-5 text-muted-foreground" />}
|
||||||
|
href="/gutachten-statistik"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -205,9 +212,9 @@ export function DashboardPage() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function KpiCard({ title, value, icon }: { title: string; value: number; icon: React.ReactNode }) {
|
function KpiCard({ title, value, icon, href }: { title: string; value: number; icon: React.ReactNode; href?: string }) {
|
||||||
return (
|
const card = (
|
||||||
<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">
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
<CardTitle className="text-sm font-medium">{title}</CardTitle>
|
<CardTitle className="text-sm font-medium">{title}</CardTitle>
|
||||||
{icon}
|
{icon}
|
||||||
|
|
@ -217,4 +224,9 @@ function KpiCard({ title, value, icon }: { title: string; value: number; icon: R
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
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() {
|
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 reviewMutation = useReviewDisclosure()
|
||||||
|
const revokeMutation = useAdminRevokeDisclosure()
|
||||||
|
|
||||||
const handleReview = (id: number, status: 'approved' | 'rejected') => {
|
const handleReview = (id: number, status: 'approved' | 'rejected') => {
|
||||||
reviewMutation.mutate({ id, status })
|
reviewMutation.mutate({ id, status })
|
||||||
|
|
@ -17,60 +47,91 @@ export function DisclosuresPage() {
|
||||||
<div className="p-6 space-y-4">
|
<div className="p-6 space-y-4">
|
||||||
<h1 className="text-2xl font-bold">Freigabe-Anfragen</h1>
|
<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 ? (
|
{isLoading ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{[1, 2, 3].map((i) => (
|
{[1, 2, 3].map((i) => (
|
||||||
<Skeleton key={i} className="h-24 w-full" />
|
<Skeleton key={i} className="h-12 w-full" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : requests.length === 0 ? (
|
) : 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">
|
<div className="rounded-md border">
|
||||||
{requests.map((dr) => (
|
<Table>
|
||||||
<Card key={dr.id}>
|
<TableHeader>
|
||||||
<CardHeader className="pb-2">
|
<TableRow>
|
||||||
<div className="flex items-center justify-between">
|
<TableHead>Fall-ID</TableHead>
|
||||||
<CardTitle className="text-base">
|
<TableHead>Antragsteller</TableHead>
|
||||||
Fall {dr.fall_id || dr.case_id}
|
<TableHead>Begründung</TableHead>
|
||||||
</CardTitle>
|
<TableHead>Status</TableHead>
|
||||||
<Badge variant="outline">{dr.status}</Badge>
|
<TableHead>Datum</TableHead>
|
||||||
</div>
|
<TableHead>Gültig bis</TableHead>
|
||||||
</CardHeader>
|
<TableHead className="text-right">Aktionen</TableHead>
|
||||||
<CardContent className="space-y-2">
|
</TableRow>
|
||||||
<p className="text-sm">
|
</TableHeader>
|
||||||
<span className="text-muted-foreground">Angefragt von:</span>{' '}
|
<TableBody>
|
||||||
{dr.requester_username || `User #${dr.requester_id}`}
|
{requests.map((dr) => (
|
||||||
</p>
|
<TableRow key={dr.id}>
|
||||||
<p className="text-sm">
|
<TableCell className="font-medium">{dr.fall_id || `#${dr.case_id}`}</TableCell>
|
||||||
<span className="text-muted-foreground">Begründung:</span>{' '}
|
<TableCell>{dr.requester_username || `User #${dr.requester_id}`}</TableCell>
|
||||||
{dr.reason}
|
<TableCell className="max-w-xs truncate">{dr.reason}</TableCell>
|
||||||
</p>
|
<TableCell>{statusBadge(dr)}</TableCell>
|
||||||
<p className="text-xs text-muted-foreground">
|
<TableCell>{new Date(dr.created_at).toLocaleDateString('de-DE')}</TableCell>
|
||||||
{new Date(dr.created_at).toLocaleString('de-DE')}
|
<TableCell>
|
||||||
</p>
|
{dr.expires_at
|
||||||
<div className="flex gap-2 pt-1">
|
? new Date(dr.expires_at).toLocaleString('de-DE')
|
||||||
<Button
|
: '—'}
|
||||||
size="sm"
|
</TableCell>
|
||||||
disabled={reviewMutation.isPending}
|
<TableCell className="text-right">
|
||||||
onClick={() => handleReview(dr.id, 'approved')}
|
<div className="flex justify-end gap-1">
|
||||||
>
|
{dr.status === 'pending' && (
|
||||||
<Check className="size-4 mr-1" />
|
<>
|
||||||
Genehmigen
|
<Button
|
||||||
</Button>
|
size="sm"
|
||||||
<Button
|
variant="outline"
|
||||||
size="sm"
|
disabled={reviewMutation.isPending}
|
||||||
variant="destructive"
|
onClick={() => handleReview(dr.id, 'approved')}
|
||||||
disabled={reviewMutation.isPending}
|
>
|
||||||
onClick={() => handleReview(dr.id, 'rejected')}
|
<Check className="size-4 mr-1" />
|
||||||
>
|
Genehmigen
|
||||||
<X className="size-4 mr-1" />
|
</Button>
|
||||||
Ablehnen
|
<Button
|
||||||
</Button>
|
size="sm"
|
||||||
</div>
|
variant="destructive"
|
||||||
</CardContent>
|
disabled={reviewMutation.isPending}
|
||||||
</Card>
|
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>
|
||||||
)}
|
)}
|
||||||
</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(() => {
|
await waitFor(() => {
|
||||||
// The mock handler returns [mockDisclosureRequest] with fall_id '2026-06-onko-A123456789'
|
// 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
|
// Should show requester username
|
||||||
|
|
@ -24,8 +24,9 @@ describe('DisclosuresPage', () => {
|
||||||
// Should show the reason
|
// Should show the reason
|
||||||
expect(screen.getByText('Benötige vollständige Patientendaten für Rückruf.')).toBeInTheDocument()
|
expect(screen.getByText('Benötige vollständige Patientendaten für Rückruf.')).toBeInTheDocument()
|
||||||
|
|
||||||
// Should show the status badge
|
// Should show the status badge (translated) — multiple "Ausstehend" exist (tab + badge)
|
||||||
expect(screen.getByText('pending')).toBeInTheDocument()
|
const badges = screen.getAllByText('Ausstehend')
|
||||||
|
expect(badges.length).toBeGreaterThanOrEqual(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows approve and reject buttons', async () => {
|
it('shows approve and reject buttons', async () => {
|
||||||
|
|
@ -48,7 +49,7 @@ describe('DisclosuresPage', () => {
|
||||||
renderWithProviders(<DisclosuresPage />)
|
renderWithProviders(<DisclosuresPage />)
|
||||||
|
|
||||||
await waitFor(() => {
|
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
|
// The heading is always visible
|
||||||
expect(screen.getByText('Freigabe-Anfragen')).toBeInTheDocument()
|
expect(screen.getByText('Freigabe-Anfragen')).toBeInTheDocument()
|
||||||
|
|
||||||
// During loading, the "Keine offenen Anfragen." message should not be visible yet
|
// During loading, the empty message should not be visible yet
|
||||||
expect(screen.queryByText('Keine offenen Anfragen.')).not.toBeInTheDocument()
|
expect(screen.queryByText('Keine Anfragen gefunden.')).not.toBeInTheDocument()
|
||||||
// Nor should any disclosure item be visible
|
// Nor should any disclosure item be visible
|
||||||
expect(screen.queryByText('Genehmigen')).not.toBeInTheDocument()
|
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 })
|
const res = await api.put<DisclosureRequest>(`/admin/disclosure-requests/${requestId}`, { status })
|
||||||
return res.data
|
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