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:
CCS Admin 2026-02-27 13:51:14 +00:00
parent 6f6a721973
commit efeb619b06
7 changed files with 584 additions and 133 deletions

View file

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

View file

@ -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()`

View file

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

View file

@ -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[] = [

View file

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

View file

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

View file

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