diff --git a/src/App.jsx b/src/App.jsx index 99d55a675..7ea66fd87 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -15,6 +15,7 @@ import InactivityHandler from "./helper/inactivityhandler"; import Examination from "./Modules/Examination/examination"; import Database from "./Modules/Database/database"; import ProgrammeCurriculumRoutes from "./Modules/Program_curriculum/programmCurriculum"; +import NotificationView from "./routes/notificationRoutes"; import NotFoundPage from "./components/NotFoundPage"; const theme = createTheme({ @@ -46,6 +47,14 @@ export default function App() { } /> + + + + } + /> + item?.data?.flag === "announcement" || + item?.data?.type === "announcement"; + setNotificationsList( - notificationsData.filter( - (item) => item.data?.flag !== "announcement", - ), + notificationsData.filter((item) => !isAnnouncement(item)), ); setAnnouncementsList( - notificationsData.filter( - (item) => item.data?.flag === "announcement", - ), + notificationsData.filter((item) => isAnnouncement(item)), ); } catch (error) { console.error("Error fetching dashboard data:", error); @@ -408,4 +408,4 @@ NotificationItem.propTypes = { markAsUnread: PropTypes.func.isRequired, deleteNotification: PropTypes.func.isRequired, loading: PropTypes.number.isRequired, -}; \ No newline at end of file +}; diff --git a/src/Modules/Notification/Announcements.jsx b/src/Modules/Notification/Announcements.jsx new file mode 100644 index 000000000..7b1ef7701 --- /dev/null +++ b/src/Modules/Notification/Announcements.jsx @@ -0,0 +1,81 @@ +import { useEffect, useState } from "react"; +import { notificationAPI, notificationUtils } from "./api"; + +function Announcements() { + const [announcementsList, setAnnouncementsList] = useState([]); + const [loading, setLoading] = useState(false); + const [loadingId, setLoadingId] = useState(-1); + + // Fetch announcements on mount + useEffect(() => { + const fetchData = async () => { + try { + setLoading(true); + const data = await notificationAPI.fetchAll(); + const { announcements } = notificationUtils.separate(data); + setAnnouncementsList(announcements); + } catch (err) { + console.error("Failed to fetch announcements:", err); + } finally { + setLoading(false); + } + }; + fetchData(); + }, []); + + // Mark as read + const handleMarkAsRead = async (announcementId) => { + try { + setLoadingId(announcementId); + await notificationAPI.markAsRead(announcementId); + setAnnouncementsList((prev) => + prev.map((a) => + a.id === announcementId ? { ...a, unread: false } : a, + ), + ); + } catch (err) { + console.error("Error:", err); + } finally { + setLoadingId(-1); + } + }; + + // Mark as unread + const handleMarkAsUnread = async (announcementId) => { + try { + setLoadingId(announcementId); + await notificationAPI.markAsUnread(announcementId); + setAnnouncementsList((prev) => + prev.map((a) => (a.id === announcementId ? { ...a, unread: true } : a)), + ); + } catch (err) { + console.error("Error:", err); + } finally { + setLoadingId(-1); + } + }; + + // Delete announcement + const handleDelete = async (announcementId) => { + try { + await notificationAPI.delete(announcementId); + setAnnouncementsList((prev) => + prev.filter((a) => a.id !== announcementId), + ); + } catch (err) { + console.error("Error:", err); + } + }; + + return { + announcementsList, + setAnnouncementsList, + loading, + loadingId, + markAsRead: handleMarkAsRead, + markAsUnread: handleMarkAsUnread, + deleteNotification: handleDelete, + }; +} + +export default Announcements; diff --git a/src/Modules/Notification/Notifications.jsx b/src/Modules/Notification/Notifications.jsx new file mode 100644 index 000000000..21b0bb73b --- /dev/null +++ b/src/Modules/Notification/Notifications.jsx @@ -0,0 +1,77 @@ +import { useEffect, useState } from "react"; +import { notificationAPI, notificationUtils } from "./api"; + +function Notifications() { + const [notificationsList, setNotificationsList] = useState([]); + const [loading, setLoading] = useState(false); + const [loadingId, setLoadingId] = useState(-1); + + // Fetch notifications on mount + useEffect(() => { + const fetchData = async () => { + try { + setLoading(true); + const data = await notificationAPI.fetchAll(); + const { notifications } = notificationUtils.separate(data); + setNotificationsList(notifications); + } catch (err) { + console.error("Failed to fetch notifications:", err); + } finally { + setLoading(false); + } + }; + fetchData(); + }, []); + + // Mark as read + const handleMarkAsRead = async (notifId) => { + try { + setLoadingId(notifId); + await notificationAPI.markAsRead(notifId); + setNotificationsList((prev) => + prev.map((n) => (n.id === notifId ? { ...n, unread: false } : n)), + ); + } catch (err) { + console.error("Error:", err); + } finally { + setLoadingId(-1); + } + }; + + // Mark as unread + const handleMarkAsUnread = async (notifId) => { + try { + setLoadingId(notifId); + await notificationAPI.markAsUnread(notifId); + setNotificationsList((prev) => + prev.map((n) => (n.id === notifId ? { ...n, unread: true } : n)), + ); + } catch (err) { + console.error("Error:", err); + } finally { + setLoadingId(-1); + } + }; + + // Delete notification + const handleDelete = async (notifId) => { + try { + await notificationAPI.delete(notifId); + setNotificationsList((prev) => prev.filter((n) => n.id !== notifId)); + } catch (err) { + console.error("Error:", err); + } + }; + + return { + notificationsList, + setNotificationsList, + loading, + loadingId, + markAsRead: handleMarkAsRead, + markAsUnread: handleMarkAsUnread, + deleteNotification: handleDelete, + }; +} + +export default Notifications; diff --git a/src/Modules/Notification/NotificationsPage.jsx b/src/Modules/Notification/NotificationsPage.jsx new file mode 100644 index 000000000..de4d29e8e --- /dev/null +++ b/src/Modules/Notification/NotificationsPage.jsx @@ -0,0 +1,106 @@ +import { useMemo, useState } from "react"; +import { Flex } from "@mantine/core"; +import CustomBreadcrumbs from "../../components/Breadcrumbs.jsx"; +import ModuleTabs from "../../components/moduleTabs.jsx"; +import { useNotifications } from "./hooks/useNotifications"; +import { sortNotifications, getUnreadCount } from "./utils/notificationUtils"; +import NotificationList from "./components/NotificationList"; +import NotificationFilters from "./components/NotificationFilters"; + +function NotificationsPage() { + const { + notificationsList, + announcementsList, + loading, + markAsRead, + markAsUnread, + deleteNotification, + } = useNotifications(); + + const [activeTab, setActiveTab] = useState("0"); + const [sortedBy, setSortedBy] = useState("Most Recent"); + const [loadingId, setLoadingId] = useState(-1); + + // Tab configuration + const tabItems = [{ title: "Notifications" }, { title: "Announcements" }]; + + // Calculate badge counts + const notificationBadgeCount = getUnreadCount(notificationsList); + const announcementBadgeCount = getUnreadCount(announcementsList); + const badges = [notificationBadgeCount, announcementBadgeCount]; + + // Get current tab data + const notificationsToDisplay = + activeTab === "1" ? announcementsList : notificationsList; + + // Sort notifications + const sortedNotifications = useMemo( + () => sortNotifications(notificationsToDisplay, sortedBy), + [sortedBy, notificationsToDisplay], + ); + + // Handlers with loading state management + const handleMarkAsRead = async (notifId) => { + try { + setLoadingId(notifId); + await markAsRead(notifId); + } finally { + setLoadingId(-1); + } + }; + + const handleMarkAsUnread = async (notifId) => { + try { + setLoadingId(notifId); + await markAsUnread(notifId); + } finally { + setLoadingId(-1); + } + }; + + const handleDelete = async (notifId) => { + try { + setLoadingId(notifId); + await deleteNotification(notifId); + } catch (err) { + console.error("Failed to delete:", err); + } finally { + setLoadingId(-1); + } + }; + + return ( + <> + + + {/* Header with tabs and filters */} + + + + + + + {/* Notifications list */} + + + ); +} + +export default NotificationsPage; diff --git a/src/Modules/Notification/api.js b/src/Modules/Notification/api.js new file mode 100644 index 000000000..f95b89c6c --- /dev/null +++ b/src/Modules/Notification/api.js @@ -0,0 +1,110 @@ +import axios from "axios"; +import { + notificationReadRoute, + notificationDeleteRoute, + notificationUnreadRoute, + getNotificationsRoute, +} from "../../routes/dashboardRoutes"; + +const getAuthToken = () => localStorage.getItem("authToken"); + +const getAuthHeaders = () => ({ + headers: { Authorization: `Token ${getAuthToken()}` }, +}); + +/** + * Notification API instance + * All backend communication for notifications + */ +export const notificationAPI = { + fetchAll: async () => { + try { + const token = getAuthToken(); + if (!token) throw new Error("No authentication token found!"); + const { data } = await axios.get(getNotificationsRoute, getAuthHeaders()); + return data.notifications || []; + } catch (error) { + console.error("Error fetching notifications:", error); + throw error; + } + }, + + markAsRead: async (notificationId) => { + try { + return await axios.post( + notificationReadRoute, + { id: notificationId }, + getAuthHeaders(), + ); + } catch (error) { + console.error("Error marking as read:", error); + throw error; + } + }, + + markAsUnread: async (notificationId) => { + try { + return await axios.post( + notificationUnreadRoute, + { id: notificationId }, + getAuthHeaders(), + ); + } catch (error) { + console.error("Error marking as unread:", error); + throw error; + } + }, + + delete: async (notificationId) => { + try { + return await axios.delete( + notificationDeleteRoute.replace("{id}", notificationId), + getAuthHeaders(), + ); + } catch (error) { + console.error("Error deleting notification:", error); + throw error; + } + }, +}; + +/** + * Utility functions for notification data + */ +export const notificationUtils = { + isAnnouncement: (notification) => + notification?.data?.flag === "announcement" || + notification?.data?.type === "announcement", + + parseData: (notification) => ({ + ...notification, + data: + typeof notification.data === "string" + ? JSON.parse(notification.data.replace(/'/g, '"')) + : notification.data, + }), + + separate: (notifications) => { + const parsed = notifications.map(notificationUtils.parseData); + return { + notifications: parsed.filter((n) => !notificationUtils.isAnnouncement(n)), + announcements: parsed.filter((n) => notificationUtils.isAnnouncement(n)), + }; + }, + + sort: (notifications, sortBy) => { + const sortMap = { + "Most Recent": (a, b) => new Date(b.timestamp) - new Date(a.timestamp), + Tags: (a, b) => + (a.data?.module || "").localeCompare(b.data?.module || ""), + Title: (a, b) => (a.verb || "").localeCompare(b.verb || ""), + }; + const sortFn = sortMap[sortBy] || sortMap["Most Recent"]; + return [...notifications].sort(sortFn); + }, + + getUnreadCount: (notifications) => + notifications.filter((n) => !n.deleted && n.unread).length, + + filterActive: (notifications) => notifications.filter((n) => !n.deleted), +}; diff --git a/src/Modules/Notification/components/NotificationCard.jsx b/src/Modules/Notification/components/NotificationCard.jsx new file mode 100644 index 000000000..d4c4eac0c --- /dev/null +++ b/src/Modules/Notification/components/NotificationCard.jsx @@ -0,0 +1,95 @@ +import PropTypes from "prop-types"; +import { + Button, + CloseButton, + Divider, + Flex, + Grid, + Paper, + Text, + Badge, +} from "@mantine/core"; + +export function NotificationCard({ + notification, + onMarkAsRead, + onDelete, + onMarkAsUnread, + loadingId, +}) { + const { module } = notification.data || {}; + const isLoading = loadingId === notification.id; + + return ( + + + + + + + {notification.verb} + + {module || "N/A"} + + + {new Date(notification.timestamp).toLocaleDateString()} + + + + onDelete(notification.id)} + /> + + + + + {notification.description || "No description available."} + + + + + + ); +} + +NotificationCard.propTypes = { + notification: PropTypes.shape({ + id: PropTypes.number.isRequired, + verb: PropTypes.string.isRequired, + description: PropTypes.string, + timestamp: PropTypes.string.isRequired, + data: PropTypes.shape({ + module: PropTypes.string, + }), + unread: PropTypes.bool.isRequired, + }).isRequired, + onMarkAsRead: PropTypes.func.isRequired, + onDelete: PropTypes.func.isRequired, + onMarkAsUnread: PropTypes.func.isRequired, + loadingId: PropTypes.number.isRequired, +}; diff --git a/src/Modules/Notification/components/NotificationFilters.jsx b/src/Modules/Notification/components/NotificationFilters.jsx new file mode 100644 index 000000000..095b540f1 --- /dev/null +++ b/src/Modules/Notification/components/NotificationFilters.jsx @@ -0,0 +1,41 @@ +import PropTypes from "prop-types"; +import { Flex, Select } from "@mantine/core"; +import { SortAscending } from "@phosphor-icons/react"; +import classes from "../../Dashboard/Dashboard.module.css"; + +const SORT_CATEGORIES = ["Most Recent", "Tags", "Title"]; + +function NotificationFilters({ sortedBy, onSortChange }) { + return ( + + } + data={["Most Recent", "Tags", "Title"]} + value={sortedBy} + onChange={setSortedBy} + placeholder="Sort By" + /> + + + + {/* Notifications/Announcements Grid */} + {loading ? ( + + + + ) : activeNotifications.length === 0 ? ( + + ) : ( + + {activeNotifications.map((item) => ( + + ))} + + )} + + {canManageAnnouncements && activeTab === "1" && ( + + + + Manage Announcements + + +