Files
rikkei_simple_care/management/src/components/ExamRoomWorkspace.tsx
PhuocNTB 3fa8778084
Some checks failed
Deploy on Master Change / deploy (push) Failing after 37s
fix ui teacher
2026-07-02 16:05:59 +07:00

1452 lines
65 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<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 [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<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 [selectedPickerRkIds, setSelectedPickerRkIds] = useState<number[]>([]);
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 [templatePickerOpen, setTemplatePickerOpen] = 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 [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<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 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 (
<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>
)}
{papers.length > 0 && (
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setPapersModalOpen(true)}>
📄 Xem đ
</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ố
</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>
{!editable && canEditApps && (
<p className="config-card-desc" style={{ color: 'var(--success)', fontWeight: 600, display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
<span>💡</span> Phòng thi đã đy hoặc đang diễn ra, bạn vẫn thể thay đi danh sách ng dụng đưc phép bên dưới.
</p>
)}
<textarea
className="app-textarea"
placeholder="chrome, msedge, acrobat, wails, simple_care, client"
value={allowedApps}
onChange={(e) => setAllowedApps(e.target.value)}
disabled={!canEditApps}
/>
{canEditApps && (
<>
<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 & khung ng dụng</span>
<p className="discovered-apps-hint" style={{ margin: '0.25rem 0 0' }}>
Thêm từng app trong kho hoặc áp dụng cả bộ keyword đã lưu.
</p>
</div>
<div className="app-pool-open-actions">
<button type="button" className="btn btn-secondary app-pool-open-btn" onClick={() => setTemplatePickerOpen(true)}>
Chọn khung
</button>
<button type="button" className="btn btn-secondary app-pool-open-btn" onClick={() => setAppPoolOpen(true)}>
Mở kho
</button>
</div>
</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) <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>
{!canModifyPapers && (
<p className="config-card-desc" style={{ color: 'var(--danger)' }}>
Không thể thay đi gói đ đ thi đã đưc gửi cho sinh viên hoặc phòng thi đã kết thúc.
</p>
)}
{canModifyPapers && (
<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, many files)</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 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', flexWrap: 'wrap' }}>
<strong>{p.title}</strong>
<div style={{ display: 'flex', gap: '0.35rem', alignItems: 'center' }}>
<button type="button" className="btn btn-ghost btn-sm" onClick={() => viewPaperPdf(p.id)}>Xem PDF</button>
<span className="badge badge-muted">Gói đ</span>
</div>
</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 tài nguyên kèm</li>
) : (
(p.resources || []).map((r) => (
<li key={r.id}>
📎 {r.fileName}{' '}
<button type="button" className="link-btn" onClick={() => downloadPaperResource(p.id, r.id, r.fileName)}>
Tải
</button>
</li>
))
)}
</ul>
{canModifyPapers && (
<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 && canModifyPapers && (
<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 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')}
>
đ 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([]); setSelectedPickerRkIds([]); }}
>
+ Thêm sinh viên
</button>
)}
{papers.length > 0 && (
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setPapersModalOpen(true)}>
📄 Xem đ ({papers.length})
</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 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([]); setSelectedPickerRkIds([]); }}
>
+ 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 sinh viên</h2>
<p>Thêm sinh viên đ xem chi tiết đ gán trạng thái nộp bài.</p>
</div>
) : (
<table className="data-table">
<thead>
<tr>
<th>Sinh viên</th>
<th> 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 || '—'}
{s.assignedPaperId && (
<button
type="button"
className="link-btn"
style={{ marginLeft: '0.35rem' }}
onClick={() => viewPaperPdf(s.assignedPaperId!)}
>
Xem đ
</button>
)}
</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">
{papers.length > 0 && (
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={() => setPapersModalOpen(true)}
>
📄 Xem đ phòng thi
</button>
)}
<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="1 repo — mỗi sinh viên một folder"
>
{publishingGit ? 'Đang đẩy...' : '🔗 Git gộp (1 repo)'}
</button>
<button
type="button"
className="btn btn-secondary btn-sm"
disabled={publishingGitStudents || !gh.connected}
onClick={publishToGitStudents}
title="Mỗi sinh viên một repo GitHub riêng"
>
{publishingGitStudents ? 'Đang tạo repo...' : '👤 Git từng SV'}
</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">
<strong>Git gộp</strong>: 1 repo, folder từng SV · <strong>Git từng SV</strong>: mỗi người 1 repo.
</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 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>Git</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' }}>
{s.gitPublishUrl ? (
<a href={s.gitPublishUrl} target="_blank" rel="noreferrer">Repo</a>
) : '—'}
</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>
{pdfViewer && (
<ExamPaperPdfModal
title={pdfViewer.title}
bytes={pdfViewer.bytes}
loading={pdfLoading}
onClose={closePdfViewer}
/>
)}
{inlineResource && (
<div className="modal-overlay exam-pdf-viewer-overlay" onClick={closeInlineResource}>
<div className="exam-pdf-viewer-modal exam-inline-resource-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header exam-pdf-viewer-header">
<h2 className="modal-title">{inlineResource.title}</h2>
<button type="button" className="modal-close-btn" onClick={closeInlineResource} aria-label="Đóng">×</button>
</div>
<div className="exam-inline-resource-body">
{inlineResource.isImage ? (
<img src={inlineResource.url} alt={inlineResource.title} className="exam-inline-resource-image" />
) : (
<iframe src={inlineResource.url} title={inlineResource.title} className="exam-pdf-viewer-frame" />
)}
</div>
</div>
</div>
)}
{papersModalOpen && (
<div className="modal-overlay" onClick={() => setPapersModalOpen(false)}>
<div className="modal-container exam-papers-view-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<div>
<h2 className="modal-title">Đ thi {roomName}</h2>
<p className="exam-student-modal-desc">{papers.length} gói đ trong phòng thi</p>
</div>
<button type="button" className="modal-close-btn" onClick={() => setPapersModalOpen(false)} aria-label="Đóng">×</button>
</div>
<div className="exam-papers-view-body">
{papers.map((p) => (
<div key={p.id} className="exam-paper-view-card">
<div className="exam-paper-view-head">
<strong>{p.title}</strong>
<button type="button" className="btn btn-primary btn-sm" onClick={(e) => { e.preventDefault(); void viewPaperPdf(p.id); }}>
Xem PDF đ
</button>
</div>
<ul className="exam-paper-view-resources">
<li>📄 Đ chính (PDF)</li>
{(p.resources || []).length === 0 ? (
<li className="text-muted">Không tài nguyên kèm</li>
) : (
(p.resources || []).map((r) => (
<li key={r.id}>
📎 {r.fileName}
<span className="exam-paper-view-actions">
{/\.(pdf|png|jpe?g|gif|webp|txt)$/i.test(r.fileName) && (
<button type="button" className="link-btn" onClick={() => viewPaperResource(p.id, r.id, r.fileName)}>
Xem
</button>
)}
<button type="button" className="link-btn" onClick={() => downloadPaperResource(p.id, r.id, r.fileName)}>
Tải
</button>
</span>
</li>
))
)}
</ul>
</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 lớp, họ tên, 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="tab-btn-group" style={{ marginBottom: '1rem' }}>
<button
type="button"
className={`tab-sub-btn ${searchMode === 'single' ? 'active' : ''}`}
onClick={() => handleSwitchTab('single')}
>
Tìm từng bạn
</button>
<button
type="button"
className={`tab-sub-btn ${searchMode === 'bulk' ? 'active' : ''}`}
onClick={() => handleSwitchTab('bulk')}
>
Tìm hàng loạt
</button>
</div>
{searchMode === 'single' ? (
<div className="exam-student-modal-search search-input-wrap" style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<input
type="search"
className="search-input"
placeholder="Tìm sinh viên..."
value={searchQ}
onChange={(e) => {
setSearchQ(e.target.value);
setSelectedPickerRkIds([]);
}}
autoFocus
style={{ flex: 1 }}
/>
{searchQ.trim() !== '' && searchHits.some((h) => !enrolledIds.has(h.studentRkId)) && (
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={toggleSelectAllHits}
style={{ whiteSpace: 'nowrap' }}
>
{searchHits.filter((h) => !enrolledIds.has(h.studentRkId)).every((h) => selectedPickerRkIds.includes(h.studentRkId))
? 'Bỏ chọn tất cả'
: 'Chọn tất cả'}
</button>
)}
</div>
) : (
<div className="exam-student-modal-search" style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<textarea
className="search-input"
placeholder="Nhập hoặc paste danh sách sinh viên tại đây (Họ tên, mã sinh viên, email...).&#10;Mỗi dòng một bạn, hoặc ngăn cách bằng dấu phẩy (,) hoặc chấm phẩy (;)."
value={bulkInput}
onChange={(e) => setBulkInput(e.target.value)}
style={{
width: '100%',
height: '100px',
resize: 'vertical',
padding: '0.5rem 0.75rem',
fontFamily: 'inherit',
fontSize: '0.9rem',
lineHeight: '1.4'
}}
autoFocus
/>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '0.25rem' }}>
<span style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>
Mẹo: thể copy một cột Excel dán trực tiếp vào đây.
</span>
<div style={{ display: 'flex', gap: '0.5rem' }}>
{searchHits.length > 0 && searchHits.some((h) => !enrolledIds.has(h.studentRkId)) && (
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={toggleSelectAllHits}
>
{searchHits.filter((h) => !enrolledIds.has(h.studentRkId)).every((h) => selectedPickerRkIds.includes(h.studentRkId))
? 'Bỏ chọn tất cả'
: 'Chọn tất cả'}
</button>
)}
<button
type="button"
className="btn btn-primary btn-sm"
onClick={handleBulkSearch}
disabled={!bulkInput.trim()}
>
Tìm hàng loạt
</button>
</div>
</div>
</div>
)}
<div className="exam-student-hits">
{searchMode === 'single' && searchQ.trim() === '' ? (
<div className="exam-student-hits-empty">
<span>🔍</span>
<p> ít nhất vài tự đ tìm sinh viên.</p>
</div>
) : searchMode === 'bulk' && searchHits.length === 0 ? (
<div className="exam-student-hits-empty">
<span>📋</span>
<p>Paste danh sách bấm "Tìm hàng loạt" đ hiển thị kết quả.</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 isSelected = selectedPickerRkIds.includes(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 ${added ? 'exam-student-hit--added' : ''} ${isSelected ? 'exam-student-hit--selected' : ''}`}
style={{ cursor: added ? 'default' : 'pointer' }}
onClick={() => {
if (!added) toggleHitSelection(h.studentRkId);
}}
>
<div
style={{ display: 'flex', alignItems: 'center' }}
onClick={(e) => e.stopPropagation()}
>
<input
type="checkbox"
checked={added || isSelected}
disabled={added}
onChange={() => {
if (!added) toggleHitSelection(h.studentRkId);
}}
/>
</div>
<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">
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={(e) => {
e.stopPropagation();
addStudent(h.studentRkId);
}}
>
{added ? '✓ Đã thêm' : '+ Thêm'}
</button>
</div>
);
})
)}
</div>
</div>
<div className="modal-footer exam-student-modal-footer" style={{
display: 'flex',
justifyContent: 'flex-end',
gap: '0.75rem',
padding: '0.85rem 1.25rem',
borderTop: '1px solid var(--border-color, #dee2e6)',
background: 'var(--bg-subtle, #f8f9fa)',
borderRadius: '0 0 var(--radius-lg) var(--radius-lg)'
}}>
<button
type="button"
className="btn btn-secondary"
onClick={() => setStudentPickerOpen(false)}
>
Đóng
</button>
<button
type="button"
className="btn btn-primary"
disabled={selectedPickerRkIds.length === 0}
onClick={addSelectedStudents}
>
Thêm {selectedPickerRkIds.length > 0 ? `(${selectedPickerRkIds.length})` : ''} sinh viên đã chọn
</button>
</div>
</div>
</div>
)}
<AppPoolModal
open={appPoolOpen}
onClose={() => setAppPoolOpen(false)}
onSelect={addAppKeyword}
allowedApps={allowedApps}
/>
<AppTemplatePickerModal
open={templatePickerOpen}
onClose={() => setTemplatePickerOpen(false)}
onSelect={applyTemplate}
allowedApps={allowedApps}
/>
</div>
);
};