From 209ee7da2d6fcde76e7bf39ca5c839f92d30bc48 Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Tue, 20 Oct 2026 16:53:15 +0530 Subject: [PATCH] fe integration for notification system with context, components, and API integration --- distribution_fe/app/(dashboard)/layout.tsx | 37 ++-- .../_components/notifications-client.tsx | 133 +++++++++++++ .../app/(dashboard)/notifications/page.tsx | 5 + .../components/layout/notification-bell.tsx | 122 ++++++++++++ distribution_fe/components/ui/popover.tsx | 50 +++++ .../contexts/notification-context.tsx | 135 +++++++++++++ distribution_fe/lib/api/index.ts | 1 + distribution_fe/lib/api/notifications.ts | 24 +++ distribution_fe/package-lock.json | 182 +++++++++++++++++- distribution_fe/package.json | 1 + distribution_fe/types/notifications.ts | 23 +++ 11 files changed, 696 insertions(+), 17 deletions(-) create mode 100644 distribution_fe/app/(dashboard)/notifications/_components/notifications-client.tsx create mode 100644 distribution_fe/app/(dashboard)/notifications/page.tsx create mode 100644 distribution_fe/components/layout/notification-bell.tsx create mode 100644 distribution_fe/components/ui/popover.tsx create mode 100644 distribution_fe/contexts/notification-context.tsx create mode 100644 distribution_fe/lib/api/notifications.ts create mode 100644 distribution_fe/types/notifications.ts diff --git a/distribution_fe/app/(dashboard)/layout.tsx b/distribution_fe/app/(dashboard)/layout.tsx index 8ae0431..5c8cc8e 100644 --- a/distribution_fe/app/(dashboard)/layout.tsx +++ b/distribution_fe/app/(dashboard)/layout.tsx @@ -2,6 +2,8 @@ import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/s import { AppSidebar } from '@/components/layout/app-sidebar' import { BottomNav } from '@/components/layout/bottom-nav' import { ThemeToggle } from '@/components/layout/theme-toggle' +import { NotificationBell } from '@/components/layout/notification-bell' +import { NotificationProvider } from '@/contexts/notification-context' import { TopLoader } from '@/components/shared/top-loader' export default function DashboardLayout({ @@ -10,21 +12,24 @@ export default function DashboardLayout({ children: React.ReactNode }) { return ( - - - - -
- -
- -
-
-
- {children} -
-
- -
+ + + + + +
+ +
+ + +
+
+
+ {children} +
+
+ +
+
) } diff --git a/distribution_fe/app/(dashboard)/notifications/_components/notifications-client.tsx b/distribution_fe/app/(dashboard)/notifications/_components/notifications-client.tsx new file mode 100644 index 0000000..fda89c6 --- /dev/null +++ b/distribution_fe/app/(dashboard)/notifications/_components/notifications-client.tsx @@ -0,0 +1,133 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import { Trash2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { EmptyState } from '@/components/shared/empty-state' +import { NotificationRow } from '@/components/layout/notification-bell' +import { useNotifications } from '@/contexts/notification-context' +import { getApiError } from '@/lib/api' +import { notifications } from '@/lib/api/notifications' +import { toast } from '@/components/ui/toast' +import type { NotificationResponse } from '@/types/notifications' + +const PAGE_SIZE = 20 + +type Filter = 'all' | 'unread' + +export function NotificationsClient() { + const { recent, unreadCount, open, markAllRead, refresh } = useNotifications() + + const [filter, setFilter] = useState('all') + const [items, setItems] = useState([]) + const [page, setPage] = useState(1) + const [total, setTotal] = useState(0) + const [loading, setLoading] = useState(true) + + // The caller decides whether to show the loading state (it is true on first render). + const load = useCallback(async (targetPage: number, reset: boolean) => { + try { + const result = await notifications.list({ + unreadOnly: filter === 'unread', + page: targetPage, + pageSize: PAGE_SIZE, + }) + setItems(prev => (reset ? result.items : [...prev, ...result.items])) + setPage(targetPage) + setTotal(result.total) + } catch (error) { + toast.add({ title: 'Could not load notifications', description: getApiError(error), type: 'error' }) + } finally { + setLoading(false) + } + }, [filter]) + + // Reload when the filter changes or a new notification is pushed in. + const latestId = recent[0]?.id + useEffect(() => { void load(1, true) }, [load, latestId]) + + async function handleOpen(n: NotificationResponse) { + setItems(prev => prev.map(x => (x.id === n.id ? { ...x, isRead: true } : x))) + await open(n) + } + + async function handleMarkAllRead() { + await markAllRead() + await load(1, true) + } + + async function handleDelete(n: NotificationResponse) { + try { + await notifications.remove(n.id) + setItems(prev => prev.filter(x => x.id !== n.id)) + setTotal(t => Math.max(0, t - 1)) + void refresh() + } catch (error) { + toast.add({ title: 'Could not delete notification', description: getApiError(error), type: 'error' }) + } + } + + const hasMore = items.length < total + + return ( +
+
+
+

Notifications

+

+ {unreadCount > 0 ? `${unreadCount} unread` : 'You\'re all caught up'} +

+
+
+
+ {(['all', 'unread'] as const).map(f => ( + + ))} +
+ +
+
+ +
+ {items.length === 0 && !loading ? ( + + ) : ( +
+ {items.map(n => ( +
+ { void handleOpen(item) }} /> + +
+ ))} +
+ )} +
+ + {hasMore && ( + + )} +
+ ) +} diff --git a/distribution_fe/app/(dashboard)/notifications/page.tsx b/distribution_fe/app/(dashboard)/notifications/page.tsx new file mode 100644 index 0000000..141fc91 --- /dev/null +++ b/distribution_fe/app/(dashboard)/notifications/page.tsx @@ -0,0 +1,5 @@ +import { NotificationsClient } from './_components/notifications-client' + +export default function NotificationsPage() { + return +} diff --git a/distribution_fe/components/layout/notification-bell.tsx b/distribution_fe/components/layout/notification-bell.tsx new file mode 100644 index 0000000..f7af6f9 --- /dev/null +++ b/distribution_fe/components/layout/notification-bell.tsx @@ -0,0 +1,122 @@ +'use client' + +import Link from 'next/link' +import { useState } from 'react' +import { Bell } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { useNotifications } from '@/contexts/notification-context' +import { cn } from '@/lib/utils' +import type { NotificationResponse, NotificationSeverity } from '@/types/notifications' + +const SEVERITY_DOT: Record = { + Info: 'bg-sky-500', + Success: 'bg-emerald-500', + Warning: 'bg-amber-500', + Critical: 'bg-red-500', +} + +export function timeAgo(iso: string): string { + const seconds = Math.max(0, Math.floor((Date.now() - new Date(iso).getTime()) / 1000)) + if (seconds < 60) return 'just now' + const minutes = Math.floor(seconds / 60) + if (minutes < 60) return `${minutes}m ago` + const hours = Math.floor(minutes / 60) + if (hours < 24) return `${hours}h ago` + const days = Math.floor(hours / 24) + if (days < 7) return `${days}d ago` + return new Date(iso).toLocaleDateString() +} + +export function NotificationRow({ + notification: n, + onOpen, +}: { + notification: NotificationResponse + onOpen: (n: NotificationResponse) => void +}) { + return ( + + ) +} + +export function NotificationBell() { + const { recent, unreadCount, open, markAllRead } = useNotifications() + const [isOpen, setIsOpen] = useState(false) + + return ( + + 0 ? `Notifications (${unreadCount} unread)` : 'Notifications'} + /> + } + > + + {unreadCount > 0 && ( + + {unreadCount > 99 ? '99+' : unreadCount} + + )} + + + +
+ Notifications + +
+ +
+ {recent.length === 0 ? ( +

You're all caught up.

+ ) : ( + recent.map(n => ( + { setIsOpen(false); void open(item) }} + /> + )) + )} +
+ +
+ setIsOpen(false)} + className="block rounded-xl px-3 py-2 text-center text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" + > + View all + +
+
+
+ ) +} diff --git a/distribution_fe/components/ui/popover.tsx b/distribution_fe/components/ui/popover.tsx new file mode 100644 index 0000000..6e6ca70 --- /dev/null +++ b/distribution_fe/components/ui/popover.tsx @@ -0,0 +1,50 @@ +"use client" + +import * as React from "react" +import { Popover as PopoverPrimitive } from "@base-ui/react/popover" + +import { cn } from "@/lib/utils" + +function Popover({ ...props }: PopoverPrimitive.Root.Props) { + return +} + +function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) { + return +} + +function PopoverContent({ + align = "end", + alignOffset = 0, + side = "bottom", + sideOffset = 6, + className, + ...props +}: PopoverPrimitive.Popup.Props & + Pick< + PopoverPrimitive.Positioner.Props, + "align" | "alignOffset" | "side" | "sideOffset" + >) { + return ( + + + + + + ) +} + +export { Popover, PopoverTrigger, PopoverContent } diff --git a/distribution_fe/contexts/notification-context.tsx b/distribution_fe/contexts/notification-context.tsx new file mode 100644 index 0000000..683f2ca --- /dev/null +++ b/distribution_fe/contexts/notification-context.tsx @@ -0,0 +1,135 @@ +'use client' + +import { createContext, useContext, useState, useEffect, useCallback, useRef, type ReactNode } from 'react' +import { useRouter } from 'next/navigation' +import { HubConnectionBuilder, LogLevel } from '@microsoft/signalr' +import { useAuth } from '@/contexts/auth-context' +import { notifications, NOTIFICATION_HUB_URL } from '@/lib/api/notifications' +import { toast } from '@/components/ui/toast' +import type { NotificationResponse, NotificationSeverity } from '@/types/notifications' + +const RECENT_COUNT = 20 + +const TOAST_TYPE: Record = { + Info: 'info', + Success: 'success', + Warning: 'warning', + Critical: 'error', +} + +interface NotificationContextValue { + /** The most recent notifications, newest first (what the bell popover shows). */ + recent: NotificationResponse[] + unreadCount: number + /** Marks the notification read (if needed) and navigates to its link. */ + open: (n: NotificationResponse) => Promise + markAllRead: () => Promise + /** Re-fetches the recent list and the unread count. */ + refresh: () => Promise +} + +const NotificationContext = createContext(null) + +export function NotificationProvider({ children }: { children: ReactNode }) { + const router = useRouter() + const { user } = useAuth() + const [recent, setRecent] = useState([]) + const [unreadCount, setUnreadCount] = useState(0) + + const refresh = useCallback(async () => { + try { + const [page, count] = await Promise.all([ + notifications.list({ page: 1, pageSize: RECENT_COUNT }), + notifications.unreadCount(), + ]) + setRecent(page.items) + setUnreadCount(count) + } catch { + // The bell is a convenience; never break the page if it can't load. + } + }, []) + + const markRead = useCallback(async (n: NotificationResponse) => { + if (n.isRead) return + setRecent(prev => prev.map(x => (x.id === n.id ? { ...x, isRead: true } : x))) + setUnreadCount(c => Math.max(0, c - 1)) + try { + await notifications.markRead(n.id) + } catch { + await refresh() + } + }, [refresh]) + + const open = useCallback(async (n: NotificationResponse) => { + await markRead(n) + if (n.link) router.push(n.link) + }, [markRead, router]) + + const markAllRead = useCallback(async () => { + setRecent(prev => prev.map(x => ({ ...x, isRead: true }))) + setUnreadCount(0) + try { + await notifications.markAllRead() + } catch { + await refresh() + } + }, [refresh]) + + // Keep the latest `open` for the long-lived socket handler without reconnecting. + const openRef = useRef(open) + useEffect(() => { openRef.current = open }, [open]) + + useEffect(() => { + if (!user) return + + void refresh() + + const connection = new HubConnectionBuilder() + .withUrl(NOTIFICATION_HUB_URL, { withCredentials: true }) + .withAutomaticReconnect() + .configureLogging(LogLevel.Warning) + .build() + + connection.on('notification', (n: NotificationResponse) => { + setRecent(prev => [n, ...prev.filter(x => x.id !== n.id)].slice(0, RECENT_COUNT)) + setUnreadCount(c => c + 1) + toast.add({ + title: n.title, + description: n.message, + type: TOAST_TYPE[n.severity] ?? 'info', + actionProps: n.link + ? { children: 'Open', onClick: () => { void openRef.current(n) } } + : undefined, + }) + }) + + // Anything pushed while the socket was down is only in the inbox — catch up. + connection.onreconnected(() => { void refresh() }) + + connection.start().catch(() => { + // Initial connect failed (offline / API down); the inbox endpoints still work. + }) + + return () => { void connection.stop() } + }, [user, refresh]) + + return ( + + {children} + + ) +} + +export function useNotifications(): NotificationContextValue { + const ctx = useContext(NotificationContext) + if (!ctx) throw new Error('useNotifications must be used inside NotificationProvider') + return ctx +} diff --git a/distribution_fe/lib/api/index.ts b/distribution_fe/lib/api/index.ts index 2b22eeb..feccf0c 100644 --- a/distribution_fe/lib/api/index.ts +++ b/distribution_fe/lib/api/index.ts @@ -16,6 +16,7 @@ export { bankAccounts } from './bank-accounts' export { chequeBooks, chequePages } from './cheques' export { receivedCheques } from './received-cheques' export { supplierPayments } from './supplier-payments' +export { notifications } from './notifications' export { getPaginatedItems } from './paginated' export type { PageParams } from './master' diff --git a/distribution_fe/lib/api/notifications.ts b/distribution_fe/lib/api/notifications.ts new file mode 100644 index 0000000..18d2f2a --- /dev/null +++ b/distribution_fe/lib/api/notifications.ts @@ -0,0 +1,24 @@ +import { api } from '@/lib/api' +import type { NotificationPage } from '@/types/notifications' + +export const notifications = { + list: (params?: { unreadOnly?: boolean; page?: number; pageSize?: number }) => + api.get('/notifications', { params }).then(r => r.data), + + unreadCount: () => + api.get<{ count: number }>('/notifications/unread-count').then(r => r.data.count), + + markRead: (id: string) => + api.patch(`/notifications/${id}/read`).then(() => undefined), + + markAllRead: () => + api.patch('/notifications/read-all').then(() => undefined), + + remove: (id: string) => + api.delete(`/notifications/${id}`).then(() => undefined), +} + +/** SignalR hub URL — the API base URL without its trailing /api. */ +export const NOTIFICATION_HUB_URL = + (process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:5089/api').replace(/\/api\/?$/, '') + + '/hubs/notifications' diff --git a/distribution_fe/package-lock.json b/distribution_fe/package-lock.json index 70cb4c4..3ff3ddf 100644 --- a/distribution_fe/package-lock.json +++ b/distribution_fe/package-lock.json @@ -12,6 +12,7 @@ "@hookform/resolvers": "^5.9.1", "@hugeicons/core-free-icons": "^4.3.0", "@hugeicons/react": "^1.1.10", + "@microsoft/signalr": "^10.0.11", "@types/leaflet": "^1.9.22", "axios": "^1.19.0", "class-variance-authority": "^0.7.1", @@ -1763,6 +1764,28 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@microsoft/signalr": { + "version": "10.0.11", + "resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-10.0.11.tgz", + "integrity": "sha512-FulOJ2EEtKvLQcswe/U7v8pzyXyk7Jua5xgWJPPwyQU/2Z9ORvKcVjyO75VvLsem+CJ1ORRexJ+7Bz1FCei2aw==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "eventsource": "^2.0.2", + "fetch-cookie": "^2.0.3", + "node-fetch": "^2.6.7", + "ws": "^7.5.10" + } + }, + "node_modules/@microsoft/signalr/node_modules/eventsource": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", + "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", @@ -3251,6 +3274,18 @@ "win32" ] }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -5683,6 +5718,15 @@ "node": ">= 0.6" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/eventemitter3": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", @@ -5923,6 +5967,16 @@ "reusify": "^1.0.4" } }, + "node_modules/fetch-cookie": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-2.2.0.tgz", + "integrity": "sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ==", + "license": "Unlicense", + "dependencies": { + "set-cookie-parser": "^2.4.8", + "tough-cookie": "^4.0.0" + } + }, "node_modules/fflate": { "version": "0.8.3", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", @@ -8426,6 +8480,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-releases": { "version": "2.0.53", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", @@ -9272,11 +9346,22 @@ "node": ">=10" } }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -9298,6 +9383,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -9651,6 +9742,12 @@ "node": ">=0.10.0" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, "node_modules/reselect": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.3.0.tgz", @@ -9971,6 +10068,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -10860,6 +10963,36 @@ "node": ">=0.6" } }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie/node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/traverse": { "version": "0.3.10", "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.10.tgz", @@ -11316,6 +11449,16 @@ "punycode": "^2.1.0" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/use-sync-external-store": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", @@ -11391,6 +11534,22 @@ "d3-timer": "^3.0.1" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -11507,6 +11666,27 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz", + "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/wsl-utils": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-1.0.0.tgz", diff --git a/distribution_fe/package.json b/distribution_fe/package.json index d40c022..a6d98c5 100644 --- a/distribution_fe/package.json +++ b/distribution_fe/package.json @@ -16,6 +16,7 @@ "@hookform/resolvers": "^5.9.1", "@hugeicons/core-free-icons": "^4.3.0", "@hugeicons/react": "^1.1.10", + "@microsoft/signalr": "^10.0.11", "@types/leaflet": "^1.9.22", "axios": "^1.19.0", "class-variance-authority": "^0.7.1", diff --git a/distribution_fe/types/notifications.ts b/distribution_fe/types/notifications.ts new file mode 100644 index 0000000..1bce432 --- /dev/null +++ b/distribution_fe/types/notifications.ts @@ -0,0 +1,23 @@ +export type NotificationSeverity = 'Info' | 'Success' | 'Warning' | 'Critical' + +export interface NotificationResponse { + /** The caller's own recipient row id — use it for read / delete. */ + id: string + type: string + severity: NotificationSeverity + title: string + message: string + entityType: string + entityId: string + link: string + createdAt: string + isRead: boolean + readAt: string | null +} + +export interface NotificationPage { + page: number + pageSize: number + total: number + items: NotificationResponse[] +}