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

@@ -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>
);
};