feat(frontend): Dashboard-Ansicht (Modul 13)
/dashboard als Startseite nach Login mit Kennzahlen-Kacheln und Hinweisen (überfällige Aufgaben, auffällige Völker, Lagerartikel unter Mindestbestand).
This commit is contained in:
1 parent
09407d4378
commit
55b9bc2587
7 files changed
+123
-3
No files matched your search
@@ -0,0 +1,90 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { fetchDashboardAlerts, fetchDashboardSummary } from "@/lib/dashboard/api";
|
||||||
|
import { DashboardAlert } from "@/lib/dashboard/types";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
function alertHref(alert: DashboardAlert): string | null {
|
||||||
|
if (alert.relatedType === "Colony") return `/colonies/${alert.relatedId}`;
|
||||||
|
if (alert.relatedType === "Task") return "/tasks";
|
||||||
|
if (alert.relatedType === "StockItem") return "/storage";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const { data: summary, isLoading: summaryLoading } = useQuery({
|
||||||
|
queryKey: ["dashboard", "summary"],
|
||||||
|
queryFn: fetchDashboardSummary,
|
||||||
|
});
|
||||||
|
const { data: alerts, isLoading: alertsLoading } = useQuery({
|
||||||
|
queryKey: ["dashboard", "alerts"],
|
||||||
|
queryFn: fetchDashboardAlerts,
|
||||||
|
});
|
||||||
|
|
||||||
|
const tiles = summary
|
||||||
|
? [
|
||||||
|
{ label: "Aktive Bienenstände", value: summary.apiariesActive },
|
||||||
|
{ label: "Aktive Völker", value: summary.coloniesActive },
|
||||||
|
{ label: "Aktive Königinnen", value: summary.queensActive },
|
||||||
|
{ label: "Offene Aufgaben", value: summary.openTasks },
|
||||||
|
{ label: "Honigchargen", value: summary.honeyBatchesTotal },
|
||||||
|
{ label: "Artikel unter Mindestbestand", value: summary.stockItemsLowCount },
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex flex-col gap-6 p-4 sm:p-6">
|
||||||
|
<h1 className="text-xl font-semibold">Dashboard</h1>
|
||||||
|
|
||||||
|
{summaryLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||||
|
{tiles.map((tile) => (
|
||||||
|
<Card key={tile.label}>
|
||||||
|
<CardHeader className="pb-1">
|
||||||
|
<CardTitle className="text-sm font-normal text-muted-foreground">
|
||||||
|
{tile.label}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-2xl font-semibold">{tile.value}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Hinweise</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-2 text-sm">
|
||||||
|
{alertsLoading ? <p className="text-muted-foreground">Lädt…</p> : null}
|
||||||
|
{!alertsLoading && (!alerts || alerts.length === 0) ? (
|
||||||
|
<p className="text-muted-foreground">Keine offenen Hinweise.</p>
|
||||||
|
) : null}
|
||||||
|
{alerts?.map((alert) => {
|
||||||
|
const href = alertHref(alert);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${alert.type}-${alert.relatedId}`}
|
||||||
|
className="flex items-center justify-between rounded-md border p-3"
|
||||||
|
>
|
||||||
|
<span>{alert.message}</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="secondary">{alert.type}</Badge>
|
||||||
|
{href ? (
|
||||||
|
<Link className="underline" href={href}>
|
||||||
|
Ansehen
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,6 +25,9 @@ export default function AppLayout({ children }: { children: ReactNode }) {
|
|||||||
<header className="flex items-center justify-between border-b px-4 py-3">
|
<header className="flex items-center justify-between border-b px-4 py-3">
|
||||||
<nav className="flex items-center gap-4">
|
<nav className="flex items-center gap-4">
|
||||||
<span className="font-semibold">ApiCore</span>
|
<span className="font-semibold">ApiCore</span>
|
||||||
|
<Link className="text-sm text-muted-foreground hover:text-foreground" href="/dashboard">
|
||||||
|
Dashboard
|
||||||
|
</Link>
|
||||||
<Link className="text-sm text-muted-foreground hover:text-foreground" href="/apiaries">
|
<Link className="text-sm text-muted-foreground hover:text-foreground" href="/apiaries">
|
||||||
Bienenstände
|
Bienenstände
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ describe("LoginPage", () => {
|
|||||||
await userEvent.click(screen.getByRole("button", { name: /anmelden/i }));
|
await userEvent.click(screen.getByRole("button", { name: /anmelden/i }));
|
||||||
|
|
||||||
expect(loginMock).toHaveBeenCalledWith("[email protected]", "ChangeMe123!");
|
expect(loginMock).toHaveBeenCalledWith("[email protected]", "ChangeMe123!");
|
||||||
expect(pushMock).toHaveBeenCalledWith("/settings/users");
|
expect(pushMock).toHaveBeenCalledWith("/dashboard");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows an error message when login fails", async () => {
|
it("shows an error message when login fails", async () => {
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export default function LoginPage() {
|
|||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await login(email, password);
|
await login(email, password);
|
||||||
router.push("/settings/users");
|
router.push("/dashboard");
|
||||||
} catch {
|
} catch {
|
||||||
setError("Login fehlgeschlagen. Bitte E-Mail und Passwort prüfen.");
|
setError("Login fehlgeschlagen. Bitte E-Mail und Passwort prüfen.");
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { apiFetch } from "@/lib/auth/api-client";
|
||||||
|
import { DashboardAlert, DashboardSummary } from "./types";
|
||||||
|
|
||||||
|
export function fetchDashboardSummary(): Promise<DashboardSummary> {
|
||||||
|
return apiFetch<DashboardSummary>("/dashboard/summary");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchDashboardAlerts(): Promise<DashboardAlert[]> {
|
||||||
|
return apiFetch<DashboardAlert[]>("/dashboard/alerts");
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export interface DashboardSummary {
|
||||||
|
apiariesActive: number;
|
||||||
|
coloniesActive: number;
|
||||||
|
queensActive: number;
|
||||||
|
openTasks: number;
|
||||||
|
honeyBatchesTotal: number;
|
||||||
|
stockItemsLowCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DashboardAlertType = "OVERDUE_TASK" | "COLONY_ATTENTION" | "LOW_STOCK";
|
||||||
|
|
||||||
|
export interface DashboardAlert {
|
||||||
|
type: DashboardAlertType;
|
||||||
|
message: string;
|
||||||
|
relatedType: string;
|
||||||
|
relatedId: string;
|
||||||
|
}
|
||||||
@@ -115,7 +115,7 @@ Abgeleitet aus [`docs/lastenheft.md`](docs/lastenheft.md) Abschnitt 26. Reihenfo
|
|||||||
- Endpunkte: `GET /api/v1/dashboard/summary`, `GET /api/v1/dashboard/alerts`
|
- Endpunkte: `GET /api/v1/dashboard/summary`, `GET /api/v1/dashboard/alerts`
|
||||||
- Frontend: `/dashboard` (Startseite nach Login)
|
- Frontend: `/dashboard` (Startseite nach Login)
|
||||||
- [x] Backend-Modul fertig, getestet, grün, committet
|
- [x] Backend-Modul fertig, getestet, grün, committet
|
||||||
- [ ] Frontend-Views fertig, getestet, grün, committet
|
- [x] Frontend-Views fertig, getestet, grün, committet
|
||||||
|
|
||||||
### 14. Suche und Filter
|
### 14. Suche und Filter
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user