add process stepper dashboard with phase cards, contact stats
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { ProcessStepper } from "./components/process-stepper";
|
||||||
|
export { updateSchritt, useKontaktStats, useNutzer } from "./hooks";
|
||||||
+59
-41
@@ -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>()
|
||||||
|
|||||||
@@ -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 });
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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