diff --git a/app-frontend/employer-panel/src/components/Header.js b/app-frontend/employer-panel/src/components/Header.js index ec2dad5ed..93db7b4a2 100644 --- a/app-frontend/employer-panel/src/components/Header.js +++ b/app-frontend/employer-panel/src/components/Header.js @@ -2,6 +2,7 @@ import React from 'react'; import { Link, useNavigate } from 'react-router-dom'; import CompanyLogo from './company_logo.svg'; import ProfilePicPlaceHolder from './ProfilePicPlaceHolder.svg'; +import NotificationsPopup from '../pages/NotificationsPopup'; export default function Header() { const navigate = useNavigate(); @@ -55,6 +56,9 @@ export default function Header() { Email )} + {/* Notifications */} + + {/* Avatar */}
navigate("/company-profile")} style={{ cursor: "pointer" }}> Profile diff --git a/app-frontend/employer-panel/src/components/NotificationIcon.svg b/app-frontend/employer-panel/src/components/NotificationIcon.svg new file mode 100644 index 000000000..24849a48a --- /dev/null +++ b/app-frontend/employer-panel/src/components/NotificationIcon.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app-frontend/employer-panel/src/pages/NotificationsPopup.js b/app-frontend/employer-panel/src/pages/NotificationsPopup.js new file mode 100644 index 000000000..5d647fde1 --- /dev/null +++ b/app-frontend/employer-panel/src/pages/NotificationsPopup.js @@ -0,0 +1,208 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { useNavigate } from 'react-router-dom'; +import NotificationIcon from '../components/NotificationIcon.svg'; +import http from '../lib/http'; + +const POLL_INTERVAL_MS = 30_000; + +export default function NotificationsPopup() { + const navigate = useNavigate(); + const [notifications, setNotifications] = useState([]); + const [showPopup, setShowPopup] = useState(false); + const popupRef = useRef(null); + + // Tracks applicant IDs we've already seen per shift, + // so we only notify on genuinely new applications + const prevApplicantsRef = useRef({}); + + // ------------------------------------------------------------------ + // Polling — diffs current applicants against what we last saw + // ------------------------------------------------------------------ + const fetchAndDiffShifts = async () => { + try { + const response = await http.get('/shifts?withApplicantsOnly=true'); + const shifts = (response.data.items || []).filter( + s => s.status === 'open' || s.status === 'applied' + ); + + const newNotifications = []; + + shifts.forEach((shift) => { + const applicants = shift.applicants || []; + const prevIds = prevApplicantsRef.current[shift._id] || []; + + applicants + .filter(a => !prevIds.includes(a._id)) + .forEach((applicant) => { + newNotifications.push({ + id: `${shift._id}-${applicant._id}`, + shiftId: shift._id, + shiftTitle: shift.title, + shiftDate: shift.date, + shiftStartTime: shift.startTime, + shiftEndTime: shift.endTime, + guardName: applicant.name, + isRead: false, + receivedAt: new Date().toISOString(), + }); + }); + + prevApplicantsRef.current[shift._id] = applicants.map(a => a._id); + }); + + if (newNotifications.length > 0) { + setNotifications(prev => [...newNotifications, ...prev]); + } + } catch (err) { + console.error('NotificationsPopup: failed to fetch shifts', err); + } + }; + + useEffect(() => { + fetchAndDiffShifts(); + const interval = setInterval(fetchAndDiffShifts, POLL_INTERVAL_MS); + return () => clearInterval(interval); + }, []); + + // Close popup when clicking outside + useEffect(() => { + const handleClickOutside = (e) => { + if (popupRef.current && !popupRef.current.contains(e.target)) { + setShowPopup(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + // ------------------------------------------------------------------ + // Handlers + // ------------------------------------------------------------------ + const unreadCount = notifications.filter(n => !n.isRead).length; + + const markAllAsRead = () => + setNotifications(prev => prev.map(n => ({ ...n, isRead: true }))); + + const handleBellClick = () => { + setShowPopup(prev => !prev); + // Mark as read when user opens the panel + if (!showPopup && unreadCount > 0) markAllAsRead(); + }; + + const handleNotificationClick = () => { + navigate('/manage-shift'); + setShowPopup(false); + }; + + // ------------------------------------------------------------------ + // Styles (inline to preserve positioning behaviour with parent header) + // ------------------------------------------------------------------ + const popupStyle = { + position: 'absolute', + top: '55px', + right: '0px', + backgroundColor: 'white', + borderRadius: '10px', + boxShadow: '0 4px 20px rgba(0,0,0,0.2)', + width: '320px', + maxHeight: '400px', + overflowY: 'auto', + zIndex: 1000, + color: '#333', + }; + + const notificationItemStyle = (isRead) => ({ + padding: '12px 16px', + borderBottom: '1px solid #f0f0f0', + backgroundColor: isRead ? 'white' : '#eef2ff', + cursor: 'pointer', + transition: 'background-color 0.2s', + }); + + // ------------------------------------------------------------------ + // Render + // ------------------------------------------------------------------ + return ( +
+ +
+ Notifications + + {unreadCount > 0 && ( +
+ {unreadCount > 99 ? '99+' : unreadCount} +
+ )} +
+ + {showPopup && ( +
+ +
+ Notifications + {unreadCount > 0 && ( + + Mark all as read + + )} +
+ + {notifications.length === 0 ? ( +
+ No new applications yet +
+ ) : ( + notifications.map((n) => ( +
+
+ New application — {n.shiftTitle} +
+
+ 👤 {n.guardName} +
+
+ 📅 {new Date(n.shiftDate).toLocaleDateString('en-AU', { + weekday: 'short', day: 'numeric', + month: 'short', year: 'numeric', + })} +
+
+ 🕐 {n.shiftStartTime} – {n.shiftEndTime} +
+
+ )) + )} +
+ )} +
+ ); +} \ No newline at end of file