fe integration for notification system with context, components, and API integration

This commit is contained in:
2026-10-20 16:53:15 +05:30
parent 6b9f27573b
commit 209ee7da2d
11 changed files with 696 additions and 17 deletions
+21 -16
View File
@@ -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 (
<SidebarProvider>
<TopLoader />
<AppSidebar />
<SidebarInset>
<header className="flex h-14 shrink-0 items-center gap-2 border-b border-border px-4">
<SidebarTrigger className="-ml-1 hidden md:flex" />
<div className="ml-auto">
<ThemeToggle />
</div>
</header>
<main className="flex flex-1 flex-col gap-4 p-4 pb-20 md:pb-4">
{children}
</main>
</SidebarInset>
<BottomNav />
</SidebarProvider>
<NotificationProvider>
<SidebarProvider>
<TopLoader />
<AppSidebar />
<SidebarInset>
<header className="flex h-14 shrink-0 items-center gap-2 border-b border-border px-4">
<SidebarTrigger className="-ml-1 hidden md:flex" />
<div className="ml-auto flex items-center gap-1">
<NotificationBell />
<ThemeToggle />
</div>
</header>
<main className="flex flex-1 flex-col gap-4 p-4 pb-20 md:pb-4">
{children}
</main>
</SidebarInset>
<BottomNav />
</SidebarProvider>
</NotificationProvider>
)
}
@@ -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<Filter>('all')
const [items, setItems] = useState<NotificationResponse[]>([])
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 (
<div className="mx-auto flex w-full max-w-3xl flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<h1 className="text-lg font-semibold">Notifications</h1>
<p className="text-sm text-muted-foreground">
{unreadCount > 0 ? `${unreadCount} unread` : 'You\'re all caught up'}
</p>
</div>
<div className="flex items-center gap-2">
<div className="flex rounded-2xl border border-border p-0.5">
{(['all', 'unread'] as const).map(f => (
<Button
key={f}
variant={filter === f ? 'secondary' : 'ghost'}
size="sm"
onClick={() => { setLoading(true); setFilter(f) }}
>
{f === 'all' ? 'All' : 'Unread'}
</Button>
))}
</div>
<Button variant="outline" size="sm" disabled={unreadCount === 0} onClick={() => { void handleMarkAllRead() }}>
Mark all read
</Button>
</div>
</div>
<div className="overflow-hidden rounded-2xl border border-border">
{items.length === 0 && !loading ? (
<EmptyState
title={filter === 'unread' ? 'No unread notifications' : 'No notifications yet'}
description="Alerts about deliveries, stock, claims and orders will show up here."
/>
) : (
<div className="divide-y divide-border">
{items.map(n => (
<div key={n.id} className="group relative">
<NotificationRow notification={n} onOpen={item => { void handleOpen(item) }} />
<Button
variant="ghost"
size="icon-sm"
aria-label="Delete notification"
className="absolute top-2 right-2 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
onClick={() => { void handleDelete(n) }}
>
<Trash2 size={14} />
</Button>
</div>
))}
</div>
)}
</div>
{hasMore && (
<Button variant="outline" disabled={loading} onClick={() => { setLoading(true); void load(page + 1, false) }} className="self-center">
{loading ? 'Loading…' : 'Load more'}
</Button>
)}
</div>
)
}
@@ -0,0 +1,5 @@
import { NotificationsClient } from './_components/notifications-client'
export default function NotificationsPage() {
return <NotificationsClient />
}
@@ -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<NotificationSeverity, string> = {
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 (
<button
type="button"
onClick={() => onOpen(n)}
className={cn(
'flex w-full items-start gap-3 px-4 py-3 text-left transition-colors hover:bg-muted/60',
!n.isRead && 'bg-muted/30'
)}
>
<span className={cn('mt-1.5 size-2 shrink-0 rounded-full', n.isRead ? 'bg-transparent' : SEVERITY_DOT[n.severity])} />
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className={cn('truncate text-sm', n.isRead ? 'text-foreground' : 'font-medium text-foreground')}>
{n.title}
</span>
<span className="line-clamp-2 text-xs text-muted-foreground">{n.message}</span>
<span className="text-[11px] text-muted-foreground/70">{timeAgo(n.createdAt)}</span>
</span>
</button>
)
}
export function NotificationBell() {
const { recent, unreadCount, open, markAllRead } = useNotifications()
const [isOpen, setIsOpen] = useState(false)
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger
render={
<Button
variant="ghost"
size="icon"
className="relative h-8 w-8 text-muted-foreground hover:text-foreground"
aria-label={unreadCount > 0 ? `Notifications (${unreadCount} unread)` : 'Notifications'}
/>
}
>
<Bell size={16} />
{unreadCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-semibold leading-none text-white">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</PopoverTrigger>
<PopoverContent>
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<span className="text-sm font-medium">Notifications</span>
<Button
variant="ghost"
size="xs"
disabled={unreadCount === 0}
onClick={() => { void markAllRead() }}
>
Mark all read
</Button>
</div>
<div className="max-h-96 divide-y divide-border overflow-y-auto">
{recent.length === 0 ? (
<p className="px-4 py-10 text-center text-sm text-muted-foreground">You&apos;re all caught up.</p>
) : (
recent.map(n => (
<NotificationRow
key={n.id}
notification={n}
onOpen={item => { setIsOpen(false); void open(item) }}
/>
))
)}
</div>
<div className="border-t border-border p-2">
<Link
href="/notifications"
onClick={() => 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
</Link>
</div>
</PopoverContent>
</Popover>
)
}
+50
View File
@@ -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 <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
align = "end",
alignOffset = 0,
side = "bottom",
sideOffset = 6,
className,
...props
}: PopoverPrimitive.Popup.Props &
Pick<
PopoverPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<PopoverPrimitive.Popup
data-slot="popover-content"
className={cn(
"z-50 w-[min(24rem,calc(100vw-2rem))] origin-(--transform-origin) rounded-2xl bg-popover text-popover-foreground shadow-lg ring-1 ring-foreground/5 duration-100 outline-none dark:ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</PopoverPrimitive.Positioner>
</PopoverPrimitive.Portal>
)
}
export { Popover, PopoverTrigger, PopoverContent }
@@ -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<NotificationSeverity, 'info' | 'success' | 'warning' | 'error'> = {
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<void>
markAllRead: () => Promise<void>
/** Re-fetches the recent list and the unread count. */
refresh: () => Promise<void>
}
const NotificationContext = createContext<NotificationContextValue | null>(null)
export function NotificationProvider({ children }: { children: ReactNode }) {
const router = useRouter()
const { user } = useAuth()
const [recent, setRecent] = useState<NotificationResponse[]>([])
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 (
<NotificationContext.Provider
value={{
recent: user ? recent : [],
unreadCount: user ? unreadCount : 0,
open,
markAllRead,
refresh,
}}
>
{children}
</NotificationContext.Provider>
)
}
export function useNotifications(): NotificationContextValue {
const ctx = useContext(NotificationContext)
if (!ctx) throw new Error('useNotifications must be used inside NotificationProvider')
return ctx
}
+1
View File
@@ -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'
+24
View File
@@ -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<NotificationPage>('/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'
+181 -1
View File
@@ -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",
+1
View File
@@ -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",
+23
View File
@@ -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[]
}