995 lines
45 KiB
TypeScript
995 lines
45 KiB
TypeScript
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<Props> = ({ 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<ExamPaper[]>([]);
|
||
const [students, setStudents] = useState<ExamRoomStudent[]>([]);
|
||
const [submissions, setSubmissions] = useState<ExamSubmission[]>([]);
|
||
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<File | null>(null);
|
||
const [packageResources, setPackageResources] = useState<File[]>([]);
|
||
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<number[]>([]);
|
||
const [searchQuery, setSearchQuery] = useState('');
|
||
const [selectedStudent, setSelectedStudent] = useState<StudentItem | null>(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<HTMLInputElement>) => {
|
||
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 (
|
||
<div className="loading-screen">
|
||
<div className="sync-spinner" style={{ width: '36px', height: '36px', borderWidth: '3px' }} />
|
||
<div style={{ fontWeight: 600, fontSize: '0.95rem', color: 'var(--text-secondary)' }}>
|
||
Đang tải không gian làm việc phòng thi...
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const badge = statusBadge(displayStatus);
|
||
const enrolledIds = new Set(students.map((s) => s.studentRkId));
|
||
const onlineCount = students.filter((s) => onlineIds.includes(s.studentRkId)).length;
|
||
|
||
return (
|
||
<div className="workspace-layout workspace-embedded">
|
||
<NavHistoryBar classLabel={roomName || 'Phòng thi'} onBack={onBack} />
|
||
|
||
{(msg || err) && (
|
||
<div className={`alert-banner ${err ? 'alert-error' : 'alert-success'}`}>{err || msg}</div>
|
||
)}
|
||
|
||
<div className="workspace-header">
|
||
<div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||
<div className="brand-logo" style={{ width: 44, height: 44, fontSize: '1.1rem' }}>📝</div>
|
||
<div>
|
||
<h1 className="workspace-class-title">{roomName || 'Phòng thi'}</h1>
|
||
<div className="class-badge-container">
|
||
<span className={`badge ${badge.cls}`}>{badge.text}</span>
|
||
{start && end && (
|
||
<span className="badge badge-muted">
|
||
{fmtTime(localInputToISO(start))} → {fmtTime(localInputToISO(end))}
|
||
</span>
|
||
)}
|
||
<span className="badge badge-info">ID: {examId}</span>
|
||
<span className="badge badge-muted">{papers.length} gói đề · {submissions.length} bài nộp</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '1.25rem', flexWrap: 'wrap' }}>
|
||
<div className="status-panel">
|
||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||
<span style={{ fontSize: '0.72rem', color: 'var(--text-muted)', fontWeight: 600, textTransform: 'uppercase' }}>
|
||
Trạng thái thi
|
||
</span>
|
||
<span style={{ fontSize: '0.875rem', fontWeight: 700, color: statusColor(displayStatus) }}>
|
||
{badge.text}
|
||
</span>
|
||
</div>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
|
||
{canPublish && (
|
||
<button type="button" className="btn btn-primary btn-sm" onClick={handlePublish}>Đẩy phòng thi</button>
|
||
)}
|
||
{canUnpublish && (
|
||
<button type="button" className="btn btn-secondary btn-sm" onClick={handleUnpublish}>Thu hồi</button>
|
||
)}
|
||
{canCancel && (
|
||
<button type="button" className="btn btn-secondary btn-sm learning-btn-danger" onClick={handleCancel}>Hủy</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end' }}>
|
||
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)', fontWeight: 600, textTransform: 'uppercase' }}>
|
||
Online / Sĩ số
|
||
</div>
|
||
<div className="workspace-stat-value">
|
||
<span style={{ color: 'var(--success)' }}>{onlineCount}</span>
|
||
<span style={{ color: 'var(--text-muted)', fontSize: '0.9rem', fontWeight: 500 }}> / {students.length} SV</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="workspace-main">
|
||
{configOpen && (
|
||
<div className="workspace-drawer-backdrop" onClick={() => setConfigOpen(false)} aria-hidden />
|
||
)}
|
||
|
||
<aside className={`workspace-config-drawer ${configOpen ? 'open' : ''}`}>
|
||
<div className="workspace-drawer-header">
|
||
<strong>Cấu hình phòng thi</strong>
|
||
<button type="button" className="btn btn-secondary workspace-drawer-close" onClick={() => setConfigOpen(false)} aria-label="Đóng">×</button>
|
||
</div>
|
||
<div className="workspace-drawer-tabs">
|
||
<button type="button" className={`workspace-drawer-tab ${configTab === 'info' ? 'active' : ''}`} onClick={() => setConfigTab('info')}>
|
||
Thông tin
|
||
</button>
|
||
<button type="button" className={`workspace-drawer-tab ${configTab === 'apps' ? 'active' : ''}`} onClick={() => setConfigTab('apps')}>
|
||
Ứng dụng
|
||
</button>
|
||
<button type="button" className={`workspace-drawer-tab ${configTab === 'papers' ? 'active' : ''}`} onClick={() => setConfigTab('papers')}>
|
||
Gói đề & gửi
|
||
</button>
|
||
</div>
|
||
<div className="workspace-drawer-body">
|
||
{configTab === 'info' && (
|
||
<div className="config-card config-card-flat">
|
||
<div className="card-header-title">Thông tin phòng thi</div>
|
||
<p className="config-card-desc">
|
||
{editable ? 'Chỉnh sửa được khi phòng ở trạng thái tạm thời.' : 'Phòng đã khóa chỉnh sửa.'}
|
||
</p>
|
||
<label className="login-field">
|
||
<span>Tên phòng thi</span>
|
||
<input value={roomName} onChange={(e) => setRoomName(e.target.value)} disabled={!editable} />
|
||
</label>
|
||
<label className="login-field">
|
||
<span>Link trắc nghiệm</span>
|
||
<input value={quizUrl} onChange={(e) => setQuizUrl(e.target.value)} placeholder="https://..." disabled={!editable} />
|
||
</label>
|
||
<div className="exam-git-settings">
|
||
<p className="exam-git-status-line">
|
||
GitHub:{' '}
|
||
{gh.connected ? (
|
||
<strong>@{gh.githubLogin}</strong>
|
||
) : (
|
||
<>
|
||
<span className="exam-git-not-connected">chưa kết nối</span>
|
||
{' — '}
|
||
<button type="button" className="link-btn" onClick={() => navigate('profile')}>
|
||
Kết nối trong Tài khoản
|
||
</button>
|
||
</>
|
||
)}
|
||
</p>
|
||
<p className="exam-git-hint">
|
||
Bấm <strong>Đẩy lên Git</strong> sẽ tự tạo repo theo tên phòng thi — mỗi sinh viên một folder, giống ZIP tải về.
|
||
</p>
|
||
{gitPublishUrl && (
|
||
<p className="exam-git-last">
|
||
Repo gần nhất:{' '}
|
||
<a href={gitPublishUrl} target="_blank" rel="noreferrer">{gitPublishUrl}</a>
|
||
</p>
|
||
)}
|
||
</div>
|
||
<label className="login-field">
|
||
<span>Bắt đầu</span>
|
||
<input type="datetime-local" value={start} onChange={(e) => setStart(e.target.value)} disabled={!editable} />
|
||
</label>
|
||
<label className="login-field">
|
||
<span>Kết thúc</span>
|
||
<input type="datetime-local" value={end} onChange={(e) => setEnd(e.target.value)} disabled={!editable} />
|
||
</label>
|
||
{editable && (
|
||
<button type="button" className="btn btn-primary" style={{ width: '100%', justifyContent: 'center' }} disabled={saving} onClick={saveRoom}>
|
||
{saving ? 'Đang lưu...' : 'Lưu thông tin'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{configTab === 'apps' && (
|
||
<div className="config-card config-card-flat">
|
||
<div className="card-header-title">Ứng dụng được phép (khi thi)</div>
|
||
<p className="config-card-desc">
|
||
Từ khóa tiến trình hoặc tiêu đề được phép (ngăn cách bằng dấu phẩy).
|
||
</p>
|
||
<textarea
|
||
className="app-textarea"
|
||
placeholder="chrome, msedge, acrobat, wails, simple_care, client"
|
||
value={allowedApps}
|
||
onChange={(e) => setAllowedApps(e.target.value)}
|
||
disabled={!editable}
|
||
/>
|
||
{editable && (
|
||
<>
|
||
<div className="config-suggestions">
|
||
<span className="config-suggestions-label">Gợi ý nhanh:</span>
|
||
<div className="config-suggestions-list">
|
||
{EXAM_APP_SUGGESTIONS.map((kw) => (
|
||
<span key={kw} className="app-suggestion-badge" onClick={() => addAppKeyword(kw)}>
|
||
+ {kw}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="app-pool-open-row">
|
||
<div>
|
||
<span className="config-suggestions-label">Kho ứng dụng (toàn hệ thống)</span>
|
||
<p className="discovered-apps-hint" style={{ margin: '0.25rem 0 0' }}>
|
||
App bị chặn từ mọi lớp — mở kho để tìm và thêm nhanh.
|
||
</p>
|
||
</div>
|
||
<button type="button" className="btn btn-secondary app-pool-open-btn" onClick={() => setAppPoolOpen(true)}>
|
||
Mở kho & tìm kiếm
|
||
</button>
|
||
</div>
|
||
<button type="button" className="btn btn-primary" style={{ width: '100%', justifyContent: 'center' }} disabled={saving} onClick={saveRoom}>
|
||
{saving ? 'Đang lưu...' : 'Lưu cấu hình apps'}
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{configTab === 'papers' && (
|
||
<div className="config-card config-card-flat">
|
||
<div className="card-header-title">Gói đề & gửi cho sinh viên</div>
|
||
<p className="config-card-desc">
|
||
Mỗi gói đề gồm <strong>1 file PDF</strong> (đề chính) và <strong>tài nguyên kèm</strong> (ảnh, zip, doc…). Chia ngẫu nhiên sẽ gán cả gói cho từng sinh viên.
|
||
</p>
|
||
{!prepEditable && (
|
||
<p className="config-card-desc" style={{ color: 'var(--danger)' }}>
|
||
Không thể thêm gói đề — phòng đã khóa hoặc đã tới giờ thi.
|
||
</p>
|
||
)}
|
||
{prepEditable && (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.65rem', marginBottom: '0.75rem' }}>
|
||
<input
|
||
className="search-input"
|
||
placeholder="Tên gói đề (VD: Gói A)"
|
||
value={paperTitle}
|
||
onChange={(e) => setPaperTitle(e.target.value)}
|
||
/>
|
||
<label className="login-field">
|
||
<span>PDF đề chính (bắt buộc)</span>
|
||
<input
|
||
type="file"
|
||
accept=".pdf"
|
||
onChange={(e) => setPackagePdf(e.target.files?.[0] || null)}
|
||
/>
|
||
</label>
|
||
<label className="login-field">
|
||
<span>Tài nguyên kèm (tùy chọn, nhiều file)</span>
|
||
<input
|
||
type="file"
|
||
multiple
|
||
onChange={(e) => setPackageResources(Array.from(e.target.files || []))}
|
||
/>
|
||
</label>
|
||
{packageResources.length > 0 && (
|
||
<p className="config-card-desc" style={{ margin: 0 }}>
|
||
{packageResources.length} tài nguyên: {packageResources.map((f) => f.name).join(', ')}
|
||
</p>
|
||
)}
|
||
<button
|
||
type="button"
|
||
className="btn btn-primary"
|
||
style={{ width: '100%' }}
|
||
disabled={creatingPackage || !packagePdf}
|
||
onClick={createPackage}
|
||
>
|
||
{creatingPackage ? 'Đang tạo...' : 'Tạo gói đề'}
|
||
</button>
|
||
</div>
|
||
)}
|
||
{papers.length === 0 ? (
|
||
<p className="config-card-desc">Chưa có gói đề. Tạo gói gồm PDF + tài nguyên.</p>
|
||
) : (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.65rem' }}>
|
||
{papers.map((p) => (
|
||
<div key={p.id} style={{ border: '1px solid var(--border-light)', borderRadius: '8px', padding: '0.75rem', background: 'var(--bg-subtle)' }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.5rem', alignItems: 'flex-start' }}>
|
||
<strong>{p.title}</strong>
|
||
<span className="badge badge-muted">Gói đề</span>
|
||
</div>
|
||
<ul className="config-card-desc" style={{ margin: '0.5rem 0 0', paddingLeft: '1.1rem' }}>
|
||
<li>📄 main.pdf (đề chính)</li>
|
||
{(p.resources || []).length === 0 ? (
|
||
<li style={{ color: 'var(--text-muted)' }}>Chưa có tài nguyên kèm</li>
|
||
) : (
|
||
(p.resources || []).map((r) => (
|
||
<li key={r.id}>📎 {r.fileName}</li>
|
||
))
|
||
)}
|
||
</ul>
|
||
{prepEditable && (
|
||
<div style={{ display: 'flex', gap: '0.35rem', marginTop: '0.5rem', flexWrap: 'wrap' }}>
|
||
<label className="btn btn-ghost btn-sm" style={{ cursor: 'pointer' }}>
|
||
+ Tài nguyên
|
||
<input type="file" multiple hidden onChange={(e) => uploadResource(p.id, e)} />
|
||
</label>
|
||
<button type="button" className="btn btn-ghost btn-sm learning-btn-danger" onClick={() => apiExam.deletePaper(examId, p.id).then(load)}>
|
||
Xóa gói
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
{papers.length > 0 && students.length > 0 && prepEditable && (
|
||
<button type="button" className="btn btn-secondary" style={{ width: '100%', marginTop: '0.65rem' }} onClick={assignRandomPackages}>
|
||
Chia gói đề ngẫu nhiên
|
||
</button>
|
||
)}
|
||
<div className="divider" style={{ opacity: 0.3 }} />
|
||
<p className="config-card-desc">Gửi gói đề sau khi đẩy phòng thi và chia gói.</p>
|
||
{(displayStatus === 'ready' || displayStatus === 'active') ? (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||
<button type="button" className="btn btn-primary" onClick={() => sendPapers(false)}>Gửi gói đề ngay</button>
|
||
<input type="datetime-local" className="search-input" value={sendSchedule} onChange={(e) => setSendSchedule(e.target.value)} />
|
||
<button type="button" className="btn btn-secondary" disabled={!sendSchedule} onClick={() => sendPapers(true)}>Hẹn giờ gửi</button>
|
||
</div>
|
||
) : (
|
||
<p className="config-card-desc">Đẩy phòng thi trước khi gửi gói đề.</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</aside>
|
||
|
||
<div className="right-panel workspace-content-panel">
|
||
<div className="workspace-panel-toolbar">
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '1rem' }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.65rem', flexWrap: 'wrap' }}>
|
||
<button
|
||
type="button"
|
||
className={`btn btn-secondary workspace-config-toggle ${configOpen ? 'active' : ''}`}
|
||
onClick={() => setConfigOpen((v) => !v)}
|
||
>
|
||
{configOpen ? 'Ẩn cấu hình' : 'Cấu hình phòng thi'}
|
||
</button>
|
||
<div className="tab-btn-group">
|
||
<button
|
||
type="button"
|
||
className={`tab-sub-btn ${activeSubTab === 'roster' ? 'active' : ''}`}
|
||
onClick={() => setActiveSubTab('roster')}
|
||
>
|
||
Sơ đồ sinh viên
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`tab-sub-btn ${activeSubTab === 'detail' ? 'active' : ''}`}
|
||
onClick={() => setActiveSubTab('detail')}
|
||
>
|
||
Chi tiết thi
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`tab-sub-btn ${activeSubTab === 'submissions' ? 'active' : ''}`}
|
||
onClick={() => setActiveSubTab('submissions')}
|
||
>
|
||
Bài nộp ({submissions.length})
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||
{activeSubTab !== 'submissions' && (
|
||
<div className="search-input-wrapper">
|
||
<input
|
||
type="text"
|
||
className="search-input"
|
||
placeholder="Tìm sinh viên..."
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
/>
|
||
<span className="search-icon">🔍</span>
|
||
</div>
|
||
)}
|
||
{prepEditable && (
|
||
<button
|
||
type="button"
|
||
className="btn btn-primary btn-sm"
|
||
onClick={() => { setStudentPickerOpen(true); setSearchQ(''); setSearchHits([]); }}
|
||
>
|
||
+ Thêm sinh viên
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="divider" style={{ opacity: 0.3, margin: '0.25rem 0' }} />
|
||
</div>
|
||
|
||
<div className={`workspace-panel-body ${activeSubTab !== 'roster' ? 'workspace-panel-fill' : ''}`}>
|
||
{activeSubTab === 'roster' ? (
|
||
students.length === 0 ? (
|
||
<div className="empty-state" style={{ minHeight: '300px' }}>
|
||
<div className="empty-state-icon">👤</div>
|
||
<h2>Chưa có sinh viên</h2>
|
||
<p>Thêm sinh viên vào phòng thi để bắt đầu giám sát.</p>
|
||
{prepEditable && (
|
||
<button
|
||
type="button"
|
||
className="btn btn-primary"
|
||
onClick={() => { setStudentPickerOpen(true); setSearchQ(''); setSearchHits([]); }}
|
||
>
|
||
+ Thêm sinh viên
|
||
</button>
|
||
)}
|
||
</div>
|
||
) : filteredStudents.length === 0 ? (
|
||
<div className="empty-state" style={{ minHeight: '300px' }}>
|
||
<div className="empty-state-icon">🔍</div>
|
||
<h2>Không tìm thấy sinh viên</h2>
|
||
<p>Hãy thử tìm kiếm với từ khóa khác.</p>
|
||
</div>
|
||
) : (
|
||
<div className="student-status-grid">
|
||
{filteredStudents.map((s) => {
|
||
const isOnline = onlineIds.includes(s.studentRkId);
|
||
return (
|
||
<div
|
||
key={s.id}
|
||
className={`student-status-card student-status-card-clickable ${isOnline ? 'online' : 'offline'}`}
|
||
onClick={() => setSelectedStudent(toStudentItem(s))}
|
||
role="button"
|
||
tabIndex={0}
|
||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') setSelectedStudent(toStudentItem(s)); }}
|
||
>
|
||
<StudentAvatar fullName={s.fullName} avatar={s.avatar} isOnline={isOnline} size={52} />
|
||
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, overflow: 'hidden' }}>
|
||
<span style={{ fontSize: '0.875rem', fontWeight: 600, color: 'var(--text-primary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||
{s.fullName}
|
||
</span>
|
||
<span style={{ fontSize: '0.78rem', color: 'var(--accent)', fontFamily: 'monospace', fontWeight: 600 }}>
|
||
{s.studentCode}
|
||
</span>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.35rem', marginTop: '0.4rem' }}>
|
||
<span className={isOnline ? 'pulse-dot-online' : 'status-badge-offline'} />
|
||
<span style={{ fontSize: '0.75rem', fontWeight: 700, color: isOnline ? 'var(--success)' : 'var(--text-muted)' }}>
|
||
{isOnline ? 'ONLINE' : 'OFFLINE'}
|
||
</span>
|
||
</div>
|
||
{(s.paperTitle || s.submitted) && (
|
||
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)', marginTop: '0.35rem' }}>
|
||
{s.paperTitle && <span>{s.paperTitle}</span>}
|
||
{s.submitted && <span style={{ color: 'var(--success)', marginLeft: s.paperTitle ? '0.35rem' : 0 }}>· Đã nộp</span>}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)
|
||
) : activeSubTab === 'detail' ? (
|
||
<div className="session-logs-panel">
|
||
<div className="attendance-table-scroll table-wrapper" style={{ border: 'none' }}>
|
||
{students.length === 0 ? (
|
||
<div className="empty-state" style={{ minHeight: '300px' }}>
|
||
<div className="empty-state-icon">📋</div>
|
||
<h2>Chưa có sinh viên</h2>
|
||
<p>Thêm sinh viên để xem chi tiết đề gán và trạng thái nộp bài.</p>
|
||
</div>
|
||
) : (
|
||
<table className="data-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Sinh viên</th>
|
||
<th>Mã SV</th>
|
||
<th>Online</th>
|
||
<th>Gói đề</th>
|
||
<th>Gửi gói</th>
|
||
<th>Nộp bài</th>
|
||
<th style={{ textAlign: 'right' }}>Thao tác</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{filteredStudents.map((s) => {
|
||
const isOnline = onlineIds.includes(s.studentRkId);
|
||
return (
|
||
<tr key={s.id}>
|
||
<td>
|
||
<div style={{ fontWeight: 600, color: 'var(--text-primary)' }}>{s.fullName}</div>
|
||
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>{s.email}</div>
|
||
</td>
|
||
<td><code>{s.studentCode}</code></td>
|
||
<td style={{ color: isOnline ? 'var(--success)' : 'var(--text-muted)', fontWeight: 700 }}>
|
||
{isOnline ? 'Online' : 'Offline'}
|
||
</td>
|
||
<td>{s.paperTitle || '—'}</td>
|
||
<td>{s.paperSentAt ? fmtTime(s.paperSentAt) : s.paperScheduledAt ? `Hẹn ${fmtTime(s.paperScheduledAt)}` : '—'}</td>
|
||
<td>{s.submitted ? <span className="badge badge-success">Đã nộp</span> : '—'}</td>
|
||
<td style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||
<button type="button" className="btn btn-primary btn-sm" onClick={() => setSelectedStudent(toStudentItem(s))}>
|
||
Giám sát
|
||
</button>
|
||
<button type="button" className="btn btn-secondary btn-sm" style={{ marginLeft: '0.35rem' }} onClick={() => openStudentChat(s)}>
|
||
Nhắn tin
|
||
</button>
|
||
{prepEditable && (
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary btn-sm learning-btn-danger"
|
||
style={{ marginLeft: '0.35rem' }}
|
||
onClick={() => apiExam.removeStudent(examId, s.studentRkId).then(load)}
|
||
>
|
||
Xóa
|
||
</button>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="session-logs-panel">
|
||
{submissions.length > 0 && (
|
||
<div className="exam-submissions-toolbar">
|
||
<button
|
||
type="button"
|
||
className="btn btn-primary btn-sm"
|
||
disabled={bundlingSubs}
|
||
onClick={downloadAllSubs}
|
||
>
|
||
{bundlingSubs ? 'Đang gói ZIP...' : '📦 Tải tất cả (ZIP gộp)'}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary btn-sm"
|
||
disabled={publishingGit || !gh.connected}
|
||
onClick={publishToGit}
|
||
title={gh.connected ? 'Tạo repo + đẩy từng folder sinh viên lên GitHub' : 'Kết nối GitHub trong Tài khoản trước'}
|
||
>
|
||
{publishingGit ? 'Đang đẩy Git...' : '🔗 Đẩy lên Git'}
|
||
</button>
|
||
{!gh.connected && (
|
||
<span className="exam-submissions-toolbar-hint">
|
||
<button type="button" className="link-btn" onClick={() => navigate('profile')}>
|
||
Kết nối GitHub
|
||
</button>
|
||
{' '}trong Tài khoản của tôi trước khi đẩy.
|
||
</span>
|
||
)}
|
||
<span className="exam-submissions-toolbar-hint">
|
||
ZIP gộp: mỗi sinh viên một folder — giải nén là chấm ngay.
|
||
</span>
|
||
</div>
|
||
)}
|
||
<div className="attendance-table-scroll table-wrapper" style={{ border: 'none' }}>
|
||
{submissions.length === 0 ? (
|
||
<div className="empty-state" style={{ minHeight: '300px' }}>
|
||
<div className="empty-state-icon">📦</div>
|
||
<h2>Chưa có bài nộp</h2>
|
||
<p>Sinh viên nộp bài qua app Simple Care sẽ hiện tại đây.</p>
|
||
</div>
|
||
) : (
|
||
<table className="data-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Sinh viên</th>
|
||
<th>File</th>
|
||
<th>Thời gian</th>
|
||
<th style={{ textAlign: 'right' }}>Tải</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{submissions.map((s) => (
|
||
<tr key={s.id}>
|
||
<td>
|
||
<div style={{ fontWeight: 600 }}>{s.fullName}</div>
|
||
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>{s.studentCode}</div>
|
||
</td>
|
||
<td style={{ fontSize: '0.85rem' }}>{s.fileName}</td>
|
||
<td style={{ fontSize: '0.8rem', color: 'var(--text-secondary)' }}>{fmtTime(s.createdAt)}</td>
|
||
<td style={{ textAlign: 'right' }}>
|
||
<button type="button" className="btn btn-secondary btn-sm" onClick={() => downloadSub(s.id, s.fileName)}>Tải</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{selectedStudent && (
|
||
<StudentDetailModal
|
||
student={selectedStudent}
|
||
isOnline={onlineIds.includes(selectedStudent.rkId)}
|
||
onClose={() => setSelectedStudent(null)}
|
||
/>
|
||
)}
|
||
|
||
{studentPickerOpen && (
|
||
<div className="modal-overlay exam-student-overlay" onClick={() => setStudentPickerOpen(false)}>
|
||
<div className="modal-container exam-student-modal" onClick={(e) => e.stopPropagation()}>
|
||
<div className="modal-header exam-student-modal-header">
|
||
<div>
|
||
<h2 className="modal-title">Thêm sinh viên</h2>
|
||
<p className="exam-student-modal-desc">Tìm theo mã lớp, họ tên, mã sinh viên</p>
|
||
</div>
|
||
<button type="button" className="modal-close-btn" onClick={() => setStudentPickerOpen(false)} aria-label="Đóng">
|
||
×
|
||
</button>
|
||
</div>
|
||
<div className="exam-student-modal-body">
|
||
<div className="exam-student-modal-search search-input-wrap">
|
||
<input
|
||
type="search"
|
||
className="search-input"
|
||
placeholder="Tìm sinh viên..."
|
||
value={searchQ}
|
||
onChange={(e) => setSearchQ(e.target.value)}
|
||
autoFocus
|
||
/>
|
||
</div>
|
||
<div className="exam-student-hits">
|
||
{searchQ.trim() === '' ? (
|
||
<div className="exam-student-hits-empty">
|
||
<span>🔍</span>
|
||
<p>Gõ ít nhất vài ký tự để tìm sinh viên.</p>
|
||
</div>
|
||
) : searchHits.length === 0 ? (
|
||
<div className="exam-student-hits-empty">
|
||
<span>📭</span>
|
||
<p>Không tìm thấy sinh viên phù hợp.</p>
|
||
</div>
|
||
) : (
|
||
searchHits.map((h) => {
|
||
const added = enrolledIds.has(h.studentRkId);
|
||
const initials = h.fullName
|
||
.split(/\s+/)
|
||
.filter(Boolean)
|
||
.slice(-2)
|
||
.map((w) => w[0]?.toUpperCase() || '')
|
||
.join('');
|
||
return (
|
||
<div key={h.studentRkId} className="exam-student-hit">
|
||
<div className="exam-student-hit-avatar" aria-hidden="true">{initials || '?'}</div>
|
||
<div className="exam-student-hit-info">
|
||
<strong>{h.fullName}</strong>
|
||
<div className="exam-student-hit-meta">
|
||
Mã SV: <code>{h.studentCode}</code>
|
||
{h.classCodes ? <> · Lớp: {h.classCodes}</> : null}
|
||
{h.email ? <> · {h.email}</> : null}
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className={`btn btn-sm ${added ? 'btn-secondary' : 'btn-primary'}`}
|
||
disabled={added}
|
||
onClick={() => addStudent(h.studentRkId)}
|
||
>
|
||
{added ? '✓ Đã thêm' : '+ Thêm'}
|
||
</button>
|
||
</div>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<AppPoolModal
|
||
open={appPoolOpen}
|
||
onClose={() => setAppPoolOpen(false)}
|
||
onSelect={addAppKeyword}
|
||
allowedApps={allowedApps}
|
||
/>
|
||
</div>
|
||
);
|
||
};
|