From efeb619b06103d0db9bfd4bdac9909ee2f475daa Mon Sep 17 00:00:00 2001 From: CCS Admin Date: Fri, 27 Feb 2026 13:51:14 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20persist=20Wochen=C3=BCbersicht=20export?= =?UTF-8?q?s,=20DAK-Mitarbeiter=20access=20+=20ICD=20upload?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wochenübersicht exports now persisted in DB (WeeklyReport) + disk - POST /reports/wochenuebersicht/generate replaces GET (admin-only) - POST /reports/wochenuebersicht/upload-icd for ICD upload (all roles) - GET /reports/list supports report_type_prefix filter - WochenuebersichtPage: report table + ICD drag-drop upload for all roles - Route + sidebar open to all authenticated users - ReportsPage filters out wochenuebersicht report types Co-Authored-By: Claude Opus 4.6 --- backend/app/api/reports.py | 133 ++++- ...-wochenuebersicht-persist-upload-design.md | 49 ++ frontend/src/App.tsx | 2 +- frontend/src/components/layout/Sidebar.tsx | 2 +- frontend/src/hooks/useReports.ts | 37 +- frontend/src/pages/ReportsPage.tsx | 6 +- frontend/src/pages/WochenuebersichtPage.tsx | 488 ++++++++++++++---- 7 files changed, 584 insertions(+), 133 deletions(-) create mode 100644 docs/plans/2026-02-27-wochenuebersicht-persist-upload-design.md diff --git a/backend/app/api/reports.py b/backend/app/api/reports.py index e94ec0a..12f69b7 100644 --- a/backend/app/api/reports.py +++ b/backend/app/api/reports.py @@ -6,7 +6,7 @@ from collections import defaultdict from datetime import date from io import BytesIO -from fastapi import APIRouter, Depends, HTTPException, Query, Request, status +from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile, status from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session @@ -284,8 +284,8 @@ def delete_reports( return {"deleted": deleted} -@router.get("/wochenuebersicht") -def download_wochenuebersicht( +@router.post("/wochenuebersicht/generate", response_model=ReportMeta) +def generate_wochenuebersicht( export_type: str = Query(...), jahr: int = Query(...), kw_von: int = Query(...), @@ -293,9 +293,9 @@ def download_wochenuebersicht( db: Session = Depends(get_db), user: User = Depends(require_admin), ): - """Generate and download a Wochenübersicht Excel file for DAK. + """Generate a Wochenübersicht Excel file and persist it to disk + DB. - Admin only. Returns an .xlsx file with weekly case summaries grouped by KW. + Admin only. Creates a WeeklyReport entry so DAK-Mitarbeiter can download it later. """ from app.config import get_settings from app.models.case import Case @@ -335,42 +335,145 @@ def download_wochenuebersicht( xlsx_bytes = generate_wochenuebersicht_xlsx(cases_by_kw, export_type, jahr) + # Persist Excel file to disk + reports_dir = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(__file__))), + "reports", + ) + os.makedirs(reports_dir, exist_ok=True) infix = type_cfg["filename_infix"] - filename = f"Wochenübersicht {infix} KW{kw_bis:02d}{jahr % 100:02d}.xlsx" + filename = f"Wochenübersicht {infix} KW{kw_von:02d}-{kw_bis:02d}_{jahr}.xlsx" + filepath = os.path.join(reports_dir, filename) + with open(filepath, "wb") as f: + f.write(xlsx_bytes) + + # report_type encodes both the wochenuebersicht prefix and the export variant + report_type = f"wochenuebersicht_{export_type}" + + # Upsert: replace if same jahr/kw_bis/report_type exists + report = ( + db.query(WeeklyReport) + .filter( + WeeklyReport.jahr == jahr, + WeeklyReport.kw == kw_bis, + WeeklyReport.report_type == report_type, + ) + .first() + ) + report_data = { + "export_type": export_type, + "kw_von": kw_von, + "kw_bis": kw_bis, + "case_count": len(cases), + } + if report: + report.report_date = date.today() + report.report_data = report_data + report.report_file_path = filepath + report.generated_by = user.id + else: + report = WeeklyReport( + jahr=jahr, + kw=kw_bis, + report_type=report_type, + report_date=date.today(), + report_data=report_data, + generated_by=user.id, + ) + report.report_file_path = filepath + db.add(report) + + db.commit() + db.refresh(report) log_action( db, user_id=user.id, - action="wochenuebersicht_downloaded", + action="wochenuebersicht_generated", entity_type="report", + entity_id=report.id, new_values={ "export_type": export_type, "jahr": jahr, "kw_von": kw_von, "kw_bis": kw_bis, "case_count": len(cases), + "filename": filename, }, ) - return StreamingResponse( - BytesIO(xlsx_bytes), - media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + return ReportMeta.model_validate(report) + + +@router.post("/wochenuebersicht/upload-icd") +async def upload_wochenuebersicht_icd( + file: UploadFile = File(...), + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): + """Upload a filled-in Wochenübersicht Excel with ICD codes. + + Accessible to both admin and dak_mitarbeiter users. + Auto-detects the format and imports ICD codes by KVNR matching. + """ + from app.config import get_settings + from app.services.icd_service import import_icd_from_xlsx + + settings = get_settings() + + if not file.filename or not file.filename.lower().endswith((".xlsx", ".xls")): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Datei muss eine Excel-Datei (.xlsx) sein", + ) + + content = await file.read() + if len(content) > settings.MAX_UPLOAD_SIZE: + raise HTTPException( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + detail=f"Datei überschreitet die maximale Größe von {settings.MAX_UPLOAD_SIZE // (1024 * 1024)}MB", + ) + + try: + result = import_icd_from_xlsx(db, content, user.id) + except Exception as exc: + logger.exception("Wochenübersicht ICD import failed for %s", file.filename) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"ICD-Import fehlgeschlagen: {exc}", + ) + + log_action( + db, + user_id=user.id, + action="wochenuebersicht_icd_uploaded", + entity_type="import", + new_values={ + "filename": file.filename, + "updated": result["updated"], + "errors": len(result["errors"]), + }, ) + return result + @router.get("/list", response_model=ReportListResponse) def list_reports( + report_type_prefix: str | None = Query(None), db: Session = Depends(get_db), user: User = Depends(get_current_user), ): - """List all generated reports, newest first. + """List generated reports, newest first. Accessible to both admin and dak_mitarbeiter users. + Optional *report_type_prefix* filter (e.g. ``wochenuebersicht`` to show + only Wochenübersicht exports, or omit to show all). """ - reports = ( - db.query(WeeklyReport).order_by(WeeklyReport.generated_at.desc()).all() - ) + query = db.query(WeeklyReport) + if report_type_prefix: + query = query.filter(WeeklyReport.report_type.startswith(report_type_prefix)) + reports = query.order_by(WeeklyReport.generated_at.desc()).all() return ReportListResponse( items=[ReportMeta.model_validate(r) for r in reports], total=len(reports), diff --git a/docs/plans/2026-02-27-wochenuebersicht-persist-upload-design.md b/docs/plans/2026-02-27-wochenuebersicht-persist-upload-design.md new file mode 100644 index 0000000..2c7fb82 --- /dev/null +++ b/docs/plans/2026-02-27-wochenuebersicht-persist-upload-design.md @@ -0,0 +1,49 @@ +# Wochenübersicht: Persistierung + DAK-Mitarbeiter-Zugang + ICD-Upload + +**Datum:** 2026-02-27 +**Status:** Genehmigt + +## Ziel + +1. Wochenübersicht-Exporte in DB persistieren (wie Berichtswesen-Reports) +2. DAK-Mitarbeiter können generierte Exports herunterladen +3. DAK-Mitarbeiter können ausgefüllte ICD-Excel-Dateien hochladen + +## Backend-Änderungen + +### 1. Wochenübersicht generieren + persistieren + +- `GET /reports/wochenuebersicht` → `POST /reports/wochenuebersicht/generate` (admin-only) +- Speichert Excel auf Disk + `WeeklyReport`-Eintrag in DB +- `report_type`: `"wochenuebersicht_c2s"` oder `"wochenuebersicht_c2s_g_s"` +- Upsert-Verhalten wie bei Berichtswesen + +### 2. Neuer ICD-Upload Endpoint + +- `POST /reports/wochenuebersicht/upload-icd` (alle authentifizierten User) +- Nimmt Excel-Datei entgegen, nutzt `import_icd_from_xlsx()` mit Auto-Detect +- Separater Endpoint vom admin-only `/import/icd-xlsx` + +### 3. Report-Liste filtern + +- `GET /reports/list` bekommt optionalen Query-Param `report_type_prefix` +- Frontend kann nach `wochenuebersicht` filtern + +## Frontend-Änderungen + +### WochenuebersichtPage + +- **Admin:** Generierungsformular (persistiert jetzt) +- **Alle:** Tabelle "Bisherige Wochenübersichten" mit Download +- **Alle:** Upload-Box für ICD-Excel + +### Route + Sidebar + +- `/wochenuebersicht`: nicht mehr admin-only +- Sidebar: `adminOnly` entfernen + +## Wiederverwendung + +- WeeklyReport-Model unverändert +- Download über bestehenden `/reports/download/{report_id}` +- ICD-Import-Logik über bestehende `import_icd_from_xlsx()` diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d10e539..fac4bcf 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -46,7 +46,7 @@ function App() { } /> } /> } /> - } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index f73c6cb..21c5fe0 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -33,7 +33,7 @@ const mainNavItems: NavItem[] = [ { label: 'ICD-Eingabe', to: '/icd', icon: FileEdit, mitarbeiterOnly: true }, { label: 'Coding', to: '/coding', icon: ClipboardCheck, adminOnly: true }, { label: 'Berichte', to: '/reports', icon: FileBarChart }, - { label: 'Wochenübersicht', to: '/wochenuebersicht', icon: FileOutput, adminOnly: true }, + { label: 'Wochenübersicht', to: '/wochenuebersicht', icon: FileOutput }, ] const accountNavItems: NavItem[] = [ diff --git a/frontend/src/hooks/useReports.ts b/frontend/src/hooks/useReports.ts index 8dc41e2..073e5c5 100644 --- a/frontend/src/hooks/useReports.ts +++ b/frontend/src/hooks/useReports.ts @@ -1,17 +1,19 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import api from '@/services/api' -import type { ReportMeta } from '@/types' +import type { ReportMeta, ICDImportResponse } from '@/types' interface ReportsListResponse { items: ReportMeta[] total: number } -export function useReports() { +export function useReports(reportTypePrefix?: string) { return useQuery({ - queryKey: ['reports'], + queryKey: ['reports', reportTypePrefix ?? 'all'], queryFn: () => - api.get('/reports/list').then(r => r.data), + api.get('/reports/list', { + params: reportTypePrefix ? { report_type_prefix: reportTypePrefix } : undefined, + }).then(r => r.data), }) } @@ -26,6 +28,33 @@ export function useGenerateReport() { }) } +export function useGenerateWochenuebersicht() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (params: { export_type: string; jahr: number; kw_von: number; kw_bis: number }) => + api.post('/reports/wochenuebersicht/generate', null, { params }).then(r => r.data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['reports'] }) + }, + }) +} + +export function useUploadWochenuebersichtIcd() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (file: File) => { + const formData = new FormData() + formData.append('file', file) + return api.post('/reports/wochenuebersicht/upload-icd', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }).then(r => r.data) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['cases'] }) + }, + }) +} + export function useDeleteReports() { const queryClient = useQueryClient() return useMutation({ diff --git a/frontend/src/pages/ReportsPage.tsx b/frontend/src/pages/ReportsPage.tsx index bb9a433..3c76a8e 100644 --- a/frontend/src/pages/ReportsPage.tsx +++ b/frontend/src/pages/ReportsPage.tsx @@ -35,10 +35,10 @@ export function ReportsPage() { const currentYear = new Date().getFullYear() const currentKw = getISOWeek(new Date()) - // TanStack Query hooks + // TanStack Query hooks — exclude Wochenübersicht reports (shown on separate page) const { data, isLoading: loading } = useReports() - const reports = data?.items ?? [] - const totalReports = data?.total ?? 0 + const reports = (data?.items ?? []).filter(r => !r.report_type.startsWith('wochenuebersicht')) + const totalReports = reports.length const generateMutation = useGenerateReport() const deleteMutation = useDeleteReports() diff --git a/frontend/src/pages/WochenuebersichtPage.tsx b/frontend/src/pages/WochenuebersichtPage.tsx index 8e1e79a..dc1d37c 100644 --- a/frontend/src/pages/WochenuebersichtPage.tsx +++ b/frontend/src/pages/WochenuebersichtPage.tsx @@ -1,6 +1,13 @@ -import { useState } from 'react' -import { Download, FileSpreadsheet, Loader2 } from 'lucide-react' +import { useCallback, useState } from 'react' +import { Download, FileSpreadsheet, Loader2, Plus, Upload } from 'lucide-react' import api from '@/services/api' +import { useAuth } from '@/context/AuthContext' +import { + useReports, + useGenerateWochenuebersicht, + useDeleteReports, + useUploadWochenuebersichtIcd, +} from '@/hooks/useReports' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Input } from '@/components/ui/input' @@ -8,6 +15,10 @@ import { Label } from '@/components/ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' +import { + Table, TableBody, TableCell, TableHead, TableHeader, TableRow, +} from '@/components/ui/table' +import { Skeleton } from '@/components/ui/skeleton' import { Alert, AlertDescription } from '@/components/ui/alert' const EXPORT_TYPES = [ @@ -15,6 +26,11 @@ const EXPORT_TYPES = [ { value: 'c2s_g_s', label: 'Galle-Schild (c2s_g_s)' }, ] as const +const REPORT_TYPE_LABELS: Record = { + wochenuebersicht_c2s: 'Onko-Intensiv', + 'wochenuebersicht_c2s_g_s': 'Galle-Schild', +} + function getISOWeek(d: Date): number { const date = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())) const dayNum = date.getUTCDay() || 7 @@ -23,151 +39,405 @@ function getISOWeek(d: Date): number { return Math.ceil(((date.getTime() - yearStart.getTime()) / 86_400_000 + 1) / 7) } +function formatDate(dateStr: string): string { + try { + return new Date(dateStr).toLocaleDateString('de-DE', { + day: '2-digit', month: '2-digit', year: 'numeric', + }) + } catch { + return dateStr + } +} + +function formatDateTime(dateStr: string): string { + try { + return new Date(dateStr).toLocaleString('de-DE', { + day: '2-digit', month: '2-digit', year: 'numeric', + hour: '2-digit', minute: '2-digit', + }) + } catch { + return dateStr + } +} + export function WochenuebersichtPage() { + const { isAdmin } = useAuth() const currentYear = new Date().getFullYear() const currentKw = getISOWeek(new Date()) + // Report list (only Wochenübersicht reports) + const { data, isLoading: listLoading } = useReports('wochenuebersicht') + const reports = data?.items ?? [] + + // Generation form state (admin only) const [jahr, setJahr] = useState(currentYear) const [kwVon, setKwVon] = useState(1) const [kwBis, setKwBis] = useState(currentKw) const [exportType, setExportType] = useState('c2s') - const [loading, setLoading] = useState(false) - const [error, setError] = useState('') - const [success, setSuccess] = useState('') + const [genError, setGenError] = useState('') + const [genSuccess, setGenSuccess] = useState('') - const handleDownload = async () => { - setError('') - setSuccess('') + const generateMutation = useGenerateWochenuebersicht() + const deleteMutation = useDeleteReports() + // ICD Upload state + const [uploadFile, setUploadFile] = useState(null) + const [uploadError, setUploadError] = useState('') + const [uploadSuccess, setUploadSuccess] = useState('') + const uploadMutation = useUploadWochenuebersichtIcd() + + // Drag state + const [isDragOver, setIsDragOver] = useState(false) + + const handleGenerate = async () => { + setGenError('') + setGenSuccess('') if (kwVon > kwBis) { - setError('KW von darf nicht größer als KW bis sein.') + setGenError('KW von darf nicht größer als KW bis sein.') return } - - setLoading(true) try { - const res = await api.get('/reports/wochenuebersicht', { - params: { export_type: exportType, jahr, kw_von: kwVon, kw_bis: kwBis }, - responseType: 'blob', + const result = await generateMutation.mutateAsync({ + export_type: exportType, jahr, kw_von: kwVon, kw_bis: kwBis, }) - - const blob = new Blob([res.data as BlobPart], { - type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - }) - const blobUrl = window.URL.createObjectURL(blob) - const link = document.createElement('a') - link.href = blobUrl - - const contentDisposition = res.headers['content-disposition'] - let filename = `Wochenuebersicht_${exportType}_KW${kwBis}_${jahr}.xlsx` - if (contentDisposition) { - const match = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/) - if (match) filename = match[1].replace(/['"]/g, '') - } - - link.download = filename - document.body.appendChild(link) - link.click() - document.body.removeChild(link) - window.URL.revokeObjectURL(blobUrl) - setSuccess(`Wochenübersicht KW ${kwVon}–${kwBis}/${jahr} wurde heruntergeladen.`) + const typeLabel = REPORT_TYPE_LABELS[result.report_type] ?? result.report_type + setGenSuccess(`Wochenübersicht "${typeLabel}" KW ${kwVon}–${kwBis}/${jahr} wurde generiert.`) } catch { - setError('Fehler beim Herunterladen der Wochenübersicht.') - } finally { - setLoading(false) + setGenError('Fehler beim Generieren der Wochenübersicht.') } } + const downloadReport = (reportId: number) => { + api.get(`/reports/download/${reportId}`, { responseType: 'blob' }) + .then((res) => { + const blob = new Blob([res.data as BlobPart], { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }) + const blobUrl = window.URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = blobUrl + const contentDisposition = res.headers['content-disposition'] + let filename = `wochenuebersicht_${reportId}.xlsx` + if (contentDisposition) { + const match = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/) + if (match) filename = match[1].replace(/['"]/g, '') + } + link.download = filename + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + window.URL.revokeObjectURL(blobUrl) + }) + .catch(() => { + const token = localStorage.getItem('access_token') + if (token) { + window.open(`/api/reports/download/${reportId}?token=${encodeURIComponent(token)}`, '_blank') + } + }) + } + + const handleUpload = async () => { + if (!uploadFile) return + setUploadError('') + setUploadSuccess('') + try { + const result = await uploadMutation.mutateAsync(uploadFile) + const errorCount = result.errors?.length ?? 0 + setUploadSuccess( + `${result.updated} ICD-Codes aktualisiert.` + + (errorCount > 0 ? ` ${errorCount} Fehler.` : ''), + ) + setUploadFile(null) + } catch { + setUploadError('Fehler beim Hochladen der ICD-Datei.') + } + } + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault() + setIsDragOver(false) + const file = e.dataTransfer.files[0] + if (file && (file.name.endsWith('.xlsx') || file.name.endsWith('.xls'))) { + setUploadFile(file) + } + }, []) + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault() + setIsDragOver(true) + }, []) + + const handleDragLeave = useCallback(() => { + setIsDragOver(false) + }, []) + return (

Wochenübersicht

+ {/* Generation form (admin only) */} + {isAdmin && ( + + + + + Wochenübersicht generieren + + + +

+ Erstellt eine Excel-Datei mit den Falldaten pro Kalenderwoche, + die an die DAK zur ICD-Code-Eingabe gesendet wird. +

+ +
+
+ + setJahr(Number(e.target.value))} + className="w-28" + min={2020} + max={2030} + /> +
+
+ + setKwVon(Number(e.target.value))} + className="w-24" + min={1} + max={53} + /> +
+
+ + setKwBis(Number(e.target.value))} + className="w-24" + min={1} + max={53} + /> +
+
+ + +
+ +
+ + {genError && ( + + {genError} + + )} + {genSuccess && ( + + {genSuccess} + + )} +
+
+ )} + + {/* Reports table (all roles) */} + + + + Bisherige Wochenübersichten ({reports.length}) + + + + {listLoading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ ) : reports.length > 0 ? ( + + + + Berichtsdatum + Jahr + KW + Typ + Erstellt am + Aktion + + + + {reports.map((r) => ( + + {formatDate(r.report_date)} + {r.jahr} + KW {r.kw} + {REPORT_TYPE_LABELS[r.report_type] ?? r.report_type} + {formatDateTime(r.generated_at)} + + + {isAdmin && ( + + )} + + + ))} + +
+ ) : ( +

+ Keine Wochenübersichten vorhanden. +

+ )} +
+
+ + {/* ICD Upload (all roles) */} - - Wochenübersicht für die DAK generieren + + ICD-Codes hochladen

