From dbb5afaeb95a094f824dcf5baf1b3c397339bca2 Mon Sep 17 00:00:00 2001 From: CCS Admin Date: Thu, 26 Feb 2026 22:57:35 +0000 Subject: [PATCH] feat: add inline report viewer with 5-tab sheet display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend: GET /reports/{id}/data endpoint returns stored report JSON - Frontend: ReportViewer component renders all 5 Excel sheets as tabs (KW gesamt, Fachgebiete, Gutachten, Therapieänderungen, ICD onko) - ReportsPage: clickable rows with inline expansion to view reports - Empty KW rows filtered, summary row at bottom, German labels - Download button still available alongside inline view Co-Authored-By: Claude Opus 4.6 --- backend/app/api/reports.py | 18 + docs/plans/2026-02-26-report-viewer-design.md | 51 +++ ...2026-02-26-report-viewer-implementation.md | 202 +++++++++ frontend/src/components/ReportViewer.tsx | 413 ++++++++++++++++++ frontend/src/hooks/useReports.ts | 10 + frontend/src/pages/ReportsPage.tsx | 96 ++-- 6 files changed, 762 insertions(+), 28 deletions(-) create mode 100644 docs/plans/2026-02-26-report-viewer-design.md create mode 100644 docs/plans/2026-02-26-report-viewer-implementation.md create mode 100644 frontend/src/components/ReportViewer.tsx diff --git a/backend/app/api/reports.py b/backend/app/api/reports.py index e3e2380..d45761a 100644 --- a/backend/app/api/reports.py +++ b/backend/app/api/reports.py @@ -211,6 +211,24 @@ def download_report( ) +@router.get("/{report_id}/data") +def get_report_data( + report_id: int, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): + """Return the stored report_data JSON for a given report. + + Accessible to both admin and dak_mitarbeiter users. + """ + report = db.query(WeeklyReport).filter(WeeklyReport.id == report_id).first() + if not report: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Report not found") + if not report.report_data: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Report data not available") + return report.report_data + + @router.delete("/delete") def delete_reports( ids: list[int], diff --git a/docs/plans/2026-02-26-report-viewer-design.md b/docs/plans/2026-02-26-report-viewer-design.md new file mode 100644 index 0000000..14f125b --- /dev/null +++ b/docs/plans/2026-02-26-report-viewer-design.md @@ -0,0 +1,51 @@ +# Berichts-Webansicht (Inline-Expansion) + +## Kontext + +Berichte werden aktuell als Excel-Dateien generiert und können nur heruntergeladen werden. Ziel: Berichte direkt auf der Webseite anzeigen, ohne Excel öffnen zu müssen. + +## Entscheidungen + +- **Inline-Expansion**: Klick auf eine Tabellenzeile klappt den Bericht darunter auf +- **5 Tabs**: Alle 5 Excel-Sheets als separate Tabs (KW gesamt, Fachgebiete, Gutachten, Therapieänderungen, ICD onko) +- **Backend-Endpoint**: `GET /reports/{id}/data` liefert gespeichertes `report_data`-JSON +- **Leere KWs ausblenden**: Nur Zeilen mit Daten anzeigen + +## Architektur + +### Backend + +Neuer Endpoint in `backend/app/api/reports.py`: + +``` +GET /reports/{id}/data → WeeklyReport.report_data (JSON) +``` + +Authentifiziert (get_current_user), gibt das gespeicherte JSON zurück. Kein Neuberechnen. + +### Frontend + +| Datei | Änderung | +|-------|----------| +| `hooks/useReports.ts` | Neuer Hook `useReportData(id)` | +| `pages/ReportsPage.tsx` | Zeile klickbar, Inline-Expansion mit expandiertem Report | +| `components/ReportViewer.tsx` (NEU) | 5-Tab-Viewer-Komponente | + +### Sheet-Darstellung + +| Tab | Spalten | +|-----|---------| +| KW gesamt | KW, Erstberatungen, Unterlagen, Ablehnungen, Keine RM, Gutachten | +| Fachgebiete | KW, pro Fallgruppe: Anzahl, Gutachten, Keine RM | +| Gutachten | KW, Gesamt + pro Fallgruppe: Gutachten, Alternative, Bestätigung | +| Therapieänderungen | KW, Gutachten, TA Ja, TA Nein, Diagnosekorrektur, Unterversorgung, Übertherapie | +| ICD onko | ICD-Code, Anzahl (sortiert nach Häufigkeit) | + +Jede Tabelle: Summenzeile am Ende, leere KWs ausgeblendet. + +## Datenfluss + +1. User klickt auf Tabellenzeile → `expandedId` State wird gesetzt +2. `useReportData(expandedId)` fetcht `GET /reports/{id}/data` +3. `ReportViewer` rendert die 5 Tabs mit den Tabellendaten +4. Erneuter Klick auf die Zeile → `expandedId = null` → Expansion wird geschlossen diff --git a/docs/plans/2026-02-26-report-viewer-implementation.md b/docs/plans/2026-02-26-report-viewer-implementation.md new file mode 100644 index 0000000..0655be0 --- /dev/null +++ b/docs/plans/2026-02-26-report-viewer-implementation.md @@ -0,0 +1,202 @@ +# Report Viewer (Inline-Expansion) Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Berichte in der Berichteseite als Inline-Expansion mit 5 Tabs (wie Excel-Sheets) anzeigen, statt nur als Download. + +**Architecture:** Neuer Backend-Endpoint `GET /reports/{id}/data` liefert gespeichertes `report_data`-JSON. Frontend rendert mit Inline-Expansion und 5-Tab-Viewer-Komponente. Daten bereits in DB vorhanden, kein Neuberechnen nötig. + +**Tech Stack:** FastAPI, SQLAlchemy, React 19, TanStack Query v5, shadcn/ui (Tabs, Table), Tailwind CSS 4 + +--- + +### Task 1: Backend — `GET /reports/{id}/data` Endpoint + +**Files:** +- Modify: `backend/app/api/reports.py` (nach dem `/download/{report_id}` Endpoint, ca. Zeile 211) + +**Step 1: Endpoint implementieren** + +In `backend/app/api/reports.py` nach dem `download_report`-Endpoint einfügen: + +```python +@router.get("/{report_id}/data") +def get_report_data( + report_id: int, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): + """Return the stored report_data JSON for a given report. + + Accessible to both admin and dak_mitarbeiter users. + """ + report = db.query(WeeklyReport).filter(WeeklyReport.id == report_id).first() + if not report: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Report not found") + if not report.report_data: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Report data not available") + return report.report_data +``` + +**WICHTIG:** Dieser Endpoint MUSS vor dem bestehenden `@router.delete("/delete")` und `@router.get("/list")` stehen — aber er hat den Pfad `/{report_id}/data` mit einem Path-Parameter. Da FastAPI Routen in der Reihenfolge prüft, und `/list` und `/delete` statisch sind (und vor `/{report_id}` definiert sind), gibt es kein Routing-Konflikt. Einfach nach `download_report` (Zeile ~211) einfügen. + +**Step 2: Testen** + +Manuell (oder cURL): `curl -H "Authorization: Bearer " http://localhost:8000/api/reports/1/data` + +**Step 3: Commit** + +```bash +git add backend/app/api/reports.py +git commit -m "feat: add GET /reports/{id}/data endpoint for report viewer" +``` + +--- + +### Task 2: Frontend — `useReportData` Hook + +**Files:** +- Modify: `frontend/src/hooks/useReports.ts` + +**Step 1: Hook hinzufügen** + +Am Ende von `frontend/src/hooks/useReports.ts` einfügen: + +```typescript +export function useReportData(reportId: number | null) { + return useQuery({ + queryKey: ['report-data', reportId], + queryFn: () => + api.get>(`/reports/${reportId}/data`).then(r => r.data), + enabled: reportId !== null, + }) +} +``` + +**Step 2: Build prüfen** + +```bash +cd frontend && pnpm build +``` + +**Step 3: Commit** + +```bash +git add frontend/src/hooks/useReports.ts +git commit -m "feat: add useReportData hook for fetching report JSON" +``` + +--- + +### Task 3: Frontend — ReportViewer-Komponente + +**Files:** +- Create: `frontend/src/components/ReportViewer.tsx` + +**Step 1: Komponente erstellen** + +Die Komponente empfängt `data: Record` (das `report_data`-JSON) und rendert 5 Tabs. + +Datenstruktur (aus `report_service.py`): +- `data.sheet1` → `{ summary: {...}, weekly: [{kw, erstberatungen, unterlagen, ablehnungen, keine_rm, gutachten}, ...] }` +- `data.sheet2` → `{ weekly: [{kw, onko: {anzahl, gutachten, keine_rm}, kardio: {...}, ...}, ...] }` +- `data.sheet3` → `{ weekly: [{kw, gesamt: {gutachten, alternative, bestaetigung}, onko: {...}, ...}, ...] }` +- `data.sheet4` → `{ weekly: [{kw, gutachten, ta_ja, ta_nein, diagnosekorrektur, unterversorgung, uebertherapie}, ...] }` +- `data.sheet5` → `{ icd_codes: [{icd, count}, ...] }` + +Implementiere die Komponente mit: +- shadcn/ui `Tabs`, `TabsList`, `TabsTrigger`, `TabsContent` +- shadcn/ui `Table`, `TableHeader`, `TableRow`, `TableHead`, `TableBody`, `TableCell` +- Leere KW-Zeilen (alle Werte 0) ausfiltern +- Summenzeile am Ende (fett) +- Deutsche Spalten-Header +- Fallgruppen-Labels: `{ onko: 'Onkologie', kardio: 'Kardiologie', intensiv: 'Intensivmedizin', galle: 'Gallenblase', sd: 'Schilddrüse' }` + +Sheet 2 und 3 haben verschachtelte Spalten (pro Fallgruppe). Nutze grouped headers mit `colSpan`. + +**Step 2: Build prüfen** + +```bash +pnpm build +``` + +**Step 3: Commit** + +```bash +git add frontend/src/components/ReportViewer.tsx +git commit -m "feat: add ReportViewer component with 5-tab sheet display" +``` + +--- + +### Task 4: Frontend — ReportsPage mit Inline-Expansion + +**Files:** +- Modify: `frontend/src/pages/ReportsPage.tsx` + +**Step 1: Expansion-State und Klick-Handler** + +Änderungen: +1. Import `useReportData` und `ReportViewer` +2. Neuer State: `const [expandedId, setExpandedId] = useState(null)` +3. `useReportData(expandedId)` aufrufen +4. Tabellenzeile klickbar machen: `onClick={() => setExpandedId(expandedId === r.id ? null : r.id)}` +5. Cursor-Pointer auf der Zeile: `className="cursor-pointer hover:bg-muted/50"` +6. Nach jeder `` eine bedingte Expansion-Zeile: + +```tsx +{expandedId === r.id && ( + + + {reportDataLoading ? ( +
+ ) : reportData ? ( +
+ +
+ ) : ( +
+ Keine Berichtsdaten verfügbar. +
+ )} +
+
+)} +``` + +**Step 2: Build und Tests prüfen** + +```bash +pnpm build && pnpm test +``` + +**Step 3: Commit** + +```bash +git add frontend/src/pages/ReportsPage.tsx +git commit -m "feat: add inline report viewer expansion to ReportsPage" +``` + +--- + +### Task 5: Verifikation und finaler Commit + +**Step 1: Build** + +```bash +cd /home/frontend/dak_c2s/frontend && pnpm build +``` + +**Step 2: Tests** + +```bash +pnpm test +``` + +Alle 128+ Tests müssen bestehen. + +**Step 3: Finaler Commit (falls noch unstaged changes)** + +```bash +git add -A && git commit -m "feat: complete report viewer inline expansion" +``` diff --git a/frontend/src/components/ReportViewer.tsx b/frontend/src/components/ReportViewer.tsx new file mode 100644 index 0000000..74e70a3 --- /dev/null +++ b/frontend/src/components/ReportViewer.tsx @@ -0,0 +1,413 @@ +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { + Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow, +} from '@/components/ui/table' + +const FALLGRUPPEN_LABELS: Record = { + onko: 'Onkologie', + kardio: 'Kardiologie', + intensiv: 'Intensivmedizin', + galle: 'Gallenblase', + sd: 'Schilddr\u00fcse', +} + +const FALLGRUPPEN_KEYS = ['onko', 'kardio', 'intensiv', 'galle', 'sd'] as const + +function fmt(n: number): string { + return n.toLocaleString('de-DE') +} + +function NoData() { + return ( +

Keine Daten verf\u00fcgbar.

+ ) +} + +// --------------------------------------------------------------------------- +// Sheet 1 — KW gesamt +// --------------------------------------------------------------------------- + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function Sheet1({ data }: { data: any }) { + if (!data?.weekly?.length) return + + const COLS = [ + { key: 'erstberatungen', label: 'Erstberatungen' }, + { key: 'unterlagen', label: 'Unterlagen' }, + { key: 'ablehnungen', label: 'Ablehnungen' }, + { key: 'keine_rm', label: 'Keine R\u00fcckmeldung' }, + { key: 'gutachten', label: 'Gutachten' }, + ] as const + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rows = (data.weekly as any[]).filter((row) => + COLS.some((c) => (row[c.key] ?? 0) !== 0), + ) + + const totals = COLS.reduce( + (acc, c) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + acc[c.key] = rows.reduce((s: number, r: any) => s + (r[c.key] ?? 0), 0) + return acc + }, + {} as Record, + ) + + return ( + + + + KW + {COLS.map((c) => ( + {c.label} + ))} + + + + {rows.map((row) => ( + + KW {row.kw} + {COLS.map((c) => ( + {fmt(row[c.key] ?? 0)} + ))} + + ))} + + + + Gesamt + {COLS.map((c) => ( + {fmt(totals[c.key])} + ))} + + +
+ ) +} + +// --------------------------------------------------------------------------- +// Sheet 2 — Fachgebiete +// --------------------------------------------------------------------------- + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function Sheet2({ data }: { data: any }) { + if (!data?.weekly?.length) return + + const SUB_COLS = ['anzahl', 'gutachten', 'keine_rm'] as const + const SUB_LABELS: Record = { + anzahl: 'Anzahl', + gutachten: 'Gutachten', + keine_rm: 'Keine RM', + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const isRowEmpty = (row: any) => + FALLGRUPPEN_KEYS.every((fg) => + SUB_COLS.every((sc) => (row[fg]?.[sc] ?? 0) === 0), + ) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rows = (data.weekly as any[]).filter((row) => !isRowEmpty(row)) + + const totals = FALLGRUPPEN_KEYS.reduce( + (acc, fg) => { + acc[fg] = SUB_COLS.reduce( + (sub, sc) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sub[sc] = rows.reduce((s: number, r: any) => s + (r[fg]?.[sc] ?? 0), 0) + return sub + }, + {} as Record, + ) + return acc + }, + {} as Record>, + ) + + return ( + + + + KW + {FALLGRUPPEN_KEYS.map((fg) => ( + + {FALLGRUPPEN_LABELS[fg]} + + ))} + + + {FALLGRUPPEN_KEYS.map((fg) => + SUB_COLS.map((sc) => ( + + {SUB_LABELS[sc]} + + )), + )} + + + + {rows.map((row) => ( + + KW {row.kw} + {FALLGRUPPEN_KEYS.map((fg) => + SUB_COLS.map((sc) => ( + + {fmt(row[fg]?.[sc] ?? 0)} + + )), + )} + + ))} + + + + Gesamt + {FALLGRUPPEN_KEYS.map((fg) => + SUB_COLS.map((sc) => ( + + {fmt(totals[fg][sc])} + + )), + )} + + +
+ ) +} + +// --------------------------------------------------------------------------- +// Sheet 3 — Gutachten +// --------------------------------------------------------------------------- + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function Sheet3({ data }: { data: any }) { + if (!data?.weekly?.length) return + + const SUB_COLS = ['gutachten', 'alternative', 'bestaetigung'] as const + const SUB_LABELS: Record = { + gutachten: 'Gutachten', + alternative: 'Alternative', + bestaetigung: 'Best\u00e4tigung', + } + + const GROUP_KEYS = ['gesamt', ...FALLGRUPPEN_KEYS] as const + + const GROUP_LABELS: Record = { + gesamt: 'Gesamt', + ...FALLGRUPPEN_LABELS, + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const isRowEmpty = (row: any) => + GROUP_KEYS.every((grp) => + SUB_COLS.every((sc) => (row[grp]?.[sc] ?? 0) === 0), + ) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rows = (data.weekly as any[]).filter((row) => !isRowEmpty(row)) + + const totals = GROUP_KEYS.reduce( + (acc, grp) => { + acc[grp] = SUB_COLS.reduce( + (sub, sc) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sub[sc] = rows.reduce((s: number, r: any) => s + (r[grp]?.[sc] ?? 0), 0) + return sub + }, + {} as Record, + ) + return acc + }, + {} as Record>, + ) + + return ( + + + + KW + {GROUP_KEYS.map((grp) => ( + + {GROUP_LABELS[grp]} + + ))} + + + {GROUP_KEYS.map((grp) => + SUB_COLS.map((sc) => ( + + {SUB_LABELS[sc]} + + )), + )} + + + + {rows.map((row) => ( + + KW {row.kw} + {GROUP_KEYS.map((grp) => + SUB_COLS.map((sc) => ( + + {fmt(row[grp]?.[sc] ?? 0)} + + )), + )} + + ))} + + + + Gesamt + {GROUP_KEYS.map((grp) => + SUB_COLS.map((sc) => ( + + {fmt(totals[grp][sc])} + + )), + )} + + +
+ ) +} + +// --------------------------------------------------------------------------- +// Sheet 4 — Therapie\u00e4nderungen +// --------------------------------------------------------------------------- + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function Sheet4({ data }: { data: any }) { + if (!data?.weekly?.length) return + + const COLS = [ + { key: 'gutachten', label: 'Gutachten' }, + { key: 'ta_ja', label: 'Therapie\u00e4nderung Ja' }, + { key: 'ta_nein', label: 'Therapie\u00e4nderung Nein' }, + { key: 'diagnosekorrektur', label: 'Diagnosekorrektur' }, + { key: 'unterversorgung', label: 'Unterversorgung' }, + { key: 'uebertherapie', label: '\u00dcbertherapie' }, + ] as const + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rows = (data.weekly as any[]).filter((row) => + COLS.some((c) => (row[c.key] ?? 0) !== 0), + ) + + const totals = COLS.reduce( + (acc, c) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + acc[c.key] = rows.reduce((s: number, r: any) => s + (r[c.key] ?? 0), 0) + return acc + }, + {} as Record, + ) + + return ( + + + + KW + {COLS.map((c) => ( + {c.label} + ))} + + + + {rows.map((row) => ( + + KW {row.kw} + {COLS.map((c) => ( + {fmt(row[c.key] ?? 0)} + ))} + + ))} + + + + Gesamt + {COLS.map((c) => ( + {fmt(totals[c.key])} + ))} + + +
+ ) +} + +// --------------------------------------------------------------------------- +// Sheet 5 — ICD onko +// --------------------------------------------------------------------------- + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function Sheet5({ data }: { data: any }) { + if (!data?.icd_codes?.length) return + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rows = data.icd_codes as any[] + const total = rows.reduce((s: number, r) => s + (r.count ?? 0), 0) + + return ( + + + + ICD-Code + Anzahl + + + + {rows.map((row) => ( + + {row.icd} + {fmt(row.count ?? 0)} + + ))} + + + + Gesamt + {fmt(total)} + + +
+ ) +} + +// --------------------------------------------------------------------------- +// ReportViewer — Main Component +// --------------------------------------------------------------------------- + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function ReportViewer({ data }: { data: Record }) { + return ( + + + KW gesamt + Fachgebiete + Gutachten + Therapie\u00e4nderungen + ICD onko + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/frontend/src/hooks/useReports.ts b/frontend/src/hooks/useReports.ts index 8781d6e..f6e81fd 100644 --- a/frontend/src/hooks/useReports.ts +++ b/frontend/src/hooks/useReports.ts @@ -36,3 +36,13 @@ export function useDeleteReports() { }, }) } + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function useReportData(reportId: number | null) { + return useQuery({ + queryKey: ['report-data', reportId], + queryFn: () => + api.get>(`/reports/${reportId}/data`).then(r => r.data), + enabled: reportId !== null, + }) +} diff --git a/frontend/src/pages/ReportsPage.tsx b/frontend/src/pages/ReportsPage.tsx index 11da435..8306993 100644 --- a/frontend/src/pages/ReportsPage.tsx +++ b/frontend/src/pages/ReportsPage.tsx @@ -1,8 +1,9 @@ -import { useState } from 'react' -import { Download, FileSpreadsheet, Loader2, Plus, Trash2 } from 'lucide-react' +import { Fragment, useState } from 'react' +import { ChevronDown, ChevronRight, Download, FileSpreadsheet, Loader2, Plus, Trash2 } from 'lucide-react' import api from '@/services/api' import { useAuth } from '@/context/AuthContext' -import { useReports, useGenerateReport, useDeleteReports } from '@/hooks/useReports' +import { useReports, useGenerateReport, useDeleteReports, useReportData } from '@/hooks/useReports' +import { ReportViewer } from '@/components/ReportViewer' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Checkbox } from '@/components/ui/checkbox' @@ -36,6 +37,10 @@ export function ReportsPage() { // Selection state for deletion const [selectedIds, setSelectedIds] = useState>(new Set()) + // Inline expansion state + const [expandedId, setExpandedId] = useState(null) + const { data: reportData, isLoading: reportDataLoading } = useReportData(expandedId) + const generateReport = async () => { setGenError('') setGenSuccess('') @@ -225,32 +230,67 @@ export function ReportsPage() {
- {reports.map((r) => ( - - {isAdmin && ( - - toggleSelect(r.id)} - /> - - )} - {formatDate(r.report_date)} - {r.jahr} - KW {r.kw} - {formatDateTime(r.generated_at)} - - - - - ))} + {isAdmin && ( + e.stopPropagation()}> + toggleSelect(r.id)} + /> + + )} + + + {isExpanded + ? + : } + {formatDate(r.report_date)} + + + {r.jahr} + KW {r.kw} + {formatDateTime(r.generated_at)} + e.stopPropagation()}> + + + + {isExpanded && ( + + + {reportDataLoading ? ( +
+ +
+ ) : reportData ? ( +
+ +
+ ) : ( +
+ Keine Berichtsdaten verfügbar. +
+ )} +
+
+ )} + + ) + })}
) : (