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

View File

@@ -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>
);
}

View File

@@ -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>
);
}

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],
);
}

View File

@@ -0,0 +1,2 @@
export { ProcessStepper } from "./components/process-stepper";
export { updateSchritt, useKontaktStats, useNutzer } from "./hooks";

View File

@@ -8,70 +8,88 @@
// 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.
import { Route as rootRouteImport } from "./routes/__root";
import { Route as IndexRouteImport } from "./routes/index";
import { Route as OnboardingIndexRouteImport } from "./routes/onboarding/index";
import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
import { Route as OnboardingIndexRouteImport } from './routes/onboarding/index'
import { Route as ProzessIndexRouteImport } from './routes/prozess/index'
const IndexRoute = IndexRouteImport.update({
id: "/",
path: "/",
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any);
} as any)
const ProzessIndexRoute = ProzessIndexRouteImport.update({
id: '/prozess/',
path: '/prozess/',
getParentRoute: () => rootRouteImport,
} as any)
const OnboardingIndexRoute = OnboardingIndexRouteImport.update({
id: "/onboarding/",
path: "/onboarding/",
id: '/onboarding/',
path: '/onboarding/',
getParentRoute: () => rootRouteImport,
} as any);
} as any)
export interface FileRoutesByFullPath {
"/": typeof IndexRoute;
"/onboarding/": typeof OnboardingIndexRoute;
'/': typeof IndexRoute
'/onboarding/': typeof OnboardingIndexRoute
'/prozess/': typeof ProzessIndexRoute
}
export interface FileRoutesByTo {
"/": typeof IndexRoute;
"/onboarding": typeof OnboardingIndexRoute;
'/': typeof IndexRoute
'/onboarding': typeof OnboardingIndexRoute
'/prozess': typeof ProzessIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport;
"/": typeof IndexRoute;
"/onboarding/": typeof OnboardingIndexRoute;
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/onboarding/': typeof OnboardingIndexRoute
'/prozess/': typeof ProzessIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath;
fullPaths: "/" | "/onboarding/";
fileRoutesByTo: FileRoutesByTo;
to: "/" | "/onboarding";
id: "__root__" | "/" | "/onboarding/";
fileRoutesById: FileRoutesById;
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/onboarding/' | '/prozess/'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/onboarding' | '/prozess'
id: '__root__' | '/' | '/onboarding/' | '/prozess/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute;
OnboardingIndexRoute: typeof OnboardingIndexRoute;
IndexRoute: typeof IndexRoute
OnboardingIndexRoute: typeof OnboardingIndexRoute
ProzessIndexRoute: typeof ProzessIndexRoute
}
declare module "@tanstack/react-router" {
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
"/": {
id: "/";
path: "/";
fullPath: "/";
preLoaderRoute: typeof IndexRouteImport;
parentRoute: typeof rootRouteImport;
};
"/onboarding/": {
id: "/onboarding/";
path: "/onboarding";
fullPath: "/onboarding/";
preLoaderRoute: typeof OnboardingIndexRouteImport;
parentRoute: typeof rootRouteImport;
};
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/prozess/': {
id: '/prozess/'
path: '/prozess'
fullPath: '/prozess/'
preLoaderRoute: typeof ProzessIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/onboarding/': {
id: '/onboarding/'
path: '/onboarding'
fullPath: '/onboarding/'
preLoaderRoute: typeof OnboardingIndexRouteImport
parentRoute: typeof rootRouteImport
}
}
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
OnboardingIndexRoute: OnboardingIndexRoute,
};
ProzessIndexRoute: ProzessIndexRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>();
._addFileTypes<FileRouteTypes>()

View File

@@ -8,7 +8,6 @@ export const Route = createFileRoute("/")({
if (result.rows.length === 0) {
throw redirect({ to: "/onboarding" });
}
// /prozess route will be added in a future task
throw redirect({ to: "/onboarding" as string });
throw redirect({ to: "/prozess" });
},
});

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)}
/>
);
}