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

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="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 />} />

View file

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

View file

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

View file

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

View file

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