- Erstellt eine Excel-Datei mit den Falldaten pro Kalenderwoche, - die an die DAK zur ICD-Code-Eingabe gesendet wird. + Laden Sie die ausgefüllte Wochenübersicht mit den ergänzten ICD-Codes hoch. + Die ICD-Codes werden automatisch den Fällen anhand der KVNR zugeordnet.

-
-
- - setJahr(Number(e.target.value))} - className="w-28" - min={2020} - max={2030} - /> -
-
- - setKwVon(Number(e.target.value))} - className="w-24" - min={1} - max={53} - /> -
-
- - setKwBis(Number(e.target.value))} - className="w-24" - min={1} - max={53} - /> -
-
- - -
- +
+ {uploadFile ? ( +
+

{uploadFile.name}

+

+ {(uploadFile.size / 1024).toFixed(1)} KB +

+
+ + +
+
+ ) : ( +
+

+ Excel-Datei hierher ziehen oder +

+ +
+ )}
- {error && ( + {uploadError && ( - {error} + {uploadError} )} - {success && ( + {uploadSuccess && ( - {success} + {uploadSuccess} + + )} + {uploadMutation.data && (uploadMutation.data.errors?.length ?? 0) > 0 && ( + + +
+ + {uploadMutation.data.errors.length} Fehler beim Import + +
    + {uploadMutation.data.errors.map((err, i) => ( +
  • {err}
  • + ))} +
+
+
)}