import React, { useCallback, useEffect, useRef, 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 { AppTemplatePickerModal } from './AppTemplatePickerModal'; import { mergeKeywordCSV } from '../utils/appKeywords'; import { ExamPaperPdfModal } from './ExamPaperPdfModal'; 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 [publishingGitStudents, setPublishingGitStudents] = useState(false); const gh = useGitHubConnection(); const [editable, setEditable] = useState(false); const [prepEditable, setPrepEditable] = useState(false); const [displayStatus, setDisplayStatus] = useState('draft'); const [searchMode, setSearchMode] = useState<'single' | 'bulk'>('single'); const [bulkInput, setBulkInput] = useState(''); 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 [selectedPickerRkIds, setSelectedPickerRkIds] = useState([]); 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 [templatePickerOpen, setTemplatePickerOpen] = 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 [papersModalOpen, setPapersModalOpen] = useState(false); const [pdfViewer, setPdfViewer] = useState<{ title: string; bytes: Uint8Array | null } | null>(null); const [pdfLoading, setPdfLoading] = useState(false); const [inlineResource, setInlineResource] = useState<{ url: string; title: string; isImage: boolean } | null>(null); const canEditApps = displayStatus === 'draft' || displayStatus === 'ready' || displayStatus === 'active'; const anyPaperSent = students.some((s) => s.paperSentAt); const canModifyPapers = prepEditable || (!anyPaperSent && (displayStatus === 'draft' || displayStatus === 'ready' || displayStatus === 'active')); 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 closePdfViewer = useCallback(() => { setPdfViewer(null); setPdfLoading(false); }, []); const closeInlineResource = useCallback(() => { setInlineResource((prev) => { if (prev?.url) URL.revokeObjectURL(prev.url); return null; }); }, []); const inlineResourceRef = useRef(inlineResource); inlineResourceRef.current = inlineResource; useEffect(() => () => { if (inlineResourceRef.current?.url) URL.revokeObjectURL(inlineResourceRef.current.url); }, []); 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 applyTemplate = (keywords: string) => { setAllowedApps((prev) => mergeKeywordCSV(prev, keywords)); }; const saveRoom = async () => { setErr(''); setMsg(''); setSaving(true); try { const payload: any = {}; if (editable) { payload.name = roomName.trim(); payload.startTime = localInputToISO(start); payload.endTime = localInputToISO(end); payload.allowedApps = allowedApps; payload.quizUrl = quizUrl.trim(); } else { payload.allowedApps = allowedApps; } await apiExam.update(examId, payload); setMsg(editable ? 'Đã lưu cấu hình phòng thi' : 'Đã cập nhật danh sách ứng dụng được phép'); 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 handleBulkSearch = async () => { const q = bulkInput.trim(); if (!q) return; try { const res = await apiExam.searchStudents(q); setSearchHits(res.data); // Auto-select all unadded hits const enrolledIds = new Set(students.map((s) => s.studentRkId)); const unaddedHits = res.data.filter((h) => !enrolledIds.has(h.studentRkId)); setSelectedPickerRkIds(unaddedHits.map((h) => h.studentRkId)); } catch (e: any) { alert(e?.message || 'Tìm kiếm thất bại'); } }; const handleSwitchTab = (mode: 'single' | 'bulk') => { setSearchMode(mode); setSearchHits([]); setSelectedPickerRkIds([]); setSearchQ(''); setBulkInput(''); }; const toggleHitSelection = (studentRkId: number) => { setSelectedPickerRkIds((prev) => prev.includes(studentRkId) ? prev.filter((id) => id !== studentRkId) : [...prev, studentRkId] ); }; const addSelectedStudents = async () => { if (selectedPickerRkIds.length === 0) return; try { await apiExam.addStudents(examId, selectedPickerRkIds); setSelectedPickerRkIds([]); setStudentPickerOpen(false); await load(); await fetchOnline(); } catch (e: any) { alert(e?.message || 'Thêm sinh viên thất bại'); } }; const toggleSelectAllHits = () => { const enrolledIds = new Set(students.map((s) => s.studentRkId)); const unaddedHits = searchHits.filter((h) => !enrolledIds.has(h.studentRkId)); const allUnaddedIds = unaddedHits.map((h) => h.studentRkId); if (allUnaddedIds.length === 0) return; const areAllSelected = allUnaddedIds.every((id) => selectedPickerRkIds.includes(id)); if (areAllSelected) { setSelectedPickerRkIds((prev) => prev.filter((id) => !allUnaddedIds.includes(id))); } else { setSelectedPickerRkIds((prev) => Array.from(new Set([...prev, ...allUnaddedIds]))); } }; 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 viewPaperPdf = async (paperId: number, title?: string) => { setErr(''); const paperTitle = title || papers.find((p) => p.id === paperId)?.title || 'Đề thi'; setPapersModalOpen(false); setPdfLoading(true); setPdfViewer({ title: paperTitle, bytes: null }); try { const buf = await apiExam.fetchPaperPdfBytes(examId, paperId); setPdfViewer({ title: paperTitle, bytes: new Uint8Array(buf) }); } catch (e: any) { closePdfViewer(); setErr(e?.message || 'Không mở được đề PDF'); } finally { setPdfLoading(false); } }; const viewPaperResource = async (paperId: number, resourceId: number, fileName: string) => { setErr(''); if (/\.pdf$/i.test(fileName)) { setPapersModalOpen(false); setPdfLoading(true); setPdfViewer({ title: fileName, bytes: null }); try { const buf = await apiExam.fetchPaperResourceBytes(examId, paperId, resourceId); setPdfViewer({ title: fileName, bytes: new Uint8Array(buf) }); } catch (e: any) { closePdfViewer(); setErr(e?.message || 'Không mở được PDF'); } finally { setPdfLoading(false); } return; } closeInlineResource(); try { const buf = await apiExam.fetchPaperResourceBytes(examId, paperId, resourceId); const isImage = /\.(png|jpe?g|gif|webp)$/i.test(fileName); const mime = /\.png$/i.test(fileName) ? 'image/png' : /\.jpe?g$/i.test(fileName) ? 'image/jpeg' : /\.gif$/i.test(fileName) ? 'image/gif' : 'image/webp'; const url = URL.createObjectURL(new Blob([buf], { type: mime })); setInlineResource({ url, title: fileName, isImage }); } catch (e: any) { setErr(e?.message || 'Không mở được tài nguyên'); } }; const downloadPaperResource = async (paperId: number, resourceId: number, fileName: string) => { setErr(''); try { await apiExam.downloadPaperFile(examId, paperId, { resourceId, fileName }); } catch (e: any) { setErr(e?.message || 'Không tải được tài nguyên'); } }; 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 publishToGitStudents = async () => { if (!gh.connected) { setErr('Kết nối GitHub trong Tài khoản của tôi trước khi đẩy.'); return; } setErr(''); setMsg(''); setPublishingGitStudents(true); try { const res = await apiExam.publishSubmissionsGitStudents(examId); setMsg(res.message || 'Đã tạo repo từng sinh viên'); if (res.openUrl) window.open(res.openUrl, '_blank', 'noopener,noreferrer'); const subs = await apiExam.listSubmissions(examId); setSubmissions(subs.data); } catch (e: any) { setErr(e?.message || 'Đẩy Git thất bại'); } finally { setPublishingGitStudents(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 && ( )} {papers.length > 0 && ( )}
Online / Sĩ số
{onlineCount} / {students.length} SV
{configOpen && (
setConfigOpen(false)} aria-hidden /> )}