add kostenerstattungs-assistent with checklist, pdf export

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 11:27:03 +01:00
parent a84fe1c5f8
commit 651dd4a255
9 changed files with 364 additions and 2 deletions
@@ -0,0 +1,118 @@
import { useKontaktStats, useNutzer } from "@/features/prozess/hooks";
import { Card, CardContent } from "@/shared/components/ui/card";
import { Separator } from "@/shared/components/ui/separator";
import { PdfExportButton } from "./pdf-export-button";
interface ChecklistItem {
label: string;
fulfilled: boolean;
}
export function AntragChecklist() {
const { data: nutzerRows, loading: nutzerLoading } = useNutzer();
const { data: statsRows, loading: statsLoading } = useKontaktStats();
if (nutzerLoading || statsLoading) {
return <p className="py-8 text-center text-muted-foreground">Laden</p>;
}
const nutzer = nutzerRows[0];
const stats = statsRows[0];
if (!nutzer || !stats) {
return (
<p className="py-8 text-center text-muted-foreground">
Keine Daten vorhanden.
</p>
);
}
const items: ChecklistItem[] = [
{
label: "Psychotherapeutische Sprechstunde besucht",
fulfilled: nutzer.aktueller_schritt !== "neu",
},
{
label: "Diagnose / Dringlichkeitscode erhalten",
fulfilled: nutzer.dringlichkeitscode,
},
{
label: "Terminservicestelle (TSS) kontaktiert",
fulfilled: nutzer.tss_beantragt,
},
{
label: "Eigenständige Therapeutensuche dokumentiert",
fulfilled: stats.absagen + stats.keine_antwort >= 5,
},
{
label: "Absagenliste exportiert",
fulfilled: false,
},
];
const fulfilledCount = items.filter((i) => i.fulfilled).length;
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-xl font-bold">Kostenerstattungs-Assistent</h1>
<p className="text-sm text-muted-foreground">
{fulfilledCount} von {items.length} Voraussetzungen erfüllt
</p>
</div>
<div className="flex flex-col gap-3">
{items.map((item, index) => (
<Card key={item.label} className="py-4">
<CardContent className="flex items-center gap-4">
{item.fulfilled ? (
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300">
<span className="text-sm font-bold"></span>
</div>
) : (
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<span className="text-sm font-medium">{index + 1}</span>
</div>
)}
<span
className={
item.fulfilled ? "text-sm" : "text-sm text-muted-foreground"
}
>
{item.label}
</span>
</CardContent>
</Card>
))}
</div>
<Separator />
<div>
<h2 className="mb-3 text-lg font-semibold">Nächste Schritte</h2>
<ol className="list-inside list-decimal space-y-2 text-sm text-muted-foreground">
<li>
Besuche eine psychotherapeutische Sprechstunde und lass dir eine
Diagnose sowie einen Dringlichkeitscode geben.
</li>
<li>
Kontaktiere die Terminservicestelle (TSS) deiner Kassenärztlichen
Vereinigung unter 116 117.
</li>
<li>
Dokumentiere mindestens 5 erfolglose Kontaktversuche (Absagen oder
keine Antwort).
</li>
<li>
Exportiere die Absagenliste als PDF und lege sie deinem Antrag bei.
</li>
<li>
Reiche den Kostenerstattungsantrag bei deiner Krankenkasse ein.
</li>
</ol>
</div>
<PdfExportButton />
</div>
);
}
@@ -0,0 +1,22 @@
import { useState } from "react";
import { Button } from "@/shared/components/ui/button";
import { generateAbsagenPdf } from "../pdf";
export function PdfExportButton() {
const [loading, setLoading] = useState(false);
async function handleExport() {
setLoading(true);
try {
await generateAbsagenPdf();
} finally {
setLoading(false);
}
}
return (
<Button onClick={handleExport} disabled={loading} className="w-full">
{loading ? "Wird erstellt…" : "Absagenliste als PDF exportieren"}
</Button>
);
}
+3
View File
@@ -0,0 +1,3 @@
export { AntragChecklist } from "./components/antrag-checklist";
export { PdfExportButton } from "./components/pdf-export-button";
export { generateAbsagenPdf } from "./pdf";
+132
View File
@@ -0,0 +1,132 @@
import { jsPDF } from "jspdf";
import { getDb } from "@/shared/db/client";
import type { KontaktErgebnis, KontaktKanal } from "@/shared/db/schema";
import { ERGEBNIS_LABELS, KANAL_LABELS } from "@/shared/lib/constants";
interface NutzerRow {
name: string | null;
krankenkasse: string | null;
}
interface KontaktRow {
datum: string;
therapeut_name: string;
stadt: string | null;
kanal: KontaktKanal;
ergebnis: KontaktErgebnis;
}
export async function generateAbsagenPdf(): Promise<void> {
const db = await getDb();
const nutzerResult = await db.query<NutzerRow>(
"SELECT name, krankenkasse FROM nutzer LIMIT 1",
);
const nutzer = nutzerResult.rows[0];
const kontaktResult = await db.query<KontaktRow>(`
SELECT
k.datum,
t.name AS therapeut_name,
t.stadt,
k.kanal,
k.ergebnis
FROM kontakt k
JOIN therapeut t ON t.id = k.therapeut_id
ORDER BY k.datum ASC
`);
const kontakte = kontaktResult.rows;
const doc = new jsPDF();
const pageWidth = doc.internal.pageSize.getWidth();
let y = 20;
// Header
doc.setFontSize(18);
doc.text("Dokumentation der Therapeutensuche", 14, y);
y += 10;
doc.setFontSize(11);
doc.text(`Name: ${nutzer?.name ?? "k. A."}`, 14, y);
y += 6;
doc.text(`Krankenkasse: ${nutzer?.krankenkasse ?? "k. A."}`, 14, y);
y += 6;
doc.text(`Erstellt am: ${new Date().toLocaleDateString("de-DE")}`, 14, y);
y += 10;
// Summary
const totalAbsagen = kontakte.filter((k) => k.ergebnis === "absage").length;
const totalKeineAntwort = kontakte.filter(
(k) => k.ergebnis === "keine_antwort",
).length;
doc.setFontSize(12);
doc.text("Zusammenfassung", 14, y);
y += 7;
doc.setFontSize(10);
doc.text(`Kontaktversuche gesamt: ${kontakte.length}`, 14, y);
y += 5;
doc.text(`Absagen: ${totalAbsagen}`, 14, y);
y += 5;
doc.text(`Keine Antwort: ${totalKeineAntwort}`, 14, y);
y += 10;
// Table header
const colX = [14, 40, 90, 130, 160];
const colHeaders = ["Datum", "Therapeut:in", "Ort", "Kontaktweg", "Ergebnis"];
doc.setFontSize(9);
doc.setFont("helvetica", "bold");
for (let i = 0; i < colHeaders.length; i++) {
doc.text(colHeaders[i], colX[i], y);
}
y += 2;
doc.setLineWidth(0.3);
doc.line(14, y, pageWidth - 14, y);
y += 5;
doc.setFont("helvetica", "normal");
// Table rows
for (const k of kontakte) {
if (y > 270) {
doc.addPage();
y = 20;
}
const datumFormatted = formatDatum(k.datum);
doc.text(datumFormatted, colX[0], y);
doc.text(truncate(k.therapeut_name, 30), colX[1], y);
doc.text(truncate(k.stadt ?? "k. A.", 20), colX[2], y);
doc.text(KANAL_LABELS[k.kanal], colX[3], y);
doc.text(ERGEBNIS_LABELS[k.ergebnis], colX[4], y);
y += 6;
}
// Footer
y += 10;
if (y > 270) {
doc.addPage();
y = 20;
}
doc.setFontSize(8);
doc.setTextColor(120);
doc.text(
"Dieses Dokument wurde automatisch erstellt und dient als Nachweis der eigenständigen Therapeutensuche.",
14,
y,
);
doc.save("therapeutensuche-dokumentation.pdf");
}
function formatDatum(iso: string): string {
try {
return new Date(iso).toLocaleDateString("de-DE");
} catch {
return iso;
}
}
function truncate(text: string, maxLen: number): string {
if (text.length <= maxLen) return text;
return `${text.slice(0, maxLen - 1)}`;
}