feat: update the ui of the notification

This commit is contained in:
Nathnael
2026-07-06 10:29:59 +00:00
parent 68c60f9e61
commit abe26c2c60
13 changed files with 820 additions and 218 deletions

View File

@@ -1,17 +1,46 @@
import { NotificationBell, type NotificationBellItem } from "@edr/ui-common";
import type { NotificationListResult } from "@edr/types";
import {
NotificationBell,
NotificationDrawer,
type NotificationItemData,
} from "@edr/ui-common";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import {
resolveNotificationHref,
resolveNotificationVisual,
} from "./notificationConfig";
import {
useInfiniteNotifications,
useMarkAllRead,
useMarkRead,
useNotificationsList,
useUnreadCount,
} from "./useNotifications";
import { useNotificationSocket } from "./useNotificationSocket";
/** Flatten an infinite query's pages into the drawer's item shape. */
function toItems(
data: { pages: NotificationListResult[] } | undefined,
): NotificationItemData[] {
return (data?.pages ?? [])
.flatMap((p) => p.items)
.map((n) => ({
id: n.id,
type: n.type,
title: n.title,
body: n.body,
createdAt: n.createdAt,
isRead: n.isRead,
link: n.link,
data: n.data,
}));
}
/**
* Wires react-query + the notification WebSocket into the shared presentational
* bell. Mounted inside the authenticated dashboard header.
* Wires react-query (infinite unread/read lists) + the notification WebSocket
* into the shared bell + drawer. Lists are only fetched while the drawer is
* open; the badge is driven by the lightweight unread-count query + socket.
*/
export default function NotificationBellContainer({
enabled = true,
@@ -19,35 +48,57 @@ export default function NotificationBellContainer({
enabled?: boolean;
}) {
const navigate = useNavigate();
const list = useNotificationsList(enabled);
const [opened, setOpened] = useState(false);
const unreadQ = useInfiniteNotifications(false, enabled && opened);
const readQ = useInfiniteNotifications(true, enabled && opened);
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,
}));
const unreadItems = toItems(unreadQ.data);
const readItems = toItems(readQ.data);
const unreadCount = unread.data ?? 0;
const handleItemClick = (item: NotificationItemData) => {
if (!item.isRead) markRead.mutate(item.id);
const href = resolveNotificationHref(item);
setOpened(false);
if (href) navigate(href);
};
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()}
/>
<>
<NotificationBell
unreadCount={unreadCount}
onClick={() => setOpened(true)}
/>
<NotificationDrawer
opened={opened}
onClose={() => setOpened(false)}
unread={unreadItems}
read={readItems}
unreadCount={unreadCount}
loading={opened && (unreadQ.isLoading || readQ.isLoading)}
hasMoreUnread={unreadQ.hasNextPage}
hasMoreRead={readQ.hasNextPage}
loadingMoreUnread={unreadQ.isFetchingNextPage}
loadingMoreRead={readQ.isFetchingNextPage}
onLoadMoreUnread={() => {
if (unreadQ.hasNextPage && !unreadQ.isFetchingNextPage) {
void unreadQ.fetchNextPage();
}
}}
onLoadMoreRead={() => {
if (readQ.hasNextPage && !readQ.isFetchingNextPage) {
void readQ.fetchNextPage();
}
}}
onItemClick={handleItemClick}
onMarkAllRead={() => markAllRead.mutate()}
resolveVisual={resolveNotificationVisual}
/>
</>
);
}

View File

