add contact tracker: list, form, cards, bottom navigation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
56
src/features/kontakte/components/contact-card.tsx
Normal file
56
src/features/kontakte/components/contact-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
249
src/features/kontakte/components/contact-form.tsx
Normal file
249
src/features/kontakte/components/contact-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
src/features/kontakte/components/contact-list.tsx
Normal file
46
src/features/kontakte/components/contact-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
})
|
||||
});
|
||||
|
||||
6
src/routes/kontakte/index.tsx
Normal file
6
src/routes/kontakte/index.tsx
Normal 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 />,
|
||||
});
|
||||
6
src/routes/kontakte/neu.tsx
Normal file
6
src/routes/kontakte/neu.tsx
Normal 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 />,
|
||||
});
|
||||
Reference in New Issue
Block a user