Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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
indexprop withstepNumber
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.