add settings page, contact editing, process flow, PWA updates

- settings tab with theme picker, data management, developer mode (mock data), PWA version/update
- contact detail page with inline editing of therapist info and contact attempts
- quick-action buttons on contact cards (update latest kontakt instead of creating duplicates)
- process stepper with inline step actions, Erstgespräch form embedded in step 1
- sticky bottom tab bar, Switch component, .htaccess in build output
- pre-warm PGlite on onboarding mount, spinner feedback on submit
- move DB init out of beforeLoad for faster initial page render
- bump to 2026.03.11.1

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 13:08:48 +01:00
parent 8490154dde
commit 16de72c017
21 changed files with 1491 additions and 128 deletions

View File

@@ -1,7 +1,7 @@
{ {
"name": "therapyfinder", "name": "therapyfinder",
"private": true, "private": true,
"version": "2026.03.11", "version": "2026.03.11.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

6
public/.htaccess Normal file
View File

@@ -0,0 +1,6 @@
RewriteEngine On
RewriteBase /tpf/
RewriteRule ^index\.html$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /tpf/index.html [L]

View File

@@ -0,0 +1,197 @@
import { useRegisterSW } from "virtual:pwa-register/react";
import { useNavigate } from "@tanstack/react-router";
import {
Database,
Download,
Monitor,
Moon,
RefreshCw,
Sun,
Trash2,
} from "lucide-react";
import { useState } from "react";
import { deleteAllData, seedMockData } from "@/features/kontakte/hooks";
import { Button } from "@/shared/components/ui/button";
import { Card, CardContent } from "@/shared/components/ui/card";
import { Label } from "@/shared/components/ui/label";
import { Separator } from "@/shared/components/ui/separator";
import { Switch } from "@/shared/components/ui/switch";
import { useThemeStore } from "@/shared/hooks/use-theme";
type Theme = "light" | "dark" | "system";
const themeOptions: { value: Theme; label: string; icon: typeof Sun }[] = [
{ value: "light", label: "Hell", icon: Sun },
{ value: "dark", label: "Dunkel", icon: Moon },
{ value: "system", label: "System", icon: Monitor },
];
export function SettingsPage() {
const { theme, setTheme } = useThemeStore();
const navigate = useNavigate();
const [confirmDelete, setConfirmDelete] = useState(false);
const [devMode, setDevMode] = useState(
() => localStorage.getItem("tpf-dev-mode") === "true",
);
const [mockStatus, setMockStatus] = useState<"idle" | "done">("idle");
const {
needRefresh: [needRefresh],
updateServiceWorker,
} = useRegisterSW();
const toggleDevMode = (checked: boolean) => {
setDevMode(checked);
localStorage.setItem("tpf-dev-mode", String(checked));
};
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">Einstellungen</h1>
{/* PWA Update */}
<section className="space-y-3">
<h2 className="text-lg font-semibold">App</h2>
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">
{needRefresh ? "Update verfügbar" : "App ist aktuell"}
</p>
<p className="text-xs text-muted-foreground">
v{__APP_VERSION__}
</p>
</div>
{needRefresh ? (
<Button size="sm" onClick={() => updateServiceWorker()}>
<Download className="mr-1 size-4" />
Aktualisieren
</Button>
) : (
<RefreshCw className="size-4 text-muted-foreground" />
)}
</CardContent>
</Card>
</section>
<Separator />
{/* Theme */}
<section className="space-y-3">
<h2 className="text-lg font-semibold">Erscheinungsbild</h2>
<div className="flex gap-2">
{themeOptions.map(({ value, label, icon: Icon }) => (
<button
key={value}
type="button"
onClick={() => setTheme(value)}
className={`flex flex-1 flex-col items-center gap-1.5 rounded-lg border p-3 text-sm transition-colors ${
theme === value
? "border-primary bg-primary/5 text-primary"
: "border-border text-muted-foreground hover:border-foreground/20"
}`}
>
<Icon className="size-5" />
{label}
</button>
))}
</div>
</section>
<Separator />
{/* Data */}
<section className="space-y-3">
<h2 className="text-lg font-semibold">Daten</h2>
<Card>
<CardContent className="space-y-3">
<p className="text-sm text-muted-foreground">
Alle Daten werden lokal in deinem Browser gespeichert. Beim
Löschen gehen alle Einträge unwiderruflich verloren.
</p>
{!confirmDelete ? (
<Button
variant="destructive"
size="sm"
onClick={() => setConfirmDelete(true)}
>
<Trash2 className="mr-1 size-4" />
Alle Daten löschen
</Button>
) : (
<div className="flex items-center gap-3">
<p className="text-sm font-medium text-destructive">
Wirklich alle Daten löschen?
</p>
<Button
variant="destructive"
size="sm"
onClick={async () => {
await deleteAllData();
navigate({ to: "/onboarding" });
}}
>
Ja, löschen
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setConfirmDelete(false)}
>
Abbrechen
</Button>
</div>
)}
</CardContent>
</Card>
</section>
<Separator />
{/* Developer */}
<section className="space-y-3">
<div className="flex items-center justify-between">
<Label htmlFor="dev-mode" className="text-lg font-semibold">
Entwickler
</Label>
<Switch
id="dev-mode"
checked={devMode}
onCheckedChange={toggleDevMode}
/>
</div>
{devMode && (
<Card>
<CardContent className="space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Testdaten</p>
<p className="text-xs text-muted-foreground">
20 Therapeut:innen mit Kontaktversuchen einfügen
</p>
</div>
<div className="flex items-center gap-2">
{mockStatus === "done" && (
<span className="text-xs text-green-600">Fertig</span>
)}
<Button
size="sm"
variant="outline"
onClick={async () => {
await seedMockData();
setMockStatus("done");
}}
>
<Database className="mr-1 size-4" />
Einfügen
</Button>
</div>
</div>
</CardContent>
</Card>
)}
</section>
</div>
);
}

View File

