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 <noreply@anthropic.com>
This commit is contained in:
CCS Admin 2026-02-28 14:43:00 +00:00
parent eb39346f02
commit 002021d7c7
4 changed files with 232 additions and 31 deletions

View file

@ -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 = (

View file

@ -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``<a download>` → 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 |

View file

@ -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

View file

@ -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<Case | null>(null)
const [sheetOpen, setSheetOpen] = useState(false)
const [batchMode, setBatchMode] = useState(false)
const [exporting, setExporting] = useState(false)
// Debounce search
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
@ -112,6 +114,34 @@ export function CasesPage({ pendingIcdOnly = false }: CasesPageProps) {
setSheetOpen(true)
}
const exportToExcel = async () => {
setExporting(true)
try {
const params: Record<string, string | number> = {}
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 (
<div className="p-6 space-y-4">
<div className="flex items-start justify-between">
@ -178,6 +208,14 @@ export function CasesPage({ pendingIcdOnly = false }: CasesPageProps) {
))}
</SelectContent>
</Select>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="icon" onClick={exportToExcel} disabled={exporting}>
{exporting ? <Loader2 className="size-4 animate-spin" /> : <Download className="size-4" />}
</Button>
</TooltipTrigger>
<TooltipContent>Gefilterte Fallliste als Excel exportieren</TooltipContent>
</Tooltip>
</div>
)}