From ccd2ba4587d6b911e1ca7d4b0e16377dda94dec4 Mon Sep 17 00:00:00 2001 From: raghuwanshi313 Date: Tue, 7 Apr 2026 15:46:00 +0530 Subject: [PATCH 1/2] fix: notifications module --- src/App.jsx | 9 ++ src/Modules/Notification/Announcements.jsx | 81 ++++++++++++ src/Modules/Notification/Notifications.jsx | 77 +++++++++++ .../Notification/NotificationsPage.jsx | 103 +++++++++++++++ src/Modules/Notification/api.js | 107 +++++++++++++++ .../components/NotificationCard.jsx | 95 ++++++++++++++ .../components/NotificationFilters.jsx | 41 ++++++ .../components/NotificationItem.jsx | 102 +++++++++++++++ .../components/NotificationList.jsx | 60 +++++++++ .../Notification/hooks/useNotifications.js | 87 +++++++++++++ src/Modules/Notification/index.js | 36 ++++++ .../Notification/redux/notificationSlice.js | 98 ++++++++++++++ .../services/notificationService.js | 82 ++++++++++++ .../Notification/utils/notificationUtils.js | 47 +++++++ src/routes/notificationRoutes/index.jsx | 122 ++++++++++++++++++ 15 files changed, 1147 insertions(+) create mode 100644 src/Modules/Notification/Announcements.jsx create mode 100644 src/Modules/Notification/Notifications.jsx create mode 100644 src/Modules/Notification/NotificationsPage.jsx create mode 100644 src/Modules/Notification/api.js create mode 100644 src/Modules/Notification/components/NotificationCard.jsx create mode 100644 src/Modules/Notification/components/NotificationFilters.jsx create mode 100644 src/Modules/Notification/components/NotificationItem.jsx create mode 100644 src/Modules/Notification/components/NotificationList.jsx create mode 100644 src/Modules/Notification/hooks/useNotifications.js create mode 100644 src/Modules/Notification/index.js create mode 100644 src/Modules/Notification/redux/notificationSlice.js create mode 100644 src/Modules/Notification/services/notificationService.js create mode 100644 src/Modules/Notification/utils/notificationUtils.js create mode 100644 src/routes/notificationRoutes/index.jsx 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() { } /> + + + + } + /> { + 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..c57de034c --- /dev/null +++ b/src/Modules/Notification/NotificationsPage.jsx @@ -0,0 +1,103 @@ +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 { + await deleteNotification(notifId); + } catch (err) { + console.error("Failed to delete:", err); + } + }; + + 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..4cf107700 --- /dev/null +++ b/src/Modules/Notification/api.js @@ -0,0 +1,107 @@ +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.post( + notificationDeleteRoute, + { id: notificationId }, + getAuthHeaders(), + ); + } catch (error) { + console.error("Error deleting notification:", error); + throw error; + } + }, +}; + +/** + * Utility functions for notification data + */ +export const notificationUtils = { + 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) => n.data?.flag !== "announcement"), + announcements: parsed.filter((n) => n.data?.flag === "announcement"), + }; + }, + + 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) => ( + + ))} + + )} + + ); +} + +export default NotificationView; From b67201d7b80e1a549dfb82f78bb57d93be739869 Mon Sep 17 00:00:00 2001 From: raghuwanshi313 Date: Thu, 7 May 2026 22:04:58 +0530 Subject: [PATCH 2/2] Update notifications and globals api --- .../Dashboard/dashboardNotifications.jsx | 14 +- .../Notification/NotificationsPage.jsx | 3 + src/Modules/Notification/api.js | 13 +- .../components/NotificationItem.jsx | 1 + .../services/notificationService.js | 5 +- .../Notification/utils/notificationUtils.js | 8 +- src/components/header.jsx | 45 +- src/routes/dashboardRoutes/index.jsx | 4 +- src/routes/notificationRoutes/index.jsx | 790 +++++++++++++++++- 9 files changed, 858 insertions(+), 25 deletions(-) diff --git a/src/Modules/Dashboard/dashboardNotifications.jsx b/src/Modules/Dashboard/dashboardNotifications.jsx index ca8565bda..90eedecbd 100644 --- a/src/Modules/Dashboard/dashboardNotifications.jsx +++ b/src/Modules/Dashboard/dashboardNotifications.jsx @@ -127,15 +127,15 @@ function Dashboard() { data: JSON.parse(item.data.replace(/'/g, '"')), })); + const isAnnouncement = (item) => + 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/NotificationsPage.jsx b/src/Modules/Notification/NotificationsPage.jsx index c57de034c..de4d29e8e 100644 --- a/src/Modules/Notification/NotificationsPage.jsx +++ b/src/Modules/Notification/NotificationsPage.jsx @@ -60,9 +60,12 @@ function NotificationsPage() { const handleDelete = async (notifId) => { try { + setLoadingId(notifId); await deleteNotification(notifId); } catch (err) { console.error("Failed to delete:", err); + } finally { + setLoadingId(-1); } }; diff --git a/src/Modules/Notification/api.js b/src/Modules/Notification/api.js index 4cf107700..f95b89c6c 100644 --- a/src/Modules/Notification/api.js +++ b/src/Modules/Notification/api.js @@ -57,9 +57,8 @@ export const notificationAPI = { delete: async (notificationId) => { try { - return await axios.post( - notificationDeleteRoute, - { id: notificationId }, + return await axios.delete( + notificationDeleteRoute.replace("{id}", notificationId), getAuthHeaders(), ); } catch (error) { @@ -73,6 +72,10 @@ export const notificationAPI = { * Utility functions for notification data */ export const notificationUtils = { + isAnnouncement: (notification) => + notification?.data?.flag === "announcement" || + notification?.data?.type === "announcement", + parseData: (notification) => ({ ...notification, data: @@ -84,8 +87,8 @@ export const notificationUtils = { separate: (notifications) => { const parsed = notifications.map(notificationUtils.parseData); return { - notifications: parsed.filter((n) => n.data?.flag !== "announcement"), - announcements: parsed.filter((n) => n.data?.flag === "announcement"), + notifications: parsed.filter((n) => !notificationUtils.isAnnouncement(n)), + announcements: parsed.filter((n) => notificationUtils.isAnnouncement(n)), }; }, diff --git a/src/Modules/Notification/components/NotificationItem.jsx b/src/Modules/Notification/components/NotificationItem.jsx index f27ec489f..776e55324 100644 --- a/src/Modules/Notification/components/NotificationItem.jsx +++ b/src/Modules/Notification/components/NotificationItem.jsx @@ -51,6 +51,7 @@ function NotificationItem({ style={{ cursor: "pointer" }} onClick={() => onDelete(notification.id)} aria-label="Delete notification" + disabled={isLoading} /> diff --git a/src/Modules/Notification/services/notificationService.js b/src/Modules/Notification/services/notificationService.js index 5d79caad3..b898fa0ad 100644 --- a/src/Modules/Notification/services/notificationService.js +++ b/src/Modules/Notification/services/notificationService.js @@ -68,9 +68,8 @@ export const notificationService = { */ deleteNotification: async (notificationId) => { try { - const response = await axios.post( - notificationDeleteRoute, - { id: notificationId }, + const response = await axios.delete( + notificationDeleteRoute.replace("{id}", notificationId), getAuthHeaders(), ); return response.data; diff --git a/src/Modules/Notification/utils/notificationUtils.js b/src/Modules/Notification/utils/notificationUtils.js index 5ee4d41d9..a4bb94e38 100644 --- a/src/Modules/Notification/utils/notificationUtils.js +++ b/src/Modules/Notification/utils/notificationUtils.js @@ -9,14 +9,18 @@ export const parseNotificationData = (notification) => ({ : notification.data, }); +export const isAnnouncementNotification = (notification) => + notification?.data?.flag === "announcement" || + notification?.data?.type === "announcement"; + /** * Separate notifications into regular notifications and announcements */ export const separateNotifications = (notifications) => { const parsed = notifications.map(parseNotificationData); return { - notifications: parsed.filter((n) => n.data?.flag !== "announcement"), - announcements: parsed.filter((n) => n.data?.flag === "announcement"), + notifications: parsed.filter((n) => !isAnnouncementNotification(n)), + announcements: parsed.filter((n) => isAnnouncementNotification(n)), }; }; diff --git a/src/components/header.jsx b/src/components/header.jsx index b2ed336ce..bfb4c24c2 100644 --- a/src/components/header.jsx +++ b/src/components/header.jsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { User, SignOut, Bell, UserSwitch } from "@phosphor-icons/react"; import { useNavigate } from "react-router-dom"; import { useDispatch, useSelector } from "react-redux"; @@ -24,13 +24,18 @@ import classes from "../Modules/Dashboard/Dashboard.module.css"; import avatarImage from "../assets/avatar.png"; import { setPfNo } from "../redux/pfNoSlice"; -import { logoutRoute, updateRoleRoute } from "../routes/dashboardRoutes"; +import { + logoutRoute, + updateRoleRoute, + getNotificationsRoute, +} from "../routes/dashboardRoutes"; function Header({ opened, toggleSidebar }) { const [popoverOpened, setPopoverOpened] = useState(false); const username = useSelector((state) => state.user.username); const roles = useSelector((state) => state.user.roles); const role = useSelector((state) => state.user.role); + const [unreadCount, setUnreadCount] = useState(0); const navigate = useNavigate(); const dispatch = useDispatch(); // const queryclient = useQueryClient(); @@ -65,7 +70,7 @@ function Header({ opened, toggleSidebar }) { console.log(response.data.message); dispatch(setRole(newRole)); dispatch(setCurrentAccessibleModules()); - navigate('/dashboard') + navigate("/dashboard"); } catch (error) { console.error("Error updating last selected role:", error.response.data); } @@ -98,6 +103,27 @@ function Header({ opened, toggleSidebar }) { } }; + useEffect(() => { + const fetchUnread = async () => { + const token = localStorage.getItem("authToken"); + if (!token) return; + + try { + const { data } = await axios.get(getNotificationsRoute, { + headers: { Authorization: `Token ${token}` }, + }); + const list = data?.notifications || []; + setUnreadCount( + list.filter((item) => item?.unread && !item?.deleted).length, + ); + } catch (err) { + console.error("Failed to fetch unread count:", err); + } + }; + + fetchUnread(); + }, [role]); + return ( - - + 99 ? "99+" : unreadCount} + size={18} + > + navigate("/dashboard/notifications")} + /> state.user.role); + const roles = useSelector((state) => state.user.roles); const [activeTab, setActiveTab] = useState("0"); const [sortedBy, setSortedBy] = useState("Most Recent"); + const [manageLoading, setManageLoading] = useState(false); + const [manageSubmitting, setManageSubmitting] = useState(false); + const [deletingAnnouncementId, setDeletingAnnouncementId] = useState(null); + const [manageAnnouncements, setManageAnnouncements] = useState([]); + const [announcementStatusMap, setAnnouncementStatusMap] = useState({}); + const [announcementStatsMap, setAnnouncementStatsMap] = useState({}); + const [studentRollOptions, setStudentRollOptions] = useState([]); + const [studentRollLoading, setStudentRollLoading] = useState(false); + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [editingAnnouncementId, setEditingAnnouncementId] = useState(null); + const [formData, setFormData] = useState({ + title: "", + content: "", + module: "Fusion", + target_audience: "all_students", + batch: "", + specific_usernames: [], + }); + const [editFormData, setEditFormData] = useState({ + title: "", + content: "", + module: "Fusion", + target_audience: "all_students", + batch: "", + specific_usernames: [], + }); + + const targetAudienceOptions = [ + { value: "all_users", label: "All Users" }, + { value: "students", label: "Students" }, + { value: "faculty", label: "Faculty" }, + { value: "professor", label: "Professor" }, + { value: "staff", label: "Staff" }, + { value: "batch", label: "Batch" }, + { value: "specific_users", label: "Specific Users" }, + { value: "all_cse", label: "ALL CSE" }, + { value: "all_ece", label: "ALL ECE" }, + { value: "all_me", label: "ALL ME" }, + { value: "all_ug", label: "ALL UG" }, + { value: "all_pg", label: "ALL PG" }, + { value: "all_students", label: "ALL Students" }, + { value: "specific_student", label: "Specific Student" }, + ]; + const tabItems = [{ title: "Notifications" }, { title: "Announcements" }]; + const canManageAnnouncements = useMemo(() => { + const allRoles = [...(Array.isArray(roles) ? roles : []), role] + .filter(Boolean) + .map((r) => String(r).toLowerCase()); + + return allRoles.some((r) => + ["admin", "professor", "faculty", "staff"].some((k) => r.includes(k)), + ); + }, [role, roles]); + + const getAuthHeaders = useCallback(() => { + const token = localStorage.getItem("authToken"); + return { headers: { Authorization: `Token ${token}` } }; + }, []); + + const refreshNotificationLists = useCallback(async () => { + try { + const data = await notificationAPI.fetchAll(); + const { notifications: updatedNotifications, announcements } = + notificationUtils.separate(data); + notificationsData.setNotificationsList(updatedNotifications); + announcementsData.setAnnouncementsList(announcements); + } catch (err) { + console.error("Failed to refresh notifications:", err); + } + }, [notificationsData, announcementsData]); + + const fetchManageAnnouncements = useCallback(async () => { + if (!canManageAnnouncements) return; + + try { + setManageLoading(true); + const { data } = await axios.get( + `${announcementCreateRoute}my_announcements/`, + getAuthHeaders(), + ); + setManageAnnouncements(data?.results || []); + } catch (err) { + console.error("Failed to load your announcements:", err); + } finally { + setManageLoading(false); + } + }, [canManageAnnouncements, getAuthHeaders]); + + useEffect(() => { + fetchManageAnnouncements(); + }, [fetchManageAnnouncements]); + + const fetchStudentRollNumbers = useCallback(async () => { + if (!canManageAnnouncements) return; + + try { + setStudentRollLoading(true); + const { data } = await axios.get( + `${announcementCreateRoute}student_roll_numbers/`, + getAuthHeaders(), + ); + + setStudentRollOptions( + (data?.results || []).map((student) => ({ + value: student.username, + label: student.username, + })), + ); + } catch (err) { + console.error("Failed to load student roll numbers:", err); + } finally { + setStudentRollLoading(false); + } + }, [canManageAnnouncements, getAuthHeaders]); + + useEffect(() => { + fetchStudentRollNumbers(); + }, [fetchStudentRollNumbers]); + + const parseAnnouncementMessage = (announcement) => { + if (announcement?.title || announcement?.content) { + return { + title: announcement?.title || "", + content: announcement?.content || "", + }; + } + + const raw = (announcement?.message || "").trim(); + if (!raw) { + return { title: "", content: "" }; + } + + if (raw.includes("\n\n")) { + const [title, ...rest] = raw.split("\n\n"); + return { title: title.trim(), content: rest.join("\n\n").trim() }; + } + + return { title: raw, content: raw }; + }; + + const getAudienceFromAnnouncement = (announcement) => { + if (announcement?.target_group === "all_users") return "all_users"; + if (announcement?.target_group === "faculty") return "faculty"; + if (announcement?.target_group === "staff") return "staff"; + if (announcement?.target_group === "students") return "all_students"; + if (announcement?.target_group === "specific_users") + return "specific_users"; + + const batch = (announcement?.batch || "").toUpperCase(); + if (batch === "BCS") return "all_cse"; + if (batch === "BEC") return "all_ece"; + if (batch === "BME") return "all_me"; + if (batch === "UG") return "all_ug"; + if (batch === "PG") return "all_pg"; + + return "all_students"; + }; + + const mapAudienceToPayload = (audience, specificUsernames = []) => { + if (audience === "all_users") return { target_group: "all_users" }; + if (audience === "students") return { target_group: "students" }; + if (audience === "faculty" || audience === "professor") { + return { target_group: "faculty" }; + } + if (audience === "staff") return { target_group: "staff" }; + if (audience === "batch") return { target_group: "batch" }; + if (audience === "specific_users") { + return { + target_group: "specific_users", + specific_usernames: specificUsernames, + }; + } + if (audience === "all_cse") return { target_group: "batch", batch: "BCS" }; + if (audience === "all_ece") return { target_group: "batch", batch: "BEC" }; + if (audience === "all_me") return { target_group: "batch", batch: "BME" }; + if (audience === "all_ug") return { target_group: "batch", batch: "UG" }; + if (audience === "all_pg") return { target_group: "batch", batch: "PG" }; + if (audience === "specific_student") { + return { + target_group: "specific_users", + specific_usernames: specificUsernames, + }; + } + + return { target_group: "students" }; + }; + + const resetCreateForm = () => { + setFormData({ + title: "", + content: "", + module: "Fusion", + target_audience: "all_students", + batch: "", + specific_usernames: [], + }); + }; + + const closeEditModal = () => { + setEditingAnnouncementId(null); + setIsEditModalOpen(false); + setEditFormData({ + title: "", + content: "", + module: "Fusion", + target_audience: "all_students", + batch: "", + specific_usernames: [], + }); + }; + + const validateAudienceSelection = (source) => { + if (!(source?.title || "").trim() || !(source?.content || "").trim()) { + notifications.show({ + title: "Missing fields", + message: "Please enter both title and content.", + color: "orange", + }); + return false; + } + + if ( + (source.target_audience === "specific_student" || + source.target_audience === "specific_users") && + (source.specific_usernames || []).length === 0 + ) { + notifications.show({ + title: "No students selected", + message: "Please select at least one student roll number.", + color: "orange", + }); + return false; + } + + if (source.target_audience === "batch" && !(source.batch || "").trim()) { + notifications.show({ + title: "Batch required", + message: "Please provide a batch value.", + color: "orange", + }); + return false; + } + + return true; + }; + + const handleEditAnnouncement = async (announcement) => { + let specificRecipients = []; + if (announcement.target_group === "specific_users") { + try { + const { data } = await axios.get( + `${announcementCreateRoute}${announcement.id}/`, + getAuthHeaders(), + ); + + specificRecipients = (data?.recipients || []).map( + (recipient) => recipient.user_username, + ); + } catch (err) { + console.error("Failed to load recipients for edit:", err); + } + } + + const parsed = parseAnnouncementMessage(announcement); + setEditingAnnouncementId(announcement.id); + setEditFormData({ + title: parsed.title, + content: parsed.content, + module: announcement.module || "Fusion", + target_audience: getAudienceFromAnnouncement(announcement), + batch: announcement.batch || "", + specific_usernames: specificRecipients, + }); + setIsEditModalOpen(true); + }; + + const handleViewStatus = async (announcementId) => { + try { + const [statsRes, detailRes] = await Promise.all([ + axios.get( + `${announcementCreateRoute}${announcementId}/statistics/`, + getAuthHeaders(), + ), + axios.get( + `${announcementCreateRoute}${announcementId}/`, + getAuthHeaders(), + ), + ]); + + setAnnouncementStatsMap((prev) => ({ + ...prev, + [announcementId]: statsRes.data, + })); + + setAnnouncementStatusMap((prev) => ({ + ...prev, + [announcementId]: detailRes.data, + })); + } catch (err) { + console.error("Failed to fetch announcement status:", err); + notifications.show({ + title: "Status load failed", + message: "Could not load recipient read status.", + color: "red", + }); + } + }; + + const handleDeleteAnnouncement = async (announcementId) => { + try { + setDeletingAnnouncementId(announcementId); + await axios.delete( + `${announcementCreateRoute}${announcementId}/`, + getAuthHeaders(), + ); + + notifications.show({ + title: "Deleted", + message: "Announcement deleted successfully.", + color: "green", + }); + + await fetchManageAnnouncements(); + await refreshNotificationLists(); + } catch (err) { + console.error("Failed to delete announcement:", err); + notifications.show({ + title: "Delete failed", + message: "Could not delete announcement.", + color: "red", + }); + } finally { + setDeletingAnnouncementId(null); + } + }; + + const buildPayloadFromForm = (source) => { + const audiencePayload = mapAudienceToPayload( + source.target_audience, + source.specific_usernames, + ); + + const payload = { + title: (source.title || "").trim(), + content: (source.content || "").trim(), + module: (source.module || "Fusion").trim() || "Fusion", + ...audiencePayload, + }; + + if (source.target_audience === "batch" && (source.batch || "").trim()) { + payload.batch = source.batch.trim(); + } + + return payload; + }; + + const handleCreateAnnouncement = async () => { + const title = (formData.title || "").trim(); + const content = (formData.content || "").trim(); + + if (!title || !content) { + notifications.show({ + title: "Missing fields", + message: "Please enter both title and content.", + color: "orange", + }); + return; + } + + if ( + (formData.target_audience === "specific_student" || + formData.target_audience === "specific_users") && + formData.specific_usernames.length === 0 + ) { + notifications.show({ + title: "No students selected", + message: "Please select at least one student roll number.", + color: "orange", + }); + return; + } + + const payload = buildPayloadFromForm(formData); + + try { + setManageSubmitting(true); + await axios.post(announcementCreateRoute, payload, getAuthHeaders()); + + notifications.show({ + title: "Created", + message: "Announcement created successfully.", + color: "green", + }); + + resetCreateForm(); + await fetchManageAnnouncements(); + await refreshNotificationLists(); + } catch (err) { + console.error("Failed to save announcement:", err); + const serverData = err?.response?.data; + const serverMessage = + serverData?.detail || + serverData?.message || + (typeof serverData === "string" ? serverData : null) || + (serverData && Object.keys(serverData).length + ? Object.entries(serverData) + .map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(", ") : v}`) + .join(" | ") + : null); + + notifications.show({ + title: "Save failed", + message: + serverMessage || + "Could not save announcement. Please check role permissions.", + color: "red", + }); + } finally { + setManageSubmitting(false); + } + }; + + const handleResendAnnouncement = async () => { + if (!editingAnnouncementId) return; + if (!validateAudienceSelection(editFormData)) return; + + const payload = buildPayloadFromForm(editFormData); + + try { + setManageSubmitting(true); + await axios.patch( + `${announcementCreateRoute}${editingAnnouncementId}/`, + payload, + getAuthHeaders(), + ); + + notifications.show({ + title: "Resent", + message: "Announcement updated and resent successfully.", + color: "green", + }); + + closeEditModal(); + await fetchManageAnnouncements(); + await refreshNotificationLists(); + } catch (err) { + console.error("Failed to resend announcement:", err); + const serverData = err?.response?.data; + const serverMessage = + serverData?.detail || + serverData?.message || + (typeof serverData === "string" ? serverData : null) || + (serverData && Object.keys(serverData).length + ? Object.entries(serverData) + .map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(", ") : v}`) + .join(" | ") + : null); + + notifications.show({ + title: "Resend failed", + message: serverMessage || "Could not update and resend announcement.", + color: "red", + }); + } finally { + setManageSubmitting(false); + } + }; + // Get data based on active tab const currentList = activeTab === "0" @@ -115,6 +608,299 @@ function NotificationView() { ))} )} + + {canManageAnnouncements && activeTab === "1" && ( + + + + Manage Announcements + + +