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 { 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 }) {
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</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">
|
||||
{user.email}
|
||||
</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;
|
||||
}
|
||||
Reference in new issue
Block a user