This commit is contained in:
2026-06-30 11:51:34 +07:00
parent fd08bee7ab
commit 0abf48ea2d
28 changed files with 3514 additions and 37 deletions

View File

@@ -3,6 +3,8 @@ import { DashboardTab } from './components/DashboardTab';
import { ClassesTab } from './components/ClassesTab';
import { StudentsTab } from './components/StudentsTab';
import { LearningTab } from './components/LearningTab';
import { ExamsTab } from './components/ExamsTab';
import { ExamRoomWorkspace } from './components/ExamRoomWorkspace';
import { NetworkTab } from './components/NetworkTab';
import { EmailDomainsTab, MyAccountTab } from './components/AccountsTab';
import { ChatWidget } from './components/ChatWidget';
@@ -41,6 +43,14 @@ const IconLearning = () => (
</svg>
);
const IconExam = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" /><line x1="16" y1="17" x2="8" y2="17" />
</svg>
);
const IconNetwork = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M5 12.55a11 11 0 0 1 14.08 0" />
@@ -63,7 +73,7 @@ function App() {
useEffect(() => {
const initial = parseRoute();
if (initial.classId) {
if (initial.classId || initial.examId) {
return;
}
pushNav({ kind: 'tab', tab: initial.tab, label: TAB_LABELS[initial.tab] });
@@ -73,10 +83,12 @@ function App() {
navigate(tab as TabId);
};
const handleBackFromClass = () => {
const handleBackFromWorkspace = () => {
goBack(route.tab);
};
const inWorkspace = Boolean(route.classId || route.examId);
return (
<>
<aside className="sidebar">
@@ -93,7 +105,7 @@ function App() {
<div className="nav-header">Tổng quan</div>
<li className="nav-item">
<button
className={`nav-btn ${route.tab === 'dashboard' && !route.classId ? 'active' : ''}`}
className={`nav-btn ${route.tab === 'dashboard' && !inWorkspace ? 'active' : ''}`}
onClick={() => navigate('dashboard')}
>
<span className="nav-icon"><IconDashboard /></span>
@@ -104,7 +116,7 @@ function App() {
<div className="nav-header">CSDL liên kết</div>
<li className="nav-item">
<button
className={`nav-btn ${route.tab === 'classes' && !route.classId ? 'active' : ''}`}
className={`nav-btn ${route.tab === 'classes' && !inWorkspace ? 'active' : ''}`}
onClick={() => navigate('classes')}
>
<span className="nav-icon"><IconClass /></span>
@@ -113,7 +125,7 @@ function App() {
</li>
<li className="nav-item">
<button
className={`nav-btn ${route.tab === 'students' && !route.classId ? 'active' : ''}`}
className={`nav-btn ${route.tab === 'students' && !inWorkspace ? 'active' : ''}`}
onClick={() => navigate('students')}
>
<span className="nav-icon"><IconStudent /></span>
@@ -124,7 +136,7 @@ function App() {
<div className="nav-header">Quản học tập</div>
<li className="nav-item">
<button
className={`nav-btn ${route.tab === 'learning' && !route.classId ? 'active' : ''}`}
className={`nav-btn ${route.tab === 'learning' && !inWorkspace ? 'active' : ''}`}
onClick={() => navigate('learning')}
>
<span className="nav-icon"><IconLearning /></span>
@@ -133,7 +145,16 @@ function App() {
</li>
<li className="nav-item">
<button
className={`nav-btn ${route.tab === 'network' && !route.classId ? 'active' : ''}`}
className={`nav-btn ${route.tab === 'exams' && !inWorkspace ? 'active' : ''}`}
onClick={() => navigate('exams')}
>
<span className="nav-icon"><IconExam /></span>
Phòng thi
</button>
</li>
<li className="nav-item">
<button
className={`nav-btn ${route.tab === 'network' && !inWorkspace ? 'active' : ''}`}
onClick={() => navigate('network')}
>
<span className="nav-icon"><IconNetwork /></span>
@@ -144,7 +165,7 @@ function App() {
<div className="nav-header">Hệ thống</div>
<li className="nav-item">
<button
className={`nav-btn ${route.tab === 'email-domains' && !route.classId ? 'active' : ''}`}
className={`nav-btn ${route.tab === 'email-domains' && !inWorkspace ? 'active' : ''}`}
onClick={() => navigate('email-domains')}
>
<span className="nav-icon"><IconEmail /></span>
@@ -157,7 +178,7 @@ function App() {
<div className="sidebar-footer">
<button
type="button"
className={`sidebar-user-btn ${route.tab === 'profile' && !route.classId ? 'active' : ''}`}
className={`sidebar-user-btn ${route.tab === 'profile' && !inWorkspace ? 'active' : ''}`}
onClick={() => navigate('profile')}
title="Tài khoản của tôi"
>
@@ -178,17 +199,20 @@ function App() {
<ClassWorkspace
classId={route.classId}
sourceTab={route.tab}
onBack={handleBackFromClass}
onBack={handleBackFromWorkspace}
renderNavBar={(className) => (
<NavHistoryBar classLabel={className} onBack={handleBackFromClass} />
<NavHistoryBar classLabel={className} onBack={handleBackFromWorkspace} />
)}
/>
) : route.examId ? (
<ExamRoomWorkspace examId={route.examId} onBack={handleBackFromWorkspace} />
) : (
<>
{route.tab === 'dashboard' && <DashboardTab onNavigate={setActiveTab} />}
{route.tab === 'classes' && <ClassesTab />}
{route.tab === 'students' && <StudentsTab />}
{route.tab === 'learning' && <LearningTab />}
{route.tab === 'exams' && <ExamsTab />}
{route.tab === 'network' && <NetworkTab />}
{route.tab === 'email-domains' && <EmailDomainsTab />}
{route.tab === 'profile' && <MyAccountTab />}

View File

@@ -4,7 +4,7 @@ function getToken(): string | null {
return localStorage.getItem('sc_staff_token');
}
async function staffFetch(path: string, init: RequestInit = {}): Promise<Response> {
export async function staffFetch(path: string, init: RequestInit = {}): Promise<Response> {
const headers = new Headers(init.headers);
const token = getToken();
if (token) headers.set('Authorization', `Bearer ${token}`);
@@ -16,7 +16,10 @@ async function staffFetch(path: string, init: RequestInit = {}): Promise<Respons
async function parseError(res: Response, fallback: string): Promise<never> {
const errData = await res.json().catch(() => ({}));
throw new Error((errData as { error?: string }).error || fallback);
const msg = (errData as { error?: string }).error;
if (msg) throw new Error(msg);
if (res.status === 413) throw new Error('File quá lớn (tối đa 100MB)');
throw new Error(`${fallback} (HTTP ${res.status})`);
}
export interface StaffUser {
@@ -568,3 +571,185 @@ export const apiPushAttendanceQLDT = async (rkId: number, date: string, period:
}
return res.json();
};
async function staffUpload(path: string, form: FormData): Promise<Response> {
const headers = new Headers();
const token = getToken();
if (token) headers.set('Authorization', `Bearer ${token}`);
return fetch(`${API_BASE}${path}`, { method: 'POST', headers, body: form });
}
export interface ExamRoomItem {
id: number;
name: string;
startTime: string;
endTime: string;
allowedApps: string;
quizUrl: string;
status: 'draft' | 'ready' | 'ended' | 'cancelled';
studentCount: number;
paperCount: number;
displayStatus: 'draft' | 'ready' | 'active' | 'ended' | 'cancelled';
}
export interface ExamPaperResource {
id: number;
examPaperId: number;
fileName: string;
}
export interface ExamPaper {
id: number;
examRoomId: number;
title: string;
pdfPath: string;
sortOrder: number;
resources: ExamPaperResource[];
}
export interface ExamRoomStudent {
id: number;
examRoomId: number;
studentRkId: number;
assignedPaperId?: number;
paperSentAt?: string;
paperScheduledAt?: string;
fullName: string;
studentCode: string;
email: string;
avatar?: string;
paperTitle?: string;
submitted: boolean;
}
export interface ExamSubmission {
id: number;
examRoomId: number;
studentRkId: number;
fileName: string;
createdAt: string;
fullName: string;
studentCode: string;
}
export const apiExam = {
list: async (): Promise<{ data: ExamRoomItem[] }> => {
const res = await staffFetch('/exam-rooms');
if (!res.ok) await parseError(res, 'Failed');
return res.json();
},
create: async (payload: { name: string; startTime: string; endTime: string; allowedApps?: string; quizUrl?: string }) => {
const res = await staffFetch('/exam-rooms', { method: 'POST', body: JSON.stringify(payload) });
if (!res.ok) await parseError(res, 'Tạo phòng thi thất bại');
return res.json();
},
get: async (id: number) => {
const res = await staffFetch(`/exam-rooms/${id}`);
if (!res.ok) await parseError(res, 'Không tìm thấy');
return res.json() as Promise<{
room: ExamRoomItem;
papers: ExamPaper[];
students: ExamRoomStudent[];
displayStatus: string;
editable: boolean;
prepEditable: boolean;
canPublish: boolean;
canUnpublish: boolean;
canCancel: boolean;
}>;
},
publish: async (id: number) => {
const res = await staffFetch(`/exam-rooms/${id}/publish`, { method: 'POST' });
if (!res.ok) {
const data = await res.json().catch(() => ({})) as { error?: string; issues?: string[] };
if (data.issues?.length) {
throw new Error(data.issues.join('\n'));
}
throw new Error(data.error || 'Đẩy phòng thi thất bại');
}
return res.json();
},
unpublish: async (id: number) => {
const res = await staffFetch(`/exam-rooms/${id}/unpublish`, { method: 'POST' });
if (!res.ok) await parseError(res, 'Thu hồi thất bại');
return res.json();
},
cancel: async (id: number) => {
const res = await staffFetch(`/exam-rooms/${id}/cancel`, { method: 'POST' });
if (!res.ok) await parseError(res, 'Hủy phòng thi thất bại');
return res.json();
},
update: async (id: number, payload: Partial<{ name: string; startTime: string; endTime: string; allowedApps: string; quizUrl: string }>) => {
const res = await staffFetch(`/exam-rooms/${id}`, { method: 'PATCH', body: JSON.stringify(payload) });
if (!res.ok) await parseError(res, 'Cập nhật thất bại');
return res.json();
},
remove: async (id: number) => {
const res = await staffFetch(`/exam-rooms/${id}`, { method: 'DELETE' });
if (!res.ok) await parseError(res, 'Xóa thất bại');
return res.json();
},
searchStudents: async (q: string) => {
const res = await staffFetch(`/exam-rooms/search-students?q=${encodeURIComponent(q)}`);
if (!res.ok) await parseError(res, 'Tìm kiếm thất bại');
return res.json() as Promise<{ data: { studentRkId: number; fullName: string; studentCode: string; email: string; classCodes: string }[] }>;
},
addStudents: async (id: number, studentRkIds: number[]) => {
const res = await staffFetch(`/exam-rooms/${id}/students`, { method: 'POST', body: JSON.stringify({ studentRkIds }) });
if (!res.ok) await parseError(res, 'Thêm sinh viên thất bại');
return res.json();
},
removeStudent: async (id: number, studentRkId: number) => {
const res = await staffFetch(`/exam-rooms/${id}/students/${studentRkId}`, { method: 'DELETE' });
if (!res.ok) await parseError(res, 'Xóa sinh viên thất bại');
return res.json();
},
fetchOnlineStudents: async (id: number): Promise<{ onlineStudentIds: number[] }> => {
const res = await staffFetch(`/exam-rooms/${id}/online-students`);
if (!res.ok) throw new Error('Không tải được trạng thái online');
return res.json();
},
uploadPackage: async (id: number, title: string, pdf: File, resources: File[] = []) => {
const form = new FormData();
form.append('title', title);
form.append('pdf', pdf);
resources.forEach((f) => form.append('resources', f));
const res = await staffUpload(`/exam-rooms/${id}/papers`, form);
if (!res.ok) await parseError(res, 'Tạo gói đề thất bại');
return res.json();
},
uploadPaper: async (id: number, title: string, pdf: File, resources: File[] = []) => {
return apiExam.uploadPackage(id, title, pdf, resources);
},
uploadPackageResources: async (id: number, paperId: number, files: File[]) => {
const form = new FormData();
files.forEach((f) => form.append('resources', f));
const res = await staffUpload(`/exam-rooms/${id}/papers/${paperId}/resources`, form);
if (!res.ok) await parseError(res, 'Thêm tài nguyên thất bại');
return res.json();
},
uploadResource: async (id: number, paperId: number, file: File) => {
return apiExam.uploadPackageResources(id, paperId, [file]);
},
deletePaper: async (id: number, paperId: number) => {
const res = await staffFetch(`/exam-rooms/${id}/papers/${paperId}`, { method: 'DELETE' });
if (!res.ok) await parseError(res, 'Xóa gói đề thất bại');
return res.json();
},
assignRandom: async (id: number) => {
const res = await staffFetch(`/exam-rooms/${id}/assign-random`, { method: 'POST' });
if (!res.ok) await parseError(res, 'Chia gói đề thất bại');
return res.json() as Promise<{ assigned: number; packages: number }>;
},
sendPapers: async (id: number, payload?: { scheduledAt?: string; studentRkIds?: number[] }) => {
const res = await staffFetch(`/exam-rooms/${id}/send-papers`, { method: 'POST', body: JSON.stringify(payload || {}) });
if (!res.ok) await parseError(res, 'Gửi gói đề thất bại');
return res.json();
},
listSubmissions: async (id: number) => {
const res = await staffFetch(`/exam-rooms/${id}/submissions`);
if (!res.ok) await parseError(res, 'Failed');
return res.json() as Promise<{ data: ExamSubmission[] }>;
},
downloadSubmission: (id: number, subId: number) => `${API_BASE}/exam-rooms/${id}/submissions/${subId}/download`,
};

View File

@@ -0,0 +1,10 @@
export type StaffChatOpenDetail = {
studentRkId: number;
fullName: string;
studentCode: string;
email?: string;
};
export function openStaffChat(student: StaffChatOpenDetail) {
window.dispatchEvent(new CustomEvent<StaffChatOpenDetail>('staff-chat:open', { detail: student }));
}

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { apiChat, type ChatConversation, type ChatMessage, type ChatStudent } from '../api';
import { type StaffChatOpenDetail } from '../chatEvents';
import { useAuth } from '../auth/AuthContext';
import { onStaffChatMessage, playChatSound } from '../hooks/useStaffChatSocket';
@@ -82,6 +83,26 @@ export function ChatWidget() {
return () => { off(); };
}, [handleIncoming]);
useEffect(() => {
const onOpen = (e: Event) => {
const detail = (e as CustomEvent<StaffChatOpenDetail>).detail;
if (!detail?.studentRkId) return;
setOpen(true);
setActive({
studentRkId: detail.studentRkId,
fullName: detail.fullName,
studentCode: detail.studentCode,
email: detail.email || '',
online: false,
});
setPickerOpen(false);
setQuery('');
loadMessages(detail.studentRkId).catch(console.error);
};
window.addEventListener('staff-chat:open', onOpen);
return () => window.removeEventListener('staff-chat:open', onOpen);
}, [loadMessages]);
useEffect(() => {
if (!staff?.id) return;
loadConversations().catch(console.error);

View File

@@ -0,0 +1,881 @@
import React, { useCallback, useEffect, useState } from 'react';
import {
apiExam,
staffFetch,
type ExamPaper,
type ExamRoomStudent,
type ExamSubmission,
type StudentItem,
} from '../api';
import { BASE_APP_SUGGESTIONS, DEFAULT_EXAM_ALLOWED_APPS } from '../constants';
import { pushNav } from '../navigation';
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 [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 || '');
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 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ố
</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>
<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 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) <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 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 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 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([]); }}
>
+ 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 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 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 || '—'}</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">
<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>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">
<div className="modal-container" style={{ maxWidth: '560px', width: '92%' }}>
<div className="modal-header">
<div>
<h2 className="modal-title">Thêm sinh viên</h2>
<p style={{ margin: '4px 0 0', color: 'var(--text-muted)', fontSize: '0.85rem' }}>
Tìm theo lớp, họ tên, sinh viên
</p>
</div>
<button type="button" className="btn btn-secondary close-btn" onClick={() => setStudentPickerOpen(false)}>Đóng</button>
</div>
<div style={{ padding: '0 0 1rem' }}>
<input
type="search"
className="app-pool-search"
placeholder="Tìm sinh viên..."
value={searchQ}
onChange={(e) => setSearchQ(e.target.value)}
autoFocus
/>
<div style={{ maxHeight: 'min(55vh, 420px)', overflowY: 'auto', marginTop: '1rem' }}>
{searchQ.trim() === '' ? (
<p style={{ color: 'var(--text-muted)', textAlign: 'center' }}> ít nhất vài tự đ tìm.</p>
) : searchHits.length === 0 ? (
<p style={{ color: 'var(--text-muted)', textAlign: 'center' }}>Không tìm thấy sinh viên phù hợp.</p>
) : (
<ul className="learning-add-list">
{searchHits.map((h) => {
const added = enrolledIds.has(h.studentRkId);
return (
<li key={h.studentRkId} className="learning-add-item">
<div>
<div className="learning-class-code">{h.studentCode}</div>
<div className="learning-class-meta">{h.fullName}</div>
{(h.classCodes || h.email) && (
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginTop: '0.2rem' }}>
{[h.classCodes, h.email].filter(Boolean).join(' · ')}
</div>
)}
</div>
<button type="button" className="btn btn-primary btn-sm" disabled={added} onClick={() => addStudent(h.studentRkId)}>
{added ? 'Đã thêm' : 'Thêm'}
</button>
</li>
);
})}
</ul>
)}
</div>
</div>
</div>
</div>
)}
<AppPoolModal
open={appPoolOpen}
onClose={() => setAppPoolOpen(false)}
onSelect={addAppKeyword}
allowedApps={allowedApps}
/>
</div>
);
};

View File

@@ -0,0 +1,149 @@
import React, { useEffect, useState } from 'react';
import { apiExam, type ExamRoomItem } from '../api';
import { openExam } from './NavHistoryBar';
function fmtTime(iso: string) {
try {
return new Date(iso).toLocaleString('vi-VN');
} catch {
return iso;
}
}
function statusLabel(st: string) {
if (st === 'draft') return { text: 'Tạm thời', cls: 'badge-muted' };
if (st === 'ready') return { text: 'Sẵn sàng', cls: 'badge-info' };
if (st === 'active') return { text: 'Đang thi', cls: 'badge-success' };
if (st === 'cancelled') return { text: 'Đã hủy', cls: 'badge-warning' };
if (st === 'ended') return { text: 'Đã kết thúc', cls: 'badge-muted' };
return { text: st, cls: 'badge-muted' };
}
function toLocalInput(iso?: string) {
if (!iso) return '';
const d = new Date(iso);
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function localInputToISO(v: string) {
if (!v) return '';
return new Date(v).toISOString();
}
export const ExamsTab: React.FC = () => {
const [rooms, setRooms] = useState<ExamRoomItem[]>([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [name, setName] = useState('');
const [start, setStart] = useState('');
const [end, setEnd] = useState('');
const [busy, setBusy] = useState(false);
const load = async () => {
setLoading(true);
try {
const res = await apiExam.list();
setRooms(res.data);
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, []);
const create = async () => {
if (!name.trim() || !start || !end) return;
setBusy(true);
try {
const room = await apiExam.create({
name: name.trim(),
startTime: localInputToISO(start),
endTime: localInputToISO(end),
});
setShowCreate(false);
setName('');
openExam(room.id, room.name);
} catch (e: any) {
alert(e?.message || 'Lỗi');
} finally {
setBusy(false);
}
};
return (
<div className="page-stack">
<header className="page-header page-header--row">
<div>
<h1 className="page-title">Phòng thi</h1>
<p className="page-desc">Tạo phòng thi, chia đ ngẫu nhiên, gửi đ thu bài từ sinh viên.</p>
</div>
<button type="button" className="btn btn-primary" onClick={() => setShowCreate(true)}>+ Tạo phòng thi</button>
</header>
{showCreate && (
<div className="card" style={{ padding: '1.25rem' }}>
<h2 className="section-title">Phòng thi mới</h2>
<div className="form-grid" style={{ maxWidth: 520 }}>
<label className="login-field">
<span>Tên phòng thi</span>
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="VD: Thi cuối kỳ Java" />
</label>
<label className="login-field">
<span>Bắt đu</span>
<input type="datetime-local" value={start} onChange={(e) => setStart(e.target.value)} />
</label>
<label className="login-field">
<span>Kết thúc</span>
<input type="datetime-local" value={end} onChange={(e) => setEnd(e.target.value)} />
</label>
</div>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem' }}>
<button type="button" className="btn btn-primary" disabled={busy} onClick={create}>Tạo</button>
<button type="button" className="btn btn-ghost" onClick={() => setShowCreate(false)}>Hủy</button>
</div>
</div>
)}
<div className="card table-card">
{loading ? (
<div className="table-empty">Đang tải...</div>
) : rooms.length === 0 ? (
<div className="table-empty">Chưa phòng thi nào.</div>
) : (
<table className="data-table">
<thead>
<tr>
<th>Tên</th>
<th>Thời gian</th>
<th>SV / Đ</th>
<th>Trạng thái</th>
<th></th>
</tr>
</thead>
<tbody>
{rooms.map((r) => {
const st = statusLabel(r.displayStatus || r.status);
return (
<tr key={r.id}>
<td><strong>{r.name}</strong></td>
<td style={{ fontSize: '0.82rem' }}>{fmtTime(r.startTime)} {fmtTime(r.endTime)}</td>
<td>{r.studentCount} SV · {r.paperCount} đ</td>
<td><span className={`badge ${st.cls}`}>{st.text}</span></td>
<td>
<button type="button" className="btn btn-ghost btn-sm" onClick={() => openExam(r.id, r.name)}>Mở</button>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
</div>
);
};
export { toLocalInput, localInputToISO, fmtTime };

View File

@@ -29,7 +29,9 @@ export const NavHistoryBar: React.FC<NavHistoryBarProps> = ({ classLabel, onBack
const handlePillClick = (entry: NavEntry) => {
if (entry.kind === 'class' && entry.classId) {
navigate(entry.tab, entry.classId, entry.label);
navigate(entry.tab, entry.classId, entry.label, 'class');
} else if (entry.kind === 'exam' && entry.examId) {
navigate(entry.tab, entry.examId, entry.label, 'exam');
} else {
navigate(entry.tab);
}
@@ -39,7 +41,10 @@ export const NavHistoryBar: React.FC<NavHistoryBarProps> = ({ classLabel, onBack
if (entry.kind === 'class' && route.classId) {
return entry.classId === route.classId;
}
return entry.kind === 'tab' && !route.classId && entry.tab === route.tab;
if (entry.kind === 'exam' && route.examId) {
return entry.examId === route.examId;
}
return entry.kind === 'tab' && !route.classId && !route.examId && entry.tab === route.tab;
};
return (
@@ -103,7 +108,11 @@ export function useRoute() {
}
export function openClass(tab: TabId, classId: number, className: string) {
navigate(tab, classId, className);
navigate(tab, classId, className, 'class');
}
export function openExam(examId: number, examName: string) {
navigate('exams', examId, examName, 'exam');
}
export function openTab(tab: TabId) {

View File

@@ -1,4 +1,6 @@
/** Whitelist mặc định khi kích hoạt lớp — khớp tên tiến trình hoặc tiêu đề cửa sổ. */
export const DEFAULT_ALLOWED_APPS = 'chrome,idea64,vscode,wails,simple_care,client';
export const DEFAULT_EXAM_ALLOWED_APPS = 'chrome,msedge,edge,acrobat,acrord32,foxit,wails,simple_care,client';
export const BASE_APP_SUGGESTIONS = ['chrome', 'vscode', 'idea64', 'wails', 'simple_care', 'client', 'cursor', 'goland', 'teams', 'zoom'];

View File

@@ -797,6 +797,12 @@ input:checked + .slider:before {
border: 1px solid rgba(187, 33, 38, 0.2);
}
.badge-warning {
background-color: #fff8e6;
color: #b45309;
border: 1px solid rgba(180, 83, 9, 0.25);
}
.courses-tag-list {
display: flex;
flex-wrap: wrap;
@@ -3729,3 +3735,514 @@ input:checked + .slider:before {
border: 1px solid var(--border-color);
font-size: 0.84rem;
}
/* ── Exam workspace ── */
.exam-workspace {
flex: 1;
min-height: 0;
height: 100%;
}
.exam-workspace-header {
flex-shrink: 0;
}
.exam-workspace-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
align-items: center;
}
.exam-workspace-alert {
flex-shrink: 0;
margin: 0;
}
.exam-workspace-scroll {
flex: 1;
min-height: 0;
padding-bottom: 1rem;
}
.exam-section-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.exam-section-grid--bottom {
margin-top: 0;
}
@media (max-width: 960px) {
.exam-section-grid {
grid-template-columns: 1fr;
}
}
.exam-section {
padding: 1.15rem 1.25rem;
display: flex;
flex-direction: column;
gap: 0.85rem;
min-width: 0;
}
.exam-section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
}
.exam-section-title {
margin: 0;
font-size: 1rem;
font-weight: 700;
color: var(--text-primary);
}
.exam-section-toolbar {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
.exam-section-toolbar .search-input {
min-width: 140px;
width: auto;
}
.exam-form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
@media (max-width: 640px) {
.exam-form-grid {
grid-template-columns: 1fr;
}
}
.exam-section-students {
min-height: 280px;
}
.exam-students-panel {
padding: 0;
overflow: hidden;
}
.exam-students-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
padding: 1.15rem 1.25rem 0.85rem;
border-bottom: 1px solid var(--border-light);
background: #fff;
flex-shrink: 0;
}
.exam-online-stat {
margin: 0.35rem 0 0;
font-size: 0.8rem;
color: var(--text-muted);
font-weight: 600;
}
.exam-online-count {
color: var(--success);
font-size: 1rem;
font-weight: 800;
}
.exam-online-sep {
margin: 0 0.2rem;
color: var(--text-muted);
}
.exam-students-toolbar {
display: flex;
align-items: center;
gap: 0.65rem;
flex-wrap: wrap;
}
.exam-students-search {
min-width: 200px;
}
.exam-students-search .search-input {
width: 100%;
min-width: 180px;
}
.exam-students-body {
padding: 1rem 1.25rem 1.15rem;
max-height: min(52vh, 520px);
overflow-y: auto;
overflow-x: hidden;
}
.exam-student-grid {
padding-right: 0.15rem;
}
.exam-student-card {
align-items: flex-start;
position: relative;
}
.exam-student-card-main {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
overflow: hidden;
}
.exam-student-card-name {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.exam-student-card-code {
font-size: 0.78rem;
color: var(--accent);
font-family: monospace;
font-weight: 600;
}
.exam-student-card-status {
display: flex;
align-items: center;
gap: 0.35rem;
margin-top: 0.4rem;
}
.exam-student-online-label {
font-size: 0.75rem;
font-weight: 700;
color: var(--success);
}
.exam-student-offline-label {
font-size: 0.75rem;
font-weight: 700;
color: var(--text-muted);
}
.exam-student-card-tags {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-top: 0.5rem;
}
.exam-student-tag {
font-size: 0.7rem;
font-weight: 600;
padding: 0.15rem 0.45rem;
border-radius: 999px;
background: var(--bg-subtle);
border: 1px solid var(--border-light);
color: var(--text-secondary);
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.exam-student-tag--muted {
color: var(--text-muted);
}
.exam-student-tag--ok {
background: var(--success-light);
color: var(--success);
border-color: rgba(13, 159, 110, 0.25);
}
.exam-student-tag--sent {
background: var(--accent-light);
color: var(--accent);
border-color: rgba(187, 33, 38, 0.2);
}
.exam-student-card-actions {
display: flex;
flex-direction: column;
gap: 0.25rem;
flex-shrink: 0;
}
.exam-student-chat-btn {
font-size: 1rem !important;
line-height: 1;
}
.exam-students-table-scroll {
max-height: min(46vh, 460px);
overflow: auto;
border: 1px solid var(--border-light);
border-radius: var(--radius-md);
}
.exam-students-table-scroll .data-table thead {
position: sticky;
top: 0;
z-index: 2;
background: #fff;
box-shadow: 0 1px 0 var(--border-light);
}
.exam-students-table-scroll .data-table th {
background: #fff;
}
.exam-submissions-scroll .data-table thead {
position: sticky;
top: 0;
z-index: 2;
background: #fff;
}
.exam-student-row-clickable {
cursor: pointer;
}
.exam-student-row-clickable:hover td {
background: var(--bg-subtle);
}
.exam-row-status {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.78rem;
font-weight: 700;
}
.exam-row-status.online {
color: var(--success);
}
.exam-row-status.offline {
color: var(--text-muted);
}
.exam-student-row-actions {
white-space: nowrap;
}
.exam-table-wrap {
flex: 1;
min-height: 0;
max-height: 320px;
overflow: auto;
border: 1px solid var(--border-light);
border-radius: var(--radius-md);
}
.exam-table-wrap--short {
max-height: 240px;
}
.exam-table-empty {
text-align: center;
color: var(--text-muted);
padding: 2rem 1rem !important;
}
.exam-cell-muted {
font-size: 0.78rem;
color: var(--text-muted);
}
.exam-empty-state {
text-align: center;
padding: 1.5rem 1rem;
color: var(--text-muted);
background: var(--bg-subtle);
border-radius: var(--radius-md);
border: 1px dashed var(--border-color);
}
.exam-empty-state span {
font-size: 1.75rem;
display: block;
margin-bottom: 0.35rem;
}
.exam-empty-state--compact {
padding: 1rem;
}
.exam-paper-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.exam-paper-item {
border: 1px solid var(--border-light);
border-radius: var(--radius-md);
padding: 0.75rem 0.85rem;
background: var(--bg-subtle);
}
.exam-paper-item-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
}
.exam-paper-meta {
font-size: 0.78rem;
color: var(--text-muted);
margin-top: 0.25rem;
}
.exam-paper-add-res {
margin-top: 0.5rem;
cursor: pointer;
}
.exam-send-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.exam-send-row .search-input {
width: auto;
min-width: 180px;
}
.exam-student-overlay {
padding: 1.5rem;
}
.exam-student-modal {
max-width: min(920px, 96vw);
width: 100%;
max-height: min(88vh, 760px);
display: flex;
flex-direction: column;
}
.exam-student-modal-header {
flex-shrink: 0;
}
.exam-student-modal-desc {
margin: 0.35rem 0 0;
color: var(--text-muted);
font-size: 0.88rem;
}
.exam-student-modal-body {
padding: 0 1.5rem 1.5rem;
display: flex;
flex-direction: column;
gap: 0.85rem;
flex: 1;
min-height: 0;
overflow: hidden;
}
.exam-student-modal-search .search-input {
width: 100%;
padding: 0.7rem 2.5rem 0.7rem 0.85rem;
font-size: 0.95rem;
}
.exam-student-hits {
flex: 1;
min-height: 280px;
max-height: min(62vh, 560px);
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.65rem;
padding-right: 0.2rem;
}
.exam-student-hits-empty {
text-align: center;
padding: 2.5rem 1rem;
color: var(--text-muted);
}
.exam-student-hits-empty span {
font-size: 2rem;
display: block;
margin-bottom: 0.5rem;
}
.exam-student-hit {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 0.85rem;
padding: 0.85rem 1rem;
border: 1px solid var(--border-light);
border-radius: var(--radius-md);
background: #fff;
transition: var(--transition);
}
.exam-student-hit:hover {
border-color: var(--accent);
box-shadow: 0 2px 10px rgba(187, 33, 38, 0.08);
}
.exam-student-hit-info {
min-width: 0;
}
.exam-student-hit-info strong {
display: block;
font-size: 0.95rem;
margin-bottom: 0.2rem;
}
.exam-student-hit-meta {
font-size: 0.8rem;
color: var(--text-muted);
line-height: 1.45;
word-break: break-word;
}
.exam-student-hit-meta code {
font-size: 0.78rem;
color: var(--accent);
font-weight: 700;
}
@media (max-width: 640px) {
.exam-student-hit {
grid-template-columns: 1fr;
text-align: left;
}
.exam-student-hit .btn {
width: 100%;
justify-content: center;
}
}

View File

@@ -1,10 +1,11 @@
export type TabId = 'dashboard' | 'classes' | 'students' | 'learning' | 'network' | 'email-domains' | 'profile';
export type TabId = 'dashboard' | 'classes' | 'students' | 'learning' | 'exams' | 'network' | 'email-domains' | 'profile';
export interface NavEntry {
id: string;
kind: 'tab' | 'class';
kind: 'tab' | 'class' | 'exam';
tab: TabId;
classId?: number;
examId?: number;
label: string;
timestamp: number;
}
@@ -14,6 +15,7 @@ export const TAB_LABELS: Record<TabId, string> = {
classes: 'Lớp học',
students: 'Sinh viên',
learning: 'Giám sát & Lịch học',
exams: 'Phòng thi',
network: 'Quản lý mạng',
'email-domains': 'Đuôi email',
profile: 'Tài khoản của tôi',
@@ -23,28 +25,34 @@ const HISTORY_KEY = 'sc_nav_history';
const MAX_HISTORY = 10;
function isTabId(value: string | null): value is TabId {
return value === 'dashboard' || value === 'classes' || value === 'students' || value === 'learning' || value === 'network' || value === 'email-domains' || value === 'profile';
return value === 'dashboard' || value === 'classes' || value === 'students' || value === 'learning' || value === 'exams' || value === 'network' || value === 'email-domains' || value === 'profile';
}
export function parseRoute(): { tab: TabId; classId: number | null } {
export function parseRoute(): { tab: TabId; classId: number | null; examId: number | null } {
const params = new URLSearchParams(window.location.search);
const tabParam = params.get('tab');
let tab: TabId = isTabId(tabParam) ? tabParam : 'dashboard';
if (tabParam === 'accounts') tab = 'email-domains';
const classIdRaw = params.get('classId');
const classId = classIdRaw ? Number(classIdRaw) : null;
const examIdRaw = params.get('examId');
const examId = examIdRaw ? Number(examIdRaw) : null;
return {
tab,
classId: classId && !Number.isNaN(classId) ? classId : null,
examId: examId && !Number.isNaN(examId) ? examId : null,
};
}
export function buildUrl(tab: TabId, classId?: number | null): string {
export function buildUrl(tab: TabId, classId?: number | null, examId?: number | null): string {
const params = new URLSearchParams();
params.set('tab', tab);
if (classId) {
params.set('classId', String(classId));
}
if (examId) {
params.set('examId', String(examId));
}
return `/?${params.toString()}`;
}
@@ -63,13 +71,15 @@ function writeHistory(entries: NavEntry[]) {
sessionStorage.setItem(HISTORY_KEY, JSON.stringify(entries.slice(0, MAX_HISTORY)));
}
function entryKey(kind: NavEntry['kind'], tab: TabId, classId?: number) {
return kind === 'class' ? `class:${classId}` : `tab:${tab}`;
function entryKey(kind: NavEntry['kind'], tab: TabId, id?: number) {
if (kind === 'class') return `class:${id}`;
if (kind === 'exam') return `exam:${id}`;
return `tab:${tab}`;
}
export function pushNav(entry: Omit<NavEntry, 'id' | 'timestamp'>) {
const history = readHistory();
const id = entryKey(entry.kind, entry.tab, entry.classId);
const id = entryKey(entry.kind, entry.tab, entry.classId ?? entry.examId);
const next: NavEntry = {
...entry,
id,
@@ -79,12 +89,14 @@ export function pushNav(entry: Omit<NavEntry, 'id' | 'timestamp'>) {
writeHistory([next, ...filtered]);
}
export function navigate(tab: TabId, classId?: number | null, className?: string) {
const url = buildUrl(tab, classId);
export function navigate(tab: TabId, id?: number | null, label?: string, kind: 'class' | 'exam' = 'class') {
const url = kind === 'exam'
? buildUrl(tab, null, id)
: buildUrl(tab, id);
window.history.pushState({}, '', url);
if (classId && className) {
pushNav({ kind: 'class', tab, classId, label: className });
if (id && label) {
pushNav({ kind, tab, classId: kind === 'class' ? id : undefined, examId: kind === 'exam' ? id : undefined, label });
} else {
pushNav({ kind: 'tab', tab, label: TAB_LABELS[tab] });
}
@@ -97,7 +109,9 @@ export function goBack(fallbackTab: TabId = 'classes') {
const current = parseRoute();
const currentId = current.classId
? entryKey('class', current.tab, current.classId)
: entryKey('tab', current.tab);
: current.examId
? entryKey('exam', current.tab, current.examId)
: entryKey('tab', current.tab);
const remaining = history.filter((h) => h.id !== currentId);
writeHistory(remaining);
@@ -106,7 +120,9 @@ export function goBack(fallbackTab: TabId = 'classes') {
if (previous) {
const url = previous.kind === 'class' && previous.classId
? buildUrl(previous.tab, previous.classId)
: buildUrl(previous.tab);
: previous.kind === 'exam' && previous.examId
? buildUrl(previous.tab, null, previous.examId)
: buildUrl(previous.tab);
window.history.pushState({}, '', url);
} else {
window.history.pushState({}, '', buildUrl(fallbackTab));