iris_backend / src /components /Admin /AdminSummary.jsx
Saandraahh's picture
Fix frequent message notifications using localStorage and isMounted checks
4012449
import React, { useEffect, useState, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
// ✅ Go up 2 levels to reach src/supabaseClient
import { supabase } from "../../supabaseClient";
// ✅ Go up 1 level to reach components folder
import StatCard from "../StatCard";
import ApplicationTrendsChart from "../ApplicationTrendsChart";
import ExperienceChart from "../ExperienceChart";
import UpcomingInterviews from "../Adminfront/UpcomingInterviews";
import RecentApplications from "../Adminfront/RecentApplications";
import TopPerformers from "../Adminfront/TopPerformers";
// --- ICONS ---
const UsersIcon = () => (<svg style={{ width: '24px', height: '24px' }} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>);
const BellIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path>
<path d="M13.73 21a2 2 0 0 1-3.46 0"></path>
</svg>
);
export default function AdminSummary({ onNavigate, setActiveTab, selectedChatUserId, setSelectedChatUserId }) {
const containerVariants = { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { staggerChildren: 0.1 } } };
const itemVariants = { hidden: { opacity: 0, y: 20 }, visible: { opacity: 1, y: 0 } };
// --- STATE ---
const [stats, setStats] = useState({ total: 0, pending: 0, accepted: 0, rejected: 0 });
const [applicants, setApplicants] = useState([]);
const [loading, setLoading] = useState(true);
const [userName, setUserName] = useState('Admin');
// ✅ Notification State
const [notifications, setNotifications] = useState([]);
const [showNotifications, setShowNotifications] = useState(false);
const [latestPopup, setLatestPopup] = useState(null); // ✅ Show only latest popup
const notifRef = useRef(null);
// ✅ CLICK OUTSIDE LISTENER
useEffect(() => {
function handleClickOutside(event) {
if (notifRef.current && !notifRef.current.contains(event.target)) {
setShowNotifications(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [notifRef]);
// --- DATA FETCHING ---
useEffect(() => {
fetchDashboardData();
}, []);
// ✅ HELPER: Show popup notification (only latest)
const showPopup = (notif) => {
// Prevent showing the exact same popup multiple times
const popped = localStorage.getItem('popped_messages') || '[]';
let poppedArray = [];
try { poppedArray = JSON.parse(popped); } catch(e) {}
if (poppedArray.includes(notif.id)) return; // Already popped ever
poppedArray.push(notif.id);
if (poppedArray.length > 50) poppedArray.shift(); // Keep local storage clean
localStorage.setItem('popped_messages', JSON.stringify(poppedArray));
setLatestPopup(notif);
// Auto-dismiss after 4 seconds
setTimeout(() => {
setLatestPopup(null);
}, 4000);
};
// ✅ MESSAGE POLLING - Check for new messages every 5 seconds
useEffect(() => {
let isMounted = true;
let pollInterval;
const startPolling = async () => {
const { data: { user } } = await supabase.auth.getUser();
if (!isMounted || !user) return;
let lastMessageId = null;
// ✅ INITIAL FETCH: Get the latest message ID silently so we don't pop up old messages on every remount
try {
const { data: initialMessages } = await supabase
.from('messages')
.select('id')
.eq('receiver_id', user.id)
.order('id', { ascending: false })
.limit(1);
if (!isMounted) return;
if (initialMessages && initialMessages.length > 0) {
lastMessageId = initialMessages[0].id;
}
} catch (err) {
console.error("Initial fetch error:", err);
}
if (!isMounted) return;
pollInterval = setInterval(async () => {
try {
const { data: newMessages } = await supabase
.from('messages')
.select('id, sender_id, content, created_at')
.eq('receiver_id', user.id)
.order('id', { ascending: false })
.limit(1);
if (newMessages && newMessages.length > 0) {
const msg = newMessages[0];
// ✅ Only process if it's TRULY a new message (greater than the initial fetch)
if (lastMessageId === null || msg.id > lastMessageId) {
lastMessageId = msg.id;
// Get sender profile
const { data: senderProfile } = await supabase
.from('profiles')
.select('full_name')
.eq('id', msg.sender_id)
.single();
const senderName = senderProfile?.full_name || 'Candidate';
// Create and add notification
const newNotif = {
id: `msg-${msg.id}`,
type: 'New Message',
text: `📨 New message from ${senderName}`,
time: msg.created_at,
color: '#10b981',
preview: msg.content,
senderId: msg.sender_id // ✅ Store sender ID for navigation
};
setNotifications(prev => {
const exists = prev.some(n => n.id === newNotif.id);
if (exists) return prev;
return [newNotif, ...prev];
});
// ✅ SHOW POPUP (It will now enforce showing only once via localStorage)
showPopup(newNotif);
}
}
} catch (err) {
console.error("Poll error:", err);
}
}, 3000); // Check every 3 seconds
};
startPolling();
return () => {
isMounted = false;
if (pollInterval) clearInterval(pollInterval);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const fetchDashboardData = async () => {
try {
setLoading(true);
// 1. User Name
const { data: { user } } = await supabase.auth.getUser();
if (user) {
const { data: roleData } = await supabase.from('user_roles').select('name').eq('user_id', user.id).single();
if (roleData?.name) setUserName(roleData.name);
}
// 2. Dashboard Stats
const { data: data, error } = await supabase.from('applications').select('id, status, created_at, experience');
if (error) throw error;
if (data) {
setApplicants(data);
setStats({
total: data.length,
pending: data.filter(a => ['Pending', 'Screening', 'Interviewing'].includes(a.status)).length,
accepted: data.filter(a => ['Hired', 'Offered', 'Accepted'].includes(a.status)).length,
rejected: data.filter(a => a.status === 'Rejected').length
});
}
// 3. Notifications Data
const { data: recentApps } = await supabase.from('applications').select('id, created_at, profiles(full_name)').order('created_at', { ascending: false }).limit(3);
const { data: upcomingInterviews } = await supabase.from('interviews').select('id, scheduled_time, applications(profiles(full_name))').gte('scheduled_time', new Date().toISOString()).order('scheduled_time', { ascending: true }).limit(3);
const appNotifs = (recentApps || []).map(app => ({
id: `app-${app.id}`, type: 'New Applicant', text: `${app.profiles?.full_name || 'Candidate'} applied for a job.`, time: app.created_at, color: '#3b82f6'
}));
const intNotifs = (upcomingInterviews || []).map(int => ({
id: `int-${int.id}`, type: 'Interview', text: `Interview with ${int.applications?.profiles?.full_name || 'Candidate'}`, time: int.scheduled_time, color: '#f59e0b'
}));
setNotifications([...appNotifs, ...intNotifs].sort((a, b) => new Date(b.time) - new Date(a.time)));
} catch (error) {
console.error('Error fetching dashboard data:', error);
} finally {
setLoading(false);
}
};
if (loading) return <div style={{ color: 'white', padding: '2rem' }}>Loading Dashboard...</div>;
return (
<motion.div variants={containerVariants} initial="hidden" animate="visible">
{/* ✅ SINGLE SMALL POPUP NOTIFICATION */}
<AnimatePresence>
{latestPopup && (
<motion.div
key="popup"
initial={{ opacity: 0, y: -15, x: 20 }}
animate={{ opacity: 1, y: 0, x: 0 }}
exit={{ opacity: 0, y: -15, x: 20 }}
style={{
position: 'fixed',
top: '20px',
right: '20px',
background: 'rgba(15, 23, 42, 0.98)',
border: `2px solid ${latestPopup.color}`,
borderRadius: '10px',
padding: '0.75rem 1rem',
width: '280px',
zIndex: 9999,
backdropFilter: 'blur(10px)',
boxShadow: '0 8px 24px rgba(0,0,0,0.6)',
fontFamily: 'inherit'
}}
>
<p style={{
color: '#e2e8f0',
margin: '0 0 0.3rem 0',
fontWeight: '600',
fontSize: '0.88rem'
}}>
{latestPopup.text}
</p>
{latestPopup.preview && (
<p style={{
color: '#cbd5e1',
margin: '0',
fontSize: '0.78rem',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
}}>
{latestPopup.preview}
</p>
)}
</motion.div>
)}
</AnimatePresence>
{/* ✅ HEADER SECTION */}
<motion.header
variants={itemVariants}
style={{
display: 'flex',
// Use space-between to push items to far Left and Right
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '2rem'
}}
>
{/* 1. Title (Left) */}
<h1 style={{ fontSize: '1.875rem', fontWeight: 'bold', margin: 0 }}>
Welcome, {userName}!
</h1>
{/* 2. Notification Wrapper (Right) */}
{/* ✅ MATCHING STYLE: Added marginRight: '11rem' to mimic the Post Job button */}
<div style={{ position: 'relative', marginRight: '11rem' }} ref={notifRef}>
<button
onClick={() => setShowNotifications(!showNotifications)}
style={{
background: 'rgba(26, 50, 85, 0.75)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: '50%',
width: '45px', height: '45px',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: 'white', cursor: 'pointer', position: 'relative',
transition: 'all 0.2s'
}}
>
<BellIcon />
{notifications.length > 0 && (
<span style={{
position: 'absolute', top: '0px', right: '0px',
width: '12px', height: '12px',
backgroundColor: '#EF4444', borderRadius: '50%',
border: '2px solid #0f172a'
}}></span>
)}
</button>
{/* ✅ DROPDOWN */}
<AnimatePresence>
{showNotifications && (
<motion.div
initial={{ opacity: 0, y: 10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.95 }}
style={{
position: 'absolute',
top: '55px',
// Align with the button
right: '0',
width: '320px',
backgroundColor: '#1e293b',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: '12px',
boxShadow: '0 10px 25px rgba(0,0,0,0.5)',
zIndex: 50, overflow: 'hidden'
}}
>
<div style={{ padding: '1rem', borderBottom: '1px solid rgba(255,255,255,0.1)', fontWeight: 'bold', fontSize: '0.9rem' }}>
Recent Notification ({notifications.length})
</div>
<div style={{ maxHeight: '300px', overflowY: 'auto' }}>
{notifications.length > 0 ? (
notifications.map(notif => (
<div
key={notif.id}
onClick={() => {
console.log('Notification clicked:', notif.type);
// ✅ Navigate based on notification type
if (notif.type === 'New Message' && notif.senderId) {
// For messages, set the selected chat user and go to messages tab
setSelectedChatUserId(notif.senderId);
}
setActiveTab('messages');
setShowNotifications(false);
// ✅ Remove notification after clicking
setNotifications(prev => prev.filter(n => n.id !== notif.id));
}}
style={{
padding: '1rem',
borderBottom: '1px solid rgba(255,255,255,0.05)',
display: 'flex',
gap: '0.75rem',
alignItems: 'start',
cursor: 'pointer',
transition: 'background 0.2s',
':hover': { background: 'rgba(255,255,255,0.05)' }
}}
onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(255,255,255,0.08)'}
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
>
<div style={{ width: '8px', height: '8px', borderRadius: '50%', backgroundColor: notif.color, marginTop: '6px', flexShrink: 0 }}></div>
<div style={{ flex: 1 }}>
<p style={{ fontSize: '0.85rem', color: '#e2e8f0', margin: '0 0 0.25rem 0', fontWeight: '500' }}>{notif.text}</p>
{notif.preview && (
<p style={{ fontSize: '0.75rem', color: '#cbd5e1', margin: '0.25rem 0 0 0', fontStyle: 'italic' }}>{notif.preview}</p>
)}
<p style={{ fontSize: '0.75rem', color: '#94a3b8', margin: '0.25rem 0 0 0' }}>
{new Date(notif.time).toLocaleDateString()} • {new Date(notif.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</p>
</div>
</div>
))
) : (
<div style={{ padding: '2rem', textAlign: 'center', color: '#64748b', fontSize: '0.85rem' }}>
No new notifications
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</motion.header>
{/* Stat Cards */}
<motion.div variants={itemVariants} style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: '1.5rem', marginBottom: '2rem' }}>
<StatCard icon={<UsersIcon />} value={stats.total} label="Total applicants" tint="239, 68, 68" />
<StatCard icon={<UsersIcon />} value={stats.pending} label="Pending review" tint="239, 68, 68" />
<StatCard icon={<UsersIcon />} value={stats.accepted} label="Accepted applications" tint="34, 197, 94" />
<StatCard icon={<UsersIcon />} value={stats.rejected} label="Rejected applications" tint="100, 116, 139" />
</motion.div>
{/* Main Layout Grid */}
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: '2rem', alignItems: 'flex-start' }}>
{/* --- LEFT COLUMN --- */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
{/* 1. Trends Chart */}
<motion.div
variants={itemVariants}
style={{
backgroundColor: 'rgba(239, 68, 68, 0.05)',
border: '1px solid rgba(239, 68, 68, 0.2)',
borderRadius: '1rem', padding: '1.5rem', height: '350px',
display: 'flex', flexDirection: 'column', overflow: 'hidden'
}}
>
<h2 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '1rem', flexShrink: 0 }}>
Application Trends
</h2>
<div style={{ flex: 1, overflow: 'hidden', position: 'relative' }}>
<ApplicationTrendsChart data={applicants} />
</div>
</motion.div>
{/* 2. Top Performers */}
<motion.div variants={itemVariants}>
<TopPerformers />
</motion.div>
</div>
{/* --- RIGHT COLUMN --- */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
{/* 1. Experience Chart */}
<motion.div variants={itemVariants} style={{ backgroundColor: 'rgba(239, 68, 68, 0.05)', border: '1px solid rgba(239, 68, 68, 0.2)', borderRadius: '1rem', padding: '1.5rem', height: '350px', display: 'flex', flexDirection: 'column' }}>
<h2 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '1rem', flexShrink: 0 }}>Avg. Experience</h2>
<div style={{ flexGrow: 1 }}>
<ExperienceChart applicants={applicants} />
</div>
</motion.div>
{/* 2. Upcoming Interviews */}
<motion.div variants={itemVariants}>
<UpcomingInterviews onNavigate={onNavigate} />
</motion.div>
{/* 3. Recent Applications */}
<motion.div variants={itemVariants}>
<RecentApplications />
</motion.div>
</div>
</div>
</motion.div>
);
}