add contact tracker: list, form, cards, bottom navigation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 11:21:06 +01:00
parent 4e4be03466
commit a84fe1c5f8
7 changed files with 428 additions and 7 deletions

View File

@@ -0,0 +1,56 @@
import { Badge } from "@/shared/components/ui/badge";
import { Card, CardContent } from "@/shared/components/ui/card";
import type { KontaktErgebnis } from "@/shared/db/schema";
import { ERGEBNIS_LABELS } from "@/shared/lib/constants";
interface ContactCardProps {
id: number;
name: string;
stadt: string | null;
letzterKontakt: string | null;
letztesErgebnis: string | null;
kontakteGesamt: number;
}
const ergebnisVariant: Record<
KontaktErgebnis,
"default" | "secondary" | "destructive" | "outline"
> = {
zusage: "default",
warteliste: "secondary",
absage: "destructive",
keine_antwort: "outline",
};
export function ContactCard({
name,
stadt,
letzterKontakt,
letztesErgebnis,
kontakteGesamt,
}: ContactCardProps) {
return (
<Card>
<CardContent className="flex items-center justify-between gap-4">
<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}</>}
</p>
</div>
{letztesErgebnis && (
<Badge
variant={
ergebnisVariant[letztesErgebnis as KontaktErgebnis] ?? "outline"
}
>
{ERGEBNIS_LABELS[letztesErgebnis as KontaktErgebnis] ??
letztesErgebnis}
</Badge>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,249 @@
import { useForm } from "@tanstack/react-form";
import { Link, useNavigate } from "@tanstack/react-router";
import { createKontakt, createTherapeut } from "@/features/kontakte/hooks";
import { Button } from "@/shared/components/ui/button";
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();
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
export function ContactForm() {
const navigate = useNavigate();
const form = useForm({
defaultValues: {
name: "",
stadt: "",
telefon: "",
email: "",
datum: todayISO(),
kanal: "telefon" as KontaktKanal,
ergebnis: "keine_antwort" as KontaktErgebnis,
notiz: "",
},
onSubmit: async ({ value }) => {
if (!value.name.trim()) return;
const therapeutId = await createTherapeut({
name: value.name.trim(),
adresse: "",
plz: "",
stadt: value.stadt.trim(),
telefon: value.telefon.trim(),
email: value.email.trim(),
website: "",
therapieform: "",
});
await createKontakt({
therapeut_id: therapeutId,
datum: value.datum,
kanal: value.kanal,
ergebnis: value.ergebnis,
notiz: value.notiz.trim() || "",
});
navigate({ to: "/kontakte" });
},
});
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";
return (
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
className="space-y-6"
>
<h1 className="text-2xl font-bold">Neuer Kontakt</h1>
<section className="space-y-4">
<h2 className="text-lg font-semibold">Therapeut:in</h2>
<form.Field name="name">
{(field) => (
<div className="space-y-1">
<label htmlFor="name" className="text-sm font-medium">
Name *
</label>
<input
id="name"
type="text"
required
className={inputClasses}
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="stadt" className="text-sm font-medium">
Stadt
</label>
<input
id="stadt"
type="text"
className={inputClasses}
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
/>
</div>
)}
</form.Field>
<form.Field name="telefon">
{(field) => (
<div className="space-y-1">
<label htmlFor="telefon" className="text-sm font-medium">
Telefon
</label>
<input
id="telefon"
type="tel"
className={inputClasses}
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="email" className="text-sm font-medium">
E-Mail
</label>
<input
id="email"
type="email"
className={inputClasses}
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
/>
</div>
)}
</form.Field>
</section>
<Separator />
<section className="space-y-4">
<h2 className="text-lg font-semibold">Kontaktversuch</h2>
<form.Field name="datum">
{(field) => (
<div className="space-y-1">
<label htmlFor="datum" className="text-sm font-medium">
Datum
</label>
<input
id="datum"
type="date"
className={inputClasses}
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
/>
</div>
)}
</form.Field>
<form.Field name="kanal">
{(field) => (
<div className="space-y-1">
<label htmlFor="kanal" className="text-sm font-medium">
Kanal
</label>
<select
id="kanal"
className={inputClasses}
value={field.state.value}
onChange={(e) =>
field.handleChange(e.target.value as KontaktKanal)
}
onBlur={field.handleBlur}
>
{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="ergebnis" className="text-sm font-medium">
Ergebnis
</label>
<select
id="ergebnis"
className={inputClasses}
value={field.state.value}
onChange={(e) =>
field.handleChange(e.target.value as KontaktErgebnis)
}
onBlur={field.handleBlur}
>
{kontaktErgebnisEnum.options.map((k) => (
<option key={k} value={k}>
{ERGEBNIS_LABELS[k]}
</option>
))}
</select>
</div>
)}
</form.Field>
<form.Field name="notiz">
{(field) => (
<div className="space-y-1">
<label htmlFor="notiz" className="text-sm font-medium">
Notiz
</label>
<textarea
id="notiz"
rows={3}
className={`${inputClasses} min-h-[80px] py-2`}
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
/>
</div>
)}
</form.Field>
</section>
<div className="flex gap-3">
<Button type="submit">Speichern</Button>
<Button type="button" variant="outline" asChild>
<Link to="/kontakte">Abbrechen</Link>
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,46 @@
import { Link } from "@tanstack/react-router";
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();
if (loading) return <p>Laden</p>;
return (
<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>
</div>
{data.length === 0 ? (
<div className="py-12 text-center">
<p className="mb-4 text-muted-foreground">
Noch keine Therapeut:innen erfasst.
</p>
<Button asChild>
<Link to="/kontakte/neu">Ersten Kontakt erfassen</Link>
</Button>
</div>
) : (
<div className="space-y-3">
{data.map((t) => (
<ContactCard
key={t.id}
id={t.id}
name={t.name}
stadt={t.stadt}
letzterKontakt={t.letzter_kontakt}
letztesErgebnis={t.letztes_ergebnis}
kontakteGesamt={Number(t.kontakte_gesamt)}
/>
))}
</div>
)}
</div>
);
}

View File

@@ -10,6 +10,8 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
import { Route as KontakteIndexRouteImport } from './routes/kontakte/index'
import { Route as KontakteNeuRouteImport } from './routes/kontakte/neu'
import { Route as OnboardingIndexRouteImport } from './routes/onboarding/index'
import { Route as ProzessIndexRouteImport } from './routes/prozess/index'
@@ -28,33 +30,57 @@ const OnboardingIndexRoute = OnboardingIndexRouteImport.update({
path: '/onboarding/',
getParentRoute: () => rootRouteImport,
} as any)
const KontakteIndexRoute = KontakteIndexRouteImport.update({
id: '/kontakte/',
path: '/kontakte/',
getParentRoute: () => rootRouteImport,
} as any)
const KontakteNeuRoute = KontakteNeuRouteImport.update({
id: '/kontakte/neu',
path: '/kontakte/neu',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/kontakte/neu': typeof KontakteNeuRoute
'/kontakte/': typeof KontakteIndexRoute
'/onboarding/': typeof OnboardingIndexRoute
'/prozess/': typeof ProzessIndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/kontakte/neu': typeof KontakteNeuRoute
'/kontakte': typeof KontakteIndexRoute
'/onboarding': typeof OnboardingIndexRoute
'/prozess': typeof ProzessIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/kontakte/neu': typeof KontakteNeuRoute
'/kontakte/': typeof KontakteIndexRoute
'/onboarding/': typeof OnboardingIndexRoute
'/prozess/': typeof ProzessIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/onboarding/' | '/prozess/'
fullPaths: '/' | '/kontakte/neu' | '/kontakte/' | '/onboarding/' | '/prozess/'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/onboarding' | '/prozess'
id: '__root__' | '/' | '/onboarding/' | '/prozess/'
to: '/' | '/kontakte/neu' | '/kontakte' | '/onboarding' | '/prozess'
id:
| '__root__'
| '/'
| '/kontakte/neu'
| '/kontakte/'
| '/onboarding/'
| '/prozess/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
KontakteNeuRoute: typeof KontakteNeuRoute
KontakteIndexRoute: typeof KontakteIndexRoute
OnboardingIndexRoute: typeof OnboardingIndexRoute
ProzessIndexRoute: typeof ProzessIndexRoute
}
@@ -82,11 +108,27 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof OnboardingIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/kontakte/': {
id: '/kontakte/'
path: '/kontakte'
fullPath: '/kontakte/'
preLoaderRoute: typeof KontakteIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/kontakte/neu': {
id: '/kontakte/neu'
path: '/kontakte/neu'
fullPath: '/kontakte/neu'
preLoaderRoute: typeof KontakteNeuRouteImport
parentRoute: typeof rootRouteImport
}
}
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
KontakteNeuRoute: KontakteNeuRoute,
KontakteIndexRoute: KontakteIndexRoute,
OnboardingIndexRoute: OnboardingIndexRoute,
ProzessIndexRoute: ProzessIndexRoute,
}

View File

@@ -1,11 +1,27 @@
import { Outlet, createRootRoute } from "@tanstack/react-router"
import { createRootRoute, Link, Outlet } from "@tanstack/react-router";
export const Route = createRootRoute({
component: () => (
<div className="min-h-screen bg-background text-foreground">
<main className="mx-auto max-w-2xl px-4 py-6">
<div className="flex min-h-screen flex-col bg-background text-foreground">
<main className="mx-auto w-full max-w-2xl flex-1 px-4 py-6">
<Outlet />
</main>
<nav className="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"
>
Fortschritt
</Link>
<Link
to="/kontakte"
className="flex-1 py-3 text-center text-sm [&.active]:font-bold [&.active]:text-primary"
>
Kontakte
</Link>
</div>
</nav>
</div>
),
})
});

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { ContactList } from "@/features/kontakte/components/contact-list";
export const Route = createFileRoute("/kontakte/")({
component: () => <ContactList />,
});

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { ContactForm } from "@/features/kontakte/components/contact-form";
export const Route = createFileRoute("/kontakte/neu")({
component: () => <ContactForm />,
});