mirror of
https://github.com/complexcaresolutions/dak.c2s.git
synced 2026-03-17 16:03:41 +00:00
feat: add Wochenübersicht export + ICD import auto-detect
- New Excel export service for weekly DAK summary sheets (c2s / c2s_g_s variants) - New API endpoint GET /reports/wochenuebersicht (admin-only) - ICD import auto-detects format (coding template vs. Wochenübersicht KVNR-based) - New admin frontend page with download form - Route + sidebar navigation entry Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
48939f01dd
commit
6f6a721973
6 changed files with 548 additions and 2 deletions
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
from collections import defaultdict
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
|
||||||
|
|
@ -283,6 +284,81 @@ def delete_reports(
|
||||||
return {"deleted": deleted}
|
return {"deleted": deleted}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/wochenuebersicht")
|
||||||
|
def download_wochenuebersicht(
|
||||||
|
export_type: str = Query(...),
|
||||||
|
jahr: int = Query(...),
|
||||||
|
kw_von: int = Query(...),
|
||||||
|
kw_bis: int = Query(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_admin),
|
||||||
|
):
|
||||||
|
"""Generate and download a Wochenübersicht Excel file for DAK.
|
||||||
|
|
||||||
|
Admin only. Returns an .xlsx file with weekly case summaries grouped by KW.
|
||||||
|
"""
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.models.case import Case
|
||||||
|
from app.services.wochenuebersicht_export import (
|
||||||
|
WOCHENUEBERSICHT_TYPES,
|
||||||
|
generate_wochenuebersicht_xlsx,
|
||||||
|
)
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
if export_type not in WOCHENUEBERSICHT_TYPES:
|
||||||
|
raise HTTPException(422, f"Unknown export_type: {export_type}")
|
||||||
|
if not (1 <= kw_von <= 53 and 1 <= kw_bis <= 53):
|
||||||
|
raise HTTPException(422, "kw_von and kw_bis must be between 1 and 53")
|
||||||
|
if kw_von > kw_bis:
|
||||||
|
raise HTTPException(422, "kw_von must be <= kw_bis")
|
||||||
|
|
||||||
|
type_cfg = WOCHENUEBERSICHT_TYPES[export_type]
|
||||||
|
fallgruppen = type_cfg["fallgruppen"]
|
||||||
|
|
||||||
|
cases = (
|
||||||
|
db.query(Case)
|
||||||
|
.filter(
|
||||||
|
Case.jahr == jahr,
|
||||||
|
Case.kw >= kw_von,
|
||||||
|
Case.kw <= kw_bis,
|
||||||
|
Case.fallgruppe.in_(fallgruppen),
|
||||||
|
Case.versicherung == settings.VERSICHERUNG_FILTER,
|
||||||
|
)
|
||||||
|
.order_by(Case.kw, Case.datum)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
cases_by_kw: dict[int, list[Case]] = defaultdict(list)
|
||||||
|
for case in cases:
|
||||||
|
cases_by_kw[case.kw].append(case)
|
||||||
|
|
||||||
|
xlsx_bytes = generate_wochenuebersicht_xlsx(cases_by_kw, export_type, jahr)
|
||||||
|
|
||||||
|
infix = type_cfg["filename_infix"]
|
||||||
|
filename = f"Wochenübersicht {infix} KW{kw_bis:02d}{jahr % 100:02d}.xlsx"
|
||||||
|
|
||||||
|
log_action(
|
||||||
|
db,
|
||||||
|
user_id=user.id,
|
||||||
|
action="wochenuebersicht_downloaded",
|
||||||
|
entity_type="report",
|
||||||
|
new_values={
|
||||||
|
"export_type": export_type,
|
||||||
|
"jahr": jahr,
|
||||||
|
"kw_von": kw_von,
|
||||||
|
"kw_bis": kw_bis,
|
||||||
|
"case_count": len(cases),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
BytesIO(xlsx_bytes),
|
||||||
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/list", response_model=ReportListResponse)
|
@router.get("/list", response_model=ReportListResponse)
|
||||||
def list_reports(
|
def list_reports(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
|
|
||||||
|
|
@ -143,14 +143,50 @@ def generate_coding_template(
|
||||||
|
|
||||||
|
|
||||||
def import_icd_from_xlsx(db: Session, content: bytes, user_id: int) -> dict:
|
def import_icd_from_xlsx(db: Session, content: bytes, user_id: int) -> dict:
|
||||||
"""Import ICD codes from a filled-in coding template Excel file.
|
"""Import ICD codes from an Excel file.
|
||||||
|
|
||||||
|
Auto-detects the format:
|
||||||
|
- Coding template: Column A contains numeric Case_IDs
|
||||||
|
- Wochenübersicht: Column A contains KVNR strings (letter prefix)
|
||||||
|
|
||||||
Expects columns: Case_ID (col 1), ICD (last col — col 5 or col 7)
|
|
||||||
Returns: {"updated": int, "errors": list[str]}
|
Returns: {"updated": int, "errors": list[str]}
|
||||||
"""
|
"""
|
||||||
wb = load_workbook(BytesIO(content), read_only=True)
|
wb = load_workbook(BytesIO(content), read_only=True)
|
||||||
ws = wb.active
|
ws = wb.active
|
||||||
|
|
||||||
|
# Detect format by scanning for first data row
|
||||||
|
detected_format = _detect_import_format(ws)
|
||||||
|
|
||||||
|
if detected_format == "wochenuebersicht":
|
||||||
|
return _import_icd_wochenuebersicht(db, ws, user_id)
|
||||||
|
|
||||||
|
# Default: Coding template (Case_ID-based)
|
||||||
|
return _import_icd_coding_template(db, ws, user_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_import_format(ws) -> str:
|
||||||
|
"""Detect whether the worksheet is a coding template or Wochenübersicht."""
|
||||||
|
for row in ws.iter_rows(min_row=1, max_row=20, values_only=True):
|
||||||
|
if not row or not row[0]:
|
||||||
|
continue
|
||||||
|
val = str(row[0]).strip()
|
||||||
|
# Skip header rows
|
||||||
|
if val.lower() in ("case_id", "kvnr", "kalenderwoche:"):
|
||||||
|
continue
|
||||||
|
# If it's a pure integer, it's a coding template (Case_ID)
|
||||||
|
try:
|
||||||
|
int(val)
|
||||||
|
return "coding_template"
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
# If it starts with a letter, it's likely a KVNR → Wochenübersicht
|
||||||
|
if val and val[0].isalpha():
|
||||||
|
return "wochenuebersicht"
|
||||||
|
return "coding_template"
|
||||||
|
|
||||||
|
|
||||||
|
def _import_icd_coding_template(db: Session, ws, user_id: int) -> dict:
|
||||||
|
"""Import ICD codes from a coding template (Case_ID in column A)."""
|
||||||
updated = 0
|
updated = 0
|
||||||
errors: list[str] = []
|
errors: list[str] = []
|
||||||
|
|
||||||
|
|
@ -180,3 +216,63 @@ def import_icd_from_xlsx(db: Session, content: bytes, user_id: int) -> dict:
|
||||||
errors.append(f"Case {case_id}: {e}")
|
errors.append(f"Case {case_id}: {e}")
|
||||||
|
|
||||||
return {"updated": updated, "errors": errors}
|
return {"updated": updated, "errors": errors}
|
||||||
|
|
||||||
|
|
||||||
|
def _import_icd_wochenuebersicht(db: Session, ws, user_id: int) -> dict:
|
||||||
|
"""Import ICD codes from a Wochenübersicht file (KVNR in col A, ICD in col I)."""
|
||||||
|
updated = 0
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
for row in ws.iter_rows(values_only=False):
|
||||||
|
# Skip rows without enough columns
|
||||||
|
if len(row) < 9:
|
||||||
|
continue
|
||||||
|
|
||||||
|
col_a = row[0].value
|
||||||
|
if not col_a:
|
||||||
|
continue
|
||||||
|
|
||||||
|
col_a_str = str(col_a).strip()
|
||||||
|
|
||||||
|
# Skip header/label rows
|
||||||
|
if col_a_str.lower() in ("kvnr", ""):
|
||||||
|
continue
|
||||||
|
# Skip non-data rows (e.g. "Kalenderwoche:", instruction text)
|
||||||
|
if not col_a_str[0].isalpha() and not col_a_str[0].isdigit():
|
||||||
|
continue
|
||||||
|
# Skip rows where col A looks like a header label
|
||||||
|
if col_a_str.lower().startswith(("bitte", "kalender")):
|
||||||
|
continue
|
||||||
|
|
||||||
|
kvnr = col_a_str
|
||||||
|
|
||||||
|
# ICD is in column I (index 8)
|
||||||
|
icd_cell = row[8].value
|
||||||
|
if not icd_cell:
|
||||||
|
continue
|
||||||
|
icd_value = str(icd_cell).strip()
|
||||||
|
if not icd_value or icd_value.lower() == "icd-10":
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Find case by KVNR — take the most recent one
|
||||||
|
case = (
|
||||||
|
db.query(Case)
|
||||||
|
.filter(
|
||||||
|
Case.kvnr == kvnr,
|
||||||
|
Case.versicherung == settings.VERSICHERUNG_FILTER,
|
||||||
|
)
|
||||||
|
.order_by(Case.datum.desc())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not case:
|
||||||
|
errors.append(f"KVNR {kvnr}: Kein Fall gefunden")
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
save_icd_for_case(db, case.id, icd_value, user_id)
|
||||||
|
updated += 1
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"KVNR {kvnr} (Case {case.id}): {e}")
|
||||||
|
|
||||||
|
return {"updated": updated, "errors": errors}
|
||||||
|
|
|
||||||
193
backend/app/services/wochenuebersicht_export.py
Normal file
193
backend/app/services/wochenuebersicht_export.py
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
"""Wochenübersicht Excel export for DAK weekly summary sheets.
|
||||||
|
|
||||||
|
Generates .xlsx files matching the format sent to DAK for ICD code entry.
|
||||||
|
Two variants exist:
|
||||||
|
- "c2s" → Fallgruppen onko + intensiv (columns ZMO / ZMI)
|
||||||
|
- "c2s_g_s" → Fallgruppen galle + sd (columns Galle / Schild)
|
||||||
|
|
||||||
|
Each KW block has 4 header rows + data rows + 2 blank separator rows.
|
||||||
|
KWs are sorted descending (newest first).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from io import BytesIO
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from openpyxl import Workbook
|
||||||
|
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
|
||||||
|
|
||||||
|
from app.services.excel_export import HEADER_FILL, HEADER_FONT
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Type configuration
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
WOCHENUEBERSICHT_TYPES: dict[str, dict[str, Any]] = {
|
||||||
|
"c2s": {
|
||||||
|
"fallgruppen": ("onko", "intensiv"),
|
||||||
|
"fg_labels": ("ZMO", "ZMI"),
|
||||||
|
"filename_infix": "c2s",
|
||||||
|
},
|
||||||
|
"c2s_g_s": {
|
||||||
|
"fallgruppen": ("galle", "sd"),
|
||||||
|
"fg_labels": ("Galle", "Schild"),
|
||||||
|
"filename_infix": "c2s_g_s",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Column headers (row 4 of each KW block)
|
||||||
|
COL_HEADERS = [
|
||||||
|
"KVNR", # A
|
||||||
|
"Datum", # B
|
||||||
|
"Erstgespräch", # C
|
||||||
|
"Zweitmeinung/Vorbereitung (bei Abbruch)", # D
|
||||||
|
"Zweitmeinung/Vorbereitung + Erteilung", # E
|
||||||
|
"Schriftliche Dokumentation (Gutachten)", # F
|
||||||
|
# G and H are dynamic (FG labels)
|
||||||
|
# I = ICD-10
|
||||||
|
]
|
||||||
|
|
||||||
|
THIN_BORDER = Border(
|
||||||
|
left=Side(style="thin"),
|
||||||
|
right=Side(style="thin"),
|
||||||
|
top=Side(style="thin"),
|
||||||
|
bottom=Side(style="thin"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _kw_date_range(jahr: int, kw: int) -> tuple[date, date]:
|
||||||
|
"""Return (Monday, Sunday) for the given ISO year/week."""
|
||||||
|
monday = date.fromisocalendar(jahr, kw, 1)
|
||||||
|
sunday = date.fromisocalendar(jahr, kw, 7)
|
||||||
|
return monday, sunday
|
||||||
|
|
||||||
|
|
||||||
|
def _format_date_range(monday: date, sunday: date) -> str:
|
||||||
|
"""Format as 'DD.MM. - DD.MM.YYYY'."""
|
||||||
|
return f"{monday.strftime('%d.%m.')} - {sunday.strftime('%d.%m.%Y')}"
|
||||||
|
|
||||||
|
|
||||||
|
def generate_wochenuebersicht_xlsx(
|
||||||
|
cases_by_kw: dict[int, list[Any]],
|
||||||
|
export_type: str,
|
||||||
|
jahr: int,
|
||||||
|
) -> bytes:
|
||||||
|
"""Generate a Wochenübersicht Excel workbook.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cases_by_kw: Cases grouped by calendar week number.
|
||||||
|
export_type: One of ``"c2s"`` or ``"c2s_g_s"``.
|
||||||
|
jahr: The report year.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The ``.xlsx`` file contents as bytes.
|
||||||
|
"""
|
||||||
|
type_cfg = WOCHENUEBERSICHT_TYPES[export_type]
|
||||||
|
fg1, fg2 = type_cfg["fallgruppen"]
|
||||||
|
fg1_label, fg2_label = type_cfg["fg_labels"]
|
||||||
|
|
||||||
|
wb = Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = "Wochenübersicht"
|
||||||
|
|
||||||
|
# Column widths
|
||||||
|
ws.column_dimensions["A"].width = 14
|
||||||
|
ws.column_dimensions["B"].width = 12
|
||||||
|
ws.column_dimensions["C"].width = 14
|
||||||
|
ws.column_dimensions["D"].width = 18
|
||||||
|
ws.column_dimensions["E"].width = 18
|
||||||
|
ws.column_dimensions["F"].width = 16
|
||||||
|
ws.column_dimensions["G"].width = 10
|
||||||
|
ws.column_dimensions["H"].width = 10
|
||||||
|
ws.column_dimensions["I"].width = 14
|
||||||
|
|
||||||
|
current_row = 1
|
||||||
|
|
||||||
|
# Sort KWs descending (newest first)
|
||||||
|
sorted_kws = sorted(cases_by_kw.keys(), reverse=True)
|
||||||
|
|
||||||
|
for kw in sorted_kws:
|
||||||
|
cases = cases_by_kw[kw]
|
||||||
|
monday, sunday = _kw_date_range(jahr, kw)
|
||||||
|
|
||||||
|
# --- Row 1: "Kalenderwoche:" label + number ---
|
||||||
|
ws.cell(row=current_row, column=5, value="Kalenderwoche:")
|
||||||
|
ws.cell(row=current_row, column=5).font = HEADER_FONT
|
||||||
|
ws.cell(row=current_row, column=6, value=kw)
|
||||||
|
ws.cell(row=current_row, column=6).font = HEADER_FONT
|
||||||
|
current_row += 1
|
||||||
|
|
||||||
|
# --- Row 2: Date range ---
|
||||||
|
ws.cell(row=current_row, column=5, value=_format_date_range(monday, sunday))
|
||||||
|
ws.cell(row=current_row, column=5).font = Font(italic=True)
|
||||||
|
current_row += 1
|
||||||
|
|
||||||
|
# --- Row 3: Instruction ---
|
||||||
|
ws.cell(
|
||||||
|
row=current_row,
|
||||||
|
column=3,
|
||||||
|
value="Bitte entsprechendes Feld mit x kennzeichnen",
|
||||||
|
)
|
||||||
|
ws.cell(row=current_row, column=3).font = Font(italic=True, color="FF808080")
|
||||||
|
current_row += 1
|
||||||
|
|
||||||
|
# --- Row 4: Column headers ---
|
||||||
|
headers = COL_HEADERS + [fg1_label, fg2_label, "ICD-10"]
|
||||||
|
for ci, header in enumerate(headers, start=1):
|
||||||
|
cell = ws.cell(row=current_row, column=ci, value=header)
|
||||||
|
cell.fill = HEADER_FILL
|
||||||
|
cell.font = HEADER_FONT
|
||||||
|
cell.border = THIN_BORDER
|
||||||
|
cell.alignment = Alignment(wrap_text=True, vertical="center")
|
||||||
|
current_row += 1
|
||||||
|
|
||||||
|
# --- Data rows ---
|
||||||
|
# Sort cases by datum ascending within each KW
|
||||||
|
sorted_cases = sorted(cases, key=lambda c: (c.datum or date.min))
|
||||||
|
|
||||||
|
for case in sorted_cases:
|
||||||
|
# A: KVNR
|
||||||
|
ws.cell(row=current_row, column=1, value=case.kvnr or "")
|
||||||
|
# B: Datum
|
||||||
|
ws.cell(
|
||||||
|
row=current_row,
|
||||||
|
column=2,
|
||||||
|
value=case.datum.strftime("%d.%m.%Y") if case.datum else "",
|
||||||
|
)
|
||||||
|
# C: Erstgespräch — always x
|
||||||
|
ws.cell(row=current_row, column=3, value="x")
|
||||||
|
# D: Zweitmeinung/Vorbereitung (bei Abbruch)
|
||||||
|
if case.ablehnung or case.abbruch:
|
||||||
|
ws.cell(row=current_row, column=4, value="x")
|
||||||
|
# E: Zweitmeinung/Vorbereitung + Erteilung
|
||||||
|
if case.unterlagen and not case.ablehnung and not case.abbruch:
|
||||||
|
ws.cell(row=current_row, column=5, value="x")
|
||||||
|
# F: Schriftliche Dokumentation (Gutachten)
|
||||||
|
if case.gutachten:
|
||||||
|
ws.cell(row=current_row, column=6, value="x")
|
||||||
|
# G: FG1 (ZMO / Galle)
|
||||||
|
if case.fallgruppe == fg1:
|
||||||
|
ws.cell(row=current_row, column=7, value="x")
|
||||||
|
# H: FG2 (ZMI / Schild)
|
||||||
|
if case.fallgruppe == fg2:
|
||||||
|
ws.cell(row=current_row, column=8, value="x")
|
||||||
|
# I: ICD-10
|
||||||
|
ws.cell(row=current_row, column=9, value=case.icd or "")
|
||||||
|
|
||||||
|
# Apply thin borders to data row
|
||||||
|
for ci in range(1, 10):
|
||||||
|
ws.cell(row=current_row, column=ci).border = THIN_BORDER
|
||||||
|
ws.cell(row=current_row, column=ci).alignment = Alignment(
|
||||||
|
horizontal="center" if ci >= 3 else "left"
|
||||||
|
)
|
||||||
|
|
||||||
|
current_row += 1
|
||||||
|
|
||||||
|
# --- 2 blank separator rows ---
|
||||||
|
current_row += 2
|
||||||
|
|
||||||
|
buf = BytesIO()
|
||||||
|
wb.save(buf)
|
||||||
|
return buf.getvalue()
|
||||||
|
|
@ -18,6 +18,7 @@ import { DisclosuresPage } from '@/pages/DisclosuresPage'
|
||||||
import { AccountPage } from '@/pages/AccountPage'
|
import { AccountPage } from '@/pages/AccountPage'
|
||||||
import { GutachtenStatistikPage } from '@/pages/GutachtenStatistikPage'
|
import { GutachtenStatistikPage } from '@/pages/GutachtenStatistikPage'
|
||||||
import { MyDisclosuresPage } from '@/pages/MyDisclosuresPage'
|
import { MyDisclosuresPage } from '@/pages/MyDisclosuresPage'
|
||||||
|
import { WochenuebersichtPage } from '@/pages/WochenuebersichtPage'
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
|
|
@ -45,6 +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="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 />} />
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
FileEdit,
|
FileEdit,
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
FileBarChart,
|
FileBarChart,
|
||||||
|
FileOutput,
|
||||||
Users,
|
Users,
|
||||||
Mail,
|
Mail,
|
||||||
History,
|
History,
|
||||||
|
|
@ -32,6 +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 },
|
||||||
]
|
]
|
||||||
|
|
||||||
const accountNavItems: NavItem[] = [
|
const accountNavItems: NavItem[] = [
|
||||||
|
|
|
||||||
177
frontend/src/pages/WochenuebersichtPage.tsx
Normal file
177
frontend/src/pages/WochenuebersichtPage.tsx
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Download, FileSpreadsheet, Loader2 } from 'lucide-react'
|
||||||
|
import api from '@/services/api'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import {
|
||||||
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
|
|
||||||
|
const EXPORT_TYPES = [
|
||||||
|
{ value: 'c2s', label: 'Onko-Intensiv (c2s)' },
|
||||||
|
{ value: 'c2s_g_s', label: 'Galle-Schild (c2s_g_s)' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
function getISOWeek(d: Date): number {
|
||||||
|
const date = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()))
|
||||||
|
const dayNum = date.getUTCDay() || 7
|
||||||
|
date.setUTCDate(date.getUTCDate() + 4 - dayNum)
|
||||||
|
const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1))
|
||||||
|
return Math.ceil(((date.getTime() - yearStart.getTime()) / 86_400_000 + 1) / 7)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WochenuebersichtPage() {
|
||||||
|
const currentYear = new Date().getFullYear()
|
||||||
|
const currentKw = getISOWeek(new Date())
|
||||||
|
|
||||||
|
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 handleDownload = async () => {
|
||||||
|
setError('')
|
||||||
|
setSuccess('')
|
||||||
|
|
||||||
|
if (kwVon > kwBis) {
|
||||||
|
setError('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 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.`)
|
||||||
|
} catch {
|
||||||
|
setError('Fehler beim Herunterladen der Wochenübersicht.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<h1 className="text-2xl font-bold">Wochenübersicht</h1>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<FileSpreadsheet className="h-5 w-5" />
|
||||||
|
Wochenübersicht für die DAK 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={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>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{success && (
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>{success}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue