mirror of
https://github.com/complexcaresolutions/dak.c2s.git
synced 2026-03-17 14:53: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 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),
|
||||
|
|
|
|||
|
|
@ -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="coding" element={<ProtectedRoute requireAdmin><CodingPage /></ProtectedRoute>} />
|
||||
<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="account" element={<AccountPage />} />
|
||||
<Route path="disclosures" element={<MyDisclosuresPage />} />
|
||||
|
|
|
|||
|
|
@ -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[] = [
|
||||
|
|
|
|||
|
|
@ -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<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() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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<string, string> = {
|
||||
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<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) {
|
||||
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 (
|
||||
<div className="p-6 space-y-6">
|
||||
<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>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<FileSpreadsheet className="h-5 w-5" />
|
||||
Wochenübersicht für die DAK generieren
|
||||
<Upload className="h-5 w-5" />
|
||||
ICD-Codes hochladen
|
||||
</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.
|
||||
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.
|
||||
</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={handleDownload} disabled={loading}>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Wird generiert...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Herunterladen
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
|
||||
isDragOver
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-muted-foreground/25 hover:border-muted-foreground/50'
|
||||
}`}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
>
|
||||
{uploadFile ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">{uploadFile.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{(uploadFile.size / 1024).toFixed(1)} KB
|
||||
</p>
|
||||
<div className="flex justify-center gap-2">
|
||||
<Button size="sm" onClick={handleUpload} disabled={uploadMutation.isPending}>
|
||||
{uploadMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Wird importiert...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
Hochladen
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setUploadFile(null)}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Excel-Datei hierher ziehen oder
|
||||
</p>
|
||||
<label>
|
||||
<input
|
||||
type="file"
|
||||
accept=".xlsx,.xls"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) setUploadFile(file)
|
||||
}}
|
||||
/>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<span>Datei auswählen</span>
|
||||
</Button>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
{uploadError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
<AlertDescription>{uploadError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{success && (
|
||||
{uploadSuccess && (
|
||||
<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>
|
||||
)}
|
||||
</CardContent>
|
||||
|
|
|
|||
Loading…
Reference in a new issue