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:
@@ -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
6
public/.htaccess
Normal 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]
|
||||
197
src/features/einstellungen/components/settings-page.tsx
Normal file
197
src/features/einstellungen/components/settings-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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={
|
||||
|
||||
482
src/features/kontakte/components/contact-detail.tsx
Normal file
482
src/features/kontakte/components/contact-detail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
|
||||
6
src/routes/einstellungen/index.tsx
Normal file
6
src/routes/einstellungen/index.tsx
Normal 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 />,
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
9
src/routes/kontakte/$id.tsx
Normal file
9
src/routes/kontakte/$id.tsx
Normal 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)} />;
|
||||
},
|
||||
});
|
||||
@@ -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();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
27
src/shared/components/ui/switch.tsx
Normal file
27
src/shared/components/ui/switch.tsx
Normal 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 };
|
||||
@@ -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
4
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-plugin-pwa/react" />
|
||||
|
||||
declare const __APP_VERSION__: string;
|
||||
@@ -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}"],
|
||||
|
||||
Reference in New Issue
Block a user