diff --git a/src/Modules/Dashboard/dashboardNotifications.jsx b/src/Modules/Dashboard/dashboardNotifications.jsx index ca8565bda..bc356f685 100644 --- a/src/Modules/Dashboard/dashboardNotifications.jsx +++ b/src/Modules/Dashboard/dashboardNotifications.jsx @@ -1,21 +1,37 @@ import axios from "axios"; import PropTypes from "prop-types"; -import { SortAscending } from "@phosphor-icons/react"; +import { + SortAscending, + Megaphone, + CalendarBlank, + Star, + EnvelopeOpen, + Envelope, + Trash, + CheckCircle, +} from "@phosphor-icons/react"; import { useEffect, useMemo, useState } from "react"; import { + ActionIcon, Container, Loader, Badge, Button, - Divider, Flex, Grid, + Group, + Modal, + MultiSelect, Paper, Select, + Stack, Text, - CloseButton, + Textarea, + TextInput, + Tooltip, } from "@mantine/core"; -import { useDispatch } from "react-redux"; +import { notifications as toast } from "@mantine/notifications"; +import { useDispatch, useSelector } from "react-redux"; import classes from "./Dashboard.module.css"; import { Empty } from "../../components/empty"; import CustomBreadcrumbs from "../../components/Breadcrumbs.jsx"; @@ -23,70 +39,190 @@ import { notificationReadRoute, notificationDeleteRoute, notificationUnreadRoute, + notificationStarRoute, getNotificationsRoute, + markAllReadRoute, + markAllUnreadRoute, } from "../../routes/dashboardRoutes"; +import { broadcastRoute } from "../../routes/notificationRoutes"; import ModuleTabs from "../../components/moduleTabs.jsx"; +const AUDIENCE_OPTIONS = [ + { value: "all", label: "All Users" }, + { value: "students", label: "All Students" }, + { value: "faculty", label: "All Faculty" }, + { value: "staff", label: "All Staff" }, + { value: "department", label: "By Department" }, + { value: "batch", label: "By Batch" }, + { value: "group", label: "Specific Designation" }, + { value: "specific_user", label: "Specific Users" }, +]; + +// Backend endpoints powering the dependent dropdowns +const HOST = ""; +const AUDIENCE_DEPARTMENTS_URL = `${HOST}/api/notifications/audience/departments/`; +const AUDIENCE_DESIGNATIONS_URL = `${HOST}/api/notifications/audience/designations/`; +const AUDIENCE_BATCHES_URL = `${HOST}/api/notifications/audience/batches/`; +const AUDIENCE_USERS_URL = `${HOST}/api/notifications/audience/users/`; + const categories = ["Most Recent", "Tags", "Title"]; +// Defensive parse for older rows that may still hold a Python-repr string +const safeParse = (s) => { + try { + return JSON.parse(String(s).replace(/'/g, '"')); + } catch { + return {}; + } +}; + +// Pretty timestamp like "5m ago", "2h ago", "yesterday", "Apr 26" +function timeAgo(ts) { + const d = new Date(ts); + const diff = (Date.now() - d.getTime()) / 1000; + if (diff < 60) return "just now"; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; + if (diff < 172800) return "yesterday"; + return d.toLocaleDateString(undefined, { month: "short", day: "numeric" }); +} + function NotificationItem({ notification, markAsRead, deleteNotification, markAsUnread, + toggleStar, loading, }) { - const { module } = notification.data; + const { module, flag } = notification.data || {}; + const isAnnouncement = flag === "announcement"; + const starred = !!notification.data?.starred; + const accent = isAnnouncement ? "#FA8C16" : "#15ABFF"; + + const [expanded, setExpanded] = useState(false); return ( - - - - - {notification.verb} - - {module || "N/A"} - - - {new Date(notification.timestamp).toLocaleDateString()} + + + + {isAnnouncement && ( + + Announcement + + )} + {module && ( + + {module} + + )} + + setExpanded((v) => !v)} + > + {notification.verb || "Notification"} - - - deleteNotification(notification.id)} - /> - - - {notification.description || "No description available."} - - + {notification.description && + notification.description !== notification.verb && ( + + {notification.description} + + )} + {((notification.verb || "").length > 80 || + (notification.description || "").length > 100) && ( + setExpanded((v) => !v)} + > + {expanded ? "Show less" : "Show more"} + + )} + + + {timeAgo(notification.timestamp)} + + {isAnnouncement && notification.data?.expiry_date && ( + } + radius="sm" + > + Expires {new Date(notification.data.expiry_date).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" })} + + )} + + + + + + toggleStar(notification.id)} + aria-label="Star" + > + + + + + + notification.unread + ? markAsRead(notification.id) + : markAsUnread(notification.id) + } + aria-label="Toggle read" + > + {notification.unread + ? + : } + + + {!isAnnouncement && ( + + deleteNotification(notification.id)} + aria-label="Delete" + > + + + + )} + + ); @@ -97,6 +233,105 @@ function Dashboard() { const [announcementsList, setAnnouncementsList] = useState([]); const [activeTab, setActiveTab] = useState("0"); const [sortedBy, setSortedBy] = useState("Most Recent"); + + // UC-NT-03: Broadcast announcement (staff only) — inline modal + const isStaff = useSelector((s) => s.user.isStaff); + const [broadcastOpen, setBroadcastOpen] = useState(false); + const [bTitle, setBTitle] = useState(""); + const [bMessage, setBMessage] = useState(""); + const [bAudience, setBAudience] = useState("all"); + const [bAudienceValue, setBAudienceValue] = useState(""); // single string (department/batch/group) + const [bUsers, setBUsers] = useState([]); // array (specific_user multi) + const [bExpiry, setBExpiry] = useState(null); + const [bSending, setBSending] = useState(false); + + // Audience option lists (fetched once when modal first opens) + const [departments, setDepartments] = useState([]); + const [designations, setDesignations] = useState([]); + const [batches, setBatches] = useState([]); + const [usersList, setUsersList] = useState([]); + const [audienceLoaded, setAudienceLoaded] = useState(false); + + useEffect(() => { + if (!broadcastOpen || audienceLoaded) return; + const token = localStorage.getItem("authToken"); + const headers = { Authorization: `Token ${token}` }; + Promise.all([ + axios.get(AUDIENCE_DEPARTMENTS_URL, { headers }), + axios.get(AUDIENCE_DESIGNATIONS_URL, { headers }), + axios.get(AUDIENCE_BATCHES_URL, { headers }), + axios.get(AUDIENCE_USERS_URL, { headers }), + ]) + .then(([d, g, b, u]) => { + setDepartments(d.data.departments || []); + setDesignations(g.data.designations || []); + setBatches(b.data.batches || []); + setUsersList(u.data.users || []); + setAudienceLoaded(true); + }) + .catch(() => + toast.show({ color: "red", message: "Failed to load audience options." }), + ); + }, [broadcastOpen, audienceLoaded]); + + // Reset the secondary value whenever the audience type changes + useEffect(() => { setBAudienceValue(""); setBUsers([]); }, [bAudience]); + + const audienceNeedsSingle = ["department", "batch", "group"].includes(bAudience); + const audienceNeedsUsers = bAudience === "specific_user"; + + const submitBroadcast = async () => { + if (!bTitle.trim() || !bMessage.trim() || !bExpiry) { + toast.show({ color: "orange", message: "Please fill all required fields." }); + return; + } + if (audienceNeedsSingle && !bAudienceValue.trim()) { + toast.show({ color: "orange", message: "Please pick the audience value." }); + return; + } + if (audienceNeedsUsers && bUsers.length === 0) { + toast.show({ color: "orange", message: "Please pick at least one user." }); + return; + } + + let audienceValue = ""; + if (audienceNeedsSingle) audienceValue = bAudienceValue.trim(); + if (audienceNeedsUsers) audienceValue = bUsers.join(","); + + setBSending(true); + try { + const token = localStorage.getItem("authToken"); + await axios.post( + broadcastRoute, + { + title: bTitle.trim(), + message: bMessage.trim(), + audience_type: bAudience, + audience_value: audienceValue, + expiry_date: (bExpiry instanceof Date ? bExpiry : new Date(bExpiry)) + .toISOString() + .split("T")[0], + }, + { headers: { Authorization: `Token ${token}` } }, + ); + toast.show({ + color: "green", + message: + audienceNeedsUsers && bUsers.length > 1 + ? `Announcement broadcasted to ${bUsers.length} users.` + : "Announcement broadcasted.", + }); + setBroadcastOpen(false); + setBTitle(""); setBMessage(""); + setBAudience("all"); setBAudienceValue(""); setBUsers([]); + setBExpiry(null); + } catch (err) { + const detail = err?.response?.data?.error || "Failed to broadcast."; + toast.show({ color: "red", message: typeof detail === "string" ? detail : "Failed to broadcast." }); + } finally { + setBSending(false); + } + }; const [loading, setLoading] = useState(false); const [read_Loading, setRead_Loading] = useState(-1); const dispatch = useDispatch(); @@ -112,39 +347,52 @@ function Dashboard() { const badges = [notificationBadgeCount, announcementBadgeCount]; useEffect(() => { - const fetchDashboardData = async () => { + let cancelled = false; + + const fetchDashboardData = async ({ silent = false } = {}) => { const token = localStorage.getItem("authToken"); if (!token) return console.error("No authentication token found!"); try { - setLoading(true); + if (!silent) setLoading(true); const { data } = await axios.get(getNotificationsRoute, { headers: { Authorization: `Token ${token}` }, }); + if (cancelled) return; const { notifications } = data; const notificationsData = notifications.map((item) => ({ ...item, - data: JSON.parse(item.data.replace(/'/g, '"')), + data: typeof item.data === "string" ? safeParse(item.data) : (item.data || {}), })); setNotificationsList( - notificationsData.filter( - (item) => item.data?.flag !== "announcement", - ), + notificationsData.filter((item) => item.flag !== "announcement"), ); setAnnouncementsList( - notificationsData.filter( - (item) => item.data?.flag === "announcement", - ), + notificationsData.filter((item) => item.flag === "announcement"), ); } catch (error) { - console.error("Error fetching dashboard data:", error); + if (!cancelled) console.error("Error fetching dashboard data:", error); } finally { - setLoading(false); + if (!cancelled && !silent) setLoading(false); } }; + // Initial load — show the loader fetchDashboardData(); + + // Poll every 5s in the background — keeps the list in sync with the bell. + const interval = setInterval(() => fetchDashboardData({ silent: true }), 5000); + + // Refetch whenever the tab regains focus — covers long sleeps where polling paused. + const onFocus = () => fetchDashboardData({ silent: true }); + window.addEventListener("focus", onFocus); + + return () => { + cancelled = true; + clearInterval(interval); + window.removeEventListener("focus", onFocus); + }; }, [dispatch]); // const handleTabChange = (direction) => { @@ -183,9 +431,9 @@ function Dashboard() { const token = localStorage.getItem("authToken"); try { setRead_Loading(notifId); - const response = await axios.post( - notificationReadRoute, - { id: notifId }, + const response = await axios.patch( + notificationReadRoute(notifId), + {}, { headers: { Authorization: `Token ${token}` } }, ); if (response.status === 200) { @@ -211,9 +459,9 @@ function Dashboard() { const token = localStorage.getItem("authToken"); try { setRead_Loading(notifId); - const response = await axios.post( - notificationUnreadRoute, - { id: notifId }, + const response = await axios.patch( + notificationUnreadRoute(notifId), + {}, { headers: { Authorization: `Token ${token}` } }, ); if (response.status === 200) { @@ -239,15 +487,11 @@ function Dashboard() { const token = localStorage.getItem("authToken"); try { - const response = await axios.post( - notificationDeleteRoute, - { id: notifId }, - { - headers: { - Authorization: `Token ${token}`, - }, + const response = await axios.delete(notificationDeleteRoute(notifId), { + headers: { + Authorization: `Token ${token}`, }, - ); + }); if (response.status === 200) { setNotificationsList((prev) => @@ -262,6 +506,57 @@ function Dashboard() { } }; + // Star toggle (server-side flip of data.starred) + const toggleStar = async (notifId) => { + const token = localStorage.getItem("authToken"); + try { + const { data } = await axios.patch( + notificationStarRoute(notifId), + {}, + { headers: { Authorization: `Token ${token}` } }, + ); + const apply = (list) => + list.map((n) => + n.id === notifId + ? { ...n, data: { ...(n.data || {}), starred: data.starred } } + : n, + ); + setNotificationsList(apply); + setAnnouncementsList(apply); + } catch (err) { + console.error("Error toggling star:", err); + } + }; + + // Bulk actions — Mark all read / Mark all unread / Delete all (BR-NT-09 archive) + const markAllRead = async () => { + const token = localStorage.getItem("authToken"); + try { + await axios.patch(markAllReadRoute, {}, { + headers: { Authorization: `Token ${token}` }, + }); + const apply = (list) => list.map((n) => ({ ...n, unread: false })); + setNotificationsList(apply); + setAnnouncementsList(apply); + } catch (err) { + console.error("Error marking all as read:", err); + } + }; + + const markAllUnread = async () => { + const token = localStorage.getItem("authToken"); + try { + await axios.patch(markAllUnreadRoute, {}, { + headers: { Authorization: `Token ${token}` }, + }); + const apply = (list) => list.map((n) => ({ ...n, unread: true })); + setNotificationsList(apply); + setAnnouncementsList(apply); + } catch (err) { + console.error("Error marking all as unread:", err); + } + }; + return ( <> @@ -346,9 +641,18 @@ function Dashboard() { align="center" mt="md" rowGap="1rem" - columnGap="4rem" + columnGap="1rem" wrap="wrap" > + {isStaff && ( + + )}