refactor: migrate CasesPage and useInlineEdit to TanStack Query

Replace manual useEffect data fetching with useCases/usePendingIcdCases
query hooks. Replace direct API calls in useInlineEdit with useCaseUpdate
and useKvnrUpdate mutations. Use useIcdUpdate for ICD saving. Remove
onCaseSaved callback prop drilling — mutations auto-invalidate the cache.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
CCS Admin 2026-02-26 18:36:08 +00:00
parent 150be9183c
commit 657a1abcaf
3 changed files with 105 additions and 68 deletions

View file

@ -0,0 +1,70 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import api from '@/services/api'
import type { Case, CaseListResponse } from '@/types'
export interface CaseFilters {
page: number
per_page: number
search?: string
jahr?: number
fallgruppe?: string
has_icd?: string
}
export function useCases(filters: CaseFilters, options?: { enabled?: boolean }) {
return useQuery({
queryKey: ['cases', filters],
queryFn: () => {
const params: Record<string, string | number> = {
page: filters.page,
per_page: filters.per_page,
}
if (filters.search) params.search = filters.search
if (filters.jahr) params.jahr = filters.jahr
if (filters.fallgruppe) params.fallgruppe = filters.fallgruppe
if (filters.has_icd) params.has_icd = filters.has_icd
return api.get<CaseListResponse>('/cases/', { params }).then(r => r.data)
},
enabled: options?.enabled ?? true,
})
}
export function usePendingIcdCases(page: number, perPage: number, options?: { enabled?: boolean }) {
return useQuery({
queryKey: ['cases', 'pending-icd', { page, per_page: perPage }],
queryFn: () => api.get<CaseListResponse>('/cases/pending-icd', {
params: { page, per_page: perPage },
}).then(r => r.data),
enabled: options?.enabled ?? true,
})
}
export function useCaseUpdate() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ id, data }: { id: number; data: Record<string, unknown> }) =>
api.put<Case>(`/cases/${id}`, data).then(r => r.data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['cases'] }),
})
}
export function useKvnrUpdate() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ id, kvnr }: { id: number; kvnr: string | null }) =>
api.put<Case>(`/cases/${id}/kvnr`, { kvnr }).then(r => r.data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['cases'] }),
})
}
export function useIcdUpdate() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ id, icd }: { id: number; icd: string }) =>
api.put<Case>(`/cases/${id}/icd`, { icd }).then(r => r.data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['cases'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
},
})
}

View file