@@ -1,6 +1,9 @@
import { useNavigate } from "@tanstack/react-router";
import { Check, Clock, X } from "lucide-react";
import { Badge } from "@/shared/components/ui/badge"; import { Badge } from "@/shared/components/ui/badge";
import { Card, CardContent } from "@/shared/components/ui/card"; import { Card, CardContent } from "@/shared/components/ui/card";
import type { KontaktErgebnis } from "@/shared/db/schema"; import type { KontaktErgebnis } from "@/shared/db/schema";
import { dbExec } from "@/shared/hooks/use-db";
import { ERGEBNIS_LABELS } from "@/shared/lib/constants"; import { ERGEBNIS_LABELS } from "@/shared/lib/constants";
interface ContactCardProps { interface ContactCardProps {
@@ -10,6 +13,7 @@ interface ContactCardProps {
letzterKontakt: string | null; letzterKontakt: string | null;
letztesErgebnis: string | null; letztesErgebnis: string | null;
kontakteGesamt: number; kontakteGesamt: number;
onUpdate: () => void;
} }
const ergebnisVariant: Record< const ergebnisVariant: Record<
@@ -22,24 +26,100 @@ const ergebnisVariant: Record<
keine_antwort: "outline", keine_antwort: "outline",
}; };
async function quickUpdate(
therapeutId: number,
ergebnis: KontaktErgebnis,
onUpdate: () => void,
) {
// Update the most recent kontakt for this therapist, or create one if none exists
const result = await dbExec(
"SELECT id FROM kontakt WHERE therapeut_id = $1 ORDER BY datum DESC, id DESC LIMIT 1",
[therapeutId],
);
if (result.rows.length > 0) {
const kontaktId = (result.rows[0] as { id: number }).id;
await dbExec("UPDATE kontakt SET ergebnis = $1 WHERE id = $2", [
ergebnis,
kontaktId,
]);
} else {
const d = new Date();
const datum = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
await dbExec(
"INSERT INTO kontakt (therapeut_id, datum, kanal, ergebnis) VALUES ($1, $2, 'telefon', $3)",
[therapeutId, datum, ergebnis],
);
}
onUpdate();
}
export function ContactCard({ export function ContactCard({
id,
name, name,
stadt, stadt,
letzterKontakt, letzterKontakt,
letztesErgebnis, letztesErgebnis,
kontakteGesamt, kontakteGesamt,
onUpdate,
}: ContactCardProps) { }: ContactCardProps) {
const navigate = useNavigate();
return ( return (
<Card> <Card
<CardContent className="flex items-center justify-between gap-4"> className="cursor-pointer transition-colors hover:bg-accent/50"
onClick={() =>
navigate({ to: "/kontakte/$id", params: { id: String(id) } })
}
>
<CardContent className="flex items-center gap-3">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="font-medium">{name}</p> <p className="font-medium">{name}</p>
{stadt && <p className="text-sm text-muted-foreground">{stadt}</p>} {stadt && <p className="text-sm text-muted-foreground">{stadt}</p>}
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{kontakteGesamt} Kontakt{kontakteGesamt !== 1 ? "e" : ""} {kontakteGesamt} Kontakt{kontakteGesamt !== 1 ? "e" : ""}
{letzterKontakt && <> · Letzter: {letzterKontakt}</>} {letzterKontakt && <> · {letzterKontakt}</>}
</p> </p>
</div> </div>
<div className="flex items-center gap-1">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
quickUpdate(id, "zusage", onUpdate);
}}
className="rounded-md p-1.5 text-green-600 hover:bg-green-50 dark:text-green-400 dark:hover:bg-green-950"
aria-label="Zusage"
title="Zusage"
>
<Check className="size-4" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
quickUpdate(id, "warteliste", onUpdate);
}}
className="rounded-md p-1.5 text-amber-600 hover:bg-amber-50 dark:text-amber-400 dark:hover:bg-amber-950"
aria-label="Warteliste"
title="Warteliste"
>
<Clock className="size-4" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
quickUpdate(id, "absage", onUpdate);
}}
className="rounded-md p-1.5 text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950"
aria-label="Absage"
title="Absage"
>
<X className="size-4" />
</button>
</div>
{letztesErgebnis && ( {letztesErgebnis && (
<Badge <Badge
variant={ variant={

View File

@@ -0,0 +1,482 @@
import { useForm } from "@tanstack/react-form";
import { Link, useNavigate } from "@tanstack/react-router";
import { ArrowLeft, Plus, Trash2 } from "lucide-react";
import {
createKontakt,
deleteTherapeut,
updateKontakt,
updateTherapeut,
useKontakteForTherapeut,
useTherapeut,
} from "@/features/kontakte/hooks";
import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button";
import { Card, CardContent } from "@/shared/components/ui/card";
import { Input } from "@/shared/components/ui/input";
import { Label } from "@/shared/components/ui/label";
import { Separator } from "@/shared/components/ui/separator";
import type { KontaktErgebnis, KontaktKanal } from "@/shared/db/schema";
import { kontaktErgebnisEnum, kontaktKanalEnum } from "@/shared/db/schema";
import { ERGEBNIS_LABELS, KANAL_LABELS } from "@/shared/lib/constants";
function todayISO() {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
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";
const ergebnisVariant: Record<
KontaktErgebnis,
"default" | "secondary" | "destructive" | "outline"
> = {
zusage: "default",
warteliste: "secondary",
absage: "destructive",
keine_antwort: "outline",
};
export function ContactDetail({ therapeutId }: { therapeutId: number }) {
const navigate = useNavigate();
const {
data: therapeuten,
loading: tLoading,
refetch: refetchT,
} = useTherapeut(therapeutId);
const {
data: kontakte,
loading: kLoading,
refetch: refetchK,
} = useKontakteForTherapeut(therapeutId);
const therapeut = therapeuten[0];
if (tLoading || kLoading) return <p>Laden</p>;
if (!therapeut) return <p>Therapeut:in nicht gefunden.</p>;
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Link
to="/kontakte"
className="text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="size-5" />
</Link>
<h1 className="text-2xl font-bold">{therapeut.name}</h1>
</div>
<TherapeutEditForm therapeut={therapeut} onSaved={refetchT} />
<Separator />
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Kontaktversuche</h2>
</div>
<NewKontaktForm
therapeutId={therapeutId}
onSaved={() => {
refetchK();
refetchT();
}}
/>
{kontakte.length === 0 ? (
<p className="text-sm text-muted-foreground">
Noch keine Kontaktversuche.
</p>
) : (
<div className="space-y-3">
{kontakte.map((k) => (
<KontaktEditCard
key={k.id}
kontakt={k}
onSaved={() => {
refetchK();
refetchT();
}}
/>
))}
</div>
)}
<Separator />
<Button
variant="destructive"
size="sm"
onClick={async () => {
await deleteTherapeut(therapeutId);
navigate({ to: "/kontakte" });
}}
>
<Trash2 className="mr-1 size-4" />
Therapeut:in löschen
</Button>
</div>
);
}
function TherapeutEditForm({
therapeut,
onSaved,
}: {
therapeut: {
id: number;
name: string;
adresse: string | null;
plz: string | null;
stadt: string | null;
telefon: string | null;
email: string | null;
website: string | null;
therapieform: string | null;
};
onSaved: () => void;
}) {
const form = useForm({
defaultValues: {
name: therapeut.name,
adresse: therapeut.adresse ?? "",
plz: therapeut.plz ?? "",
stadt: therapeut.stadt ?? "",
telefon: therapeut.telefon ?? "",
email: therapeut.email ?? "",
website: therapeut.website ?? "",
therapieform: therapeut.therapieform ?? "",
},
onSubmit: async ({ value }) => {
await updateTherapeut(therapeut.id, value);
onSaved();
},
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
className="space-y-3"
>
<div className="grid grid-cols-2 gap-3">
<form.Field name="name">
{(field) => (
<div className="space-y-1">
<Label htmlFor="t-name">Name *</Label>
<Input
id="t-name"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
/>
</div>
)}
</form.Field>
<form.Field name="stadt">
{(field) => (
<div className="space-y-1">
<Label htmlFor="t-stadt">Stadt</Label>
<Input
id="t-stadt"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
/>
</div>
)}
</form.Field>
</div>
<div className="grid grid-cols-2 gap-3">
<form.Field name="telefon">
{(field) => (
<div className="space-y-1">
<Label htmlFor="t-telefon">Telefon</Label>
<Input
id="t-telefon"
type="tel"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
/>
</div>
)}
</form.Field>
<form.Field name="email">
{(field) => (
<div className="space-y-1">
<Label htmlFor="t-email">E-Mail</Label>
<Input
id="t-email"
type="email"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
/>
</div>
)}
</form.Field>
</div>
<Button type="submit" size="sm">
Speichern
</Button>
</form>
);
}
function NewKontaktForm({
therapeutId,
onSaved,
}: {
therapeutId: number;
onSaved: () => void;
}) {
const form = useForm({
defaultValues: {
datum: todayISO(),
kanal: "telefon" as KontaktKanal,
ergebnis: "keine_antwort" as KontaktErgebnis,
notiz: "",
},
onSubmit: async ({ value }) => {
await createKontakt({
therapeut_id: therapeutId,
datum: value.datum,
kanal: value.kanal,
ergebnis: value.ergebnis,
notiz: value.notiz,
});
form.reset();
onSaved();
},
});
return (
<Card>
<CardContent>
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
className="space-y-3"
>
<p className="text-sm font-medium">Neuer Kontaktversuch</p>
<div className="grid grid-cols-3 gap-3">
<form.Field name="datum">
{(field) => (
<div className="space-y-1">
<Label htmlFor="nk-datum">Datum</Label>
<input
id="nk-datum"
type="date"
className={inputClasses}
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
</div>
)}
</form.Field>
<form.Field name="kanal">
{(field) => (
<div className="space-y-1">
<Label htmlFor="nk-kanal">Kanal</Label>
<select
id="nk-kanal"
className={inputClasses}
value={field.state.value}
onChange={(e) =>
field.handleChange(e.target.value as KontaktKanal)
}
>
{kontaktKanalEnum.options.map((k) => (
<option key={k} value={k}>
{KANAL_LABELS[k]}
</option>
))}
</select>
</div>
)}
</form.Field>
<form.Field name="ergebnis">
{(field) => (
<div className="space-y-1">
<Label htmlFor="nk-ergebnis">Ergebnis</Label>
<select
id="nk-ergebnis"
className={inputClasses}
value={field.state.value}
onChange={(e) =>
field.handleChange(e.target.value as KontaktErgebnis)
}
>
{kontaktErgebnisEnum.options.map((k) => (
<option key={k} value={k}>
{ERGEBNIS_LABELS[k]}
</option>
))}
</select>
</div>
)}
</form.Field>
</div>
<form.Field name="notiz">
{(field) => (
<div className="space-y-1">
<Label htmlFor="nk-notiz">Notiz</Label>
<textarea
id="nk-notiz"
rows={2}
className={`${inputClasses} min-h-[60px] py-2`}
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
</div>
)}
</form.Field>
<Button type="submit" size="sm">
<Plus className="mr-1 size-4" />
Hinzufügen
</Button>
</form>
</CardContent>
</Card>
);
}
function KontaktEditCard({
kontakt,
onSaved,
}: {
kontakt: {
id: number;
therapeut_id: number;
datum: string;
kanal: string;
ergebnis: string;
notiz: string | null;
};
onSaved: () => void;
}) {
const form = useForm({
defaultValues: {
datum: kontakt.datum,
kanal: kontakt.kanal as KontaktKanal,
ergebnis: kontakt.ergebnis as KontaktErgebnis,
notiz: kontakt.notiz ?? "",
},
onSubmit: async ({ value }) => {
await updateKontakt(kontakt.id, {
therapeut_id: kontakt.therapeut_id,
datum: value.datum,
kanal: value.kanal,
ergebnis: value.ergebnis,
notiz: value.notiz,
});
onSaved();
},
});
return (
<Card>
<CardContent>
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
className="space-y-3"
>
<div className="flex items-center justify-between">
<p className="text-sm font-medium">{kontakt.datum}</p>
<Badge
variant={
ergebnisVariant[kontakt.ergebnis as KontaktErgebnis] ??
"outline"
}
>
{ERGEBNIS_LABELS[kontakt.ergebnis as KontaktErgebnis] ??
kontakt.ergebnis}
</Badge>
</div>
<div className="grid grid-cols-3 gap-3">
<form.Field name="datum">
{(field) => (
<div className="space-y-1">
<Label>Datum</Label>
<input
type="date"
className={inputClasses}
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
</div>
)}
</form.Field>
<form.Field name="kanal">
{(field) => (
<div className="space-y-1">
<Label>Kanal</Label>
<select
className={inputClasses}
value={field.state.value}
onChange={(e) =>
field.handleChange(e.target.value as KontaktKanal)
}
>
{kontaktKanalEnum.options.map((k) => (
<option key={k} value={k}>
{KANAL_LABELS[k]}
</option>
))}
</select>
</div>
)}
</form.Field>
<form.Field name="ergebnis">
{(field) => (
<div className="space-y-1">
<Label>Ergebnis</Label>
<select
className={inputClasses}
value={field.state.value}
onChange={(e) =>
field.handleChange(e.target.value as KontaktErgebnis)
}
>
{kontaktErgebnisEnum.options.map((k) => (
<option key={k} value={k}>
{ERGEBNIS_LABELS[k]}
</option>
))}
</select>
</div>
)}
</form.Field>
</div>
<form.Field name="notiz">
{(field) => (
<div className="space-y-1">
<Label>Notiz</Label>
<textarea
rows={2}
className={`${inputClasses} min-h-[60px] py-2`}
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
</div>
)}
</form.Field>
<Button type="submit" size="sm" variant="outline">
Speichern
</Button>
</form>
</CardContent>
</Card>
);
}

View File

@@ -1,10 +1,11 @@
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { Plus } from "lucide-react";
import { useTherapeutenListe } from "@/features/kontakte/hooks"; import { useTherapeutenListe } from "@/features/kontakte/hooks";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import { ContactCard } from "./contact-card"; import { ContactCard } from "./contact-card";
export function ContactList() { export function ContactList() {
const { data, loading } = useTherapeutenListe(); const { data, loading, refetch } = useTherapeutenListe();
if (loading) return <p>Laden</p>; if (loading) return <p>Laden</p>;
@@ -12,8 +13,11 @@ export function ContactList() {
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Kontakte</h1> <h1 className="text-2xl font-bold">Kontakte</h1>
<Button asChild> <Button asChild size="sm">
<Link to="/kontakte/neu">+ Neu</Link> <Link to="/kontakte/neu">
<Plus className="mr-1 size-4" />
Neu
</Link>
</Button> </Button>
</div> </div>
@@ -37,6 +41,7 @@ export function ContactList() {
letzterKontakt={t.letzter_kontakt} letzterKontakt={t.letzter_kontakt}
letztesErgebnis={t.letztes_ergebnis} letztesErgebnis={t.letztes_ergebnis}
kontakteGesamt={Number(t.kontakte_gesamt)} kontakteGesamt={Number(t.kontakte_gesamt)}
onUpdate={refetch}
/> />
))} ))}
</div> </div>

View File

@@ -1,17 +1,31 @@
import { dbExec, useDbQuery } from "@/shared/hooks/use-db"; import { dbExec, useDbQuery } from "@/shared/hooks/use-db";
import type { KontaktFormData, TherapeutFormData } from "./schema"; import type { KontaktFormData, TherapeutFormData } from "./schema";
interface TherapeutMitKontakte { export interface TherapeutMitKontakte {
id: number; id: number;
name: string; name: string;
stadt: string | null; stadt: string | null;
therapieform: string | null; therapieform: string | null;
telefon: string | null;
email: string | null;
letzter_kontakt: string | null; letzter_kontakt: string | null;
letztes_ergebnis: string | null; letztes_ergebnis: string | null;
kontakte_gesamt: number; kontakte_gesamt: number;
} }
interface KontaktRow { export interface TherapeutRow {
id: number;
name: string;
adresse: string | null;
plz: string | null;
stadt: string | null;
telefon: string | null;
email: string | null;
website: string | null;
therapieform: string | null;
}
export interface KontaktRow {
id: number; id: number;
therapeut_id: number; therapeut_id: number;
datum: string; datum: string;
@@ -24,7 +38,7 @@ interface KontaktRow {
export function useTherapeutenListe() { export function useTherapeutenListe() {
return useDbQuery<TherapeutMitKontakte>(` return useDbQuery<TherapeutMitKontakte>(`
SELECT SELECT
t.id, t.name, t.stadt, t.therapieform, t.id, t.name, t.stadt, t.therapieform, t.telefon, t.email,
(SELECT k.datum::text FROM kontakt k WHERE k.therapeut_id = t.id ORDER BY k.datum DESC LIMIT 1) as letzter_kontakt, (SELECT k.datum::text FROM kontakt k WHERE k.therapeut_id = t.id ORDER BY k.datum DESC LIMIT 1) as letzter_kontakt,
(SELECT k.ergebnis FROM kontakt k WHERE k.therapeut_id = t.id ORDER BY k.datum DESC LIMIT 1) as letztes_ergebnis, (SELECT k.ergebnis FROM kontakt k WHERE k.therapeut_id = t.id ORDER BY k.datum DESC LIMIT 1) as letztes_ergebnis,
(SELECT COUNT(*) FROM kontakt k WHERE k.therapeut_id = t.id) as kontakte_gesamt (SELECT COUNT(*) FROM kontakt k WHERE k.therapeut_id = t.id) as kontakte_gesamt
@@ -33,9 +47,17 @@ export function useTherapeutenListe() {
`); `);
} }
export function useTherapeut(id: number) {
return useDbQuery<TherapeutRow>(
"SELECT id, name, adresse, plz, stadt, telefon, email, website, therapieform FROM therapeut WHERE id = $1",
[id],
[id],
);
}
export function useKontakteForTherapeut(therapeutId: number) { export function useKontakteForTherapeut(therapeutId: number) {
return useDbQuery<KontaktRow>( return useDbQuery<KontaktRow>(
"SELECT * FROM kontakt WHERE therapeut_id = $1 ORDER BY datum DESC", "SELECT id, therapeut_id, datum::text as datum, kanal, ergebnis, notiz, antwort_datum::text as antwort_datum FROM kontakt WHERE therapeut_id = $1 ORDER BY datum DESC",
[therapeutId], [therapeutId],
[therapeutId], [therapeutId],
); );
@@ -62,6 +84,23 @@ export async function createTherapeut(
return (result.rows[0] as { id: number }).id; return (result.rows[0] as { id: number }).id;
} }
export async function updateTherapeut(id: number, data: TherapeutFormData) {
await dbExec(
`UPDATE therapeut SET name=$1, adresse=$2, plz=$3, stadt=$4, telefon=$5, email=$6, website=$7, therapieform=$8 WHERE id=$9`,
[
data.name,
data.adresse,
data.plz,
data.stadt,
data.telefon,
data.email,
data.website,
data.therapieform,
id,
],
);
}
export async function createKontakt(data: KontaktFormData) { export async function createKontakt(data: KontaktFormData) {
await dbExec( await dbExec(
`INSERT INTO kontakt (therapeut_id, datum, kanal, ergebnis, notiz) `INSERT INTO kontakt (therapeut_id, datum, kanal, ergebnis, notiz)
@@ -69,3 +108,152 @@ export async function createKontakt(data: KontaktFormData) {
[data.therapeut_id, data.datum, data.kanal, data.ergebnis, data.notiz], [data.therapeut_id, data.datum, data.kanal, data.ergebnis, data.notiz],
); );
} }
export async function updateKontakt(id: number, data: KontaktFormData) {
await dbExec(
`UPDATE kontakt SET datum=$1, kanal=$2, ergebnis=$3, notiz=$4 WHERE id=$5`,
[data.datum, data.kanal, data.ergebnis, data.notiz, id],
);
}
export async function updateKontaktErgebnis(id: number, ergebnis: string) {
await dbExec("UPDATE kontakt SET ergebnis=$1 WHERE id=$2", [ergebnis, id]);
}
export async function deleteTherapeut(id: number) {
await dbExec("DELETE FROM therapeut WHERE id = $1", [id]);
}
export async function deleteAllData() {
await dbExec("DELETE FROM kontakt");
await dbExec("DELETE FROM sprechstunde");
await dbExec("DELETE FROM therapeut");
await dbExec("DELETE FROM nutzer");
}
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",
];
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")}`;
}
export async function seedMockData() {
const kanale = ["telefon", "email", "online_formular", "persoenlich"];
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 = kanale[(i + j) % kanale.length];
await dbExec(
`INSERT INTO kontakt (therapeut_id, datum, kanal, ergebnis) VALUES ($1, $2, $3, $4)`,
[therapeutId, daysAgoISO(daysBack), kanal, ergebnis],
);
}
}
// Create a sprechstunde for the first therapist (enables step 2)
const firstT = await dbExec("SELECT id FROM therapeut ORDER BY id LIMIT 1");
if (firstT.rows.length > 0) {
const tId = (firstT.rows[0] as { id: number }).id;
await dbExec(
"INSERT INTO sprechstunde (therapeut_id, datum, ergebnis, diagnose, dringlichkeitscode) VALUES ($1, $2, 'erstgespraech', 'F32.1', true)",
[tId, daysAgoISO(14)],
);
}
// Advance user to eigensuche step so they can see the full kontakt flow
await dbExec(
"UPDATE nutzer SET aktueller_schritt = 'eigensuche', aktualisiert_am = NOW() WHERE id = 1",
);
}

View File

@@ -1,7 +1,19 @@
export type {
KontaktRow,
TherapeutMitKontakte,
TherapeutRow,
} from "./hooks";
export { export {
createKontakt, createKontakt,
createTherapeut, createTherapeut,
deleteAllData,
deleteTherapeut,
seedMockData,
updateKontakt,
updateKontaktErgebnis,
updateTherapeut,
useKontakteForTherapeut, useKontakteForTherapeut,
useTherapeut,
useTherapeutenListe, useTherapeutenListe,
} from "./hooks"; } from "./hooks";
export type { KontaktFormData, TherapeutFormData } from "./schema"; export type { KontaktFormData, TherapeutFormData } from "./schema";

View File

@@ -1,14 +1,24 @@
import { useForm } from "@tanstack/react-form"; import { useForm } from "@tanstack/react-form";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import { Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import { Input } from "@/shared/components/ui/input"; import { Input } from "@/shared/components/ui/input";
import { Label } from "@/shared/components/ui/label"; import { Label } from "@/shared/components/ui/label";
import { getDb } from "@/shared/db/client";
import type { ProzessSchritt } from "@/shared/db/schema"; import type { ProzessSchritt } from "@/shared/db/schema";
import { dbExec } from "@/shared/hooks/use-db"; import { dbExec } from "@/shared/hooks/use-db";
import { PROZESS_SCHRITTE } from "@/shared/lib/constants"; import { PROZESS_SCHRITTE } from "@/shared/lib/constants";
export function OnboardingForm() { export function OnboardingForm() {
const navigate = useNavigate(); const navigate = useNavigate();
const [dbReady, setDbReady] = useState(false);
const [submitting, setSubmitting] = useState(false);
// Pre-warm DB on mount so submit is instant
useEffect(() => {
getDb().then(() => setDbReady(true));
}, []);
const form = useForm({ const form = useForm({
defaultValues: { defaultValues: {
@@ -19,21 +29,28 @@ export function OnboardingForm() {
aktueller_schritt: "neu" as ProzessSchritt, aktueller_schritt: "neu" as ProzessSchritt,
}, },
onSubmit: async ({ value }) => { onSubmit: async ({ value }) => {
await dbExec( setSubmitting(true);
`INSERT INTO nutzer (name, plz, ort, krankenkasse, aktueller_schritt) try {
VALUES ($1, $2, $3, $4, $5)`, await dbExec(
[ `INSERT INTO nutzer (name, plz, ort, krankenkasse, aktueller_schritt)
value.name, VALUES ($1, $2, $3, $4, $5)`,
value.plz, [
value.ort, value.name,
value.krankenkasse, value.plz,
value.aktueller_schritt, value.ort,
], value.krankenkasse,
); value.aktueller_schritt,
navigate({ to: "/prozess" }); ],
);
navigate({ to: "/prozess" });
} finally {
setSubmitting(false);
}
}, },
}); });
const isDisabled = !dbReady || submitting;
return ( return (
<div className="flex min-h-[80vh] flex-col items-center justify-center"> <div className="flex min-h-[80vh] flex-col items-center justify-center">
<div className="w-full max-w-md space-y-6"> <div className="w-full max-w-md space-y-6">
@@ -145,8 +162,20 @@ export function OnboardingForm() {
)} )}
</form.Field> </form.Field>
<Button type="submit" className="w-full"> <Button type="submit" className="w-full" disabled={isDisabled}>
Weiter {submitting ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
Wird gespeichert
</>
) : !dbReady ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
Datenbank wird geladen
</>
) : (
"Weiter"
)}
</Button> </Button>
</form> </form>
</div> </div>

View File

@@ -1,3 +1,5 @@
import { Check } from "lucide-react";
import type { ReactNode } from "react";
import { Badge } from "@/shared/components/ui/badge"; import { Badge } from "@/shared/components/ui/badge";
import { Card, CardContent } from "@/shared/components/ui/card"; import { Card, CardContent } from "@/shared/components/ui/card";
import { cn } from "@/shared/lib/utils"; import { cn } from "@/shared/lib/utils";
@@ -9,6 +11,7 @@ interface PhaseCardProps {
beschreibung: string; beschreibung: string;
status: PhaseStatus; status: PhaseStatus;
index: number; index: number;
children?: ReactNode;
} }
export function PhaseCard({ export function PhaseCard({
@@ -16,6 +19,7 @@ export function PhaseCard({
beschreibung, beschreibung,
status, status,
index, index,
children,
}: PhaseCardProps) { }: PhaseCardProps) {
return ( return (
<Card <Card
@@ -25,28 +29,31 @@ export function PhaseCard({
status === "erledigt" && "opacity-60", status === "erledigt" && "opacity-60",
)} )}
> >
<CardContent className="flex items-start gap-4"> <CardContent className="space-y-3">
<div <div className="flex items-start gap-4">
className={cn( <div
"flex h-8 w-8 shrink-0 items-center justify-center rounded-full border-2 text-sm font-bold", className={cn(
status === "erledigt" && "flex h-8 w-8 shrink-0 items-center justify-center rounded-full border-2 text-sm font-bold",
"border-primary bg-primary text-primary-foreground", status === "erledigt" &&
status === "aktuell" && "border-primary text-primary", "border-primary bg-primary text-primary-foreground",
status === "offen" && status === "aktuell" && "border-primary text-primary",
"border-muted-foreground/40 text-muted-foreground/40", status === "offen" &&
)} "border-muted-foreground/40 text-muted-foreground/40",
> )}
{status === "erledigt" ? "✓" : index + 1} >
</div> {status === "erledigt" ? <Check className="size-4" /> : index + 1}
<div className="flex flex-col gap-1"> </div>
<div className="flex items-center gap-2"> <div className="flex flex-col gap-1">
<span className="font-medium">{label}</span> <div className="flex items-center gap-2">
{status === "aktuell" && <Badge>Aktuell</Badge>} <span className="font-medium">{label}</span>
{status === "aktuell" && <Badge>Aktuell</Badge>}
</div>
{status === "aktuell" && (
<p className="text-sm text-muted-foreground">{beschreibung}</p>
)}
</div> </div>
{status === "aktuell" && (
<p className="text-sm text-muted-foreground">{beschreibung}</p>
)}
</div> </div>
{children}
</CardContent> </CardContent>
</Card> </Card>
); );

View File

@@ -1,10 +1,12 @@
import { import { useForm } from "@tanstack/react-form";
Card, import { Link } from "@tanstack/react-router";
CardContent, import { useState } from "react";
CardHeader, import { useTherapeutenListe } from "@/features/kontakte/hooks";
CardTitle, import { Button } from "@/shared/components/ui/button";
} from "@/shared/components/ui/card"; import { Label } from "@/shared/components/ui/label";
import { Separator } from "@/shared/components/ui/separator";
import type { ProzessSchritt } from "@/shared/db/schema"; import type { ProzessSchritt } from "@/shared/db/schema";
import { dbExec } from "@/shared/hooks/use-db";
import { PROZESS_SCHRITTE } from "@/shared/lib/constants"; import { PROZESS_SCHRITTE } from "@/shared/lib/constants";
import { PhaseCard } from "./phase-card"; import { PhaseCard } from "./phase-card";
@@ -12,19 +14,29 @@ interface ProcessStepperProps {
aktuellerSchritt: ProzessSchritt; aktuellerSchritt: ProzessSchritt;
kontaktGesamt: number; kontaktGesamt: number;
absagen: number; absagen: number;
onUpdate: () => void;
}
const inputClasses =
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring";
function todayISO() {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
} }
export function ProcessStepper({ export function ProcessStepper({
aktuellerSchritt, aktuellerSchritt,
kontaktGesamt, kontaktGesamt,
absagen, absagen,
onUpdate,
}: ProcessStepperProps) { }: ProcessStepperProps) {
const currentIndex = PROZESS_SCHRITTE.findIndex( const currentIndex = PROZESS_SCHRITTE.findIndex(
(s) => s.key === aktuellerSchritt, (s) => s.key === aktuellerSchritt,
); );
return ( return (
<div className="mx-auto flex w-full max-w-lg flex-col gap-6 p-4"> <div className="flex flex-col gap-6">
<div className="flex items-baseline justify-between"> <div className="flex items-baseline justify-between">
<h1 className="text-2xl font-bold">Dein Fortschritt</h1> <h1 className="text-2xl font-bold">Dein Fortschritt</h1>
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
@@ -48,24 +60,235 @@ export function ProcessStepper({
beschreibung={schritt.beschreibung} beschreibung={schritt.beschreibung}
status={status} status={status}
index={i} index={i}
/> >
{status === "aktuell" && (
<StepAction
schritt={schritt.key}
kontaktGesamt={kontaktGesamt}
absagen={absagen}
onUpdate={onUpdate}
/>
)}
</PhaseCard>
); );
})} })}
</div> </div>
{currentIndex >= 3 && (
<Card>
<CardHeader>
<CardTitle>Kontaktübersicht</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
{kontaktGesamt} Kontaktversuche insgesamt, davon {absagen}{" "}
Absagen.
</p>
</CardContent>
</Card>
)}
</div> </div>
); );
} }
function StepAction({
schritt,
kontaktGesamt,
absagen,
onUpdate,
}: {
schritt: ProzessSchritt;
kontaktGesamt: number;
absagen: number;
onUpdate: () => void;
}) {
switch (schritt) {
case "neu":
return <SprechstundeForm onDone={onUpdate} />;
case "sprechstunde_absolviert":
return (
<AdvanceButton
nextStep="diagnose_erhalten"
label="Diagnose erhalten"
onDone={onUpdate}
/>
);
case "diagnose_erhalten":
return (
<AdvanceButton
nextStep="tss_beantragt"
label="TSS kontaktiert"
onDone={onUpdate}
/>
);
case "tss_beantragt":
return (
<AdvanceButton
nextStep="eigensuche"
label="Eigensuche starten"
onDone={onUpdate}
/>
);
case "eigensuche":
return (
<>
<Separator />
<div className="text-sm text-muted-foreground">
{kontaktGesamt} Kontaktversuche, davon {absagen} Absagen.{" "}
<Link to="/kontakte" className="text-primary underline">
Kontakte verwalten
</Link>
</div>
<AdvanceButton
nextStep="antrag_gestellt"
label="Antrag stellen"
onDone={onUpdate}
/>
</>
);
case "antrag_gestellt":
return (
<>
<Separator />
<p className="text-sm text-muted-foreground">
Dein Kostenerstattungsantrag wurde eingereicht.{" "}
<Link to="/antrag" className="text-primary underline">
Zum Antrag
</Link>
</p>
</>
);
default:
return null;
}
}
function AdvanceButton({
nextStep,
label,
onDone,
}: {
nextStep: ProzessSchritt;
label: string;
onDone: () => void;
}) {
return (
<>
<Separator />
<Button
size="sm"
onClick={async () => {
await dbExec(
"UPDATE nutzer SET aktueller_schritt = $1, aktualisiert_am = NOW() WHERE id = 1",
[nextStep],
);
onDone();
}}
>
{label}
</Button>
</>
);
}
function SprechstundeForm({ onDone }: { onDone: () => void }) {
const { data: therapeuten, loading } = useTherapeutenListe();
const [saved, setSaved] = useState(false);
const form = useForm({
defaultValues: {
therapeut_id: "",
datum: todayISO(),
diagnose: "",
},
onSubmit: async ({ value }) => {
const therapeutId = Number(value.therapeut_id);
if (!therapeutId) return;
await dbExec(
`INSERT INTO sprechstunde (therapeut_id, datum, ergebnis, diagnose) VALUES ($1, $2, 'erstgespraech', $3)`,
[therapeutId, value.datum, value.diagnose || null],
);
await dbExec(
"UPDATE nutzer SET aktueller_schritt = 'sprechstunde_absolviert', aktualisiert_am = NOW() WHERE id = 1",
);
setSaved(true);
onDone();
},
});
if (saved) {
return (
<p className="text-sm font-medium text-green-600 dark:text-green-400">
Sprechstunde erfasst.
</p>
);
}
return (
<>
<Separator />
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
className="space-y-3"
>
<p className="text-sm font-medium">Erstgespräch erfassen</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>
<input
type="date"
className={inputClasses}
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
</div>
)}
</form.Field>
<form.Field name="diagnose">
{(field) => (
<div className="space-y-1">
<Label>Diagnose (optional)</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>
<Button type="submit" size="sm" disabled={therapeuten.length === 0}>
Sprechstunde erfassen
</Button>
</form>
</>
);
}

View File

@@ -10,7 +10,9 @@
import { Route as rootRouteImport } from './routes/__root' import { Route as rootRouteImport } from './routes/__root'
import { Route as AntragIndexRouteImport } from './routes/antrag/index' import { Route as AntragIndexRouteImport } from './routes/antrag/index'
import { Route as EinstellungenIndexRouteImport } from './routes/einstellungen/index'
import { Route as IndexRouteImport } from './routes/index' import { Route as IndexRouteImport } from './routes/index'
import { Route as KontakteIdRouteImport } from './routes/kontakte/$id'
import { Route as KontakteIndexRouteImport } from './routes/kontakte/index' import { Route as KontakteIndexRouteImport } from './routes/kontakte/index'
import { Route as KontakteNeuRouteImport } from './routes/kontakte/neu' import { Route as KontakteNeuRouteImport } from './routes/kontakte/neu'
import { Route as OnboardingIndexRouteImport } from './routes/onboarding/index' import { Route as OnboardingIndexRouteImport } from './routes/onboarding/index'
@@ -36,6 +38,11 @@ const KontakteIndexRoute = KontakteIndexRouteImport.update({
path: '/kontakte/', path: '/kontakte/',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const EinstellungenIndexRoute = EinstellungenIndexRouteImport.update({
id: '/einstellungen/',
path: '/einstellungen/',
getParentRoute: () => rootRouteImport,
} as any)
const AntragIndexRoute = AntragIndexRouteImport.update({ const AntragIndexRoute = AntragIndexRouteImport.update({
id: '/antrag/', id: '/antrag/',
path: '/antrag/', path: '/antrag/',
@@ -46,19 +53,28 @@ const KontakteNeuRoute = KontakteNeuRouteImport.update({
path: '/kontakte/neu', path: '/kontakte/neu',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const KontakteIdRoute = KontakteIdRouteImport.update({
id: '/kontakte/$id',
path: '/kontakte/$id',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
'/kontakte/$id': typeof KontakteIdRoute
'/kontakte/neu': typeof KontakteNeuRoute '/kontakte/neu': typeof KontakteNeuRoute
'/antrag/': typeof AntragIndexRoute '/antrag/': typeof AntragIndexRoute
'/einstellungen/': typeof EinstellungenIndexRoute
'/kontakte/': typeof KontakteIndexRoute '/kontakte/': typeof KontakteIndexRoute
'/onboarding/': typeof OnboardingIndexRoute '/onboarding/': typeof OnboardingIndexRoute
'/prozess/': typeof ProzessIndexRoute '/prozess/': typeof ProzessIndexRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
'/kontakte/$id': typeof KontakteIdRoute
'/kontakte/neu': typeof KontakteNeuRoute '/kontakte/neu': typeof KontakteNeuRoute
'/antrag': typeof AntragIndexRoute '/antrag': typeof AntragIndexRoute
'/einstellungen': typeof EinstellungenIndexRoute
'/kontakte': typeof KontakteIndexRoute '/kontakte': typeof KontakteIndexRoute
'/onboarding': typeof OnboardingIndexRoute '/onboarding': typeof OnboardingIndexRoute
'/prozess': typeof ProzessIndexRoute '/prozess': typeof ProzessIndexRoute
@@ -66,8 +82,10 @@ export interface FileRoutesByTo {
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
'/': typeof IndexRoute '/': typeof IndexRoute
'/kontakte/$id': typeof KontakteIdRoute
'/kontakte/neu': typeof KontakteNeuRoute '/kontakte/neu': typeof KontakteNeuRoute
'/antrag/': typeof AntragIndexRoute '/antrag/': typeof AntragIndexRoute
'/einstellungen/': typeof EinstellungenIndexRoute
'/kontakte/': typeof KontakteIndexRoute '/kontakte/': typeof KontakteIndexRoute
'/onboarding/': typeof OnboardingIndexRoute '/onboarding/': typeof OnboardingIndexRoute
'/prozess/': typeof ProzessIndexRoute '/prozess/': typeof ProzessIndexRoute
@@ -76,24 +94,30 @@ export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: fullPaths:
| '/' | '/'
| '/kontakte/$id'
| '/kontakte/neu' | '/kontakte/neu'
| '/antrag/' | '/antrag/'
| '/einstellungen/'
| '/kontakte/' | '/kontakte/'
| '/onboarding/' | '/onboarding/'
| '/prozess/' | '/prozess/'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: to:
| '/' | '/'
| '/kontakte/$id'
| '/kontakte/neu' | '/kontakte/neu'
| '/antrag' | '/antrag'
| '/einstellungen'
| '/kontakte' | '/kontakte'
| '/onboarding' | '/onboarding'
| '/prozess' | '/prozess'
id: id:
| '__root__' | '__root__'
| '/' | '/'
| '/kontakte/$id'
| '/kontakte/neu' | '/kontakte/neu'
| '/antrag/' | '/antrag/'
| '/einstellungen/'
| '/kontakte/' | '/kontakte/'
| '/onboarding/' | '/onboarding/'
| '/prozess/' | '/prozess/'
@@ -101,8 +125,10 @@ export interface FileRouteTypes {
} }
export interface RootRouteChildren { export interface RootRouteChildren {
IndexRoute: typeof IndexRoute IndexRoute: typeof IndexRoute
KontakteIdRoute: typeof KontakteIdRoute
KontakteNeuRoute: typeof KontakteNeuRoute KontakteNeuRoute: typeof KontakteNeuRoute
AntragIndexRoute: typeof AntragIndexRoute AntragIndexRoute: typeof AntragIndexRoute
EinstellungenIndexRoute: typeof EinstellungenIndexRoute
KontakteIndexRoute: typeof KontakteIndexRoute KontakteIndexRoute: typeof KontakteIndexRoute
OnboardingIndexRoute: typeof OnboardingIndexRoute OnboardingIndexRoute: typeof OnboardingIndexRoute
ProzessIndexRoute: typeof ProzessIndexRoute ProzessIndexRoute: typeof ProzessIndexRoute
@@ -138,6 +164,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof KontakteIndexRouteImport preLoaderRoute: typeof KontakteIndexRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/einstellungen/': {
id: '/einstellungen/'
path: '/einstellungen'
fullPath: '/einstellungen/'
preLoaderRoute: typeof EinstellungenIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/antrag/': { '/antrag/': {
id: '/antrag/' id: '/antrag/'
path: '/antrag' path: '/antrag'
@@ -152,13 +185,22 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof KontakteNeuRouteImport preLoaderRoute: typeof KontakteNeuRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/kontakte/$id': {
id: '/kontakte/$id'
path: '/kontakte/$id'
fullPath: '/kontakte/$id'
preLoaderRoute: typeof KontakteIdRouteImport
parentRoute: typeof rootRouteImport
}
} }
} }
const rootRouteChildren: RootRouteChildren = { const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute, IndexRoute: IndexRoute,
KontakteIdRoute: KontakteIdRoute,
KontakteNeuRoute: KontakteNeuRoute, KontakteNeuRoute: KontakteNeuRoute,
AntragIndexRoute: AntragIndexRoute, AntragIndexRoute: AntragIndexRoute,
EinstellungenIndexRoute: EinstellungenIndexRoute,
KontakteIndexRoute: KontakteIndexRoute, KontakteIndexRoute: KontakteIndexRoute,
OnboardingIndexRoute: OnboardingIndexRoute, OnboardingIndexRoute: OnboardingIndexRoute,
ProzessIndexRoute: ProzessIndexRoute, ProzessIndexRoute: ProzessIndexRoute,

View File

@@ -1,58 +1,55 @@
import { createRootRoute, Link, Outlet } from "@tanstack/react-router"; import {
import { useThemeStore } from "../shared/hooks/use-theme"; createRootRoute,
Link,
const themeIcon = { Outlet,
light: "\u2600", useMatchRoute,
dark: "\u263D", } from "@tanstack/react-router";
system: "\u2699", import { FileText, ListChecks, Settings, Users } from "lucide-react";
} as const;
const themeNext = {
light: "dark",
dark: "system",
system: "light",
} as const;
export const Route = createRootRoute({ export const Route = createRootRoute({
component: () => { component: () => {
const { theme, setTheme } = useThemeStore(); const matchRoute = useMatchRoute();
const isOnboarding = matchRoute({ to: "/onboarding" });
return ( return (
<div className="flex min-h-screen flex-col bg-background text-foreground"> <div className="flex min-h-screen flex-col bg-background text-foreground">
<header className="flex items-center justify-end px-4 pt-3"> <main className="mx-auto w-full max-w-2xl flex-1 px-4 pb-20 pt-6">
<button
type="button"
onClick={() => setTheme(themeNext[theme])}
className="rounded-md p-1.5 text-lg text-muted-foreground hover:text-foreground"
aria-label={`Theme: ${theme}`}
>
{themeIcon[theme]}
</button>
</header>
<main className="mx-auto w-full max-w-2xl flex-1 px-4 py-6">
<Outlet /> <Outlet />
</main> </main>
<nav className="border-t bg-background"> {!isOnboarding && (
<div className="mx-auto flex max-w-2xl"> <nav className="fixed inset-x-0 bottom-0 border-t bg-background">
<Link <div className="mx-auto flex max-w-2xl">
to="/prozess" <Link
className="flex-1 py-3 text-center text-sm [&.active]:font-bold [&.active]:text-primary" to="/prozess"
> className="flex flex-1 flex-col items-center gap-1 py-2 text-xs text-muted-foreground [&.active]:font-bold [&.active]:text-primary"
Fortschritt >
</Link> <ListChecks className="size-5" />
<Link Fortschritt
to="/kontakte" </Link>
className="flex-1 py-3 text-center text-sm [&.active]:font-bold [&.active]:text-primary" <Link
> to="/kontakte"
Kontakte className="flex flex-1 flex-col items-center gap-1 py-2 text-xs text-muted-foreground [&.active]:font-bold [&.active]:text-primary"
</Link> >
<Link <Users className="size-5" />
to="/antrag" Kontakte
className="flex-1 py-3 text-center text-sm [&.active]:font-bold [&.active]:text-primary" </Link>
> <Link
Antrag to="/antrag"
</Link> className="flex flex-1 flex-col items-center gap-1 py-2 text-xs text-muted-foreground [&.active]:font-bold [&.active]:text-primary"
</div> >
</nav> <FileText className="size-5" />
Antrag
</Link>
<Link
to="/einstellungen"
className="flex flex-1 flex-col items-center gap-1 py-2 text-xs text-muted-foreground [&.active]:font-bold [&.active]:text-primary"
>
<Settings className="size-5" />
Mehr
</Link>
</div>
</nav>
)}
</div> </div>
); );
}, },

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { SettingsPage } from "@/features/einstellungen/components/settings-page";
export const Route = createFileRoute("/einstellungen/")({
component: () => <SettingsPage />,
});

View File

@@ -1,13 +1,39 @@
import { createFileRoute, redirect } from "@tanstack/react-router"; import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { getDb } from "@/shared/db/client"; import { getDb } from "@/shared/db/client";
export const Route = createFileRoute("/")({ export const Route = createFileRoute("/")({
beforeLoad: async () => { component: RootRedirect,
const db = await getDb();
const result = await db.query("SELECT id FROM nutzer LIMIT 1");
if (result.rows.length === 0) {
throw redirect({ to: "/onboarding" });
}
throw redirect({ to: "/prozess" });
},
}); });
function RootRedirect() {
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
useEffect(() => {
(async () => {
try {
const db = await getDb();
const result = await db.query("SELECT id FROM nutzer LIMIT 1");
if (result.rows.length === 0) {
navigate({ to: "/onboarding", replace: true });
} else {
navigate({ to: "/prozess", replace: true });
}
} catch (e) {
console.error("DB init failed:", e);
navigate({ to: "/onboarding", replace: true });
} finally {
setLoading(false);
}
})();
}, [navigate]);
if (!loading) return null;
return (
<div className="flex min-h-[60vh] items-center justify-center">
<p className="text-muted-foreground">Laden</p>
</div>
);
}

View File

@@ -0,0 +1,9 @@
import { createFileRoute } from "@tanstack/react-router";
import { ContactDetail } from "@/features/kontakte/components/contact-detail";
export const Route = createFileRoute("/kontakte/$id")({
component: () => {
const { id } = Route.useParams();
return <ContactDetail therapeutId={Number(id)} />;
},
});

View File

@@ -7,8 +7,16 @@ export const Route = createFileRoute("/prozess/")({
}); });
function ProzessPage() { function ProzessPage() {
const { data: nutzer, loading: nutzerLoading } = useNutzer(); const {
const { data: stats, loading: statsLoading } = useKontaktStats(); data: nutzer,
loading: nutzerLoading,
refetch: refetchNutzer,
} = useNutzer();
const {
data: stats,
loading: statsLoading,
refetch: refetchStats,
} = useKontaktStats();
if (nutzerLoading || statsLoading) return <p>Laden</p>; if (nutzerLoading || statsLoading) return <p>Laden</p>;
if (!nutzer[0]) return <p>Bitte zuerst das Onboarding abschließen.</p>; if (!nutzer[0]) return <p>Bitte zuerst das Onboarding abschließen.</p>;
@@ -20,6 +28,10 @@ function ProzessPage() {
aktuellerSchritt={nutzer[0].aktueller_schritt} aktuellerSchritt={nutzer[0].aktueller_schritt}
kontaktGesamt={Number(s.gesamt)} kontaktGesamt={Number(s.gesamt)}
absagen={Number(s.absagen)} absagen={Number(s.absagen)}
onUpdate={() => {
refetchNutzer();
refetchStats();
}}
/> />
); );
} }

View File

@@ -0,0 +1,27 @@
import * as SwitchPrimitive from "@radix-ui/react-switch";
import type * as React from "react";
import { cn } from "@/shared/lib/utils";
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className,
)}
{...props}
>
<SwitchPrimitive.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0",
)}
/>
</SwitchPrimitive.Root>
);
}
export { Switch };

View File

@@ -12,8 +12,9 @@ export const PROZESS_SCHRITTE: {
}[] = [ }[] = [
{ {
key: "neu", key: "neu",
label: "Noch nicht begonnen", label: "Erstgespräch durchführen",
beschreibung: "Du hast noch keine Schritte unternommen.", beschreibung:
"Das Erstgespräch heißt in der Fachsprache psychotherapeutische Sprechstunde. Dort wird eine erste Einschätzung vorgenommen.",
}, },
{ {
key: "sprechstunde_absolviert", key: "sprechstunde_absolviert",

4
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/react" />
declare const __APP_VERSION__: string;

View File

@@ -1,3 +1,5 @@
import { execSync } from "node:child_process";
import { readFileSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import tanstackRouter from "@tanstack/router-plugin/vite"; import tanstackRouter from "@tanstack/router-plugin/vite";
@@ -5,14 +7,22 @@ import react from "@vitejs/plugin-react";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import { VitePWA } from "vite-plugin-pwa"; import { VitePWA } from "vite-plugin-pwa";
const pkg = JSON.parse(readFileSync("./package.json", "utf-8"));
const gitCount = execSync("git rev-list --count HEAD", {
encoding: "utf-8",
}).trim();
export default defineConfig({ export default defineConfig({
base: "/tpf/", base: "/tpf/",
define: {
__APP_VERSION__: JSON.stringify(`${pkg.version}+${gitCount}`),
},
plugins: [ plugins: [
tanstackRouter(), tanstackRouter(),
react(), react(),
tailwindcss(), tailwindcss(),
VitePWA({ VitePWA({
registerType: "autoUpdate", registerType: "prompt",
includeAssets: ["icons/icon-192.png", "icons/icon-512.png"], includeAssets: ["icons/icon-192.png", "icons/icon-512.png"],
workbox: { workbox: {
globPatterns: ["**/*.{js,css,html,wasm,data}"], globPatterns: ["**/*.{js,css,html,wasm,data}"],