mirror of
https://github.com/complexcaresolutions/dak.c2s.git
synced 2026-03-17 16:03:41 +00:00
Fall-ID is redundant for DAK staff since KVNR is shown separately. Export now only includes Datum, KVNR, Fallgruppe, ICD, Gutachten, Status. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
869 lines
32 KiB
TypeScript
869 lines
32 KiB
TypeScript
import { useState, useEffect, useCallback, useRef } from 'react'
|
||
import type { KeyboardEvent } from 'react'
|
||
import {
|
||
Search, ChevronLeft, ChevronRight, Save, CheckCircle, XCircle, Pencil, X, Info, Loader2, Download, Star, Trash2,
|
||
} from 'lucide-react'
|
||
import type { Case } from '@/types'
|
||
import { useAuth } from '@/context/AuthContext'
|
||
import { useCases, usePendingIcdCases, useIcdUpdate, type CaseFilters } from '@/hooks/useCases'
|
||
import api from '@/services/api'
|
||
import { useFilterPresets, useCreateFilterPreset, useDeleteFilterPreset } from '@/hooks/useFilterPresets'
|
||
import { Input } from '@/components/ui/input'
|
||
import { Button } from '@/components/ui/button'
|
||
import { Badge } from '@/components/ui/badge'
|
||
import {
|
||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||
} from '@/components/ui/select'
|
||
import {
|
||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||
} from '@/components/ui/table'
|
||
import {
|
||
Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription,
|
||
} from '@/components/ui/sheet'
|
||
import { Skeleton } from '@/components/ui/skeleton'
|
||
import { Switch } from '@/components/ui/switch'
|
||
import { Label } from '@/components/ui/label'
|
||
import {
|
||
Popover, PopoverContent, PopoverTrigger,
|
||
} from '@/components/ui/popover'
|
||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||
import {
|
||
Tooltip, TooltipContent, TooltipTrigger,
|
||
} from '@/components/ui/tooltip'
|
||
import { CASE_SECTIONS } from './cases/fieldConfig'
|
||
import { useInlineEdit } from './cases/useInlineEdit'
|
||
import { EditableField } from './cases/EditableField'
|
||
import { requestDisclosure } from '@/services/disclosureService'
|
||
|
||
const FALLGRUPPEN_LABELS: Record<string, string> = {
|
||
onko: 'Onkologie',
|
||
kardio: 'Kardiologie',
|
||
intensiv: 'Intensivmedizin',
|
||
galle: 'Gallenblase',
|
||
sd: 'Schilddrüse',
|
||
}
|
||
|
||
const FALLGRUPPEN_OPTIONS = [
|
||
{ value: '__all__', label: 'Alle Fallgruppen' },
|
||
{ value: 'onko', label: 'Onkologie' },
|
||
{ value: 'kardio', label: 'Kardiologie' },
|
||
{ value: 'intensiv', label: 'Intensivmedizin' },
|
||
{ value: 'galle', label: 'Gallenblase' },
|
||
{ value: 'sd', label: 'Schilddrüse' },
|
||
]
|
||
|
||
const ICD_OPTIONS = [
|
||
{ value: '__all__', label: 'Alle' },
|
||
{ value: 'true', label: 'Mit ICD' },
|
||
{ value: 'false', label: 'Ohne ICD' },
|
||
]
|
||
|
||
interface CasesPageProps {
|
||
/** When true, fetches from pending-icd endpoint instead */
|
||
pendingIcdOnly?: boolean
|
||
}
|
||
|
||
export function CasesPage({ pendingIcdOnly = false }: CasesPageProps) {
|
||
const { isAdmin } = useAuth()
|
||
const currentYear = new Date().getFullYear()
|
||
const [search, setSearch] = useState('')
|
||
const [debouncedSearch, setDebouncedSearch] = useState('')
|
||
const [jahr, setJahr] = useState<string>('__all__')
|
||
const [fallgruppe, setFallgruppe] = useState<string>('__all__')
|
||
const [hasIcd, setHasIcd] = useState<string>('__all__')
|
||
const [page, setPage] = useState(1)
|
||
const [perPage] = useState(50)
|
||
const [selectedCase, setSelectedCase] = useState<Case | null>(null)
|
||
const [sheetOpen, setSheetOpen] = useState(false)
|
||
const [batchMode, setBatchMode] = useState(false)
|
||
const [exporting, setExporting] = useState(false)
|
||
const [presetOpen, setPresetOpen] = useState(false)
|
||
const [presetName, setPresetName] = useState('')
|
||
const [showSavePreset, setShowSavePreset] = useState(false)
|
||
const { data: presets } = useFilterPresets()
|
||
const createPreset = useCreateFilterPreset()
|
||
const deletePreset = useDeleteFilterPreset()
|
||
|
||
// Debounce search
|
||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||
const handleSearchChange = useCallback((val: string) => {
|
||
setSearch(val)
|
||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||
debounceRef.current = setTimeout(() => {
|
||
setDebouncedSearch(val)
|
||
setPage(1)
|
||
}, 300)
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||
}
|
||
}, [])
|
||
|
||
// Fetch cases via TanStack Query
|
||
const filters: CaseFilters = {
|
||
page, per_page: perPage,
|
||
...(debouncedSearch ? { search: debouncedSearch } : {}),
|
||
...(jahr !== '__all__' ? { jahr: Number(jahr) } : {}),
|
||
...(fallgruppe !== '__all__' ? { fallgruppe } : {}),
|
||
...(hasIcd !== '__all__' ? { has_icd: hasIcd } : {}),
|
||
}
|
||
|
||
const casesQuery = useCases(filters, { enabled: !pendingIcdOnly })
|
||
const pendingQuery = usePendingIcdCases(page, perPage, { enabled: pendingIcdOnly })
|
||
const activeQuery = pendingIcdOnly ? pendingQuery : casesQuery
|
||
const data = activeQuery.data ?? null
|
||
const loading = activeQuery.isLoading
|
||
|
||
const totalPages = data ? Math.ceil(data.total / perPage) : 0
|
||
const years = Array.from({ length: 5 }, (_, i) => currentYear - i)
|
||
|
||
const openDetail = (c: Case) => {
|
||
setSelectedCase(c)
|
||
setSheetOpen(true)
|
||
}
|
||
|
||
const exportToExcel = async () => {
|
||
setExporting(true)
|
||
try {
|
||
const params: Record<string, string | number> = {}
|
||
if (jahr !== '__all__') params.jahr = Number(jahr)
|
||
if (fallgruppe !== '__all__') params.fallgruppe = fallgruppe
|
||
if (hasIcd !== '__all__') params.has_icd = hasIcd
|
||
if (debouncedSearch) params.search = debouncedSearch
|
||
|
||
const res = await api.get('/cases/export', { params, responseType: 'blob' })
|
||
const blob = new Blob([res.data], {
|
||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||
})
|
||
const url = window.URL.createObjectURL(blob)
|
||
const a = document.createElement('a')
|
||
a.href = url
|
||
a.download = `Fallliste_${new Date().toISOString().slice(0, 10)}.xlsx`
|
||
document.body.appendChild(a)
|
||
a.click()
|
||
document.body.removeChild(a)
|
||
window.URL.revokeObjectURL(url)
|
||
} catch {
|
||
// silently fail — user sees no file download
|
||
} finally {
|
||
setExporting(false)
|
||
}
|
||
}
|
||
|
||
const applyPreset = (filters: { jahr?: number; fallgruppe?: string; has_icd?: string; search?: string }) => {
|
||
setJahr(filters.jahr != null ? String(filters.jahr) : '__all__')
|
||
setFallgruppe(filters.fallgruppe || '__all__')
|
||
setHasIcd(filters.has_icd || '__all__')
|
||
setSearch(filters.search || '')
|
||
setDebouncedSearch(filters.search || '')
|
||
setPage(1)
|
||
setPresetOpen(false)
|
||
}
|
||
|
||
const savePreset = async () => {
|
||
if (!presetName.trim()) return
|
||
const filterValues: Record<string, string | number> = {}
|
||
if (jahr !== '__all__') filterValues.jahr = Number(jahr)
|
||
if (fallgruppe !== '__all__') filterValues.fallgruppe = fallgruppe
|
||
if (hasIcd !== '__all__') filterValues.has_icd = hasIcd
|
||
if (debouncedSearch) filterValues.search = debouncedSearch
|
||
await createPreset.mutateAsync({ name: presetName.trim(), filters: filterValues })
|
||
setPresetName('')
|
||
setShowSavePreset(false)
|
||
}
|
||
|
||
return (
|
||
<div className="p-6 space-y-4">
|
||
<div className="flex items-start justify-between">
|
||
<div>
|
||
<h1 className="text-2xl font-bold">
|
||
{pendingIcdOnly ? 'ICD-Eingabe' : 'Fälle'}
|
||
</h1>
|
||
{pendingIcdOnly && (
|
||
<p className="text-sm text-muted-foreground mt-1">
|
||
{batchMode
|
||
? 'ICD-Codes direkt in der Tabelle eingeben. Enter zum Speichern, Tab zum nächsten Feld.'
|
||
: 'Fälle, die noch einen ICD-10-Diagnosecode benötigen. Klicken Sie auf einen Fall, um den Code einzugeben.'}
|
||
</p>
|
||
)}
|
||
</div>
|
||
{pendingIcdOnly && (
|
||
<div className="flex items-center gap-2">
|
||
<Switch id="batch-mode" checked={batchMode} onCheckedChange={setBatchMode} />
|
||
<Label htmlFor="batch-mode" className="text-sm cursor-pointer">Batch-Modus</Label>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Filter bar */}
|
||
{!pendingIcdOnly && (
|
||
<div className="flex flex-wrap items-center gap-3">
|
||
<div className="relative flex-1 min-w-[200px] max-w-sm">
|
||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
||
<Input
|
||
placeholder={isAdmin ? "Suche nach Name, Fall-ID, KVNR..." : "Suche nach Fall-ID, KVNR..."}
|
||
value={search}
|
||
onChange={(e) => handleSearchChange(e.target.value)}
|
||
className="pl-9"
|
||
/>
|
||
</div>
|
||
<Select value={jahr} onValueChange={(v) => { setJahr(v); setPage(1) }}>
|
||
<SelectTrigger className="w-[120px]">
|
||
<SelectValue placeholder="Jahr" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="__all__">Alle Jahre</SelectItem>
|
||
{years.map((y) => (
|
||
<SelectItem key={y} value={String(y)}>{y}</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<Select value={fallgruppe} onValueChange={(v) => { setFallgruppe(v); setPage(1) }}>
|
||
<SelectTrigger className="w-[170px]">
|
||
<SelectValue placeholder="Fallgruppe" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{FALLGRUPPEN_OPTIONS.map((o) => (
|
||
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<Select value={hasIcd} onValueChange={(v) => { setHasIcd(v); setPage(1) }}>
|
||
<SelectTrigger className="w-[130px]">
|
||
<SelectValue placeholder="ICD" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{ICD_OPTIONS.map((o) => (
|
||
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button variant="outline" size="icon" onClick={exportToExcel} disabled={exporting}>
|
||
{exporting ? <Loader2 className="size-4 animate-spin" /> : <Download className="size-4" />}
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>Gefilterte Fallliste als Excel exportieren</TooltipContent>
|
||
</Tooltip>
|
||
<Popover open={presetOpen} onOpenChange={setPresetOpen}>
|
||
<PopoverTrigger asChild>
|
||
<Button variant="outline" size="sm" className="gap-1.5">
|
||
<Star className="size-4" />
|
||
Filter
|
||
{presets && presets.length > 0 && (
|
||
<Badge variant="secondary" className="ml-1 px-1.5 py-0 text-xs">{presets.length}</Badge>
|
||
)}
|
||
</Button>
|
||
</PopoverTrigger>
|
||
<PopoverContent className="w-72 p-2" align="end">
|
||
<div className="space-y-1">
|
||
{presets && presets.length > 0 ? (
|
||
presets.map((p) => (
|
||
<div key={p.id} className="flex items-center gap-1 group">
|
||
<button
|
||
className="flex-1 text-left text-sm px-2 py-1.5 rounded hover:bg-accent transition-colors truncate"
|
||
onClick={() => applyPreset(p.filters)}
|
||
>
|
||
{p.name}
|
||
</button>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="size-7 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
|
||
onClick={() => deletePreset.mutate(p.id)}
|
||
>
|
||
<Trash2 className="size-3.5 text-muted-foreground" />
|
||
</Button>
|
||
</div>
|
||
))
|
||
) : (
|
||
<p className="text-sm text-muted-foreground px-2 py-1.5">Keine gespeicherten Filter.</p>
|
||
)}
|
||
<div className="border-t pt-1 mt-1">
|
||
{showSavePreset ? (
|
||
<div className="flex items-center gap-1.5 px-1">
|
||
<Input
|
||
value={presetName}
|
||
onChange={(e) => setPresetName(e.target.value)}
|
||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); savePreset() } if (e.key === 'Escape') setShowSavePreset(false) }}
|
||
placeholder="z.B. Onko ohne ICD"
|
||
className="h-8 text-sm"
|
||
autoFocus
|
||
/>
|
||
<Button size="sm" className="h-8 px-2" onClick={savePreset} disabled={!presetName.trim() || createPreset.isPending}>
|
||
<Save className="size-3.5" />
|
||
</Button>
|
||
</div>
|
||
) : (
|
||
<button
|
||
className="w-full text-left text-sm px-2 py-1.5 rounded hover:bg-accent transition-colors text-muted-foreground"
|
||
onClick={() => setShowSavePreset(true)}
|
||
>
|
||
+ Aktuellen Filter speichern
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</PopoverContent>
|
||
</Popover>
|
||
</div>
|
||
)}
|
||
|
||
{/* Table */}
|
||
{loading ? (
|
||
<div className="space-y-2">
|
||
{Array.from({ length: 8 }).map((_, i) => (
|
||
<Skeleton key={i} className="h-10 w-full" />
|
||
))}
|
||
</div>
|
||
) : data && data.items.length > 0 ? (
|
||
<>
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
{isAdmin && (
|
||
<TableHead>
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<span className="inline-flex items-center gap-1 cursor-help border-b border-dashed border-muted-foreground/40">
|
||
Fall-ID
|
||
<Info className="size-3 text-muted-foreground" />
|
||
</span>
|
||
</TooltipTrigger>
|
||
<TooltipContent>Eindeutige Kennung: Jahr-KW-Fallgruppe-KVNR</TooltipContent>
|
||
</Tooltip>
|
||
</TableHead>
|
||
)}
|
||
<TableHead>Datum</TableHead>
|
||
{isAdmin && <TableHead>Nachname</TableHead>}
|
||
{isAdmin && <TableHead>Vorname</TableHead>}
|
||
<TableHead>
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<span className="inline-flex items-center gap-1 cursor-help border-b border-dashed border-muted-foreground/40">
|
||
KVNR
|
||
<Info className="size-3 text-muted-foreground" />
|
||
</span>
|
||
</TooltipTrigger>
|
||
<TooltipContent>Krankenversichertennummer des Versicherten</TooltipContent>
|
||
</Tooltip>
|
||
</TableHead>
|
||
<TableHead>Fallgruppe</TableHead>
|
||
<TableHead>
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<span className="inline-flex items-center gap-1 cursor-help border-b border-dashed border-muted-foreground/40">
|
||
ICD
|
||
<Info className="size-3 text-muted-foreground" />
|
||
</span>
|
||
</TooltipTrigger>
|
||
<TooltipContent>Internationale Klassifikation der Krankheiten (Diagnosecode)</TooltipContent>
|
||
</Tooltip>
|
||
</TableHead>
|
||
<TableHead>
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<span className="inline-flex items-center gap-1 cursor-help border-b border-dashed border-muted-foreground/40">
|
||
Gutachten
|
||
<Info className="size-3 text-muted-foreground" />
|
||
</span>
|
||
</TooltipTrigger>
|
||
<TooltipContent>Schriftliche medizinische Stellungnahme vorhanden</TooltipContent>
|
||
</Tooltip>
|
||
</TableHead>
|
||
<TableHead>
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<span className="inline-flex items-center gap-1 cursor-help border-b border-dashed border-muted-foreground/40">
|
||
Status
|
||
<Info className="size-3 text-muted-foreground" />
|
||
</span>
|
||
</TooltipTrigger>
|
||
<TooltipContent>Aktueller Bearbeitungsstatus des Falls</TooltipContent>
|
||
</Tooltip>
|
||
</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{data.items.map((c) => (
|
||
<TableRow
|
||
key={c.id}
|
||
className={pendingIcdOnly && batchMode ? '' : 'cursor-pointer'}
|
||
onClick={() => { if (!(pendingIcdOnly && batchMode)) openDetail(c) }}
|
||
>
|
||
{isAdmin && (
|
||
<TableCell className="font-mono text-sm">
|
||
{c.fall_id || '-'}
|
||
</TableCell>
|
||
)}
|
||
<TableCell>{formatDate(c.datum)}</TableCell>
|
||
{isAdmin && <TableCell className="font-medium">{c.nachname}</TableCell>}
|
||
{isAdmin && <TableCell>{c.vorname || '-'}</TableCell>}
|
||
<TableCell className="font-mono text-sm">
|
||
{c.kvnr || '-'}
|
||
</TableCell>
|
||
<TableCell>
|
||
<Badge variant="outline">
|
||
{FALLGRUPPEN_LABELS[c.fallgruppe] || c.fallgruppe}
|
||
</Badge>
|
||
</TableCell>
|
||
<TableCell>
|
||
{pendingIcdOnly && batchMode ? (
|
||
<InlineIcdInput caseId={c.id} initialValue={c.icd || ''} />
|
||
) : c.icd ? (
|
||
<span className="font-mono text-sm">{c.icd}</span>
|
||
) : (
|
||
<span className="text-muted-foreground">-</span>
|
||
)}
|
||
</TableCell>
|
||
<TableCell>
|
||
{c.gutachten ? (
|
||
<CheckCircle className="size-4 text-green-600" />
|
||
) : (
|
||
<XCircle className="size-4 text-muted-foreground" />
|
||
)}
|
||
</TableCell>
|
||
<TableCell>
|
||
<StatusBadges c={c} />
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
|
||
{/* Pagination */}
|
||
<div className="flex items-center justify-between">
|
||
<p className="text-sm text-muted-foreground">
|
||
{data.total} Fälle insgesamt
|
||
</p>
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
disabled={page <= 1}
|
||
onClick={() => setPage((p) => p - 1)}
|
||
>
|
||
<ChevronLeft className="size-4" />
|
||
</Button>
|
||
<span className="text-sm">
|
||
Seite {page} von {totalPages || 1}
|
||
</span>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
disabled={page >= totalPages}
|
||
onClick={() => setPage((p) => p + 1)}
|
||
>
|
||
<ChevronRight className="size-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<p className="text-muted-foreground py-8 text-center">
|
||
Keine Fälle gefunden.
|
||
</p>
|
||
)}
|
||
|
||
{/* Detail Sheet */}
|
||
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
||
<SheetContent className="w-full sm:max-w-lg overflow-y-auto">
|
||
{selectedCase && (
|
||
<CaseDetail caseData={selectedCase} />
|
||
)}
|
||
</SheetContent>
|
||
</Sheet>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function InlineIcdInput({ caseId, initialValue }: { caseId: number; initialValue: string }) {
|
||
const [value, setValue] = useState(initialValue)
|
||
const [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
|
||
const [errorMsg, setErrorMsg] = useState('')
|
||
const icdMutation = useIcdUpdate()
|
||
const inputRef = useRef<HTMLInputElement>(null)
|
||
|
||
const save = async () => {
|
||
const trimmed = value.trim()
|
||
if (!trimmed || trimmed === initialValue) return
|
||
setStatus('saving')
|
||
setErrorMsg('')
|
||
try {
|
||
await icdMutation.mutateAsync({ id: caseId, icd: trimmed })
|
||
setStatus('saved')
|
||
} catch (err: any) {
|
||
const detail = err?.response?.data?.detail
|
||
setErrorMsg(typeof detail === 'string' ? detail : 'Ungültiger ICD-Code')
|
||
setStatus('error')
|
||
}
|
||
}
|
||
|
||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault()
|
||
save()
|
||
}
|
||
if (e.key === 'Escape') {
|
||
setValue(initialValue)
|
||
setStatus('idle')
|
||
setErrorMsg('')
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="flex items-center gap-1.5" onClick={(e) => e.stopPropagation()}>
|
||
<Input
|
||
ref={inputRef}
|
||
value={value}
|
||
onChange={(e) => { setValue(e.target.value); if (status === 'saved' || status === 'error') setStatus('idle') }}
|
||
onKeyDown={handleKeyDown}
|
||
onBlur={save}
|
||
placeholder="z.B. C50.9"
|
||
className={`h-8 w-32 font-mono text-sm ${
|
||
status === 'saved' ? 'border-green-500 focus-visible:ring-green-500' :
|
||
status === 'error' ? 'border-red-500 focus-visible:ring-red-500' : ''
|
||
}`}
|
||
disabled={status === 'saving'}
|
||
/>
|
||
{status === 'saving' && <Loader2 className="size-4 text-muted-foreground animate-spin" />}
|
||
{status === 'saved' && <CheckCircle className="size-4 text-green-600" />}
|
||
{status === 'error' && (
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<XCircle className="size-4 text-red-500 cursor-help" />
|
||
</TooltipTrigger>
|
||
<TooltipContent>{errorMsg}</TooltipContent>
|
||
</Tooltip>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function StatusBadges({ c }: { c: Case }) {
|
||
return (
|
||
<div className="flex gap-1">
|
||
{c.unterlagen && (
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Badge variant="secondary" className="text-xs cursor-help">Unterlagen</Badge>
|
||
</TooltipTrigger>
|
||
<TooltipContent>Patientenunterlagen wurden eingereicht</TooltipContent>
|
||
</Tooltip>
|
||
)}
|
||
{c.gutachten && (
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Badge variant="secondary" className="text-xs cursor-help">Gutachten</Badge>
|
||
</TooltipTrigger>
|
||
<TooltipContent>Medizinisches Gutachten wurde erstellt</TooltipContent>
|
||
</Tooltip>
|
||
)}
|
||
{c.abgerechnet && (
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Badge className="text-xs cursor-help">Abgerechnet</Badge>
|
||
</TooltipTrigger>
|
||
<TooltipContent>Fall wurde abgerechnet</TooltipContent>
|
||
</Tooltip>
|
||
)}
|
||
{c.ablehnung && (
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Badge variant="destructive" className="text-xs cursor-help">Abgelehnt</Badge>
|
||
</TooltipTrigger>
|
||
<TooltipContent>Leistung wurde abgelehnt</TooltipContent>
|
||
</Tooltip>
|
||
)}
|
||
{c.abbruch && (
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Badge variant="destructive" className="text-xs cursor-help">Abbruch</Badge>
|
||
</TooltipTrigger>
|
||
<TooltipContent>Verfahren wurde abgebrochen</TooltipContent>
|
||
</Tooltip>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function CaseDetail({ caseData }: { caseData: Case }) {
|
||
const { isAdmin } = useAuth()
|
||
const {
|
||
editing, formValues, saving, error, success,
|
||
startEditing, cancelEditing, saveAll, setFieldValue, canEditField, isDirty,
|
||
} = useInlineEdit(caseData)
|
||
|
||
// ICD has its own endpoint and state
|
||
const icdMutation = useIcdUpdate()
|
||
const [icdValue, setIcdValue] = useState(caseData.icd || '')
|
||
const [icdError, setIcdError] = useState('')
|
||
const [icdSuccess, setIcdSuccess] = useState(false)
|
||
|
||
useEffect(() => {
|
||
setIcdValue(caseData.icd || '')
|
||
setIcdError('')
|
||
setIcdSuccess(false)
|
||
}, [caseData.id, caseData.icd])
|
||
|
||
const saveIcd = async () => {
|
||
if (!icdValue.trim()) return
|
||
setIcdError('')
|
||
setIcdSuccess(false)
|
||
try {
|
||
await icdMutation.mutateAsync({ id: caseData.id, icd: icdValue.trim() })
|
||
setIcdSuccess(true)
|
||
} catch {
|
||
setIcdError('Fehler beim Speichern des ICD-Codes.')
|
||
}
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<SheetHeader>
|
||
<SheetTitle>
|
||
Fall {caseData.fall_id || `#${caseData.id}`}
|
||
</SheetTitle>
|
||
<SheetDescription>
|
||
{FALLGRUPPEN_LABELS[caseData.fallgruppe] || caseData.fallgruppe} — KW {caseData.kw}/{caseData.jahr}
|
||
</SheetDescription>
|
||
</SheetHeader>
|
||
|
||
<div className="px-4 space-y-4">
|
||
{/* Edit toolbar */}
|
||
<div className="flex items-center gap-2">
|
||
{!editing ? (
|
||
<Button size="sm" variant="outline" onClick={startEditing}>
|
||
<Pencil className="size-4 mr-1" />
|
||
Bearbeiten
|
||
</Button>
|
||
) : (
|
||
<>
|
||
<Button size="sm" onClick={saveAll} disabled={saving || !isDirty}>
|
||
<Save className="size-4 mr-1" />
|
||
{saving ? 'Speichern...' : 'Speichern'}
|
||
</Button>
|
||
<Button size="sm" variant="outline" onClick={cancelEditing} disabled={saving}>
|
||
<X className="size-4 mr-1" />
|
||
Abbrechen
|
||
</Button>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{error && (
|
||
<Alert variant="destructive">
|
||
<AlertDescription>{error}</AlertDescription>
|
||
</Alert>
|
||
)}
|
||
{success && (
|
||
<p className="text-sm text-green-600">Änderungen gespeichert.</p>
|
||
)}
|
||
|
||
{/* Status badges (admin only) */}
|
||
{isAdmin && (
|
||
<div className="flex flex-wrap gap-2">
|
||
<StatusBadges c={caseData} />
|
||
</div>
|
||
)}
|
||
|
||
{/* Disclosure status / request */}
|
||
{!isAdmin && caseData.disclosure_granted && caseData.disclosure_expires_at && (
|
||
<Alert>
|
||
<AlertDescription>
|
||
Personendaten sichtbar bis {formatDateTime(caseData.disclosure_expires_at)}
|
||
</AlertDescription>
|
||
</Alert>
|
||
)}
|
||
{!isAdmin && !caseData.disclosure_granted && (
|
||
<DisclosureRequestButton caseId={caseData.id} />
|
||
)}
|
||
|
||
{/* Static metadata (always read-only) */}
|
||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||
<ReadOnlyField label="Fall-ID" value={caseData.fall_id} />
|
||
<ReadOnlyField label="CRM-Ticket" value={caseData.crm_ticket_id} />
|
||
<ReadOnlyField label="Datum" value={formatDate(caseData.datum)} />
|
||
<ReadOnlyField label="KW / Jahr" value={`${caseData.kw} / ${caseData.jahr}`} />
|
||
</div>
|
||
|
||
{/* Editable sections from config */}
|
||
{CASE_SECTIONS.map((section) => {
|
||
if (section.visibleTo === 'admin' && !isAdmin) return null
|
||
const visibleFields = section.fields.filter((field) => {
|
||
if (field.visibleTo === 'admin' && !isAdmin && !caseData.disclosure_granted) return false
|
||
return true
|
||
})
|
||
if (visibleFields.length === 0) return null
|
||
return (
|
||
<div key={section.title} className="border-t pt-3 space-y-3">
|
||
<h3 className="text-sm font-semibold">{section.title}</h3>
|
||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||
{visibleFields.map((field) => (
|
||
<div key={field.key} className={field.colSpan === 2 ? 'col-span-2' : ''}>
|
||
<EditableField
|
||
field={field}
|
||
value={editing ? formValues[field.key] : caseData[field.key]}
|
||
editable={canEditField(field, isAdmin)}
|
||
onChange={setFieldValue}
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
|
||
{/* ICD section — own endpoint with validation */}
|
||
<div className="border-t pt-3 space-y-2">
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<label className="text-sm font-semibold inline-flex items-center gap-1 cursor-help">
|
||
ICD-Code
|
||
<Info className="size-3 text-muted-foreground" />
|
||
</label>
|
||
</TooltipTrigger>
|
||
<TooltipContent>ICD-10-GM Diagnoseschlüssel, z.B. C50.9 für bösartige Neubildung der Brustdrüse</TooltipContent>
|
||
</Tooltip>
|
||
<div className="flex gap-2">
|
||
<Input
|
||
value={icdValue}
|
||
onChange={(e) => { setIcdValue(e.target.value); setIcdSuccess(false) }}
|
||
placeholder="z.B. C50.9"
|
||
className="flex-1"
|
||
/>
|
||
<Button
|
||
size="sm"
|
||
onClick={saveIcd}
|
||
disabled={icdMutation.isPending || !icdValue.trim()}
|
||
>
|
||
<Save className="size-4 mr-1" />
|
||
{icdMutation.isPending ? 'Speichern...' : 'Speichern'}
|
||
</Button>
|
||
</div>
|
||
{icdError && (
|
||
<Alert variant="destructive">
|
||
<AlertDescription>{icdError}</AlertDescription>
|
||
</Alert>
|
||
)}
|
||
{icdSuccess && (
|
||
<p className="text-sm text-green-600">ICD-Code gespeichert.</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* Coding info (read-only, admin only) */}
|
||
{isAdmin && (caseData.gutachten_typ || caseData.therapieaenderung) && (
|
||
<div className="border-t pt-3 space-y-2">
|
||
<h3 className="text-sm font-semibold">Coding</h3>
|
||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||
<ReadOnlyField label="Gutachten-Typ" value={caseData.gutachten_typ} />
|
||
<ReadOnlyField label="Therapieänderung" value={caseData.therapieaenderung} />
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Metadata footer */}
|
||
<div className="border-t pt-3 text-xs text-muted-foreground space-y-1">
|
||
<p>Importiert: {formatDateTime(caseData.imported_at)}</p>
|
||
<p>Aktualisiert: {formatDateTime(caseData.updated_at)}</p>
|
||
{caseData.import_source && <p>Quelle: {caseData.import_source}</p>}
|
||
</div>
|
||
</div>
|
||
</>
|
||
)
|
||
}
|
||
|
||
function DisclosureRequestButton({ caseId }: { caseId: number }) {
|
||
const [open, setOpen] = useState(false)
|
||
const [reason, setReason] = useState('')
|
||
const [loading, setLoading] = useState(false)
|
||
const [error, setError] = useState('')
|
||
const [success, setSuccess] = useState(false)
|
||
|
||
const submit = async () => {
|
||
if (!reason.trim()) return
|
||
setLoading(true)
|
||
setError('')
|
||
try {
|
||
await requestDisclosure(caseId, reason.trim())
|
||
setSuccess(true)
|
||
setOpen(false)
|
||
} catch (err: any) {
|
||
const detail = err.response?.data?.detail
|
||
setError(typeof detail === 'string' ? detail : 'Fehler beim Senden der Anfrage')
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
if (success) {
|
||
return <p className="text-sm text-muted-foreground">Freigabe-Anfrage gesendet.</p>
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button size="sm" variant="outline" onClick={() => setOpen(true)}>
|
||
Personendaten anfordern
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>Zeitlich begrenzte Freigabe (24h) der Personendaten beantragen</TooltipContent>
|
||
</Tooltip>
|
||
{open && (
|
||
<div className="space-y-2 border rounded-lg p-3">
|
||
<p className="text-sm text-muted-foreground">
|
||
Begründung für die Einsicht in Personendaten:
|
||
</p>
|
||
<Input
|
||
value={reason}
|
||
onChange={(e) => setReason(e.target.value)}
|
||
placeholder="z.B. KVNR-Fehler, Identifikation nötig"
|
||
/>
|
||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||
<div className="flex gap-2">
|
||
<Button size="sm" onClick={submit} disabled={loading || !reason.trim()}>
|
||
{loading ? 'Senden...' : 'Anfrage senden'}
|
||
</Button>
|
||
<Button size="sm" variant="outline" onClick={() => { setOpen(false); setError('') }}>
|
||
Abbrechen
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
)
|
||
}
|
||
|
||
function ReadOnlyField({ label, value }: { label: string; value: string | null | undefined }) {
|
||
return (
|
||
<div>
|
||
<span className="text-muted-foreground text-sm">{label}</span>
|
||
<p className="font-medium text-sm">{value || '–'}</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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
|
||
}
|
||
}
|