add
This commit is contained in:
@@ -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 lý 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 />}
|
||||
|
||||
@@ -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`,
|
||||
};
|
||||
|
||||
10
management/src/chatEvents.ts
Normal file
10
management/src/chatEvents.ts
Normal 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 }));
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
881
management/src/components/ExamRoomWorkspace.tsx
Normal file
881
management/src/components/ExamRoomWorkspace.tsx
Normal 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ĩ 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 và thêm nhanh.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" className="btn btn-secondary app-pool-open-btn" onClick={() => setAppPoolOpen(true)}>
|
||||
Mở kho & tìm kiếm
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" className="btn btn-primary" style={{ width: '100%', justifyContent: 'center' }} disabled={saving} onClick={saveRoom}>
|
||||
{saving ? 'Đang lưu...' : 'Lưu cấu hình apps'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{configTab === 'papers' && (
|
||||
<div className="config-card config-card-flat">
|
||||
<div className="card-header-title">Gói đề & gửi cho sinh viên</div>
|
||||
<p className="config-card-desc">
|
||||
Mỗi gói đề gồm <strong>1 file PDF</strong> (đề chính) và <strong>tài nguyên kèm</strong> (ảnh, zip, doc…). Chia ngẫu nhiên sẽ gán cả gói cho từng sinh viên.
|
||||
</p>
|
||||
{!prepEditable && (
|
||||
<p className="config-card-desc" style={{ color: 'var(--danger)' }}>
|
||||
Không thể thêm gói đề — phòng đã khóa hoặc đã tới giờ thi.
|
||||
</p>
|
||||
)}
|
||||
{prepEditable && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.65rem', marginBottom: '0.75rem' }}>
|
||||
<input
|
||||
className="search-input"
|
||||
placeholder="Tên gói đề (VD: Gói A)"
|
||||
value={paperTitle}
|
||||
onChange={(e) => setPaperTitle(e.target.value)}
|
||||
/>
|
||||
<label className="login-field">
|
||||
<span>PDF đề chính (bắt buộc)</span>
|
||||
<input
|
||||
type="file"
|
||||
accept=".pdf"
|
||||
onChange={(e) => setPackagePdf(e.target.files?.[0] || null)}
|
||||
/>
|
||||
</label>
|
||||
<label className="login-field">
|
||||
<span>Tài nguyên kèm (tùy chọn, nhiều file)</span>
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
onChange={(e) => setPackageResources(Array.from(e.target.files || []))}
|
||||
/>
|
||||
</label>
|
||||
{packageResources.length > 0 && (
|
||||
<p className="config-card-desc" style={{ margin: 0 }}>
|
||||
{packageResources.length} tài nguyên: {packageResources.map((f) => f.name).join(', ')}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
style={{ width: '100%' }}
|
||||
disabled={creatingPackage || !packagePdf}
|
||||
onClick={createPackage}
|
||||
>
|
||||
{creatingPackage ? 'Đang tạo...' : 'Tạo gói đề'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{papers.length === 0 ? (
|
||||
<p className="config-card-desc">Chưa có gói đề. Tạo gói gồm PDF + tài nguyên.</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.65rem' }}>
|
||||
{papers.map((p) => (
|
||||
<div key={p.id} style={{ border: '1px solid var(--border-light)', borderRadius: '8px', padding: '0.75rem', background: 'var(--bg-subtle)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.5rem', alignItems: 'flex-start' }}>
|
||||
<strong>{p.title}</strong>
|
||||
<span className="badge badge-muted">Gói đề</span>
|
||||
</div>
|
||||
<ul className="config-card-desc" style={{ margin: '0.5rem 0 0', paddingLeft: '1.1rem' }}>
|
||||
<li>📄 main.pdf (đề chính)</li>
|
||||
{(p.resources || []).length === 0 ? (
|
||||
<li style={{ color: 'var(--text-muted)' }}>Chưa có tài nguyên kèm</li>
|
||||
) : (
|
||||
(p.resources || []).map((r) => (
|
||||
<li key={r.id}>📎 {r.fileName}</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
{prepEditable && (
|
||||
<div style={{ display: 'flex', gap: '0.35rem', marginTop: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<label className="btn btn-ghost btn-sm" style={{ cursor: 'pointer' }}>
|
||||
+ Tài nguyên
|
||||
<input type="file" multiple hidden onChange={(e) => uploadResource(p.id, e)} />
|
||||
</label>
|
||||
<button type="button" className="btn btn-ghost btn-sm learning-btn-danger" onClick={() => apiExam.deletePaper(examId, p.id).then(load)}>
|
||||
Xóa gói
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{papers.length > 0 && students.length > 0 && prepEditable && (
|
||||
<button type="button" className="btn btn-secondary" style={{ width: '100%', marginTop: '0.65rem' }} onClick={assignRandomPackages}>
|
||||
Chia gói đề ngẫu nhiên
|
||||
</button>
|
||||
)}
|
||||
<div className="divider" style={{ opacity: 0.3 }} />
|
||||
<p className="config-card-desc">Gửi gói đề sau khi đẩy phòng thi và chia gói.</p>
|
||||
{(displayStatus === 'ready' || displayStatus === 'active') ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
<button type="button" className="btn btn-primary" onClick={() => sendPapers(false)}>Gửi gói đề ngay</button>
|
||||
<input type="datetime-local" className="search-input" value={sendSchedule} onChange={(e) => setSendSchedule(e.target.value)} />
|
||||
<button type="button" className="btn btn-secondary" disabled={!sendSchedule} onClick={() => sendPapers(true)}>Hẹn giờ gửi</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="config-card-desc">Đẩy phòng thi trước khi gửi gói đề.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="right-panel workspace-content-panel">
|
||||
<div className="workspace-panel-toolbar">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '1rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.65rem', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-secondary workspace-config-toggle ${configOpen ? 'active' : ''}`}
|
||||
onClick={() => setConfigOpen((v) => !v)}
|
||||
>
|
||||
{configOpen ? 'Ẩn cấu hình' : 'Cấu hình phòng thi'}
|
||||
</button>
|
||||
<div className="tab-btn-group">
|
||||
<button
|
||||
type="button"
|
||||
className={`tab-sub-btn ${activeSubTab === 'roster' ? 'active' : ''}`}
|
||||
onClick={() => setActiveSubTab('roster')}
|
||||
>
|
||||
Sơ đồ sinh viên
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`tab-sub-btn ${activeSubTab === 'detail' ? 'active' : ''}`}
|
||||
onClick={() => setActiveSubTab('detail')}
|
||||
>
|
||||
Chi tiết thi
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`tab-sub-btn ${activeSubTab === 'submissions' ? 'active' : ''}`}
|
||||
onClick={() => setActiveSubTab('submissions')}
|
||||
>
|
||||
Bài nộp ({submissions.length})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
{activeSubTab !== 'submissions' && (
|
||||
<div className="search-input-wrapper">
|
||||
<input
|
||||
type="text"
|
||||
className="search-input"
|
||||
placeholder="Tìm sinh viên..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<span className="search-icon">🔍</span>
|
||||
</div>
|
||||
)}
|
||||
{prepEditable && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => { setStudentPickerOpen(true); setSearchQ(''); setSearchHits([]); }}
|
||||
>
|
||||
+ Thêm sinh viên
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="divider" style={{ opacity: 0.3, margin: '0.25rem 0' }} />
|
||||
</div>
|
||||
|
||||
<div className={`workspace-panel-body ${activeSubTab !== 'roster' ? 'workspace-panel-fill' : ''}`}>
|
||||
{activeSubTab === 'roster' ? (
|
||||
students.length === 0 ? (
|
||||
<div className="empty-state" style={{ minHeight: '300px' }}>
|
||||
<div className="empty-state-icon">👤</div>
|
||||
<h2>Chưa có sinh viên</h2>
|
||||
<p>Thêm sinh viên vào phòng thi để bắt đầu giám sát.</p>
|
||||
{prepEditable && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={() => { setStudentPickerOpen(true); setSearchQ(''); setSearchHits([]); }}
|
||||
>
|
||||
+ Thêm sinh viên
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : filteredStudents.length === 0 ? (
|
||||
<div className="empty-state" style={{ minHeight: '300px' }}>
|
||||
<div className="empty-state-icon">🔍</div>
|
||||
<h2>Không tìm thấy sinh viên</h2>
|
||||
<p>Hãy thử tìm kiếm với từ khóa khác.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="student-status-grid">
|
||||
{filteredStudents.map((s) => {
|
||||
const isOnline = onlineIds.includes(s.studentRkId);
|
||||
return (
|
||||
<div
|
||||
key={s.id}
|
||||
className={`student-status-card student-status-card-clickable ${isOnline ? 'online' : 'offline'}`}
|
||||
onClick={() => setSelectedStudent(toStudentItem(s))}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') setSelectedStudent(toStudentItem(s)); }}
|
||||
>
|
||||
<StudentAvatar fullName={s.fullName} avatar={s.avatar} isOnline={isOnline} size={52} />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, overflow: 'hidden' }}>
|
||||
<span style={{ fontSize: '0.875rem', fontWeight: 600, color: 'var(--text-primary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{s.fullName}
|
||||
</span>
|
||||
<span style={{ fontSize: '0.78rem', color: 'var(--accent)', fontFamily: 'monospace', fontWeight: 600 }}>
|
||||
{s.studentCode}
|
||||
</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.35rem', marginTop: '0.4rem' }}>
|
||||
<span className={isOnline ? 'pulse-dot-online' : 'status-badge-offline'} />
|
||||
<span style={{ fontSize: '0.75rem', fontWeight: 700, color: isOnline ? 'var(--success)' : 'var(--text-muted)' }}>
|
||||
{isOnline ? 'ONLINE' : 'OFFLINE'}
|
||||
</span>
|
||||
</div>
|
||||
{(s.paperTitle || s.submitted) && (
|
||||
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)', marginTop: '0.35rem' }}>
|
||||
{s.paperTitle && <span>{s.paperTitle}</span>}
|
||||
{s.submitted && <span style={{ color: 'var(--success)', marginLeft: s.paperTitle ? '0.35rem' : 0 }}>· Đã nộp</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
) : activeSubTab === 'detail' ? (
|
||||
<div className="session-logs-panel">
|
||||
<div className="attendance-table-scroll table-wrapper" style={{ border: 'none' }}>
|
||||
{students.length === 0 ? (
|
||||
<div className="empty-state" style={{ minHeight: '300px' }}>
|
||||
<div className="empty-state-icon">📋</div>
|
||||
<h2>Chưa có sinh viên</h2>
|
||||
<p>Thêm sinh viên để xem chi tiết đề gán và trạng thái nộp bài.</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Sinh viên</th>
|
||||
<th>Mã SV</th>
|
||||
<th>Online</th>
|
||||
<th>Gói đề</th>
|
||||
<th>Gửi gói</th>
|
||||
<th>Nộp bài</th>
|
||||
<th style={{ textAlign: 'right' }}>Thao tác</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredStudents.map((s) => {
|
||||
const isOnline = onlineIds.includes(s.studentRkId);
|
||||
return (
|
||||
<tr key={s.id}>
|
||||
<td>
|
||||
<div style={{ fontWeight: 600, color: 'var(--text-primary)' }}>{s.fullName}</div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>{s.email}</div>
|
||||
</td>
|
||||
<td><code>{s.studentCode}</code></td>
|
||||
<td style={{ color: isOnline ? 'var(--success)' : 'var(--text-muted)', fontWeight: 700 }}>
|
||||
{isOnline ? 'Online' : 'Offline'}
|
||||
</td>
|
||||
<td>{s.paperTitle || '—'}</td>
|
||||
<td>{s.paperSentAt ? fmtTime(s.paperSentAt) : s.paperScheduledAt ? `Hẹn ${fmtTime(s.paperScheduledAt)}` : '—'}</td>
|
||||
<td>{s.submitted ? <span className="badge badge-success">Đã nộp</span> : '—'}</td>
|
||||
<td style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={() => setSelectedStudent(toStudentItem(s))}>
|
||||
Giám sát
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary btn-sm" style={{ marginLeft: '0.35rem' }} onClick={() => openStudentChat(s)}>
|
||||
Nhắn tin
|
||||
</button>
|
||||
{prepEditable && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm learning-btn-danger"
|
||||
style={{ marginLeft: '0.35rem' }}
|
||||
onClick={() => apiExam.removeStudent(examId, s.studentRkId).then(load)}
|
||||
>
|
||||
Xóa
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="session-logs-panel">
|
||||
<div className="attendance-table-scroll table-wrapper" style={{ border: 'none' }}>
|
||||
{submissions.length === 0 ? (
|
||||
<div className="empty-state" style={{ minHeight: '300px' }}>
|
||||
<div className="empty-state-icon">📦</div>
|
||||
<h2>Chưa có bài nộp</h2>
|
||||
<p>Sinh viên nộp bài qua app Simple Care sẽ hiện tại đây.</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Sinh viên</th>
|
||||
<th>File</th>
|
||||
<th>Thời gian</th>
|
||||
<th style={{ textAlign: 'right' }}>Tải</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{submissions.map((s) => (
|
||||
<tr key={s.id}>
|
||||
<td>
|
||||
<div style={{ fontWeight: 600 }}>{s.fullName}</div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>{s.studentCode}</div>
|
||||
</td>
|
||||
<td style={{ fontSize: '0.85rem' }}>{s.fileName}</td>
|
||||
<td style={{ fontSize: '0.8rem', color: 'var(--text-secondary)' }}>{fmtTime(s.createdAt)}</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
<button type="button" className="btn btn-secondary btn-sm" onClick={() => downloadSub(s.id, s.fileName)}>Tải</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedStudent && (
|
||||
<StudentDetailModal
|
||||
student={selectedStudent}
|
||||
isOnline={onlineIds.includes(selectedStudent.rkId)}
|
||||
onClose={() => setSelectedStudent(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{studentPickerOpen && (
|
||||
<div className="modal-overlay">
|
||||
<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 mã lớp, họ tên, mã 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' }}>Gõ ít nhất vài ký 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>
|
||||
);
|
||||
};
|
||||
149
management/src/components/ExamsTab.tsx
Normal file
149
management/src/components/ExamsTab.tsx
Normal 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 đề và 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 có 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 };
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user