add process stepper dashboard with phase cards, contact stats

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 11:13:37 +01:00
parent 39060c4e8e
commit b884e4e8c4
7 changed files with 258 additions and 50 deletions
@@ -0,0 +1,53 @@
import { Badge } from "@/shared/components/ui/badge";
import { Card, CardContent } from "@/shared/components/ui/card";
import { cn } from "@/shared/lib/utils";
type PhaseStatus = "erledigt" | "aktuell" | "offen";
interface PhaseCardProps {
label: string;
beschreibung: string;
status: PhaseStatus;
index: number;
}
export function PhaseCard({
label,
beschreibung,
status,
index,
}: PhaseCardProps) {
return (
<Card
className={cn(
"transition-all",
status === "aktuell" && "ring-2 ring-primary border-primary",
status === "erledigt" && "opacity-60",
)}
>
<CardContent className="flex items-start gap-4">
<div
className={cn(
"flex h-8 w-8 shrink-0 items-center justify-center rounded-full border-2 text-sm font-bold",
status === "erledigt" &&
"border-primary bg-primary text-primary-foreground",
status === "aktuell" && "border-primary text-primary",
status === "offen" &&
"border-muted-foreground/40 text-muted-foreground/40",
)}
>
{status === "erledigt" ? "✓" : index + 1}
</div>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<span className="font-medium">{label}</span>
{status === "aktuell" && <Badge>Aktuell</Badge>}
</div>
{status === "aktuell" && (
<p className="text-sm text-muted-foreground">{beschreibung}</p>
)}
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,71 @@
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card";
import type { ProzessSchritt } from "@/shared/db/schema";
import { PROZESS_SCHRITTE } from "@/shared/lib/constants";
import { PhaseCard } from "./phase-card";
interface ProcessStepperProps {
aktuellerSchritt: ProzessSchritt;
kontaktGesamt: number;
absagen: number;
}
export function ProcessStepper({
aktuellerSchritt,
kontaktGesamt,
absagen,
}: ProcessStepperProps) {
const currentIndex = PROZESS_SCHRITTE.findIndex(
(s) => s.key === aktuellerSchritt,
);
return (
<div className="mx-auto flex w-full max-w-lg flex-col gap-6 p-4">
<div className="flex items-baseline justify-between">
<h1 className="text-2xl font-bold">Dein Fortschritt</h1>
<span className="text-sm text-muted-foreground">
Schritt {currentIndex + 1} von {PROZESS_SCHRITTE.length}
</span>
</div>
<div className="flex flex-col gap-3">
{PROZESS_SCHRITTE.map((schritt, i) => {
const status =
i < currentIndex
? "erledigt"
: i === currentIndex
? "aktuell"
: "offen";
return (
<PhaseCard
key={schritt.key}
label={schritt.label}
beschreibung={schritt.beschreibung}
status={status}
index={i}
/>
);
})}
</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>
);
}
+40
View File
@@ -0,0 +1,40 @@
import type { ProzessSchritt } from "@/shared/db/schema";
import { dbExec, useDbQuery } from "@/shared/hooks/use-db";
interface NutzerRow {
id: number;
name: string;
aktueller_schritt: ProzessSchritt;
dringlichkeitscode: boolean;
tss_beantragt: boolean;
krankenkasse: string;
}
interface KontaktStats {
gesamt: number;
absagen: number;
warteliste: number;
keine_antwort: number;
}
export function useNutzer() {
return useDbQuery<NutzerRow>("SELECT * FROM nutzer LIMIT 1");
}
export function useKontaktStats() {
return useDbQuery<KontaktStats>(`
SELECT
COUNT(*) as gesamt,
COUNT(*) FILTER (WHERE ergebnis = 'absage') as absagen,
COUNT(*) FILTER (WHERE ergebnis = 'warteliste') as warteliste,
COUNT(*) FILTER (WHERE ergebnis = 'keine_antwort') as keine_antwort
FROM kontakt
`);
}
export async function updateSchritt(schritt: ProzessSchritt) {
await dbExec(
"UPDATE nutzer SET aktueller_schritt = $1, aktualisiert_am = NOW() WHERE id = 1",
[schritt],
);
}
+2
View File
@@ -0,0 +1,2 @@
export { ProcessStepper } from "./components/process-stepper";
export { updateSchritt, useKontaktStats, useNutzer } from "./hooks";
+59 -41
View File
@@ -8,70 +8,88 @@
// You should NOT make any changes in this file as it will be overwritten. // You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from "./routes/__root"; import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from "./routes/index"; import { Route as IndexRouteImport } from './routes/index'
import { Route as OnboardingIndexRouteImport } from "./routes/onboarding/index"; import { Route as OnboardingIndexRouteImport } from './routes/onboarding/index'
import { Route as ProzessIndexRouteImport } from './routes/prozess/index'
const IndexRoute = IndexRouteImport.update({ const IndexRoute = IndexRouteImport.update({
id: "/", id: '/',
path: "/", path: '/',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any); } as any)
const ProzessIndexRoute = ProzessIndexRouteImport.update({
id: '/prozess/',
path: '/prozess/',
getParentRoute: () => rootRouteImport,
} as any)
const OnboardingIndexRoute = OnboardingIndexRouteImport.update({ const OnboardingIndexRoute = OnboardingIndexRouteImport.update({
id: "/onboarding/", id: '/onboarding/',
path: "/onboarding/", path: '/onboarding/',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any); } as any)
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
"/": typeof IndexRoute; '/': typeof IndexRoute
"/onboarding/": typeof OnboardingIndexRoute; '/onboarding/': typeof OnboardingIndexRoute
'/prozess/': typeof ProzessIndexRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
"/": typeof IndexRoute; '/': typeof IndexRoute
"/onboarding": typeof OnboardingIndexRoute; '/onboarding': typeof OnboardingIndexRoute
'/prozess': typeof ProzessIndexRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport; __root__: typeof rootRouteImport
"/": typeof IndexRoute; '/': typeof IndexRoute
"/onboarding/": typeof OnboardingIndexRoute; '/onboarding/': typeof OnboardingIndexRoute
'/prozess/': typeof ProzessIndexRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath; fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: "/" | "/onboarding/"; fullPaths: '/' | '/onboarding/' | '/prozess/'
fileRoutesByTo: FileRoutesByTo; fileRoutesByTo: FileRoutesByTo
to: "/" | "/onboarding"; to: '/' | '/onboarding' | '/prozess'
id: "__root__" | "/" | "/onboarding/"; id: '__root__' | '/' | '/onboarding/' | '/prozess/'
fileRoutesById: FileRoutesById; fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
IndexRoute: typeof IndexRoute; IndexRoute: typeof IndexRoute
OnboardingIndexRoute: typeof OnboardingIndexRoute; OnboardingIndexRoute: typeof OnboardingIndexRoute
ProzessIndexRoute: typeof ProzessIndexRoute
} }
declare module "@tanstack/react-router" { declare module '@tanstack/react-router' {
interface FileRoutesByPath { interface FileRoutesByPath {
"/": { '/': {
id: "/"; id: '/'
path: "/"; path: '/'
fullPath: "/"; fullPath: '/'
preLoaderRoute: typeof IndexRouteImport; preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport; parentRoute: typeof rootRouteImport
}; }
"/onboarding/": { '/prozess/': {
id: "/onboarding/"; id: '/prozess/'
path: "/onboarding"; path: '/prozess'
fullPath: "/onboarding/"; fullPath: '/prozess/'
preLoaderRoute: typeof OnboardingIndexRouteImport; preLoaderRoute: typeof ProzessIndexRouteImport
parentRoute: typeof rootRouteImport; parentRoute: typeof rootRouteImport
}; }
'/onboarding/': {
id: '/onboarding/'
path: '/onboarding'
fullPath: '/onboarding/'
preLoaderRoute: typeof OnboardingIndexRouteImport
parentRoute: typeof rootRouteImport
}
} }
} }
const rootRouteChildren: RootRouteChildren = { const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute, IndexRoute: IndexRoute,
OnboardingIndexRoute: OnboardingIndexRoute, OnboardingIndexRoute: OnboardingIndexRoute,
}; ProzessIndexRoute: ProzessIndexRoute,
}
export const routeTree = rootRouteImport export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren) ._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>(); ._addFileTypes<FileRouteTypes>()
+1 -2
View File
@@ -8,7 +8,6 @@ export const Route = createFileRoute("/")({
if (result.rows.length === 0) { if (result.rows.length === 0) {
throw redirect({ to: "/onboarding" }); throw redirect({ to: "/onboarding" });
} }
// /prozess route will be added in a future task throw redirect({ to: "/prozess" });
throw redirect({ to: "/onboarding" as string });
}, },
}); });
+25
View File
@@ -0,0 +1,25 @@
import { createFileRoute } from "@tanstack/react-router";
import { ProcessStepper } from "@/features/prozess/components/process-stepper";
import { useKontaktStats, useNutzer } from "@/features/prozess/hooks";
export const Route = createFileRoute("/prozess/")({
component: ProzessPage,
});
function ProzessPage() {
const { data: nutzer, loading: nutzerLoading } = useNutzer();
const { data: stats, loading: statsLoading } = useKontaktStats();
if (nutzerLoading || statsLoading) return <p>Laden</p>;
if (!nutzer[0]) return <p>Bitte zuerst das Onboarding abschließen.</p>;
const s = stats[0] ?? { gesamt: 0, absagen: 0 };
return (
<ProcessStepper
aktuellerSchritt={nutzer[0].aktueller_schritt}
kontaktGesamt={Number(s.gesamt)}
absagen={Number(s.absagen)}
/>
);
}