Spaces:
Sleeping
Sleeping
| import { useState, useEffect, useRef } from 'react'; | |
| import { useParams, useNavigate } from 'react-router-dom'; | |
| import { Button } from '../ui/button'; | |
| import { Badge } from '../ui/badge'; | |
| import { Textarea } from '../ui/textarea'; | |
| import { | |
| Video, VideoOff, Mic, MicOff, PhoneOff, | |
| MessageSquare, ClipboardEdit, Save, Wifi, | |
| AlertTriangle, Clock, CheckCircle2, XCircle, | |
| } from 'lucide-react'; | |
| import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '../ui/dialog'; | |
| import { Label } from '../ui/label'; | |
| import { useAuth } from '../../lib/authContext'; | |
| import { useWebRTC } from '../../hooks/useWebRTC'; | |
| import adminApi from '../../lib/api/adminApi'; | |
| import { toast } from 'sonner'; | |
| export function InterviewSession() { | |
| const { sheikhId } = useParams<{ sheikhId: string }>(); | |
| const navigate = useNavigate(); | |
| const { user } = useAuth(); | |
| // Use "interview-{sheikhId}" as the WebRTC room ID | |
| const roomId = `interview-${sheikhId ?? 'unknown'}`; | |
| const [isVideoOn, setIsVideoOn] = useState(true); | |
| const [isAudioOn, setIsAudioOn] = useState(true); | |
| const [sessionTime, setSessionTime] = useState(0); | |
| const [newMessage, setNewMessage] = useState(''); | |
| const [showChat, setShowChat] = useState(false); | |
| const [showNotes, setShowNotes] = useState(false); | |
| const [notes, setNotes] = useState(''); | |
| const [isSavingNotes, setIsSavingNotes] = useState(false); | |
| const [lastSavedNotes, setLastSavedNotes] = useState(''); | |
| // Post-interview decision dialog | |
| const [showDecision, setShowDecision] = useState(false); | |
| const [decisionType, setDecisionType] = useState<'approve' | 'reject' | null>(null); | |
| const [rejectionReason, setRejectionReason] = useState(''); | |
| const [processing, setProcessing] = useState(false); | |
| const localVideoRef = useRef<HTMLVideoElement>(null); | |
| const remoteVideoRef = useRef<HTMLVideoElement>(null); | |
| const { | |
| localStream, remoteStream, isConnected, | |
| messages, sendChatMessage, | |
| toggleVideo, toggleAudio, connectionQuality, | |
| } = useWebRTC(roomId, user?.id ?? 'admin'); | |
| useEffect(() => { | |
| if (localVideoRef.current && localStream) localVideoRef.current.srcObject = localStream; | |
| }, [localStream]); | |
| useEffect(() => { | |
| if (remoteVideoRef.current && remoteStream) { | |
| remoteVideoRef.current.srcObject = remoteStream; | |
| } | |
| }, [remoteStream]); | |
| // Session timer | |
| useEffect(() => { | |
| const timer = setInterval(() => setSessionTime(prev => prev + 1), 1000); | |
| return () => clearInterval(timer); | |
| }, []); | |
| const formatTime = (seconds: number) => { | |
| const h = Math.floor(seconds / 3600); | |
| const m = Math.floor((seconds % 3600) / 60); | |
| const s = seconds % 60; | |
| return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; | |
| }; | |
| const handleSendMessage = () => { | |
| if (newMessage.trim()) { | |
| sendChatMessage(newMessage); | |
| setNewMessage(''); | |
| } | |
| }; | |
| const handleSaveNotes = async (isAutoSave = false) => { | |
| if (notes === lastSavedNotes) return; | |
| if (!isAutoSave) setIsSavingNotes(true); | |
| try { | |
| // حفظ الملاحظات على السيرفر | |
| await adminApi.saveInterviewNotes(sheikhId, notes); | |
| setLastSavedNotes(notes); | |
| if (!isAutoSave) toast.success('Notes saved successfully'); | |
| } catch (error) { | |
| if (!isAutoSave) toast.error('Failed to save notes'); | |
| } finally { | |
| if (!isAutoSave) setIsSavingNotes(false); | |
| } | |
| }; | |
| // const handleSaveNotes = async (isAutoSave = false) => { | |
| // if (notes === lastSavedNotes) return; | |
| // if (!isAutoSave) setIsSavingNotes(true); | |
| // // Notes are local for now — just mark as saved | |
| // setLastSavedNotes(notes); | |
| // if (!isAutoSave) setIsSavingNotes(false); | |
| // }; | |
| // Auto-save notes every 60s | |
| useEffect(() => { | |
| const timer = setInterval(() => handleSaveNotes(true), 60000); | |
| return () => clearInterval(timer); | |
| }, [notes, lastSavedNotes]); | |
| const handleEndInterview = () => { | |
| setShowDecision(true); | |
| }; | |
| const handleDecisionConfirm = async () => { | |
| if (!sheikhId || !decisionType || processing) return; | |
| setProcessing(true); | |
| try { | |
| if (decisionType === 'approve') { | |
| await adminApi.approveSheikh(Number(sheikhId)); | |
| } else { | |
| await adminApi.rejectSheikh(Number(sheikhId), rejectionReason); | |
| } | |
| setShowDecision(false); | |
| navigate('/admin/sheikh-approval'); | |
| } catch (err) { | |
| console.error('Decision failed:', err); | |
| const apiErr = err as { status?: number; message?: string }; | |
| if (apiErr?.status === 500 && apiErr?.message?.includes('already approved')) { | |
| toast.info('Sheikh was already approved. Redirecting...'); | |
| setShowDecision(false); | |
| navigate('/admin/sheikh-approval'); | |
| } else { | |
| toast.error('Failed to process decision. Please try again.'); | |
| } | |
| } finally { | |
| setProcessing(false); | |
| } | |
| }; | |
| const getConnectionColor = () => { | |
| switch (connectionQuality) { | |
| case 'good': return 'text-emerald-500'; | |
| case 'fair': return 'text-yellow-500'; | |
| case 'poor': return 'text-red-500'; | |
| default: return 'text-gray-500'; | |
| } | |
| }; | |
| const getConnectionLabel = () => { | |
| if (!isConnected) return 'Connecting...'; | |
| switch (connectionQuality) { | |
| case 'good': return 'Excellent'; | |
| case 'fair': return 'Stable'; | |
| case 'poor': return 'Poor Connection'; | |
| default: return 'Waiting for Sheikh...'; | |
| } | |
| }; | |
| return ( | |
| <div className="h-screen bg-gray-900 flex flex-col"> | |
| {/* Header */} | |
| <div className="bg-gray-800 border-b border-gray-700 px-6 py-3"> | |
| <div className="flex items-center justify-between"> | |
| <div> | |
| <h2 className="text-white font-semibold">Verification Interview</h2> | |
| <p className="text-gray-400 text-sm">Sheikh ID #{sheikhId}</p> | |
| </div> | |
| <div className="flex items-center gap-6"> | |
| <div className={`flex items-center gap-1.5 px-3 py-1 rounded-full bg-gray-700/50 border border-gray-600 ${getConnectionColor()}`}> | |
| {connectionQuality === 'poor' | |
| ? <AlertTriangle className="h-3.5 w-3.5" /> | |
| : <Wifi className="h-3.5 w-3.5" />} | |
| <span className="text-xs font-medium">{getConnectionLabel()}</span> | |
| </div> | |
| <div className="flex items-center gap-2 text-white border-l border-gray-700 pl-6"> | |
| <Clock className="h-4 w-4 text-emerald-500" /> | |
| <span className="font-mono text-sm">{formatTime(sessionTime)}</span> | |
| </div> | |
| <Badge variant="destructive" className="animate-pulse px-3">LIVE</Badge> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Main Content */} | |
| <div className="flex-1 flex overflow-hidden"> | |
| {/* Video Area */} | |
| <div className="flex-1 relative bg-black"> | |
| {/* Remote Video (Sheikh) — always in DOM so ref is always attached */} | |
| <div className="absolute inset-0 flex items-center justify-center"> | |
| <video | |
| ref={remoteVideoRef} | |
| autoPlay | |
| playsInline | |
| className={`w-full h-full object-cover ${remoteStream ? '' : 'hidden'}`} | |
| /> | |
| {!remoteStream && ( | |
| <div className="w-full h-full bg-gradient-to-br from-gray-800 to-gray-900 flex items-center justify-center"> | |
| <div className="text-center"> | |
| <div className="w-32 h-32 rounded-full bg-blue-600 flex items-center justify-center text-white text-4xl mx-auto mb-4"> | |
| S | |
| </div> | |
| <p className="text-white text-xl">Sheikh</p> | |
| <p className="text-gray-400 mt-2">Waiting for sheikh to join...</p> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| {/* Local Video PiP (Admin) */} | |
| <div className="absolute top-4 right-4 w-48 h-36 bg-black rounded-lg shadow-lg border-2 border-white overflow-hidden"> | |
| <video | |
| ref={localVideoRef} | |
| autoPlay | |
| playsInline | |
| muted | |
| className={`w-full h-full object-cover ${!isVideoOn ? 'hidden' : ''}`} | |
| /> | |
| {!isVideoOn && ( | |
| <div className="w-full h-full flex items-center justify-center bg-gray-800"> | |
| <div className="text-center"> | |
| <div className="w-12 h-12 rounded-full bg-emerald-600 flex items-center justify-center text-white mx-auto mb-1"> | |
| A | |
| </div> | |
| <p className="text-white text-xs">You (Video Off)</p> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| {/* Chat Sidebar */} | |
| {showChat && ( | |
| <div className="w-80 bg-gray-800 border-l border-gray-700 flex flex-col"> | |
| <div className="p-4 border-b border-gray-700"> | |
| <h3 className="text-white font-medium">Chat</h3> | |
| </div> | |
| <div className="flex-1 overflow-y-auto p-4 space-y-3"> | |
| {messages.map((msg, i) => ( | |
| <div key={i} className={`flex flex-col ${msg.sender === 'You' ? 'items-end' : 'items-start'}`}> | |
| <div className={`max-w-[80%] rounded-lg p-3 ${msg.sender === 'You' ? 'bg-emerald-600' : 'bg-gray-700'}`}> | |
| <p className="text-white text-sm">{msg.message}</p> | |
| </div> | |
| <p className="text-gray-400 text-xs mt-1">{msg.sender} · {msg.time}</p> | |
| </div> | |
| ))} | |
| </div> | |
| <div className="p-4 border-t border-gray-700 flex gap-2"> | |
| <Textarea | |
| value={newMessage} | |
| onChange={e => setNewMessage(e.target.value)} | |
| onKeyDown={e => e.key === 'Enter' && !e.shiftKey && (e.preventDefault(), handleSendMessage())} | |
| placeholder="Type a message..." | |
| rows={2} | |
| className="bg-gray-700 border-gray-600 text-white" | |
| /> | |
| <Button onClick={handleSendMessage} size="sm">Send</Button> | |
| </div> | |
| </div> | |
| )} | |
| {/* Admin Notes Sidebar */} | |
| {showNotes && ( | |
| <div className="w-96 bg-gray-800 border-l border-gray-700 flex flex-col"> | |
| <div className="p-4 border-b border-gray-700 flex justify-between items-center"> | |
| <h3 className="text-white font-medium flex items-center gap-2"> | |
| <ClipboardEdit className="h-5 w-5 text-emerald-500" /> | |
| Interview Notes | |
| </h3> | |
| <Badge variant="outline" className="text-gray-400 border-gray-600">Private</Badge> | |
| </div> | |
| <div className="flex-1 p-4 flex flex-col gap-4"> | |
| <div className="bg-gray-700/50 p-3 rounded-lg border border-gray-600"> | |
| <p className="text-sm text-gray-300"> | |
| Record your observations during the interview. These notes are private and will help you make your approval decision. | |
| </p> | |
| </div> | |
| <Textarea | |
| value={notes} | |
| onChange={e => setNotes(e.target.value)} | |
| placeholder="Tajweed level, credentials verified, communication skills..." | |
| className="flex-1 bg-gray-700 border-gray-600 text-white resize-none" | |
| /> | |
| </div> | |
| <div className="p-4 border-t border-gray-700"> | |
| <Button | |
| onClick={() => handleSaveNotes(false)} | |
| className="w-full bg-emerald-600 hover:bg-emerald-700 text-white" | |
| disabled={isSavingNotes || notes.trim() === ''} | |
| > | |
| {isSavingNotes ? ( | |
| <span className="flex items-center gap-2"> | |
| <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" /> | |
| Saving... | |
| </span> | |
| ) : ( | |
| <span className="flex items-center gap-2"> | |
| <Save className="h-4 w-4" /> | |
| Save Notes | |
| </span> | |
| )} | |
| </Button> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| {/* Controls */} | |
| <div className="bg-gray-800 border-t border-gray-700 px-6 py-4"> | |
| <div className="flex items-center justify-center gap-4"> | |
| <Button | |
| variant={isVideoOn ? 'outline' : 'destructive'} | |
| size="lg" | |
| className={isVideoOn ? 'bg-gray-700 border-gray-600 hover:bg-gray-600 text-white' : ''} | |
| onClick={() => { setIsVideoOn(!isVideoOn); toggleVideo(); }} | |
| > | |
| {isVideoOn ? <Video className="h-5 w-5" /> : <VideoOff className="h-5 w-5" />} | |
| </Button> | |
| <Button | |
| variant={isAudioOn ? 'outline' : 'destructive'} | |
| size="lg" | |
| className={isAudioOn ? 'bg-gray-700 border-gray-600 hover:bg-gray-600 text-white' : ''} | |
| onClick={() => { setIsAudioOn(!isAudioOn); toggleAudio(); }} | |
| > | |
| {isAudioOn ? <Mic className="h-5 w-5" /> : <MicOff className="h-5 w-5" />} | |
| </Button> | |
| <Button | |
| variant={showChat ? 'default' : 'outline'} | |
| size="lg" | |
| className={showChat ? 'bg-emerald-600 text-white hover:bg-emerald-700' : 'bg-gray-700 border-gray-600 hover:bg-gray-600 text-white'} | |
| onClick={() => { setShowChat(!showChat); if (!showChat) setShowNotes(false); }} | |
| > | |
| <MessageSquare className="h-5 w-5" /> | |
| </Button> | |
| <Button | |
| variant={showNotes ? 'default' : 'outline'} | |
| size="lg" | |
| className={showNotes ? 'bg-emerald-600 text-white hover:bg-emerald-700' : 'bg-gray-700 border-gray-600 hover:bg-gray-600 text-white'} | |
| onClick={() => { setShowNotes(!showNotes); if (!showNotes) setShowChat(false); }} | |
| > | |
| <ClipboardEdit className="h-5 w-5" /> | |
| </Button> | |
| <Button variant="destructive" size="lg" onClick={handleEndInterview}> | |
| <PhoneOff className="h-5 w-5 mr-2" /> | |
| End Interview | |
| </Button> | |
| </div> | |
| </div> | |
| {/* Post-interview decision dialog */} | |
| <Dialog open={showDecision} onOpenChange={(open) => { if (!processing) setShowDecision(open); }}> | |
| <DialogContent> | |
| <DialogHeader> | |
| <DialogTitle>Interview Complete — Make a Decision</DialogTitle> | |
| <DialogDescription> | |
| Approve or reject this sheikh based on the interview. | |
| </DialogDescription> | |
| </DialogHeader> | |
| <div className="space-y-4 pt-2"> | |
| {/* Decision buttons */} | |
| <div className="grid grid-cols-2 gap-3"> | |
| <button | |
| onClick={() => setDecisionType('approve')} | |
| className={`flex flex-col items-center gap-2 p-4 rounded-xl border-2 transition-all ${ | |
| decisionType === 'approve' | |
| ? 'border-emerald-500 bg-emerald-50' | |
| : 'border-slate-200 hover:border-emerald-300' | |
| }`} | |
| > | |
| <CheckCircle2 className={`h-8 w-8 ${decisionType === 'approve' ? 'text-emerald-600' : 'text-slate-400'}`} /> | |
| <span className={`font-semibold ${decisionType === 'approve' ? 'text-emerald-700' : 'text-slate-600'}`}> | |
| Approve | |
| </span> | |
| </button> | |
| <button | |
| onClick={() => setDecisionType('reject')} | |
| className={`flex flex-col items-center gap-2 p-4 rounded-xl border-2 transition-all ${ | |
| decisionType === 'reject' | |
| ? 'border-red-500 bg-red-50' | |
| : 'border-slate-200 hover:border-red-300' | |
| }`} | |
| > | |
| <XCircle className={`h-8 w-8 ${decisionType === 'reject' ? 'text-red-600' : 'text-slate-400'}`} /> | |
| <span className={`font-semibold ${decisionType === 'reject' ? 'text-red-700' : 'text-slate-600'}`}> | |
| Reject | |
| </span> | |
| </button> | |
| </div> | |
| {/* Rejection reason */} | |
| {decisionType === 'reject' && ( | |
| <div className="space-y-2"> | |
| <Label htmlFor="reason">Rejection Reason</Label> | |
| <Textarea | |
| id="reason" | |
| placeholder="Explain why the application is being rejected..." | |
| value={rejectionReason} | |
| onChange={e => setRejectionReason(e.target.value)} | |
| rows={3} | |
| /> | |
| </div> | |
| )} | |
| {/* Notes summary */} | |
| {notes.trim() && ( | |
| <div className="bg-slate-50 rounded-lg p-3 border border-slate-200"> | |
| <p className="text-xs text-slate-500 mb-1 font-medium uppercase tracking-wide">Your notes</p> | |
| <p className="text-sm text-slate-700 line-clamp-3">{notes}</p> | |
| </div> | |
| )} | |
| <div className="flex gap-2 pt-1"> | |
| <Button | |
| variant="outline" | |
| className="flex-1" | |
| onClick={() => setShowDecision(false)} | |
| disabled={processing} | |
| > | |
| Back to Interview | |
| </Button> | |
| <Button | |
| className="flex-1" | |
| variant={decisionType === 'reject' ? 'destructive' : 'default'} | |
| disabled={ | |
| processing || | |
| !decisionType || | |
| (decisionType === 'reject' && !rejectionReason.trim()) | |
| } | |
| onClick={handleDecisionConfirm} | |
| > | |
| {processing ? ( | |
| <span className="flex items-center gap-2"> | |
| <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" /> | |
| Processing... | |
| </span> | |
| ) : ( | |
| `Confirm ${decisionType === 'approve' ? 'Approval' : 'Rejection'}` | |
| )} | |
| </Button> | |
| </div> | |
| </div> | |
| </DialogContent> | |
| </Dialog> | |
| </div> | |
| ); | |
| } | |