mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 11:08:12 +00:00
feat: setup notification to the backoffice
This commit is contained in:
@@ -33,6 +33,7 @@
|
|||||||
"react-hot-toast": "^2.6.0",
|
"react-hot-toast": "^2.6.0",
|
||||||
"react-router-dom": "^6.27.0",
|
"react-router-dom": "^6.27.0",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
|
"socket.io-client": "^4.8.3",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"stream-browserify": "^3.0.0",
|
"stream-browserify": "^3.0.0",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
|
|||||||
@@ -5,14 +5,12 @@ import {
|
|||||||
Burger,
|
Burger,
|
||||||
Divider,
|
Divider,
|
||||||
Group,
|
Group,
|
||||||
Indicator,
|
|
||||||
Menu,
|
Menu,
|
||||||
Text,
|
Text,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
UnstyledButton,
|
UnstyledButton,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
Bell,
|
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
FileSignature,
|
FileSignature,
|
||||||
Languages,
|
Languages,
|
||||||
@@ -25,6 +23,8 @@ import {
|
|||||||
import { type ReactNode } from "react";
|
import { type ReactNode } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import NotificationBellContainer from "@/features/notifications/NotificationBellContainer";
|
||||||
|
|
||||||
import type { PageMeta } from "./types";
|
import type { PageMeta } from "./types";
|
||||||
|
|
||||||
export interface FreightDashboardHeaderProps {
|
export interface FreightDashboardHeaderProps {
|
||||||
@@ -117,19 +117,7 @@ const FreightDashboardHeader = ({
|
|||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip label="Notifications" withArrow openDelay={300}>
|
<NotificationBellContainer />
|
||||||
<Indicator
|
|
||||||
color="edr-accent"
|
|
||||||
size={8}
|
|
||||||
offset={6}
|
|
||||||
withBorder
|
|
||||||
aria-label="Unread notifications"
|
|
||||||
>
|
|
||||||
<UnstyledButton className={ISLAND} aria-label="Notifications">
|
|
||||||
<Bell size={17} strokeWidth={1.8} />
|
|
||||||
</UnstyledButton>
|
|
||||||
</Indicator>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
{enableThemeToggle && (
|
{enableThemeToggle && (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { NotificationBell, type NotificationBellItem } from "@edr/ui-common";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import {
|
||||||
|
useMarkAllRead,
|
||||||
|
useMarkRead,
|
||||||
|
useNotificationsList,
|
||||||
|
useUnreadCount,
|
||||||
|
} from "./useNotifications";
|
||||||
|
import { useNotificationSocket } from "./useNotificationSocket";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wires react-query + the notification WebSocket into the shared presentational
|
||||||
|
* bell. Mounted inside the authenticated dashboard header.
|
||||||
|
*/
|
||||||
|
export default function NotificationBellContainer({
|
||||||
|
enabled = true,
|
||||||
|
}: {
|
||||||
|
enabled?: boolean;
|
||||||
|
}) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const list = useNotificationsList(enabled);
|
||||||
|
const unread = useUnreadCount(enabled);
|
||||||
|
const markRead = useMarkRead();
|
||||||
|
const markAllRead = useMarkAllRead();
|
||||||
|
useNotificationSocket(enabled);
|
||||||
|
|
||||||
|
const items: NotificationBellItem[] = (list.data?.items ?? []).map((n) => ({
|
||||||
|
id: n.id,
|
||||||
|
title: n.title,
|
||||||
|
body: n.body,
|
||||||
|
createdAt: n.createdAt,
|
||||||
|
isRead: n.isRead,
|
||||||
|
link: n.link,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NotificationBell
|
||||||
|
items={items}
|
||||||
|
unreadCount={unread.data ?? list.data?.unreadCount ?? 0}
|
||||||
|
loading={list.isLoading}
|
||||||
|
onOpen={() => {
|
||||||
|
void list.refetch();
|
||||||
|
void unread.refetch();
|
||||||
|
}}
|
||||||
|
onItemClick={(item) => {
|
||||||
|
if (!item.isRead) markRead.mutate(item.id);
|
||||||
|
if (item.link) navigate(item.link);
|
||||||
|
}}
|
||||||
|
onMarkAllRead={() => markAllRead.mutate()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import type { NotificationListResult } from "@edr/types";
|
||||||
|
|
||||||
|
import { api } from "@/auth/http";
|
||||||
|
|
||||||
|
export interface ListNotificationsParams {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
isRead?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backoffice notification REST calls. The backoffice axios `api` response
|
||||||
|
* interceptor already unwraps the `{ success, data }` envelope, so `.data` here
|
||||||
|
* is the payload itself.
|
||||||
|
*/
|
||||||
|
export const notificationsApi = {
|
||||||
|
list: async (
|
||||||
|
params: ListNotificationsParams = {},
|
||||||
|
): Promise<NotificationListResult> => {
|
||||||
|
const { data } = await api.get<NotificationListResult>("/notifications", {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
unreadCount: async (): Promise<number> => {
|
||||||
|
const { data } = await api.get<{ unreadCount: number }>(
|
||||||
|
"/notifications/unread-count",
|
||||||
|
);
|
||||||
|
return data.unreadCount;
|
||||||
|
},
|
||||||
|
markRead: async (id: string): Promise<void> => {
|
||||||
|
await api.patch(`/notifications/${id}/read`);
|
||||||
|
},
|
||||||
|
markAllRead: async (): Promise<void> => {
|
||||||
|
await api.post("/notifications/read-all");
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import {
|
||||||
|
NOTIFICATION_WS_EVENTS,
|
||||||
|
NOTIFICATION_WS_NAMESPACE,
|
||||||
|
type NotificationDto,
|
||||||
|
} from "@edr/types";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import { io } from "socket.io-client";
|
||||||
|
|
||||||
|
import { API_BASE_URL } from "@/constants/apiConfig";
|
||||||
|
import { AUTH_TOKEN_COOKIE, getCookie } from "@/auth/cookies";
|
||||||
|
|
||||||
|
import { NOTIFICATIONS_KEY, UNREAD_KEY } from "./useNotifications";
|
||||||
|
|
||||||
|
// The socket namespace lives at the server root, not under the `/api` REST
|
||||||
|
// prefix — strip a trailing `/api` if the base URL carries one.
|
||||||
|
const SOCKET_ORIGIN = String(API_BASE_URL ?? "").replace(/\/api\/?$/, "");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribes to live notification pushes for the signed-in staff user. New
|
||||||
|
* items invalidate the list + toast; unread-count pushes update the badge.
|
||||||
|
*/
|
||||||
|
export function useNotificationSocket(enabled: boolean) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
const token = getCookie(AUTH_TOKEN_COOKIE);
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
const socket = io(`${SOCKET_ORIGIN}/${NOTIFICATION_WS_NAMESPACE}`, {
|
||||||
|
auth: { token },
|
||||||
|
transports: ["websocket"],
|
||||||
|
withCredentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on(NOTIFICATION_WS_EVENTS.NEW, (n: NotificationDto) => {
|
||||||
|
qc.invalidateQueries({ queryKey: NOTIFICATIONS_KEY });
|
||||||
|
toast(n.title);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on(NOTIFICATION_WS_EVENTS.UNREAD_COUNT, (count: number) => {
|
||||||
|
if (typeof count === "number") qc.setQueryData(UNREAD_KEY, count);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.off();
|
||||||
|
socket.disconnect();
|
||||||
|
};
|
||||||
|
}, [enabled, qc]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { notificationsApi } from "./notificationsApi";
|
||||||
|
|
||||||
|
export const NOTIFICATIONS_KEY = ["notifications"] as const;
|
||||||
|
export const UNREAD_KEY = ["notifications", "unread"] as const;
|
||||||
|
|
||||||
|
export function useNotificationsList(enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [...NOTIFICATIONS_KEY, "list"],
|
||||||
|
queryFn: () => notificationsApi.list({ page: 1, limit: 20 }),
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUnreadCount(enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: UNREAD_KEY,
|
||||||
|
queryFn: () => notificationsApi.unreadCount(),
|
||||||
|
enabled,
|
||||||
|
// WebSocket keeps this fresh; poll as a fallback if the socket drops.
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMarkRead() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => notificationsApi.markRead(id),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: NOTIFICATIONS_KEY }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMarkAllRead() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: () => notificationsApi.markAllRead(),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: NOTIFICATIONS_KEY }),
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user