iris_backend / src /components /Admin /AdminInterviewManagement.jsx
sameer2026's picture
Fix messaging UI alignment, unsend functionality, and applicant notification bugs
b66d14f
import React, { useState, useEffect, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { supabase } from '../../supabaseClient';
import FullProfileOverlay from '../FullProfileOverlay';
import ScheduleInterviewModal from '../ScheduleInterviewModal';
// --- ICONS ---
const SmallCalendarIcon = () => (<svg style={{ width: '24px', height: '24px', color: 'rgba(255,255,255,0.7)' }} viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" clipRule="evenodd" /></svg>);
const ChevronRightIcon = () => (<svg style={{ width: '16px', height: '16px', marginLeft: '4px' }} viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd" /></svg>);
const CloseIcon = () => <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>;
const SendIcon = () => <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><line x1="22" y1="2" x2="11" y2="13" /><polygon points="22 2 15 22 11 13 2 9 22 2" /></svg>;
// --- NEW FULL CHAT UI MODAL ---
const AdminChatModal = ({ isOpen, onClose, applicant }) => {
const [messages, setMessages] = useState([]);
const [text, setText] = useState('');
const [loading, setLoading] = useState(true);
const [adminId, setAdminId] = useState(null);
const messagesEndRef = useRef(null);
// Fetch chat history
useEffect(() => {
if (!isOpen || !applicant?.userId) return;
const fetchMessages = async () => {
setLoading(true);
try {
const { data: { user } } = await supabase.auth.getUser();
if (!user) return;
setAdminId(user.id);
// Fetch conversation strictly between this Admin and this Applicant
const { data, error } = await supabase
.from('messages')
.select('*')
.or(`and(sender_id.eq.${user.id},receiver_id.eq.${applicant.userId}),and(sender_id.eq.${applicant.userId},receiver_id.eq.${user.id})`)
.order('created_at', { ascending: true });
if (!error && data) {
setMessages(data);
}
} catch (err) {
console.error("Error fetching chat:", err);
} finally {
setLoading(false);
}
};
fetchMessages();
}, [isOpen, applicant]);
// Auto-scroll to bottom of chat
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const sendMsg = async () => {
if (!text.trim() || !adminId || !applicant?.userId) return;
const newMsg = {
id: Date.now(), // Temporary ID for optimistic UI update
sender_id: adminId,
receiver_id: applicant.userId,
content: text.trim(),
created_at: new Date().toISOString()
};
// Update UI instantly
setMessages(prev => [...prev, newMsg]);
setText('');
// Send to database
const { error } = await supabase.from('messages').insert([{
sender_id: adminId,
receiver_id: applicant.userId,
content: newMsg.content
}]);
if (error) console.error("Error sending message:", error);
};
const unsendMsg = async (msgId) => {
if (!window.confirm("Unsend this message?")) return;
// 1. Try to delete the message
const { data, error } = await supabase.from('messages').delete().eq('id', msgId).select();
if (error) {
console.error("Unsend Error:", error);
alert("Failed to unsend message.");
return;
}
// 2. If RLS silently blocks deletion, fallback to update
if (!data || data.length === 0) {
const { error: updateError } = await supabase
.from('messages')
.update({ content: "🚫 This message was unsent" })
.eq('id', msgId);
if (updateError) {
alert("Database policies prevent unsending this message.");
return;
}
setMessages(prev => prev.map(m => m.id === msgId ? { ...m, content: "🚫 This message was unsent" } : m));
} else {
setMessages(prev => prev.filter(m => m.id !== msgId));
}
};
const canUnsend = (rawTime) => {
if (!rawTime) return false;
const msgTime = new Date(rawTime).getTime();
return (Date.now() - msgTime) < 5 * 60 * 1000; // 5 minutes
};
if (!isOpen || !applicant) return null;
return (
<div style={{ position: 'fixed', inset: 0, backgroundColor: 'rgba(0,0,0,0.7)', backdropFilter: 'blur(5px)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 100 }}>
<style>{`.chat-scroll::-webkit-scrollbar { width: 6px; } .chat-scroll::-webkit-scrollbar-track { background: transparent; } .chat-scroll::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 10px; }`}</style>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
style={{
width: '100%', maxWidth: '600px', height: '75vh',
background: 'rgba(15, 23, 42, 0.95)', border: '1px solid rgba(255,255,255,0.1)',
borderRadius: '1.25rem', display: 'flex', flexDirection: 'column',
overflow: 'hidden', boxShadow: '0 25px 50px rgba(0,0,0,0.5)'
}}
>
{/* Chat Header */}
<div style={{ padding: '1.25rem', borderBottom: '1px solid rgba(255,255,255,0.08)', background: 'rgba(0,0,0,0.2)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<img src={applicant.avatar || `https://ui-avatars.com/api/?name=${encodeURIComponent(applicant.name)}&background=random`} alt={applicant.name} style={{ width: '40px', height: '40px', borderRadius: '50%', objectFit: 'cover', border: '2px solid rgba(255,255,255,0.1)' }} />
<div>
<h3 style={{ margin: 0, color: 'white', fontSize: '1.1rem', fontWeight: 'bold' }}>{applicant.name}</h3>
<p style={{ margin: 0, fontSize: '0.8rem', color: '#94a3b8' }}>{applicant.role}</p>
</div>
</div>
<button onClick={onClose} style={{ background: 'rgba(255,255,255,0.05)', border: 'none', color: '#94a3b8', cursor: 'pointer', padding: '0.5rem', borderRadius: '50%', display: 'flex' }}>
<CloseIcon />
</button>
</div>
{/* Chat History */}
<div className="chat-scroll" style={{ flex: 1, padding: '1.5rem', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: '1rem' }}>
{loading ? (
<div style={{ textAlign: 'center', color: '#64748b', marginTop: '2rem' }}>Loading conversation...</div>
) : messages.length === 0 ? (
<div style={{ textAlign: 'center', color: '#64748b', marginTop: '2rem', fontSize: '0.9rem' }}>
No previous messages. Start the conversation!
</div>
) : (
messages.map(m => {
const isMe = m.sender_id === adminId;
return (
<motion.div key={m.id} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} style={{ alignSelf: isMe ? 'flex-end' : 'flex-start', maxWidth: '75%', position: 'relative' }}>
<div style={{
// Use Admin's Red Theme for outgoing bubbles
background: isMe ? 'linear-gradient(135deg, #EF4444 0%, #DC2626 100%)' : 'rgba(255,255,255,0.08)',
color: 'white', padding: '0.85rem 1.25rem', borderRadius: '1.25rem',
borderBottomRightRadius: isMe ? 4 : '1.25rem', borderBottomLeftRadius: isMe ? '1.25rem' : 4,
fontSize: '0.95rem', lineHeight: '1.5',
boxShadow: isMe ? '0 4px 15px rgba(239, 68, 68, 0.3)' : 'none'
}}>
{m.content}
</div>
<div style={{ display: 'flex', justifyContent: isMe ? 'flex-end' : 'flex-start', alignItems: 'center', gap: '8px', marginTop: '4px' }}>
<div style={{ fontSize: '0.7rem', color: '#64748b', padding: '0 0.5rem' }}>
{new Date(m.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</div>
{isMe && canUnsend(m.created_at) && (
<button onClick={() => unsendMsg(m.id)} title="Unsend message (5 min window)" style={{ background: 'transparent', border: 'none', color: '#ef4444', cursor: 'pointer', padding: '0 2px', display: 'flex', alignItems: 'center' }}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path><line x1="10" y1="11" x2="10" y2="17"></line><line x1="14" y1="11" x2="14" y2="17"></line></svg>
</button>
)}
</div>
</motion.div>
);
})
)}
<div ref={messagesEndRef} />
</div>
{/* Input Area */}
<div style={{ padding: '1.25rem', background: 'rgba(0,0,0,0.3)', borderTop: '1px solid rgba(255,255,255,0.05)' }}>
<div style={{ display: 'flex', gap: '0.75rem', background: 'rgba(255,255,255,0.03)', borderRadius: '1rem', padding: '0.5rem', border: '1px solid rgba(255,255,255,0.1)' }}>
<textarea
value={text}
onChange={e => setText(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMsg(); } }}
placeholder="Type a message..."
style={{ flex: 1, padding: '0.75rem', border: 'none', background: 'transparent', color: 'white', outline: 'none', resize: 'none', height: '45px', fontFamily: 'inherit' }}
/>
<button
onClick={sendMsg}
disabled={!text.trim()}
style={{
background: text.trim() ? '#EF4444' : 'rgba(255,255,255,0.05)',
color: text.trim() ? 'white' : '#64748b', border: 'none', borderRadius: '50%',
width: '45px', height: '45px', cursor: text.trim() ? 'pointer' : 'not-allowed',
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
transition: 'all 0.2s ease'
}}
>
<SendIcon />
</button>
</div>
</div>
</motion.div>
</div>
);
};
// --- MAIN COMPONENT ---
export default function AdminInterviewManagement() {
const [activeSubTab, setActiveSubTab] = useState('interviews');
const [loading, setLoading] = useState(true);
const [applicants, setApplicants] = useState({ interviews: [], accepted: [], rejected: [] });
// Modals State
const [isScheduleModalOpen, setIsScheduleModalOpen] = useState(false);
const [isMessageModalOpen, setIsMessageModalOpen] = useState(false);
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [selectedApplicant, setSelectedApplicant] = useState(null);
const [drawerCandidate, setDrawerCandidate] = useState(null);
// ✅ FIXED: Marked this function as 'async' to resolve the Syntax Error
const fetchData = async () => {
try {
setLoading(true);
// Join applications with profiles, jobs, and CHECK interviews table
const { data, error } = await supabase
.from('applications')
.select(`
id, user_id, job_id, created_at, status, experience, skills, match_score, resume_url,
profiles ( id, full_name, email, avatar_url, phone, location, summary, headline, current_position, education, work_experience, experience_years, projects, skills, technical_skills, resume_url ),
jobs ( id, title ),
interviews ( id, date, time, status, created_at )
`)
.order('created_at', { ascending: false });
if (error) throw error;
const categorized = { interviews: [], accepted: [], rejected: [] };
data.forEach(app => {
// Check if an interview row exists for this application (Get the LATEST one)
const interviews = app.interviews || [];
interviews.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
const interviewData = interviews.length > 0 ? interviews[0] : null;
// Combine skills and technical_skills
const profileSkills = Array.isArray(app.profiles?.skills) ? app.profiles.skills : [];
const profileTechSkills = Array.isArray(app.profiles?.technical_skills) ? app.profiles.technical_skills : [];
const parsedTechSkills = (typeof app.profiles?.technical_skills === 'string')
? app.profiles.technical_skills.split(',').map(s => s.trim())
: profileTechSkills;
const combinedSkills = [...new Set([...profileSkills, ...parsedTechSkills])];
const finalSkills = combinedSkills.length > 0
? combinedSkills
: (Array.isArray(app.skills) ? app.skills : (app.skills ? [app.skills] : []));
const formattedApp = {
...app,
full_name: app.profiles?.full_name,
email: app.profiles?.email,
phone: app.profiles?.phone,
location: app.profiles?.location,
summary: app.profiles?.summary,
headline: app.profiles?.headline,
current_position: app.profiles?.current_position,
education: app.profiles?.education,
work_experience: app.profiles?.work_experience,
projects: app.profiles?.projects,
name: app.profiles?.full_name || 'Unknown User',
role: app.jobs?.title || 'Unknown Role',
avatar: app.profiles?.avatar_url,
resumeUrl: app.resume_url || app.profiles?.resume_url,
userId: app.profiles?.id || app.user_id,
jobId: app.job_id,
experience: (app.experience === '0' || app.experience === 0)
? 'Fresher'
: (app.experience ? `${app.experience} years` : 'N/A'),
skills: finalSkills,
interviewId: interviewData?.id,
date: interviewData ? interviewData.date : 'Not Scheduled',
time: interviewData ? interviewData.time : '',
};
// --- SORTING LOGIC ---
if (interviewData) {
categorized.interviews.push(formattedApp);
} else if (app.status === 'Accepted' || app.status === 'Approved') {
categorized.accepted.push(formattedApp);
} else if (app.status === 'Rejected') {
categorized.rejected.push(formattedApp);
}
});
setApplicants(categorized);
} catch (error) {
console.error('Error fetching data:', error);
} finally {
setLoading(false);
}
};
// 1. Fetch Data on Component Mount
useEffect(() => {
fetchData();
}, []);
// 2. Updated Schedule Handler
// ✅ Helper: Send message to candidate
const sendMessageToCandidate = async (candidateUserId, message) => {
try {
const { data: { user: adminUser } } = await supabase.auth.getUser();
if (!adminUser) return;
const { error } = await supabase.from('messages').insert([{
sender_id: adminUser.id,
receiver_id: candidateUserId,
content: message,
is_read: false
}]);
if (error) console.error('Error sending message:', error);
} catch (err) {
console.error('Failed to send message:', err);
}
};
const handleScheduleConfirm = async (scheduleData) => {
if (!selectedApplicant) return;
const { date, time, interviewType, mode, details, interviewerName, interviewerRole } = scheduleData;
try {
const scheduledTimeISO = new Date(`${date}T${time}:00`).toISOString();
const interviewPayload = {
application_id: selectedApplicant.id,
scheduled_time: scheduledTimeISO,
date, time,
status: 'Scheduled',
interview_type: interviewType,
mode: mode,
meeting_link: mode === 'Online' ? details : null,
location: mode === 'Offline' ? details : null,
interviewer_name: interviewerName,
interviewer_role: interviewerRole,
duration_mins: 45
};
let dbError;
if (selectedApplicant.interviewId) {
const { error } = await supabase
.from('interviews')
.update(interviewPayload)
.eq('id', selectedApplicant.interviewId);
dbError = error;
} else {
const { error } = await supabase
.from('interviews')
.insert([interviewPayload]);
dbError = error;
}
if (dbError) throw dbError;
// ✅ Send interview scheduled message to candidate
const interviewDatesTime = `${date} at ${time}`;
const modeInfo = mode === 'Online' ? `via ${details}` : `at ${details}`;
const jobContext = selectedApplicant.role ? ` for ${selectedApplicant.role}` : '';
await sendMessageToCandidate(
selectedApplicant.userId,
`📅 Great news! Your interview${jobContext} has been scheduled for ${interviewDatesTime} (${interviewType}) ${modeInfo}. Interviewer: ${interviewerName} (${interviewerRole}). Please confirm your availability.`
);
if (selectedApplicant.email) {
await supabase.functions.invoke('send-interview-email', {
body: {
candidateName: selectedApplicant.name,
candidateEmail: selectedApplicant.email,
role: selectedApplicant.role,
date, time, mode, details
}
});
}
alert("Interview Scheduled Successfully and candidate notified!");
setIsScheduleModalOpen(false);
fetchData();
} catch (error) {
console.error("Error scheduling:", error);
alert(`Failed to schedule: ${error.message}`);
}
};
const openScheduleModal = (applicant) => { setSelectedApplicant(applicant); setIsScheduleModalOpen(true); };
const openMessageModal = (applicant) => { setSelectedApplicant(applicant); setIsMessageModalOpen(true); };
const openDrawer = (applicant) => { setDrawerCandidate(applicant); setIsDrawerOpen(true); };
const primaryButtonStyle = { backgroundColor: '#EF4444', color: 'white', padding: '0.5rem 1rem', borderRadius: '9999px', fontWeight: '500', cursor: 'pointer', border: 'none', display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%' };
const secondaryButtonStyle = { backgroundColor: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.2)', color: 'white', padding: '0.5rem 1rem', borderRadius: '9999px', fontWeight: '500', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%' };
return (
<div>
<header style={{ marginBottom: '2rem' }}><h1 style={{ fontSize: '1.875rem', fontWeight: 'bold' }}>Interview Management</h1></header>
{/* Tabs */}
<div style={{ display: 'flex', justifyContent: 'center', width: '100%', marginBottom: '2rem' }}>
<nav style={{ position: 'relative', display: 'inline-flex', gap: '1rem', backgroundColor: 'rgba(255, 255, 255, 0.1)', borderRadius: '1rem', padding: '0.5rem' }}>
{['interviews', 'accepted', 'rejected'].map(key => (
<div key={key} onClick={() => setActiveSubTab(key)} style={{ padding: '0.75rem 1.5rem', borderRadius: '0.5rem', cursor: 'pointer', color: key === activeSubTab ? '#F87171' : '#d1d5db', fontWeight: key === activeSubTab ? 'bold' : 'normal', backgroundColor: key === activeSubTab ? 'rgba(239, 68, 68, 0.2)' : 'transparent' }}>
{key.charAt(0).toUpperCase() + key.slice(1)} ({applicants[key]?.length || 0})
</div>
))}
</nav>
</div>
<AnimatePresence mode="wait">
<motion.div key={activeSubTab} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }}>
{loading ? (<div style={{ textAlign: 'center', color: '#888', padding: '2rem' }}>Loading...</div>) : applicants[activeSubTab].length === 0 ? (<div style={{ textAlign: 'center', color: '#666', padding: '2rem' }}>No candidates found.</div>) : (
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: '1.5rem' }}>
{applicants[activeSubTab].map((applicant, index) => (
<div key={applicant.id || index} style={{ backgroundColor: 'rgba(239, 68, 68, 0.05)', border: '1px solid rgba(239, 68, 68, 0.2)', borderRadius: '1rem', padding: '1.5rem', display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '1rem' }}>
{/* INFO SECTION */}
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '1rem' }}>
<img src={applicant.avatar || `https://ui-avatars.com/api/?name=${encodeURIComponent(applicant.name)}&background=random`} alt={applicant.name} style={{ width: '48px', height: '48px', borderRadius: '50%', objectFit: 'cover' }} />
<div>
<h3 style={{ fontSize: '1.25rem', fontWeight: 'bold' }}>{applicant.name}</h3>
<p style={{ color: '#d1d5db', fontSize: '0.9rem' }}>{applicant.role} • {applicant.experience}</p>
{activeSubTab === 'interviews' && applicant.time && (
<div style={{ marginTop: '12px', fontSize: '0.85rem', color: '#9ca3af', display: 'flex', alignItems: 'center', gap: '6px' }}>
<SmallCalendarIcon /> <span>Interview: <span style={{ color: 'white' }}>{applicant.date} at {applicant.time}</span></span>
</div>
)}
</div>
</div>
{/* BUTTONS SECTION */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', minWidth: '170px' }}>
{activeSubTab === 'interviews' && (<>
<motion.button onClick={() => openScheduleModal(applicant)} whileHover={{ scale: 1.02 }} style={secondaryButtonStyle}>Reschedule</motion.button>
<motion.button onClick={() => openMessageModal(applicant)} whileHover={{ scale: 1.02 }} style={secondaryButtonStyle}>Message</motion.button>
<motion.button onClick={() => openDrawer(applicant)} whileHover={{ scale: 1.02 }} style={primaryButtonStyle}>View CV <ChevronRightIcon /></motion.button>
</>)}
{activeSubTab === 'accepted' && (<>
<motion.button onClick={() => openScheduleModal(applicant)} whileHover={{ scale: 1.02 }} style={secondaryButtonStyle}>Schedule Interview</motion.button>
<motion.button onClick={() => openMessageModal(applicant)} whileHover={{ scale: 1.02 }} style={secondaryButtonStyle}>Send Message</motion.button>
<motion.button onClick={() => openDrawer(applicant)} whileHover={{ scale: 1.02 }} style={primaryButtonStyle}>View CV <ChevronRightIcon /></motion.button>
</>)}
{activeSubTab === 'rejected' && (<>
<motion.button onClick={() => openMessageModal(applicant)} whileHover={{ scale: 1.02 }} style={secondaryButtonStyle}>Send Message</motion.button>
<motion.button onClick={() => openDrawer(applicant)} whileHover={{ scale: 1.02 }} style={primaryButtonStyle}>View CV <ChevronRightIcon /></motion.button>
</>)}
</div>
</div>
))}
</div>
)}
</motion.div>
</AnimatePresence>
{/* --- MODALS --- */}
<AnimatePresence>
{isScheduleModalOpen && (
<ScheduleInterviewModal
isOpen={isScheduleModalOpen}
onClose={() => setIsScheduleModalOpen(false)}
onConfirm={handleScheduleConfirm}
candidateName={selectedApplicant?.name}
/>
)}
</AnimatePresence>
<AnimatePresence>
{isMessageModalOpen && (
<AdminChatModal
isOpen={isMessageModalOpen}
onClose={() => setIsMessageModalOpen(false)}
applicant={selectedApplicant}
/>
)}
</AnimatePresence>
<AnimatePresence>
{isDrawerOpen && (
<FullProfileOverlay
candidate={drawerCandidate}
onClose={() => setIsDrawerOpen(false)}
/>
)}
</AnimatePresence>
</div>
);
}