diff --git a/src/frontend/src/components/nav/Header.tsx b/src/frontend/src/components/nav/Header.tsx index 6b403db455..15edb27a71 100644 --- a/src/frontend/src/components/nav/Header.tsx +++ b/src/frontend/src/components/nav/Header.tsx @@ -1,14 +1,18 @@ -import { ActionIcon, Container, Group, Tabs } from '@mantine/core'; +import { ActionIcon, Container, Group, Indicator, Tabs } from '@mantine/core'; import { useDisclosure } from '@mantine/hooks'; -import { IconSearch } from '@tabler/icons-react'; +import { IconBell, IconSearch } from '@tabler/icons-react'; +import { useQuery } from '@tanstack/react-query'; +import { useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; +import { api } from '../../App'; import { navTabs as mainNavTabs } from '../../defaults/links'; import { InvenTreeStyle } from '../../globalStyle'; import { ScanButton } from '../items/ScanButton'; import { MainMenu } from './MainMenu'; import { NavHoverMenu } from './NavHoverMenu'; import { NavigationDrawer } from './NavigationDrawer'; +import { NotificationDrawer } from './NotificationDrawer'; import { SearchDrawer } from './SearchDrawer'; export function Header() { @@ -20,10 +24,46 @@ export function Header() { { open: openSearchDrawer, close: closeSearchDrawer } ] = useDisclosure(false); + const [ + notificationDrawerOpened, + { open: openNotificationDrawer, close: closeNotificationDrawer } + ] = useDisclosure(false); + + const [notificationCount, setNotificationCount] = useState(0); + + // Fetch number of notifications for the current user + const notifications = useQuery({ + queryKey: ['notification-count'], + queryFn: async () => { + return api + .get('/notifications/', { + params: { + read: false, + limit: 1 + } + }) + .then((response) => { + setNotificationCount(response.data.count); + return response.data; + }) + .catch((error) => { + console.error('Error fetching notifications:', error); + return error; + }); + }, + refetchInterval: 30000, + refetchOnMount: true, + refetchOnWindowFocus: false + }); + return (
+ @@ -35,6 +75,17 @@ export function Header() { + + + + + diff --git a/src/frontend/src/components/nav/MainMenu.tsx b/src/frontend/src/components/nav/MainMenu.tsx index d116b32a46..1bfd080cc7 100644 --- a/src/frontend/src/components/nav/MainMenu.tsx +++ b/src/frontend/src/components/nav/MainMenu.tsx @@ -34,10 +34,6 @@ export function MainMenu() { - }> - Notifications - - }> Profile diff --git a/src/frontend/src/components/nav/NotificationDrawer.tsx b/src/frontend/src/components/nav/NotificationDrawer.tsx new file mode 100644 index 0000000000..f2e0391047 --- /dev/null +++ b/src/frontend/src/components/nav/NotificationDrawer.tsx @@ -0,0 +1,111 @@ +import { t } from '@lingui/macro'; +import { + ActionIcon, + Divider, + Drawer, + LoadingOverlay, + Space, + Tooltip +} from '@mantine/core'; +import { Badge, Group, Stack, Text } from '@mantine/core'; +import { IconBellCheck, IconBellPlus, IconBookmark } from '@tabler/icons-react'; +import { IconMacro } from '@tabler/icons-react'; +import { useQuery } from '@tanstack/react-query'; +import { useNavigate } from 'react-router-dom'; + +import { api } from '../../App'; + +/** + * Construct a notification drawer. + */ +export function NotificationDrawer({ + opened, + onClose +}: { + opened: boolean; + onClose: () => void; +}) { + const navigate = useNavigate(); + + const notificationQuery = useQuery({ + enabled: opened, + queryKey: ['notifications', opened], + queryFn: async () => + api + .get('/notifications/', { + params: { + read: false, + limit: 10 + } + }) + .then((response) => response.data) + .catch((error) => { + console.error('Error fetching notifications:', error); + return error; + }), + refetchOnMount: false, + refetchOnWindowFocus: false + }); + + return ( + + {t`Notifications`} + { + onClose(); + navigate('/notifications/'); + }} + > + + + + } + > + + + + {notificationQuery.data?.results.map((notification: any) => ( + + + {notification.target?.name ?? 'target'} + {notification.age_human ?? 'name'} + + + { + api + .patch(`/notifications/${notification.pk}/`, { + read: true + }) + .then((response) => { + notificationQuery.refetch(); + }); + }} + > + + + + + + ))} + + + ); +} diff --git a/src/frontend/src/components/tables/notifications/NotificationsTable.tsx b/src/frontend/src/components/tables/notifications/NotificationsTable.tsx new file mode 100644 index 0000000000..08212a6363 --- /dev/null +++ b/src/frontend/src/components/tables/notifications/NotificationsTable.tsx @@ -0,0 +1,52 @@ +import { t } from '@lingui/macro'; +import { useMemo } from 'react'; + +import { TableColumn } from '../Column'; +import { InvenTreeTable } from '../InvenTreeTable'; +import { RowAction } from '../RowActions'; + +export function NotificationTable({ + params, + refreshId, + tableKey, + actions +}: { + params: any; + refreshId: string; + tableKey: string; + actions: (record: any) => RowAction[]; +}) { + const columns: TableColumn[] = useMemo(() => { + return [ + { + accessor: 'age_human', + title: t`Age`, + sortable: true + }, + { + accessor: 'category', + title: t`Category`, + sortable: true + }, + { + accessor: `name`, + title: t`Notification` + }, + { + accessor: 'message', + title: t`Message` + } + ]; + }, []); + + return ( + + ); +} diff --git a/src/frontend/src/pages/Notifications.tsx b/src/frontend/src/pages/Notifications.tsx new file mode 100644 index 0000000000..e6a8b40f2c --- /dev/null +++ b/src/frontend/src/pages/Notifications.tsx @@ -0,0 +1,92 @@ +import { t } from '@lingui/macro'; +import { Stack } from '@mantine/core'; +import { IconBellCheck, IconBellExclamation } from '@tabler/icons-react'; +import { useMemo } from 'react'; + +import { api } from '../App'; +import { StylishText } from '../components/items/StylishText'; +import { PanelGroup } from '../components/nav/PanelGroup'; +import { NotificationTable } from '../components/tables/notifications/NotificationsTable'; +import { useTableRefresh } from '../hooks/TableRefresh'; + +export default function NotificationsPage() { + const unreadRefresh = useTableRefresh(); + const historyRefresh = useTableRefresh(); + + const notificationPanels = useMemo(() => { + return [ + { + name: 'notifications-unread', + label: t`Notifications`, + icon: , + content: ( + [ + { + title: t`Mark as read`, + onClick: () => { + api + .patch(`/notifications/${record.pk}/`, { + read: true + }) + .then((response) => { + unreadRefresh.refreshTable(); + }); + } + } + ]} + /> + ) + }, + { + name: 'notifications-history', + label: t`History`, + icon: , + content: ( + [ + { + title: t`Mark as unread`, + onClick: () => { + api + .patch(`/notifications/${record.pk}/`, { + read: false + }) + .then((response) => { + historyRefresh.refreshTable(); + }); + } + }, + { + title: t`Delete`, + color: 'red', + onClick: () => { + api + .delete(`/notifications/${record.pk}/`) + .then((response) => { + historyRefresh.refreshTable(); + }); + } + } + ]} + /> + ) + } + ]; + }, [historyRefresh, unreadRefresh]); + + return ( + <> + + {t`Notifications`} + + + + ); +} diff --git a/src/frontend/src/router.tsx b/src/frontend/src/router.tsx index 2a757ad618..10c8fbc5d7 100644 --- a/src/frontend/src/router.tsx +++ b/src/frontend/src/router.tsx @@ -19,6 +19,11 @@ export const Dashboard = Loadable( lazy(() => import('./pages/Index/Dashboard')) ); export const ErrorPage = Loadable(lazy(() => import('./pages/ErrorPage'))); + +export const Notifications = Loadable( + lazy(() => import('./pages/Notifications')) +); + export const Profile = Loadable( lazy(() => import('./pages/Index/Profile/Profile')) ); @@ -60,6 +65,10 @@ export const router = createBrowserRouter( path: 'dashboard/', element: }, + { + path: 'notifications/', + element: + }, { path: 'playground/', element: