From 0fc13952fb5b52661228040678022469eb99ba4c Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Sat, 22 Aug 2026 22:50:45 +0200 Subject: [PATCH] feat(frontend): Benachrichtigungssystem (Modul 26) NotificationBell in der Kopfzeile: ungelesene Anzahl als Badge, Dropdown mit Prioritaets-Badges (INFO/HINWEIS/WARNUNG/KRITISCH), Klick markiert als gelesen und fuehrt zum verknuepften Objekt. Ausserdem IDOR im Backend gefunden und behoben (siehe vorheriger Commit) - live mit einem echten Datensatz verifiziert. --- ApiCore-Frontend/src/app/(app)/layout.tsx | 4 +- .../src/components/notification-bell.tsx | 100 ++++++++++++++++++ ApiCore-Frontend/src/lib/notifications/api.ts | 10 ++ .../src/lib/notifications/types.ts | 11 ++ TASKS.md | 2 +- 5 files changed, 125 insertions(+), 2 deletions(-) create mode 100644 ApiCore-Frontend/src/components/notification-bell.tsx create mode 100644 ApiCore-Frontend/src/lib/notifications/api.ts create mode 100644 ApiCore-Frontend/src/lib/notifications/types.ts diff --git a/ApiCore-Frontend/src/app/(app)/layout.tsx b/ApiCore-Frontend/src/app/(app)/layout.tsx index 62c5b6d..0c7241e 100644 --- a/ApiCore-Frontend/src/app/(app)/layout.tsx +++ b/ApiCore-Frontend/src/app/(app)/layout.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { Hexagon, MoreHorizontal } from "lucide-react"; import { useAuth } from "@/lib/auth/auth-context"; +import { NotificationBell } from "@/components/notification-bell"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -104,7 +105,8 @@ export default function AppLayout({ children }: { children: ReactNode }) { -
+
+ {user.email} diff --git a/ApiCore-Frontend/src/components/notification-bell.tsx b/ApiCore-Frontend/src/components/notification-bell.tsx new file mode 100644 index 0000000..61fe39f --- /dev/null +++ b/ApiCore-Frontend/src/components/notification-bell.tsx @@ -0,0 +1,100 @@ +"use client"; + +import Link from "next/link"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { Bell } from "lucide-react"; +import { fetchNotifications, markNotificationRead } from "@/lib/notifications/api"; +import { Notification, NotificationPriority } from "@/lib/notifications/types"; +import { Badge, badgeVariants } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; + +const PRIORITY_VARIANT: Record[0]>["variant"]> = { + INFO: "secondary", + HINWEIS: "outline", + WARNUNG: "warning", + KRITISCH: "destructive", +}; + +function notificationHref(notification: Notification): string | null { + if (notification.relatedType === "Colony") return `/colonies/${notification.relatedId}`; + if (notification.relatedType === "Apiary") return `/apiaries/${notification.relatedId}`; + if (notification.relatedType === "Task") return "/tasks"; + return null; +} + +export function NotificationBell() { + const queryClient = useQueryClient(); + const { data: notifications } = useQuery({ + queryKey: ["notifications"], + queryFn: () => fetchNotifications(), + refetchInterval: 60_000, + }); + + const readMutation = useMutation({ + mutationFn: markNotificationRead, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["notifications"] }); + }, + }); + + const unreadCount = notifications?.filter((notification) => !notification.read).length ?? 0; + + return ( + + + } + > + + {unreadCount > 0 ? ( + + {unreadCount > 9 ? "9+" : unreadCount} + + ) : null} + + + {!notifications || notifications.length === 0 ? ( +

Keine Benachrichtigungen.

+ ) : ( + notifications.slice(0, 10).map((notification) => { + const href = notificationHref(notification); + const item = ( +
+
+ + {notification.priority} + + + {new Date(notification.createdAt).toLocaleDateString("de-DE")} + +
+ {notification.message} +
+ ); + return ( + { + if (!notification.read) readMutation.mutate(notification.id); + }} + render={href ? : undefined} + > + {item} + + ); + }) + )} +
+
+ ); +} diff --git a/ApiCore-Frontend/src/lib/notifications/api.ts b/ApiCore-Frontend/src/lib/notifications/api.ts new file mode 100644 index 0000000..6483175 --- /dev/null +++ b/ApiCore-Frontend/src/lib/notifications/api.ts @@ -0,0 +1,10 @@ +import { apiFetch } from "@/lib/auth/api-client"; +import { Notification } from "./types"; + +export function fetchNotifications(unreadOnly = false): Promise { + return apiFetch(`/notifications?unreadOnly=${unreadOnly}`); +} + +export function markNotificationRead(id: string): Promise { + return apiFetch(`/notifications/${id}/read`, { method: "POST" }); +} diff --git a/ApiCore-Frontend/src/lib/notifications/types.ts b/ApiCore-Frontend/src/lib/notifications/types.ts new file mode 100644 index 0000000..ccd7a6c --- /dev/null +++ b/ApiCore-Frontend/src/lib/notifications/types.ts @@ -0,0 +1,11 @@ +export type NotificationPriority = "INFO" | "HINWEIS" | "WARNUNG" | "KRITISCH"; + +export interface Notification { + id: string; + priority: NotificationPriority; + message: string; + relatedType: string | null; + relatedId: string | null; + read: boolean; + createdAt: string; +} diff --git a/TASKS.md b/TASKS.md index 0bb2d0e..4128de4 100644 --- a/TASKS.md +++ b/TASKS.md @@ -212,7 +212,7 @@ Reihenfolge = Bau-Reihenfolge. Gleiche Haken-Regel wie MVP: erst setzen, wenn Li - Endpunkte: `GET /api/v1/notifications`, `POST /api/v1/notifications/:id/read` - Frontend: Glocken-Icon in der Navigation mit Liste/Badge für ungelesene Einträge - [x] Backend-Modul fertig, getestet, grün, committet -- [ ] Frontend-Views fertig, getestet, grün, committet +- [x] Frontend-Views fertig, getestet, grün, committet ### 27. Statistik und Reporting