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.
This commit is contained in:
1 parent
75f0d4fd51
commit
0fc13952fb
5 files changed
+125
-2
No files matched your search
@@ -5,6 +5,7 @@ import Link from "next/link";
|
|||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { Hexagon, MoreHorizontal } from "lucide-react";
|
import { Hexagon, MoreHorizontal } from "lucide-react";
|
||||||
import { useAuth } from "@/lib/auth/auth-context";
|
import { useAuth } from "@/lib/auth/auth-context";
|
||||||
|
import { NotificationBell } from "@/components/notification-bell";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -104,7 +105,8 @@ export default function AppLayout({ children }: { children: ReactNode }) {
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
<div className="flex shrink-0 items-center gap-3 text-sm">
|
<div className="flex shrink-0 items-center gap-2 text-sm">
|
||||||
|
<NotificationBell />
|
||||||
<span className="hidden max-w-40 truncate text-muted-foreground md:inline">
|
<span className="hidden max-w-40 truncate text-muted-foreground md:inline">
|
||||||
{user.email}
|
{user.email}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -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<NotificationPriority, NonNullable<Parameters<typeof badgeVariants>[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 (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={
|
||||||
|
<Button variant="ghost" size="icon" className="relative" aria-label="Benachrichtigungen" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Bell className="size-4.5" />
|
||||||
|
{unreadCount > 0 ? (
|
||||||
|
<span className="absolute -top-0.5 -right-0.5 flex size-4 items-center justify-center rounded-full bg-destructive text-[0.6rem] font-medium text-destructive-foreground">
|
||||||
|
{unreadCount > 9 ? "9+" : unreadCount}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-80">
|
||||||
|
{!notifications || notifications.length === 0 ? (
|
||||||
|
<p className="p-3 text-sm text-muted-foreground">Keine Benachrichtigungen.</p>
|
||||||
|
) : (
|
||||||
|
notifications.slice(0, 10).map((notification) => {
|
||||||
|
const href = notificationHref(notification);
|
||||||
|
const item = (
|
||||||
|
<div
|
||||||
|
className={`flex flex-col gap-1 p-2 text-sm ${notification.read ? "opacity-60" : ""}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<Badge variant={PRIORITY_VARIANT[notification.priority]}>
|
||||||
|
{notification.priority}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{new Date(notification.createdAt).toLocaleDateString("de-DE")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span>{notification.message}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={notification.id}
|
||||||
|
className="flex-col items-stretch p-0"
|
||||||
|
onClick={() => {
|
||||||
|
if (!notification.read) readMutation.mutate(notification.id);
|
||||||
|
}}
|
||||||
|
render={href ? <Link href={href} /> : undefined}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { apiFetch } from "@/lib/auth/api-client";
|
||||||
|
import { Notification } from "./types";
|
||||||
|
|
||||||
|
export function fetchNotifications(unreadOnly = false): Promise<Notification[]> {
|
||||||
|
return apiFetch<Notification[]>(`/notifications?unreadOnly=${unreadOnly}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markNotificationRead(id: string): Promise<Notification> {
|
||||||
|
return apiFetch<Notification>(`/notifications/${id}/read`, { method: "POST" });
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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`
|
- 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
|
- Frontend: Glocken-Icon in der Navigation mit Liste/Badge für ungelesene Einträge
|
||||||
- [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
|
||||||
|
|
||||||
### 27. Statistik und Reporting
|
### 27. Statistik und Reporting
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user