import React, { useCallback, useEffect, useState } from 'react'; import { apiExam, staffFetch, type ExamPaper, type ExamRoomStudent, type ExamSubmission, type StudentItem, } from '../api'; import { useGitHubConnection } from '../hooks/useGitHubConnection'; import { navigate, pushNav } from '../navigation'; import { BASE_APP_SUGGESTIONS, DEFAULT_EXAM_ALLOWED_APPS } from '../constants'; import { openStaffChat } from '../chatEvents'; import { AppPoolModal } from './AppPoolModal'; import { NavHistoryBar } from './NavHistoryBar'; import { StudentAvatar } from './StudentAvatar'; import { StudentDetailModal } from './StudentDetailModal'; import { fmtTime, localInputToISO, toLocalInput } from './ExamsTab'; const EXAM_APP_SUGGESTIONS = [...new Set([...BASE_APP_SUGGESTIONS, 'msedge', 'edge', 'acrobat', 'foxit'])]; interface Props { examId: number; onBack: () => void; } function statusBadge(displayStatus: string) { switch (displayStatus) { case 'draft': return { text: 'Tạm thời', cls: 'badge-muted' }; case 'ready': return { text: 'Sẵn sàng', cls: 'badge-info' }; case 'active': return { text: 'Đang thi', cls: 'badge-success' }; case 'cancelled': return { text: 'Đã hủy', cls: 'badge-warning' }; case 'ended': return { text: 'Kết thúc', cls: 'badge-muted' }; default: return { text: displayStatus, cls: 'badge-muted' }; } } function statusColor(displayStatus: string) { switch (displayStatus) { case 'active': return 'var(--success)'; case 'ready': return 'var(--accent)'; case 'cancelled': return 'var(--danger)'; default: return 'var(--text-muted)'; } } export const ExamRoomWorkspace: React.FC = ({ examId, onBack }) => { const [roomName, setRoomName] = useState(''); const [start, setStart] = useState(''); const [end, setEnd] = useState(''); const [allowedApps, setAllowedApps] = useState(DEFAULT_EXAM_ALLOWED_APPS); const [quizUrl, setQuizUrl] = useState(''); const [gitPublishUrl, setGitPublishUrl] = useState(''); const [bundlingSubs, setBundlingSubs] = useState(false); const [publishingGit, setPublishingGit] = useState(false); const gh = useGitHubConnection(); const [editable, setEditable] = useState(false); const [prepEditable, setPrepEditable] = useState(false); const [displayStatus, setDisplayStatus] = useState('draft'); const [canPublish, setCanPublish] = useState(false); const [canUnpublish, setCanUnpublish] = useState(false); const [canCancel, setCanCancel] = useState(false); const [papers, setPapers] = useState([]); const [students, setStudents] = useState([]); const [submissions, setSubmissions] = useState([]); const [searchQ, setSearchQ] = useState(''); const [searchHits, setSearchHits] = useState<{ studentRkId: number; fullName: string; studentCode: string; email: string; classCodes: string }[]>([]); const [paperTitle, setPaperTitle] = useState(''); const [packagePdf, setPackagePdf] = useState(null); const [packageResources, setPackageResources] = useState([]); const [creatingPackage, setCreatingPackage] = useState(false); const [sendSchedule, setSendSchedule] = useState(''); const [msg, setMsg] = useState(''); const [err, setErr] = useState(''); const [appPoolOpen, setAppPoolOpen] = useState(false); const [studentPickerOpen, setStudentPickerOpen] = useState(false); const [saving, setSaving] = useState(false); const [loading, setLoading] = useState(true); const [onlineIds, setOnlineIds] = useState([]); const [searchQuery, setSearchQuery] = useState(''); const [selectedStudent, setSelectedStudent] = useState(null); const [activeSubTab, setActiveSubTab] = useState<'roster' | 'detail' | 'submissions'>('roster'); const [configOpen, setConfigOpen] = useState(false); const [configTab, setConfigTab] = useState<'info' | 'apps' | 'papers'>('info'); const load = useCallback(async () => { setLoading(true); try { const data = await apiExam.get(examId); setRoomName(data.room.name); setStart(toLocalInput(data.room.startTime)); setEnd(toLocalInput(data.room.endTime)); setAllowedApps(data.room.allowedApps || DEFAULT_EXAM_ALLOWED_APPS); setQuizUrl(data.room.quizUrl || ''); setGitPublishUrl(data.room.gitPublishUrl || ''); setEditable(data.editable); setPrepEditable(data.prepEditable ?? data.editable); setDisplayStatus(data.displayStatus); setCanPublish(data.canPublish); setCanUnpublish(data.canUnpublish); setCanCancel(data.canCancel); setPapers(data.papers); setStudents(data.students); pushNav({ kind: 'exam', tab: 'exams', examId, label: data.room.name || `Phòng thi ${examId}`, }); const subs = await apiExam.listSubmissions(examId); setSubmissions(subs.data); } finally { setLoading(false); } }, [examId]); useEffect(() => { load().catch(console.error); }, [load]); const fetchOnline = useCallback(async () => { try { const res = await apiExam.fetchOnlineStudents(examId); setOnlineIds(res.onlineStudentIds || []); } catch (e) { console.error(e); } }, [examId]); useEffect(() => { fetchOnline(); const t = setInterval(fetchOnline, 5000); return () => clearInterval(t); }, [fetchOnline]); useEffect(() => { if (!studentPickerOpen || !searchQ.trim()) { if (!studentPickerOpen) setSearchHits([]); return; } const t = setTimeout(() => { apiExam.searchStudents(searchQ.trim()).then((r) => setSearchHits(r.data)).catch(console.error); }, 300); return () => clearTimeout(t); }, [searchQ, studentPickerOpen]); const addAppKeyword = (kw: string) => { const trimmed = kw.trim().toLowerCase(); if (!trimmed) return; setAllowedApps((prev) => { const parts = prev.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean); if (parts.includes(trimmed)) return prev; return prev ? `${prev}, ${trimmed}` : trimmed; }); }; const saveRoom = async () => { setErr(''); setMsg(''); setSaving(true); try { await apiExam.update(examId, { name: roomName.trim(), startTime: localInputToISO(start), endTime: localInputToISO(end), allowedApps, quizUrl: quizUrl.trim(), }); setMsg('Đã lưu cấu hình phòng thi'); await load(); } catch (e: any) { setErr(e?.message || 'Lỗi'); } finally { setSaving(false); } }; const addStudent = async (studentRkId: number) => { await apiExam.addStudents(examId, [studentRkId]); await load(); await fetchOnline(); }; const createPackage = async () => { if (!packagePdf) { setErr('Chọn file PDF cho gói đề'); return; } setErr(''); setCreatingPackage(true); try { const title = paperTitle.trim() || packagePdf.name.replace(/\.pdf$/i, '') || 'Gói đề'; const resCount = packageResources.length; await apiExam.uploadPackage(examId, title, packagePdf, packageResources); setPaperTitle(''); setPackagePdf(null); setPackageResources([]); setMsg(`Đã tạo gói đề "${title}" (1 PDF${resCount ? ` + ${resCount} tài nguyên` : ''})`); await load(); } catch (e: any) { setErr(e?.message || 'Tạo gói đề thất bại'); } finally { setCreatingPackage(false); } }; const uploadResource = async (paperId: number, e: React.ChangeEvent) => { const files = Array.from(e.target.files || []); if (!files.length) return; await apiExam.uploadPackageResources(examId, paperId, files); e.target.value = ''; await load(); }; const assignRandomPackages = async () => { const res = await apiExam.assignRandom(examId); setMsg(`Đã chia ${res.assigned} sinh viên vào ${res.packages} gói đề`); await load(); }; const sendPapers = async (scheduled: boolean) => { const payload = scheduled && sendSchedule ? { scheduledAt: localInputToISO(sendSchedule) } : undefined; const res = await apiExam.sendPapers(examId, payload); setMsg(`Đã gửi ${res.sent} gói đề${res.scheduled ? `, hẹn ${res.scheduled}` : ''}`); await load(); }; const downloadSub = async (subId: number, fileName: string) => { const res = await staffFetch(`/exam-rooms/${examId}/submissions/${subId}/download`); if (!res.ok) return; const blob = await res.blob(); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = fileName; a.click(); URL.revokeObjectURL(url); }; const downloadAllSubs = async () => { setErr(''); setMsg(''); setBundlingSubs(true); try { await apiExam.downloadAllSubmissions(examId, roomName || `phong_thi_${examId}`); setMsg('Đã tải ZIP gộp — giải nén sẽ thấy từng folder bài làm theo sinh viên.'); } catch (e: any) { setErr(e?.message || 'Tải thất bại'); } finally { setBundlingSubs(false); } }; const publishToGit = async () => { if (!gh.connected) { setErr('Kết nối GitHub trong Tài khoản của tôi (sidebar dưới cùng) trước khi đẩy.'); return; } setErr(''); setMsg(''); setPublishingGit(true); try { const res = await apiExam.publishSubmissionsGit(examId); const openUrl = res.openUrl || res.gitPublishUrl || res.url; setGitPublishUrl(openUrl); setMsg(res.message || 'Đã đẩy bài nộp lên GitHub'); if (openUrl) window.open(openUrl, '_blank', 'noopener,noreferrer'); } catch (e: any) { setErr(e?.message || 'Đẩy Git thất bại'); } finally { setPublishingGit(false); } }; const handlePublish = async () => { setErr(''); setMsg(''); try { await apiExam.publish(examId); setMsg('Đã đẩy phòng thi — hệ thống sẽ mở khi tới giờ'); await load(); } catch (e: any) { setErr(e?.message || 'Lỗi'); } }; const handleUnpublish = async () => { if (!confirm('Thu hồi lệnh đẩy? Phòng thi quay về trạng thái tạm thời.')) return; setErr(''); setMsg(''); try { await apiExam.unpublish(examId); setMsg('Đã thu hồi — phòng thi ở trạng thái tạm thời'); await load(); } catch (e: any) { setErr(e?.message || 'Lỗi'); } }; const handleCancel = async () => { if (!confirm('Hủy phòng thi ngay? Sinh viên sẽ thoát chế độ thi.')) return; setErr(''); setMsg(''); try { await apiExam.cancel(examId); setMsg('Đã hủy phòng thi'); await load(); } catch (e: any) { setErr(e?.message || 'Lỗi'); } }; const filteredStudents = students.filter((s) => { const term = searchQuery.toLowerCase().trim(); if (!term) return true; return ( s.fullName.toLowerCase().includes(term) || s.studentCode.toLowerCase().includes(term) || s.email.toLowerCase().includes(term) || (s.paperTitle || '').toLowerCase().includes(term) ); }); const toStudentItem = (s: ExamRoomStudent): StudentItem => ({ id: s.id, rkId: s.studentRkId, studentCode: s.studentCode, fullName: s.fullName, email: s.email, avatar: s.avatar, }); const openStudentChat = (s: ExamRoomStudent) => { openStaffChat({ studentRkId: s.studentRkId, fullName: s.fullName, studentCode: s.studentCode, email: s.email, }); }; if (loading && !roomName) { return (
Đang tải không gian làm việc phòng thi...
); } const badge = statusBadge(displayStatus); const enrolledIds = new Set(students.map((s) => s.studentRkId)); const onlineCount = students.filter((s) => onlineIds.includes(s.studentRkId)).length; return (
{(msg || err) && (
{err || msg}
)}
📝

{roomName || 'Phòng thi'}

{badge.text} {start && end && ( {fmtTime(localInputToISO(start))} → {fmtTime(localInputToISO(end))} )} ID: {examId} {papers.length} gói đề · {submissions.length} bài nộp
Trạng thái thi {badge.text}
{canPublish && ( )} {canUnpublish && ( )} {canCancel && ( )}
Online / Sĩ số
{onlineCount} / {students.length} SV
{configOpen && (
setConfigOpen(false)} aria-hidden /> )}