rework process flow, compact contact cards, add GKV autocomplete

remove sprechstunde_absolviert as separate step, merge diagnosis +
dringlichkeitscode into erstgespräch form, add 116117 hint, fix TSS
wording, make process steps expandable/collapsible, align advance
buttons right, compact contact list with status-encoded icon buttons,
add delete button on kontaktversuche, add GKV krankenkasse autocomplete
with datalist, add deleteKontakt hook

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 20:15:12 +01:00
parent 16de72c017
commit 7032f43295
10 changed files with 268 additions and 132 deletions

View File

@@ -1,7 +1,7 @@
{
"name": "therapyfinder",
"private": true,
"version": "2026.03.11.1",
"version": "2026.03.11.2",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -1,37 +1,60 @@
import { useNavigate } from "@tanstack/react-router";
import { Check, Clock, X } from "lucide-react";
import { Badge } from "@/shared/components/ui/badge";
import { Card, CardContent } from "@/shared/components/ui/card";
import { Link } from "@tanstack/react-router";
import { Check, Clock, HelpCircle, X } from "lucide-react";
import type { KontaktErgebnis } from "@/shared/db/schema";
import { dbExec } from "@/shared/hooks/use-db";
import { ERGEBNIS_LABELS } from "@/shared/lib/constants";
import { cn } from "@/shared/lib/utils";
interface ContactCardProps {
id: number;
name: string;
stadt: string | null;
letzterKontakt: string | null;
letztesErgebnis: string | null;
kontakteGesamt: number;
onUpdate: () => void;
}
const ergebnisVariant: Record<
KontaktErgebnis,
"default" | "secondary" | "destructive" | "outline"
> = {
zusage: "default",
warteliste: "secondary",
absage: "destructive",
keine_antwort: "outline",
};
const statusButtons: {
ergebnis: KontaktErgebnis;
icon: typeof Check;
color: string;
selectedBg: string;
label: string;
}[] = [
{
ergebnis: "zusage",
icon: Check,
color: "text-green-600 dark:text-green-400",
selectedBg: "bg-green-600 dark:bg-green-500 text-white",
label: "Zusage",
},
{
ergebnis: "warteliste",
icon: Clock,
color: "text-amber-600 dark:text-amber-400",
selectedBg: "bg-amber-600 dark:bg-amber-500 text-white",
label: "Warteliste",
},
{
ergebnis: "absage",
icon: X,
color: "text-red-600 dark:text-red-400",
selectedBg: "bg-red-600 dark:bg-red-500 text-white",
label: "Absage",
},
{
ergebnis: "keine_antwort",
icon: HelpCircle,
color: "text-muted-foreground",
selectedBg: "bg-muted-foreground text-white",
label: "Keine Antwort",
},
];
async function quickUpdate(
therapeutId: number,
ergebnis: KontaktErgebnis,
onUpdate: () => void,
) {
// Update the most recent kontakt for this therapist, or create one if none exists
const result = await dbExec(
"SELECT id FROM kontakt WHERE therapeut_id = $1 ORDER BY datum DESC, id DESC LIMIT 1",
[therapeutId],
@@ -56,81 +79,47 @@ async function quickUpdate(
export function ContactCard({
id,
name,
stadt,
letzterKontakt,
letztesErgebnis,
kontakteGesamt,
onUpdate,
}: ContactCardProps) {
const navigate = useNavigate();
return (
<Card
className="cursor-pointer transition-colors hover:bg-accent/50"
onClick={() =>
navigate({ to: "/kontakte/$id", params: { id: String(id) } })
}
>
<CardContent className="flex items-center gap-3">
<div className="min-w-0 flex-1">
<p className="font-medium">{name}</p>
{stadt && <p className="text-sm text-muted-foreground">{stadt}</p>}
<p className="text-sm text-muted-foreground">
{kontakteGesamt} Kontakt{kontakteGesamt !== 1 ? "e" : ""}
{letzterKontakt && <> · {letzterKontakt}</>}
</p>
</div>
<div className="flex items-center gap-2 rounded-lg border bg-card px-3 py-2">
<Link
to="/kontakte/$id"
params={{ id: String(id) }}
className="min-w-0 flex-1 transition-colors hover:text-primary"
>
<p className="truncate text-sm font-medium">{name}</p>
<p className="text-xs text-muted-foreground">
{kontakteGesamt} Kontakt{kontakteGesamt !== 1 ? "e" : ""}
{letzterKontakt && <> · {letzterKontakt}</>}
</p>
</Link>
<div className="flex items-center gap-1">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
quickUpdate(id, "zusage", onUpdate);
}}
className="rounded-md p-1.5 text-green-600 hover:bg-green-50 dark:text-green-400 dark:hover:bg-green-950"
aria-label="Zusage"
title="Zusage"
>
<Check className="size-4" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
quickUpdate(id, "warteliste", onUpdate);
}}
className="rounded-md p-1.5 text-amber-600 hover:bg-amber-50 dark:text-amber-400 dark:hover:bg-amber-950"
aria-label="Warteliste"
title="Warteliste"
>
<Clock className="size-4" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
quickUpdate(id, "absage", onUpdate);
}}
className="rounded-md p-1.5 text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950"
aria-label="Absage"
title="Absage"
>
<X className="size-4" />
</button>
</div>
{letztesErgebnis && (
<Badge
variant={
ergebnisVariant[letztesErgebnis as KontaktErgebnis] ?? "outline"
}
>
{ERGEBNIS_LABELS[letztesErgebnis as KontaktErgebnis] ??
letztesErgebnis}
</Badge>
)}
</CardContent>
</Card>
<div className="flex items-center gap-0.5">
{statusButtons.map((btn) => {
const Icon = btn.icon;
const isSelected = letztesErgebnis === btn.ergebnis;
return (
<button
key={btn.ergebnis}
type="button"
onClick={() => quickUpdate(id, btn.ergebnis, onUpdate)}
className={cn(
"rounded-md p-1.5",
isSelected ? btn.selectedBg : btn.color,
!isSelected && "hover:bg-accent",
)}
aria-label={btn.label}
title={btn.label}
>
<Icon className="size-3.5" />
</button>
);
})}
</div>
</div>
);
}

