mirror of
https://github.com/complexcaresolutions/dak.c2s.git
synced 2026-03-17 16:03:41 +00:00
feat: persist Wochenübersicht exports, DAK-Mitarbeiter access + ICD upload
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
6f6a721973
commit
efeb619b06
7 changed files with 584 additions and 133 deletions
|
|
@ -6,7 +6,7 @@ from collections import defaultdict
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from io import BytesIO
|
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 fastapi.responses import StreamingResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
|
@ -284,8 +284,8 @@ def delete_reports(
|
||||||
return {"deleted": deleted}
|
return {"deleted": deleted}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/wochenuebersicht")
|
@router.post("/wochenuebersicht/generate", response_model=ReportMeta)
|
||||||
def download_wochenuebersicht(
|
def generate_wochenuebersicht(
|
||||||
export_type: str = Query(...),
|
export_type: str = Query(...),
|
||||||
jahr: int = Query(...),
|
jahr: int = Query(...),
|
||||||
kw_von: int = Query(...),
|
kw_von: int = Query(...),
|
||||||
|
|
@ -293,9 +293,9 @@ def download_wochenuebersicht(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
user: User = Depends(require_admin),
|
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.config import get_settings
|
||||||
from app.models.case import Case
|
from app.models.case import Case
|
||||||
|
|
@ -335,42 +335,145 @@ def download_wochenuebersicht(
|
||||||
|
|
||||||
xlsx_bytes = generate_wochenuebersicht_xlsx(cases_by_kw, export_type, jahr)
|
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"]
|
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(
|
log_action(
|
||||||
db,
|
db,
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
action="wochenuebersicht_downloaded",
|
action="wochenuebersicht_generated",
|
||||||
entity_type="report",
|
entity_type="report",
|
||||||
|
entity_id=report.id,
|
||||||
new_values={
|
new_values={
|
||||||
"export_type": export_type,
|
"export_type": export_type,
|
||||||
"jahr": jahr,
|
"jahr": jahr,
|
||||||
"kw_von": kw_von,
|
"kw_von": kw_von,
|
||||||
"kw_bis": kw_bis,
|
"kw_bis": kw_bis,
|
||||||
"case_count": len(cases),
|
"case_count": len(cases),
|
||||||
|
"filename": filename,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
return StreamingResponse(
|
return ReportMeta.model_validate(report)
|
||||||
BytesIO(xlsx_bytes),
|
|
||||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
||||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
@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)
|
@router.get("/list", response_model=ReportListResponse)
|
||||||
def list_reports(
|
def list_reports(
|
||||||
|
report_type_prefix: str | None = Query(None),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
user: User = Depends(get_current_user),
|
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.
|
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 = (
|
query = db.query(WeeklyReport)
|
||||||
db.query(WeeklyReport).order_by(WeeklyReport.generated_at.desc()).all()
|
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(
|
return ReportListResponse(
|
||||||
items=[ReportMeta.model_validate(r) for r in reports],
|
items=[ReportMeta.model_validate(r) for r in reports],
|
||||||
total=len(reports),
|
total=len(reports),
|
||||||
|
|
|
||||||
|
|
@ -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()`
|
||||||
|
|
@ -46,7 +46,7 @@ function App() {
|
||||||
<Route path="icd" element={<IcdPage />} />
|
<Route path="icd" element={<IcdPage />} />
|
||||||
<Route path="coding" element={<ProtectedRoute requireAdmin><CodingPage /></ProtectedRoute>} />
|
<Route path="coding" element={<ProtectedRoute requireAdmin><CodingPage /></ProtectedRoute>} />
|
||||||
<Route path="reports" element={<ReportsPage />} />
|
<Route path="reports" element={<ReportsPage />} />
|
||||||
<Route path="wochenuebersicht" element={<ProtectedRoute requireAdmin><WochenuebersichtPage /></ProtectedRoute>} />
|
<Route path="wochenuebersicht" element={<WochenuebersichtPage />} />
|
||||||
<Route path="gutachten-statistik" element={<GutachtenStatistikPage />} />
|
<Route path="gutachten-statistik" element={<GutachtenStatistikPage />} />
|
||||||
<Route path="account" element={<AccountPage />} />
|
<Route path="account" element={<AccountPage />} />
|
||||||
<Route path="disclosures" element={<MyDisclosuresPage />} />
|
<Route path="disclosures" element={<MyDisclosuresPage />} />
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ const mainNavItems: NavItem[] = [
|
||||||
{ label: 'ICD-Eingabe', to: '/icd', icon: FileEdit, mitarbeiterOnly: true },
|
{ label: 'ICD-Eingabe', to: '/icd', icon: FileEdit, mitarbeiterOnly: true },
|
||||||
{ label: 'Coding', to: '/coding', icon: ClipboardCheck, adminOnly: true },
|
{ label: 'Coding', to: '/coding', icon: ClipboardCheck, adminOnly: true },
|
||||||
{ label: 'Berichte', to: '/reports', icon: FileBarChart },
|
{ 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[] = [
|
const accountNavItems: NavItem[] = [
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,19 @@
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import api from '@/services/api'
|
import api from '@/services/api'
|
||||||
import type { ReportMeta } from '@/types'
|
import type { ReportMeta, ICDImportResponse } from '@/types'
|
||||||
|
|
||||||
interface ReportsListResponse {
|
interface ReportsListResponse {
|
||||||
items: ReportMeta[]
|
items: ReportMeta[]
|
||||||
total: number
|
total: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useReports() {
|
export function useReports(reportTypePrefix?: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['reports'],
|
queryKey: ['reports', reportTypePrefix ?? 'all'],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
api.get<ReportsListResponse>('/reports/list').then(r => r.data),
|
api.get<ReportsListResponse>('/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<ReportMeta>('/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<ICDImportResponse>('/reports/wochenuebersicht/upload-icd', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
}).then(r => r.data)
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['cases'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function useDeleteReports() {
|
export function useDeleteReports() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|
|
||||||
|
|
@ -35,10 +35,10 @@ export function ReportsPage() {
|
||||||
const currentYear = new Date().getFullYear()
|
const currentYear = new Date().getFullYear()
|
||||||
const currentKw = getISOWeek(new Date())
|
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 { data, isLoading: loading } = useReports()
|
||||||
const reports = data?.items ?? []
|
const reports = (data?.items ?? []).filter(r => !r.report_type.startsWith('wochenuebersicht'))
|
||||||
const totalReports = data?.total ?? 0
|
const totalReports = reports.length
|
||||||
|
|
||||||
const generateMutation = useGenerateReport()
|
const generateMutation = useGenerateReport()
|
||||||
const deleteMutation = useDeleteReports()
|
const deleteMutation = useDeleteReports()
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,13 @@
|
||||||
import { useState } from 'react'
|
import { useCallback, useState } from 'react'
|
||||||
import { Download, FileSpreadsheet, Loader2 } from 'lucide-react'
|
import { Download, FileSpreadsheet, Loader2, Plus, Upload } from 'lucide-react'
|
||||||
import api from '@/services/api'
|
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 { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
|
|
@ -8,6 +15,10 @@ import { Label } from '@/components/ui/label'
|
||||||
import {
|
import {
|
||||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||||
} from '@/components/ui/select'
|
} 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'
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
|
|
||||||
const EXPORT_TYPES = [
|
const EXPORT_TYPES = [
|
||||||
|
|
@ -15,6 +26,11 @@ const EXPORT_TYPES = [
|
||||||
{ value: 'c2s_g_s', label: 'Galle-Schild (c2s_g_s)' },
|
{ value: 'c2s_g_s', label: 'Galle-Schild (c2s_g_s)' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
|
const REPORT_TYPE_LABELS: Record<string, string> = {
|
||||||
|
wochenuebersicht_c2s: 'Onko-Intensiv',
|
||||||
|
'wochenuebersicht_c2s_g_s': 'Galle-Schild',
|
||||||
|
}
|
||||||
|
|
||||||
function getISOWeek(d: Date): number {
|
function getISOWeek(d: Date): number {
|
||||||
const date = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()))
|
const date = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()))
|
||||||
const dayNum = date.getUTCDay() || 7
|
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)
|
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() {
|
export function WochenuebersichtPage() {
|
||||||
|
const { isAdmin } = useAuth()
|
||||||
const currentYear = new Date().getFullYear()
|
const currentYear = new Date().getFullYear()
|
||||||
const currentKw = getISOWeek(new Date())
|
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 [jahr, setJahr] = useState(currentYear)
|
||||||
const [kwVon, setKwVon] = useState(1)
|
const [kwVon, setKwVon] = useState(1)
|
||||||
const [kwBis, setKwBis] = useState(currentKw)
|
const [kwBis, setKwBis] = useState(currentKw)
|
||||||
const [exportType, setExportType] = useState('c2s')
|
const [exportType, setExportType] = useState('c2s')
|
||||||
const [loading, setLoading] = useState(false)
|
const [genError, setGenError] = useState('')
|
||||||
const [error, setError] = useState('')
|
const [genSuccess, setGenSuccess] = useState('')
|
||||||
const [success, setSuccess] = useState('')
|
|
||||||
|
|
||||||
const handleDownload = async () => {
|
const generateMutation = useGenerateWochenuebersicht()
|
||||||
setError('')
|
const deleteMutation = useDeleteReports()
|
||||||
setSuccess('')
|
|
||||||
|
|
||||||
|
// ICD Upload state
|
||||||
|
const [uploadFile, setUploadFile] = useState<File | null>(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) {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true)
|
|
||||||
try {
|
try {
|
||||||
const res = await api.get('/reports/wochenuebersicht', {
|
const result = await generateMutation.mutateAsync({
|
||||||
params: { export_type: exportType, jahr, kw_von: kwVon, kw_bis: kwBis },
|
export_type: exportType, jahr, kw_von: kwVon, kw_bis: kwBis,
|
||||||
responseType: 'blob',
|
|
||||||
})
|
})
|
||||||
|
const typeLabel = REPORT_TYPE_LABELS[result.report_type] ?? result.report_type
|
||||||
const blob = new Blob([res.data as BlobPart], {
|
setGenSuccess(`Wochenübersicht "${typeLabel}" KW ${kwVon}–${kwBis}/${jahr} wurde generiert.`)
|
||||||
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.`)
|
|
||||||
} catch {
|
} catch {
|
||||||
setError('Fehler beim Herunterladen der Wochenübersicht.')
|
setGenError('Fehler beim Generieren der Wochenübersicht.')
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
<h1 className="text-2xl font-bold">Wochenübersicht</h1>
|
<h1 className="text-2xl font-bold">Wochenübersicht</h1>
|
||||||
|
|
||||||
|
{/* Generation form (admin only) */}
|
||||||
|
{isAdmin && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<FileSpreadsheet className="h-5 w-5" />
|
||||||
|
Wochenübersicht generieren
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Erstellt eine Excel-Datei mit den Falldaten pro Kalenderwoche,
|
||||||
|
die an die DAK zur ICD-Code-Eingabe gesendet wird.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-end gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="wu-jahr">Jahr</Label>
|
||||||
|
<Input
|
||||||
|
id="wu-jahr"
|
||||||
|
type="number"
|
||||||
|
value={jahr}
|
||||||
|
onChange={(e) => setJahr(Number(e.target.value))}
|
||||||
|
className="w-28"
|
||||||
|
min={2020}
|
||||||
|
max={2030}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="wu-kw-von">KW von</Label>
|
||||||
|
<Input
|
||||||
|
id="wu-kw-von"
|
||||||
|
type="number"
|
||||||
|
value={kwVon}
|
||||||
|
onChange={(e) => setKwVon(Number(e.target.value))}
|
||||||
|
className="w-24"
|
||||||
|
min={1}
|
||||||
|
max={53}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="wu-kw-bis">KW bis</Label>
|
||||||
|
<Input
|
||||||
|
id="wu-kw-bis"
|
||||||
|
type="number"
|
||||||
|
value={kwBis}
|
||||||
|
onChange={(e) => setKwBis(Number(e.target.value))}
|
||||||
|
className="w-24"
|
||||||
|
min={1}
|
||||||
|
max={53}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Exporttyp</Label>
|
||||||
|
<Select value={exportType} onValueChange={setExportType}>
|
||||||
|
<SelectTrigger className="w-52">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{EXPORT_TYPES.map((t) => (
|
||||||
|
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleGenerate} disabled={generateMutation.isPending}>
|
||||||
|
{generateMutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Wird generiert...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Generieren
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{genError && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{genError}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{genSuccess && (
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>{genSuccess}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reports table (all roles) */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">
|
||||||
|
Bisherige Wochenübersichten ({reports.length})
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{listLoading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-10 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : reports.length > 0 ? (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Berichtsdatum</TableHead>
|
||||||
|
<TableHead>Jahr</TableHead>
|
||||||
|
<TableHead>KW</TableHead>
|
||||||
|
<TableHead>Typ</TableHead>
|
||||||
|
<TableHead>Erstellt am</TableHead>
|
||||||
|
<TableHead className="text-right">Aktion</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{reports.map((r) => (
|
||||||
|
<TableRow key={r.id}>
|
||||||
|
<TableCell>{formatDate(r.report_date)}</TableCell>
|
||||||
|
<TableCell>{r.jahr}</TableCell>
|
||||||
|
<TableCell>KW {r.kw}</TableCell>
|
||||||
|
<TableCell>{REPORT_TYPE_LABELS[r.report_type] ?? r.report_type}</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" />
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
{isAdmin && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="ml-2 text-destructive"
|
||||||
|
onClick={async () => {
|
||||||
|
await deleteMutation.mutateAsync([r.id])
|
||||||
|
}}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
) : (
|
||||||
|
<p className="py-8 text-center text-muted-foreground">
|
||||||
|
Keine Wochenübersichten vorhanden.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ICD Upload (all roles) */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-lg flex items-center gap-2">
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
<FileSpreadsheet className="h-5 w-5" />
|
<Upload className="h-5 w-5" />
|
||||||
Wochenübersicht für die DAK generieren
|
ICD-Codes hochladen
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Erstellt eine Excel-Datei mit den Falldaten pro Kalenderwoche,
|
Laden Sie die ausgefüllte Wochenübersicht mit den ergänzten ICD-Codes hoch.
|
||||||
die an die DAK zur ICD-Code-Eingabe gesendet wird.
|
Die ICD-Codes werden automatisch den Fällen anhand der KVNR zugeordnet.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-end gap-4">
|
<div
|
||||||
<div className="space-y-1">
|
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
|
||||||
<Label htmlFor="wu-jahr">Jahr</Label>
|
isDragOver
|
||||||
<Input
|
? 'border-primary bg-primary/5'
|
||||||
id="wu-jahr"
|
: 'border-muted-foreground/25 hover:border-muted-foreground/50'
|
||||||
type="number"
|
}`}
|
||||||
value={jahr}
|
onDrop={handleDrop}
|
||||||
onChange={(e) => setJahr(Number(e.target.value))}
|
onDragOver={handleDragOver}
|
||||||
className="w-28"
|
onDragLeave={handleDragLeave}
|
||||||
min={2020}
|
>
|
||||||
max={2030}
|
{uploadFile ? (
|
||||||
/>
|
<div className="space-y-2">
|
||||||
</div>
|
<p className="text-sm font-medium">{uploadFile.name}</p>
|
||||||
<div className="space-y-1">
|
<p className="text-xs text-muted-foreground">
|
||||||
<Label htmlFor="wu-kw-von">KW von</Label>
|
{(uploadFile.size / 1024).toFixed(1)} KB
|
||||||
<Input
|
</p>
|
||||||
id="wu-kw-von"
|
<div className="flex justify-center gap-2">
|
||||||
type="number"
|
<Button size="sm" onClick={handleUpload} disabled={uploadMutation.isPending}>
|
||||||
value={kwVon}
|
{uploadMutation.isPending ? (
|
||||||
onChange={(e) => setKwVon(Number(e.target.value))}
|
<>
|
||||||
className="w-24"
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
min={1}
|
Wird importiert...
|
||||||
max={53}
|
</>
|
||||||
/>
|
) : (
|
||||||
</div>
|
<>
|
||||||
<div className="space-y-1">
|
<Upload className="mr-2 h-4 w-4" />
|
||||||
<Label htmlFor="wu-kw-bis">KW bis</Label>
|
Hochladen
|
||||||
<Input
|
</>
|
||||||
id="wu-kw-bis"
|
)}
|
||||||
type="number"
|
</Button>
|
||||||
value={kwBis}
|
<Button
|
||||||
onChange={(e) => setKwBis(Number(e.target.value))}
|
size="sm"
|
||||||
className="w-24"
|
variant="outline"
|
||||||
min={1}
|
onClick={() => setUploadFile(null)}
|
||||||
max={53}
|
>
|
||||||
/>
|
Abbrechen
|
||||||
</div>
|
</Button>
|
||||||
<div className="space-y-1">
|
</div>
|
||||||
<Label>Exporttyp</Label>
|
</div>
|
||||||
<Select value={exportType} onValueChange={setExportType}>
|
) : (
|
||||||
<SelectTrigger className="w-52">
|
<div className="space-y-2">
|
||||||
<SelectValue />
|
<p className="text-sm text-muted-foreground">
|
||||||
</SelectTrigger>
|
Excel-Datei hierher ziehen oder
|
||||||
<SelectContent>
|
</p>
|
||||||
{EXPORT_TYPES.map((t) => (
|
<label>
|
||||||
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
|
<input
|
||||||
))}
|
type="file"
|
||||||
</SelectContent>
|
accept=".xlsx,.xls"
|
||||||
</Select>
|
className="hidden"
|
||||||
</div>
|
onChange={(e) => {
|
||||||
<Button onClick={handleDownload} disabled={loading}>
|
const file = e.target.files?.[0]
|
||||||
{loading ? (
|
if (file) setUploadFile(file)
|
||||||
<>
|
}}
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
/>
|
||||||
Wird generiert...
|
<Button variant="outline" size="sm" asChild>
|
||||||
</>
|
<span>Datei auswählen</span>
|
||||||
) : (
|
</Button>
|
||||||
<>
|
</label>
|
||||||
<Download className="mr-2 h-4 w-4" />
|
</div>
|
||||||
Herunterladen
|
)}
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{uploadError && (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertDescription>{error}</AlertDescription>
|
<AlertDescription>{uploadError}</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
{success && (
|
{uploadSuccess && (
|
||||||
<Alert>
|
<Alert>
|
||||||
<AlertDescription>{success}</AlertDescription>
|
<AlertDescription>{uploadSuccess}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{uploadMutation.data && (uploadMutation.data.errors?.length ?? 0) > 0 && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>
|
||||||
|
<details>
|
||||||
|
<summary className="cursor-pointer">
|
||||||
|
{uploadMutation.data.errors.length} Fehler beim Import
|
||||||
|
</summary>
|
||||||
|
<ul className="mt-2 text-xs space-y-1">
|
||||||
|
{uploadMutation.data.errors.map((err, i) => (
|
||||||
|
<li key={i}>{err}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue