mirror of
https://github.com/complexcaresolutions/dak.c2s.git
synced 2026-03-17 17:13:42 +00:00
feat: add inline report viewer with 5-tab sheet display
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
32cee4d30d
commit
dbb5afaeb9
6 changed files with 762 additions and 28 deletions
|
|
@ -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")
|
@router.delete("/delete")
|
||||||
def delete_reports(
|
def delete_reports(
|
||||||
ids: list[int],
|
ids: list[int],
|
||||||
|
|
|
||||||
51
docs/plans/2026-02-26-report-viewer-design.md
Normal file
51
docs/plans/2026-02-26-report-viewer-design.md
Normal file
|
|
@ -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
|
||||||
202
docs/plans/2026-02-26-report-viewer-implementation.md
Normal file
202
docs/plans/2026-02-26-report-viewer-implementation.md
Normal file
|
|
@ -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 <token>" 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<Record<string, unknown>>(`/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<string, unknown>` (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<number | null>(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 `<TableRow>` eine bedingte Expansion-Zeile:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{expandedId === r.id && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={isAdmin ? 6 : 5} className="p-0">
|
||||||
|
{reportDataLoading ? (
|
||||||
|
<div className="p-6"><Skeleton className="h-64 w-full" /></div>
|
||||||
|
) : reportData ? (
|
||||||
|
<div className="p-4 border-t bg-muted/20">
|
||||||
|
<ReportViewer data={reportData} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-6 text-center text-muted-foreground">
|
||||||
|
Keine Berichtsdaten verfügbar.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
**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"
|
||||||
|
```
|
||||||
413
frontend/src/components/ReportViewer.tsx
Normal file
413
frontend/src/components/ReportViewer.tsx
Normal file
|
|
@ -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<string, string> = {
|
||||||
|
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 (
|
||||||
|
<p className="py-8 text-center text-muted-foreground">Keine Daten verf\u00fcgbar.</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sheet 1 — KW gesamt
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
function Sheet1({ data }: { data: any }) {
|
||||||
|
if (!data?.weekly?.length) return <NoData />
|
||||||
|
|
||||||
|
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<string, number>,
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>KW</TableHead>
|
||||||
|
{COLS.map((c) => (
|
||||||
|
<TableHead key={c.key} className="text-right">{c.label}</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{rows.map((row) => (
|
||||||
|
<TableRow key={row.kw}>
|
||||||
|
<TableCell>KW {row.kw}</TableCell>
|
||||||
|
{COLS.map((c) => (
|
||||||
|
<TableCell key={c.key} className="text-right">{fmt(row[c.key] ?? 0)}</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
<TableFooter>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="font-bold">Gesamt</TableCell>
|
||||||
|
{COLS.map((c) => (
|
||||||
|
<TableCell key={c.key} className="text-right font-bold">{fmt(totals[c.key])}</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</TableFooter>
|
||||||
|
</Table>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sheet 2 — Fachgebiete
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
function Sheet2({ data }: { data: any }) {
|
||||||
|
if (!data?.weekly?.length) return <NoData />
|
||||||
|
|
||||||
|
const SUB_COLS = ['anzahl', 'gutachten', 'keine_rm'] as const
|
||||||
|
const SUB_LABELS: Record<string, string> = {
|
||||||
|
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<string, number>,
|
||||||
|
)
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
{} as Record<string, Record<string, number>>,
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead rowSpan={2} className="align-bottom">KW</TableHead>
|
||||||
|
{FALLGRUPPEN_KEYS.map((fg) => (
|
||||||
|
<TableHead key={fg} colSpan={SUB_COLS.length} className="text-center border-l">
|
||||||
|
{FALLGRUPPEN_LABELS[fg]}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
{FALLGRUPPEN_KEYS.map((fg) =>
|
||||||
|
SUB_COLS.map((sc) => (
|
||||||
|
<TableHead key={`${fg}-${sc}`} className="text-right border-l first:border-l">
|
||||||
|
{SUB_LABELS[sc]}
|
||||||
|
</TableHead>
|
||||||
|
)),
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{rows.map((row) => (
|
||||||
|
<TableRow key={row.kw}>
|
||||||
|
<TableCell>KW {row.kw}</TableCell>
|
||||||
|
{FALLGRUPPEN_KEYS.map((fg) =>
|
||||||
|
SUB_COLS.map((sc) => (
|
||||||
|
<TableCell key={`${fg}-${sc}`} className="text-right border-l">
|
||||||
|
{fmt(row[fg]?.[sc] ?? 0)}
|
||||||
|
</TableCell>
|
||||||
|
)),
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
<TableFooter>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="font-bold">Gesamt</TableCell>
|
||||||
|
{FALLGRUPPEN_KEYS.map((fg) =>
|
||||||
|
SUB_COLS.map((sc) => (
|
||||||
|
<TableCell key={`${fg}-${sc}`} className="text-right font-bold border-l">
|
||||||
|
{fmt(totals[fg][sc])}
|
||||||
|
</TableCell>
|
||||||
|
)),
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
</TableFooter>
|
||||||
|
</Table>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sheet 3 — Gutachten
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
function Sheet3({ data }: { data: any }) {
|
||||||
|
if (!data?.weekly?.length) return <NoData />
|
||||||
|
|
||||||
|
const SUB_COLS = ['gutachten', 'alternative', 'bestaetigung'] as const
|
||||||
|
const SUB_LABELS: Record<string, string> = {
|
||||||
|
gutachten: 'Gutachten',
|
||||||
|
alternative: 'Alternative',
|
||||||
|
bestaetigung: 'Best\u00e4tigung',
|
||||||
|
}
|
||||||
|
|
||||||
|
const GROUP_KEYS = ['gesamt', ...FALLGRUPPEN_KEYS] as const
|
||||||
|
|
||||||
|
const GROUP_LABELS: Record<string, string> = {
|
||||||
|
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<string, number>,
|
||||||
|
)
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
{} as Record<string, Record<string, number>>,
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead rowSpan={2} className="align-bottom">KW</TableHead>
|
||||||
|
{GROUP_KEYS.map((grp) => (
|
||||||
|
<TableHead key={grp} colSpan={SUB_COLS.length} className="text-center border-l">
|
||||||
|
{GROUP_LABELS[grp]}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
{GROUP_KEYS.map((grp) =>
|
||||||
|
SUB_COLS.map((sc) => (
|
||||||
|
<TableHead key={`${grp}-${sc}`} className="text-right border-l">
|
||||||
|
{SUB_LABELS[sc]}
|
||||||
|
</TableHead>
|
||||||
|
)),
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{rows.map((row) => (
|
||||||
|
<TableRow key={row.kw}>
|
||||||
|
<TableCell>KW {row.kw}</TableCell>
|
||||||
|
{GROUP_KEYS.map((grp) =>
|
||||||
|
SUB_COLS.map((sc) => (
|
||||||
|
<TableCell key={`${grp}-${sc}`} className="text-right border-l">
|
||||||
|
{fmt(row[grp]?.[sc] ?? 0)}
|
||||||
|
</TableCell>
|
||||||
|
)),
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
<TableFooter>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="font-bold">Gesamt</TableCell>
|
||||||
|
{GROUP_KEYS.map((grp) =>
|
||||||
|
SUB_COLS.map((sc) => (
|
||||||
|
<TableCell key={`${grp}-${sc}`} className="text-right font-bold border-l">
|
||||||
|
{fmt(totals[grp][sc])}
|
||||||
|
</TableCell>
|
||||||
|
)),
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
</TableFooter>
|
||||||
|
</Table>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sheet 4 — Therapie\u00e4nderungen
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
function Sheet4({ data }: { data: any }) {
|
||||||
|
if (!data?.weekly?.length) return <NoData />
|
||||||
|
|
||||||
|
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<string, number>,
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>KW</TableHead>
|
||||||
|
{COLS.map((c) => (
|
||||||
|
<TableHead key={c.key} className="text-right">{c.label}</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{rows.map((row) => (
|
||||||
|
<TableRow key={row.kw}>
|
||||||
|
<TableCell>KW {row.kw}</TableCell>
|
||||||
|
{COLS.map((c) => (
|
||||||
|
<TableCell key={c.key} className="text-right">{fmt(row[c.key] ?? 0)}</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
<TableFooter>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="font-bold">Gesamt</TableCell>
|
||||||
|
{COLS.map((c) => (
|
||||||
|
<TableCell key={c.key} className="text-right font-bold">{fmt(totals[c.key])}</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</TableFooter>
|
||||||
|
</Table>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sheet 5 — ICD onko
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
function Sheet5({ data }: { data: any }) {
|
||||||
|
if (!data?.icd_codes?.length) return <NoData />
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>ICD-Code</TableHead>
|
||||||
|
<TableHead className="text-right">Anzahl</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{rows.map((row) => (
|
||||||
|
<TableRow key={row.icd}>
|
||||||
|
<TableCell>{row.icd}</TableCell>
|
||||||
|
<TableCell className="text-right">{fmt(row.count ?? 0)}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
<TableFooter>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="font-bold">Gesamt</TableCell>
|
||||||
|
<TableCell className="text-right font-bold">{fmt(total)}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableFooter>
|
||||||
|
</Table>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ReportViewer — Main Component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export function ReportViewer({ data }: { data: Record<string, any> }) {
|
||||||
|
return (
|
||||||
|
<Tabs defaultValue="sheet1" className="w-full">
|
||||||
|
<TabsList className="w-full justify-start">
|
||||||
|
<TabsTrigger value="sheet1">KW gesamt</TabsTrigger>
|
||||||
|
<TabsTrigger value="sheet2">Fachgebiete</TabsTrigger>
|
||||||
|
<TabsTrigger value="sheet3">Gutachten</TabsTrigger>
|
||||||
|
<TabsTrigger value="sheet4">Therapie\u00e4nderungen</TabsTrigger>
|
||||||
|
<TabsTrigger value="sheet5">ICD onko</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="sheet1">
|
||||||
|
<Sheet1 data={data?.sheet1} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="sheet2">
|
||||||
|
<Sheet2 data={data?.sheet2} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="sheet3">
|
||||||
|
<Sheet3 data={data?.sheet3} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="sheet4">
|
||||||
|
<Sheet4 data={data?.sheet4} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="sheet5">
|
||||||
|
<Sheet5 data={data?.sheet5} />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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<Record<string, any>>(`/reports/${reportId}/data`).then(r => r.data),
|
||||||
|
enabled: reportId !== null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { useState } from 'react'
|
import { Fragment, useState } from 'react'
|
||||||
import { Download, FileSpreadsheet, Loader2, Plus, Trash2 } from 'lucide-react'
|
import { ChevronDown, ChevronRight, Download, FileSpreadsheet, Loader2, Plus, Trash2 } from 'lucide-react'
|
||||||
import api from '@/services/api'
|
import api from '@/services/api'
|
||||||
import { useAuth } from '@/context/AuthContext'
|
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 { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
|
@ -36,6 +37,10 @@ export function ReportsPage() {
|
||||||
// Selection state for deletion
|
// Selection state for deletion
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
|
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
|
||||||
|
|
||||||
|
// Inline expansion state
|
||||||
|
const [expandedId, setExpandedId] = useState<number | null>(null)
|
||||||
|
const { data: reportData, isLoading: reportDataLoading } = useReportData(expandedId)
|
||||||
|
|
||||||
const generateReport = async () => {
|
const generateReport = async () => {
|
||||||
setGenError('')
|
setGenError('')
|
||||||
setGenSuccess('')
|
setGenSuccess('')
|
||||||
|
|
@ -225,32 +230,67 @@ export function ReportsPage() {
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{reports.map((r) => (
|
{reports.map((r) => {
|
||||||
<TableRow key={r.id}>
|
const isExpanded = expandedId === r.id
|
||||||
{isAdmin && (
|
const colCount = isAdmin ? 6 : 5
|
||||||
<TableCell>
|
return (
|
||||||
<Checkbox
|
<Fragment key={r.id}>
|
||||||
checked={selectedIds.has(r.id)}
|
<TableRow
|
||||||
onCheckedChange={() => toggleSelect(r.id)}
|
className="cursor-pointer hover:bg-muted/50"
|
||||||
/>
|
onClick={() => setExpandedId(isExpanded ? null : r.id)}
|
||||||
</TableCell>
|
|
||||||
)}
|
|
||||||
<TableCell>{formatDate(r.report_date)}</TableCell>
|
|
||||||
<TableCell>{r.jahr}</TableCell>
|
|
||||||
<TableCell>KW {r.kw}</TableCell>
|
|
||||||
<TableCell>{formatDateTime(r.generated_at)}</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => downloadReport(r.id)}
|
|
||||||
>
|
>
|
||||||
<Download className="mr-1.5 h-4 w-4" />
|
{isAdmin && (
|
||||||
Download
|
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||||
</Button>
|
<Checkbox
|
||||||
</TableCell>
|
checked={selectedIds.has(r.id)}
|
||||||
</TableRow>
|
onCheckedChange={() => toggleSelect(r.id)}
|
||||||
))}
|
/>
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
<TableCell>
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
{isExpanded
|
||||||
|
? <ChevronDown className="size-4 text-muted-foreground" />
|
||||||
|
: <ChevronRight className="size-4 text-muted-foreground" />}
|
||||||
|
{formatDate(r.report_date)}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{r.jahr}</TableCell>
|
||||||
|
<TableCell>KW {r.kw}</TableCell>
|
||||||
|
<TableCell>{formatDateTime(r.generated_at)}</TableCell>
|
||||||
|
<TableCell className="text-right" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => downloadReport(r.id)}
|
||||||
|
>
|
||||||
|
<Download className="mr-1.5 h-4 w-4" />
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
{isExpanded && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={colCount} className="p-0">
|
||||||
|
{reportDataLoading ? (
|
||||||
|
<div className="p-6">
|
||||||
|
<Skeleton className="h-64 w-full" />
|
||||||
|
</div>
|
||||||
|
) : reportData ? (
|
||||||
|
<div className="p-4 border-t bg-muted/20">
|
||||||
|
<ReportViewer data={reportData} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-6 text-center text-muted-foreground">
|
||||||
|
Keine Berichtsdaten verfügbar.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue