Files
tpf/docs/superpowers/plans/2026-03-12-process-model-overhaul.md
T
2026-03-12 19:36:50 +01:00

44 KiB

Process Model Overhaul Implementation Plan

For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Replace the manually-synced aktueller_schritt enum with a data-driven process model supporting multiple Erstgespräche with sessions and conditional TSS step.

Architecture: New migration adds sitzung table and removes step-tracking columns from nutzer. A useProcessStatus() hook derives all step statuses from data queries. The process stepper renders steps with dynamic visibility and status.

Tech Stack: React, TanStack Router, TanStack Form, PGlite (Postgres in IndexedDB), Zod, Vitest


File Structure

File Responsibility
src/shared/db/migrations/002_process_model.sql Schema migration: add sitzung, modify nutzer/sprechstunde
src/shared/db/schema.ts Remove ProzessSchritt, add Sitzung type
src/shared/lib/constants.ts Remove PROZESS_SCHRITTE, keep label records
src/features/prozess/hooks.ts useProcessStatus() hook, useErstgespraeche(), data queries
src/features/prozess/components/process-stepper.tsx Data-driven stepper with Erstgespräch list/form
src/features/prozess/components/phase-card.tsx Accept stepNumber prop instead of index
src/features/antrag/components/antrag-checklist.tsx Derive checks from data queries
src/features/einstellungen/scenarios.ts Seed data rows instead of setting aktueller_schritt
src/features/onboarding/components/onboarding-form.tsx Remove aktueller_schritt from INSERT
src/features/onboarding/schema.ts Remove aktueller_schritt from validation schema
src/features/onboarding/schema.test.ts Remove aktueller_schritt from test data
src/features/prozess/index.ts Update barrel exports
src/routes/prozess/index.tsx Pass process status instead of step string

Chunk 1: Schema and types

Task 1: Write migration 002

Files:

  • Create: src/shared/db/migrations/002_process_model.sql

  • Step 1: Create migration file

-- Add tss_kontaktiert_datum before dropping old columns
ALTER TABLE nutzer ADD COLUMN tss_kontaktiert_datum DATE;

-- Migrate existing TSS data
UPDATE nutzer SET tss_kontaktiert_datum = tss_beantragt_datum WHERE tss_beantragt = TRUE;

-- Drop old tracking columns from nutzer
ALTER TABLE nutzer DROP COLUMN aktueller_schritt;
ALTER TABLE nutzer DROP COLUMN dringlichkeitscode;
ALTER TABLE nutzer DROP COLUMN dringlichkeitscode_datum;
ALTER TABLE nutzer DROP COLUMN tss_beantragt;
ALTER TABLE nutzer DROP COLUMN tss_beantragt_datum;

-- Create sitzung table
CREATE TABLE sitzung (
  id SERIAL PRIMARY KEY,
  sprechstunde_id INTEGER NOT NULL REFERENCES sprechstunde(id) ON DELETE CASCADE,
  datum DATE NOT NULL,
  erstellt_am TIMESTAMPTZ DEFAULT NOW()
);

-- Migrate existing sprechstunde.datum to sitzung
INSERT INTO sitzung (sprechstunde_id, datum)
SELECT id, datum FROM sprechstunde WHERE datum IS NOT NULL;

-- Drop migrated/vestigial columns from sprechstunde
ALTER TABLE sprechstunde DROP COLUMN datum;
ALTER TABLE sprechstunde DROP COLUMN ergebnis;

-- One Erstgespräch per therapist
ALTER TABLE sprechstunde ADD CONSTRAINT sprechstunde_therapeut_unique UNIQUE (therapeut_id);
  • Step 2: Verify migration loads

Run: npx vitest run --reporter verbose 2>&1 | head -30

The migration file just needs to exist and be valid SQL. The app's runMigrations() in client.ts auto-discovers *.sql files in the migrations folder.

  • Step 3: Commit
git add src/shared/db/migrations/002_process_model.sql
git commit -m "add migration 002: data-driven process model, sitzung table"

Task 2: Update schema types

Files:

  • Modify: src/shared/db/schema.ts

  • Step 1: Remove ProzessSchritt, update nutzerSchema, update sprechstundeSchema, add sitzungSchema

Replace the entire file with:

import { z } from "zod"

export const kontaktKanalEnum = z.enum([
	"telefon",
	"email",
	"online_formular",
	"persoenlich",
])
export type KontaktKanal = z.infer<typeof kontaktKanalEnum>

export const kontaktErgebnisEnum = z.enum([
	"keine_antwort",
	"absage",
	"warteliste",
	"zusage",
])
export type KontaktErgebnis = z.infer<typeof kontaktErgebnisEnum>

export const therapieformEnum = z.enum([
	"verhaltenstherapie",
	"tiefenpsychologisch",
	"analytisch",
	"systemisch",
])
export type Therapieform = z.infer<typeof therapieformEnum>

export const nutzerSchema = z.object({
	id: z.number(),
	name: z.string().nullable(),
	plz: z.string().nullable(),
	ort: z.string().nullable(),
	krankenkasse: z.string().nullable(),
	tss_kontaktiert_datum: z.string().nullable(),
})

export const therapeutSchema = z.object({
	id: z.number(),
	name: z.string(),
	adresse: z.string().nullable(),
	plz: z.string().nullable(),
	stadt: z.string().nullable(),
	telefon: z.string().nullable(),
	email: z.string().nullable(),
	website: z.string().nullable(),
	therapieform: z.string().nullable(),
	kassenzulassung: z.string().nullable(),
})

export const kontaktSchema = z.object({
	id: z.number(),
	therapeut_id: z.number(),
	datum: z.string(),
	kanal: kontaktKanalEnum,
	ergebnis: kontaktErgebnisEnum,
	notiz: z.string().nullable(),
	antwort_datum: z.string().nullable(),
})

export const sprechstundeSchema = z.object({
	id: z.number(),
	therapeut_id: z.number(),
	diagnose: z.string().nullable(),
	dringlichkeitscode: z.boolean(),
})

export const sitzungSchema = z.object({
	id: z.number(),
	sprechstunde_id: z.number(),
	datum: z.string(),
})
  • Step 2: Verify no type errors

Run: npx tsc --noEmit 2>&1 | head -30

This will show errors in files that import ProzessSchritt — those are fixed in later tasks.

  • Step 3: Commit
git add src/shared/db/schema.ts
git commit -m "update schema types: remove ProzessSchritt, add sitzung, update nutzer/sprechstunde"

Task 3: Remove PROZESS_SCHRITTE and its test

Files:

  • Modify: src/shared/lib/constants.ts

  • Delete: src/shared/lib/constants.test.ts

  • Step 1: Remove PROZESS_SCHRITTE from constants.ts

Remove the ProzessSchritt import and the entire PROZESS_SCHRITTE export. Keep KANAL_LABELS, ERGEBNIS_LABELS, THERAPIEFORM_LABELS.

New file content:

import type {
	KontaktErgebnis,
	KontaktKanal,
	Therapieform,
} from "../db/schema";

export const KANAL_LABELS: Record<KontaktKanal, string> = {
	telefon: "Telefon",
	email: "E-Mail",
	online_formular: "Online-Formular",
	persoenlich: "Persönlich",
};

export const ERGEBNIS_LABELS: Record<KontaktErgebnis, string> = {
	keine_antwort: "Keine Antwort",
	absage: "Absage",
	warteliste: "Warteliste",
	zusage: "Zusage",
};

export const THERAPIEFORM_LABELS: Record<Therapieform, string> = {
	verhaltenstherapie: "Verhaltenstherapie (VT)",
	tiefenpsychologisch: "Tiefenpsychologisch fundierte PT (TP)",
	analytisch: "Analytische Psychotherapie (AP)",
	systemisch: "Systemische Therapie",
};
  • Step 2: Delete the test file

Delete src/shared/lib/constants.test.ts entirely.

  • Step 3: Verify lint passes

Run: npx biome check src/shared/lib/constants.ts

  • Step 4: Commit
git add src/shared/lib/constants.ts
git rm src/shared/lib/constants.test.ts
git commit -m "remove PROZESS_SCHRITTE array, delete stale test"

Task 4: Update onboarding form

Files:

  • Modify: src/features/onboarding/components/onboarding-form.tsx

  • Step 1: Remove aktueller_schritt from INSERT

In the onSubmit handler (around line 51-55), change:

// Old:
await dbExec(
	`INSERT INTO nutzer (name, plz, ort, krankenkasse, aktueller_schritt)
	VALUES ($1, $2, $3, $4, 'neu')`,
	[value.name, value.plz, value.ort, value.krankenkasse],
);

// New:
await dbExec(
	`INSERT INTO nutzer (name, plz, ort, krankenkasse)
	VALUES ($1, $2, $3, $4)`,
	[value.name, value.plz, value.ort, value.krankenkasse],
);
  • Step 2: Verify no type errors

Run: npx tsc --noEmit 2>&1 | head -30

  • Step 3: Commit
git add src/features/onboarding/components/onboarding-form.tsx
git commit -m "remove aktueller_schritt from onboarding INSERT"

Task 5: Update onboarding schema and test

Files:

  • Modify: src/features/onboarding/schema.ts

  • Modify: src/features/onboarding/schema.test.ts

  • Step 1: Remove aktueller_schritt from onboarding schema

Replace src/features/onboarding/schema.ts with:

import { z } from "zod";

export const onboardingSchema = z.object({
	name: z.string().min(1, "Bitte gib deinen Namen ein."),
	plz: z.string().regex(/^\d{5}$/, "Bitte gib eine gültige PLZ ein."),
	ort: z.string().min(1, "Bitte gib deinen Ort ein."),
	krankenkasse: z.string().min(1, "Bitte gib deine Krankenkasse ein."),
});

export type OnboardingData = z.infer<typeof onboardingSchema>;
  • Step 2: Remove aktueller_schritt from test data

Replace src/features/onboarding/schema.test.ts with:

import { describe, expect, it } from "vitest";
import { onboardingSchema } from "./schema";

describe("onboardingSchema", () => {
	it("accepts valid data", () => {
		const result = onboardingSchema.safeParse({
			name: "Max Mustermann",
			plz: "10115",
			ort: "Berlin",
			krankenkasse: "TK",
		});
		expect(result.success).toBe(true);
	});

	it("rejects invalid PLZ", () => {
		const result = onboardingSchema.safeParse({
			name: "Max",
			plz: "123",
			ort: "Berlin",
			krankenkasse: "TK",
		});
		expect(result.success).toBe(false);
	});

	it("rejects empty name", () => {
		const result = onboardingSchema.safeParse({
			name: "",
			plz: "10115",
			ort: "Berlin",
			krankenkasse: "TK",
		});
		expect(result.success).toBe(false);
	});
});
  • Step 3: Run tests

Run: npx vitest run src/features/onboarding/schema.test.ts --reporter verbose

Expected: All 3 tests pass.

  • Step 4: Commit
git add src/features/onboarding/schema.ts src/features/onboarding/schema.test.ts
git commit -m "remove aktueller_schritt from onboarding schema and tests"

Chunk 2: Process status hook and stepper rewrite

Task 6: Write useProcessStatus hook

Files:

  • Rewrite: src/features/prozess/hooks.ts

  • Step 1: Replace hooks.ts with data-driven queries

import { dbExec, useDbQuery } from "@/shared/hooks/use-db";

interface NutzerRow {
	id: number;
	name: string;
	krankenkasse: string;
	tss_kontaktiert_datum: string | null;
}

interface KontaktStats {
	gesamt: number;
	absagen: number;
	warteliste: number;
	keine_antwort: number;
}

export interface ErstgespraechRow {
	id: number;
	therapeut_id: number;
	therapeut_name: string;
	therapeut_stadt: string | null;
	diagnose: string | null;
	dringlichkeitscode: boolean;
	sitzung_count: number;
}

interface SitzungRow {
	id: number;
	datum: string;
}

export type StepStatus = "erledigt" | "aktuell" | "offen";

export interface ProcessStatus {
	hasDiagnose: boolean;
	hasDringlichkeit: boolean;
	tssKontaktiert: boolean;
	absagenUndKeineAntwort: number;
	steps: {
		key: string;
		label: string;
		beschreibung: string;
		status: StepStatus;
		visible: boolean;
	}[];
}

export function useNutzer() {
	return useDbQuery<NutzerRow>("SELECT id, name, krankenkasse, tss_kontaktiert_datum FROM nutzer LIMIT 1");
}

export function useKontaktStats() {
	return useDbQuery<KontaktStats>(`
		SELECT
			COUNT(*) as gesamt,
			COUNT(*) FILTER (WHERE ergebnis = 'absage') as absagen,
			COUNT(*) FILTER (WHERE ergebnis = 'warteliste') as warteliste,
			COUNT(*) FILTER (WHERE ergebnis = 'keine_antwort') as keine_antwort
		FROM kontakt
	`);
}

export function useErstgespraeche() {
	return useDbQuery<ErstgespraechRow>(`
		SELECT
			s.id,
			s.therapeut_id,
			t.name AS therapeut_name,
			t.stadt AS therapeut_stadt,
			s.diagnose,
			s.dringlichkeitscode,
			(SELECT COUNT(*)::int FROM sitzung WHERE sprechstunde_id = s.id) AS sitzung_count
		FROM sprechstunde s
		JOIN therapeut t ON t.id = s.therapeut_id
		ORDER BY s.erstellt_am DESC
	`);
}

export function useSitzungen(sprechstundeId: number) {
	return useDbQuery<SitzungRow>(
		"SELECT id, datum FROM sitzung WHERE sprechstunde_id = $1 ORDER BY datum DESC",
		[sprechstundeId],
		[sprechstundeId],
	);
}

export function useProcessStatus(
	nutzer: NutzerRow | undefined,
	stats: KontaktStats | undefined,
	erstgespraeche: ErstgespraechRow[],
): ProcessStatus {
	const hasDiagnose = erstgespraeche.some((e) => e.diagnose != null);
	const hasDringlichkeit = erstgespraeche.some((e) => e.dringlichkeitscode);
	const tssKontaktiert = nutzer?.tss_kontaktiert_datum != null;
	const absagenUndKeineAntwort = stats
		? Number(stats.absagen) + Number(stats.keine_antwort)
		: 0;

	const step1Erledigt = hasDiagnose;
	const step2Erledigt = tssKontaktiert;
	const step3Erledigt = absagenUndKeineAntwort >= 5;
	const step4Available =
		step1Erledigt && step3Erledigt && (!hasDringlichkeit || step2Erledigt);

	const steps = [
		{
			key: "erstgespraech",
			label: "Erstgespräch durchführen",
			beschreibung:
				"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.",
			status: (step1Erledigt ? "erledigt" : "aktuell") as StepStatus,
			visible: true,
		},
		{
			key: "tss",
			label: "Terminservicestelle kontaktieren",
			beschreibung:
				"Kontaktiere die Terminservicestelle (TSS) deiner Kassenärztlichen Vereinigung (KV), um nach einem Therapieplatz zu suchen. Die TSS ist ein Service der KV, erreichbar unter 116117 oder online.",
			status: (step2Erledigt
				? "erledigt"
				: step1Erledigt
					? "aktuell"
					: "offen") as StepStatus,
			visible: hasDringlichkeit,
		},
		{
			key: "eigensuche",
			label: "Eigensuche durchführen",
			beschreibung:
				"Suche parallel selbst nach Therapieplätzen und dokumentiere deine Kontaktversuche.",
			status: (step3Erledigt
				? "erledigt"
				: step1Erledigt
					? "aktuell"
					: "offen") as StepStatus,
			visible: true,
		},
		{
			key: "antrag",
			label: "Kostenerstattung beantragen",
			beschreibung:
				"Reiche den Kostenerstattungsantrag bei deiner Krankenkasse ein.",
			status: (step4Available ? "aktuell" : "offen") as StepStatus,
			visible: true,
		},
	];

	return {
		hasDiagnose,
		hasDringlichkeit,
		tssKontaktiert,
		absagenUndKeineAntwort,
		steps,
	};
}

export async function setTssKontaktiert() {
	const d = new Date();
	const iso = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
	await dbExec(
		"UPDATE nutzer SET tss_kontaktiert_datum = $1, aktualisiert_am = NOW() WHERE id = 1",
		[iso],
	);
}

export async function createErstgespraech(
	therapeutId: number,
	datum: string,
	diagnose: string | null,
	dringlichkeitscode: boolean,
) {
	const result = await dbExec(
		`INSERT INTO sprechstunde (therapeut_id, diagnose, dringlichkeitscode)
		 VALUES ($1, $2, $3) RETURNING id`,
		[therapeutId, diagnose || null, dringlichkeitscode],
	);
	const sprechstundeId = (result.rows[0] as { id: number }).id;
	await dbExec(
		"INSERT INTO sitzung (sprechstunde_id, datum) VALUES ($1, $2)",
		[sprechstundeId, datum],
	);
}

export async function updateErstgespraech(
	id: number,
	diagnose: string | null,
	dringlichkeitscode: boolean,
) {
	await dbExec(
		"UPDATE sprechstunde SET diagnose = $1, dringlichkeitscode = $2 WHERE id = $3",
		[diagnose || null, dringlichkeitscode, id],
	);
}

export async function addSitzung(sprechstundeId: number, datum: string) {
	await dbExec(
		"INSERT INTO sitzung (sprechstunde_id, datum) VALUES ($1, $2)",
		[sprechstundeId, datum],
	);
}

export async function deleteSitzung(id: number) {
	await dbExec("DELETE FROM sitzung WHERE id = $1", [id]);
}

export async function deleteErstgespraech(id: number) {
	await dbExec("DELETE FROM sprechstunde WHERE id = $1", [id]);
}
  • Step 2: Verify no type errors in this file

Run: npx tsc --noEmit 2>&1 | head -30

Expect errors in other files that still import old types — that's fine for now.

  • Step 3: Commit
git add src/features/prozess/hooks.ts
git commit -m "rewrite process hooks: data-driven status derivation, erstgespräch CRUD"

Task 7: Update PhaseCard

Files:

  • Modify: src/features/prozess/components/phase-card.tsx

  • Step 1: Replace index prop with stepNumber

Change the interface and usage:

interface PhaseCardProps {
	label: string;
	beschreibung: string;
	status: PhaseStatus;
	stepNumber: number;
	children?: ReactNode;
}

export function PhaseCard({
	label,
	beschreibung,
	status,
	stepNumber,
	children,
}: PhaseCardProps) {

And in the circle rendering (line 49):

{status === "erledigt" ? <Check className="size-4" /> : stepNumber}
  • Step 2: Commit
git add src/features/prozess/components/phase-card.tsx
git commit -m "phase-card: replace index prop with explicit stepNumber"

Task 8: Rewrite ProcessStepper

Files:

  • Rewrite: src/features/prozess/components/process-stepper.tsx

  • Step 1: Replace entire file

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 { ErstgespraechRow, ProcessStatus } from "../hooks";
import {
	addSitzung,
	createErstgespraech,
	setTssKontaktiert,
	updateErstgespraech,
	useErstgespraeche,
	useSitzungen,
} from "../hooks";
import { PhaseCard } from "./phase-card";

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";

function todayISO() {
	const d = new Date();
	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({
	processStatus,
	tssKontaktiertDatum,
	kontaktGesamt,
	absagen,
	onUpdate,
}: ProcessStepperProps) {
	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>
				{currentIndex >= 0 && (
					<span className="text-sm text-muted-foreground">
						Schritt {currentIndex + 1} von {visibleSteps.length}
					</span>
				)}
			</div>

			<div className="flex flex-col gap-3">
				{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 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={() => setShowForm(true)}>
							<Plus className="mr-1 size-4" />
							Erstgespräch hinzufügen
						</Button>
					</div>
				</>
			)}
		</>
	);
}

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 form = useForm({
		defaultValues: {
			therapeut_id: "",
			datum: todayISO(),
			diagnose: "",
			dringlichkeitscode: false,
		},
		onSubmit: async ({ value }) => {
			const therapeutId = Number(value.therapeut_id);
			if (!therapeutId) return;
			await createErstgespraech(
				therapeutId,
				value.datum,
				value.diagnose || null,
				value.dringlichkeitscode,
			);
			onDone();
		},
	});

	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 />
			<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>
		</>
	);
}
  • Step 2: Verify no type errors

Run: npx tsc --noEmit 2>&1 | head -30

  • Step 3: Commit
git add src/features/prozess/components/process-stepper.tsx
git commit -m "rewrite process stepper: data-driven steps, erstgespräch list/form, conditional TSS"

Task 9: Update route to pass ProcessStatus

Files:

  • Modify: src/routes/prozess/index.tsx

  • Step 1: Replace entire file

import { createFileRoute } from "@tanstack/react-router";
import { ProcessStepper } from "@/features/prozess/components/process-stepper";
import {
	useErstgespraeche,
	useKontaktStats,
	useNutzer,
	useProcessStatus,
} from "@/features/prozess/hooks";

export const Route = createFileRoute("/prozess/")({
	component: ProzessPage,
});

function ProzessPage() {
	const {
		data: nutzer,
		loading: nutzerLoading,
		refetch: refetchNutzer,
	} = useNutzer();
	const {
		data: stats,
		loading: statsLoading,
		refetch: refetchStats,
	} = useKontaktStats();
	const {
		data: erstgespraeche,
		loading: egLoading,
		refetch: refetchEg,
	} = useErstgespraeche();

	if (nutzerLoading || statsLoading || egLoading) return <p>Laden</p>;
	if (!nutzer[0]) return <p>Bitte zuerst das Onboarding abschließen.</p>;

	const s = stats[0] ?? { gesamt: 0, absagen: 0, warteliste: 0, keine_antwort: 0 };

	const processStatus = useProcessStatus(nutzer[0], s, erstgespraeche);

	return (
		<ProcessStepper
			processStatus={processStatus}
			tssKontaktiertDatum={nutzer[0].tss_kontaktiert_datum}
			kontaktGesamt={Number(s.gesamt)}
			absagen={Number(s.absagen)}
			onUpdate={() => {
				refetchNutzer();
				refetchStats();
				refetchEg();
			}}
		/>
	);
}
  • Step 2: Verify no type errors

Run: npx tsc --noEmit 2>&1 | head -30

  • Step 3: Run dev server and verify /prozess loads

Run: npx vite build 2>&1 | tail -10

  • Step 4: Commit
git add src/routes/prozess/index.tsx
git commit -m "update prozess route: pass process status from data queries"

Chunk 3: Antrag checklist and scenarios

Task 10: Update antrag checklist

Files:

  • Modify: src/features/antrag/components/antrag-checklist.tsx

  • Step 1: Replace entire file

import { useErstgespraeche, 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;
	visible: boolean;
}

export function AntragChecklist() {
	const { data: nutzerRows, loading: nutzerLoading } = useNutzer();
	const { data: statsRows, loading: statsLoading } = useKontaktStats();
	const { data: erstgespraeche, loading: egLoading } = useErstgespraeche();

	if (nutzerLoading || statsLoading || egLoading) {
		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 hasDiagnose = erstgespraeche.some((e) => e.diagnose != null);
	const hasDringlichkeit = erstgespraeche.some((e) => e.dringlichkeitscode);

	const items: ChecklistItem[] = [
		{
			label: "Erstgespräch durchgeführt",
			fulfilled: hasDiagnose,
			visible: true,
		},
		{
			label: "Dringlichkeitscode erhalten",
			fulfilled: hasDringlichkeit,
			visible: true,
		},
		{
			label: "Terminservicestelle (TSS) kontaktiert",
			fulfilled: nutzer.tss_kontaktiert_datum != null,
			visible: hasDringlichkeit,
		},
		{
			label: "Therapeutensuche dokumentiert",
			fulfilled: Number(stats.absagen) + Number(stats.keine_antwort) >= 5,
			visible: true,
		},
		{
			label: "Absagenliste exportiert",
			fulfilled: false,
			visible: true,
		},
	];

	const visibleItems = items.filter((i) => i.visible);
	const fulfilledCount = visibleItems.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 {visibleItems.length} Voraussetzungen erfüllt
				</p>
			</div>

			<div className="flex flex-col gap-3">
				{visibleItems.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>
	);
}
  • Step 2: Verify no type errors

Run: npx tsc --noEmit 2>&1 | head -30

  • Step 3: Commit
git add src/features/antrag/components/antrag-checklist.tsx
git commit -m "update antrag checklist: derive checks from data, conditional TSS visibility"

Task 11: Rewrite scenarios

Files:

  • Modify: src/features/einstellungen/scenarios.ts

  • Step 1: Replace entire file

import { dbExec } from "@/shared/hooks/use-db";

const MOCK_VORNAMEN = [
	"Anna", "Marie", "Sophie", "Laura", "Julia",
	"Lena", "Sarah", "Lisa", "Katharina", "Eva",
	"Thomas", "Michael", "Stefan", "Andreas", "Daniel",
	"Markus", "Christian", "Martin", "Jan", "Felix",
];
const MOCK_NACHNAMEN = [
	"Müller", "Schmidt", "Schneider", "Fischer", "Weber",
	"Meyer", "Wagner", "Becker", "Schulz", "Hoffmann",
	"Koch", "Richter", "Wolf", "Schröder", "Neumann",
	"Schwarz", "Braun", "Zimmermann", "Hartmann", "Krüger",
];
const MOCK_STAEDTE = [
	"Berlin", "Hamburg", "München", "Köln", "Frankfurt",
	"Stuttgart", "Düsseldorf", "Leipzig", "Dortmund", "Essen",
];
const MOCK_THERAPIEFORMEN = [
	"verhaltenstherapie", "tiefenpsychologisch", "analytisch", "systemisch",
];
const MOCK_ERGEBNISSE = [
	"keine_antwort", "keine_antwort", "keine_antwort",
	"absage", "absage", "warteliste",
];
const MOCK_KANALE = ["telefon", "email", "online_formular", "persoenlich"];

function daysAgoISO(days: number) {
	const d = new Date();
	d.setDate(d.getDate() - days);
	return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}

async function clearData() {
	await dbExec("DELETE FROM sitzung");
	await dbExec("DELETE FROM kontakt");
	await dbExec("DELETE FROM sprechstunde");
	await dbExec("DELETE FROM therapeut");
}

async function resetNutzer() {
	await dbExec(
		`UPDATE nutzer SET
			tss_kontaktiert_datum = NULL,
			aktualisiert_am = NOW()
		WHERE id = 1`,
	);
}

async function seedErstgespraech() {
	const result = await dbExec(
		`INSERT INTO therapeut (name, stadt, therapieform, telefon, email)
		 VALUES ($1, $2, $3, $4, $5) RETURNING id`,
		[
			"Anna Müller",
			"Leipzig",
			"verhaltenstherapie",
			"0170 1000000",
			"anna.mueller@example.de",
		],
	);
	const therapeutId = (result.rows[0] as { id: number }).id;

	const spResult = await dbExec(
		`INSERT INTO sprechstunde (therapeut_id, diagnose, dringlichkeitscode)
		 VALUES ($1, 'F32.1', TRUE) RETURNING id`,
		[therapeutId],
	);
	const sprechstundeId = (spResult.rows[0] as { id: number }).id;

	await dbExec(
		`INSERT INTO sitzung (sprechstunde_id, datum) VALUES ($1, $2)`,
		[sprechstundeId, daysAgoISO(14)],
	);
	await dbExec(
		`INSERT INTO sitzung (sprechstunde_id, datum) VALUES ($1, $2)`,
		[sprechstundeId, daysAgoISO(7)],
	);
}

async function seedTssKontaktiert() {
	await dbExec(
		`UPDATE nutzer SET tss_kontaktiert_datum = $1, aktualisiert_am = NOW() WHERE id = 1`,
		[daysAgoISO(10)],
	);
}

async function seedKontakte() {
	for (let i = 0; i < 20; i++) {
		const vorname = MOCK_VORNAMEN[i % MOCK_VORNAMEN.length];
		const nachname = MOCK_NACHNAMEN[i % MOCK_NACHNAMEN.length];
		const stadt = MOCK_STAEDTE[i % MOCK_STAEDTE.length];
		const therapieform = MOCK_THERAPIEFORMEN[i % MOCK_THERAPIEFORMEN.length];

		const result = await dbExec(
			`INSERT INTO therapeut (name, stadt, therapieform, telefon, email)
			 VALUES ($1, $2, $3, $4, $5) RETURNING id`,
			[
				`${vorname} ${nachname}`,
				stadt,
				therapieform,
				`0${170 + i} ${1000000 + i * 12345}`,
				`${vorname.toLowerCase()}.${nachname.toLowerCase()}@example.de`,
			],
		);
		const therapeutId = (result.rows[0] as { id: number }).id;

		const kontaktCount = 1 + (i % 3);
		for (let j = 0; j < kontaktCount; j++) {
			const daysBack = (kontaktCount - j) * 3 + i;
			const ergebnis = MOCK_ERGEBNISSE[(i + j) % MOCK_ERGEBNISSE.length];
			const kanal = MOCK_KANALE[(i + j) % MOCK_KANALE.length];
			await dbExec(
				`INSERT INTO kontakt (therapeut_id, datum, kanal, ergebnis) VALUES ($1, $2, $3, $4)`,
				[therapeutId, daysAgoISO(daysBack), kanal, ergebnis],
			);
		}
	}
}

export type Scenario =
	| "erstgespraech"
	| "diagnose_erhalten"
	| "tss_kontaktiert"
	| "eigensuche"
	| "antrag_bereit";

const SCENARIO_ORDER: Scenario[] = [
	"erstgespraech",
	"diagnose_erhalten",
	"tss_kontaktiert",
	"eigensuche",
	"antrag_bereit",
];

const SCENARIO_SEEDERS: Record<Scenario, (() => Promise<void>) | null> = {
	erstgespraech: null,
	diagnose_erhalten: seedErstgespraech,
	tss_kontaktiert: seedTssKontaktiert,
	eigensuche: seedKontakte,
	antrag_bereit: null,
};

export async function seedToScenario(target: Scenario) {
	await clearData();
	await resetNutzer();

	const targetIndex = SCENARIO_ORDER.indexOf(target);
	if (targetIndex < 0) return;

	for (let i = 0; i <= targetIndex; i++) {
		const seeder = SCENARIO_SEEDERS[SCENARIO_ORDER[i]];
		if (seeder) await seeder();
	}
}
  • Step 2: Commit
git add src/features/einstellungen/scenarios.ts
git commit -m "rewrite scenarios: seed data rows instead of setting aktueller_schritt"

Task 12: Update settings page

Files:

  • Modify: src/features/einstellungen/components/settings-page.tsx

  • Step 1: Update imports and dropdown options

Replace the ProzessSchritt import and scenarioStep state:

Replace these two imports:

import { seedToStep } from "@/features/einstellungen/scenarios";
// ...
import type { ProzessSchritt } from "@/shared/db/schema";

with a single import:

import { seedToScenario, type Scenario } from "@/features/einstellungen/scenarios";

Change state:

const [scenarioStep, setScenarioStep] = useState<ProzessSchritt>("neu");

to:

const [scenarioStep, setScenarioStep] = useState<Scenario>("erstgespraech");

Change dropdown options:

<option value="erstgespraech">Schritt 1 — Erstgespräch</option>
<option value="diagnose_erhalten">Schritt 2 — Diagnose erhalten</option>
<option value="tss_kontaktiert">Schritt 3 — TSS kontaktiert</option>
<option value="eigensuche">Schritt 4 — Eigensuche</option>
<option value="antrag_bereit">Schritt 5 — Antrag bereit</option>

Change onClick:

onClick={async () => {
	await seedToScenario(scenarioStep);
	setScenarioStatus("done");
}}

Also change the onChange cast:

onChange={(e) => {
	setScenarioStep(e.target.value as Scenario);
	setScenarioStatus("idle");
}}
  • Step 2: Verify build

Run: npx vite build 2>&1 | tail -10

  • Step 3: Commit
git add src/features/einstellungen/components/settings-page.tsx
git commit -m "update settings: use new scenario types"

Task 13: Update deleteAllData and barrel export

Files:

  • Modify: src/features/kontakte/hooks.ts

  • Modify: src/features/prozess/index.ts

  • Step 1: Add sitzung to deleteAllData

The deleteAllData() function needs to also clear the new sitzung table. Add it before the existing deletes:

Change:

export async function deleteAllData() {
	await dbExec("DELETE FROM kontakt");
	await dbExec("DELETE FROM sprechstunde");
	await dbExec("DELETE FROM therapeut");
	await dbExec("DELETE FROM nutzer");
}

to:

export async function deleteAllData() {
	await dbExec("DELETE FROM sitzung");
	await dbExec("DELETE FROM kontakt");
	await dbExec("DELETE FROM sprechstunde");
	await dbExec("DELETE FROM therapeut");
	await dbExec("DELETE FROM nutzer");
}
  • Step 2: Update barrel export

Replace src/features/prozess/index.ts with:

export { ProcessStepper } from "./components/process-stepper";
export {
	useErstgespraeche,
	useKontaktStats,
	useNutzer,
	useProcessStatus,
} from "./hooks";
  • Step 3: Verify build

Run: npx tsc --noEmit 2>&1 | head -20

  • Step 4: Run lint

Run: npx biome check src/ 2>&1 | tail -10

  • Step 5: Commit
git add src/features/kontakte/hooks.ts src/features/prozess/index.ts
git commit -m "add sitzung to deleteAllData, update barrel export"

Task 14: Final verification

  • Step 1: Full build

Run: npx vite build 2>&1 | tail -15

Expected: Build succeeds with no errors.

  • Step 2: Run all tests

Run: npx vitest run --reporter verbose 2>&1

Expected: All tests pass (the deleted constants.test.ts should no longer run).

  • Step 3: Lint check

Run: npx biome check src/ 2>&1 | tail -10

Expected: No errors.

  • Step 4: Final commit if any fixes needed

Only if previous steps required fixes.