Spaces:
Sleeping
Sleeping
| 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> | |
| ); | |
| } |