@ -2,9 +2,9 @@ import { useState, useEffect, useCallback, useRef } from 'react'
import {
Search, ChevronLeft, ChevronRight, Save, CheckCircle, XCircle, Pencil, X,
} from 'lucide-react'
import api from '@/services/api'
import type { Case, CaseListResponse } from '@/types'
import type { Case } from '@/types'
import { useAuth } from '@/context/AuthContext'
import { useCases, usePendingIcdCases, useIcdUpdate, type CaseFilters } from '@/hooks/useCases'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
@ -62,8 +62,6 @@ export function CasesPage({ pendingIcdOnly = false }: CasesPageProps) {
const [hasIcd, setHasIcd] = useState<string>('__all__')
const [page, setPage] = useState(1)
const [perPage] = useState(50)
const [data, setData] = useState<CaseListResponse | null>(null)
const [loading, setLoading] = useState(true)
const [selectedCase, setSelectedCase] = useState<Case | null>(null)
const [sheetOpen, setSheetOpen] = useState(false)
@ -84,30 +82,20 @@ export function CasesPage({ pendingIcdOnly = false }: CasesPageProps) {
}
}, [])
// Fetch cases
useEffect(() => {
setLoading(true)
// 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 } : {}),
}
if (pendingIcdOnly) {
api.get<CaseListResponse>('/cases/pending-icd', {
params: { page, per_page: perPage },
})
.then((res) => setData(res.data))
.catch(() => setData(null))
.finally(() => setLoading(false))
} else {
const params: Record<string, string | number> = { page, per_page: perPage }
if (debouncedSearch) params.search = debouncedSearch
if (jahr !== '__all__') params.jahr = Number(jahr)
if (fallgruppe !== '__all__') params.fallgruppe = fallgruppe
if (hasIcd !== '__all__') params.has_icd = hasIcd
api.get<CaseListResponse>('/cases/', { params })
.then((res) => setData(res.data))
.catch(() => setData(null))
.finally(() => setLoading(false))
}
}, [page, perPage, debouncedSearch, jahr, fallgruppe, hasIcd, pendingIcdOnly])
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)
@ -117,19 +105,6 @@ export function CasesPage({ pendingIcdOnly = false }: CasesPageProps) {
setSheetOpen(true)
}
const handleCaseSaved = (updated: Case) => {
setSelectedCase(updated)
// Refresh list
setData((prev) =>
prev
? {
...prev,
items: prev.items.map((c) => (c.id === updated.id ? updated : c)),
}
: prev,
)
}
return (
<div className="p-6 space-y-4">
<h1 className="text-2xl font-bold">
@ -286,10 +261,7 @@ export function CasesPage({ pendingIcdOnly = false }: CasesPageProps) {
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<SheetContent className="w-full sm:max-w-lg overflow-y-auto">
{selectedCase && (
<CaseDetail
caseData={selectedCase}
onCaseSaved={handleCaseSaved}
/>
<CaseDetail caseData={selectedCase} />
)}
</SheetContent>
</Sheet>
@ -309,22 +281,16 @@ function StatusBadges({ c }: { c: Case }) {
)
}
function CaseDetail({
caseData,
onCaseSaved,
}: {
caseData: Case
onCaseSaved: (updated: Case) => void
}) {
function CaseDetail({ caseData }: { caseData: Case }) {
const { isAdmin } = useAuth()
const {
editing, formValues, saving, error, success,
startEditing, cancelEditing, saveAll, setFieldValue, canEditField, isDirty,
} = useInlineEdit(caseData, onCaseSaved)
} = useInlineEdit(caseData)
// ICD has its own endpoint and state
const icdMutation = useIcdUpdate()
const [icdValue, setIcdValue] = useState(caseData.icd || '')
const [icdSaving, setIcdSaving] = useState(false)
const [icdError, setIcdError] = useState('')
const [icdSuccess, setIcdSuccess] = useState(false)
@ -336,17 +302,13 @@ function CaseDetail({
const saveIcd = async () => {
if (!icdValue.trim()) return
setIcdSaving(true)
setIcdError('')
setIcdSuccess(false)
try {
const res = await api.put<Case>(`/cases/${caseData.id}/icd`, { icd: icdValue.trim() })
onCaseSaved(res.data)
await icdMutation.mutateAsync({ id: caseData.id, icd: icdValue.trim() })
setIcdSuccess(true)
} catch {
setIcdError('Fehler beim Speichern des ICD-Codes.')
} finally {
setIcdSaving(false)
}
}
@ -456,10 +418,10 @@ function CaseDetail({
<Button
size="sm"
onClick={saveIcd}
disabled={icdSaving || !icdValue.trim()}
disabled={icdMutation.isPending || !icdValue.trim()}
>
<Save className="size-4 mr-1" />
{icdSaving ? 'Speichern...' : 'Speichern'}
{icdMutation.isPending ? 'Speichern...' : 'Speichern'}
</Button>
</div>
{icdError && (

View file

@ -1,6 +1,6 @@
import { useState, useCallback, useEffect } from 'react'
import api from '@/services/api'
import type { Case } from '@/types'
import { useCaseUpdate, useKvnrUpdate } from '@/hooks/useCases'
import { CASE_SECTIONS, type FieldConfig } from './fieldConfig'
type FormValues = Record<string, unknown>
@ -43,8 +43,10 @@ function getDirtyFields(original: FormValues, current: FormValues): FormValues {
export function useInlineEdit(
caseData: Case,
onSaved: (updated: Case) => void,
): UseInlineEditReturn {
const caseUpdateMutation = useCaseUpdate()
const kvnrUpdateMutation = useKvnrUpdate()
const [editing, setEditing] = useState(false)
const [formValues, setFormValues] = useState<FormValues>(() => extractFormValues(caseData))
const [originalValues, setOriginalValues] = useState<FormValues>(() => extractFormValues(caseData))
@ -101,10 +103,11 @@ export function useInlineEdit(
// Split: KVNR goes to its own endpoint (accessible by all users)
if ('kvnr' in dirty) {
const kvnrVal = dirty.kvnr as string | null
const res = await api.put<Case>(`/cases/${caseData.id}/kvnr`, {
const kvnrResult = await kvnrUpdateMutation.mutateAsync({
id: caseData.id,
kvnr: kvnrVal?.trim() || null,
})
lastResult = res.data
lastResult = kvnrResult
delete dirty.kvnr
}
@ -119,12 +122,14 @@ export function useInlineEdit(
payload[key] = value
}
}
const res = await api.put<Case>(`/cases/${caseData.id}`, payload)
lastResult = res.data
const updateResult = await caseUpdateMutation.mutateAsync({
id: caseData.id,
data: payload,
})
lastResult = updateResult
}
if (lastResult) {
onSaved(lastResult)
setSuccess(true)
setEditing(false)
}
@ -138,7 +143,7 @@ export function useInlineEdit(
} finally {
setSaving(false)
}
}, [caseData.id, formValues, originalValues, onSaved])
}, [caseData.id, formValues, originalValues, caseUpdateMutation, kvnrUpdateMutation])
const canEditField = useCallback(
(field: FieldConfig, isAdmin: boolean): boolean => {