This commit is contained in:
2026-04-05 05:09:32 +03:30
parent ae1bbc126f
commit 3f15890393
7 changed files with 600 additions and 569 deletions
@@ -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 (
<CustomAvatar color={avatarColor} skin={avatarSkin || 'light-static'}> return (
{avatarText || getInitials(title)} <CustomAvatar color={avatarColor} skin={avatarSkin || 'light-static'}>
</CustomAvatar> {avatarText || getInitials(title)}
) </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-96 !mbs-3 z-[1] max-bs-[550px] bs-[550px]' })} : {
className: 'is-[380px] !mbs-4 z-[1] max-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'>
{getAvatar({ avatarImage, avatarIcon, title, avatarText, avatarColor, avatarSkin })} <Typography className='font-medium' color='text.primary'>
<div className='flex flex-col flex-auto'> {notification.title}
<Typography variant='body2' className='font-medium mbe-1' color='text.primary'> </Typography>
{title} <Typography variant='body2' color='text.secondary'>
</Typography> {notification.message}
<Typography variant='caption' color='text.secondary' className='mbe-2'> </Typography>
{subtitle} <Typography variant='caption' color='text.disabled'>
</Typography> {formatDistanceToNow(new Date(notification.created_at), { addSuffix: true })}
<Typography variant='caption' color='text.disabled'> </Typography>
{time} </div>
</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>
)
})}
</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)
@@ -121,12 +24,10 @@ const NavbarContent = () => {
<NavSearch /> <NavSearch />
</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>
) )
+14 -4
View File
@@ -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'> </div>
{tableData.map((data, index) => ( ) : notifications.length === 0 ? (
<tr key={index}> <Alert severity='success'>هنوز نوتیفیکیشنی برای این مزرعه ثبت نشده است.</Alert>
<td> ) : (
<Typography color='text.primary'>{data.type}</Typography> <>
</td> <div className='flex flex-col gap-4'>
<td> {notifications.map(notification => (
<Checkbox defaultChecked={data.email} /> <Card key={notification.uuid} variant='outlined'>
</td> <CardContent className='flex flex-col gap-3'>
<td> <div className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between'>
<Checkbox defaultChecked={data.browser} /> <div className='flex flex-col gap-1'>
</td> <Typography variant='h6'>{notification.title}</Typography>
<td> <Typography color='text.secondary'>{notification.message}</Typography>
<Checkbox defaultChecked={data.app} /> </div>
</td> <Stack direction='row' spacing={1} alignItems='center' className='flex-wrap justify-end'>
</tr> <Chip size='small' color={notification.is_read ? 'default' : 'error'} label={notification.is_read ? 'Read' : 'Unread'} />
<Chip size='small' color={getLevelColor(notification.level)} label={notification.level} />
</Stack>
</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>
))} ))}
</tbody> </div>
</table>
</div> {totalPages > 1 ? (
<CardContent> <Box className='flex items-center justify-center pt-2'>
<Typography className='mbe-6 font-medium'>{tNotif('whenToSend')}</Typography> <Pagination count={totalPages} page={page} color='primary' onChange={(_, value) => setPage(value)} />
<Grid container spacing={6}> </Box>
<Grid size={{ xs: 12, sm: 6, md: 4 }}> ) : null}
<CustomTextField select fullWidth defaultValue='online'> </>
<MenuItem value='online'>{tNotif('onlyWhenOnline')}</MenuItem> )}
<MenuItem value='anytime'>{tNotif('anytime')}</MenuItem> </CardContent>
</CustomTextField>
</Grid>
<Grid size={{ xs: 12 }} className='flex gap-4 flex-wrap'>
<Button variant='contained' type='submit'>
{t('saveChanges')}
</Button>
<Button variant='tonal' color='secondary' type='reset'>
{tNotif('discard')}
</Button>
</Grid>
</Grid>
</CardContent>
</Form>
</Card> </Card>
) )
} }