View File

@@ -3,6 +3,7 @@ import { Link, useNavigate } from "@tanstack/react-router";
import { ArrowLeft, Plus, Trash2 } from "lucide-react";
import {
createKontakt,
deleteKontakt,
deleteTherapeut,
updateKontakt,
updateTherapeut,
@@ -472,9 +473,23 @@ function KontaktEditCard({
</div>
)}
</form.Field>
<Button type="submit" size="sm" variant="outline">
Speichern
</Button>
<div className="flex items-center justify-between">
<Button
type="button"
size="sm"
variant="destructive"
onClick={async () => {
await deleteKontakt(kontakt.id);
onSaved();
}}
>
<Trash2 className="mr-1 size-4" />
Löschen
</Button>
<Button type="submit" size="sm" variant="outline">
Speichern
</Button>
</div>
</form>
</CardContent>
</Card>

View File

@@ -31,13 +31,12 @@ export function ContactList() {
</Button>
</div>
) : (
<div className="space-y-3">
<div className="space-y-1.5">
{data.map((t) => (
<ContactCard
key={t.id}
id={t.id}
name={t.name}
stadt={t.stadt}
letzterKontakt={t.letzter_kontakt}
letztesErgebnis={t.letztes_ergebnis}
kontakteGesamt={Number(t.kontakte_gesamt)}

View File

@@ -124,6 +124,10 @@ export async function deleteTherapeut(id: number) {
await dbExec("DELETE FROM therapeut WHERE id = $1", [id]);
}
export async function deleteKontakt(id: number) {
await dbExec("DELETE FROM kontakt WHERE id = $1", [id]);
}
export async function deleteAllData() {
await dbExec("DELETE FROM kontakt");
await dbExec("DELETE FROM sprechstunde");

View File

@@ -9,6 +9,7 @@ import { getDb } from "@/shared/db/client";
import type { ProzessSchritt } from "@/shared/db/schema";
import { dbExec } from "@/shared/hooks/use-db";
import { PROZESS_SCHRITTE } from "@/shared/lib/constants";
import { GKV_KASSEN } from "@/shared/lib/gkv-kassen";
export function OnboardingForm() {
const navigate = useNavigate();
@@ -128,11 +129,17 @@ export function OnboardingForm() {
<Label htmlFor="krankenkasse">Krankenkasse</Label>
<Input
id="krankenkasse"
list="gkv-kassen"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
placeholder="TK"
placeholder="z.B. Techniker Krankenkasse"
/>
<datalist id="gkv-kassen">
{GKV_KASSEN.map((k) => (
<option key={k} value={k} />
))}
</datalist>
<FieldErrors errors={field.state.meta.errors} />
</div>
)}

View File

@@ -1,5 +1,5 @@
import { Check } from "lucide-react";
import type { ReactNode } from "react";
import { Check, ChevronDown, ChevronRight } from "lucide-react";
import { type ReactNode, useState } from "react";
import { Badge } from "@/shared/components/ui/badge";
import { Card, CardContent } from "@/shared/components/ui/card";
import { cn } from "@/shared/lib/utils";
@@ -21,13 +21,18 @@ export function PhaseCard({
index,
children,
}: PhaseCardProps) {
const [expanded, setExpanded] = useState(false);
const showContent = status === "aktuell" || expanded;
return (
<Card
className={cn(
"transition-all",
status === "aktuell" && "ring-2 ring-primary border-primary",
status === "erledigt" && "opacity-60",
status !== "aktuell" && "cursor-pointer",
)}
onClick={() => status !== "aktuell" && setExpanded(!expanded)}
>
<CardContent className="space-y-3">
<div className="flex items-start gap-4">
@@ -43,12 +48,21 @@ export function PhaseCard({
>
{status === "erledigt" ? <Check className="size-4" /> : index + 1}
</div>
<div className="flex flex-col gap-1">
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex items-center gap-2">
<span className="font-medium">{label}</span>
{status === "aktuell" && <Badge>Aktuell</Badge>}
{status !== "aktuell" && (
<span className="ml-auto text-muted-foreground">
{expanded ? (
<ChevronDown className="size-4" />
) : (
<ChevronRight className="size-4" />
)}
</span>
)}
</div>
{status === "aktuell" && (
{showContent && (
<p className="text-sm text-muted-foreground">{beschreibung}</p>
)}
</div>

View File

@@ -5,6 +5,7 @@ import { useTherapeutenListe } from "@/features/kontakte/hooks";
import { Button } from "@/shared/components/ui/button";
import { Label } from "@/shared/components/ui/label";
import { Separator } from "@/shared/components/ui/separator";
import { Switch } from "@/shared/components/ui/switch";
import type { ProzessSchritt } from "@/shared/db/schema";
import { dbExec } from "@/shared/hooks/use-db";
import { PROZESS_SCHRITTE } from "@/shared/lib/constants";
@@ -31,8 +32,14 @@ export function ProcessStepper({
absagen,
onUpdate,
}: ProcessStepperProps) {
// Map legacy step to current steps
const effectiveSchritt =
aktuellerSchritt === "sprechstunde_absolviert"
? "diagnose_erhalten"
: aktuellerSchritt;
const currentIndex = PROZESS_SCHRITTE.findIndex(
(s) => s.key === aktuellerSchritt,
(s) => s.key === effectiveSchritt,
);
return (
@@ -91,14 +98,6 @@ function StepAction({
switch (schritt) {
case "neu":
return <SprechstundeForm onDone={onUpdate} />;
case "sprechstunde_absolviert":
return (
<AdvanceButton
nextStep="diagnose_erhalten"
label="Diagnose erhalten"
onDone={onUpdate}
/>
);
case "diagnose_erhalten":
return (
<AdvanceButton
@@ -161,18 +160,20 @@ function AdvanceButton({
return (
<>
<Separator />
<Button
size="sm"
onClick={async () => {
await dbExec(
"UPDATE nutzer SET aktueller_schritt = $1, aktualisiert_am = NOW() WHERE id = 1",
[nextStep],
);
onDone();
}}
>
{label}
</Button>
<div className="flex justify-end">
<Button
size="sm"
onClick={async () => {
await dbExec(
"UPDATE nutzer SET aktueller_schritt = $1, aktualisiert_am = NOW() WHERE id = 1",
[nextStep],
);
onDone();
}}
>
{label}
</Button>
</div>
</>
);
}
@@ -186,17 +187,23 @@ function SprechstundeForm({ onDone }: { onDone: () => void }) {
therapeut_id: "",
datum: todayISO(),
diagnose: "",
dringlichkeitscode: false,
},
onSubmit: async ({ value }) => {
const therapeutId = Number(value.therapeut_id);
if (!therapeutId) return;
await dbExec(
`INSERT INTO sprechstunde (therapeut_id, datum, ergebnis, diagnose) VALUES ($1, $2, 'erstgespraech', $3)`,
[therapeutId, value.datum, value.diagnose || null],
`INSERT INTO sprechstunde (therapeut_id, datum, ergebnis, diagnose, dringlichkeitscode) VALUES ($1, $2, 'erstgespraech', $3, $4)`,
[
therapeutId,
value.datum,
value.diagnose || null,
value.dringlichkeitscode,
],
);
await dbExec(
"UPDATE nutzer SET aktueller_schritt = 'sprechstunde_absolviert', aktualisiert_am = NOW() WHERE id = 1",
"UPDATE nutzer SET aktueller_schritt = 'diagnose_erhalten', aktualisiert_am = NOW() WHERE id = 1",
);
setSaved(true);
onDone();
@@ -223,6 +230,12 @@ function SprechstundeForm({ onDone }: { onDone: () => void }) {
className="space-y-3"
>
<p className="text-sm font-medium">Erstgespräch erfassen</p>
<p className="text-sm text-muted-foreground">
Freie Termine findest du unter{" "}
<span className="font-mono font-medium text-primary">116117</span>{" "}
(Telefon oder online).
</p>
<form.Field name="therapeut_id">
{(field) => (
<div className="space-y-1">
@@ -273,7 +286,7 @@ function SprechstundeForm({ onDone }: { onDone: () => void }) {
<form.Field name="diagnose">
{(field) => (
<div className="space-y-1">
<Label>Diagnose (optional)</Label>
<Label>Diagnose</Label>
<input
type="text"
className={inputClasses}
@@ -285,9 +298,34 @@ function SprechstundeForm({ onDone }: { onDone: () => void }) {
)}
</form.Field>
<Button type="submit" size="sm" disabled={therapeuten.length === 0}>
Sprechstunde erfassen
</Button>
<form.Field name="dringlichkeitscode">
{(field) => (
<div className="space-y-1">
<div className="flex items-center gap-3">
<Switch
id="dringlichkeitscode"
checked={field.state.value}
onCheckedChange={(checked) => field.handleChange(checked)}
/>
<Label htmlFor="dringlichkeitscode">
Dringlichkeitscode erhalten
</Label>
</div>
{!field.state.value && (
<p className="text-sm text-muted-foreground">
Ohne Dringlichkeitscode kann die TSS dich ggf. nicht
vermitteln. Frage in der Sprechstunde gezielt danach.
</p>
)}
</div>
)}
</form.Field>
<div className="flex justify-end">
<Button type="submit" size="sm" disabled={therapeuten.length === 0}>
Sprechstunde erfassen
</Button>
</div>
</form>
</>
);

View File

@@ -14,13 +14,7 @@ export const PROZESS_SCHRITTE: {
key: "neu",
label: "Erstgespräch durchführen",
beschreibung:
"Das Erstgespräch heißt in der Fachsprache psychotherapeutische Sprechstunde. Dort wird eine erste Einschätzung vorgenommen.",
},
{
key: "sprechstunde_absolviert",
label: "Sprechstunde absolviert",
beschreibung:
"Du hast eine psychotherapeutische Sprechstunde (PTS) besucht. Dort wurde eine erste Einschätzung vorgenommen.",
"Das Erstgespräch heißt in der Fachsprache psychotherapeutische Sprechstunde (PTS). Dort wird eine erste Einschätzung vorgenommen und du erhältst ggf. eine Diagnose und einen Dringlichkeitscode. Tipp: Unter 116117 findest du freie PTS-Termine.",
},
{
key: "diagnose_erhalten",
@@ -32,7 +26,7 @@ export const PROZESS_SCHRITTE: {
key: "tss_beantragt",
label: "TSS kontaktiert",
beschreibung:
"Du hast die Terminservicestelle (TSS) deiner Kassenärztlichen Vereinigung kontaktiert.",
"Du hast über die Terminservicestelle (TSS) deiner Kassenärztlichen Vereinigung (KV) nach einem Therapieplatz gesucht. Die TSS ist ein Service der KV, erreichbar unter 116117 oder online.",
},
{
key: "eigensuche",

View File

@@ -0,0 +1,76 @@
export const GKV_KASSEN = [
"AOK Baden-Württemberg",
"AOK Bayern",
"AOK Bremen/Bremerhaven",
"AOK Hessen",
"AOK Niedersachsen",
"AOK Nordost",
"AOK Nordwest",
"AOK Plus (Sachsen/Thüringen)",
"AOK Rheinland/Hamburg",
"AOK Rheinland-Pfalz/Saarland",
"AOK Sachsen-Anhalt",
"BARMER",
"BIG direkt gesund",
"BKK Achenbach Buschhütten",
"BKK Akzo Nobel Bayern",
"BKK Diakonie",
"BKK Euregio",
"BKK EWE",
"BKK exklusiv",
"BKK firmus",
"BKK Freudenberg",
"BKK Gildemeister Seidensticker",
"BKK Herkules",
"BKK Linde",
"BKK Melitta HMR",
"BKK Mobil Oil",
"BKK Pfalz",
"BKK ProVita",
"BKK Public",
"BKK Rieker Ricosta Weisser",
"BKK Scheufelen",
"BKK Schwarzwald-Baar-Heuberg",
"BKK Technoform",
"BKK Textilgruppe Hof",
"BKK VBU",
"BKK VDN",
"BKK Verbund Plus",
"BKK Werra-Meissner",
"BKK Wirtschaft & Finanzen",
"BKK ZF & Partner",
"BKK24",
"Bosch BKK",
"Continentale BKK",
"DAK-Gesundheit",
"Debeka BKK",
"Deutsche BKK",
"Die Bergische Krankenkasse",
"Die Schwenninger Krankenkasse",
"energie-BKK",
"Handelskrankenkasse (hkk)",
"Hanseatische Krankenkasse (HEK)",
"Heimat Krankenkasse",
"IKK Brandenburg und Berlin",
"IKK classic",
"IKK gesund plus",
"IKK Südwest",
"Kaufmännische Krankenkasse (KKH)",
"Knappschaft",
"Koenig & Bauer BKK",
"mhplus Krankenkasse",
"Mobil Krankenkasse",
"Novitas BKK",
"Pronova BKK",
"R+V BKK",
"Salus BKK",
"SECURVITA Krankenkasse",
"SIEMAG BKK",
"SKD BKK",
"Sozialversicherung für Landwirtschaft (SVLFG)",
"Techniker Krankenkasse (TK)",
"TUI BKK",
"Viactiv Krankenkasse",
"vivida bkk",
"WMF BKK",
];