mirror of
https://github.com/complexcaresolutions/dak.c2s.git
synced 2026-03-17 18:23:42 +00:00
docs: add state management implementation plan (9 tasks, TanStack Query)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d4420500e5
commit
62ee46fa3e
1 changed files with 877 additions and 0 deletions
877
docs/plans/2026-02-26-state-management-implementation.md
Normal file
877
docs/plans/2026-02-26-state-management-implementation.md
Normal file
|
|
@ -0,0 +1,877 @@
|
||||||
|
# State Management Refactoring — Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Introduce TanStack Query for server-state management to eliminate boilerplate and add caching/invalidation across all pages.
|
||||||
|
|
||||||
|
**Architecture:** Add `@tanstack/react-query` with a `QueryClientProvider` wrapping the app. Create domain-specific hooks (`useCases`, `useDashboard`, etc.) that encapsulate query keys + API calls. Migrate pages from `useState+useEffect` to `useQuery`/`useMutation`. Existing AuthContext and UI-state patterns remain unchanged.
|
||||||
|
|
||||||
|
**Tech Stack:** React 19, TanStack Query v5, TypeScript, Vite, Axios
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Install TanStack Query and add QueryClientProvider
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/package.json`
|
||||||
|
- Modify: `frontend/src/App.tsx`
|
||||||
|
|
||||||
|
**Step 1: Install dependency**
|
||||||
|
|
||||||
|
Run: `cd /home/frontend/dak_c2s/frontend && pnpm add @tanstack/react-query`
|
||||||
|
|
||||||
|
**Step 2: Add QueryClientProvider to App.tsx**
|
||||||
|
|
||||||
|
Replace the contents of `frontend/src/App.tsx` with:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { AuthProvider } from '@/context/AuthContext'
|
||||||
|
import { ProtectedRoute } from '@/components/layout/ProtectedRoute'
|
||||||
|
import { AppLayout } from '@/components/layout/AppLayout'
|
||||||
|
import { LoginPage } from '@/pages/LoginPage'
|
||||||
|
import { RegisterPage } from '@/pages/RegisterPage'
|
||||||
|
import { DashboardPage } from '@/pages/DashboardPage'
|
||||||
|
import { CasesPage } from '@/pages/CasesPage'
|
||||||
|
import { ImportPage } from '@/pages/ImportPage'
|
||||||
|
import { IcdPage } from '@/pages/IcdPage'
|
||||||
|
import { CodingPage } from '@/pages/CodingPage'
|
||||||
|
import { ReportsPage } from '@/pages/ReportsPage'
|
||||||
|
import { AdminUsersPage } from '@/pages/AdminUsersPage'
|
||||||
|
import { AdminInvitationsPage } from '@/pages/AdminInvitationsPage'
|
||||||
|
import { AdminAuditPage } from '@/pages/AdminAuditPage'
|
||||||
|
import { DisclosuresPage } from '@/pages/DisclosuresPage'
|
||||||
|
import { AccountPage } from '@/pages/AccountPage'
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 30_000,
|
||||||
|
retry: 1,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<AuthProvider>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/register" element={<RegisterPage />} />
|
||||||
|
<Route path="/" element={<ProtectedRoute><AppLayout /></ProtectedRoute>}>
|
||||||
|
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||||
|
<Route path="dashboard" element={<DashboardPage />} />
|
||||||
|
<Route path="cases" element={<CasesPage />} />
|
||||||
|
<Route path="import" element={<ProtectedRoute requireAdmin><ImportPage /></ProtectedRoute>} />
|
||||||
|
<Route path="icd" element={<IcdPage />} />
|
||||||
|
<Route path="coding" element={<ProtectedRoute requireAdmin><CodingPage /></ProtectedRoute>} />
|
||||||
|
<Route path="reports" element={<ReportsPage />} />
|
||||||
|
<Route path="account" element={<AccountPage />} />
|
||||||
|
<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>} />
|
||||||
|
<Route path="admin/disclosures" element={<ProtectedRoute requireAdmin><DisclosuresPage /></ProtectedRoute>} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</AuthProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Verify build**
|
||||||
|
|
||||||
|
Run: `cd /home/frontend/dak_c2s/frontend && pnpm build`
|
||||||
|
Expected: Build succeeds without errors.
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/frontend/dak_c2s
|
||||||
|
git add frontend/package.json frontend/pnpm-lock.yaml frontend/src/App.tsx
|
||||||
|
git commit -m "feat: add TanStack Query with QueryClientProvider"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Migrate DashboardPage
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `frontend/src/hooks/useDashboard.ts`
|
||||||
|
- Modify: `frontend/src/pages/DashboardPage.tsx`
|
||||||
|
|
||||||
|
**Step 1: Create useDashboard hook**
|
||||||
|
|
||||||
|
Create `frontend/src/hooks/useDashboard.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import api from '@/services/api'
|
||||||
|
import type { DashboardResponse } from '@/types'
|
||||||
|
|
||||||
|
export function useDashboard(jahr: number) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['dashboard', jahr],
|
||||||
|
queryFn: () => api.get<DashboardResponse>('/reports/dashboard', { params: { jahr } }).then(r => r.data),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Update DashboardPage to use the hook**
|
||||||
|
|
||||||
|
In `frontend/src/pages/DashboardPage.tsx`, replace the state + useEffect block (lines 33-43):
|
||||||
|
|
||||||
|
Remove:
|
||||||
|
```tsx
|
||||||
|
const [data, setData] = useState<DashboardResponse | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true)
|
||||||
|
api.get<DashboardResponse>('/reports/dashboard', { params: { jahr } })
|
||||||
|
.then((res) => setData(res.data))
|
||||||
|
.catch(() => setData(null))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [jahr])
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
```tsx
|
||||||
|
const { data, isLoading: loading } = useDashboard(jahr)
|
||||||
|
```
|
||||||
|
|
||||||
|
Update imports: remove `useEffect` from react import, remove `api` import, add:
|
||||||
|
```tsx
|
||||||
|
import { useDashboard } from '@/hooks/useDashboard'
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep `useState` for `jahr` (UI state, not server state).
|
||||||
|
|
||||||
|
**Step 3: Verify build**
|
||||||
|
|
||||||
|
Run: `cd /home/frontend/dak_c2s/frontend && pnpm build`
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/frontend/dak_c2s
|
||||||
|
git add frontend/src/hooks/useDashboard.ts frontend/src/pages/DashboardPage.tsx
|
||||||
|
git commit -m "refactor: migrate DashboardPage to TanStack Query"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Migrate DisclosuresPage
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `frontend/src/hooks/useDisclosures.ts`
|
||||||
|
- Modify: `frontend/src/pages/DisclosuresPage.tsx`
|
||||||
|
|
||||||
|
**Step 1: Create useDisclosures hook**
|
||||||
|
|
||||||
|
Create `frontend/src/hooks/useDisclosures.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { getDisclosureRequests, reviewDisclosure } from '@/services/disclosureService'
|
||||||
|
|
||||||
|
export function useDisclosures(status: string = 'pending') {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['disclosures', status],
|
||||||
|
queryFn: () => getDisclosureRequests(status),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useReviewDisclosure() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, status }: { id: number; status: 'approved' | 'rejected' }) =>
|
||||||
|
reviewDisclosure(id, status),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['disclosures'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['notifications'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Rewrite DisclosuresPage**
|
||||||
|
|
||||||
|
Replace the full content of `frontend/src/pages/DisclosuresPage.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Check, X } 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 type { DisclosureRequest } from '@/types'
|
||||||
|
import { useDisclosures, useReviewDisclosure } from '@/hooks/useDisclosures'
|
||||||
|
|
||||||
|
export function DisclosuresPage() {
|
||||||
|
const { data: requests = [], isLoading: loading } = useDisclosures('pending')
|
||||||
|
const reviewMutation = useReviewDisclosure()
|
||||||
|
|
||||||
|
const handleReview = (id: number, status: 'approved' | 'rejected') => {
|
||||||
|
reviewMutation.mutate({ id, status })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<h1 className="text-2xl font-bold">Freigabe-Anfragen</h1>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<Skeleton key={i} className="h-24 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : requests.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground">Keine offenen Anfragen.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{requests.map((dr: DisclosureRequest) => (
|
||||||
|
<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"
|
||||||
|
onClick={() => handleReview(dr.id, 'approved')}
|
||||||
|
disabled={reviewMutation.isPending}
|
||||||
|
>
|
||||||
|
<Check className="size-4 mr-1" />
|
||||||
|
Genehmigen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => handleReview(dr.id, 'rejected')}
|
||||||
|
disabled={reviewMutation.isPending}
|
||||||
|
>
|
||||||
|
<X className="size-4 mr-1" />
|
||||||
|
Ablehnen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Verify build**
|
||||||
|
|
||||||
|
Run: `cd /home/frontend/dak_c2s/frontend && pnpm build`
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/frontend/dak_c2s
|
||||||
|
git add frontend/src/hooks/useDisclosures.ts frontend/src/pages/DisclosuresPage.tsx
|
||||||
|
git commit -m "refactor: migrate DisclosuresPage to TanStack Query"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Migrate AdminAuditPage
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `frontend/src/hooks/useAuditLog.ts`
|
||||||
|
- Modify: `frontend/src/pages/AdminAuditPage.tsx`
|
||||||
|
|
||||||
|
**Step 1: Create useAuditLog hook**
|
||||||
|
|
||||||
|
Create `frontend/src/hooks/useAuditLog.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import api from '@/services/api'
|
||||||
|
import type { AuditLogEntry } from '@/types'
|
||||||
|
|
||||||
|
export interface AuditLogFilters {
|
||||||
|
skip: number
|
||||||
|
limit: number
|
||||||
|
user_id?: string
|
||||||
|
action?: string
|
||||||
|
date_from?: string
|
||||||
|
date_to?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuditLog(filters: AuditLogFilters) {
|
||||||
|
const params: Record<string, string | number> = {
|
||||||
|
skip: filters.skip,
|
||||||
|
limit: filters.limit,
|
||||||
|
}
|
||||||
|
if (filters.user_id) params.user_id = filters.user_id
|
||||||
|
if (filters.action) params.action = filters.action
|
||||||
|
if (filters.date_from) params.date_from = filters.date_from
|
||||||
|
if (filters.date_to) params.date_to = filters.date_to
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['audit-log', params],
|
||||||
|
queryFn: () => api.get<AuditLogEntry[]>('/admin/audit-log', { params }).then(r => r.data),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Update AdminAuditPage**
|
||||||
|
|
||||||
|
In `frontend/src/pages/AdminAuditPage.tsx`:
|
||||||
|
|
||||||
|
Remove: `import api from '@/services/api'`
|
||||||
|
Add: `import { useAuditLog } from '@/hooks/useAuditLog'`
|
||||||
|
|
||||||
|
Replace the state + fetch logic (lines 16-18 + 30-46) with:
|
||||||
|
|
||||||
|
Remove these lines:
|
||||||
|
```tsx
|
||||||
|
const [entries, setEntries] = useState<AuditLogEntry[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
```
|
||||||
|
and the `fetchEntries` function and its `useEffect`.
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
```tsx
|
||||||
|
const { data: entries = [], isLoading: loading, refetch } = useAuditLog({
|
||||||
|
skip, limit, user_id: filterUserId, action: filterAction,
|
||||||
|
date_from: filterDateFrom, date_to: filterDateTo,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Update `handleFilter` to call `refetch()` instead of `fetchEntries()`.
|
||||||
|
Update `resetFilters` to call `refetch()` instead of `setTimeout(fetchEntries, 0)`.
|
||||||
|
|
||||||
|
Remove `useEffect` from imports if no longer used. Keep `useState` for filter UI state and `expandedIds`.
|
||||||
|
|
||||||
|
**Step 3: Verify build**
|
||||||
|
|
||||||
|
Run: `cd /home/frontend/dak_c2s/frontend && pnpm build`
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/frontend/dak_c2s
|
||||||
|
git add frontend/src/hooks/useAuditLog.ts frontend/src/pages/AdminAuditPage.tsx
|
||||||
|
git commit -m "refactor: migrate AdminAuditPage to TanStack Query"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Migrate AdminUsersPage
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `frontend/src/hooks/useUsers.ts`
|
||||||
|
- Modify: `frontend/src/pages/AdminUsersPage.tsx`
|
||||||
|
|
||||||
|
**Step 1: Create useUsers hook**
|
||||||
|
|
||||||
|
Create `frontend/src/hooks/useUsers.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import api from '@/services/api'
|
||||||
|
import type { UserResponse, CreateUserPayload, UpdateUserPayload } from '@/types'
|
||||||
|
|
||||||
|
export function useUsers() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['users'],
|
||||||
|
queryFn: () => api.get<UserResponse[]>('/admin/users', { params: { skip: 0, limit: 200 } }).then(r => r.data),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateUser() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: CreateUserPayload) => api.post<UserResponse>('/admin/users', data).then(r => r.data),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateUser() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: number; data: UpdateUserPayload }) =>
|
||||||
|
api.put<UserResponse>(`/admin/users/${id}`, data).then(r => r.data),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Update AdminUsersPage**
|
||||||
|
|
||||||
|
In `frontend/src/pages/AdminUsersPage.tsx`:
|
||||||
|
|
||||||
|
Remove: `import api from '@/services/api'`
|
||||||
|
Add: `import { useUsers, useCreateUser, useUpdateUser } from '@/hooks/useUsers'`
|
||||||
|
|
||||||
|
Replace state + fetch (lines 23-24, 42-52):
|
||||||
|
```tsx
|
||||||
|
// Remove:
|
||||||
|
const [users, setUsers] = useState<UserResponse[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const fetchUsers = () => { ... }
|
||||||
|
useEffect(() => { fetchUsers() }, [])
|
||||||
|
|
||||||
|
// Replace with:
|
||||||
|
const { data: users = [], isLoading: loading } = useUsers()
|
||||||
|
const createMutation = useCreateUser()
|
||||||
|
const updateMutation = useUpdateUser()
|
||||||
|
```
|
||||||
|
|
||||||
|
Update `handleCreate` (lines 55-69):
|
||||||
|
```tsx
|
||||||
|
const handleCreate = async () => {
|
||||||
|
setCreateError('')
|
||||||
|
try {
|
||||||
|
await createMutation.mutateAsync(createForm)
|
||||||
|
setCreateOpen(false)
|
||||||
|
setCreateForm({ username: '', email: '', password: '', role: 'dak_mitarbeiter' })
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail
|
||||||
|
setCreateError(msg || 'Fehler beim Erstellen des Benutzers.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove `creating` state, use `createMutation.isPending` instead.
|
||||||
|
|
||||||
|
Update `handleEdit` (lines 79-94) similarly with `updateMutation.mutateAsync({ id: editUser.id, data: editForm })`.
|
||||||
|
|
||||||
|
Remove `editing` state, use `updateMutation.isPending` instead.
|
||||||
|
|
||||||
|
**Step 3: Verify build**
|
||||||
|
|
||||||
|
Run: `cd /home/frontend/dak_c2s/frontend && pnpm build`
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/frontend/dak_c2s
|
||||||
|
git add frontend/src/hooks/useUsers.ts frontend/src/pages/AdminUsersPage.tsx
|
||||||
|
git commit -m "refactor: migrate AdminUsersPage to TanStack Query"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Migrate useNotifications
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/src/hooks/useNotifications.ts`
|
||||||
|
|
||||||
|
**Step 1: Rewrite useNotifications with TanStack Query**
|
||||||
|
|
||||||
|
Replace the full content of `frontend/src/hooks/useNotifications.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import api from '@/services/api'
|
||||||
|
import type { Notification, NotificationList } from '@/types'
|
||||||
|
|
||||||
|
export function useNotifications() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const { data, isLoading: loading } = useQuery({
|
||||||
|
queryKey: ['notifications'],
|
||||||
|
queryFn: () => api.get<NotificationList>('/notifications').then(r => r.data),
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const markAsReadMutation = useMutation({
|
||||||
|
mutationFn: (id: number) => api.put<Notification>(`/notifications/${id}/read`),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['notifications'] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const markAllAsReadMutation = useMutation({
|
||||||
|
mutationFn: () => api.put<{ marked_read: number }>('/notifications/read-all'),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['notifications'] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
notifications: data?.items ?? [],
|
||||||
|
unreadCount: data?.unread_count ?? 0,
|
||||||
|
loading,
|
||||||
|
markAsRead: (id: number) => markAsReadMutation.mutate(id),
|
||||||
|
markAllAsRead: () => markAllAsReadMutation.mutate(),
|
||||||
|
refresh: () => queryClient.invalidateQueries({ queryKey: ['notifications'] }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The public API of this hook is identical — `notifications`, `unreadCount`, `loading`, `markAsRead`, `markAllAsRead`, `refresh` — so no consumers need to change.
|
||||||
|
|
||||||
|
**Step 2: Verify build**
|
||||||
|
|
||||||
|
Run: `cd /home/frontend/dak_c2s/frontend && pnpm build`
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/frontend/dak_c2s
|
||||||
|
git add frontend/src/hooks/useNotifications.ts
|
||||||
|
git commit -m "refactor: migrate useNotifications to TanStack Query"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Migrate ReportsPage
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `frontend/src/hooks/useReports.ts`
|
||||||
|
- Modify: `frontend/src/pages/ReportsPage.tsx`
|
||||||
|
|
||||||
|
**Step 1: Create useReports hook**
|
||||||
|
|
||||||
|
Create `frontend/src/hooks/useReports.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import api from '@/services/api'
|
||||||
|
import type { ReportMeta } from '@/types'
|
||||||
|
|
||||||
|
export function useReports() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['reports'],
|
||||||
|
queryFn: () => api.get<{ items: ReportMeta[]; total: number }>('/reports/list').then(r => r.data),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGenerateReport() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ jahr, kw }: { jahr: number; kw: number }) =>
|
||||||
|
api.post<ReportMeta>('/reports/generate', null, { params: { jahr, kw } }).then(r => r.data),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['reports'] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteReports() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (ids: number[]) => api.delete('/reports/delete', { data: ids }),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['reports'] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Update ReportsPage**
|
||||||
|
|
||||||
|
In `frontend/src/pages/ReportsPage.tsx`:
|
||||||
|
|
||||||
|
Remove: `import api from '@/services/api'`
|
||||||
|
Add: `import { useReports, useGenerateReport, useDeleteReports } from '@/hooks/useReports'`
|
||||||
|
|
||||||
|
Replace state + fetch logic (lines 22-54):
|
||||||
|
```tsx
|
||||||
|
// Remove:
|
||||||
|
const [reports, setReports] = useState<ReportMeta[]>([])
|
||||||
|
const [totalReports, setTotalReports] = useState(0)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [generating, setGenerating] = useState(false)
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
const fetchReports = () => { ... }
|
||||||
|
useEffect(() => { fetchReports() }, [])
|
||||||
|
|
||||||
|
// Replace with:
|
||||||
|
const { data: reportData, isLoading: loading } = useReports()
|
||||||
|
const reports = reportData?.items ?? []
|
||||||
|
const totalReports = reportData?.total ?? 0
|
||||||
|
const generateMutation = useGenerateReport()
|
||||||
|
const deleteMutation = useDeleteReports()
|
||||||
|
```
|
||||||
|
|
||||||
|
Update `generateReport`:
|
||||||
|
```tsx
|
||||||
|
const generateReport = async () => {
|
||||||
|
setGenError('')
|
||||||
|
setGenSuccess('')
|
||||||
|
try {
|
||||||
|
const result = await generateMutation.mutateAsync({ jahr: genJahr, kw: genKw })
|
||||||
|
setGenSuccess(`Bericht für KW ${result.kw}/${result.jahr} wurde generiert.`)
|
||||||
|
} catch {
|
||||||
|
setGenError('Fehler beim Generieren des Berichts.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `generateMutation.isPending` instead of `generating` state.
|
||||||
|
|
||||||
|
Update `deleteSelected`:
|
||||||
|
```tsx
|
||||||
|
const deleteSelected = async () => {
|
||||||
|
if (selectedIds.size === 0) return
|
||||||
|
try {
|
||||||
|
await deleteMutation.mutateAsync(Array.from(selectedIds))
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
} catch {
|
||||||
|
setGenError('Fehler beim Löschen der Berichte.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `deleteMutation.isPending` instead of `deleting` state.
|
||||||
|
|
||||||
|
Remove `useEffect` from imports if no longer used.
|
||||||
|
|
||||||
|
**Step 3: Verify build**
|
||||||
|
|
||||||
|
Run: `cd /home/frontend/dak_c2s/frontend && pnpm build`
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/frontend/dak_c2s
|
||||||
|
git add frontend/src/hooks/useReports.ts frontend/src/pages/ReportsPage.tsx
|
||||||
|
git commit -m "refactor: migrate ReportsPage to TanStack Query"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: Migrate CasesPage (list + mutations)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `frontend/src/hooks/useCases.ts`
|
||||||
|
- Modify: `frontend/src/pages/CasesPage.tsx`
|
||||||
|
- Modify: `frontend/src/pages/cases/useInlineEdit.ts`
|
||||||
|
|
||||||
|
**Step 1: Create useCases hook**
|
||||||
|
|
||||||
|
Create `frontend/src/hooks/useCases.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
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) {
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePendingIcdCases(page: number, perPage: number) {
|
||||||
|
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),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Update CasesPage**
|
||||||
|
|
||||||
|
In `frontend/src/pages/CasesPage.tsx`:
|
||||||
|
|
||||||
|
Remove: `import api from '@/services/api'`
|
||||||
|
Add: `import { useCases, usePendingIcdCases, useIcdUpdate } from '@/hooks/useCases'`
|
||||||
|
|
||||||
|
Replace the fetch useEffect (lines 88-110) and state (lines 65-66) with:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Remove:
|
||||||
|
const [data, setData] = useState<CaseListResponse | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
// Remove the entire useEffect that fetches cases
|
||||||
|
|
||||||
|
// Replace with:
|
||||||
|
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 = pendingIcdOnly
|
||||||
|
? usePendingIcdCases(page, perPage)
|
||||||
|
: useCases(filters)
|
||||||
|
|
||||||
|
const data = casesQuery.data ?? null
|
||||||
|
const loading = casesQuery.isLoading
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: Import `type { CaseFilters }` from `@/hooks/useCases`.
|
||||||
|
|
||||||
|
Remove `handleCaseSaved` function entirely (lines 120-131). TanStack Query cache invalidation handles this automatically via mutations.
|
||||||
|
|
||||||
|
Update CaseDetail props: remove `onCaseSaved` prop.
|
||||||
|
|
||||||
|
**Step 3: Update useInlineEdit to use mutations**
|
||||||
|
|
||||||
|
In `frontend/src/pages/cases/useInlineEdit.ts`:
|
||||||
|
|
||||||
|
Remove: `import api from '@/services/api'`
|
||||||
|
Add: `import { useCaseUpdate, useKvnrUpdate } from '@/hooks/useCases'`
|
||||||
|
|
||||||
|
Remove the `onSaved` parameter from the function signature:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export function useInlineEdit(caseData: Case): UseInlineEditReturn {
|
||||||
|
```
|
||||||
|
|
||||||
|
Add mutations at the top of the hook:
|
||||||
|
```tsx
|
||||||
|
const caseUpdateMutation = useCaseUpdate()
|
||||||
|
const kvnrUpdateMutation = useKvnrUpdate()
|
||||||
|
```
|
||||||
|
|
||||||
|
In `saveAll`, replace direct API calls:
|
||||||
|
```tsx
|
||||||
|
// Instead of: api.put<Case>(`/cases/${caseData.id}/kvnr`, { kvnr: kvnrVal?.trim() || null })
|
||||||
|
const res = await kvnrUpdateMutation.mutateAsync({ id: caseData.id, kvnr: kvnrVal?.trim() || null })
|
||||||
|
|
||||||
|
// Instead of: api.put<Case>(`/cases/${caseData.id}`, payload)
|
||||||
|
const res = await caseUpdateMutation.mutateAsync({ id: caseData.id, data: payload })
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove the `onSaved(lastResult)` call — cache invalidation from mutations handles list refresh.
|
||||||
|
|
||||||
|
**Step 4: Update CaseDetail to remove onCaseSaved**
|
||||||
|
|
||||||
|
In CaseDetail component within CasesPage.tsx:
|
||||||
|
|
||||||
|
Remove `onCaseSaved` prop and its type. Update ICD save to use `useIcdUpdate()`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const icdMutation = useIcdUpdate()
|
||||||
|
|
||||||
|
const saveIcd = async () => {
|
||||||
|
if (!icdValue.trim()) return
|
||||||
|
try {
|
||||||
|
await icdMutation.mutateAsync({ id: caseData.id, icd: icdValue.trim() })
|
||||||
|
setIcdSuccess(true)
|
||||||
|
} catch {
|
||||||
|
setIcdError('Fehler beim Speichern des ICD-Codes.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `icdMutation.isPending` instead of `icdSaving` state. Remove `icdSaving` useState.
|
||||||
|
|
||||||
|
Remove `onCaseSaved` from the `<CaseDetail>` JSX call.
|
||||||
|
|
||||||
|
**Step 5: Verify build**
|
||||||
|
|
||||||
|
Run: `cd /home/frontend/dak_c2s/frontend && pnpm build`
|
||||||
|
|
||||||
|
**Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/frontend/dak_c2s
|
||||||
|
git add frontend/src/hooks/useCases.ts frontend/src/pages/CasesPage.tsx frontend/src/pages/cases/useInlineEdit.ts
|
||||||
|
git commit -m "refactor: migrate CasesPage and useInlineEdit to TanStack Query"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 9: Build, test, deploy
|
||||||
|
|
||||||
|
**Step 1: Build frontend**
|
||||||
|
|
||||||
|
Run: `cd /home/frontend/dak_c2s/frontend && pnpm build`
|
||||||
|
Expected: Build succeeds.
|
||||||
|
|
||||||
|
**Step 2: Push and merge**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/frontend/dak_c2s
|
||||||
|
git push origin develop
|
||||||
|
git checkout main && git pull origin main && git merge develop && git push origin main
|
||||||
|
git checkout develop
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Deploy to Hetzner 1**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh hetzner1 "cd /opt/dak-portal && git pull origin main"
|
||||||
|
ssh hetzner1 "cd /opt/dak-portal/frontend && pnpm install && pnpm build && cp -r dist/* /var/www/vhosts/complexcaresolutions.de/dak.complexcaresolutions.de/dist/"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Smoke test**
|
||||||
|
|
||||||
|
Verify in browser:
|
||||||
|
1. Dashboard loads with caching (navigate away and back — no reload flash)
|
||||||
|
2. Cases list loads, detail sheet opens
|
||||||
|
3. Edit a case field → save → list refreshes automatically
|
||||||
|
4. ICD save works
|
||||||
|
5. Notifications badge updates
|
||||||
|
6. Admin pages (Users, Audit, Disclosures) all work
|
||||||
Loading…
Reference in a new issue