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",
|
"name": "therapyfinder",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2026.03.11",
|
"version": "2026.03.11.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
6
public/.htaccess
Normal file
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 { Badge } from "@/shared/components/ui/badge";
|
||||||
import { Card, CardContent } from "@/shared/components/ui/card";
|
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||||
import type { KontaktErgebnis } from "@/shared/db/schema";
|
import type { KontaktErgebnis } from "@/shared/db/schema";
|
||||||
|
import { dbExec } from "@/shared/hooks/use-db";
|
||||||
import { ERGEBNIS_LABELS } from "@/shared/lib/constants";
|
import { ERGEBNIS_LABELS } from "@/shared/lib/constants";
|
||||||
|
|
||||||
interface ContactCardProps {
|
interface ContactCardProps {
|
||||||
@@ -10,6 +13,7 @@ interface ContactCardProps {
|
|||||||
letzterKontakt: string | null;
|
letzterKontakt: string | null;
|
||||||
letztesErgebnis: string | null;
|
letztesErgebnis: string | null;
|
||||||
kontakteGesamt: number;
|
kontakteGesamt: number;
|
||||||
|
onUpdate: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ergebnisVariant: Record<
|
const ergebnisVariant: Record<
|
||||||
@@ -22,24 +26,100 @@ const ergebnisVariant: Record<
|
|||||||
keine_antwort: "outline",
|
keine_antwort: "outline",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function quickUpdate(
|
||||||
|
therapeutId: number,
|
||||||
|
ergebnis: KontaktErgebnis,
|
||||||
|
onUpdate: () => void,
|
||||||
|
) {
|
||||||
|
// Update the most recent kontakt for this therapist, or create one if none exists
|
||||||
|
const result = await dbExec(
|
||||||
|
"SELECT id FROM kontakt WHERE therapeut_id = $1 ORDER BY datum DESC, id DESC LIMIT 1",
|
||||||
|
[therapeutId],
|
||||||
|
);
|
||||||
|
if (result.rows.length > 0) {
|
||||||
|
const kontaktId = (result.rows[0] as { id: number }).id;
|
||||||
|
await dbExec("UPDATE kontakt SET ergebnis = $1 WHERE id = $2", [
|
||||||
|
ergebnis,
|
||||||
|
kontaktId,
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
const d = new Date();
|
||||||
|
const datum = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||||
|
await dbExec(
|
||||||
|
"INSERT INTO kontakt (therapeut_id, datum, kanal, ergebnis) VALUES ($1, $2, 'telefon', $3)",
|
||||||
|
[therapeutId, datum, ergebnis],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
onUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
export function ContactCard({
|
export function ContactCard({
|
||||||
|
id,
|
||||||
name,
|
name,
|
||||||
stadt,
|
stadt,
|
||||||
letzterKontakt,
|
letzterKontakt,
|
||||||
letztesErgebnis,
|
letztesErgebnis,
|
||||||
kontakteGesamt,
|
kontakteGesamt,
|
||||||
|
onUpdate,
|
||||||
}: ContactCardProps) {
|
}: ContactCardProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card
|
||||||
<CardContent className="flex items-center justify-between gap-4">
|
className="cursor-pointer transition-colors hover:bg-accent/50"
|
||||||
|
onClick={() =>
|
||||||
|
navigate({ to: "/kontakte/$id", params: { id: String(id) } })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CardContent className="flex items-center gap-3">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="font-medium">{name}</p>
|
<p className="font-medium">{name}</p>
|
||||||
{stadt && <p className="text-sm text-muted-foreground">{stadt}</p>}
|
{stadt && <p className="text-sm text-muted-foreground">{stadt}</p>}
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{kontakteGesamt} Kontakt{kontakteGesamt !== 1 ? "e" : ""}
|
{kontakteGesamt} Kontakt{kontakteGesamt !== 1 ? "e" : ""}
|
||||||
{letzterKontakt && <> · Letzter: {letzterKontakt}</>}
|
{letzterKontakt && <> · {letzterKontakt}</>}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
quickUpdate(id, "zusage", onUpdate);
|
||||||
|
}}
|
||||||
|
className="rounded-md p-1.5 text-green-600 hover:bg-green-50 dark:text-green-400 dark:hover:bg-green-950"
|
||||||
|
aria-label="Zusage"
|
||||||
|
title="Zusage"
|
||||||
|
>
|
||||||
|
<Check className="size-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
quickUpdate(id, "warteliste", onUpdate);
|
||||||
|
}}
|
||||||
|
className="rounded-md p-1.5 text-amber-600 hover:bg-amber-50 dark:text-amber-400 dark:hover:bg-amber-950"
|
||||||
|
aria-label="Warteliste"
|
||||||
|
title="Warteliste"
|
||||||
|
>
|
||||||
|
<Clock className="size-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
quickUpdate(id, "absage", onUpdate);
|
||||||
|
}}
|
||||||
|
className="rounded-md p-1.5 text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950"
|
||||||
|
aria-label="Absage"
|
||||||
|
title="Absage"
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{letztesErgebnis && (
|
{letztesErgebnis && (
|
||||||
<Badge
|
<Badge
|
||||||
variant={
|
variant={
|
||||||
|
|||||||
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 { Link } from "@tanstack/react-router";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
import { useTherapeutenListe } from "@/features/kontakte/hooks";
|
import { useTherapeutenListe } from "@/features/kontakte/hooks";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { ContactCard } from "./contact-card";
|
import { ContactCard } from "./contact-card";
|
||||||
|
|
||||||
export function ContactList() {
|
export function ContactList() {
|
||||||
const { data, loading } = useTherapeutenListe();
|
const { data, loading, refetch } = useTherapeutenListe();
|
||||||
|
|
||||||
if (loading) return <p>Laden…</p>;
|
if (loading) return <p>Laden…</p>;
|
||||||
|
|
||||||
@@ -12,8 +13,11 @@ export function ContactList() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold">Kontakte</h1>
|
<h1 className="text-2xl font-bold">Kontakte</h1>
|
||||||
<Button asChild>
|
<Button asChild size="sm">
|
||||||
<Link to="/kontakte/neu">+ Neu</Link>
|
<Link to="/kontakte/neu">
|
||||||
|
<Plus className="mr-1 size-4" />
|
||||||
|
Neu
|
||||||
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -37,6 +41,7 @@ export function ContactList() {
|
|||||||
letzterKontakt={t.letzter_kontakt}
|
letzterKontakt={t.letzter_kontakt}
|
||||||
letztesErgebnis={t.letztes_ergebnis}
|
letztesErgebnis={t.letztes_ergebnis}
|
||||||
kontakteGesamt={Number(t.kontakte_gesamt)}
|
kontakteGesamt={Number(t.kontakte_gesamt)}
|
||||||
|
onUpdate={refetch}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,17 +1,31 @@
|
|||||||
import { dbExec, useDbQuery } from "@/shared/hooks/use-db";
|
import { dbExec, useDbQuery } from "@/shared/hooks/use-db";
|
||||||
import type { KontaktFormData, TherapeutFormData } from "./schema";
|
import type { KontaktFormData, TherapeutFormData } from "./schema";
|
||||||
|
|
||||||
interface TherapeutMitKontakte {
|
export interface TherapeutMitKontakte {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
stadt: string | null;
|
stadt: string | null;
|
||||||
therapieform: string | null;
|
therapieform: string | null;
|
||||||
|
telefon: string | null;
|
||||||
|
email: string | null;
|
||||||
letzter_kontakt: string | null;
|
letzter_kontakt: string | null;
|
||||||
letztes_ergebnis: string | null;
|
letztes_ergebnis: string | null;
|
||||||
kontakte_gesamt: number;
|
kontakte_gesamt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface KontaktRow {
|
export interface TherapeutRow {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
adresse: string | null;
|
||||||
|
plz: string | null;
|
||||||
|
stadt: string | null;
|
||||||
|
telefon: string | null;
|
||||||
|
email: string | null;
|
||||||
|
website: string | null;
|
||||||
|
therapieform: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KontaktRow {
|
||||||
id: number;
|
id: number;
|
||||||
therapeut_id: number;
|
therapeut_id: number;
|
||||||
datum: string;
|
datum: string;
|
||||||
@@ -24,7 +38,7 @@ interface KontaktRow {
|
|||||||
export function useTherapeutenListe() {
|
export function useTherapeutenListe() {
|
||||||
return useDbQuery<TherapeutMitKontakte>(`
|
return useDbQuery<TherapeutMitKontakte>(`
|
||||||
SELECT
|
SELECT
|
||||||
t.id, t.name, t.stadt, t.therapieform,
|
t.id, t.name, t.stadt, t.therapieform, t.telefon, t.email,
|
||||||
(SELECT k.datum::text FROM kontakt k WHERE k.therapeut_id = t.id ORDER BY k.datum DESC LIMIT 1) as letzter_kontakt,
|
(SELECT k.datum::text FROM kontakt k WHERE k.therapeut_id = t.id ORDER BY k.datum DESC LIMIT 1) as letzter_kontakt,
|
||||||
(SELECT k.ergebnis FROM kontakt k WHERE k.therapeut_id = t.id ORDER BY k.datum DESC LIMIT 1) as letztes_ergebnis,
|
(SELECT k.ergebnis FROM kontakt k WHERE k.therapeut_id = t.id ORDER BY k.datum DESC LIMIT 1) as letztes_ergebnis,
|
||||||
(SELECT COUNT(*) FROM kontakt k WHERE k.therapeut_id = t.id) as kontakte_gesamt
|
(SELECT COUNT(*) FROM kontakt k WHERE k.therapeut_id = t.id) as kontakte_gesamt
|
||||||
@@ -33,9 +47,17 @@ export function useTherapeutenListe() {
|
|||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useTherapeut(id: number) {
|
||||||
|
return useDbQuery<TherapeutRow>(
|
||||||
|
"SELECT id, name, adresse, plz, stadt, telefon, email, website, therapieform FROM therapeut WHERE id = $1",
|
||||||
|
[id],
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function useKontakteForTherapeut(therapeutId: number) {
|
export function useKontakteForTherapeut(therapeutId: number) {
|
||||||
return useDbQuery<KontaktRow>(
|
return useDbQuery<KontaktRow>(
|
||||||
"SELECT * FROM kontakt WHERE therapeut_id = $1 ORDER BY datum DESC",
|
"SELECT id, therapeut_id, datum::text as datum, kanal, ergebnis, notiz, antwort_datum::text as antwort_datum FROM kontakt WHERE therapeut_id = $1 ORDER BY datum DESC",
|
||||||
[therapeutId],
|
[therapeutId],
|
||||||
[therapeutId],
|
[therapeutId],
|
||||||
);
|
);
|
||||||
@@ -62,6 +84,23 @@ export async function createTherapeut(
|
|||||||
return (result.rows[0] as { id: number }).id;
|
return (result.rows[0] as { id: number }).id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateTherapeut(id: number, data: TherapeutFormData) {
|
||||||
|
await dbExec(
|
||||||
|
`UPDATE therapeut SET name=$1, adresse=$2, plz=$3, stadt=$4, telefon=$5, email=$6, website=$7, therapieform=$8 WHERE id=$9`,
|
||||||
|
[
|
||||||
|
data.name,
|
||||||
|
data.adresse,
|
||||||
|
data.plz,
|
||||||
|
data.stadt,
|
||||||
|
data.telefon,
|
||||||
|
data.email,
|
||||||
|
data.website,
|
||||||
|
data.therapieform,
|
||||||
|
id,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function createKontakt(data: KontaktFormData) {
|
export async function createKontakt(data: KontaktFormData) {
|
||||||
await dbExec(
|
await dbExec(
|
||||||
`INSERT INTO kontakt (therapeut_id, datum, kanal, ergebnis, notiz)
|
`INSERT INTO kontakt (therapeut_id, datum, kanal, ergebnis, notiz)
|
||||||
@@ -69,3 +108,152 @@ export async function createKontakt(data: KontaktFormData) {
|
|||||||
[data.therapeut_id, data.datum, data.kanal, data.ergebnis, data.notiz],
|
[data.therapeut_id, data.datum, data.kanal, data.ergebnis, data.notiz],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateKontakt(id: number, data: KontaktFormData) {
|
||||||
|
await dbExec(
|
||||||
|
`UPDATE kontakt SET datum=$1, kanal=$2, ergebnis=$3, notiz=$4 WHERE id=$5`,
|
||||||
|
[data.datum, data.kanal, data.ergebnis, data.notiz, id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateKontaktErgebnis(id: number, ergebnis: string) {
|
||||||
|
await dbExec("UPDATE kontakt SET ergebnis=$1 WHERE id=$2", [ergebnis, id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTherapeut(id: number) {
|
||||||
|
await dbExec("DELETE FROM therapeut WHERE id = $1", [id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAllData() {
|
||||||
|
await dbExec("DELETE FROM kontakt");
|
||||||
|
await dbExec("DELETE FROM sprechstunde");
|
||||||
|
await dbExec("DELETE FROM therapeut");
|
||||||
|
await dbExec("DELETE FROM nutzer");
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOCK_VORNAMEN = [
|
||||||
|
"Anna",
|
||||||
|
"Marie",
|
||||||
|
"Sophie",
|
||||||
|
"Laura",
|
||||||
|
"Julia",
|
||||||
|
"Lena",
|
||||||
|
"Sarah",
|
||||||
|
"Lisa",
|
||||||
|
"Katharina",
|
||||||
|
"Eva",
|
||||||
|
"Thomas",
|
||||||
|
"Michael",
|
||||||
|
"Stefan",
|
||||||
|
"Andreas",
|
||||||
|
"Daniel",
|
||||||
|
"Markus",
|
||||||
|
"Christian",
|
||||||
|
"Martin",
|
||||||
|
"Jan",
|
||||||
|
"Felix",
|
||||||
|
];
|
||||||
|
const MOCK_NACHNAMEN = [
|
||||||
|
"Müller",
|
||||||
|
"Schmidt",
|
||||||
|
"Schneider",
|
||||||
|
"Fischer",
|
||||||
|
"Weber",
|
||||||
|
"Meyer",
|
||||||
|
"Wagner",
|
||||||
|
"Becker",
|
||||||
|
"Schulz",
|
||||||
|
"Hoffmann",
|
||||||
|
"Koch",
|
||||||
|
"Richter",
|
||||||
|
"Wolf",
|
||||||
|
"Schröder",
|
||||||
|
"Neumann",
|
||||||
|
"Schwarz",
|
||||||
|
"Braun",
|
||||||
|
"Zimmermann",
|
||||||
|
"Hartmann",
|
||||||
|
"Krüger",
|
||||||
|
];
|
||||||
|
const MOCK_STAEDTE = [
|
||||||
|
"Berlin",
|
||||||
|
"Hamburg",
|
||||||
|
"München",
|
||||||
|
"Köln",
|
||||||
|
"Frankfurt",
|
||||||
|
"Stuttgart",
|
||||||
|
"Düsseldorf",
|
||||||
|
"Leipzig",
|
||||||
|
"Dortmund",
|
||||||
|
"Essen",
|
||||||
|
];
|
||||||
|
const MOCK_THERAPIEFORMEN = [
|
||||||
|
"verhaltenstherapie",
|
||||||
|
"tiefenpsychologisch",
|
||||||
|
"analytisch",
|
||||||
|
"systemisch",
|
||||||
|
];
|
||||||
|
const MOCK_ERGEBNISSE = [
|
||||||
|
"keine_antwort",
|
||||||
|
"keine_antwort",
|
||||||
|
"keine_antwort",
|
||||||
|
"absage",
|
||||||
|
"absage",
|
||||||
|
"warteliste",
|
||||||
|
];
|
||||||
|
|
||||||
|
function daysAgoISO(days: number) {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() - days);
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function seedMockData() {
|
||||||
|
const kanale = ["telefon", "email", "online_formular", "persoenlich"];
|
||||||
|
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
const vorname = MOCK_VORNAMEN[i % MOCK_VORNAMEN.length];
|
||||||
|
const nachname = MOCK_NACHNAMEN[i % MOCK_NACHNAMEN.length];
|
||||||
|
const stadt = MOCK_STAEDTE[i % MOCK_STAEDTE.length];
|
||||||
|
const therapieform = MOCK_THERAPIEFORMEN[i % MOCK_THERAPIEFORMEN.length];
|
||||||
|
|
||||||
|
const result = await dbExec(
|
||||||
|
`INSERT INTO therapeut (name, stadt, therapieform, telefon, email)
|
||||||
|
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
|
||||||
|
[
|
||||||
|
`${vorname} ${nachname}`,
|
||||||
|
stadt,
|
||||||
|
therapieform,
|
||||||
|
`0${170 + i} ${1000000 + i * 12345}`,
|
||||||
|
`${vorname.toLowerCase()}.${nachname.toLowerCase()}@example.de`,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const therapeutId = (result.rows[0] as { id: number }).id;
|
||||||
|
|
||||||
|
const kontaktCount = 1 + (i % 3);
|
||||||
|
for (let j = 0; j < kontaktCount; j++) {
|
||||||
|
const daysBack = (kontaktCount - j) * 3 + i;
|
||||||
|
const ergebnis = MOCK_ERGEBNISSE[(i + j) % MOCK_ERGEBNISSE.length];
|
||||||
|
const kanal = kanale[(i + j) % kanale.length];
|
||||||
|
await dbExec(
|
||||||
|
`INSERT INTO kontakt (therapeut_id, datum, kanal, ergebnis) VALUES ($1, $2, $3, $4)`,
|
||||||
|
[therapeutId, daysAgoISO(daysBack), kanal, ergebnis],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a sprechstunde for the first therapist (enables step 2)
|
||||||
|
const firstT = await dbExec("SELECT id FROM therapeut ORDER BY id LIMIT 1");
|
||||||
|
if (firstT.rows.length > 0) {
|
||||||
|
const tId = (firstT.rows[0] as { id: number }).id;
|
||||||
|
await dbExec(
|
||||||
|
"INSERT INTO sprechstunde (therapeut_id, datum, ergebnis, diagnose, dringlichkeitscode) VALUES ($1, $2, 'erstgespraech', 'F32.1', true)",
|
||||||
|
[tId, daysAgoISO(14)],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance user to eigensuche step so they can see the full kontakt flow
|
||||||
|
await dbExec(
|
||||||
|
"UPDATE nutzer SET aktueller_schritt = 'eigensuche', aktualisiert_am = NOW() WHERE id = 1",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,19 @@
|
|||||||
|
export type {
|
||||||
|
KontaktRow,
|
||||||
|
TherapeutMitKontakte,
|
||||||
|
TherapeutRow,
|
||||||
|
} from "./hooks";
|
||||||
export {
|
export {
|
||||||
createKontakt,
|
createKontakt,
|
||||||
createTherapeut,
|
createTherapeut,
|
||||||
|
deleteAllData,
|
||||||
|
deleteTherapeut,
|
||||||
|
seedMockData,
|
||||||
|
updateKontakt,
|
||||||
|
updateKontaktErgebnis,
|
||||||
|
updateTherapeut,
|
||||||
useKontakteForTherapeut,
|
useKontakteForTherapeut,
|
||||||
|
useTherapeut,
|
||||||
useTherapeutenListe,
|
useTherapeutenListe,
|
||||||
} from "./hooks";
|
} from "./hooks";
|
||||||
export type { KontaktFormData, TherapeutFormData } from "./schema";
|
export type { KontaktFormData, TherapeutFormData } from "./schema";
|
||||||
|
|||||||
@@ -1,14 +1,24 @@
|
|||||||
import { useForm } from "@tanstack/react-form";
|
import { useForm } from "@tanstack/react-form";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { Input } from "@/shared/components/ui/input";
|
import { Input } from "@/shared/components/ui/input";
|
||||||
import { Label } from "@/shared/components/ui/label";
|
import { Label } from "@/shared/components/ui/label";
|
||||||
|
import { getDb } from "@/shared/db/client";
|
||||||
import type { ProzessSchritt } from "@/shared/db/schema";
|
import type { ProzessSchritt } from "@/shared/db/schema";
|
||||||
import { dbExec } from "@/shared/hooks/use-db";
|
import { dbExec } from "@/shared/hooks/use-db";
|
||||||
import { PROZESS_SCHRITTE } from "@/shared/lib/constants";
|
import { PROZESS_SCHRITTE } from "@/shared/lib/constants";
|
||||||
|
|
||||||
export function OnboardingForm() {
|
export function OnboardingForm() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [dbReady, setDbReady] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Pre-warm DB on mount so submit is instant
|
||||||
|
useEffect(() => {
|
||||||
|
getDb().then(() => setDbReady(true));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -19,21 +29,28 @@ export function OnboardingForm() {
|
|||||||
aktueller_schritt: "neu" as ProzessSchritt,
|
aktueller_schritt: "neu" as ProzessSchritt,
|
||||||
},
|
},
|
||||||
onSubmit: async ({ value }) => {
|
onSubmit: async ({ value }) => {
|
||||||
await dbExec(
|
setSubmitting(true);
|
||||||
`INSERT INTO nutzer (name, plz, ort, krankenkasse, aktueller_schritt)
|
try {
|
||||||
VALUES ($1, $2, $3, $4, $5)`,
|
await dbExec(
|
||||||
[
|
`INSERT INTO nutzer (name, plz, ort, krankenkasse, aktueller_schritt)
|
||||||
value.name,
|
VALUES ($1, $2, $3, $4, $5)`,
|
||||||
value.plz,
|
[
|
||||||
value.ort,
|
value.name,
|
||||||
value.krankenkasse,
|
value.plz,
|
||||||
value.aktueller_schritt,
|
value.ort,
|
||||||
],
|
value.krankenkasse,
|
||||||
);
|
value.aktueller_schritt,
|
||||||
navigate({ to: "/prozess" });
|
],
|
||||||
|
);
|
||||||
|
navigate({ to: "/prozess" });
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isDisabled = !dbReady || submitting;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[80vh] flex-col items-center justify-center">
|
<div className="flex min-h-[80vh] flex-col items-center justify-center">
|
||||||
<div className="w-full max-w-md space-y-6">
|
<div className="w-full max-w-md space-y-6">
|
||||||
@@ -145,8 +162,20 @@ export function OnboardingForm() {
|
|||||||
)}
|
)}
|
||||||
</form.Field>
|
</form.Field>
|
||||||
|
|
||||||
<Button type="submit" className="w-full">
|
<Button type="submit" className="w-full" disabled={isDisabled}>
|
||||||
Weiter
|
{submitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||||
|
Wird gespeichert…
|
||||||
|
</>
|
||||||
|
) : !dbReady ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||||
|
Datenbank wird geladen…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Weiter"
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { Check } from "lucide-react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
import { Badge } from "@/shared/components/ui/badge";
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
import { Card, CardContent } from "@/shared/components/ui/card";
|
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||||
import { cn } from "@/shared/lib/utils";
|
import { cn } from "@/shared/lib/utils";
|
||||||
@@ -9,6 +11,7 @@ interface PhaseCardProps {
|
|||||||
beschreibung: string;
|
beschreibung: string;
|
||||||
status: PhaseStatus;
|
status: PhaseStatus;
|
||||||
index: number;
|
index: number;
|
||||||
|
children?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PhaseCard({
|
export function PhaseCard({
|
||||||
@@ -16,6 +19,7 @@ export function PhaseCard({
|
|||||||
beschreibung,
|
beschreibung,
|
||||||
status,
|
status,
|
||||||
index,
|
index,
|
||||||
|
children,
|
||||||
}: PhaseCardProps) {
|
}: PhaseCardProps) {
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
@@ -25,28 +29,31 @@ export function PhaseCard({
|
|||||||
status === "erledigt" && "opacity-60",
|
status === "erledigt" && "opacity-60",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<CardContent className="flex items-start gap-4">
|
<CardContent className="space-y-3">
|
||||||
<div
|
<div className="flex items-start gap-4">
|
||||||
className={cn(
|
<div
|
||||||
"flex h-8 w-8 shrink-0 items-center justify-center rounded-full border-2 text-sm font-bold",
|
className={cn(
|
||||||
status === "erledigt" &&
|
"flex h-8 w-8 shrink-0 items-center justify-center rounded-full border-2 text-sm font-bold",
|
||||||
"border-primary bg-primary text-primary-foreground",
|
status === "erledigt" &&
|
||||||
status === "aktuell" && "border-primary text-primary",
|
"border-primary bg-primary text-primary-foreground",
|
||||||
status === "offen" &&
|
status === "aktuell" && "border-primary text-primary",
|
||||||
"border-muted-foreground/40 text-muted-foreground/40",
|
status === "offen" &&
|
||||||
)}
|
"border-muted-foreground/40 text-muted-foreground/40",
|
||||||
>
|
)}
|
||||||
{status === "erledigt" ? "✓" : index + 1}
|
>
|
||||||
</div>
|
{status === "erledigt" ? <Check className="size-4" /> : index + 1}
|
||||||
<div className="flex flex-col gap-1">
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="font-medium">{label}</span>
|
<div className="flex items-center gap-2">
|
||||||
{status === "aktuell" && <Badge>Aktuell</Badge>}
|
<span className="font-medium">{label}</span>
|
||||||
|
{status === "aktuell" && <Badge>Aktuell</Badge>}
|
||||||
|
</div>
|
||||||
|
{status === "aktuell" && (
|
||||||
|
<p className="text-sm text-muted-foreground">{beschreibung}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{status === "aktuell" && (
|
|
||||||
<p className="text-sm text-muted-foreground">{beschreibung}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
{children}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import {
|
import { useForm } from "@tanstack/react-form";
|
||||||
Card,
|
import { Link } from "@tanstack/react-router";
|
||||||
CardContent,
|
import { useState } from "react";
|
||||||
CardHeader,
|
import { useTherapeutenListe } from "@/features/kontakte/hooks";
|
||||||
CardTitle,
|
import { Button } from "@/shared/components/ui/button";
|
||||||
} from "@/shared/components/ui/card";
|
import { Label } from "@/shared/components/ui/label";
|
||||||
|
import { Separator } from "@/shared/components/ui/separator";
|
||||||
import type { ProzessSchritt } from "@/shared/db/schema";
|
import type { ProzessSchritt } from "@/shared/db/schema";
|
||||||
|
import { dbExec } from "@/shared/hooks/use-db";
|
||||||
import { PROZESS_SCHRITTE } from "@/shared/lib/constants";
|
import { PROZESS_SCHRITTE } from "@/shared/lib/constants";
|
||||||
import { PhaseCard } from "./phase-card";
|
import { PhaseCard } from "./phase-card";
|
||||||
|
|
||||||
@@ -12,19 +14,29 @@ interface ProcessStepperProps {
|
|||||||
aktuellerSchritt: ProzessSchritt;
|
aktuellerSchritt: ProzessSchritt;
|
||||||
kontaktGesamt: number;
|
kontaktGesamt: number;
|
||||||
absagen: number;
|
absagen: number;
|
||||||
|
onUpdate: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputClasses =
|
||||||
|
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring";
|
||||||
|
|
||||||
|
function todayISO() {
|
||||||
|
const d = new Date();
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProcessStepper({
|
export function ProcessStepper({
|
||||||
aktuellerSchritt,
|
aktuellerSchritt,
|
||||||
kontaktGesamt,
|
kontaktGesamt,
|
||||||
absagen,
|
absagen,
|
||||||
|
onUpdate,
|
||||||
}: ProcessStepperProps) {
|
}: ProcessStepperProps) {
|
||||||
const currentIndex = PROZESS_SCHRITTE.findIndex(
|
const currentIndex = PROZESS_SCHRITTE.findIndex(
|
||||||
(s) => s.key === aktuellerSchritt,
|
(s) => s.key === aktuellerSchritt,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex w-full max-w-lg flex-col gap-6 p-4">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex items-baseline justify-between">
|
<div className="flex items-baseline justify-between">
|
||||||
<h1 className="text-2xl font-bold">Dein Fortschritt</h1>
|
<h1 className="text-2xl font-bold">Dein Fortschritt</h1>
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
@@ -48,24 +60,235 @@ export function ProcessStepper({
|
|||||||
beschreibung={schritt.beschreibung}
|
beschreibung={schritt.beschreibung}
|
||||||
status={status}
|
status={status}
|
||||||
index={i}
|
index={i}
|
||||||
/>
|
>
|
||||||
|
{status === "aktuell" && (
|
||||||
|
<StepAction
|
||||||
|
schritt={schritt.key}
|
||||||
|
kontaktGesamt={kontaktGesamt}
|
||||||
|
absagen={absagen}
|
||||||
|
onUpdate={onUpdate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</PhaseCard>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{currentIndex >= 3 && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Kontaktübersicht</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{kontaktGesamt} Kontaktversuche insgesamt, davon {absagen}{" "}
|
|
||||||
Absagen.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function StepAction({
|
||||||
|
schritt,
|
||||||
|
kontaktGesamt,
|
||||||
|
absagen,
|
||||||
|
onUpdate,
|
||||||
|
}: {
|
||||||
|
schritt: ProzessSchritt;
|
||||||
|
kontaktGesamt: number;
|
||||||
|
absagen: number;
|
||||||
|
onUpdate: () => void;
|
||||||
|
}) {
|
||||||
|
switch (schritt) {
|
||||||
|
case "neu":
|
||||||
|
return <SprechstundeForm onDone={onUpdate} />;
|
||||||
|
case "sprechstunde_absolviert":
|
||||||
|
return (
|
||||||
|
<AdvanceButton
|
||||||
|
nextStep="diagnose_erhalten"
|
||||||
|
label="Diagnose erhalten"
|
||||||
|
onDone={onUpdate}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case "diagnose_erhalten":
|
||||||
|
return (
|
||||||
|
<AdvanceButton
|
||||||
|
nextStep="tss_beantragt"
|
||||||
|
label="TSS kontaktiert"
|
||||||
|
onDone={onUpdate}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case "tss_beantragt":
|
||||||
|
return (
|
||||||
|
<AdvanceButton
|
||||||
|
nextStep="eigensuche"
|
||||||
|
label="Eigensuche starten"
|
||||||
|
onDone={onUpdate}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case "eigensuche":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{kontaktGesamt} Kontaktversuche, davon {absagen} Absagen.{" "}
|
||||||
|
<Link to="/kontakte" className="text-primary underline">
|
||||||
|
Kontakte verwalten
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<AdvanceButton
|
||||||
|
nextStep="antrag_gestellt"
|
||||||
|
label="Antrag stellen"
|
||||||
|
onDone={onUpdate}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "antrag_gestellt":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Dein Kostenerstattungsantrag wurde eingereicht.{" "}
|
||||||
|
<Link to="/antrag" className="text-primary underline">
|
||||||
|
Zum Antrag
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function AdvanceButton({
|
||||||
|
nextStep,
|
||||||
|
label,
|
||||||
|
onDone,
|
||||||
|
}: {
|
||||||
|
nextStep: ProzessSchritt;
|
||||||
|
label: string;
|
||||||
|
onDone: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={async () => {
|
||||||
|
await dbExec(
|
||||||
|
"UPDATE nutzer SET aktueller_schritt = $1, aktualisiert_am = NOW() WHERE id = 1",
|
||||||
|
[nextStep],
|
||||||
|
);
|
||||||
|
onDone();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SprechstundeForm({ onDone }: { onDone: () => void }) {
|
||||||
|
const { data: therapeuten, loading } = useTherapeutenListe();
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
defaultValues: {
|
||||||
|
therapeut_id: "",
|
||||||
|
datum: todayISO(),
|
||||||
|
diagnose: "",
|
||||||
|
},
|
||||||
|
onSubmit: async ({ value }) => {
|
||||||
|
const therapeutId = Number(value.therapeut_id);
|
||||||
|
if (!therapeutId) return;
|
||||||
|
|
||||||
|
await dbExec(
|
||||||
|
`INSERT INTO sprechstunde (therapeut_id, datum, ergebnis, diagnose) VALUES ($1, $2, 'erstgespraech', $3)`,
|
||||||
|
[therapeutId, value.datum, value.diagnose || null],
|
||||||
|
);
|
||||||
|
await dbExec(
|
||||||
|
"UPDATE nutzer SET aktueller_schritt = 'sprechstunde_absolviert', aktualisiert_am = NOW() WHERE id = 1",
|
||||||
|
);
|
||||||
|
setSaved(true);
|
||||||
|
onDone();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (saved) {
|
||||||
|
return (
|
||||||
|
<p className="text-sm font-medium text-green-600 dark:text-green-400">
|
||||||
|
Sprechstunde erfasst.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
form.handleSubmit();
|
||||||
|
}}
|
||||||
|
className="space-y-3"
|
||||||
|
>
|
||||||
|
<p className="text-sm font-medium">Erstgespräch erfassen</p>
|
||||||
|
<form.Field name="therapeut_id">
|
||||||
|
{(field) => (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Therapeut:in</Label>
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Laden…</p>
|
||||||
|
) : therapeuten.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Lege zuerst unter{" "}
|
||||||
|
<Link to="/kontakte" className="text-primary underline">
|
||||||
|
Kontakte
|
||||||
|
</Link>{" "}
|
||||||
|
einen Eintrag an.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<select
|
||||||
|
className={inputClasses}
|
||||||
|
value={field.state.value}
|
||||||
|
onChange={(e) => field.handleChange(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Bitte wählen…</option>
|
||||||
|
{therapeuten.map((t) => (
|
||||||
|
<option key={t.id} value={t.id}>
|
||||||
|
{t.name}
|
||||||
|
{t.stadt ? ` (${t.stadt})` : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
|
||||||
|
<form.Field name="datum">
|
||||||
|
{(field) => (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Datum</Label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className={inputClasses}
|
||||||
|
value={field.state.value}
|
||||||
|
onChange={(e) => field.handleChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
|
||||||
|
<form.Field name="diagnose">
|
||||||
|
{(field) => (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Diagnose (optional)</Label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={inputClasses}
|
||||||
|
placeholder="z.B. F32.1"
|
||||||
|
value={field.state.value}
|
||||||
|
onChange={(e) => field.handleChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
|
||||||
|
<Button type="submit" size="sm" disabled={therapeuten.length === 0}>
|
||||||
|
Sprechstunde erfassen
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,7 +10,9 @@
|
|||||||
|
|
||||||
import { Route as rootRouteImport } from './routes/__root'
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
import { Route as AntragIndexRouteImport } from './routes/antrag/index'
|
import { Route as AntragIndexRouteImport } from './routes/antrag/index'
|
||||||
|
import { Route as EinstellungenIndexRouteImport } from './routes/einstellungen/index'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
|
import { Route as KontakteIdRouteImport } from './routes/kontakte/$id'
|
||||||
import { Route as KontakteIndexRouteImport } from './routes/kontakte/index'
|
import { Route as KontakteIndexRouteImport } from './routes/kontakte/index'
|
||||||
import { Route as KontakteNeuRouteImport } from './routes/kontakte/neu'
|
import { Route as KontakteNeuRouteImport } from './routes/kontakte/neu'
|
||||||
import { Route as OnboardingIndexRouteImport } from './routes/onboarding/index'
|
import { Route as OnboardingIndexRouteImport } from './routes/onboarding/index'
|
||||||
@@ -36,6 +38,11 @@ const KontakteIndexRoute = KontakteIndexRouteImport.update({
|
|||||||
path: '/kontakte/',
|
path: '/kontakte/',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const EinstellungenIndexRoute = EinstellungenIndexRouteImport.update({
|
||||||
|
id: '/einstellungen/',
|
||||||
|
path: '/einstellungen/',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const AntragIndexRoute = AntragIndexRouteImport.update({
|
const AntragIndexRoute = AntragIndexRouteImport.update({
|
||||||
id: '/antrag/',
|
id: '/antrag/',
|
||||||
path: '/antrag/',
|
path: '/antrag/',
|
||||||
@@ -46,19 +53,28 @@ const KontakteNeuRoute = KontakteNeuRouteImport.update({
|
|||||||
path: '/kontakte/neu',
|
path: '/kontakte/neu',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const KontakteIdRoute = KontakteIdRouteImport.update({
|
||||||
|
id: '/kontakte/$id',
|
||||||
|
path: '/kontakte/$id',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/kontakte/$id': typeof KontakteIdRoute
|
||||||
'/kontakte/neu': typeof KontakteNeuRoute
|
'/kontakte/neu': typeof KontakteNeuRoute
|
||||||
'/antrag/': typeof AntragIndexRoute
|
'/antrag/': typeof AntragIndexRoute
|
||||||
|
'/einstellungen/': typeof EinstellungenIndexRoute
|
||||||
'/kontakte/': typeof KontakteIndexRoute
|
'/kontakte/': typeof KontakteIndexRoute
|
||||||
'/onboarding/': typeof OnboardingIndexRoute
|
'/onboarding/': typeof OnboardingIndexRoute
|
||||||
'/prozess/': typeof ProzessIndexRoute
|
'/prozess/': typeof ProzessIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/kontakte/$id': typeof KontakteIdRoute
|
||||||
'/kontakte/neu': typeof KontakteNeuRoute
|
'/kontakte/neu': typeof KontakteNeuRoute
|
||||||
'/antrag': typeof AntragIndexRoute
|
'/antrag': typeof AntragIndexRoute
|
||||||
|
'/einstellungen': typeof EinstellungenIndexRoute
|
||||||
'/kontakte': typeof KontakteIndexRoute
|
'/kontakte': typeof KontakteIndexRoute
|
||||||
'/onboarding': typeof OnboardingIndexRoute
|
'/onboarding': typeof OnboardingIndexRoute
|
||||||
'/prozess': typeof ProzessIndexRoute
|
'/prozess': typeof ProzessIndexRoute
|
||||||
@@ -66,8 +82,10 @@ export interface FileRoutesByTo {
|
|||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/kontakte/$id': typeof KontakteIdRoute
|
||||||
'/kontakte/neu': typeof KontakteNeuRoute
|
'/kontakte/neu': typeof KontakteNeuRoute
|
||||||
'/antrag/': typeof AntragIndexRoute
|
'/antrag/': typeof AntragIndexRoute
|
||||||
|
'/einstellungen/': typeof EinstellungenIndexRoute
|
||||||
'/kontakte/': typeof KontakteIndexRoute
|
'/kontakte/': typeof KontakteIndexRoute
|
||||||
'/onboarding/': typeof OnboardingIndexRoute
|
'/onboarding/': typeof OnboardingIndexRoute
|
||||||
'/prozess/': typeof ProzessIndexRoute
|
'/prozess/': typeof ProzessIndexRoute
|
||||||
@@ -76,24 +94,30 @@ export interface FileRouteTypes {
|
|||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths:
|
fullPaths:
|
||||||
| '/'
|
| '/'
|
||||||
|
| '/kontakte/$id'
|
||||||
| '/kontakte/neu'
|
| '/kontakte/neu'
|
||||||
| '/antrag/'
|
| '/antrag/'
|
||||||
|
| '/einstellungen/'
|
||||||
| '/kontakte/'
|
| '/kontakte/'
|
||||||
| '/onboarding/'
|
| '/onboarding/'
|
||||||
| '/prozess/'
|
| '/prozess/'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to:
|
||||||
| '/'
|
| '/'
|
||||||
|
| '/kontakte/$id'
|
||||||
| '/kontakte/neu'
|
| '/kontakte/neu'
|
||||||
| '/antrag'
|
| '/antrag'
|
||||||
|
| '/einstellungen'
|
||||||
| '/kontakte'
|
| '/kontakte'
|
||||||
| '/onboarding'
|
| '/onboarding'
|
||||||
| '/prozess'
|
| '/prozess'
|
||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/'
|
| '/'
|
||||||
|
| '/kontakte/$id'
|
||||||
| '/kontakte/neu'
|
| '/kontakte/neu'
|
||||||
| '/antrag/'
|
| '/antrag/'
|
||||||
|
| '/einstellungen/'
|
||||||
| '/kontakte/'
|
| '/kontakte/'
|
||||||
| '/onboarding/'
|
| '/onboarding/'
|
||||||
| '/prozess/'
|
| '/prozess/'
|
||||||
@@ -101,8 +125,10 @@ export interface FileRouteTypes {
|
|||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
|
KontakteIdRoute: typeof KontakteIdRoute
|
||||||
KontakteNeuRoute: typeof KontakteNeuRoute
|
KontakteNeuRoute: typeof KontakteNeuRoute
|
||||||
AntragIndexRoute: typeof AntragIndexRoute
|
AntragIndexRoute: typeof AntragIndexRoute
|
||||||
|
EinstellungenIndexRoute: typeof EinstellungenIndexRoute
|
||||||
KontakteIndexRoute: typeof KontakteIndexRoute
|
KontakteIndexRoute: typeof KontakteIndexRoute
|
||||||
OnboardingIndexRoute: typeof OnboardingIndexRoute
|
OnboardingIndexRoute: typeof OnboardingIndexRoute
|
||||||
ProzessIndexRoute: typeof ProzessIndexRoute
|
ProzessIndexRoute: typeof ProzessIndexRoute
|
||||||
@@ -138,6 +164,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof KontakteIndexRouteImport
|
preLoaderRoute: typeof KontakteIndexRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/einstellungen/': {
|
||||||
|
id: '/einstellungen/'
|
||||||
|
path: '/einstellungen'
|
||||||
|
fullPath: '/einstellungen/'
|
||||||
|
preLoaderRoute: typeof EinstellungenIndexRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/antrag/': {
|
'/antrag/': {
|
||||||
id: '/antrag/'
|
id: '/antrag/'
|
||||||
path: '/antrag'
|
path: '/antrag'
|
||||||
@@ -152,13 +185,22 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof KontakteNeuRouteImport
|
preLoaderRoute: typeof KontakteNeuRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/kontakte/$id': {
|
||||||
|
id: '/kontakte/$id'
|
||||||
|
path: '/kontakte/$id'
|
||||||
|
fullPath: '/kontakte/$id'
|
||||||
|
preLoaderRoute: typeof KontakteIdRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
|
KontakteIdRoute: KontakteIdRoute,
|
||||||
KontakteNeuRoute: KontakteNeuRoute,
|
KontakteNeuRoute: KontakteNeuRoute,
|
||||||
AntragIndexRoute: AntragIndexRoute,
|
AntragIndexRoute: AntragIndexRoute,
|
||||||
|
EinstellungenIndexRoute: EinstellungenIndexRoute,
|
||||||
KontakteIndexRoute: KontakteIndexRoute,
|
KontakteIndexRoute: KontakteIndexRoute,
|
||||||
OnboardingIndexRoute: OnboardingIndexRoute,
|
OnboardingIndexRoute: OnboardingIndexRoute,
|
||||||
ProzessIndexRoute: ProzessIndexRoute,
|
ProzessIndexRoute: ProzessIndexRoute,
|
||||||
|
|||||||
@@ -1,58 +1,55 @@
|
|||||||
import { createRootRoute, Link, Outlet } from "@tanstack/react-router";
|
import {
|
||||||
import { useThemeStore } from "../shared/hooks/use-theme";
|
createRootRoute,
|
||||||
|
Link,
|
||||||
const themeIcon = {
|
Outlet,
|
||||||
light: "\u2600",
|
useMatchRoute,
|
||||||
dark: "\u263D",
|
} from "@tanstack/react-router";
|
||||||
system: "\u2699",
|
import { FileText, ListChecks, Settings, Users } from "lucide-react";
|
||||||
} as const;
|
|
||||||
const themeNext = {
|
|
||||||
light: "dark",
|
|
||||||
dark: "system",
|
|
||||||
system: "light",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const Route = createRootRoute({
|
export const Route = createRootRoute({
|
||||||
component: () => {
|
component: () => {
|
||||||
const { theme, setTheme } = useThemeStore();
|
const matchRoute = useMatchRoute();
|
||||||
|
const isOnboarding = matchRoute({ to: "/onboarding" });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col bg-background text-foreground">
|
<div className="flex min-h-screen flex-col bg-background text-foreground">
|
||||||
<header className="flex items-center justify-end px-4 pt-3">
|
<main className="mx-auto w-full max-w-2xl flex-1 px-4 pb-20 pt-6">
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setTheme(themeNext[theme])}
|
|
||||||
className="rounded-md p-1.5 text-lg text-muted-foreground hover:text-foreground"
|
|
||||||
aria-label={`Theme: ${theme}`}
|
|
||||||
>
|
|
||||||
{themeIcon[theme]}
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
<main className="mx-auto w-full max-w-2xl flex-1 px-4 py-6">
|
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
<nav className="border-t bg-background">
|
{!isOnboarding && (
|
||||||
<div className="mx-auto flex max-w-2xl">
|
<nav className="fixed inset-x-0 bottom-0 border-t bg-background">
|
||||||
<Link
|
<div className="mx-auto flex max-w-2xl">
|
||||||
to="/prozess"
|
<Link
|
||||||
className="flex-1 py-3 text-center text-sm [&.active]:font-bold [&.active]:text-primary"
|
to="/prozess"
|
||||||
>
|
className="flex flex-1 flex-col items-center gap-1 py-2 text-xs text-muted-foreground [&.active]:font-bold [&.active]:text-primary"
|
||||||
Fortschritt
|
>
|
||||||
</Link>
|
<ListChecks className="size-5" />
|
||||||
<Link
|
Fortschritt
|
||||||
to="/kontakte"
|
</Link>
|
||||||
className="flex-1 py-3 text-center text-sm [&.active]:font-bold [&.active]:text-primary"
|
<Link
|
||||||
>
|
to="/kontakte"
|
||||||
Kontakte
|
className="flex flex-1 flex-col items-center gap-1 py-2 text-xs text-muted-foreground [&.active]:font-bold [&.active]:text-primary"
|
||||||
</Link>
|
>
|
||||||
<Link
|
<Users className="size-5" />
|
||||||
to="/antrag"
|
Kontakte
|
||||||
className="flex-1 py-3 text-center text-sm [&.active]:font-bold [&.active]:text-primary"
|
</Link>
|
||||||
>
|
<Link
|
||||||
Antrag
|
to="/antrag"
|
||||||
</Link>
|
className="flex flex-1 flex-col items-center gap-1 py-2 text-xs text-muted-foreground [&.active]:font-bold [&.active]:text-primary"
|
||||||
</div>
|
>
|
||||||
</nav>
|
<FileText className="size-5" />
|
||||||
|
Antrag
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/einstellungen"
|
||||||
|
className="flex flex-1 flex-col items-center gap-1 py-2 text-xs text-muted-foreground [&.active]:font-bold [&.active]:text-primary"
|
||||||
|
>
|
||||||
|
<Settings className="size-5" />
|
||||||
|
Mehr
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
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";
|
import { getDb } from "@/shared/db/client";
|
||||||
|
|
||||||
export const Route = createFileRoute("/")({
|
export const Route = createFileRoute("/")({
|
||||||
beforeLoad: async () => {
|
component: RootRedirect,
|
||||||
const db = await getDb();
|
|
||||||
const result = await db.query("SELECT id FROM nutzer LIMIT 1");
|
|
||||||
if (result.rows.length === 0) {
|
|
||||||
throw redirect({ to: "/onboarding" });
|
|
||||||
}
|
|
||||||
throw redirect({ to: "/prozess" });
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function RootRedirect() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const db = await getDb();
|
||||||
|
const result = await db.query("SELECT id FROM nutzer LIMIT 1");
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
navigate({ to: "/onboarding", replace: true });
|
||||||
|
} else {
|
||||||
|
navigate({ to: "/prozess", replace: true });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("DB init failed:", e);
|
||||||
|
navigate({ to: "/onboarding", replace: true });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
if (!loading) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[60vh] items-center justify-center">
|
||||||
|
<p className="text-muted-foreground">Laden…</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
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() {
|
function ProzessPage() {
|
||||||
const { data: nutzer, loading: nutzerLoading } = useNutzer();
|
const {
|
||||||
const { data: stats, loading: statsLoading } = useKontaktStats();
|
data: nutzer,
|
||||||
|
loading: nutzerLoading,
|
||||||
|
refetch: refetchNutzer,
|
||||||
|
} = useNutzer();
|
||||||
|
const {
|
||||||
|
data: stats,
|
||||||
|
loading: statsLoading,
|
||||||
|
refetch: refetchStats,
|
||||||
|
} = useKontaktStats();
|
||||||
|
|
||||||
if (nutzerLoading || statsLoading) return <p>Laden…</p>;
|
if (nutzerLoading || statsLoading) return <p>Laden…</p>;
|
||||||
if (!nutzer[0]) return <p>Bitte zuerst das Onboarding abschließen.</p>;
|
if (!nutzer[0]) return <p>Bitte zuerst das Onboarding abschließen.</p>;
|
||||||
@@ -20,6 +28,10 @@ function ProzessPage() {
|
|||||||
aktuellerSchritt={nutzer[0].aktueller_schritt}
|
aktuellerSchritt={nutzer[0].aktueller_schritt}
|
||||||
kontaktGesamt={Number(s.gesamt)}
|
kontaktGesamt={Number(s.gesamt)}
|
||||||
absagen={Number(s.absagen)}
|
absagen={Number(s.absagen)}
|
||||||
|
onUpdate={() => {
|
||||||
|
refetchNutzer();
|
||||||
|
refetchStats();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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",
|
key: "neu",
|
||||||
label: "Noch nicht begonnen",
|
label: "Erstgespräch durchführen",
|
||||||
beschreibung: "Du hast noch keine Schritte unternommen.",
|
beschreibung:
|
||||||
|
"Das Erstgespräch heißt in der Fachsprache psychotherapeutische Sprechstunde. Dort wird eine erste Einschätzung vorgenommen.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "sprechstunde_absolviert",
|
key: "sprechstunde_absolviert",
|
||||||
|
|||||||
4
src/vite-env.d.ts
vendored
Normal file
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 path from "node:path";
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
import tanstackRouter from "@tanstack/router-plugin/vite";
|
import tanstackRouter from "@tanstack/router-plugin/vite";
|
||||||
@@ -5,14 +7,22 @@ import react from "@vitejs/plugin-react";
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import { VitePWA } from "vite-plugin-pwa";
|
import { VitePWA } from "vite-plugin-pwa";
|
||||||
|
|
||||||
|
const pkg = JSON.parse(readFileSync("./package.json", "utf-8"));
|
||||||
|
const gitCount = execSync("git rev-list --count HEAD", {
|
||||||
|
encoding: "utf-8",
|
||||||
|
}).trim();
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
base: "/tpf/",
|
base: "/tpf/",
|
||||||
|
define: {
|
||||||
|
__APP_VERSION__: JSON.stringify(`${pkg.version}+${gitCount}`),
|
||||||
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
tanstackRouter(),
|
tanstackRouter(),
|
||||||
react(),
|
react(),
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
VitePWA({
|
VitePWA({
|
||||||
registerType: "autoUpdate",
|
registerType: "prompt",
|
||||||
includeAssets: ["icons/icon-192.png", "icons/icon-512.png"],
|
includeAssets: ["icons/icon-192.png", "icons/icon-512.png"],
|
||||||
workbox: {
|
workbox: {
|
||||||
globPatterns: ["**/*.{js,css,html,wasm,data}"],
|
globPatterns: ["**/*.{js,css,html,wasm,data}"],
|
||||||
|
|||||||
Reference in New Issue
Block a user