UPDATE
This commit is contained in:
@@ -4,16 +4,11 @@ import Link from 'next/link'
|
|||||||
// Third-party Imports
|
// Third-party Imports
|
||||||
import classnames from 'classnames'
|
import classnames from 'classnames'
|
||||||
|
|
||||||
// Type Imports
|
|
||||||
import type { ShortcutsType } from '@components/layout/shared/ShortcutsDropdown'
|
|
||||||
import type { NotificationsType } from '@components/layout/shared/NotificationsDropdown'
|
|
||||||
|
|
||||||
// Component Imports
|
// Component Imports
|
||||||
import NavToggle from './NavToggle'
|
import NavToggle from './NavToggle'
|
||||||
import Logo from '@components/layout/shared/Logo'
|
import Logo from '@components/layout/shared/Logo'
|
||||||
import NavSearch from '@components/layout/shared/search'
|
import NavSearch from '@components/layout/shared/search'
|
||||||
import ModeDropdown from '@components/layout/shared/ModeDropdown'
|
import ModeDropdown from '@components/layout/shared/ModeDropdown'
|
||||||
import ShortcutsDropdown from '@components/layout/shared/ShortcutsDropdown'
|
|
||||||
import NotificationsDropdown from '@components/layout/shared/NotificationsDropdown'
|
import NotificationsDropdown from '@components/layout/shared/NotificationsDropdown'
|
||||||
import UserDropdown from '@components/layout/shared/UserDropdown'
|
import UserDropdown from '@components/layout/shared/UserDropdown'
|
||||||
|
|
||||||
@@ -23,105 +18,13 @@ import useHorizontalNav from '@menu/hooks/useHorizontalNav'
|
|||||||
// Util Imports
|
// Util Imports
|
||||||
import { horizontalLayoutClasses } from '@layouts/utils/layoutClasses'
|
import { horizontalLayoutClasses } from '@layouts/utils/layoutClasses'
|
||||||
|
|
||||||
// Vars
|
|
||||||
const shortcuts: ShortcutsType[] = [
|
|
||||||
{
|
|
||||||
url: '/apps/calendar',
|
|
||||||
icon: 'tabler-calendar',
|
|
||||||
title: 'Calendar',
|
|
||||||
subtitle: 'Appointments'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: '/apps/invoice/list',
|
|
||||||
icon: 'tabler-file-dollar',
|
|
||||||
title: 'Invoice App',
|
|
||||||
subtitle: 'Manage Accounts'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: '/apps/user/list',
|
|
||||||
icon: 'tabler-user',
|
|
||||||
title: 'Users',
|
|
||||||
subtitle: 'Manage Users'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: '/apps/roles',
|
|
||||||
icon: 'tabler-users-group',
|
|
||||||
title: 'Role Management',
|
|
||||||
subtitle: 'Permissions'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: '/',
|
|
||||||
icon: 'tabler-device-desktop-analytics',
|
|
||||||
title: 'Dashboard',
|
|
||||||
subtitle: 'User Dashboard'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: '/pages/account-settings',
|
|
||||||
icon: 'tabler-settings',
|
|
||||||
title: 'Settings',
|
|
||||||
subtitle: 'Account Settings'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
const notifications: NotificationsType[] = [
|
|
||||||
{
|
|
||||||
avatarImage: '/images/avatars/8.png',
|
|
||||||
title: 'Congratulations Flora 🎉',
|
|
||||||
subtitle: 'Won the monthly bestseller gold badge',
|
|
||||||
time: '1h ago',
|
|
||||||
read: false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Cecilia Becker',
|
|
||||||
avatarColor: 'secondary',
|
|
||||||
subtitle: 'Accepted your connection',
|
|
||||||
time: '12h ago',
|
|
||||||
read: false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
avatarImage: '/images/avatars/3.png',
|
|
||||||
title: 'Bernard Woods',
|
|
||||||
subtitle: 'You have new message from Bernard Woods',
|
|
||||||
time: 'May 18, 8:26 AM',
|
|
||||||
read: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
avatarIcon: 'tabler-chart-bar',
|
|
||||||
title: 'Monthly report generated',
|
|
||||||
subtitle: 'July month financial report is generated',
|
|
||||||
avatarColor: 'info',
|
|
||||||
time: 'Apr 24, 10:30 AM',
|
|
||||||
read: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
avatarText: 'MG',
|
|
||||||
title: 'Application has been approved 🚀',
|
|
||||||
subtitle: 'Your Meta Gadgets project application has been approved.',
|
|
||||||
avatarColor: 'success',
|
|
||||||
time: 'Feb 17, 12:17 PM',
|
|
||||||
read: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
avatarIcon: 'tabler-mail',
|
|
||||||
title: 'New message from Harry',
|
|
||||||
subtitle: 'You have new message from Harry',
|
|
||||||
avatarColor: 'error',
|
|
||||||
time: 'Jan 6, 1:48 PM',
|
|
||||||
read: true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
const NavbarContent = () => {
|
const NavbarContent = () => {
|
||||||
// Hooks
|
|
||||||
const { isBreakpointReached } = useHorizontalNav()
|
const { isBreakpointReached } = useHorizontalNav()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={classnames(horizontalLayoutClasses.navbarContent, 'flex items-center justify-between gap-4 is-full')}>
|
||||||
className={classnames(horizontalLayoutClasses.navbarContent, 'flex items-center justify-between gap-4 is-full')}
|
|
||||||
>
|
|
||||||
<div className='flex items-center gap-4'>
|
<div className='flex items-center gap-4'>
|
||||||
<NavToggle />
|
<NavToggle />
|
||||||
{/* Hide Logo on Smaller screens */}
|
|
||||||
{!isBreakpointReached && (
|
{!isBreakpointReached && (
|
||||||
<Link href='/'>
|
<Link href='/'>
|
||||||
<Logo />
|
<Logo />
|
||||||
@@ -132,10 +35,8 @@ const NavbarContent = () => {
|
|||||||
<div className='flex items-center'>
|
<div className='flex items-center'>
|
||||||
<NavSearch />
|
<NavSearch />
|
||||||
<ModeDropdown />
|
<ModeDropdown />
|
||||||
<ShortcutsDropdown shortcuts={shortcuts} />
|
<NotificationsDropdown />
|
||||||
<NotificationsDropdown notifications={notifications} />
|
|
||||||
<UserDropdown />
|
<UserDropdown />
|
||||||
{/* Notification Dropdown, quick access menu dropdown, user dropdown will be placed here */}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
// React Imports
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useRef, useState, useEffect } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import type { MouseEvent, ReactNode } from 'react'
|
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
// MUI Imports
|
|
||||||
import IconButton from '@mui/material/IconButton'
|
import IconButton from '@mui/material/IconButton'
|
||||||
import Badge from '@mui/material/Badge'
|
import Badge from '@mui/material/Badge'
|
||||||
import Popper from '@mui/material/Popper'
|
import Popper from '@mui/material/Popper'
|
||||||
@@ -13,32 +13,26 @@ import Paper from '@mui/material/Paper'
|
|||||||
import ClickAwayListener from '@mui/material/ClickAwayListener'
|
import ClickAwayListener from '@mui/material/ClickAwayListener'
|
||||||
import Typography from '@mui/material/Typography'
|
import Typography from '@mui/material/Typography'
|
||||||
import Chip from '@mui/material/Chip'
|
import Chip from '@mui/material/Chip'
|
||||||
import Tooltip from '@mui/material/Tooltip'
|
|
||||||
import Divider from '@mui/material/Divider'
|
import Divider from '@mui/material/Divider'
|
||||||
import Avatar from '@mui/material/Avatar'
|
import Avatar from '@mui/material/Avatar'
|
||||||
import useMediaQuery from '@mui/material/useMediaQuery'
|
import CircularProgress from '@mui/material/CircularProgress'
|
||||||
|
import Box from '@mui/material/Box'
|
||||||
|
import Alert from '@mui/material/Alert'
|
||||||
import Button from '@mui/material/Button'
|
import Button from '@mui/material/Button'
|
||||||
|
import useMediaQuery from '@mui/material/useMediaQuery'
|
||||||
import type { Theme } from '@mui/material/styles'
|
import type { Theme } from '@mui/material/styles'
|
||||||
|
|
||||||
// Third Party Components
|
|
||||||
import classnames from 'classnames'
|
import classnames from 'classnames'
|
||||||
import PerfectScrollbar from 'react-perfect-scrollbar'
|
import { formatDistanceToNow } from 'date-fns'
|
||||||
|
|
||||||
// Type Imports
|
|
||||||
import type { ThemeColor } from '@core/types'
|
import type { ThemeColor } from '@core/types'
|
||||||
import type { CustomAvatarProps } from '@core/components/mui/Avatar'
|
import type { CustomAvatarProps } from '@core/components/mui/Avatar'
|
||||||
|
|
||||||
// Component Imports
|
|
||||||
import CustomAvatar from '@core/components/mui/Avatar'
|
import CustomAvatar from '@core/components/mui/Avatar'
|
||||||
|
|
||||||
// Config Imports
|
|
||||||
import themeConfig from '@configs/themeConfig'
|
import themeConfig from '@configs/themeConfig'
|
||||||
|
|
||||||
// Hook Imports
|
|
||||||
import { useSettings } from '@core/hooks/useSettings'
|
import { useSettings } from '@core/hooks/useSettings'
|
||||||
|
|
||||||
// Util Imports
|
|
||||||
import { getInitials } from '@/utils/getInitials'
|
import { getInitials } from '@/utils/getInitials'
|
||||||
|
import { useFarmHub } from '@/hooks/useFarmHub'
|
||||||
|
import { notificationsService, type NotificationItem } from '@/libs/api/services/notificationsService'
|
||||||
|
|
||||||
export type NotificationsType = {
|
export type NotificationsType = {
|
||||||
title: string
|
title: string
|
||||||
@@ -69,107 +63,204 @@ export type NotificationsType = {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const ScrollWrapper = ({ children, hidden }: { children: ReactNode; hidden: boolean }) => {
|
const ScrollWrapper = ({ children }: { children: ReactNode }) => {
|
||||||
if (hidden) {
|
return <div className='overflow-y-auto overflow-x-hidden grow min-bs-0 max-bs-[360px]'>{children}</div>
|
||||||
return <div className='overflow-x-hidden bs-full'>{children}</div>
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<PerfectScrollbar className='bs-full' options={{ wheelPropagation: false, suppressScrollX: true }}>
|
|
||||||
{children}
|
|
||||||
</PerfectScrollbar>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const getAvatar = (
|
const getAvatar = ({
|
||||||
params: Pick<NotificationsType, 'avatarImage' | 'avatarIcon' | 'title' | 'avatarText' | 'avatarColor' | 'avatarSkin'>
|
avatarImage,
|
||||||
) => {
|
avatarIcon,
|
||||||
const { avatarImage, avatarIcon, avatarText, title, avatarColor, avatarSkin } = params
|
avatarText,
|
||||||
|
title,
|
||||||
|
avatarColor,
|
||||||
|
avatarSkin
|
||||||
|
}: Pick<NotificationsType, 'avatarImage' | 'avatarIcon' | 'title' | 'avatarText' | 'avatarColor' | 'avatarSkin'>) => {
|
||||||
if (avatarImage) {
|
if (avatarImage) {
|
||||||
return <Avatar src={avatarImage} />
|
return <Avatar src={avatarImage} />
|
||||||
} else if (avatarIcon) {
|
}
|
||||||
|
|
||||||
|
if (avatarIcon) {
|
||||||
return (
|
return (
|
||||||
<CustomAvatar color={avatarColor} skin={avatarSkin || 'light-static'}>
|
<CustomAvatar color={avatarColor} skin={avatarSkin || 'light-static'}>
|
||||||
<i className={avatarIcon} />
|
<i className={avatarIcon} />
|
||||||
</CustomAvatar>
|
</CustomAvatar>
|
||||||
)
|
)
|
||||||
} else {
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CustomAvatar color={avatarColor} skin={avatarSkin || 'light-static'}>
|
<CustomAvatar color={avatarColor} skin={avatarSkin || 'light-static'}>
|
||||||
{avatarText || getInitials(title)}
|
{avatarText || getInitials(title)}
|
||||||
</CustomAvatar>
|
</CustomAvatar>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getNotificationAppearance = (notification: NotificationItem): Pick<NotificationsType, 'avatarIcon' | 'avatarColor'> => {
|
||||||
|
switch (notification.level) {
|
||||||
|
case 'critical':
|
||||||
|
return { avatarIcon: 'tabler-alert-triangle', avatarColor: 'error' }
|
||||||
|
case 'warning':
|
||||||
|
return { avatarIcon: 'tabler-alert-circle', avatarColor: 'warning' }
|
||||||
|
default:
|
||||||
|
return { avatarIcon: 'tabler-info-circle', avatarColor: 'info' }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const NotificationDropdown = ({ notifications }: { notifications: NotificationsType[] }) => {
|
const NotificationsDropdown = () => {
|
||||||
// States
|
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [notificationsState, setNotificationsState] = useState(notifications)
|
const [notifications, setNotifications] = useState<NotificationItem[]>([])
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [lastSinceId, setLastSinceId] = useState<number | null>(null)
|
||||||
|
const [lastMarkedSliceId, setLastMarkedSliceId] = useState<number | null>(null)
|
||||||
|
|
||||||
// Vars
|
|
||||||
const notificationCount = notificationsState.filter(notification => !notification.read).length
|
|
||||||
const readAll = notificationsState.every(notification => notification.read)
|
|
||||||
|
|
||||||
// Refs
|
|
||||||
const anchorRef = useRef<HTMLButtonElement>(null)
|
const anchorRef = useRef<HTMLButtonElement>(null)
|
||||||
const ref = useRef<HTMLDivElement | null>(null)
|
const ref = useRef<HTMLDivElement | null>(null)
|
||||||
|
const pollingAbortRef = useRef(false)
|
||||||
|
const { farmHub } = useFarmHub()
|
||||||
|
|
||||||
// Hooks
|
|
||||||
const hidden = useMediaQuery((theme: Theme) => theme.breakpoints.down('lg'))
|
|
||||||
const isSmallScreen = useMediaQuery((theme: Theme) => theme.breakpoints.down('sm'))
|
const isSmallScreen = useMediaQuery((theme: Theme) => theme.breakpoints.down('sm'))
|
||||||
const { settings } = useSettings()
|
const { settings } = useSettings()
|
||||||
|
|
||||||
|
const farmUuid = farmHub?.farm_uuid ?? null
|
||||||
|
|
||||||
|
const unreadCount = useMemo(() => notifications.filter(item => !item.is_read).length, [notifications])
|
||||||
|
const latestUnreadSinceId = useMemo(() => {
|
||||||
|
return notifications.reduce<number | null>((latest, item) => {
|
||||||
|
if (item.is_read) {
|
||||||
|
return latest
|
||||||
|
}
|
||||||
|
|
||||||
|
if (latest === null || item.since_id > latest) {
|
||||||
|
return item.since_id
|
||||||
|
}
|
||||||
|
|
||||||
|
return latest
|
||||||
|
}, null)
|
||||||
|
}, [notifications])
|
||||||
|
|
||||||
|
const markNotificationsAsRead = useCallback(
|
||||||
|
async (sliceId: number) => {
|
||||||
|
if (!farmUuid || sliceId <= 0 || lastMarkedSliceId === sliceId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await notificationsService.markAsRead({ farmUuid, sliceId })
|
||||||
|
setNotifications(current => current.map(item => (item.since_id <= sliceId ? { ...item, is_read: true } : item)))
|
||||||
|
setLastMarkedSliceId(sliceId)
|
||||||
|
} catch (markError) {
|
||||||
|
console.error('Failed to mark notifications as read', markError)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[farmUuid, lastMarkedSliceId]
|
||||||
|
)
|
||||||
|
|
||||||
|
const fetchNotifications = useCallback(
|
||||||
|
async (timeout: number, sinceId?: number) => {
|
||||||
|
if (!farmUuid) {
|
||||||
|
setNotifications([])
|
||||||
|
setLastSinceId(null)
|
||||||
|
return [] as NotificationItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await notificationsService.longPoll({ farmUuid, sinceId, timeout })
|
||||||
|
|
||||||
|
if (response.length > 0 || sinceId === undefined) {
|
||||||
|
setNotifications(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.length > 0) {
|
||||||
|
setLastSinceId(response[response.length - 1]?.since_id ?? sinceId ?? null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
},
|
||||||
|
[farmUuid]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
pollingAbortRef.current = false
|
||||||
|
|
||||||
|
if (!farmUuid) {
|
||||||
|
setNotifications([])
|
||||||
|
setError(null)
|
||||||
|
setIsLoading(false)
|
||||||
|
setLastSinceId(null)
|
||||||
|
setLastMarkedSliceId(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const poll = async () => {
|
||||||
|
setIsLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const initialItems = await fetchNotifications(0)
|
||||||
|
const initialLastSinceId = initialItems[initialItems.length - 1]?.since_id ?? null
|
||||||
|
let nextSinceId = initialLastSinceId
|
||||||
|
|
||||||
|
if (!pollingAbortRef.current) {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
while (!pollingAbortRef.current) {
|
||||||
|
const newItems = await notificationsService.longPoll({ farmUuid, sinceId: nextSinceId ?? undefined, timeout: 15 })
|
||||||
|
|
||||||
|
if (pollingAbortRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newItems.length > 0) {
|
||||||
|
setNotifications(newItems)
|
||||||
|
nextSinceId = newItems[newItems.length - 1]?.since_id ?? nextSinceId
|
||||||
|
setLastSinceId(nextSinceId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (pollError) {
|
||||||
|
if (!pollingAbortRef.current) {
|
||||||
|
setError(pollError instanceof Error ? pollError.message : 'Unable to load notifications.')
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
poll()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
pollingAbortRef.current = true
|
||||||
|
}
|
||||||
|
}, [farmUuid, fetchNotifications])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const adjustPopoverHeight = () => {
|
||||||
|
if (ref.current) {
|
||||||
|
const availableHeight = window.innerHeight - 100
|
||||||
|
ref.current.style.height = `${Math.min(availableHeight, 550)}px`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
adjustPopoverHeight()
|
||||||
|
window.addEventListener('resize', adjustPopoverHeight)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', adjustPopoverHeight)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && latestUnreadSinceId) {
|
||||||
|
void markNotificationsAsRead(latestUnreadSinceId)
|
||||||
|
}
|
||||||
|
}, [open, latestUnreadSinceId, markNotificationsAsRead])
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleToggle = () => {
|
const handleToggle = () => {
|
||||||
setOpen(prevOpen => !prevOpen)
|
setOpen(previousOpen => !previousOpen)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read notification when notification is clicked
|
|
||||||
const handleReadNotification = (event: MouseEvent<HTMLElement>, value: boolean, index: number) => {
|
|
||||||
event.stopPropagation()
|
|
||||||
const newNotifications = [...notificationsState]
|
|
||||||
|
|
||||||
newNotifications[index].read = value
|
|
||||||
setNotificationsState(newNotifications)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove notification when close icon is clicked
|
|
||||||
const handleRemoveNotification = (event: MouseEvent<HTMLElement>, index: number) => {
|
|
||||||
event.stopPropagation()
|
|
||||||
const newNotifications = [...notificationsState]
|
|
||||||
|
|
||||||
newNotifications.splice(index, 1)
|
|
||||||
setNotificationsState(newNotifications)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read or unread all notifications when read all icon is clicked
|
|
||||||
const readAllNotifications = () => {
|
|
||||||
const newNotifications = [...notificationsState]
|
|
||||||
|
|
||||||
newNotifications.forEach(notification => {
|
|
||||||
notification.read = !readAll
|
|
||||||
})
|
|
||||||
setNotificationsState(newNotifications)
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const adjustPopoverHeight = () => {
|
|
||||||
if (ref.current) {
|
|
||||||
// Calculate available height, subtracting any fixed UI elements' height as necessary
|
|
||||||
const availableHeight = window.innerHeight - 100
|
|
||||||
|
|
||||||
ref.current.style.height = `${Math.min(availableHeight, 550)}px`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('resize', adjustPopoverHeight)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<IconButton ref={anchorRef} onClick={handleToggle} className='text-textPrimary'>
|
<IconButton ref={anchorRef} onClick={handleToggle} className='text-textPrimary'>
|
||||||
@@ -178,10 +269,8 @@ const NotificationDropdown = ({ notifications }: { notifications: NotificationsT
|
|||||||
className='cursor-pointer'
|
className='cursor-pointer'
|
||||||
variant='dot'
|
variant='dot'
|
||||||
overlap='circular'
|
overlap='circular'
|
||||||
invisible={notificationCount === 0}
|
invisible={unreadCount === 0}
|
||||||
sx={{
|
sx={{ '& .MuiBadge-dot': { top: 6, right: 5, boxShadow: 'var(--mui-palette-background-paper) 0px 0px 0px 2px' } }}
|
||||||
'& .MuiBadge-dot': { top: 6, right: 5, boxShadow: 'var(--mui-palette-background-paper) 0px 0px 0px 2px' }
|
|
||||||
}}
|
|
||||||
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
|
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||||
>
|
>
|
||||||
<i className='tabler-bell' />
|
<i className='tabler-bell' />
|
||||||
@@ -196,110 +285,69 @@ const NotificationDropdown = ({ notifications }: { notifications: NotificationsT
|
|||||||
anchorEl={anchorRef.current}
|
anchorEl={anchorRef.current}
|
||||||
{...(isSmallScreen
|
{...(isSmallScreen
|
||||||
? {
|
? {
|
||||||
className: 'is-full !mbs-3 z-[1] max-bs-[550px] bs-[550px]',
|
className: 'is-full !mbs-4 z-[1] max-bs-[550px] bs-[calc(100dvh-theme(spacing.6))] max-is-[calc(100dvw-theme(spacing.6))]'
|
||||||
modifiers: [
|
|
||||||
{
|
|
||||||
name: 'preventOverflow',
|
|
||||||
options: {
|
|
||||||
padding: themeConfig.layoutPadding
|
|
||||||
}
|
}
|
||||||
}
|
: {
|
||||||
]
|
className: 'is-[380px] !mbs-4 z-[1] max-bs-[550px]'
|
||||||
}
|
})}
|
||||||
: { className: 'is-96 !mbs-3 z-[1] max-bs-[550px] bs-[550px]' })}
|
|
||||||
>
|
>
|
||||||
{({ TransitionProps, placement }) => (
|
{({ TransitionProps, placement }) => (
|
||||||
<Fade {...TransitionProps} style={{ transformOrigin: placement === 'bottom-end' ? 'right top' : 'left top' }}>
|
<Fade {...TransitionProps} style={{ transformOrigin: placement === 'bottom-end' ? 'right top' : 'left top' }}>
|
||||||
<Paper className={classnames('bs-full', settings.skin === 'bordered' ? 'border shadow-none' : 'shadow-lg')}>
|
<Paper className={classnames('bs-full overflow-hidden', settings.skin === 'bordered' ? 'border shadow-none' : 'shadow-lg')}>
|
||||||
<ClickAwayListener onClickAway={handleClose}>
|
<ClickAwayListener onClickAway={handleClose}>
|
||||||
<div className='bs-full flex flex-col'>
|
<div className='bs-full flex flex-col'>
|
||||||
<div className='flex items-center justify-between plb-3.5 pli-4 is-full gap-2'>
|
<div className='flex items-center justify-between plb-4 pli-6'>
|
||||||
<Typography variant='h6' className='flex-auto'>
|
<div className='flex items-center gap-3'>
|
||||||
Notifications
|
<Typography variant='h5'>Notifications</Typography>
|
||||||
</Typography>
|
{unreadCount > 0 ? <Chip size='small' variant='tonal' color='primary' label={`${unreadCount} new`} /> : null}
|
||||||
{notificationCount > 0 && (
|
</div>
|
||||||
<Chip size='small' variant='tonal' color='primary' label={`${notificationCount} New`} />
|
{isLoading ? <CircularProgress size={18} /> : null}
|
||||||
)}
|
|
||||||
<Tooltip
|
|
||||||
title={readAll ? 'Mark all as unread' : 'Mark all as read'}
|
|
||||||
placement={placement === 'bottom-end' ? 'left' : 'right'}
|
|
||||||
slotProps={{
|
|
||||||
popper: {
|
|
||||||
sx: {
|
|
||||||
'& .MuiTooltip-tooltip': {
|
|
||||||
transformOrigin:
|
|
||||||
placement === 'bottom-end' ? 'right center !important' : 'right center !important'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{notificationsState.length > 0 ? (
|
|
||||||
<IconButton size='small' onClick={() => readAllNotifications()} className='text-textPrimary'>
|
|
||||||
<i className={readAll ? 'tabler-mail' : 'tabler-mail-opened'} />
|
|
||||||
</IconButton>
|
|
||||||
) : (
|
|
||||||
<></>
|
|
||||||
)}
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
<Divider />
|
<Divider />
|
||||||
<ScrollWrapper hidden={hidden}>
|
<ScrollWrapper>
|
||||||
{notificationsState.map((notification, index) => {
|
{error ? (
|
||||||
const {
|
<Box className='p-4'>
|
||||||
title,
|
<Alert severity='error'>{error}</Alert>
|
||||||
subtitle,
|
</Box>
|
||||||
time,
|
) : !farmUuid ? (
|
||||||
read,
|
<Box className='p-6'>
|
||||||
avatarImage,
|
<Typography color='text.secondary'>To see notifications, first select an active farm.</Typography>
|
||||||
avatarIcon,
|
</Box>
|
||||||
avatarText,
|
) : notifications.length === 0 && !isLoading ? (
|
||||||
avatarColor,
|
<Box className='p-6'>
|
||||||
avatarSkin
|
<Typography color='text.secondary'>No notifications yet.</Typography>
|
||||||
} = notification
|
</Box>
|
||||||
|
) : (
|
||||||
|
notifications.map(notification => {
|
||||||
|
const appearance = getNotificationAppearance(notification)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={notification.uuid} className={classnames('cursor-pointer pli-6 plb-4 flex items-start gap-4', { 'bg-actionHover': !notification.is_read })}>
|
||||||
key={index}
|
{getAvatar({
|
||||||
className={classnames('flex plb-3 pli-4 gap-3 cursor-pointer hover:bg-actionHover group', {
|
title: notification.title,
|
||||||
'border-be': index !== notificationsState.length - 1
|
avatarIcon: appearance.avatarIcon,
|
||||||
|
avatarColor: appearance.avatarColor
|
||||||
})}
|
})}
|
||||||
onClick={e => handleReadNotification(e, true, index)}
|
<div className='flex grow flex-col gap-1'>
|
||||||
>
|
<Typography className='font-medium' color='text.primary'>
|
||||||
{getAvatar({ avatarImage, avatarIcon, title, avatarText, avatarColor, avatarSkin })}
|
{notification.title}
|
||||||
<div className='flex flex-col flex-auto'>
|
|
||||||
<Typography variant='body2' className='font-medium mbe-1' color='text.primary'>
|
|
||||||
{title}
|
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant='caption' color='text.secondary' className='mbe-2'>
|
<Typography variant='body2' color='text.secondary'>
|
||||||
{subtitle}
|
{notification.message}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant='caption' color='text.disabled'>
|
<Typography variant='caption' color='text.disabled'>
|
||||||
{time}
|
{formatDistanceToNow(new Date(notification.created_at), { addSuffix: true })}
|
||||||
</Typography>
|
</Typography>
|
||||||
</div>
|
</div>
|
||||||
<div className='flex flex-col items-end gap-2'>
|
|
||||||
<Badge
|
|
||||||
variant='dot'
|
|
||||||
color={read ? 'secondary' : 'primary'}
|
|
||||||
onClick={e => handleReadNotification(e, !read, index)}
|
|
||||||
className={classnames('mbs-1 mie-1', {
|
|
||||||
'invisible group-hover:visible': read
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
<i
|
|
||||||
className='tabler-x text-xl invisible group-hover:visible'
|
|
||||||
onClick={e => handleRemoveNotification(e, index)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})
|
||||||
|
)}
|
||||||
</ScrollWrapper>
|
</ScrollWrapper>
|
||||||
<Divider />
|
<Divider />
|
||||||
<div className='p-4'>
|
<div className='p-4'>
|
||||||
<Button fullWidth variant='contained' size='small'>
|
<Button fullWidth component={Link} href='/pages/account-settings' variant='contained' onClick={handleClose}>
|
||||||
View All Notifications
|
View all notifications
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -312,4 +360,4 @@ const NotificationDropdown = ({ notifications }: { notifications: NotificationsT
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default NotificationDropdown
|
export default NotificationsDropdown
|
||||||
|
|||||||
@@ -1,116 +1,19 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
// Third-party Imports
|
|
||||||
import classnames from 'classnames'
|
import classnames from 'classnames'
|
||||||
import { useContext } from 'react'
|
import { useContext } from 'react'
|
||||||
|
|
||||||
// Context Imports
|
|
||||||
import NavbarSlotContext from '@/contexts/navbarSlotContext'
|
import NavbarSlotContext from '@/contexts/navbarSlotContext'
|
||||||
|
|
||||||
// Type Imports
|
|
||||||
import type { ShortcutsType } from '@components/layout/shared/ShortcutsDropdown'
|
|
||||||
import type { NotificationsType } from '@components/layout/shared/NotificationsDropdown'
|
|
||||||
|
|
||||||
// Component Imports
|
|
||||||
import NavToggle from './NavToggle'
|
import NavToggle from './NavToggle'
|
||||||
import NavSearch from '@components/layout/shared/search'
|
import NavSearch from '@components/layout/shared/search'
|
||||||
import ThemeColorDropdown from '@components/layout/shared/ThemeColorDropdown'
|
import ThemeColorDropdown from '@components/layout/shared/ThemeColorDropdown'
|
||||||
import ShortcutsDropdown from '@components/layout/shared/ShortcutsDropdown'
|
|
||||||
import NotificationsDropdown from '@components/layout/shared/NotificationsDropdown'
|
import NotificationsDropdown from '@components/layout/shared/NotificationsDropdown'
|
||||||
import UserDropdown from '@components/layout/shared/UserDropdown'
|
import UserDropdown from '@components/layout/shared/UserDropdown'
|
||||||
|
|
||||||
// Util Imports
|
|
||||||
import { verticalLayoutClasses } from '@layouts/utils/layoutClasses'
|
import { verticalLayoutClasses } from '@layouts/utils/layoutClasses'
|
||||||
import ModeDropdown from '../shared/ModeDropdown'
|
import ModeDropdown from '../shared/ModeDropdown'
|
||||||
|
|
||||||
// Vars
|
|
||||||
const shortcuts: ShortcutsType[] = [
|
|
||||||
{
|
|
||||||
url: '/apps/calendar',
|
|
||||||
icon: 'tabler-calendar',
|
|
||||||
title: 'Calendar',
|
|
||||||
subtitle: 'Appointments'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: '/apps/invoice/list',
|
|
||||||
icon: 'tabler-file-dollar',
|
|
||||||
title: 'Invoice App',
|
|
||||||
subtitle: 'Manage Accounts'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: '/apps/user/list',
|
|
||||||
icon: 'tabler-user',
|
|
||||||
title: 'Users',
|
|
||||||
subtitle: 'Manage Users'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: '/apps/roles',
|
|
||||||
icon: 'tabler-users-group',
|
|
||||||
title: 'Role Management',
|
|
||||||
subtitle: 'Permissions'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: '/',
|
|
||||||
icon: 'tabler-device-desktop-analytics',
|
|
||||||
title: 'Dashboard',
|
|
||||||
subtitle: 'User Dashboard'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: '/pages/account-settings',
|
|
||||||
icon: 'tabler-settings',
|
|
||||||
title: 'Settings',
|
|
||||||
subtitle: 'Account Settings'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
const notifications: NotificationsType[] = [
|
|
||||||
{
|
|
||||||
avatarImage: '/images/avatars/8.png',
|
|
||||||
title: 'Congratulations Flora 🎉',
|
|
||||||
subtitle: 'Won the monthly bestseller gold badge',
|
|
||||||
time: '1h ago',
|
|
||||||
read: false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Cecilia Becker',
|
|
||||||
avatarColor: 'secondary',
|
|
||||||
subtitle: 'Accepted your connection',
|
|
||||||
time: '12h ago',
|
|
||||||
read: false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
avatarImage: '/images/avatars/3.png',
|
|
||||||
title: 'Bernard Woods',
|
|
||||||
subtitle: 'You have new message from Bernard Woods',
|
|
||||||
time: 'May 18, 8:26 AM',
|
|
||||||
read: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
avatarIcon: 'tabler-chart-bar',
|
|
||||||
title: 'Monthly report generated',
|
|
||||||
subtitle: 'July month financial report is generated',
|
|
||||||
avatarColor: 'info',
|
|
||||||
time: 'Apr 24, 10:30 AM',
|
|
||||||
read: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
avatarText: 'MG',
|
|
||||||
title: 'Application has been approved 🚀',
|
|
||||||
subtitle: 'Your Meta Gadgets project application has been approved.',
|
|
||||||
avatarColor: 'success',
|
|
||||||
time: 'Feb 17, 12:17 PM',
|
|
||||||
read: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
avatarIcon: 'tabler-mail',
|
|
||||||
title: 'New message from Harry',
|
|
||||||
subtitle: 'You have new message from Harry',
|
|
||||||
avatarColor: 'error',
|
|
||||||
time: 'Jan 6, 1:48 PM',
|
|
||||||
read: true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
const NavbarContent = () => {
|
const NavbarContent = () => {
|
||||||
const { slotContent } = useContext(NavbarSlotContext)
|
const { slotContent } = useContext(NavbarSlotContext)
|
||||||
|
|
||||||
@@ -122,11 +25,9 @@ const NavbarContent = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className='flex items-center'>
|
<div className='flex items-center'>
|
||||||
{slotContent}
|
{slotContent}
|
||||||
|
|
||||||
<ModeDropdown />
|
<ModeDropdown />
|
||||||
|
|
||||||
<ThemeColorDropdown />
|
<ThemeColorDropdown />
|
||||||
{/* <NotificationsDropdown notifications={notifications} /> */}
|
<NotificationsDropdown />
|
||||||
<UserDropdown />
|
<UserDropdown />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { apiClient } from '../client'
|
||||||
|
|
||||||
|
const PREFIX = '/api/notifications'
|
||||||
|
|
||||||
|
export type NotificationLevel = 'info' | 'warning' | 'critical' | string
|
||||||
|
|
||||||
|
export interface NotificationItem {
|
||||||
|
uuid: string
|
||||||
|
farm_uuid: string
|
||||||
|
since_id: number
|
||||||
|
title: string
|
||||||
|
message: string
|
||||||
|
level: NotificationLevel
|
||||||
|
is_read: boolean
|
||||||
|
metadata?: Record<string, unknown> | null
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationsLongPollResponse {
|
||||||
|
code: number
|
||||||
|
msg: string
|
||||||
|
data: NotificationItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationsListResponse {
|
||||||
|
count: number
|
||||||
|
next: string | null
|
||||||
|
previous: string | null
|
||||||
|
results?: {
|
||||||
|
code: number
|
||||||
|
msg: string
|
||||||
|
data: NotificationItem[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarkNotificationsAsReadResponse {
|
||||||
|
code: number
|
||||||
|
msg: string
|
||||||
|
marked_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const notificationsService = {
|
||||||
|
async longPoll(params: { farmUuid: string; sinceId?: number; timeout?: number }): Promise<NotificationItem[]> {
|
||||||
|
const searchParams = new URLSearchParams({ farm_uuid: params.farmUuid })
|
||||||
|
|
||||||
|
if (typeof params.sinceId === 'number') {
|
||||||
|
searchParams.set('since_id', String(params.sinceId))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof params.timeout === 'number') {
|
||||||
|
searchParams.set('timeout', String(params.timeout))
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await apiClient.get<NotificationsLongPollResponse>(`${PREFIX}/long-poll/?${searchParams.toString()}`)
|
||||||
|
|
||||||
|
return Array.isArray(response.data) ? response.data : []
|
||||||
|
},
|
||||||
|
|
||||||
|
async list(params: { farmUuid: string; page?: number; pageSize?: number }): Promise<NotificationsListResponse> {
|
||||||
|
const searchParams = new URLSearchParams({ farm_uuid: params.farmUuid })
|
||||||
|
|
||||||
|
if (typeof params.page === 'number') {
|
||||||
|
searchParams.set('page', String(params.page))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof params.pageSize === 'number') {
|
||||||
|
searchParams.set('page_size', String(params.pageSize))
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiClient.get<NotificationsListResponse>(`${PREFIX}/list/?${searchParams.toString()}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
async markAsRead(payload: { farmUuid: string; sliceId: number }): Promise<MarkNotificationsAsReadResponse> {
|
||||||
|
return apiClient.post<MarkNotificationsAsReadResponse>(`${PREFIX}/mark-as-read/`, {
|
||||||
|
farm_uuid: payload.farmUuid,
|
||||||
|
slice_id: payload.sliceId
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,82 +1,128 @@
|
|||||||
// MUI Imports
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
import Card from '@mui/material/Card'
|
import Card from '@mui/material/Card'
|
||||||
import CardContent from '@mui/material/CardContent'
|
import CardContent from '@mui/material/CardContent'
|
||||||
import Checkbox from '@mui/material/Checkbox'
|
import Chip from '@mui/material/Chip'
|
||||||
|
import Stack from '@mui/material/Stack'
|
||||||
import Typography from '@mui/material/Typography'
|
import Typography from '@mui/material/Typography'
|
||||||
|
import Alert from '@mui/material/Alert'
|
||||||
|
import CircularProgress from '@mui/material/CircularProgress'
|
||||||
|
import Button from '@mui/material/Button'
|
||||||
|
import Divider from '@mui/material/Divider'
|
||||||
|
|
||||||
// Style Imports
|
import { format } from 'date-fns'
|
||||||
import tableStyles from '@core/styles/table.module.css'
|
|
||||||
|
|
||||||
type tableData = { type: string; email: boolean; app: boolean }
|
import { useFarmHub } from '@/hooks/useFarmHub'
|
||||||
|
import { notificationsService, type NotificationItem } from '@/libs/api/services/notificationsService'
|
||||||
type CardProps = {
|
|
||||||
title: string
|
|
||||||
data: tableData[]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vars
|
|
||||||
const customerData: tableData[] = [
|
|
||||||
{ type: 'New customer sign up', email: true, app: false },
|
|
||||||
{ type: 'Customer account password reset', email: false, app: true },
|
|
||||||
{ type: 'Customer account invite', email: false, app: false }
|
|
||||||
]
|
|
||||||
|
|
||||||
const shippingData: tableData[] = [
|
|
||||||
{ type: 'Picked up', email: true, app: false },
|
|
||||||
{ type: 'Shipping update ', email: false, app: true },
|
|
||||||
{ type: 'Delivered', email: false, app: false }
|
|
||||||
]
|
|
||||||
|
|
||||||
const ordersData: tableData[] = [
|
|
||||||
{ type: 'Order purchase', email: true, app: false },
|
|
||||||
{ type: 'Order cancelled', email: false, app: true },
|
|
||||||
{ type: 'Order refund request', email: false, app: false },
|
|
||||||
{ type: 'Order confirmation', email: false, app: true },
|
|
||||||
{ type: 'Payment error', email: false, app: true }
|
|
||||||
]
|
|
||||||
|
|
||||||
const TableCard = (props: CardProps) => {
|
|
||||||
// Props
|
|
||||||
const { title, data } = props
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='flex flex-col gap-4'>
|
|
||||||
<Typography variant='h5'>{title}</Typography>
|
|
||||||
<div className='border rounded overflow-x-auto'>
|
|
||||||
<table className={tableStyles.table}>
|
|
||||||
<thead className='border-0'>
|
|
||||||
<tr>
|
|
||||||
<th className='is-2/4'>Type</th>
|
|
||||||
<th className='is-1/4'>Email</th>
|
|
||||||
<th className='is-1/4'>App</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{data.map((data, index) => (
|
|
||||||
<tr key={index}>
|
|
||||||
<td className='text-textPrimary'>{data.type}</td>
|
|
||||||
<td>
|
|
||||||
<Checkbox defaultChecked={data.email} />
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<Checkbox defaultChecked={data.app} />
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const Notifications = () => {
|
const Notifications = () => {
|
||||||
|
const { farmHub } = useFarmHub()
|
||||||
|
const farmUuid = farmHub?.farm_uuid ?? null
|
||||||
|
|
||||||
|
const [notifications, setNotifications] = useState<NotificationItem[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [markingAll, setMarkingAll] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const unreadCount = useMemo(() => notifications.filter(item => !item.is_read).length, [notifications])
|
||||||
|
const latestSinceId = useMemo(() => notifications.reduce((latest, item) => Math.max(latest, item.since_id), 0), [notifications])
|
||||||
|
|
||||||
|
const loadNotifications = useCallback(async () => {
|
||||||
|
if (!farmUuid) {
|
||||||
|
setNotifications([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const items = await notificationsService.longPoll({ farmUuid, timeout: 0 })
|
||||||
|
setNotifications(items.sort((first, second) => new Date(second.created_at).getTime() - new Date(first.created_at).getTime()))
|
||||||
|
} catch (loadError) {
|
||||||
|
setError(loadError instanceof Error ? loadError.message : 'Unable to load notifications.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [farmUuid])
|
||||||
|
|
||||||
|
const markAllAsRead = useCallback(async () => {
|
||||||
|
if (!farmUuid || latestSinceId <= 0 || unreadCount === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setMarkingAll(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await notificationsService.markAsRead({ farmUuid, sliceId: latestSinceId })
|
||||||
|
setNotifications(current => current.map(item => ({ ...item, is_read: true })))
|
||||||
|
} catch (markError) {
|
||||||
|
setError(markError instanceof Error ? markError.message : 'Unable to mark notifications as read.')
|
||||||
|
} finally {
|
||||||
|
setMarkingAll(false)
|
||||||
|
}
|
||||||
|
}, [farmUuid, latestSinceId, unreadCount])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadNotifications()
|
||||||
|
}, [loadNotifications])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className='flex flex-col gap-6'>
|
<CardContent className='flex flex-col gap-6'>
|
||||||
<TableCard title='Customer' data={customerData} />
|
<div className='flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
|
||||||
<TableCard title='Orders' data={ordersData} />
|
<div>
|
||||||
<TableCard title='Shipping' data={shippingData} />
|
<Typography variant='h5'>Notifications</Typography>
|
||||||
|
<Typography color='text.secondary'>Live farm alerts for the currently selected farm appear here.</Typography>
|
||||||
|
</div>
|
||||||
|
<Stack direction='row' spacing={2} alignItems='center'>
|
||||||
|
<Chip color={unreadCount > 0 ? 'error' : 'default'} label={`${unreadCount} unread`} />
|
||||||
|
<Button variant='outlined' onClick={() => void loadNotifications()} disabled={loading || !farmUuid}>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
<Button variant='contained' onClick={() => void markAllAsRead()} disabled={markingAll || unreadCount === 0 || !farmUuid}>
|
||||||
|
Mark all as read
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{!farmUuid ? (
|
||||||
|
<Alert severity='info'>برای دیدن نوتیفیکیشن ها، ابتدا یک مزرعه فعال انتخاب کنید.</Alert>
|
||||||
|
) : error ? (
|
||||||
|
<Alert severity='error'>{error}</Alert>
|
||||||
|
) : loading ? (
|
||||||
|
<div className='flex items-center justify-center py-10'>
|
||||||
|
<CircularProgress />
|
||||||
|
</div>
|
||||||
|
) : notifications.length === 0 ? (
|
||||||
|
<Alert severity='success'>هنوز نوتیفیکیشنی برای این مزرعه ثبت نشده است.</Alert>
|
||||||
|
) : (
|
||||||
|
<div className='flex flex-col gap-4'>
|
||||||
|
{notifications.map(notification => (
|
||||||
|
<Card key={notification.uuid} variant='outlined'>
|
||||||
|
<CardContent className='flex flex-col gap-3'>
|
||||||
|
<div className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between'>
|
||||||
|
<div className='flex flex-col gap-1'>
|
||||||
|
<Typography variant='h6'>{notification.title}</Typography>
|
||||||
|
<Typography color='text.secondary'>{notification.message}</Typography>
|
||||||
|
</div>
|
||||||
|
<Stack direction='row' spacing={1} alignItems='center'>
|
||||||
|
<Chip size='small' color={notification.is_read ? 'default' : 'error'} label={notification.is_read ? 'Read' : 'Unread'} />
|
||||||
|
<Chip size='small' color={notification.level === 'critical' ? 'error' : notification.level === 'warning' ? 'warning' : 'info'} label={notification.level} />
|
||||||
|
</Stack>
|
||||||
|
</div>
|
||||||
|
<Typography variant='caption' color='text.disabled'>
|
||||||
|
{format(new Date(notification.created_at), 'yyyy-MM-dd HH:mm')}
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
// React Imports
|
// React Imports
|
||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useTranslations } from 'next-intl'
|
import { useTranslations } from 'next-intl'
|
||||||
|
import { useSearchParams } from 'next/navigation'
|
||||||
import type { SyntheticEvent, ReactElement } from 'react'
|
import type { SyntheticEvent, ReactElement } from 'react'
|
||||||
|
|
||||||
// MUI Imports
|
// MUI Imports
|
||||||
@@ -16,7 +17,15 @@ import CustomTabList from '@core/components/mui/TabList'
|
|||||||
|
|
||||||
const AccountSettings = ({ tabContentList }: { tabContentList: { [key: string]: ReactElement } }) => {
|
const AccountSettings = ({ tabContentList }: { tabContentList: { [key: string]: ReactElement } }) => {
|
||||||
const t = useTranslations('accountSettings')
|
const t = useTranslations('accountSettings')
|
||||||
const [activeTab, setActiveTab] = useState('account')
|
const searchParams = useSearchParams()
|
||||||
|
const initialTab = searchParams.get('tab') ?? 'account'
|
||||||
|
const [activeTab, setActiveTab] = useState(initialTab)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const nextTab = searchParams.get('tab') ?? 'account'
|
||||||
|
|
||||||
|
setActiveTab(nextTab)
|
||||||
|
}, [searchParams])
|
||||||
|
|
||||||
const handleChange = (event: SyntheticEvent, value: string) => {
|
const handleChange = (event: SyntheticEvent, value: string) => {
|
||||||
setActiveTab(value)
|
setActiveTab(value)
|
||||||
@@ -35,14 +44,15 @@ const AccountSettings = ({ tabContentList }: { tabContentList: { [key: string]:
|
|||||||
icon={<i className='tabler-bookmark' />}
|
icon={<i className='tabler-bookmark' />}
|
||||||
iconPosition='start'
|
iconPosition='start'
|
||||||
value='billing-plans'
|
value='billing-plans'
|
||||||
/>
|
>
|
||||||
|
*/}
|
||||||
<Tab
|
<Tab
|
||||||
label='Notifications'
|
label='Notifications'
|
||||||
icon={<i className='tabler-bell' />}
|
icon={<i className='tabler-bell' />}
|
||||||
iconPosition='start'
|
iconPosition='start'
|
||||||
value='notifications'
|
value='notifications'
|
||||||
/>
|
/>
|
||||||
<Tab label='Connections' icon={<i className='tabler-link' />} iconPosition='start' value='connections' /> */}
|
{/*<Tab label='Connections' icon={<i className='tabler-link' />} iconPosition='start' value='connections' /> */}
|
||||||
</CustomTabList>
|
</CustomTabList>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid size={{ xs: 12 }}>
|
<Grid size={{ xs: 12 }}>
|
||||||
|
|||||||
@@ -1,126 +1,172 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useTranslations } from 'next-intl'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
// MUI Imports
|
import Alert from '@mui/material/Alert'
|
||||||
import Card from '@mui/material/Card'
|
import Box from '@mui/material/Box'
|
||||||
import CardHeader from '@mui/material/CardHeader'
|
|
||||||
import CardContent from '@mui/material/CardContent'
|
|
||||||
import Typography from '@mui/material/Typography'
|
|
||||||
import Checkbox from '@mui/material/Checkbox'
|
|
||||||
import MenuItem from '@mui/material/MenuItem'
|
|
||||||
import Grid from '@mui/material/Grid2'
|
|
||||||
import Button from '@mui/material/Button'
|
import Button from '@mui/material/Button'
|
||||||
|
import Card from '@mui/material/Card'
|
||||||
|
import CardContent from '@mui/material/CardContent'
|
||||||
|
import CardHeader from '@mui/material/CardHeader'
|
||||||
|
import Chip from '@mui/material/Chip'
|
||||||
|
import CircularProgress from '@mui/material/CircularProgress'
|
||||||
|
import Divider from '@mui/material/Divider'
|
||||||
|
import Pagination from '@mui/material/Pagination'
|
||||||
|
import Stack from '@mui/material/Stack'
|
||||||
|
import Typography from '@mui/material/Typography'
|
||||||
|
|
||||||
// Component Imports
|
import { format } from 'date-fns'
|
||||||
import Link from '@components/Link'
|
|
||||||
import Form from '@components/Form'
|
|
||||||
import CustomTextField from '@core/components/mui/TextField'
|
|
||||||
|
|
||||||
// Style Imports
|
import { useFarmHub } from '@/hooks/useFarmHub'
|
||||||
import tableStyles from '@core/styles/table.module.css'
|
import { notificationsService, type NotificationItem } from '@/libs/api/services/notificationsService'
|
||||||
|
|
||||||
type TableDataType = {
|
const PAGE_SIZE = 10
|
||||||
type: string
|
|
||||||
app: boolean
|
const getLevelColor = (level: NotificationItem['level']) => {
|
||||||
email: boolean
|
switch (level) {
|
||||||
browser: boolean
|
case 'critical':
|
||||||
|
return 'error'
|
||||||
|
case 'warning':
|
||||||
|
return 'warning'
|
||||||
|
default:
|
||||||
|
return 'info'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vars
|
|
||||||
const tableData: TableDataType[] = [
|
|
||||||
{
|
|
||||||
app: true,
|
|
||||||
email: true,
|
|
||||||
browser: true,
|
|
||||||
type: 'New for you'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
app: true,
|
|
||||||
email: true,
|
|
||||||
browser: true,
|
|
||||||
type: 'Account activity'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
app: false,
|
|
||||||
email: true,
|
|
||||||
browser: true,
|
|
||||||
type: 'A new browser used to sign in'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
app: false,
|
|
||||||
email: true,
|
|
||||||
browser: false,
|
|
||||||
type: 'A new device is linked'
|
|
||||||
}
|
}
|
||||||
]
|
|
||||||
|
|
||||||
const Notifications = () => {
|
const Notifications = () => {
|
||||||
const t = useTranslations('accountSettings')
|
const { farmHub } = useFarmHub()
|
||||||
const tNotif = useTranslations('accountSettings.notifications')
|
const farmUuid = farmHub?.farm_uuid ?? null
|
||||||
|
|
||||||
|
const [notifications, setNotifications] = useState<NotificationItem[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [markingAll, setMarkingAll] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [count, setCount] = useState(0)
|
||||||
|
|
||||||
|
const unreadCount = useMemo(() => notifications.filter(item => !item.is_read).length, [notifications])
|
||||||
|
const latestSinceId = useMemo(() => notifications.reduce((latest, item) => Math.max(latest, item.since_id), 0), [notifications])
|
||||||
|
const totalPages = useMemo(() => Math.max(1, Math.ceil(count / PAGE_SIZE)), [count])
|
||||||
|
|
||||||
|
const loadNotifications = useCallback(
|
||||||
|
async (nextPage: number) => {
|
||||||
|
if (!farmUuid) {
|
||||||
|
setNotifications([])
|
||||||
|
setCount(0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await notificationsService.list({ farmUuid, page: nextPage, pageSize: PAGE_SIZE })
|
||||||
|
|
||||||
|
setNotifications(Array.isArray(response.results?.data) ? response.results.data : [])
|
||||||
|
setCount(typeof response.count === 'number' ? response.count : 0)
|
||||||
|
} catch (loadError) {
|
||||||
|
setError(loadError instanceof Error ? loadError.message : 'Unable to load notifications.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[farmUuid]
|
||||||
|
)
|
||||||
|
|
||||||
|
const markAllAsRead = useCallback(async () => {
|
||||||
|
if (!farmUuid || latestSinceId <= 0 || unreadCount === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setMarkingAll(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await notificationsService.markAsRead({ farmUuid, sliceId: latestSinceId })
|
||||||
|
setNotifications(current => current.map(item => ({ ...item, is_read: true })))
|
||||||
|
} catch (markError) {
|
||||||
|
setError(markError instanceof Error ? markError.message : 'Unable to mark notifications as read.')
|
||||||
|
} finally {
|
||||||
|
setMarkingAll(false)
|
||||||
|
}
|
||||||
|
}, [farmUuid, latestSinceId, unreadCount])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1)
|
||||||
|
}, [farmUuid])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadNotifications(page)
|
||||||
|
}, [loadNotifications, page])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader
|
<CardHeader
|
||||||
title={tNotif('recentDevices')}
|
title='Notifications'
|
||||||
subheader={
|
subheader='All notifications for the currently selected farm are listed here.'
|
||||||
<>
|
action={
|
||||||
{tNotif('permissionRequest')}
|
<Stack direction='row' spacing={2} alignItems='center' className='flex-wrap justify-end'>
|
||||||
<Link className='text-primary'> {tNotif('requestPermission')}</Link>
|
<Chip color={unreadCount > 0 ? 'error' : 'default'} label={`${unreadCount} unread`} />
|
||||||
</>
|
<Button variant='outlined' onClick={() => void loadNotifications(page)} disabled={loading || !farmUuid}>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
<Button variant='contained' onClick={() => void markAllAsRead()} disabled={markingAll || unreadCount === 0 || !farmUuid}>
|
||||||
|
Mark page as read
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Form>
|
|
||||||
<div className='overflow-x-auto'>
|
<CardContent className='flex flex-col gap-6'>
|
||||||
<table className={tableStyles.table}>
|
<Divider />
|
||||||
<thead>
|
|
||||||
<tr>
|
{!farmUuid ? (
|
||||||
<th>{tNotif('type')}</th>
|
<Alert severity='info'>برای دیدن نوتیفیکیشن ها، ابتدا یک مزرعه فعال انتخاب کنید.</Alert>
|
||||||
<th>{t('email')}</th>
|
) : error ? (
|
||||||
<th>Browser</th>
|
<Alert severity='error'>{error}</Alert>
|
||||||
<th>App</th>
|
) : loading ? (
|
||||||
</tr>
|
<div className='flex items-center justify-center py-10'>
|
||||||
</thead>
|
<CircularProgress />
|
||||||
<tbody className='border-be'>
|
|
||||||
{tableData.map((data, index) => (
|
|
||||||
<tr key={index}>
|
|
||||||
<td>
|
|
||||||
<Typography color='text.primary'>{data.type}</Typography>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<Checkbox defaultChecked={data.email} />
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<Checkbox defaultChecked={data.browser} />
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<Checkbox defaultChecked={data.app} />
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
<CardContent>
|
) : notifications.length === 0 ? (
|
||||||
<Typography className='mbe-6 font-medium'>{tNotif('whenToSend')}</Typography>
|
<Alert severity='success'>هنوز نوتیفیکیشنی برای این مزرعه ثبت نشده است.</Alert>
|
||||||
<Grid container spacing={6}>
|
) : (
|
||||||
<Grid size={{ xs: 12, sm: 6, md: 4 }}>
|
<>
|
||||||
<CustomTextField select fullWidth defaultValue='online'>
|
<div className='flex flex-col gap-4'>
|
||||||
<MenuItem value='online'>{tNotif('onlyWhenOnline')}</MenuItem>
|
{notifications.map(notification => (
|
||||||
<MenuItem value='anytime'>{tNotif('anytime')}</MenuItem>
|
<Card key={notification.uuid} variant='outlined'>
|
||||||
</CustomTextField>
|
<CardContent className='flex flex-col gap-3'>
|
||||||
</Grid>
|
<div className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between'>
|
||||||
<Grid size={{ xs: 12 }} className='flex gap-4 flex-wrap'>
|
<div className='flex flex-col gap-1'>
|
||||||
<Button variant='contained' type='submit'>
|
<Typography variant='h6'>{notification.title}</Typography>
|
||||||
{t('saveChanges')}
|
<Typography color='text.secondary'>{notification.message}</Typography>
|
||||||
</Button>
|
</div>
|
||||||
<Button variant='tonal' color='secondary' type='reset'>
|
<Stack direction='row' spacing={1} alignItems='center' className='flex-wrap justify-end'>
|
||||||
{tNotif('discard')}
|
<Chip size='small' color={notification.is_read ? 'default' : 'error'} label={notification.is_read ? 'Read' : 'Unread'} />
|
||||||
</Button>
|
<Chip size='small' color={getLevelColor(notification.level)} label={notification.level} />
|
||||||
</Grid>
|
</Stack>
|
||||||
</Grid>
|
</div>
|
||||||
|
|
||||||
|
<Box className='flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between'>
|
||||||
|
<Typography variant='caption' color='text.disabled'>
|
||||||
|
{format(new Date(notification.created_at), 'yyyy-MM-dd HH:mm')}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant='caption' color='text.disabled'>
|
||||||
|
Since ID: {notification.since_id}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{totalPages > 1 ? (
|
||||||
|
<Box className='flex items-center justify-center pt-2'>
|
||||||
|
<Pagination count={totalPages} page={page} color='primary' onChange={(_, value) => setPage(value)} />
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Form>
|
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user