diff --git a/apps/edr-freight-web/backoffice/package.json b/apps/edr-freight-web/backoffice/package.json
index 72c782e11..4efc5c2d4 100644
--- a/apps/edr-freight-web/backoffice/package.json
+++ b/apps/edr-freight-web/backoffice/package.json
@@ -33,6 +33,7 @@
"react-hot-toast": "^2.6.0",
"react-router-dom": "^6.27.0",
"recharts": "^3.8.1",
+ "socket.io-client": "^4.8.3",
"sonner": "^2.0.7",
"stream-browserify": "^3.0.0",
"tailwind-merge": "^3.6.0",
diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx
index 8a45c7cf0..8748f589e 100644
--- a/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx
@@ -5,14 +5,12 @@ import {
Burger,
Divider,
Group,
- Indicator,
Menu,
Text,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import {
- Bell,
ChevronDown,
FileSignature,
Languages,
@@ -25,6 +23,8 @@ import {
import { type ReactNode } from "react";
import { useNavigate } from "react-router-dom";
+import NotificationBellContainer from "@/features/notifications/NotificationBellContainer";
+
import type { PageMeta } from "./types";
export interface FreightDashboardHeaderProps {
@@ -117,19 +117,7 @@ const FreightDashboardHeader = ({
-
-
-
-
-
-
-
+
{enableThemeToggle && (
({
+ id: n.id,
+ title: n.title,
+ body: n.body,
+ createdAt: n.createdAt,
+ isRead: n.isRead,
+ link: n.link,
+ }));
+
+ return (
+ {
+ void list.refetch();
+ void unread.refetch();
+ }}
+ onItemClick={(item) => {
+ if (!item.isRead) markRead.mutate(item.id);
+ if (item.link) navigate(item.link);
+ }}
+ onMarkAllRead={() => markAllRead.mutate()}
+ />
+ );
+}
diff --git a/apps/edr-freight-web/backoffice/src/features/notifications/notificationsApi.ts b/apps/edr-freight-web/backoffice/src/features/notifications/notificationsApi.ts
new file mode 100644
index 000000000..1982aa238
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/features/notifications/notificationsApi.ts
@@ -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 => {
+ const { data } = await api.get("/notifications", {
+ params,
+ });
+ return data;
+ },
+ unreadCount: async (): Promise => {
+ const { data } = await api.get<{ unreadCount: number }>(
+ "/notifications/unread-count",
+ );
+ return data.unreadCount;
+ },
+ markRead: async (id: string): Promise => {
+ await api.patch(`/notifications/${id}/read`);
+ },
+ markAllRead: async (): Promise => {
+ await api.post("/notifications/read-all");
+ },
+};
diff --git a/apps/edr-freight-web/backoffice/src/features/notifications/useNotificationSocket.ts b/apps/edr-freight-web/backoffice/src/features/notifications/useNotificationSocket.ts
new file mode 100644
index 000000000..ade0ce97d
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/features/notifications/useNotificationSocket.ts
@@ -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]);
+}
diff --git a/apps/edr-freight-web/backoffice/src/features/notifications/useNotifications.ts b/apps/edr-freight-web/backoffice/src/features/notifications/useNotifications.ts
new file mode 100644
index 000000000..58f69df3e
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/features/notifications/useNotifications.ts
@@ -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 }),
+ });
+}