4b8f260777
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1736 lines
44 KiB
Markdown
1736 lines
44 KiB
Markdown
# 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**
|
|
|
|
```sql
|
|
-- 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**
|
|
|
|
```bash
|
|
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:
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```bash
|
|
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:
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```bash
|
|
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:
|
|
|
|
```typescript
|
|
// 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**
|
|
|
|
```bash
|
|
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:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```bash
|
|
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:
|
|
|
|
```typescript
|
|
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):
|
|
|
|
```typescript
|
|
{status === "erledigt" ? <Check className="size-4" /> : stepNumber}
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```bash
|
|
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:
|
|
```typescript
|
|
import { seedToStep } from "@/features/einstellungen/scenarios";
|
|
// ...
|
|
import type { ProzessSchritt } from "@/shared/db/schema";
|
|
```
|
|
with a single import:
|
|
```typescript
|
|
import { seedToScenario, type Scenario } from "@/features/einstellungen/scenarios";
|
|
```
|
|
|
|
Change state:
|
|
```typescript
|
|
const [scenarioStep, setScenarioStep] = useState<ProzessSchritt>("neu");
|
|
```
|
|
to:
|
|
```typescript
|
|
const [scenarioStep, setScenarioStep] = useState<Scenario>("erstgespraech");
|
|
```
|
|
|
|
Change dropdown options:
|
|
```html
|
|
<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:
|
|
```typescript
|
|
onClick={async () => {
|
|
await seedToScenario(scenarioStep);
|
|
setScenarioStatus("done");
|
|
}}
|
|
```
|
|
|
|
Also change the `onChange` cast:
|
|
```typescript
|
|
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**
|
|
|
|
```bash
|
|
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:
|
|
```typescript
|
|
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:
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```bash
|
|
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.
|