fe integration for notification system with context, components, and API integration
This commit is contained in:
@@ -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,13 +12,15 @@ export default function DashboardLayout({
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<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">
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<NotificationBell />
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
@@ -26,5 +30,6 @@ export default function DashboardLayout({
|
||||
</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'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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
Generated
+181
-1
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
Reference in New Issue
Block a user