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.unread - ? markAsRead(notification.id) - : markAsUnread(notification.id) - } - loaderProps={{ type: "dots" }} - loading={loading === notification.id} - style={{ cursor: "pointer" }} - ml="sm" - miw="120px" - > - {notification.unread ? "Mark as read" : "Unread"} - - + {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 && ( + } + color="orange" + onClick={() => setBroadcastOpen(true)} + > + Broadcast + + )} - + + {/* UC-NT-03: Broadcast announcement modal — inline, no new page */} + setBroadcastOpen(false)} + title={Broadcast Announcement} + size="lg" + radius="md" + centered + overlayProps={{ backgroundOpacity: 0.55, blur: 3 }} + > + + setBTitle(e.target.value)} + /> + setBMessage(e.target.value)} + /> + + {bAudience === "department" && ( + setBAudienceValue(v || "")} + /> + )} + {bAudience === "group" && ( + setBAudienceValue(v || "")} + /> + )} + {bAudience === "batch" && ( + setBAudienceValue(v || "")} + /> + )} + {bAudience === "specific_user" && ( + + )} + } + leftSectionPointerEvents="none" + min={new Date().toISOString().split("T")[0]} + max={new Date(new Date().setFullYear(new Date().getFullYear() + 1)) + .toISOString().split("T")[0]} + value={bExpiry ? new Date(bExpiry).toISOString().split("T")[0] : ""} + onChange={(e) => setBExpiry(e.target.value ? new Date(e.target.value) : null)} + /> + + setBroadcastOpen(false)} + > + Cancel + + } + loading={bSending} + onClick={submitBroadcast} + > + Broadcast + + + + + {/* Toolbar — counter + bulk actions */} + {!loading && sortedNotifications.length > 0 && ( + + + + + {sortedNotifications.filter((n) => n.unread).length} unread + + + of {sortedNotifications.length} {activeTab === "1" ? "announcements" : "notifications"} + + + + } + onClick={markAllRead} + > + Mark all read + + } + onClick={markAllUnread} + > + Mark all unread + + + + + )} + + {loading ? ( @@ -381,6 +851,7 @@ function Dashboard() { markAsRead={markAsRead} markAsUnread={markAsUnread} deleteNotification={deleteNotification} + toggleStar={toggleStar} loading={read_Loading} /> )) @@ -407,5 +878,6 @@ NotificationItem.propTypes = { markAsRead: PropTypes.func.isRequired, markAsUnread: PropTypes.func.isRequired, deleteNotification: PropTypes.func.isRequired, + toggleStar: PropTypes.func.isRequired, loading: PropTypes.number.isRequired, }; \ No newline at end of file diff --git a/src/Modules/Notification/NotificationModule.jsx b/src/Modules/Notification/NotificationModule.jsx new file mode 100644 index 000000000..9a1b45747 --- /dev/null +++ b/src/Modules/Notification/NotificationModule.jsx @@ -0,0 +1,1004 @@ +/** + * NotificationModule — Single-page notification centre. + * All features in one place with a clean, website-quality UI. + */ + +import { useState, useEffect, useCallback, useRef } from "react"; +import { + Alert, Badge, Button, Card, Code, Divider, Group, Loader, + NumberInput, Paper, SegmentedControl, Select, Stack, Switch, + Table, Text, Textarea, TextInput, Title, Tabs, ThemeIcon, + SimpleGrid, RingProgress, Box, Accordion, Tooltip, ActionIcon, +} from "@mantine/core"; +import { DateInput } from "@mantine/dates"; +import { notifications as toast } from "@mantine/notifications"; +import { + Bell, Sliders, PaperPlane, Megaphone, Tag, + BellSlash, Check, Warning, Archive, Info, Lightning, ShieldCheck, +} from "@phosphor-icons/react"; +import CustomBreadcrumbs from "../../components/Breadcrumbs"; +import NotificationList from "./components/NotificationList"; +import PreferenceToggle from "./components/PreferenceToggle"; +import { + fetchAllNotifications, fetchUnreadNotifications, fetchNotificationsByModule, + fetchUnreadCount, markRead, markUnread, markAllRead, deleteOne, deleteAll, + fetchPreferences, setPreference, + sendGeneric, notifyLeave, notifyMess, notifyHostel, notifyHealthcare, + notifyScholarship, notifyDeanPnD, notifyDeanStudents, notifyDeanRSPC, + notifyGymkhanaVoting, notifyGymkhanaSession, notifyGymkhanaEvent, + notifyResearch, notifyAssistantshipApproved, notifyAssistantshipFaculty, + notifyAssistantshipAcad, notifyAssistantshipAccounts, + notifyComplaint, notifyFileTracking, notifyPlacement, notifyAcademics, notifyDepartment, + fetchEventTypes, registerEventType, triggerEventNotification, + fetchActiveAnnouncements, broadcastAnnouncement, previewAudience, +} from "./api"; + +// ─── Constants ──────────────────────────────────────────────────────────────── + +const MODULE_FILTER_OPTIONS = [ + { value: "all", label: "All Modules" }, + { value: "Leave Module", label: "Leave Module" }, + { value: "Central Mess", label: "Central Mess" }, + { value: "Visitor's Hostel", label: "Visitor's Hostel" }, + { value: "Healthcare Center", label: "Healthcare Center" }, + { value: "File Tracking", label: "File Tracking" }, + { value: "Scholarship Portal", label: "Scholarship Portal" }, + { value: "Complaint System", label: "Complaint System" }, + { value: "Placement Cell", label: "Placement Cell" }, + { value: "Academic's Module", label: "Academic's Module" }, + { value: "Office of Dean PnD Module", label: "Office of Dean PnD" }, + { value: "Office Module", label: "Office Module" }, + { value: "Gymkhana Module", label: "Gymkhana Module" }, + { value: "Assistantship Request", label: "Assistantship Request" }, + { value: "department", label: "Department" }, + { value: "Research Procedures", label: "Research Procedures" }, +]; + +const MODULE_OPTIONS = [ + { value: "Leave Module", label: "Leave Module" }, + { value: "Placement Cell", label: "Placement Cell" }, + { value: "Academic's Module", label: "Academic's Module" }, + { value: "Central Mess", label: "Central Mess" }, + { value: "Visitor's Hostel", label: "Visitor's Hostel" }, + { value: "Healthcare Center", label: "Healthcare Center" }, + { value: "File Tracking", label: "File Tracking" }, + { value: "Scholarship Portal", label: "Scholarship Portal" }, + { value: "Complaint System", label: "Complaint System" }, + { value: "Office of Dean PnD Module", label: "Office of Dean PnD" }, + { value: "Office Module", label: "Office Module" }, + { value: "Gymkhana Module", label: "Gymkhana Module" }, + { value: "Assistantship Request", label: "Assistantship Request" }, + { value: "department", label: "Department" }, + { value: "Research Procedures", label: "Research Procedures" }, +]; + +const SEND_MODULE_TYPES = [ + { value: "leave", label: "Leave Module" }, + { value: "mess", label: "Central Mess" }, + { value: "hostel", label: "Visitor's Hostel" }, + { value: "healthcare", label: "Healthcare Center" }, + { value: "scholarship", label: "Scholarship Portal" }, + { value: "dean-pnd", label: "Office of Dean PnD" }, + { value: "dean-students", label: "Office of Dean Students" }, + { value: "dean-rspc", label: "Office of Dean RSPC" }, + { value: "gymkhana-voting", label: "Gymkhana — Voting" }, + { value: "gymkhana-session", label: "Gymkhana — Session" }, + { value: "gymkhana-event", label: "Gymkhana — Event" }, + { value: "research", label: "Research Procedures" }, + { value: "assistantship-approved", label: "Assistantship — Claim Approved" }, + { value: "assistantship-faculty", label: "Assistantship — Notify Faculty" }, + { value: "assistantship-acad", label: "Assistantship — Notify Acad Section" }, + { value: "assistantship-accounts", label: "Assistantship — Notify Accounts" }, + { value: "complaint", label: "Complaint System" }, + { value: "file-tracking", label: "File Tracking" }, + { value: "placement", label: "Placement Cell" }, + { value: "academics", label: "Academic's Module" }, + { value: "department", label: "Department" }, + { value: "generic", label: "Generic (any module)" }, +]; + +const PRIORITY_OPTIONS = [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High" }, + { value: "critical", label: "Critical — bypasses user preferences (BR-NT-05)" }, +]; + +const AUDIENCE_OPTIONS = [ + { value: "all", label: "All Users (everyone in DB)" }, + { value: "students", label: "All Students (user_type = student)" }, + { value: "staff", label: "All Staff (user_type = staff)" }, + { value: "group", label: "Specific Designation (BR-NT-07)" }, +]; + +// Designation values from globals_designation in fusionlab DB (populated via seed) +const DESIGNATIONS = [ + "admin", "staff", "student", + "director", "dean", "hod", "professor", +]; + +// ─── Use Cases ──────────────────────────────────────────────────────────────── + +const USE_CASES = [ + { module: "Leave Module", color: "blue", cases: [ + { actor: "Student / Faculty", trigger: "leave_applied", description: "A leave application is submitted." }, + { actor: "Approver", trigger: "request_accepted", description: "The immediate approver accepts the request." }, + { actor: "Approver", trigger: "request_declined", description: "The immediate approver declines the request." }, + { actor: "Final Authority", trigger: "leave_accepted", description: "Leave is fully approved by the final authority." }, + { actor: "Approver", trigger: "leave_forwarded", description: "Application is forwarded to the next authority." }, + { actor: "Final Authority", trigger: "leave_rejected", description: "Leave application is rejected." }, + { actor: "Admin", trigger: "offline_leave", description: "An offline leave record is updated." }, + { actor: "Faculty", trigger: "replacement_request", description: "A replacement request is sent to another faculty." }, + { actor: "Faculty", trigger: "leave_request", description: "A leave request is sent to a colleague." }, + { actor: "Student / Faculty", trigger: "leave_withdrawn", description: "The applicant withdraws a submitted leave." }, + { actor: "Faculty", trigger: "replacement_cancel", description: "A replacement arrangement is cancelled." }, + ]}, + { module: "Central Mess", color: "orange", cases: [ + { actor: "Student", trigger: "feedback_submitted", description: "Student submits feedback about mess food or service." }, + { actor: "Mess Committee", trigger: "menu_change_accepted", description: "A proposed menu change is approved." }, + { actor: "Student", trigger: "leave_request", description: "Student requests a mess leave (rebate)." }, + { actor: "Student", trigger: "vacation_request", description: "Student requests mess closure during vacation." }, + { actor: "Committee Head", trigger: "meeting_invitation", description: "A meeting invitation is sent to committee members." }, + { actor: "Student", trigger: "special_request", description: "A special dietary or event request is submitted." }, + { actor: "Admin", trigger: "added_committee", description: "A user is added to the mess committee." }, + ]}, + { module: "Visitor's Hostel", color: "teal", cases: [ + { actor: "Warden / Admin", trigger: "booking_confirmation", description: "A hostel booking is confirmed." }, + { actor: "Warden / Admin", trigger: "booking_cancellation_request_accepted", description: "A cancellation request is accepted." }, + { actor: "Visitor", trigger: "booking_request", description: "A visitor submits a booking request." }, + { actor: "Visitor", trigger: "cancellation_request_placed", description: "A visitor requests cancellation." }, + { actor: "Admin", trigger: "booking_forwarded", description: "A booking request is forwarded." }, + { actor: "Warden / Admin", trigger: "booking_rejected", description: "A booking request is rejected." }, + ]}, + { module: "Healthcare Center", color: "red", cases: [ + { actor: "Doctor / Staff", trigger: "appoint", description: "An appointment is booked for the patient." }, + { actor: "Patient", trigger: "amb_request", description: "An ambulance request is placed." }, + { actor: "Doctor", trigger: "Presc", description: "A prescription is issued to the patient." }, + { actor: "Doctor / Staff", trigger: "appoint_req", description: "A new appointment request is received." }, + { actor: "Doctor / Staff", trigger: "amb_req", description: "A new ambulance request is received." }, + ]}, + { module: "Scholarship Portal", color: "violet", cases: [ + { actor: "SPACS Convenor", trigger: "Accept_MCM", description: "MCM scholarship form is accepted." }, + { actor: "SPACS Convenor", trigger: "Reject_MCM", description: "MCM scholarship form is rejected." }, + { actor: "SPACS Convenor", trigger: "Accept_Gold", description: "Gold Medal application is accepted." }, + { actor: "SPACS Convenor", trigger: "Reject_Gold", description: "Gold Medal application is rejected." }, + { actor: "SPACS Convenor", trigger: "Accept_Silver", description: "Silver Medal application is accepted." }, + { actor: "SPACS Convenor", trigger: "Reject_Silver", description: "Silver Medal application is rejected." }, + { actor: "SPACS Convenor", trigger: "Accept_DM", description: "D&M Medal application is accepted." }, + ]}, + { module: "Office of Dean PnD", color: "cyan", cases: [ + { actor: "Faculty / Staff", trigger: "requisition_filed", description: "A requisition is filed for approval." }, + { actor: "Dean PnD", trigger: "request_accepted", description: "A requisition is accepted." }, + { actor: "Dean PnD", trigger: "request_rejected", description: "A requisition is rejected." }, + { actor: "Admin", trigger: "assignment_created", description: "A new work assignment is created." }, + { actor: "Assignee", trigger: "assignment_received", description: "An assignment is received." }, + { actor: "Admin", trigger: "assignment_reverted", description: "An assignment is reverted." }, + { actor: "Dean PnD", trigger: "assignment_approved", description: "An assignment is approved." }, + { actor: "Dean PnD", trigger: "assignment_rejected", description: "An assignment is rejected." }, + ]}, + { module: "Office of Dean Students", color: "indigo", cases: [ + { actor: "Dean Students", trigger: "hostel_alloted", description: "A hostel room is allotted to a student." }, + { actor: "System", trigger: "insufficient_funds", description: "Insufficient funds detected in a club account." }, + { actor: "Club / Committee", trigger: "MOM_submitted", description: "Minutes of Meeting are submitted." }, + { actor: "Dean Students", trigger: "budget_approved", description: "A budget is approved." }, + { actor: "Dean Students", trigger: "budget_rejected", description: "A budget is rejected." }, + { actor: "Dean Students", trigger: "club_approved", description: "A student club is officially approved." }, + { actor: "Dean Students", trigger: "club_rejected", description: "A student club is rejected." }, + { actor: "Student / Club", trigger: "meeting_booked", description: "A meeting with Dean Students is scheduled." }, + { actor: "Dean Students", trigger: "session_approved", description: "A session or event is approved." }, + { actor: "Dean Students", trigger: "session_rejected", description: "A session or event is rejected." }, + { actor: "Dean Students", trigger: "budget_alloted", description: "A budget amount is formally allotted." }, + ]}, + { module: "Office of Dean RSPC", color: "grape", cases: [ + { actor: "Dean RSPC", trigger: "Approve", description: "A research/sponsored project proposal is approved." }, + { actor: "Dean RSPC", trigger: "Disapprove", description: "A research/sponsored project proposal is disapproved." }, + { actor: "System", trigger: "Pending", description: "A proposal is marked pending for further review." }, + ]}, + { module: "Gymkhana", color: "lime", cases: [ + { actor: "Election Committee", trigger: "voting", description: "A new election/voting event is announced." }, + { actor: "Club / Gymkhana", trigger: "session", description: "A club session is scheduled." }, + { actor: "Club / Gymkhana", trigger: "event", description: "An upcoming event is announced." }, + ]}, + { module: "Research Procedures", color: "yellow", cases: [ + { actor: "Dean RSPC", trigger: "Approved", description: "A research procedure request is approved." }, + { actor: "Dean RSPC", trigger: "Disapproved", description: "A research procedure request is disapproved." }, + { actor: "System", trigger: "Pending", description: "A request is pending review." }, + { actor: "Faculty / Student", trigger: "submitted", description: "A research form or report is submitted." }, + { actor: "Admin", trigger: "created", description: "A new research project entry is created." }, + ]}, + { module: "Assistantship Request", color: "pink", cases: [ + { actor: "Accounts Section", trigger: "assistantship-approved", description: "An assistantship claim is approved." }, + { actor: "System", trigger: "assistantship-faculty", description: "Faculty is notified about a pending claim." }, + { actor: "System", trigger: "assistantship-acad", description: "Academic section is notified." }, + { actor: "System", trigger: "assistantship-accounts", description: "Accounts section is notified." }, + ]}, + { module: "Complaint System", color: "red", cases: [ + { actor: "Complaint Handler", trigger: "complaint", description: "A complaint is updated or resolved." }, + ]}, + { module: "File Tracking", color: "gray", cases: [ + { actor: "File Sender", trigger: "file-tracking", description: "A tracked file is forwarded to a new recipient." }, + ]}, + { module: "Placement Cell", color: "dark", cases: [ + { actor: "Placement Officer", trigger: "placement", description: "A placement announcement is broadcast to students." }, + ]}, + { module: "Academic's Module", color: "blue", cases: [ + { actor: "Academic Admin", trigger: "academics", description: "An academic announcement or action update is sent." }, + ]}, + { module: "Department", color: "teal", cases: [ + { actor: "Department Admin", trigger: "department", description: "A department-level notification is sent." }, + ]}, +]; + +// ─── Business Rules ─────────────────────────────────────────────────────────── + +const BUSINESS_RULES = [ + { id: "BR-NT-03", title: "Only Authorized Senders Can Broadcast", color: "red", icon: , + description: "Only users with is_staff or is_superuser can send broadcast announcements. Regular students and faculty are blocked with HTTP 403.", + enforcement: "services.py → broadcast_announcement()", + codeSnippet: "if not (sender.is_staff or sender.is_superuser):\n raise UnauthorizedSender(...)", + demo: "Login as a student → go to Announcements tab → try to broadcast → see 403 error toast.", + }, + { id: "BR-NT-04", title: "60-Second Deduplication Per User + Event", color: "orange", icon: , + description: "Same Event ID + same recipient triggered within 60 seconds is suppressed with HTTP 429. Prevents notification spam.", + enforcement: "services.py → trigger_notification_by_event_id() → _recent_triggers cache", + codeSnippet: "if elapsed < 60:\n raise DuplicateNotification(\n f'Duplicate suppressed. Sent {elapsed}s ago (BR-NT-04: 60s cooldown).'\n )", + demo: "Register an event → trigger it → trigger again within 60s → see 429 with elapsed seconds shown.", + }, + { id: "BR-NT-05", title: "Critical Priority Bypasses User Preferences", color: "grape", icon: , + description: "Notifications with priority='critical' are always delivered, even if the user has opted out of that module. All other priorities respect preferences.", + enforcement: "services.py → _send() helper", + codeSnippet: "if priority != 'critical' and not selectors.is_module_enabled_for_user(recipient, module):\n return # User opted out — silently skip.", + demo: "Turn off a module in Preferences → send with normal priority → not delivered. Send same with priority=critical → delivered.", + }, + { id: "BR-NT-06", title: "Per-Module Notification Opt-Out", color: "blue", icon: , + description: "Each user can independently enable or disable notifications for any of the 15 Fusion modules. Disabled modules are silently skipped.", + enforcement: "NotificationPreference model + services.py → _send()", + codeSnippet: "NotificationPreference.objects.update_or_create(\n user=user,\n module=module,\n defaults={'is_enabled': is_enabled},\n)", + demo: "Go to Preferences tab → toggle off any module → send a notification for that module → does not appear in inbox.", + }, + { id: "BR-NT-07", title: "RBAC — Designation-Based Broadcast Audience", color: "teal", icon: , + description: "Broadcasts can target specific role groups using real designation data from fusionlab's globals_designation and globals_holdsdesignation tables.", + enforcement: "services.py → _resolve_audience() → selectors.get_users_by_designation()", + codeSnippet: "# audience_type='group', audience_value='Director'\nelif audience_type == AudienceType.GROUP:\n return selectors.get_users_by_designation(audience_value)", + demo: "Go to Announcements → select 'Specific Designation' → enter 'Director' → only the Director user receives it.", + extra: { + label: "Valid designation values (from globals_designation in fusionlab DB)", + items: DESIGNATIONS, + }, + }, + { id: "BR-NT-09", title: "Soft Delete — 180-Day Retention", color: "gray", icon: , + description: "Deleting a notification does NOT remove it from the database. An ArchivedNotification record is created instead. Retained for 180 days for audit.", + enforcement: "services.py → delete_notification() / delete_all_notifications()", + codeSnippet: "ArchivedNotification.objects.get_or_create(\n user=user,\n notification_id=notification_id\n)\n# Does NOT call notification.delete()", + demo: "Delete a notification from inbox → disappears from UI. Query notifications_extension_archivednotification in DB → record still exists.", + }, +]; + +// ─── Send extra fields ──────────────────────────────────────────────────────── + +function ExtraFields({ moduleType, fields, setFields }) { + const set = (k) => (v) => setFields((f) => ({ ...f, [k]: v })); + const setE = (k) => (e) => setFields((f) => ({ ...f, [k]: e.currentTarget.value })); + const LEAVE = [{ value:"leave_applied",label:"Leave Applied"},{ value:"request_accepted",label:"Request Accepted"},{ value:"request_declined",label:"Request Declined"},{ value:"leave_accepted",label:"Leave Accepted"},{ value:"leave_forwarded",label:"Leave Forwarded"},{ value:"leave_rejected",label:"Leave Rejected"},{ value:"offline_leave",label:"Offline Leave Updated"},{ value:"replacement_request",label:"Replacement Request"},{ value:"leave_request",label:"Leave Request"},{ value:"leave_withdrawn",label:"Leave Withdrawn"},{ value:"replacement_cancel",label:"Replacement Cancelled"}]; + const MESS = [{ value:"feedback_submitted",label:"Feedback Submitted"},{ value:"menu_change_accepted",label:"Menu Change Accepted"},{ value:"leave_request",label:"Leave Request"},{ value:"vacation_request",label:"Vacation Request"},{ value:"meeting_invitation",label:"Meeting Invitation"},{ value:"special_request",label:"Special Request"},{ value:"added_committee",label:"Added to Committee"}]; + const HOSTEL = [{ value:"booking_confirmation",label:"Booking Confirmed"},{ value:"booking_cancellation_request_accepted",label:"Cancellation Accepted"},{ value:"booking_request",label:"Booking Request"},{ value:"cancellation_request_placed",label:"Cancellation Placed"},{ value:"booking_forwarded",label:"Booking Forwarded"},{ value:"booking_rejected",label:"Booking Rejected"}]; + const HEALTH = [{ value:"appoint",label:"Appointment Booked"},{ value:"amb_request",label:"Ambulance Request"},{ value:"Presc",label:"Prescription Issued"},{ value:"appoint_req",label:"New Appointment Request"},{ value:"amb_req",label:"New Ambulance Request"}]; + const SCHOLAR = [{ value:"Accept_MCM",label:"MCM Accepted"},{ value:"Reject_MCM",label:"MCM Rejected"},{ value:"Accept_Gold",label:"Gold Medal Accepted"},{ value:"Reject_Gold",label:"Gold Medal Rejected"},{ value:"Accept_Silver",label:"Silver Medal Accepted"},{ value:"Reject_Silver",label:"Silver Medal Rejected"},{ value:"Accept_DM",label:"D&M Medal Accepted"}]; + const DPND = [{ value:"requisition_filed",label:"Requisition Filed"},{ value:"request_accepted",label:"Request Accepted"},{ value:"request_rejected",label:"Request Rejected"},{ value:"assignment_created",label:"Assignment Created"},{ value:"assignment_received",label:"Assignment Received"},{ value:"assignment_reverted",label:"Assignment Reverted"},{ value:"assignment_approved",label:"Assignment Approved"},{ value:"assignment_rejected",label:"Assignment Rejected"}]; + const DS = [{ value:"hostel_alloted",label:"Hostel Alloted"},{ value:"insufficient_funds",label:"Insufficient Funds"},{ value:"MOM_submitted",label:"MOM Submitted"},{ value:"budget_approved",label:"Budget Approved"},{ value:"budget_rejected",label:"Budget Rejected"},{ value:"club_approved",label:"Club Approved"},{ value:"club_rejected",label:"Club Rejected"},{ value:"meeting_booked",label:"Meeting Booked"},{ value:"session_approved",label:"Session Approved"},{ value:"session_rejected",label:"Session Rejected"},{ value:"budget_alloted",label:"Budget Alloted"}]; + const RSPC = [{ value:"Approve",label:"Approved"},{ value:"Disapprove",label:"Disapproved"},{ value:"Pending",label:"Pending"}]; + const RESEARCH = [{ value:"Approved",label:"Approved"},{ value:"Disapproved",label:"Disapproved"},{ value:"Pending",label:"Pending"},{ value:"submitted",label:"Submitted"},{ value:"created",label:"Created"}]; + switch (moduleType) { + case "leave": return (<>>); + case "mess": return (<>>); + case "hostel": return ; + case "healthcare": return ; + case "scholarship": return ; + case "dean-pnd": return ; + case "dean-students": return ; + case "dean-rspc": return ; + case "gymkhana-voting": return (<>>); + case "gymkhana-session": return (<>>); + case "gymkhana-event": return (<>>); + case "research": return ; + case "assistantship-approved": return (<>>); + case "assistantship-faculty": case "assistantship-acad": return No extra fields needed for this use case.; + case "assistantship-accounts": return ; + case "complaint": return (<> setFields((f) => ({ ...f, is_student: e.currentTarget.checked }))} />>); + case "file-tracking": return ; + case "placement": case "academics": case "department": return ; + case "generic": return (<>>); + default: return null; + } +} + +async function dispatchSend(moduleType, fields) { + const base = { recipient_username: fields.recipient }; + switch (moduleType) { + case "leave": return notifyLeave({ ...base, type: fields.type, date: fields.date||undefined }); + case "mess": return notifyMess({ ...base, type: fields.type, message: fields.message||undefined }); + case "hostel": return notifyHostel({ ...base, type: fields.type }); + case "healthcare": return notifyHealthcare({ ...base, type: fields.type }); + case "scholarship": return notifyScholarship({ ...base, type: fields.type }); + case "dean-pnd": return notifyDeanPnD({ ...base, type: fields.type }); + case "dean-students": return notifyDeanStudents({ ...base, type: fields.type }); + case "dean-rspc": return notifyDeanRSPC({ ...base, type: fields.type }); + case "gymkhana-voting": return notifyGymkhanaVoting({ ...base, title: fields.title, desc: fields.desc }); + case "gymkhana-session": return notifyGymkhanaSession({ ...base, club: fields.club, desc: fields.desc, venue: fields.venue }); + case "gymkhana-event": return notifyGymkhanaEvent({ ...base, club: fields.club, event_name: fields.event_name, desc: fields.desc, venue: fields.venue }); + case "research": return notifyResearch({ ...base, type: fields.type }); + case "assistantship-approved": return notifyAssistantshipApproved({ ...base, month: fields.month, year: fields.year }); + case "assistantship-faculty": return notifyAssistantshipFaculty(base); + case "assistantship-acad": return notifyAssistantshipAcad(base); + case "assistantship-accounts": return notifyAssistantshipAccounts({ ...base, student_username: fields.student_username }); + case "complaint": return notifyComplaint({ ...base, complaint_id: fields.complaint_id, is_student: fields.is_student, message: fields.message }); + case "file-tracking": return notifyFileTracking({ ...base, title: fields.title }); + case "placement": return notifyPlacement({ ...base, message: fields.message }); + case "academics": return notifyAcademics({ ...base, message: fields.message }); + case "department": return notifyDepartment({ ...base, message: fields.message }); + case "generic": return sendGeneric({ ...base, module: fields.module, verb: fields.verb, description: fields.description, url: fields.url }); + default: throw new Error("Unknown module"); + } +} + +// ─── Main Component ─────────────────────────────────────────────────────────── + +export default function NotificationModule() { + const [activeTab, setActiveTab] = useState("inbox"); + const [unreadCount, setUnreadCount] = useState(0); + + const refreshCount = useCallback(async () => { + try { setUnreadCount(await fetchUnreadCount()); } catch { /* silent */ } + }, []); + + useEffect(() => { + refreshCount(); + const id = setInterval(refreshCount, 30000); + return () => clearInterval(id); + }, [refreshCount]); + + const breadcrumbs = [ + { title: "Home", href: "/dashboard" }, + { title: "Notifications" }, + ]; + + return ( + + + + {/* ── Page header ── */} + + + + + + + Notification Centre + Fusion ERP · Notification Module + + + {unreadCount > 0 && ( + }> + {unreadCount} unread + + )} + + + {/* ── Tabs ── */} + + + }> + Inbox {unreadCount > 0 && {unreadCount > 99 ? "99+" : unreadCount}} + + }>Preferences + }>Send + }>Announcements + }>Event Types + + + + + + + + + + ); +} + +// ─── Tab: Inbox ─────────────────────────────────────────────────────────────── + +function InboxTab({ onUnreadChange }) { + const [view, setView] = useState("all"); + const [module, setModule] = useState("all"); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [unreadCount, setUnreadCount] = useState(0); + + const updateCount = (val) => { setUnreadCount(val); onUnreadChange?.(val); }; + + const load = useCallback(async () => { + setLoading(true); + try { + let data; + if (module !== "all") data = await fetchNotificationsByModule(module); + else if (view === "unread") data = (await fetchUnreadNotifications()).notifications; + else data = await fetchAllNotifications(); + setItems(data ?? []); + } catch { toast.show({ color: "red", message: "Failed to load notifications." }); } + finally { setLoading(false); } + }, [view, module]); + + useEffect(() => { load(); }, [load]); + + useEffect(() => { + fetchUnreadCount().then((c) => { updateCount(c); }).catch(() => {}); + }, [items]); + + const handleMarkRead = async (id) => { await markRead(id); setItems((p) => p.map((n) => n.id===id?{...n,unread:false}:n)); updateCount(Math.max(0, unreadCount-1)); }; + const handleMarkUnread = async (id) => { await markUnread(id); setItems((p) => p.map((n) => n.id===id?{...n,unread:true}:n)); updateCount(unreadCount+1); }; + const handleDelete = async (id) => { const was=items.find((n)=>n.id===id)?.unread; await deleteOne(id); setItems((p)=>p.filter((n)=>n.id!==id)); if(was) updateCount(Math.max(0,unreadCount-1)); }; + const handleMarkAllRead = async () => { await markAllRead(); setItems((p)=>p.map((n)=>({...n,unread:false}))); updateCount(0); toast.show({color:"green",message:"All marked as read."}); }; + const handleDeleteAll = async () => { await deleteAll(); setItems([]); updateCount(0); toast.show({color:"green",message:"All notifications deleted."}); }; + + const totalCount = items.length; + + return ( + + {/* Stats row */} + + + + + {totalCount}Total + + + + + + {unreadCount}Unread + + + + + + {totalCount - unreadCount}Read + + + + + {/* Filters + actions */} + + + {setView(v);setModule("all");}} disabled={module!=="all"} + data={[{label:"All",value:"all"},{label:"Unread",value:"unread"}]} size="xs" /> + {setModule(v);if(v!=="all")setView("all");}} w={200} size="xs" /> + + + {unreadCount > 0 && } onClick={handleMarkAllRead}>Mark all read} + } onClick={handleDeleteAll}>Archive all + + + + {loading ? ( + Loading notifications… + ) : ( + + )} + + ); +} + +// ─── Tab: Preferences ───────────────────────────────────────────────────────── + +function PreferencesTab() { + const [prefs, setPrefs] = useState([]); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + + useEffect(() => { + fetchPreferences() + .then(setPrefs) + .catch(()=>toast.show({color:"red",message:"Failed to load preferences."})) + .finally(()=>setLoading(false)); + }, []); + + const handleChange = async (module, is_enabled) => { + setSaving(true); + try { + const updated = await setPreference(module, is_enabled); + setPrefs((p)=>p.map((x)=>x.module===module?{...x,is_enabled:updated.is_enabled}:x)); + } catch { toast.show({color:"red",message:"Failed to update preference."}); } + finally { setSaving(false); } + }; + + const enabledCount = prefs.filter((p)=>p.is_enabled).length; + const disabledCount = prefs.filter((p)=>!p.is_enabled).length; + + if (loading) return Loading preferences…; + + return ( + + } color="blue" variant="light" title="BR-NT-06 — Per-Module Opt-Out"> + Toggle off any module to stop receiving its notifications. Modules toggled off will be silently skipped when a notification is triggered — unless it's critical priority (BR-NT-05). + + + + {enabledCount} enabled + {disabledCount} disabled + + + + {prefs.map((pref, i) => ( + + + {i < prefs.length-1 && } + + ))} + {prefs.length===0 && No preferences found.} + + + ); +} + +// ─── Tab: Send ──────────────────────────────────────────────────────────────── + +function SendTab() { + const [moduleType, setModuleType] = useState(null); + const [priority, setPriority] = useState("medium"); + const [fields, setFields] = useState({ recipient: "" }); + const [sending, setSending] = useState(false); + const [cooldown, setCooldown] = useState(0); + const cooldownRef = useRef(null); + + useEffect(() => { + if (cooldown <= 0) return; + cooldownRef.current = setInterval(()=>{ + setCooldown((c)=>{ if(c<=1){clearInterval(cooldownRef.current);return 0;} return c-1; }); + }, 1000); + return ()=>clearInterval(cooldownRef.current); + }, [cooldown > 0]); + + const handleSend = async () => { + if (!moduleType) return toast.show({color:"red",message:"Select a module first."}); + if (!fields.recipient) return toast.show({color:"red",message:"Recipient username is required."}); + setSending(true); + try { + await dispatchSend(moduleType, {...fields, priority}); + toast.show({color:"green",message:"Notification sent successfully."}); + setFields({recipient:fields.recipient}); + setCooldown(60); + } catch (err) { + const status = err?.response?.status; + const msg = status===403 + ? "BR-NT-03: Only staff/admin can send this notification." + : err?.response?.data?.error ? JSON.stringify(err.response.data.error) : "Failed to send."; + toast.show({color:"red",message:msg}); + } finally { setSending(false); } + }; + + return ( + + } color="gray" variant="light"> + Trigger any module-specific notification directly from the UI. Use this to demonstrate all 15 use cases. + + + + + {setModuleType(v);setFields({recipient:fields.recipient});}} searchable required /> + setFields((f)=>({...f,recipient:e.currentTarget.value}))} required /> + + + {priority==="critical" && ( + } color="red" variant="light" title="BR-NT-05 Active"> + This notification will bypass the recipient's module preferences and be delivered immediately. + + )} + + {moduleType && ( + <> + + + > + )} + + + 0} + color={priority==="critical"?"red":undefined} + leftSection={}> + {cooldown > 0 ? `Wait ${cooldown}s (BR-NT-04)` : "Send Notification"} + + + + + + ); +} + +// ─── Tab: Announcements ─────────────────────────────────────────────────────── + +function daysLeft(d) { return Math.max(0, Math.ceil((new Date(d)-new Date())/(86400000))); } + +function AnnouncementsTab() { + const [title, setTitle] = useState(""); + const [message, setMessage] = useState(""); + const [audienceType, setAudienceType] = useState("all"); + const [audienceValue, setAudienceValue] = useState(""); + const [expiryDate, setExpiryDate] = useState(null); + const [sending, setSending] = useState(false); + const [list, setList] = useState([]); + const [loadingList, setLoadingList] = useState(true); + const [preview, setPreview] = useState(null); + const [previewLoading, setPreviewLoading] = useState(false); + + const load = useCallback(async ()=>{ + setLoadingList(true); + try { setList(await fetchActiveAnnouncements()); } + catch { toast.show({color:"red",message:"Failed to load announcements."}); } + finally { setLoadingList(false); } + },[]); + + useEffect(()=>{ load(); },[load]); + + // Fetch recipient preview whenever audience selection changes + useEffect(()=>{ + if (audienceType === "group" && !audienceValue) { setPreview(null); return; } + setPreviewLoading(true); + previewAudience(audienceType, audienceType === "group" ? audienceValue : "") + .then(setPreview) + .catch(()=>setPreview(null)) + .finally(()=>setPreviewLoading(false)); + },[audienceType, audienceValue]); + + const handleBroadcast = async () => { + if (!title.trim()||!message.trim()||!expiryDate) return toast.show({color:"orange",message:"Fill all required fields."}); + if (audienceType==="group"&&!audienceValue.trim()) return toast.show({color:"orange",message:"Enter a designation name."}); + setSending(true); + try { + await broadcastAnnouncement({ title:title.trim(), message:message.trim(), audience_type:audienceType, + audience_value:audienceType==="group"?audienceValue.trim():"", expiry_date:expiryDate.toISOString().split("T")[0] }); + toast.show({color:"green",message:"Announcement broadcasted successfully."}); + setTitle(""); setMessage(""); setAudienceType("all"); setAudienceValue(""); setExpiryDate(null); + load(); + } catch (err) { + toast.show({color:"red",message:err?.response?.data?.error||"Failed to broadcast."}); + } finally { setSending(false); } + }; + + return ( + + + + + UC-NT-03 — Broadcast New Announcement + + + setTitle(e.target.value)} /> + setMessage(e.target.value)} /> + + { setAudienceType(v); setAudienceValue(""); setPreview(null); }} required /> + {audienceType==="group" && ( + ({value:d,label:d}))} + value={audienceValue} onChange={setAudienceValue} searchable required /> + )} + + + {/* Recipient Preview */} + {previewLoading && ( + Resolving recipients from database… + )} + {!previewLoading && preview && ( + 0 ? "blue" : "orange"} radius="md" + title={`Recipients: ${preview.count} user${preview.count !== 1 ? "s" : ""} will receive this broadcast`}> + {preview.count > 0 ? ( + + {preview.users.map((u) => ( + {u} + ))} + + ) : ( + No users found for this audience in the database. + )} + + )} + + + + }> + Broadcast + + + + + + + + {loadingList ? ( + Loading… + ) : list.length===0 ? ( + + + + No active announcements + + + ) : ( + + {list.map((a)=>( + + + {a.title} + + + {AUDIENCE_OPTIONS.find((o)=>o.value===a.audience_type)?.label??a.audience_type} + {a.audience_value?` — ${a.audience_value}`:""} + + + {daysLeft(a.expiry_date)===0?"Expires today":`${daysLeft(a.expiry_date)}d left`} + + + + {a.message} + + Sent by {a.sender_username} + {a.created_at&&<> · {new Date(a.created_at).toLocaleString()}>} + + + ))} + + )} + + ); +} + +// ─── Tab: Event Types ───────────────────────────────────────────────────────── + +function EventTypesTab() { + const [eName, setEName] = useState(""); + const [eMod, setEMod] = useState(MODULE_OPTIONS[0].value); + const [ePri, setEPri] = useState("medium"); + const [eDesc, setEDesc] = useState(""); + const [regBusy,setRegBusy]= useState(false); + const [tId, setTId] = useState(""); + const [tRec, setTRec] = useState(""); + const [tMsg, setTMsg] = useState(""); + const [tLink, setTLink] = useState("#"); + const [trigBusy,setTrigBusy]=useState(false); + const [list, setList] = useState([]); + const [listBusy,setListBusy]=useState(true); + + const loadList = useCallback(async()=>{ + setListBusy(true); + try { setList(await fetchEventTypes()); } + catch { toast.show({color:"red",message:"Failed to load event types."}); } + finally { setListBusy(false); } + },[]); + + useEffect(()=>{ loadList(); },[loadList]); + + const handleRegister = async () => { + if (!eName.trim()) return toast.show({color:"orange",message:"Event name is required."}); + setRegBusy(true); + try { + await registerEventType({event_name:eName.trim(),module:eMod,default_priority:ePri,description:eDesc.trim()}); + toast.show({color:"green",message:`Event type "${eName}" registered.`}); + setEName(""); setEDesc(""); loadList(); + } catch (err) { toast.show({color:"red",message:err?.response?.data?.error||"Registration failed."}); } + finally { setRegBusy(false); } + }; + + const handleTrigger = async () => { + if (!tId.trim()||!tRec.trim()||!tMsg.trim()) return toast.show({color:"orange",message:"Event ID, recipient, and message are required."}); + setTrigBusy(true); + try { + await triggerEventNotification({event_id:tId.trim(),recipient_username:tRec.trim(),message_content:tMsg.trim(),deep_link:tLink.trim()||"#"}); + toast.show({color:"green",message:"Notification triggered successfully."}); + setTMsg(""); + } catch (err) { + const status=err?.response?.status; + const detail=err?.response?.data?.error||"Trigger failed."; + if (status===429) toast.show({color:"orange",title:"Duplicate suppressed (BR-NT-04)",message:detail,autoClose:5000}); + else toast.show({color:"red",message:detail}); + } finally { setTrigBusy(false); } + }; + + const pc=(p)=>({low:"gray",medium:"blue",high:"orange",critical:"red"}[p]??"gray"); + + return ( + + + {/* UC-NT-01 */} + + + + UC-NT-01 — Register Event Type + + + setEName(e.target.value)} /> + + + setEDesc(e.target.value)} /> + }> + Register + + + + + {/* UC-NT-02 */} + + + + UC-NT-02 — Trigger by Event ID + + + setTId(e.target.value)} /> + setTRec(e.target.value)} /> + setTMsg(e.target.value)} /> + setTLink(e.target.value)} /> + }> + Trigger + + + + + + + + {listBusy ? ( + Loading… + ) : list.length===0 ? ( + No event types registered yet. + ) : ( + + + + + Event NameModule + PriorityStatus + Event IDRegistered By + + + + {list.map((et)=>( + + {et.event_name} + {et.module} + {et.default_priority} + {et.is_active?"Active":"Inactive"} + {et.event_id} + {et.registered_by_username??"—"} + + ))} + + + + )} + + ); +} + +// ─── Tab: Use Cases ─────────────────────────────────────────────────────────── + +function UseCasesTab() { + return ( + + {/* UC-NT-03 RBAC section */} + + + UC-NT-03 + Broadcast Announcements — RBAC Audience Types + + + Audience is resolved using real data from globals_extrainfo and globals_holdsdesignation tables in fusionlab DB. + + + {[ + { type:"all", label:"All Users", desc:"Every auth_user", color:"gray" }, + { type:"students", label:"All Students", desc:"user_type = 'student'", color:"blue" }, + { type:"faculty", label:"All Faculty", desc:"user_type = 'faculty'", color:"cyan" }, + { type:"staff", label:"All Staff", desc:"user_type = 'staff'", color:"green" }, + ].map((a)=>( + + {a.type} + {a.label} + {a.desc} + + ))} + + group (BR-NT-07) + Specific Designation + From globals_designation: + + {DESIGNATIONS.map((d)=>{d})} + + + + + + {/* Module use cases accordion */} + + {USE_CASES.map((section)=>( + + + + {section.module} + {section.cases.length} trigger{section.cases.length!==1?"s":""} + + + + + {section.cases.map((uc, i)=>( + + + {uc.trigger} + {uc.actor} + + {uc.description} + + ))} + + + + ))} + + + ); +} + +// ─── Tab: Business Rules ────────────────────────────────────────────────────── + +function BusinessRulesTab() { + const colorMap = { red:"#fa5252", orange:"#fd7e14", grape:"#be4bdb", blue:"#228be6", teal:"#12b886", gray:"#868e96" }; + return ( + + } color="blue" variant="light" title="6 Business Rules Enforced"> + All rules are enforced server-side in services.py. The frontend demonstrates each rule live. + + + {BUSINESS_RULES.map((rule)=>( + + + {rule.icon} + + + {rule.id} + {rule.title} + + {rule.description} + + + + + + + + Enforced in: + {rule.enforcement} + + {rule.codeSnippet} + + How to demo: + {rule.demo} + + {rule.extra && ( + + {rule.extra.label}: + + {rule.extra.items.map((item)=>( + {item} + ))} + + + )} + + + ))} + + ); +} diff --git a/src/Modules/Notification/api.js b/src/Modules/Notification/api.js new file mode 100644 index 000000000..71a93c463 --- /dev/null +++ b/src/Modules/Notification/api.js @@ -0,0 +1,205 @@ +/** + * api.js — Axios calls for the Notification module. + * Every function reads the token from localStorage and talks to + * the Django REST endpoints defined in notificationRoutes/index.jsx. + */ + +import axios from "axios"; +import { + notificationsRoute, + unreadNotificationsRoute, + unreadCountRoute, + markAllReadRoute, + markAllUnreadRoute, + deleteAllRoute, + preferencesRoute, + setPreferenceRoute, + sendRoute, + markReadRoute, + markUnreadRoute, + deleteOneRoute, + byModuleRoute, + notifyLeaveRoute, + notifyMessRoute, + notifyHostelRoute, + notifyHealthcareRoute, + notifyScholarshipRoute, + notifyDeanPnDRoute, + notifyDeanStudentsRoute, + notifyDeanRSPCRoute, + notifyGymkhanaVotingRoute, + notifyGymkhanaSessionRoute, + notifyGymkhanaEventRoute, + notifyResearchRoute, + notifyAssistantshipApprovedRoute, + notifyAssistantshipFacultyRoute, + notifyAssistantshipAcadRoute, + notifyAssistantshipAccountsRoute, + notifyComplaintRoute, + notifyFileTrackingRoute, + notifyPlacementRoute, + notifyAcademicsRoute, + notifyDepartmentRoute, + // UC-NT-01 + eventTypesRoute, + registerEventTypeRoute, + eventTypeDetailRoute, + // UC-NT-02 + triggerEventRoute, + // Group send + sendGroupRoute, + // UC-NT-03 + announcementsRoute, + allAnnouncementsRoute, + broadcastRoute, + // UC-NT-04 + openNotificationRoute, +} from "../../routes/notificationRoutes"; + +const authHeader = () => ({ + Authorization: `Token ${localStorage.getItem("authToken")}`, +}); + +// ── Selectors (GET) ────────────────────────────────────────────────────────── + +export const fetchAllNotifications = async () => { + const { data } = await axios.get(notificationsRoute, { headers: authHeader() }); + return data.notifications; +}; + +export const fetchUnreadNotifications = async () => { + const { data } = await axios.get(unreadNotificationsRoute, { headers: authHeader() }); + return data; +}; + +export const fetchUnreadCount = async () => { + const { data } = await axios.get(unreadCountRoute, { headers: authHeader() }); + return data.unread_count; +}; + +export const fetchNotificationsByModule = async (module) => { + const { data } = await axios.get(byModuleRoute(module), { headers: authHeader() }); + return data.notifications; +}; + +export const fetchPreferences = async () => { + const { data } = await axios.get(preferencesRoute, { headers: authHeader() }); + return data.preferences; +}; + +// ── Services (PATCH / POST / DELETE) ──────────────────────────────────────── + +export const markRead = async (id) => { + const { data } = await axios.patch(markReadRoute(id), {}, { headers: authHeader() }); + return data; +}; + +export const markUnread = async (id) => { + const { data } = await axios.patch(markUnreadRoute(id), {}, { headers: authHeader() }); + return data; +}; + +export const markAllRead = async () => { + const { data } = await axios.patch(markAllReadRoute, {}, { headers: authHeader() }); + return data; +}; + +export const markAllUnread = async () => { + const { data } = await axios.patch(markAllUnreadRoute, {}, { headers: authHeader() }); + return data; +}; + +export const deleteOne = async (id) => { + const { data } = await axios.delete(deleteOneRoute(id), { headers: authHeader() }); + return data; +}; + +export const deleteAll = async () => { + const { data } = await axios.delete(deleteAllRoute, { headers: authHeader() }); + return data; +}; + +export const setPreference = async (module, is_enabled) => { + const { data } = await axios.post( + setPreferenceRoute, + { module, is_enabled }, + { headers: authHeader() }, + ); + return data.preference; +}; + +// ── Module-specific notification senders (POST) ────────────────────────────── + +const post = (url, payload) => + axios.post(url, payload, { headers: authHeader() }).then((r) => r.data); + +export const sendGeneric = (payload) => post(sendRoute, payload); +export const sendGroupNotification = (payload) => post(sendGroupRoute, payload); +export const notifyLeave = (payload) => post(notifyLeaveRoute, payload); +export const notifyMess = (payload) => post(notifyMessRoute, payload); +export const notifyHostel = (payload) => post(notifyHostelRoute, payload); +export const notifyHealthcare = (payload) => post(notifyHealthcareRoute, payload); +export const notifyScholarship = (payload) => post(notifyScholarshipRoute, payload); +export const notifyDeanPnD = (payload) => post(notifyDeanPnDRoute, payload); +export const notifyDeanStudents = (payload) => post(notifyDeanStudentsRoute, payload); +export const notifyDeanRSPC = (payload) => post(notifyDeanRSPCRoute, payload); +export const notifyGymkhanaVoting = (payload) => post(notifyGymkhanaVotingRoute, payload); +export const notifyGymkhanaSession = (payload) => post(notifyGymkhanaSessionRoute, payload); +export const notifyGymkhanaEvent = (payload) => post(notifyGymkhanaEventRoute, payload); +export const notifyResearch = (payload) => post(notifyResearchRoute, payload); +export const notifyAssistantshipApproved = (payload) => post(notifyAssistantshipApprovedRoute, payload); +export const notifyAssistantshipFaculty = (payload) => post(notifyAssistantshipFacultyRoute, payload); +export const notifyAssistantshipAcad = (payload) => post(notifyAssistantshipAcadRoute, payload); +export const notifyAssistantshipAccounts = (payload) => post(notifyAssistantshipAccountsRoute, payload); +export const notifyComplaint = (payload) => post(notifyComplaintRoute, payload); +export const notifyFileTracking = (payload) => post(notifyFileTrackingRoute, payload); +export const notifyPlacement = (payload) => post(notifyPlacementRoute, payload); +export const notifyAcademics = (payload) => post(notifyAcademicsRoute, payload); +export const notifyDepartment = (payload) => post(notifyDepartmentRoute, payload); + +// ── UC-NT-01: Event Type Registry ──────────────────────────────────────────── + +export const fetchEventTypes = async () => { + const { data } = await axios.get(eventTypesRoute, { headers: authHeader() }); + return data.event_types; +}; + +export const fetchEventTypeDetail = async (eventId) => { + const { data } = await axios.get(eventTypeDetailRoute(eventId), { headers: authHeader() }); + return data.event_type; +}; + +export const registerEventType = (payload) => post(registerEventTypeRoute, payload); + +// ── UC-NT-02: Trigger notification via Event ID ─────────────────────────────── + +export const triggerEventNotification = (payload) => post(triggerEventRoute, payload); + +// ── UC-NT-03: Announcements ─────────────────────────────────────────────────── + +export const fetchActiveAnnouncements = async () => { + const { data } = await axios.get(announcementsRoute, { headers: authHeader() }); + return data.announcements; +}; + +export const fetchAllAnnouncements = async () => { + const { data } = await axios.get(allAnnouncementsRoute, { headers: authHeader() }); + return data.announcements; +}; + +export const broadcastAnnouncement = (payload) => post(broadcastRoute, payload); + +export const previewAudience = async (audienceType, audienceValue = "") => { + const { data } = await axios.get( + `/api/notifications/announcements/preview-audience/?audience_type=${audienceType}&audience_value=${encodeURIComponent(audienceValue)}`, + { headers: authHeader() } + ); + return data; +}; + +// ── UC-NT-04: Open notification (mark read + get deep link URL) ─────────────── + +export const openNotification = async (id) => { + const { data } = await axios.get(openNotificationRoute(id), { headers: authHeader() }); + return data.url; +}; diff --git a/src/Modules/Notification/components/NotificationBell.jsx b/src/Modules/Notification/components/NotificationBell.jsx new file mode 100644 index 000000000..d99fdfe0c --- /dev/null +++ b/src/Modules/Notification/components/NotificationBell.jsx @@ -0,0 +1,345 @@ +/** + * NotificationBell — UC-NT-04: Global Navbar Notification Tray + * + * Persistent bell icon for the top navbar. Shows an unread count badge. + * Clicking opens a dropdown tray with the latest unread notifications. + * Each item can be marked as read or followed via its deep link. + * Polls the unread count every 30 seconds. + */ + +import { useState, useEffect, useCallback, useRef } from "react"; +import { + ActionIcon, + Badge, + Box, + Button, + Divider, + Drawer, + Group, + Indicator, + Loader, + Paper, + ScrollArea, + Stack, + Text, + Tooltip, + Transition, +} from "@mantine/core"; +import { useNavigate, useLocation } from "react-router-dom"; +import { fetchUnreadNotifications, fetchAllNotifications, fetchUnreadCount, markRead, markAllRead, openNotification } from "../api"; + +const POLL_INTERVAL_MS = 5_000; // 5 s +const TRAY_LIMIT = 8; // max items shown in the dropdown + +// ── Bell SVG icon (inline, no extra dep) ───────────────────────────────────── +function BellIcon({ size = 20 }) { + return ( + + + + + ); +} + +// ── Single notification row inside the tray ─────────────────────────────────── +function TrayItem({ notification, onRead, onNavigate }) { + const timeAgo = (timestamp) => { + const diff = Date.now() - new Date(timestamp).getTime(); + const minutes = Math.floor(diff / 60_000); + if (minutes < 1) return "just now"; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + return `${Math.floor(hours / 24)}d ago`; + }; + + const handleClick = async () => { + // Just mark as read in place — don't redirect to a deep-link route that + // may not exist on the React side. The user stays on whatever page + // they're on; the tray will reflect the new read state. + if (notification.unread) { + await onRead(notification.id); + } + }; + + return ( + + + + + {notification.verb} + + + {notification.module && ( + + {notification.module} + + )} + + {timeAgo(notification.timestamp)} + + + + {notification.unread && ( + + { + e.stopPropagation(); + onRead(notification.id); + }} + > + ✓ + + + )} + + + ); +} + +// ── Main Bell component ─────────────────────────────────────────────────────── +export default function NotificationBell() { + const navigate = useNavigate(); + const location = useLocation(); + const [open, setOpen] = useState(false); + const [drawerOpen, setDrawerOpen] = useState(false); + const [allItems, setAllItems] = useState([]); + const [drawerLoading, setDrawerLoading] = useState(false); + const [unreadCount, setUnreadCount] = useState(0); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); + const trayRef = useRef(null); + + // Close tray on route change (user navigated away) + useEffect(() => { setOpen(false); }, [location.pathname]); + + // Poll unread count + const refreshCount = useCallback(async () => { + try { + const count = await fetchUnreadCount(); + setUnreadCount(count); + } catch { + // silently ignore polling errors + } + }, []); + + useEffect(() => { + refreshCount(); + const id = setInterval(refreshCount, POLL_INTERVAL_MS); + return () => clearInterval(id); + }, [refreshCount]); + + // Load tray contents when opened + const loadTray = useCallback(async () => { + setLoading(true); + try { + const { notifications } = await fetchUnreadNotifications(); + setItems((notifications ?? []).slice(0, TRAY_LIMIT)); + } catch { + setItems([]); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (open) loadTray(); + }, [open, loadTray]); + + // Close on outside click + useEffect(() => { + const handler = (e) => { + if (trayRef.current && !trayRef.current.contains(e.target)) { + setOpen(false); + } + }; + if (open) document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [open]); + + const handleRead = async (id) => { + await markRead(id); + setItems((prev) => prev.map((n) => (n.id === id ? { ...n, unread: false } : n))); + setUnreadCount((c) => Math.max(0, c - 1)); + }; + + const handleNavigate = async (id) => { + const url = await openNotification(id); + setItems((prev) => prev.map((n) => (n.id === id ? { ...n, unread: false } : n))); + setUnreadCount((c) => Math.max(0, c - 1)); + return url; + }; + + const handleViewAll = async () => { + setOpen(false); + setDrawerOpen(true); + setDrawerLoading(true); + try { + const data = await fetchAllNotifications(); + setAllItems(Array.isArray(data) ? data : (data?.notifications ?? [])); + } catch { setAllItems([]); } + finally { setDrawerLoading(false); } + }; + + const handleMarkAllRead = async () => { + await markAllRead(); + setAllItems((prev) => prev.map((n) => ({ ...n, unread: false }))); + setUnreadCount(0); + }; + + return ( + + {/* Bell button — BR-NT-02: fixed on global navbar across all module transitions */} + + 99 ? "99+" : unreadCount} + size={16} + disabled={unreadCount === 0} + color="red" + offset={4} + > + setOpen((v) => !v)} + > + + + + + + {/* Dropdown tray — animated */} + + {(styles) => ( + + {/* Header — BR-NT-01: all notifications routed through this NAM tray */} + + + Notifications + {/* BR-NT-01 */} + NAM + + {unreadCount > 0 && ( + + {unreadCount} unread + + )} + + + + {/* Body */} + {loading ? ( + + + + ) : items.length === 0 ? ( + + No unread notifications + + ) : ( + + + {items.map((n) => ( + + ))} + + + )} + + + {/* Footer */} + + + View all notifications + + + + )} + + + {/* All Notifications Drawer — stays on current page */} + setDrawerOpen(false)} + title={ + + All Notifications + {unreadCount > 0 && ( + + Mark all read + + )} + + } + position="right" + size="md" + padding="md" + > + {drawerLoading ? ( + + ) : allItems.length === 0 ? ( + No notifications + ) : ( + + + {allItems.map((n) => ( + { + await markRead(id); + setAllItems((prev) => prev.map((x) => x.id === id ? { ...x, unread: false } : x)); + setUnreadCount((c) => Math.max(0, c - 1)); + }} + onNavigate={handleNavigate} + /> + ))} + + + )} + + + ); +} diff --git a/src/Modules/Notification/components/NotificationItem.jsx b/src/Modules/Notification/components/NotificationItem.jsx new file mode 100644 index 000000000..e1faccc3d --- /dev/null +++ b/src/Modules/Notification/components/NotificationItem.jsx @@ -0,0 +1,225 @@ +/** + * NotificationItem — single notification row. + * + * Features: + * - Unread indicator (blue left border + background) + * - Module badge + sender chip + * - Relative timestamp ("2 h ago") + * - Click verb/row → marks as read via openNotification() and follows deep link + * - Action buttons: mark read/unread, delete + */ + +import PropTypes from "prop-types"; +import { useState } from "react"; +import { + ActionIcon, + Badge, + Box, + Group, + Loader, + Paper, + Stack, + Text, + Tooltip, +} from "@mantine/core"; +import { Check, ArrowCounterClockwise, Archive, ArrowSquareOut } from "@phosphor-icons/react"; +import { openNotification } from "../api"; + +/* ── helpers ────────────────────────────────────────────────────────────────── */ + +function relativeTime(iso) { + const diff = Date.now() - new Date(iso).getTime(); + const s = Math.floor(diff / 1000); + if (s < 60) return "just now"; + const m = Math.floor(s / 60); + if (m < 60) return `${m} min ago`; + const h = Math.floor(m / 60); + if (h < 24) return `${h} h ago`; + const d = Math.floor(h / 24); + if (d < 7) return `${d} d ago`; + return new Date(iso).toLocaleDateString(); +} + +const MODULE_COLORS = { + "Leave Module": "blue", + "Central Mess": "orange", + "Visitor's Hostel": "teal", + "Healthcare Center": "red", + "File Tracking": "grape", + "Scholarship Portal": "yellow", + "Complaint System": "pink", + "Placement Cell": "indigo", + "Academic's Module": "cyan", + "Office of Dean PnD Module": "lime", + "Office Module": "violet", + "Gymkhana Module": "green", + "Assistantship Request": "brown", + "department": "gray", + "Research Procedures": "dark", +}; + +/* ── component ──────────────────────────────────────────────────────────────── */ + +export default function NotificationItem({ + notification, + onMarkRead, + onMarkUnread, + onDelete, +}) { + const { id, verb, description, timestamp, unread, module, url, sender } = + notification; + const [navigating, setNavigating] = useState(false); + + const moduleColor = MODULE_COLORS[module] || "gray"; + + const handleOpen = async () => { + if (navigating) return; + setNavigating(true); + try { + const deepLink = await openNotification(id); + // optimistically mark read in parent + if (unread) onMarkRead(id); + if (deepLink && deepLink !== "#") { + window.location.href = deepLink; + } + } catch { + // ignore navigation errors silently + } finally { + setNavigating(false); + } + }; + + return ( + + + {/* ── Left: content ── */} + + {/* Module + sender row */} + + {module && ( + + {module} + + )} + {unread && ( + + New + + )} + {sender?.full_name && ( + + from {sender.full_name} + + )} + + + {/* Verb — clickable to follow deep link */} + + + + {verb} + + {navigating ? ( + + ) : url && url !== "#" ? ( + + ) : null} + + + + {/* Description */} + {description && ( + + {description} + + )} + + {/* Timestamp */} + + {relativeTime(timestamp)} + + + + {/* ── Right: action buttons ── */} + + {unread ? ( + + onMarkRead(id)} + > + + + + ) : ( + + onMarkUnread(id)} + > + + + + )} + {/* BR-NT-09: Archive instead of permanent delete */} + + onDelete(id)} + > + + + + + + + ); +} + +NotificationItem.propTypes = { + notification: PropTypes.shape({ + id: PropTypes.number.isRequired, + verb: PropTypes.string.isRequired, + description: PropTypes.string, + timestamp: PropTypes.string.isRequired, + unread: PropTypes.bool.isRequired, + module: PropTypes.string, + url: PropTypes.string, + sender: PropTypes.shape({ + full_name: PropTypes.string, + username: PropTypes.string, + }), + }).isRequired, + onMarkRead: PropTypes.func.isRequired, + onMarkUnread: PropTypes.func.isRequired, + onDelete: PropTypes.func.isRequired, +}; diff --git a/src/Modules/Notification/components/NotificationList.jsx b/src/Modules/Notification/components/NotificationList.jsx new file mode 100644 index 000000000..3f339cef3 --- /dev/null +++ b/src/Modules/Notification/components/NotificationList.jsx @@ -0,0 +1,55 @@ +/** + * NotificationList — wraps multiple NotificationItem rows. + * Shows a contextual empty state depending on whether the inbox is + * filtered by module/unread or simply empty overall. + */ + +import PropTypes from "prop-types"; +import { Center, Stack, Text, ThemeIcon } from "@mantine/core"; +import { BellSlash } from "@phosphor-icons/react"; +import NotificationItem from "./NotificationItem"; + +export default function NotificationList({ + notifications, + onMarkRead, + onMarkUnread, + onDelete, + emptyMessage, +}) { + if (!notifications.length) { + return ( + + + + + + + {emptyMessage ?? "No notifications"} + + + + ); + } + + return ( + + {notifications.map((n) => ( + + ))} + + ); +} + +NotificationList.propTypes = { + notifications: PropTypes.array.isRequired, + onMarkRead: PropTypes.func.isRequired, + onMarkUnread: PropTypes.func.isRequired, + onDelete: PropTypes.func.isRequired, + emptyMessage: PropTypes.string, +}; diff --git a/src/Modules/Notification/components/PreferenceToggle.jsx b/src/Modules/Notification/components/PreferenceToggle.jsx new file mode 100644 index 000000000..8325ce19e --- /dev/null +++ b/src/Modules/Notification/components/PreferenceToggle.jsx @@ -0,0 +1,61 @@ +/** + * PreferenceToggle — one row in the preferences list. + * Shows a coloured module badge, a short description, and an enable/disable Switch. + */ + +import PropTypes from "prop-types"; +import { Badge, Group, Stack, Switch, Text } from "@mantine/core"; + +/* Short descriptions per module */ +const MODULE_META = { + "Leave Module": { color: "blue", desc: "Leave applications, approvals & withdrawals" }, + "Central Mess": { color: "orange", desc: "Menu changes, feedback & committee updates" }, + "Visitor's Hostel": { color: "teal", desc: "Booking confirmations & cancellations" }, + "Healthcare Center": { color: "red", desc: "Appointments, ambulance & prescriptions" }, + "File Tracking": { color: "grape", desc: "Inward file & document routing" }, + "Scholarship Portal": { color: "yellow", desc: "Medal & MCM form status updates" }, + "Complaint System": { color: "pink", desc: "New complaints & status changes" }, + "Placement Cell": { color: "indigo", desc: "Placement drives & announcements" }, + "Academic's Module": { color: "cyan", desc: "Academic notices & events" }, + "Office of Dean PnD Module": { color: "lime", desc: "Requisitions & project assignments" }, + "Office Module": { color: "violet", desc: "Dean Students & RSPC office updates" }, + "Gymkhana Module": { color: "green", desc: "Voting, sessions & club events" }, + "Assistantship Request": { color: "brown", desc: "Assistantship claim approvals & routing" }, + "department": { color: "gray", desc: "Department-level notices" }, + "Research Procedures": { color: "dark", desc: "Patent submissions & approvals" }, +}; + +export default function PreferenceToggle({ module, isEnabled, onChange, loading }) { + const meta = MODULE_META[module] ?? { color: "gray", desc: "" }; + + return ( + + + + + {module} + + + {meta.desc && ( + + {meta.desc} + + )} + + onChange(module, e.currentTarget.checked)} + disabled={loading} + color="blue" + size="sm" + /> + + ); +} + +PreferenceToggle.propTypes = { + module: PropTypes.string.isRequired, + isEnabled: PropTypes.bool.isRequired, + onChange: PropTypes.func.isRequired, + loading: PropTypes.bool, +}; diff --git a/src/Modules/Notification/components/UnreadBadge.jsx b/src/Modules/Notification/components/UnreadBadge.jsx new file mode 100644 index 000000000..0e5f7c5c1 --- /dev/null +++ b/src/Modules/Notification/components/UnreadBadge.jsx @@ -0,0 +1,20 @@ +/** + * UnreadBadge — shows the number of unread notifications. + * Returns null when count is 0. + */ + +import PropTypes from "prop-types"; +import { Badge } from "@mantine/core"; + +export default function UnreadBadge({ count }) { + if (!count) return null; + return ( + + {count > 99 ? "99+" : count} + + ); +} + +UnreadBadge.propTypes = { + count: PropTypes.number.isRequired, +}; diff --git a/src/components/header.jsx b/src/components/header.jsx index b2ed336ce..e6d197c5e 100644 --- a/src/components/header.jsx +++ b/src/components/header.jsx @@ -1,5 +1,6 @@ import { useState } from "react"; -import { User, SignOut, Bell, UserSwitch } from "@phosphor-icons/react"; +import { User, SignOut, UserSwitch } from "@phosphor-icons/react"; +import NotificationBell from "../Modules/Notification/components/NotificationBell"; import { useNavigate } from "react-router-dom"; import { useDispatch, useSelector } from "react-redux"; import axios from "axios"; @@ -7,7 +8,6 @@ import { Avatar, Burger, Flex, - Indicator, Popover, Group, Stack, @@ -141,9 +141,7 @@ function Header({ opened, toggleSidebar }) { onChange={handleRoleChange} placeholder="Role" /> - - - + - + diff --git a/src/helper/validateauth.jsx b/src/helper/validateauth.jsx index 314d6513c..21f26c816 100644 --- a/src/helper/validateauth.jsx +++ b/src/helper/validateauth.jsx @@ -10,6 +10,7 @@ import { setRole, setAccessibleModules, setCurrentAccessibleModules, + setIsStaff, clearUserName, clearRoles, } from "../redux/userslice"; @@ -20,6 +21,11 @@ function ValidateAuth() { const navigate = useNavigate(); const validateUser = useCallback(async () => { + // /notifications is a single-page module — let it mount; individual tabs handle auth errors + if (window.location.pathname === "/notifications") { + return; + } + const token = localStorage.getItem("authToken"); if (!token) { @@ -44,6 +50,8 @@ function ValidateAuth() { accessible_modules = [], last_selected_role, roll_no, + is_staff = false, + is_superuser = false, } = data; // console.log("User Data:", data); @@ -51,6 +59,7 @@ function ValidateAuth() { dispatch(setUserName(name)); dispatch(setRollNo(roll_no)); dispatch(setRoles(designation_info)); + dispatch(setIsStaff(is_staff || is_superuser)); const selectedRole = last_selected_role || designation_info[0] || null; if (selectedRole) dispatch(setRole(selectedRole)); diff --git a/src/main.jsx b/src/main.jsx index 333282baf..0b320c5a1 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -4,6 +4,8 @@ import { BrowserRouter } from "react-router-dom"; import { Provider } from "react-redux"; import { store } from "./redux/store"; import App from "./App"; +import "@mantine/core/styles.css"; +import "@mantine/notifications/styles.css"; import "./index.css"; createRoot(document.getElementById("root")).render( diff --git a/src/redux/userslice.jsx b/src/redux/userslice.jsx index a61699cc8..2f13c5934 100644 --- a/src/redux/userslice.jsx +++ b/src/redux/userslice.jsx @@ -9,6 +9,8 @@ const userSlice = createSlice({ role: "Guest-User", accessibleModules: {}, // Format---> {role: {module: true}} currentAccessibleModules: {}, // Format---> {module: true} + isStaff: false, + totalNotifications: 0, }, reducers: { setUserName: (state, action) => { @@ -33,6 +35,12 @@ const userSlice = createSlice({ clearUserName: (state) => { state.username = "User"; }, + setIsStaff: (state, action) => { + state.isStaff = action.payload; + }, + setTotalNotifications: (state, action) => { + state.totalNotifications = action.payload; + }, clearRoles: (state) => { state.roles = null; }, @@ -46,6 +54,8 @@ export const { setRole, setAccessibleModules, setCurrentAccessibleModules, + setIsStaff, + setTotalNotifications, clearUserName, clearRoles, } = userSlice.actions; diff --git a/src/routes/dashboardRoutes/index.jsx b/src/routes/dashboardRoutes/index.jsx index 007c8f013..46f0b2f9d 100644 --- a/src/routes/dashboardRoutes/index.jsx +++ b/src/routes/dashboardRoutes/index.jsx @@ -2,9 +2,22 @@ import { host } from "../globalRoutes"; export const logoutRoute = `${host}/api/auth/logout/`; export const updateRoleRoute = `${host}/api/update-role/`; -export const getNotificationsRoute = `${host}/api/notification/`; -export const notificationReadRoute = `${host}/api/notificationread`; -export const notificationDeleteRoute = `${host}/api/notificationdelete`; -export const notificationUnreadRoute = `${host}/api/notificationunread`; +export const getNotificationsRoute = `${host}/api/notifications/`; +export const notificationsBaseRoute = `${host}/api/notifications/`; export const getProfileDataRoute = `${host}/api/profile/`; export const updateProfileDataRoute = `${host}/api/profile_update/`; + +// Per-notification action endpoints (new NAM API) +export const notificationReadRoute = (id) => + `${host}/api/notifications/${id}/mark-read/`; +export const notificationUnreadRoute = (id) => + `${host}/api/notifications/${id}/mark-unread/`; +export const notificationDeleteRoute = (id) => + `${host}/api/notifications/${id}/delete/`; +export const notificationStarRoute = (id) => + `${host}/api/notifications/${id}/star/`; + +// Bulk actions +export const markAllReadRoute = `${host}/api/notifications/mark-all-read/`; +export const markAllUnreadRoute = `${host}/api/notifications/mark-all-unread/`; +export const deleteAllRoute = `${host}/api/notifications/delete-all/`; diff --git a/src/routes/globalRoutes/index.jsx b/src/routes/globalRoutes/index.jsx index 2ec9eeb62..0d1538cbb 100644 --- a/src/routes/globalRoutes/index.jsx +++ b/src/routes/globalRoutes/index.jsx @@ -1,4 +1,4 @@ -export const host = "http://127.0.0.1:8000"; +export const host = ""; export const authRoute = `${host}/api/auth/me`; export const loginRoute = `${host}/api/auth/login/`; export const mediaRoute = `${host}/media/`; diff --git a/src/routes/notificationRoutes/index.jsx b/src/routes/notificationRoutes/index.jsx new file mode 100644 index 000000000..c917c483d --- /dev/null +++ b/src/routes/notificationRoutes/index.jsx @@ -0,0 +1,56 @@ +import { host } from "../globalRoutes"; + +export const notificationsRoute = `${host}/api/notifications/`; +export const unreadNotificationsRoute = `${host}/api/notifications/unread/`; +export const unreadCountRoute = `${host}/api/notifications/unread-count/`; +export const markAllReadRoute = `${host}/api/notifications/mark-all-read/`; +export const markAllUnreadRoute = `${host}/api/notifications/mark-all-unread/`; +export const deleteAllRoute = `${host}/api/notifications/delete-all/`; +export const preferencesRoute = `${host}/api/notifications/preferences/`; +export const setPreferenceRoute = `${host}/api/notifications/preferences/set/`; +export const sendRoute = `${host}/api/notifications/send/`; +export const sendGroupRoute = `${host}/api/notifications/send-group/`; + +export const markReadRoute = (id) => `${host}/api/notifications/${id}/mark-read/`; +export const markUnreadRoute = (id) => `${host}/api/notifications/${id}/mark-unread/`; +export const deleteOneRoute = (id) => `${host}/api/notifications/${id}/delete/`; +export const byModuleRoute = (mod) => `${host}/api/notifications/module/${mod}/`; + +// Module-specific send endpoints +export const notifyLeaveRoute = `${host}/api/notifications/notify/leave/`; +export const notifyMessRoute = `${host}/api/notifications/notify/mess/`; +export const notifyHostelRoute = `${host}/api/notifications/notify/hostel/`; +export const notifyHealthcareRoute = `${host}/api/notifications/notify/healthcare/`; +export const notifyScholarshipRoute = `${host}/api/notifications/notify/scholarship/`; +export const notifyDeanPnDRoute = `${host}/api/notifications/notify/dean-pnd/`; +export const notifyDeanStudentsRoute = `${host}/api/notifications/notify/dean-students/`; +export const notifyDeanRSPCRoute = `${host}/api/notifications/notify/dean-rspc/`; +export const notifyGymkhanaVotingRoute = `${host}/api/notifications/notify/gymkhana/voting/`; +export const notifyGymkhanaSessionRoute = `${host}/api/notifications/notify/gymkhana/session/`; +export const notifyGymkhanaEventRoute = `${host}/api/notifications/notify/gymkhana/event/`; +export const notifyResearchRoute = `${host}/api/notifications/notify/research/`; +export const notifyAssistantshipApprovedRoute = `${host}/api/notifications/notify/assistantship/approved/`; +export const notifyAssistantshipFacultyRoute = `${host}/api/notifications/notify/assistantship/faculty/`; +export const notifyAssistantshipAcadRoute = `${host}/api/notifications/notify/assistantship/acad/`; +export const notifyAssistantshipAccountsRoute = `${host}/api/notifications/notify/assistantship/accounts/`; +export const notifyComplaintRoute = `${host}/api/notifications/notify/complaint/`; +export const notifyFileTrackingRoute = `${host}/api/notifications/notify/file-tracking/`; +export const notifyPlacementRoute = `${host}/api/notifications/notify/placement/`; +export const notifyAcademicsRoute = `${host}/api/notifications/notify/academics/`; +export const notifyDepartmentRoute = `${host}/api/notifications/notify/department/`; + +// UC-NT-01: Event Type Registry +export const eventTypesRoute = `${host}/api/notifications/event-types/`; +export const registerEventTypeRoute = `${host}/api/notifications/event-types/register/`; +export const eventTypeDetailRoute = (eventId) => `${host}/api/notifications/event-types/${eventId}/`; + +// UC-NT-02: Trigger via Event ID +export const triggerEventRoute = `${host}/api/notifications/trigger/`; + +// UC-NT-03: Announcements +export const announcementsRoute = `${host}/api/notifications/announcements/`; +export const allAnnouncementsRoute = `${host}/api/notifications/announcements/all/`; +export const broadcastRoute = `${host}/api/notifications/announcements/broadcast/`; + +// UC-NT-04: Deep link open (mark read + return URL) +export const openNotificationRoute = (id) => `${host}/api/notifications/${id}/open/`; diff --git a/vite.config.js b/vite.config.js index 02a7cb94e..67c4be3f3 100644 --- a/vite.config.js +++ b/vite.config.js @@ -3,4 +3,16 @@ import react from "@vitejs/plugin-react"; // https://vitejs.dev/config/ export default defineConfig({ plugins: [react()], + server: { + proxy: { + "/api": { + target: "http://127.0.0.1:8000", + changeOrigin: true, + }, + "/media": { + target: "http://127.0.0.1:8000", + changeOrigin: true, + }, + }, + }, });
{rule.codeSnippet}