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", "name": "therapyfinder",
"private": true, "private": true,
"version": "2026.03.11.1", "version": "2026.03.11.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

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

View File

@@ -3,6 +3,7 @@ import { Link, useNavigate } from "@tanstack/react-router";
import { ArrowLeft, Plus, Trash2 } from "lucide-react"; import { ArrowLeft, Plus, Trash2 } from "lucide-react";
import { import {
createKontakt, createKontakt,
deleteKontakt,
deleteTherapeut, deleteTherapeut,
updateKontakt, updateKontakt,
updateTherapeut, updateTherapeut,
@@ -472,9 +473,23 @@ function KontaktEditCard({
</div> </div>
)} )}
</form.Field> </form.Field>
<Button type="submit" size="sm" variant="outline"> <div className="flex items-center justify-between">
Speichern <Button
</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> </form>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -31,13 +31,12 @@ export function ContactList() {
</Button> </Button>
</div> </div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-1.5">
{data.map((t) => ( {data.map((t) => (
<ContactCard <ContactCard
key={t.id} key={t.id}
id={t.id} id={t.id}
name={t.name} name={t.name}
stadt={t.stadt}
letzterKontakt={t.letzter_kontakt} letzterKontakt={t.letzter_kontakt}
letztesErgebnis={t.letztes_ergebnis} letztesErgebnis={t.letztes_ergebnis}
kontakteGesamt={Number(t.kontakte_gesamt)} 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]); 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() { export async function deleteAllData() {
await dbExec("DELETE FROM kontakt"); await dbExec("DELETE FROM kontakt");
await dbExec("DELETE FROM sprechstunde"); 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 type { ProzessSchritt } from "@/shared/db/schema";
import { dbExec } from "@/shared/hooks/use-db"; import { dbExec } from "@/shared/hooks/use-db";
import { PROZESS_SCHRITTE } from "@/shared/lib/constants"; import { PROZESS_SCHRITTE } from "@/shared/lib/constants";
import { GKV_KASSEN } from "@/shared/lib/gkv-kassen";
export function OnboardingForm() { export function OnboardingForm() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -128,11 +129,17 @@ export function OnboardingForm() {
<Label htmlFor="krankenkasse">Krankenkasse</Label> <Label htmlFor="krankenkasse">Krankenkasse</Label>
<Input <Input
id="krankenkasse" id="krankenkasse"
list="gkv-kassen"
value={field.state.value} value={field.state.value}
onBlur={field.handleBlur} onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)} 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} /> <FieldErrors errors={field.state.meta.errors} />
</div> </div>
)} )}

View File

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

View File

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

View File

@@ -14,13 +14,7 @@ export const PROZESS_SCHRITTE: {
key: "neu", key: "neu",
label: "Erstgespräch durchführen", label: "Erstgespräch durchführen",
beschreibung: beschreibung:
"Das Erstgespräch heißt in der Fachsprache psychotherapeutische Sprechstunde. Dort wird 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: "sprechstunde_absolviert",
label: "Sprechstunde absolviert",
beschreibung:
"Du hast eine psychotherapeutische Sprechstunde (PTS) besucht. Dort wurde eine erste Einschätzung vorgenommen.",
}, },
{ {
key: "diagnose_erhalten", key: "diagnose_erhalten",
@@ -32,7 +26,7 @@ export const PROZESS_SCHRITTE: {
key: "tss_beantragt", key: "tss_beantragt",
label: "TSS kontaktiert", label: "TSS kontaktiert",
beschreibung: 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", 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",
];