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",
"private": true,
"version": "2026.03.11",
"version": "2026.03.11.1",
"type": "module",
"scripts": {
"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 { Card, CardContent } from "@/shared/components/ui/card";
import type { KontaktErgebnis } from "@/shared/db/schema";
import { dbExec } from "@/shared/hooks/use-db";
import { ERGEBNIS_LABELS } from "@/shared/lib/constants";
interface ContactCardProps {
@@ -10,6 +13,7 @@ interface ContactCardProps {
letzterKontakt: string | null;
letztesErgebnis: string | null;
kontakteGesamt: number;
onUpdate: () => void;
}
const ergebnisVariant: Record<
@@ -22,24 +26,100 @@ const ergebnisVariant: Record<
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({
id,
name,
stadt,
letzterKontakt,
letztesErgebnis,
kontakteGesamt,
onUpdate,
}: ContactCardProps) {
const navigate = useNavigate();
return (
<Card>
<CardContent className="flex items-center justify-between gap-4">
<Card
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">
<p className="font-medium">{name}</p>
{stadt && <p className="text-sm text-muted-foreground">{stadt}</p>}
<p className="text-sm text-muted-foreground">
{kontakteGesamt} Kontakt{kontakteGesamt !== 1 ? "e" : ""}
{letzterKontakt && <> · Letzter: {letzterKontakt}</>}
{letzterKontakt && <> · {letzterKontakt}</>}
</p>
</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 && (
<Badge
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 { Plus } from "lucide-react";
import { useTherapeutenListe } from "@/features/kontakte/hooks";
import { Button } from "@/shared/components/ui/button";
import { ContactCard } from "./contact-card";
export function ContactList() {
const { data, loading } = useTherapeutenListe();
const { data, loading, refetch } = useTherapeutenListe();
if (loading) return <p>Laden</p>;
@@ -12,8 +13,11 @@ export function ContactList() {
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Kontakte</h1>
<Button asChild>
<Link to="/kontakte/neu">+ Neu</Link>
<Button asChild size="sm">
<Link to="/kontakte/neu">
<Plus className="mr-1 size-4" />
Neu
</Link>
</Button>
</div>
@@ -37,6 +41,7 @@ export function ContactList() {
letzterKontakt={t.letzter_kontakt}
letztesErgebnis={t.letztes_ergebnis}
kontakteGesamt={Number(t.kontakte_gesamt)}
onUpdate={refetch}
/>
))}
</div>

View File

@@ -1,17 +1,31 @@
import { dbExec, useDbQuery } from "@/shared/hooks/use-db";
import type { KontaktFormData, TherapeutFormData } from "./schema";
interface TherapeutMitKontakte {
export interface TherapeutMitKontakte {
id: number;
name: string;
stadt: string | null;
therapieform: string | null;
telefon: string | null;
email: string | null;
letzter_kontakt: string | null;
letztes_ergebnis: string | null;
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;
therapeut_id: number;
datum: string;
@@ -24,7 +38,7 @@ interface KontaktRow {
export function useTherapeutenListe() {
return useDbQuery<TherapeutMitKontakte>(`
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.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
@@ -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) {
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],
);
@@ -62,6 +84,23 @@ export async function createTherapeut(
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) {
await dbExec(
`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],
);
}
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 {
createKontakt,
createTherapeut,
deleteAllData,
deleteTherapeut,
seedMockData,
updateKontakt,
updateKontaktErgebnis,
updateTherapeut,
useKontakteForTherapeut,
useTherapeut,
useTherapeutenListe,
} from "./hooks";
export type { KontaktFormData, TherapeutFormData } from "./schema";

View File

@@ -1,14 +1,24 @@
import { useForm } from "@tanstack/react-form";
import { useNavigate } from "@tanstack/react-router";
import { Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
import { Button } from "@/shared/components/ui/button";
import { Input } from "@/shared/components/ui/input";
import { Label } from "@/shared/components/ui/label";
import { getDb } from "@/shared/db/client";
import type { ProzessSchritt } from "@/shared/db/schema";
import { dbExec } from "@/shared/hooks/use-db";
import { PROZESS_SCHRITTE } from "@/shared/lib/constants";
export function OnboardingForm() {
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({
defaultValues: {
@@ -19,6 +29,8 @@ export function OnboardingForm() {
aktueller_schritt: "neu" as ProzessSchritt,
},
onSubmit: async ({ value }) => {
setSubmitting(true);
try {
await dbExec(
`INSERT INTO nutzer (name, plz, ort, krankenkasse, aktueller_schritt)
VALUES ($1, $2, $3, $4, $5)`,
@@ -31,9 +43,14 @@ export function OnboardingForm() {
],
);
navigate({ to: "/prozess" });
} finally {
setSubmitting(false);
}
},
});
const isDisabled = !dbReady || submitting;
return (
<div className="flex min-h-[80vh] flex-col items-center justify-center">
<div className="w-full max-w-md space-y-6">
@@ -145,8 +162,20 @@ export function OnboardingForm() {
)}
</form.Field>
<Button type="submit" className="w-full">
Weiter
<Button type="submit" className="w-full" disabled={isDisabled}>
{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>
</form>
</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 { Card, CardContent } from "@/shared/components/ui/card";
import { cn } from "@/shared/lib/utils";
@@ -9,6 +11,7 @@ interface PhaseCardProps {
beschreibung: string;
status: PhaseStatus;
index: number;
children?: ReactNode;
}
export function PhaseCard({
@@ -16,6 +19,7 @@ export function PhaseCard({
beschreibung,
status,
index,
children,
}: PhaseCardProps) {
return (
<Card
@@ -25,7 +29,8 @@ export function PhaseCard({
status === "erledigt" && "opacity-60",
)}
>
<CardContent className="flex items-start gap-4">
<CardContent className="space-y-3">
<div className="flex items-start gap-4">
<div
className={cn(
"flex h-8 w-8 shrink-0 items-center justify-center rounded-full border-2 text-sm font-bold",
@@ -36,7 +41,7 @@ export function PhaseCard({
"border-muted-foreground/40 text-muted-foreground/40",
)}
>
{status === "erledigt" ? "✓" : index + 1}
{status === "erledigt" ? <Check className="size-4" /> : index + 1}
</div>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
@@ -47,6 +52,8 @@ export function PhaseCard({
<p className="text-sm text-muted-foreground">{beschreibung}</p>
)}
</div>
</div>
{children}
</CardContent>
</Card>
);

View File

@@ -1,10 +1,12 @@
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card";
import { useForm } from "@tanstack/react-form";
import { Link } from "@tanstack/react-router";
import { useState } from "react";
import { useTherapeutenListe } from "@/features/kontakte/hooks";
import { Button } from "@/shared/components/ui/button";
import { Label } from "@/shared/components/ui/label";
import { Separator } from "@/shared/components/ui/separator";
import type { ProzessSchritt } from "@/shared/db/schema";
import { dbExec } from "@/shared/hooks/use-db";
import { PROZESS_SCHRITTE } from "@/shared/lib/constants";
import { PhaseCard } from "./phase-card";
@@ -12,19 +14,29 @@ interface ProcessStepperProps {
aktuellerSchritt: ProzessSchritt;
kontaktGesamt: number;
absagen: number;
onUpdate: () => void;
}
const inputClasses =
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring";
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({
aktuellerSchritt,
kontaktGesamt,
absagen,
onUpdate,
}: ProcessStepperProps) {
const currentIndex = PROZESS_SCHRITTE.findIndex(
(s) => s.key === aktuellerSchritt,
);
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">
<h1 className="text-2xl font-bold">Dein Fortschritt</h1>
<span className="text-sm text-muted-foreground">
@@ -48,24 +60,235 @@ export function ProcessStepper({
beschreibung={schritt.beschreibung}
status={status}
index={i}
>
{status === "aktuell" && (
<StepAction
schritt={schritt.key}
kontaktGesamt={kontaktGesamt}
absagen={absagen}
onUpdate={onUpdate}
/>
)}
</PhaseCard>
);
})}
</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>
);
}
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 AntragIndexRouteImport } from './routes/antrag/index'
import { Route as EinstellungenIndexRouteImport } from './routes/einstellungen/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 KontakteNeuRouteImport } from './routes/kontakte/neu'
import { Route as OnboardingIndexRouteImport } from './routes/onboarding/index'
@@ -36,6 +38,11 @@ const KontakteIndexRoute = KontakteIndexRouteImport.update({
path: '/kontakte/',
getParentRoute: () => rootRouteImport,
} as any)
const EinstellungenIndexRoute = EinstellungenIndexRouteImport.update({
id: '/einstellungen/',
path: '/einstellungen/',
getParentRoute: () => rootRouteImport,
} as any)
const AntragIndexRoute = AntragIndexRouteImport.update({
id: '/antrag/',
path: '/antrag/',
@@ -46,19 +53,28 @@ const KontakteNeuRoute = KontakteNeuRouteImport.update({
path: '/kontakte/neu',
getParentRoute: () => rootRouteImport,
} as any)
const KontakteIdRoute = KontakteIdRouteImport.update({
id: '/kontakte/$id',
path: '/kontakte/$id',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/kontakte/$id': typeof KontakteIdRoute
'/kontakte/neu': typeof KontakteNeuRoute
'/antrag/': typeof AntragIndexRoute
'/einstellungen/': typeof EinstellungenIndexRoute
'/kontakte/': typeof KontakteIndexRoute
'/onboarding/': typeof OnboardingIndexRoute
'/prozess/': typeof ProzessIndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/kontakte/$id': typeof KontakteIdRoute
'/kontakte/neu': typeof KontakteNeuRoute
'/antrag': typeof AntragIndexRoute
'/einstellungen': typeof EinstellungenIndexRoute
'/kontakte': typeof KontakteIndexRoute
'/onboarding': typeof OnboardingIndexRoute
'/prozess': typeof ProzessIndexRoute
@@ -66,8 +82,10 @@ export interface FileRoutesByTo {
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/kontakte/$id': typeof KontakteIdRoute
'/kontakte/neu': typeof KontakteNeuRoute
'/antrag/': typeof AntragIndexRoute
'/einstellungen/': typeof EinstellungenIndexRoute
'/kontakte/': typeof KontakteIndexRoute
'/onboarding/': typeof OnboardingIndexRoute
'/prozess/': typeof ProzessIndexRoute
@@ -76,24 +94,30 @@ export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/kontakte/$id'
| '/kontakte/neu'
| '/antrag/'
| '/einstellungen/'
| '/kontakte/'
| '/onboarding/'
| '/prozess/'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/kontakte/$id'
| '/kontakte/neu'
| '/antrag'
| '/einstellungen'
| '/kontakte'
| '/onboarding'
| '/prozess'
id:
| '__root__'
| '/'
| '/kontakte/$id'
| '/kontakte/neu'
| '/antrag/'
| '/einstellungen/'
| '/kontakte/'
| '/onboarding/'
| '/prozess/'
@@ -101,8 +125,10 @@ export interface FileRouteTypes {
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
KontakteIdRoute: typeof KontakteIdRoute
KontakteNeuRoute: typeof KontakteNeuRoute
AntragIndexRoute: typeof AntragIndexRoute
EinstellungenIndexRoute: typeof EinstellungenIndexRoute
KontakteIndexRoute: typeof KontakteIndexRoute
OnboardingIndexRoute: typeof OnboardingIndexRoute
ProzessIndexRoute: typeof ProzessIndexRoute
@@ -138,6 +164,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof KontakteIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/einstellungen/': {
id: '/einstellungen/'
path: '/einstellungen'
fullPath: '/einstellungen/'
preLoaderRoute: typeof EinstellungenIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/antrag/': {
id: '/antrag/'
path: '/antrag'
@@ -152,13 +185,22 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof KontakteNeuRouteImport
parentRoute: typeof rootRouteImport
}
'/kontakte/$id': {
id: '/kontakte/$id'
path: '/kontakte/$id'
fullPath: '/kontakte/$id'
preLoaderRoute: typeof KontakteIdRouteImport
parentRoute: typeof rootRouteImport
}
}
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
KontakteIdRoute: KontakteIdRoute,
KontakteNeuRoute: KontakteNeuRoute,
AntragIndexRoute: AntragIndexRoute,
EinstellungenIndexRoute: EinstellungenIndexRoute,
KontakteIndexRoute: KontakteIndexRoute,
OnboardingIndexRoute: OnboardingIndexRoute,
ProzessIndexRoute: ProzessIndexRoute,

View File

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

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";
export const Route = createFileRoute("/")({
beforeLoad: async () => {
component: RootRedirect,
});
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) {
throw redirect({ to: "/onboarding" });
navigate({ to: "/onboarding", replace: true });
} else {
navigate({ to: "/prozess", replace: true });
}
throw redirect({ to: "/prozess" });
},
});
} 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() {
const { data: nutzer, loading: nutzerLoading } = useNutzer();
const { data: stats, loading: statsLoading } = useKontaktStats();
const {
data: nutzer,
loading: nutzerLoading,
refetch: refetchNutzer,
} = useNutzer();
const {
data: stats,
loading: statsLoading,
refetch: refetchStats,
} = useKontaktStats();
if (nutzerLoading || statsLoading) return <p>Laden</p>;
if (!nutzer[0]) return <p>Bitte zuerst das Onboarding abschließen.</p>;
@@ -20,6 +28,10 @@ function ProzessPage() {
aktuellerSchritt={nutzer[0].aktueller_schritt}
kontaktGesamt={Number(s.gesamt)}
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",
label: "Noch nicht begonnen",
beschreibung: "Du hast noch keine Schritte unternommen.",
label: "Erstgespräch durchführen",
beschreibung:
"Das Erstgespräch heißt in der Fachsprache psychotherapeutische Sprechstunde. Dort wird eine erste Einschätzung vorgenommen.",
},
{
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 tailwindcss from "@tailwindcss/vite";
import tanstackRouter from "@tanstack/router-plugin/vite";
@@ -5,14 +7,22 @@ import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
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({
base: "/tpf/",
define: {
__APP_VERSION__: JSON.stringify(`${pkg.version}+${gitCount}`),
},
plugins: [
tanstackRouter(),
react(),
tailwindcss(),
VitePWA({
registerType: "autoUpdate",
registerType: "prompt",
includeAssets: ["icons/icon-192.png", "icons/icon-512.png"],
workbox: {
globPatterns: ["**/*.{js,css,html,wasm,data}"],