@@ -0,0 +1,53 @@
import { NotificationType } from "@edr/types";
import type { NotificationItemData, NotificationVisual } from "@edr/ui-common";
import { Bell, ClipboardCheck, Inbox, Wallet } from "lucide-react";
const ICON_SIZE = 17;
/**
* Backoffice notification registry. Maps a notification `type` → icon + Mantine
* color, and `type`/`data` → an in-app deep link. This is the single place to
* customize how each staff-facing notification looks and where it goes.
*/
export function resolveNotificationVisual(
item: NotificationItemData,
): NotificationVisual {
switch (item.type) {
case NotificationType.REQUEST_SUBMITTED:
return { icon: <Inbox size={ICON_SIZE} />, color: "blue" };
case NotificationType.PAYMENT_RECEIVED:
return { icon: <Wallet size={ICON_SIZE} />, color: "teal" };
case NotificationType.CLEARANCE_REVIEW:
return { icon: <ClipboardCheck size={ICON_SIZE} />, color: "orange" };
default:
return { icon: <Bell size={ICON_SIZE} />, color: "gray" };
}
}
function asId(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
/**
* Resolve where clicking a notification navigates. Prefers an explicit
* server-provided `link`, else derives a `/dashboard/*` route from `type` +
* `data`. Returns `null` when there's nowhere sensible to go.
*/
export function resolveNotificationHref(
item: NotificationItemData,
): string | null {
if (item.link) return item.link;
const data = item.data ?? {};
switch (item.type) {
case NotificationType.REQUEST_SUBMITTED:
return "/dashboard/booking-requests";
case NotificationType.PAYMENT_RECEIVED: {
const id = asId(data.customerId);
return id ? `/dashboard/customers/${id}` : "/dashboard/customers";
}
case NotificationType.CLEARANCE_REVIEW:
return "/dashboard/arrival-queue";
default:
return null;
}
}

View File

@@ -1,14 +1,32 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
useInfiniteQuery,
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 }),
const PAGE_SIZE = 20;
/**
* Paginated (infinite) notifications for one read-state. Drives a drawer
* section; call `fetchNextPage` as the user scrolls. Each page carries the
* server `count` so we know when to stop.
*/
export function useInfiniteNotifications(isRead: boolean, enabled = true) {
return useInfiniteQuery({
queryKey: [...NOTIFICATIONS_KEY, "list", { isRead }],
queryFn: ({ pageParam }) =>
notificationsApi.list({ page: pageParam, limit: PAGE_SIZE, isRead }),
initialPageParam: 1,
getNextPageParam: (lastPage, allPages) => {
const loaded = allPages.reduce((sum, p) => sum + p.items.length, 0);
return loaded < lastPage.count ? allPages.length + 1 : undefined;
},
enabled,
});
}
@@ -27,7 +45,10 @@ export function useMarkRead() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => notificationsApi.markRead(id),
onSuccess: () => qc.invalidateQueries({ queryKey: NOTIFICATIONS_KEY }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: NOTIFICATIONS_KEY });
qc.invalidateQueries({ queryKey: UNREAD_KEY });
},
});
}
@@ -35,6 +56,9 @@ export function useMarkAllRead() {
const qc = useQueryClient();
return useMutation({
mutationFn: () => notificationsApi.markAllRead(),
onSuccess: () => qc.invalidateQueries({ queryKey: NOTIFICATIONS_KEY }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: NOTIFICATIONS_KEY });
qc.invalidateQueries({ queryKey: UNREAD_KEY });
},
});
}

View File

@@ -1,17 +1,46 @@
import { NotificationBell, type NotificationBellItem } from "@edr/ui-common";
import type { NotificationListResult } from "@edr/types";
import {
NotificationBell,
NotificationDrawer,
type NotificationItemData,
} from "@edr/ui-common";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import {
resolveNotificationHref,
resolveNotificationVisual,
} from "./notificationConfig";
import {
useInfiniteNotifications,
useMarkAllRead,
useMarkRead,
useNotificationsList,
useUnreadCount,
} from "./useNotifications";
import { useNotificationSocket } from "./useNotificationSocket";
/** Flatten an infinite query's pages into the drawer's item shape. */
function toItems(
data: { pages: NotificationListResult[] } | undefined,
): NotificationItemData[] {
return (data?.pages ?? [])
.flatMap((p) => p.items)
.map((n) => ({
id: n.id,
type: n.type,
title: n.title,
body: n.body,
createdAt: n.createdAt,
isRead: n.isRead,
link: n.link,
data: n.data,
}));
}
/**
* Wires react-query + the notification WebSocket into the shared presentational
* bell. Mount inside an authenticated layout.
* Wires react-query (infinite unread/read lists) + the notification WebSocket
* into the shared bell + drawer. Lists are only fetched while the drawer is
* open; the badge is driven by the lightweight unread-count query + socket.
*/
export default function NotificationBellContainer({
enabled = true,
@@ -19,35 +48,57 @@ export default function NotificationBellContainer({
enabled?: boolean;
}) {
const navigate = useNavigate();
const list = useNotificationsList(enabled);
const [opened, setOpened] = useState(false);
const unreadQ = useInfiniteNotifications(false, enabled && opened);
const readQ = useInfiniteNotifications(true, enabled && opened);
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,
}));
const unreadItems = toItems(unreadQ.data);
const readItems = toItems(readQ.data);
const unreadCount = unread.data ?? 0;
const handleItemClick = (item: NotificationItemData) => {
if (!item.isRead) markRead.mutate(item.id);
const href = resolveNotificationHref(item);
setOpened(false);
if (href) navigate(href);
};
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()}
/>
<>
<NotificationBell
unreadCount={unreadCount}
onClick={() => setOpened(true)}
/>
<NotificationDrawer
opened={opened}
onClose={() => setOpened(false)}
unread={unreadItems}
read={readItems}
unreadCount={unreadCount}
loading={opened && (unreadQ.isLoading || readQ.isLoading)}
hasMoreUnread={unreadQ.hasNextPage}
hasMoreRead={readQ.hasNextPage}
loadingMoreUnread={unreadQ.isFetchingNextPage}
loadingMoreRead={readQ.isFetchingNextPage}
onLoadMoreUnread={() => {
if (unreadQ.hasNextPage && !unreadQ.isFetchingNextPage) {
void unreadQ.fetchNextPage();
}
}}
onLoadMoreRead={() => {
if (readQ.hasNextPage && !readQ.isFetchingNextPage) {
void readQ.fetchNextPage();
}
}}
onItemClick={handleItemClick}
onMarkAllRead={() => markAllRead.mutate()}
resolveVisual={resolveNotificationVisual}
/>
</>
);
}

View File

@@ -0,0 +1,66 @@
import { NotificationType } from "@edr/types";
import type { NotificationItemData, NotificationVisual } from "@edr/ui-common";
import {
BadgeCheck,
Bell,
FileWarning,
Package,
Receipt,
} from "lucide-react";
const ICON_SIZE = 17;
/**
* Portal notification registry. Maps a notification `type` → icon + Mantine
* color, and `type`/`data` → an in-app deep link. This is the single place to
* customize how each notification looks and where clicking it goes.
*/
export function resolveNotificationVisual(
item: NotificationItemData,
): NotificationVisual {
switch (item.type) {
case NotificationType.CLEARANCE_DECISION:
return { icon: <BadgeCheck size={ICON_SIZE} />, color: "teal" };
case NotificationType.DOCUMENT_ACTION:
return { icon: <FileWarning size={ICON_SIZE} />, color: "orange" };
case NotificationType.BOOKING_STATUS:
return { icon: <Package size={ICON_SIZE} />, color: "blue" };
case NotificationType.INVOICE_ISSUED:
return { icon: <Receipt size={ICON_SIZE} />, color: "violet" };
default:
return { icon: <Bell size={ICON_SIZE} />, color: "gray" };
}
}
function asId(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
/**
* Resolve where clicking a notification navigates. Prefers an explicit
* server-provided `link`, else derives a route from `type` + `data`.
* Returns `null` when there's nowhere sensible to go (item just marks read).
*/
export function resolveNotificationHref(
item: NotificationItemData,
): string | null {
if (item.link) return item.link;
const data = item.data ?? {};
switch (item.type) {
case NotificationType.INVOICE_ISSUED: {
const id = asId(data.invoiceId);
return id ? `/billing/${id}` : "/billing";
}
case NotificationType.BOOKING_STATUS: {
const id = asId(data.bookingId);
return id ? `/bookings/${id}` : null;
}
case NotificationType.CLEARANCE_DECISION:
case NotificationType.DOCUMENT_ACTION: {
const id = asId(data.contractId);
return id ? `/contracts/${id}` : "/contracts";
}
default:
return null;
}
}

View File

@@ -1,14 +1,32 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
useInfiniteQuery,
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 }),
const PAGE_SIZE = 20;
/**
* Paginated (infinite) notifications for one read-state. Drives a drawer
* section; call `fetchNextPage` as the user scrolls. Each page carries the
* server `count` so we know when to stop.
*/
export function useInfiniteNotifications(isRead: boolean, enabled = true) {
return useInfiniteQuery({
queryKey: [...NOTIFICATIONS_KEY, "list", { isRead }],
queryFn: ({ pageParam }) =>
notificationsApi.list({ page: pageParam, limit: PAGE_SIZE, isRead }),
initialPageParam: 1,
getNextPageParam: (lastPage, allPages) => {
const loaded = allPages.reduce((sum, p) => sum + p.items.length, 0);
return loaded < lastPage.count ? allPages.length + 1 : undefined;
},
enabled,
});
}
@@ -27,7 +45,10 @@ export function useMarkRead() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => notificationsApi.markRead(id),
onSuccess: () => qc.invalidateQueries({ queryKey: NOTIFICATIONS_KEY }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: NOTIFICATIONS_KEY });
qc.invalidateQueries({ queryKey: UNREAD_KEY });
},
});
}
@@ -35,6 +56,9 @@ export function useMarkAllRead() {
const qc = useQueryClient();
return useMutation({
mutationFn: () => notificationsApi.markAllRead(),
onSuccess: () => qc.invalidateQueries({ queryKey: NOTIFICATIONS_KEY }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: NOTIFICATIONS_KEY });
qc.invalidateQueries({ queryKey: UNREAD_KEY });
},
});
}