From 002021d7c7dba949d6f2072606e176997f2f149e Mon Sep 17 00:00:00 2001 From: CCS Admin Date: Sat, 28 Feb 2026 14:43:00 +0000 Subject: [PATCH] feat: add Excel export for filtered case list New GET /cases/export endpoint generates .xlsx with openpyxl using the same filters as the case list (year, fallgruppe, ICD status, search). Role-aware columns: admins see patient names, DAK staff does not. Frontend adds a Download button next to the filter bar with loading state. Refactors shared query logic into _build_case_query helper. Co-Authored-By: Claude Opus 4.6 --- backend/app/api/cases.py | 163 +++++++++++++++---- docs/plans/2026-02-28-excel-export-design.md | 56 +++++++ docs/todo.md | 4 +- frontend/src/pages/CasesPage.tsx | 40 ++++- 4 files changed, 232 insertions(+), 31 deletions(-) create mode 100644 docs/plans/2026-02-28-excel-export-design.md diff --git a/backend/app/api/cases.py b/backend/app/api/cases.py index 7e9e2d4..d72bff3 100644 --- a/backend/app/api/cases.py +++ b/backend/app/api/cases.py @@ -1,11 +1,14 @@ """Cases API routes — list, detail, update, ICD entry, coding, template download.""" import logging -from datetime import datetime, timezone +from datetime import date, datetime, timezone +from io import BytesIO from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query, Request, status from fastapi.responses import StreamingResponse +from openpyxl import Workbook +from openpyxl.styles import Font, PatternFill from sqlalchemy import or_ from sqlalchemy.orm import Session @@ -166,6 +169,136 @@ def download_coding_template( ) +# --------------------------------------------------------------------------- +# Excel export +# --------------------------------------------------------------------------- + +EXPORT_HEADER_FILL = PatternFill(start_color="FFD9D9D9", end_color="FFD9D9D9", fill_type="solid") +EXPORT_HEADER_FONT = Font(bold=True, name="Calibri", size=11) + +FALLGRUPPEN_LABELS = { + "onko": "Onkologie", + "kardio": "Kardiologie", + "intensiv": "Intensivmedizin", + "galle": "Gallenblase", + "sd": "Schilddrüse", +} + + +def _build_case_query(db: Session, user: User, *, jahr=None, fallgruppe=None, has_icd=None, search=None): + """Build a filtered Case query (shared by list and export).""" + query = db.query(Case).filter(Case.versicherung == settings.VERSICHERUNG_FILTER) + if jahr is not None: + query = query.filter(Case.jahr == jahr) + if fallgruppe is not None: + query = query.filter(Case.fallgruppe == fallgruppe) + if has_icd is True: + query = query.filter(Case.icd != None) # noqa: E711 + elif has_icd is False: + query = query.filter(Case.icd == None) # noqa: E711 + if search: + like_pattern = f"%{search}%" + if user.role == "admin": + query = query.filter( + or_(Case.nachname.ilike(like_pattern), Case.vorname.ilike(like_pattern), + Case.fall_id.ilike(like_pattern), Case.kvnr.ilike(like_pattern)) + ) + else: + query = query.filter( + or_(Case.fall_id.ilike(like_pattern), Case.kvnr.ilike(like_pattern)) + ) + return query.order_by(Case.datum.desc(), Case.id.desc()) + + +@router.get("/export") +def export_cases( + request: Request, + jahr: Optional[int] = Query(None), + fallgruppe: Optional[str] = Query(None), + has_icd: Optional[bool] = Query(None), + search: Optional[str] = Query(None, min_length=1, max_length=200), + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): + """Export filtered cases as .xlsx download.""" + query = _build_case_query(db, user, jahr=jahr, fallgruppe=fallgruppe, has_icd=has_icd, search=search) + cases = query.all() + + is_admin = user.role == "admin" + + # Define columns based on role + if is_admin: + columns = [ + ("Fall-ID", lambda c: c.fall_id or ""), + ("Datum", lambda c: c.datum.strftime("%d.%m.%Y") if isinstance(c.datum, date) else str(c.datum or "")), + ("Nachname", lambda c: c.nachname or ""), + ("Vorname", lambda c: c.vorname or ""), + ("KVNR", lambda c: c.kvnr or ""), + ("Fallgruppe", lambda c: FALLGRUPPEN_LABELS.get(c.fallgruppe, c.fallgruppe or "")), + ("ICD", lambda c: c.icd or ""), + ("Gutachten-Typ", lambda c: c.gutachten_typ or ""), + ("Therapieänderung", lambda c: c.therapieaenderung or ""), + ("Unterlagen", lambda c: "Ja" if c.unterlagen else "Nein"), + ("Gutachten", lambda c: "Ja" if c.gutachten else "Nein"), + ("Abgelehnt", lambda c: "Ja" if c.ablehnung else "Nein"), + ("Abgerechnet", lambda c: "Ja" if c.abgerechnet else "Nein"), + ("Abbruch", lambda c: "Ja" if c.abbruch else "Nein"), + ] + else: + columns = [ + ("Fall-ID", lambda c: c.fall_id or ""), + ("Datum", lambda c: c.datum.strftime("%d.%m.%Y") if isinstance(c.datum, date) else str(c.datum or "")), + ("KVNR", lambda c: c.kvnr or ""), + ("Fallgruppe", lambda c: FALLGRUPPEN_LABELS.get(c.fallgruppe, c.fallgruppe or "")), + ("ICD", lambda c: c.icd or ""), + ("Gutachten-Typ", lambda c: c.gutachten_typ or ""), + ("Therapieänderung", lambda c: c.therapieaenderung or ""), + ("Unterlagen", lambda c: "Ja" if c.unterlagen else "Nein"), + ("Gutachten", lambda c: "Ja" if c.gutachten else "Nein"), + ("Abgelehnt", lambda c: "Ja" if c.ablehnung else "Nein"), + ("Abgerechnet", lambda c: "Ja" if c.abgerechnet else "Nein"), + ("Abbruch", lambda c: "Ja" if c.abbruch else "Nein"), + ] + + wb = Workbook() + ws = wb.active + ws.title = "Fallliste" + + # Header row + for col_idx, (header, _) in enumerate(columns, 1): + cell = ws.cell(row=1, column=col_idx, value=header) + cell.font = EXPORT_HEADER_FONT + cell.fill = EXPORT_HEADER_FILL + + # Data rows + for row_idx, case in enumerate(cases, 2): + for col_idx, (_, getter) in enumerate(columns, 1): + ws.cell(row=row_idx, column=col_idx, value=getter(case)) + + # Auto-width columns + for col_idx, (header, _) in enumerate(columns, 1): + from openpyxl.utils import get_column_letter + col_letter = get_column_letter(col_idx) + max_len = len(header) + for row in ws.iter_rows(min_row=2, min_col=col_idx, max_col=col_idx): + for cell in row: + if cell.value: + max_len = max(max_len, len(str(cell.value))) + ws.column_dimensions[col_letter].width = min(max_len + 2, 40) + + buf = BytesIO() + wb.save(buf) + buf.seek(0) + + filename = f"Fallliste_{date.today().strftime('%Y-%m-%d')}.xlsx" + + return StreamingResponse( + buf, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + # --------------------------------------------------------------------------- # Disclosure request # --------------------------------------------------------------------------- @@ -314,40 +447,14 @@ def list_cases( - ``has_coding``: True = only with gutachten_typ; False = only without - ``search``: free-text search across nachname, vorname, fall_id, kvnr """ - query = db.query(Case).filter(Case.versicherung == settings.VERSICHERUNG_FILTER) + query = _build_case_query(db, user, jahr=jahr, fallgruppe=fallgruppe, has_icd=has_icd, search=search) - if jahr is not None: - query = query.filter(Case.jahr == jahr) if kw is not None: query = query.filter(Case.kw == kw) - if fallgruppe is not None: - query = query.filter(Case.fallgruppe == fallgruppe) - if has_icd is True: - query = query.filter(Case.icd != None) # noqa: E711 - elif has_icd is False: - query = query.filter(Case.icd == None) # noqa: E711 if has_coding is True: query = query.filter(Case.gutachten_typ != None) # noqa: E711 elif has_coding is False: query = query.filter(Case.gutachten_typ == None) # noqa: E711 - if search: - like_pattern = f"%{search}%" - if user.role == "admin": - query = query.filter( - or_( - Case.nachname.ilike(like_pattern), - Case.vorname.ilike(like_pattern), - Case.fall_id.ilike(like_pattern), - Case.kvnr.ilike(like_pattern), - ) - ) - else: - query = query.filter( - or_( - Case.fall_id.ilike(like_pattern), - Case.kvnr.ilike(like_pattern), - ) - ) total = query.count() cases = ( diff --git a/docs/plans/2026-02-28-excel-export-design.md b/docs/plans/2026-02-28-excel-export-design.md new file mode 100644 index 0000000..ea17e4b --- /dev/null +++ b/docs/plans/2026-02-28-excel-export-design.md @@ -0,0 +1,56 @@ +# Fallliste als Excel exportieren — Design + +## Ziel + +Export-Button auf der Cases-Seite, der alle gefilterten Fälle als `.xlsx` herunterlädt. Nutzt aktive Filter (Jahr, Fallgruppe, ICD-Status, Suchbegriff). + +## Architektur + +**Backend-Endpoint** `GET /cases/export` generiert XLSX on-demand mit `openpyxl` (bereits installiert). Gleiche Filter-Parameter wie `GET /cases/`, aber ohne Pagination. Rollenbasierte Spaltenauswahl + Masking für DAK-Mitarbeiter. + +**Frontend** ruft den Endpoint als Blob ab und triggert Browser-Download. Bewährtes Pattern aus ReportsPage. + +## Backend + +### Endpoint: `GET /cases/export` + +- **Datei:** `backend/app/api/cases.py` +- **Query-Parameter:** `jahr`, `fallgruppe`, `has_icd`, `search` (optional) +- **Auth:** Alle authentifizierten Nutzer +- **Response:** `StreamingResponse` mit `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` +- **Dateiname:** `Fallliste_YYYY-MM-DD.xlsx` + +### Spalten + +**Admin:** +Fall-ID, Datum, Nachname, Vorname, KVNR, Fallgruppe, ICD, Gutachten-Typ, Therapieänderung, Unterlagen, Gutachten, Abgelehnt, Abgerechnet, Abbruch + +**DAK-Mitarbeiter:** +Fall-ID, Datum, KVNR, Fallgruppe, ICD, Gutachten-Typ, Therapieänderung, Unterlagen, Gutachten, Abgelehnt, Abgerechnet, Abbruch + +### Excel-Formatierung + +- Header-Zeile: fett, hellgrauer Hintergrund +- Auto-Width für Spalten +- Ja/Nein für Boolean-Felder +- Datum als deutsches Format (DD.MM.YYYY) + +## Frontend + +### Button-Platzierung + +Download-Icon-Button neben den bestehenden Filtern auf CasesPage. Sichtbar für alle Rollen. + +### Download-Flow + +1. Klick → Button zeigt Spinner +2. `api.get('/cases/export', { params, responseType: 'blob' })` +3. Blob → `URL.createObjectURL` → `` → Cleanup +4. Button kehrt zum Normalzustand zurück + +## Dateien + +| Datei | Änderung | +|-------|----------| +| `backend/app/api/cases.py` | Neuer `GET /cases/export` Endpoint | +| `frontend/src/pages/CasesPage.tsx` | Download-Button + Blob-Download-Logik | diff --git a/docs/todo.md b/docs/todo.md index c210836..33ee1a4 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -3,14 +3,14 @@ ## Hohe Priorität - [x] **Gutachten-Statistik Seite** — ✅ Implementiert: 4 KPIs, Stacked-Bar (Typ pro KW), Donut (Typ-Verteilung), Grouped-Bar (Therapieänderungen pro KW), Horizontal-Bars (Gründe). Backend-Endpoint + Frontend komplett. -- [ ] **Fallliste als Excel exportieren** — Export-Button auf der Cases-Seite für gefilterte Falllisten als .xlsx Download. Nutzt aktive Filter (Jahr, Fallgruppe, ICD-Status). +- [x] **Fallliste als Excel exportieren** — ✅ Implementiert: Download-Button auf der Cases-Seite. Backend `GET /cases/export` generiert XLSX mit openpyxl. Rollenbasierte Spalten (Admin sieht Namen, DAK-Mitarbeiter nicht). Alle aktiven Filter werden berücksichtigt. - [x] **E-Mail-Benachrichtigungen bei Freigabe-Entscheidung** — ✅ Implementiert: disclosure_service nutzt nun notification_service für In-App + E-Mail. Admins erhalten E-Mail bei neuer Anfrage, Mitarbeiter bei Genehmigung/Ablehnung. ## Mittlere Priorität - [x] **Benachrichtigungs-Center (Bell-Icon)** — ✅ Bereits implementiert: Bell-Icon im Header mit Badge-Counter, Popover-Dropdown, Mark-as-read, 60s-Polling. War schon in Header.tsx vorhanden. - [x] **Dashboard: Vorjahresvergleich bei KPIs** — ✅ Implementiert: prev_kpis im Dashboard-Endpoint, KpiCards zeigen farbige Trend-Indikatoren (+X% grün, -X% rot) mit Vorjahresvergleich. -- [ ] **Batch-ICD-Eingabe** — Inline-Tabelle auf der ICD-Seite mit direkter ICD-Eingabe pro Zeile statt Einzelklick auf jeden Fall. +- [x] **Batch-ICD-Eingabe** — ✅ Implementiert: Switch-Toggle für Batch-Modus auf ICD-Seite. Inline-Eingabefelder mit Enter/Tab/Escape-Bedienung, visuelles Feedback (Spinner, Häkchen, Fehler-Tooltip). - [x] **Dark Mode Toggle** — ✅ Bereits implementiert: Sun/Moon-Toggle im Header, useTheme Hook aktiv. ## Niedrige Priorität diff --git a/frontend/src/pages/CasesPage.tsx b/frontend/src/pages/CasesPage.tsx index 8c29981..91672d1 100644 --- a/frontend/src/pages/CasesPage.tsx +++ b/frontend/src/pages/CasesPage.tsx @@ -1,11 +1,12 @@ import { useState, useEffect, useCallback, useRef } from 'react' import type { KeyboardEvent } from 'react' import { - Search, ChevronLeft, ChevronRight, Save, CheckCircle, XCircle, Pencil, X, Info, Loader2, + Search, ChevronLeft, ChevronRight, Save, CheckCircle, XCircle, Pencil, X, Info, Loader2, Download, } from 'lucide-react' import type { Case } from '@/types' import { useAuth } from '@/context/AuthContext' import { useCases, usePendingIcdCases, useIcdUpdate, type CaseFilters } from '@/hooks/useCases' +import api from '@/services/api' import { Input } from '@/components/ui/input' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' @@ -71,6 +72,7 @@ export function CasesPage({ pendingIcdOnly = false }: CasesPageProps) { const [selectedCase, setSelectedCase] = useState(null) const [sheetOpen, setSheetOpen] = useState(false) const [batchMode, setBatchMode] = useState(false) + const [exporting, setExporting] = useState(false) // Debounce search const debounceRef = useRef | null>(null) @@ -112,6 +114,34 @@ export function CasesPage({ pendingIcdOnly = false }: CasesPageProps) { setSheetOpen(true) } + const exportToExcel = async () => { + setExporting(true) + try { + const params: Record = {} + if (jahr !== '__all__') params.jahr = Number(jahr) + if (fallgruppe !== '__all__') params.fallgruppe = fallgruppe + if (hasIcd !== '__all__') params.has_icd = hasIcd + if (debouncedSearch) params.search = debouncedSearch + + const res = await api.get('/cases/export', { params, responseType: 'blob' }) + const blob = new Blob([res.data], { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }) + const url = window.URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `Fallliste_${new Date().toISOString().slice(0, 10)}.xlsx` + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + window.URL.revokeObjectURL(url) + } catch { + // silently fail — user sees no file download + } finally { + setExporting(false) + } + } + return (
@@ -178,6 +208,14 @@ export function CasesPage({ pendingIcdOnly = false }: CasesPageProps) { ))} + + + + + Gefilterte Fallliste als Excel exportieren +
)}