mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 14:08:11 +00:00
feat: update the ui of the notification
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user