add process stepper dashboard with phase cards, contact stats
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
53
src/features/prozess/components/phase-card.tsx
Normal file
53
src/features/prozess/components/phase-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
71
src/features/prozess/components/process-stepper.tsx
Normal file
71
src/features/prozess/components/process-stepper.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
src/features/prozess/hooks.ts
Normal file
40
src/features/prozess/hooks.ts
Normal 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
src/features/prozess/index.ts
Normal file
2
src/features/prozess/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { ProcessStepper } from "./components/process-stepper";
|
||||
export { updateSchritt, useKontaktStats, useNutzer } from "./hooks";
|
||||
@@ -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: "/",
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any);
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ProzessIndexRoute = ProzessIndexRouteImport.update({
|
||||
id: '/prozess/',
|
||||
path: '/prozess/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const OnboardingIndexRoute = OnboardingIndexRouteImport.update({
|
||||
id: "/onboarding/",
|
||||
path: "/onboarding/",
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any);
|
||||
id: '/onboarding/',
|
||||
path: '/onboarding/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} 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" {
|
||||
interface FileRoutesByPath {
|
||||
"/": {
|
||||
id: "/";
|
||||
path: "/";
|
||||
fullPath: "/";
|
||||
preLoaderRoute: typeof IndexRouteImport;
|
||||
parentRoute: typeof rootRouteImport;
|
||||
};
|
||||
"/onboarding/": {
|
||||
id: "/onboarding/";
|
||||
path: "/onboarding";
|
||||
fullPath: "/onboarding/";
|
||||
preLoaderRoute: typeof OnboardingIndexRouteImport;
|
||||
parentRoute: typeof rootRouteImport;
|
||||
};
|
||||
}
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/': {
|
||||
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,
|
||||
};
|
||||
IndexRoute: IndexRoute,
|
||||
OnboardingIndexRoute: OnboardingIndexRoute,
|
||||
ProzessIndexRoute: ProzessIndexRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
._addFileTypes<FileRouteTypes>();
|
||||
._addFileChildren(rootRouteChildren)
|
||||
._addFileTypes<FileRouteTypes>()
|
||||
|
||||
@@ -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" });
|
||||
},
|
||||
});
|
||||
|
||||
25
src/routes/prozess/index.tsx
Normal file
25
src/routes/prozess/index.tsx
Normal 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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user