Quran_Tech_Server / F_Pro /src /components /admin /InterviewSession.tsx
aboalaa147's picture
Initial deployment
eb6a2f9
Raw
History Blame Contribute Delete
18.3 kB
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>
);
}