rewrite process stepper: data-driven steps, erstgespräch list/form, conditional TSS

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-12 19:42:57 +01:00
parent 054f2bb3d1
commit c6db9cec65

View File

@@ -1,24 +1,25 @@
import { useForm } from "@tanstack/react-form";
import { Link } from "@tanstack/react-router";
import { ChevronDown, ChevronRight, Plus } from "lucide-react";
import { useState } from "react";
import { useTherapeutenListe } from "@/features/kontakte/hooks";
import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button";
import { DateInput } from "@/shared/components/ui/date-input";
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";
import type { ErstgespraechRow, ProcessStatus } from "../hooks";
import {
addSitzung,
createErstgespraech,
setTssKontaktiert,
updateErstgespraech,
useErstgespraeche,
useSitzungen,
} from "../hooks";
import { PhaseCard } from "./phase-card";
interface ProcessStepperProps {
aktuellerSchritt: ProzessSchritt;
kontaktGesamt: number;
absagen: number;
onUpdate: () => void;
}
const inputClasses =
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring";
@@ -27,177 +28,236 @@ function todayISO() {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
interface ProcessStepperProps {
processStatus: ProcessStatus;
tssKontaktiertDatum: string | null;
kontaktGesamt: number;
absagen: number;
onUpdate: () => void;
}
export function ProcessStepper({
aktuellerSchritt,
processStatus,
tssKontaktiertDatum,
kontaktGesamt,
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 === effectiveSchritt,
);
const visibleSteps = processStatus.steps.filter((s) => s.visible);
const currentIndex = visibleSteps.findIndex((s) => s.status === "aktuell");
return (
<div className="flex flex-col gap-6">
<div className="flex items-baseline justify-between">
<h1 className="text-2xl font-bold">Dein Fortschritt</h1>
<span className="text-sm text-muted-foreground">
Schritt {currentIndex + 1} von {PROZESS_SCHRITTE.length}
</span>
{currentIndex >= 0 && (
<span className="text-sm text-muted-foreground">
Schritt {currentIndex + 1} von {visibleSteps.length}
</span>
)}
</div>
<div className="flex flex-col gap-3">
{PROZESS_SCHRITTE.map((schritt, i) => {
const status =
i < currentIndex
? "erledigt"
: i === currentIndex
? "aktuell"
: "offen";
return (
<PhaseCard
key={schritt.key}
label={schritt.label}
beschreibung={schritt.beschreibung}
status={status}
index={i}
>
{status === "aktuell" && (
<StepAction
schritt={schritt.key}
kontaktGesamt={kontaktGesamt}
absagen={absagen}
onUpdate={onUpdate}
/>
)}
</PhaseCard>
);
})}
{visibleSteps.map((step, i) => (
<PhaseCard
key={step.key}
label={step.label}
beschreibung={step.beschreibung}
status={step.status}
stepNumber={i + 1}
>
{step.key === "erstgespraech" && (
<ErstgespraechAction onUpdate={onUpdate} />
)}
{step.key === "tss" && step.status === "aktuell" && (
<TssAction onUpdate={onUpdate} />
)}
{step.key === "tss" &&
step.status === "erledigt" &&
tssKontaktiertDatum && <TssDone datum={tssKontaktiertDatum} />}
{step.key === "eigensuche" && (
<EigensucheInfo kontaktGesamt={kontaktGesamt} absagen={absagen} />
)}
{step.key === "antrag" && step.status === "aktuell" && (
<AntragLink />
)}
</PhaseCard>
))}
</div>
</div>
);
}
function StepAction({
schritt,
kontaktGesamt,
absagen,
onUpdate,
}: {
schritt: ProzessSchritt;
kontaktGesamt: number;
absagen: number;
onUpdate: () => void;
}) {
switch (schritt) {
case "neu":
return <SprechstundeForm onDone={onUpdate} />;
case "diagnose_erhalten":
return (
function ErstgespraechAction({ onUpdate }: { onUpdate: () => void }) {
const { data: erstgespraeche, refetch } = useErstgespraeche();
const [showForm, setShowForm] = useState(false);
const handleUpdate = () => {
refetch();
onUpdate();
};
return (
<>
{erstgespraeche.length > 0 && (
<>
<Separator />
<div className="space-y-2">
{erstgespraeche.map((eg) => (
<ErstgespraechCard
key={eg.id}
erstgespraech={eg}
onUpdate={handleUpdate}
/>
))}
</div>
</>
)}
{showForm ? (
<>
<Separator />
<ErstgespraechForm
onDone={() => {
setShowForm(false);
handleUpdate();
}}
onCancel={() => setShowForm(false)}
/>
</>
) : (
<>
<Separator />
<div className="flex justify-end">
<Button
size="sm"
onClick={async () => {
await dbExec(
`UPDATE nutzer SET
aktueller_schritt = 'tss_beantragt',
tss_beantragt = TRUE,
tss_beantragt_datum = CURRENT_DATE,
aktualisiert_am = NOW()
WHERE id = 1`,
);
onUpdate();
}}
>
TSS kontaktiert
<Button size="sm" onClick={() => setShowForm(true)}>
<Plus className="mr-1 size-4" />
Erstgespräch hinzufügen
</Button>
</div>
</>
);
case "tss_beantragt":
return (
<AdvanceButton
nextStep="eigensuche"
label="Eigensuche gestartet"
onDone={onUpdate}
/>
);
case "eigensuche":
return (
<>
<Separator />
<div className="text-sm text-muted-foreground">
{kontaktGesamt} Kontaktversuche, davon {absagen} Absagen.{" "}
<Link to="/kontakte" className="text-primary underline">
Kontakte verwalten
</Link>
</div>
<AdvanceButton
nextStep="antrag_gestellt"
label="Kostenerstattung beantragt"
onDone={onUpdate}
/>
</>
);
case "antrag_gestellt":
return (
<>
<Separator />
<p className="text-sm text-muted-foreground">
Dein Kostenerstattungsantrag wurde eingereicht.{" "}
<Link to="/antrag" className="text-primary underline">
Zum Antrag
</Link>
</p>
</>
);
default:
return null;
}
}
function AdvanceButton({
nextStep,
label,
onDone,
}: {
nextStep: ProzessSchritt;
label: string;
onDone: () => void;
}) {
return (
<>
<Separator />
<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>
)}
</>
);
}
function SprechstundeForm({ onDone }: { onDone: () => void }) {
function ErstgespraechCard({
erstgespraech,
onUpdate,
}: {
erstgespraech: ErstgespraechRow;
onUpdate: () => void;
}) {
const [expanded, setExpanded] = useState(false);
const [editing, setEditing] = useState(false);
return (
<div className="rounded-lg border p-3">
<button
type="button"
className="flex w-full items-center gap-3 text-left text-sm"
onClick={() => setExpanded(!expanded)}
>
<span className="flex-1 font-medium">
{erstgespraech.therapeut_name}
{erstgespraech.therapeut_stadt
? ` (${erstgespraech.therapeut_stadt})`
: ""}
</span>
<span className="flex items-center gap-2">
{erstgespraech.diagnose && (
<Badge variant="secondary">{erstgespraech.diagnose}</Badge>
)}
{erstgespraech.dringlichkeitscode && <Badge>Dringlichkeit</Badge>}
<span className="text-xs text-muted-foreground">
{erstgespraech.sitzung_count}{" "}
{Number(erstgespraech.sitzung_count) === 1
? "Sitzung"
: "Sitzungen"}
</span>
{expanded ? (
<ChevronDown className="size-4 text-muted-foreground" />
) : (
<ChevronRight className="size-4 text-muted-foreground" />
)}
</span>
</button>
{expanded && (
<div className="mt-3 space-y-3">
<SitzungList sprechstundeId={erstgespraech.id} onUpdate={onUpdate} />
{editing ? (
<EditErstgespraechForm
erstgespraech={erstgespraech}
onDone={() => {
setEditing(false);
onUpdate();
}}
onCancel={() => setEditing(false)}
/>
) : (
<div className="flex justify-end">
<Button
size="sm"
variant="outline"
onClick={() => setEditing(true)}
>
Diagnose bearbeiten
</Button>
</div>
)}
</div>
)}
</div>
);
}
function SitzungList({
sprechstundeId,
onUpdate,
}: {
sprechstundeId: number;
onUpdate: () => void;
}) {
const { data: sitzungen, refetch } = useSitzungen(sprechstundeId);
const [addingDate, setAddingDate] = useState("");
return (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">Sitzungen</p>
<ul className="space-y-1">
{sitzungen.map((s) => (
<li key={s.id} className="text-sm">
{new Date(s.datum).toLocaleDateString("de-DE")}
</li>
))}
</ul>
<div className="flex items-center gap-2">
<DateInput value={addingDate} onChange={(iso) => setAddingDate(iso)} />
<Button
size="sm"
variant="outline"
disabled={!addingDate}
onClick={async () => {
await addSitzung(sprechstundeId, addingDate);
setAddingDate("");
refetch();
onUpdate();
}}
>
Sitzung hinzufügen
</Button>
</div>
</div>
);
}
function ErstgespraechForm({
onDone,
onCancel,
}: {
onDone: () => void;
onCancel: () => void;
}) {
const { data: therapeuten, loading } = useTherapeutenListe();
const [saved, setSaved] = useState(false);
const form = useForm({
defaultValues: {
@@ -209,145 +269,258 @@ function SprechstundeForm({ onDone }: { onDone: () => void }) {
onSubmit: async ({ value }) => {
const therapeutId = Number(value.therapeut_id);
if (!therapeutId) return;
await dbExec(
`INSERT INTO sprechstunde (therapeut_id, datum, ergebnis, diagnose, dringlichkeitscode) VALUES ($1, $2, 'erstgespraech', $3, $4)`,
[
therapeutId,
value.datum,
value.diagnose || null,
value.dringlichkeitscode,
],
await createErstgespraech(
therapeutId,
value.datum,
value.diagnose || null,
value.dringlichkeitscode,
);
await dbExec(
`UPDATE nutzer SET
aktueller_schritt = 'diagnose_erhalten',
dringlichkeitscode = $1,
dringlichkeitscode_datum = CASE WHEN $1 = TRUE THEN $2::date ELSE NULL END,
aktualisiert_am = NOW()
WHERE id = 1`,
[value.dringlichkeitscode, value.datum],
);
setSaved(true);
onDone();
},
});
if (saved) {
return (
<p className="text-sm font-medium text-green-600 dark:text-green-400">
Erstgespräch erfasst.
return (
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
className="space-y-3"
>
<p className="text-sm font-medium">Erstgespräch dokumentieren</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">
<Label>Therapeut:in</Label>
{loading ? (
<p className="text-sm text-muted-foreground">Laden</p>
) : therapeuten.length === 0 ? (
<p className="text-sm text-muted-foreground">
Lege zuerst unter{" "}
<Link to="/kontakte" className="text-primary underline">
Kontakte
</Link>{" "}
einen Eintrag an.
</p>
) : (
<select
className={inputClasses}
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
>
<option value="">Bitte wählen</option>
{therapeuten.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
{t.stadt ? ` (${t.stadt})` : ""}
</option>
))}
</select>
)}
</div>
)}
</form.Field>
<form.Field name="datum">
{(field) => (
<div className="space-y-1">
<Label>Datum</Label>
<DateInput
value={field.state.value}
onChange={(iso) => field.handleChange(iso)}
/>
</div>
)}
</form.Field>
<form.Field name="diagnose">
{(field) => (
<div className="space-y-1">
<Label>Diagnose</Label>
<input
type="text"
className={inputClasses}
placeholder="z.B. F32.1"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
</div>
)}
</form.Field>
<form.Field name="dringlichkeitscode">
{(field) => (
<div className="space-y-1">
<div className="flex items-center gap-3">
<Switch
id="dringlichkeitscode-new"
checked={field.state.value}
onCheckedChange={(checked) => field.handleChange(checked)}
/>
<Label htmlFor="dringlichkeitscode-new">
Dringlichkeitscode erhalten
</Label>
</div>
</div>
)}
</form.Field>
<div className="flex justify-end gap-2">
<Button type="button" size="sm" variant="outline" onClick={onCancel}>
Abbrechen
</Button>
<Button type="submit" size="sm" disabled={therapeuten.length === 0}>
Erstgespräch durchgeführt
</Button>
</div>
</form>
);
}
function EditErstgespraechForm({
erstgespraech,
onDone,
onCancel,
}: {
erstgespraech: ErstgespraechRow;
onDone: () => void;
onCancel: () => void;
}) {
const form = useForm({
defaultValues: {
diagnose: erstgespraech.diagnose ?? "",
dringlichkeitscode: erstgespraech.dringlichkeitscode,
},
onSubmit: async ({ value }) => {
await updateErstgespraech(
erstgespraech.id,
value.diagnose || null,
value.dringlichkeitscode,
);
onDone();
},
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
className="space-y-3"
>
<form.Field name="diagnose">
{(field) => (
<div className="space-y-1">
<Label>Diagnose</Label>
<input
type="text"
className={inputClasses}
placeholder="z.B. F32.1"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
</div>
)}
</form.Field>
<form.Field name="dringlichkeitscode">
{(field) => (
<div className="space-y-1">
<div className="flex items-center gap-3">
<Switch
id="dringlichkeitscode-edit"
checked={field.state.value}
onCheckedChange={(checked) => field.handleChange(checked)}
/>
<Label htmlFor="dringlichkeitscode-edit">
Dringlichkeitscode erhalten
</Label>
</div>
</div>
)}
</form.Field>
<div className="flex justify-end gap-2">
<Button type="button" size="sm" variant="outline" onClick={onCancel}>
Abbrechen
</Button>
<Button type="submit" size="sm">
Speichern
</Button>
</div>
</form>
);
}
function TssAction({ onUpdate }: { onUpdate: () => void }) {
return (
<>
<Separator />
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
className="space-y-3"
>
<p className="text-sm font-medium">Erstgespräch dokumentieren</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">
<Label>Therapeut:in</Label>
{loading ? (
<p className="text-sm text-muted-foreground">Laden</p>
) : therapeuten.length === 0 ? (
<p className="text-sm text-muted-foreground">
Lege zuerst unter{" "}
<Link to="/kontakte" className="text-primary underline">
Kontakte
</Link>{" "}
einen Eintrag an.
</p>
) : (
<select
className={inputClasses}
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
>
<option value="">Bitte wählen</option>
{therapeuten.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
{t.stadt ? ` (${t.stadt})` : ""}
</option>
))}
</select>
)}
</div>
)}
</form.Field>
<form.Field name="datum">
{(field) => (
<div className="space-y-1">
<Label>Datum</Label>
<DateInput
value={field.state.value}
onChange={(iso) => field.handleChange(iso)}
/>
</div>
)}
</form.Field>
<form.Field name="diagnose">
{(field) => (
<div className="space-y-1">
<Label>Diagnose</Label>
<input
type="text"
className={inputClasses}
placeholder="z.B. F32.1"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
</div>
)}
</form.Field>
<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}>
Erstgespräch durchgeführt
</Button>
</div>
</form>
<div className="flex justify-end">
<Button
size="sm"
onClick={async () => {
await setTssKontaktiert();
onUpdate();
}}
>
TSS kontaktiert
</Button>
</div>
</>
);
}
function TssDone({ datum }: { datum: string }) {
return (
<>
<Separator />
<p className="text-sm text-muted-foreground">
TSS kontaktiert am {new Date(datum).toLocaleDateString("de-DE")}
</p>
</>
);
}
function EigensucheInfo({
kontaktGesamt,
absagen,
}: {
kontaktGesamt: number;
absagen: number;
}) {
return (
<>
<Separator />
<div className="text-sm text-muted-foreground">
{kontaktGesamt} Kontaktversuche, davon {absagen} Absagen.{" "}
<Link to="/kontakte" className="text-primary underline">
Kontakte verwalten
</Link>
</div>
</>
);
}
function AntragLink() {
return (
<>
<Separator />
<p className="text-sm text-muted-foreground">
Dein Kostenerstattungsantrag kann eingereicht werden.{" "}
<Link to="/antrag" className="text-primary underline">
Zum Antrag
</Link>
</p>
</>
);
}