Files
rikkei_simple_care/management/src/components/ExamRoomWorkspace.tsx
2026-06-30 14:46:19 +07:00

995 lines
45 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import React, { useCallback, useEffect, useState } from 'react';
import {
apiExam,
staffFetch,
type ExamPaper,
type ExamRoomStudent,
type ExamSubmission,
type StudentItem,
} from '../api';
import { useGitHubConnection } from '../hooks/useGitHubConnection';
import { navigate, pushNav } from '../navigation';
import { BASE_APP_SUGGESTIONS, DEFAULT_EXAM_ALLOWED_APPS } from '../constants';
import { openStaffChat } from '../chatEvents';
import { AppPoolModal } from './AppPoolModal';
import { NavHistoryBar } from './NavHistoryBar';
import { StudentAvatar } from './StudentAvatar';
import { StudentDetailModal } from './StudentDetailModal';
import { fmtTime, localInputToISO, toLocalInput } from './ExamsTab';
const EXAM_APP_SUGGESTIONS = [...new Set([...BASE_APP_SUGGESTIONS, 'msedge', 'edge', 'acrobat', 'foxit'])];
interface Props {
examId: number;
onBack: () => void;
}
function statusBadge(displayStatus: string) {
switch (displayStatus) {
case 'draft': return { text: 'Tạm thời', cls: 'badge-muted' };
case 'ready': return { text: 'Sẵn sàng', cls: 'badge-info' };
case 'active': return { text: 'Đang thi', cls: 'badge-success' };
case 'cancelled': return { text: 'Đã hủy', cls: 'badge-warning' };
case 'ended': return { text: 'Kết thúc', cls: 'badge-muted' };
default: return { text: displayStatus, cls: 'badge-muted' };
}
}
function statusColor(displayStatus: string) {
switch (displayStatus) {
case 'active': return 'var(--success)';
case 'ready': return 'var(--accent)';
case 'cancelled': return 'var(--danger)';
default: return 'var(--text-muted)';
}
}
export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
const [roomName, setRoomName] = useState('');
const [start, setStart] = useState('');
const [end, setEnd] = useState('');
const [allowedApps, setAllowedApps] = useState(DEFAULT_EXAM_ALLOWED_APPS);
const [quizUrl, setQuizUrl] = useState('');
const [gitPublishUrl, setGitPublishUrl] = useState('');
const [bundlingSubs, setBundlingSubs] = useState(false);
const [publishingGit, setPublishingGit] = useState(false);
const gh = useGitHubConnection();
const [editable, setEditable] = useState(false);
const [prepEditable, setPrepEditable] = useState(false);
const [displayStatus, setDisplayStatus] = useState('draft');
const [canPublish, setCanPublish] = useState(false);
const [canUnpublish, setCanUnpublish] = useState(false);
const [canCancel, setCanCancel] = useState(false);
const [papers, setPapers] = useState<ExamPaper[]>([]);
const [students, setStudents] = useState<ExamRoomStudent[]>([]);
const [submissions, setSubmissions] = useState<ExamSubmission[]>([]);
const [searchQ, setSearchQ] = useState('');
const [searchHits, setSearchHits] = useState<{ studentRkId: number; fullName: string; studentCode: string; email: string; classCodes: string }[]>([]);
const [paperTitle, setPaperTitle] = useState('');
const [packagePdf, setPackagePdf] = useState<File | null>(null);
const [packageResources, setPackageResources] = useState<File[]>([]);
const [creatingPackage, setCreatingPackage] = useState(false);
const [sendSchedule, setSendSchedule] = useState('');
const [msg, setMsg] = useState('');
const [err, setErr] = useState('');
const [appPoolOpen, setAppPoolOpen] = useState(false);
const [studentPickerOpen, setStudentPickerOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [loading, setLoading] = useState(true);
const [onlineIds, setOnlineIds] = useState<number[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [selectedStudent, setSelectedStudent] = useState<StudentItem | null>(null);
const [activeSubTab, setActiveSubTab] = useState<'roster' | 'detail' | 'submissions'>('roster');
const [configOpen, setConfigOpen] = useState(false);
const [configTab, setConfigTab] = useState<'info' | 'apps' | 'papers'>('info');
const load = useCallback(async () => {
setLoading(true);
try {
const data = await apiExam.get(examId);
setRoomName(data.room.name);
setStart(toLocalInput(data.room.startTime));
setEnd(toLocalInput(data.room.endTime));
setAllowedApps(data.room.allowedApps || DEFAULT_EXAM_ALLOWED_APPS);
setQuizUrl(data.room.quizUrl || '');
setGitPublishUrl(data.room.gitPublishUrl || '');
setEditable(data.editable);
setPrepEditable(data.prepEditable ?? data.editable);
setDisplayStatus(data.displayStatus);
setCanPublish(data.canPublish);
setCanUnpublish(data.canUnpublish);
setCanCancel(data.canCancel);
setPapers(data.papers);
setStudents(data.students);
pushNav({
kind: 'exam',
tab: 'exams',
examId,
label: data.room.name || `Phòng thi ${examId}`,
});
const subs = await apiExam.listSubmissions(examId);
setSubmissions(subs.data);
} finally {
setLoading(false);
}
}, [examId]);
useEffect(() => { load().catch(console.error); }, [load]);
const fetchOnline = useCallback(async () => {
try {
const res = await apiExam.fetchOnlineStudents(examId);
setOnlineIds(res.onlineStudentIds || []);
} catch (e) {
console.error(e);
}
}, [examId]);
useEffect(() => {
fetchOnline();
const t = setInterval(fetchOnline, 5000);
return () => clearInterval(t);
}, [fetchOnline]);
useEffect(() => {
if (!studentPickerOpen || !searchQ.trim()) {
if (!studentPickerOpen) setSearchHits([]);
return;
}
const t = setTimeout(() => {
apiExam.searchStudents(searchQ.trim()).then((r) => setSearchHits(r.data)).catch(console.error);
}, 300);
return () => clearTimeout(t);
}, [searchQ, studentPickerOpen]);
const addAppKeyword = (kw: string) => {
const trimmed = kw.trim().toLowerCase();
if (!trimmed) return;
setAllowedApps((prev) => {
const parts = prev.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
if (parts.includes(trimmed)) return prev;
return prev ? `${prev}, ${trimmed}` : trimmed;
});
};
const saveRoom = async () => {
setErr(''); setMsg('');
setSaving(true);
try {
await apiExam.update(examId, {
name: roomName.trim(),
startTime: localInputToISO(start),
endTime: localInputToISO(end),
allowedApps,
quizUrl: quizUrl.trim(),
});
setMsg('Đã lưu cấu hình phòng thi');
await load();
} catch (e: any) {
setErr(e?.message || 'Lỗi');
} finally {
setSaving(false);
}
};
const addStudent = async (studentRkId: number) => {
await apiExam.addStudents(examId, [studentRkId]);
await load();
await fetchOnline();
};
const createPackage = async () => {
if (!packagePdf) {
setErr('Chọn file PDF cho gói đề');
return;
}
setErr('');
setCreatingPackage(true);
try {
const title = paperTitle.trim() || packagePdf.name.replace(/\.pdf$/i, '') || 'Gói đề';
const resCount = packageResources.length;
await apiExam.uploadPackage(examId, title, packagePdf, packageResources);
setPaperTitle('');
setPackagePdf(null);
setPackageResources([]);
setMsg(`Đã tạo gói đề "${title}" (1 PDF${resCount ? ` + ${resCount} tài nguyên` : ''})`);
await load();
} catch (e: any) {
setErr(e?.message || 'Tạo gói đề thất bại');
} finally {
setCreatingPackage(false);
}
};
const uploadResource = async (paperId: number, e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
if (!files.length) return;
await apiExam.uploadPackageResources(examId, paperId, files);
e.target.value = '';
await load();
};
const assignRandomPackages = async () => {
const res = await apiExam.assignRandom(examId);
setMsg(`Đã chia ${res.assigned} sinh viên vào ${res.packages} gói đề`);
await load();
};
const sendPapers = async (scheduled: boolean) => {
const payload = scheduled && sendSchedule
? { scheduledAt: localInputToISO(sendSchedule) }
: undefined;
const res = await apiExam.sendPapers(examId, payload);
setMsg(`Đã gửi ${res.sent} gói đề${res.scheduled ? `, hẹn ${res.scheduled}` : ''}`);
await load();
};
const downloadSub = async (subId: number, fileName: string) => {
const res = await staffFetch(`/exam-rooms/${examId}/submissions/${subId}/download`);
if (!res.ok) return;
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
a.click();
URL.revokeObjectURL(url);
};
const downloadAllSubs = async () => {
setErr(''); setMsg('');
setBundlingSubs(true);
try {
await apiExam.downloadAllSubmissions(examId, roomName || `phong_thi_${examId}`);
setMsg('Đã tải ZIP gộp — giải nén sẽ thấy từng folder bài làm theo sinh viên.');
} catch (e: any) {
setErr(e?.message || 'Tải thất bại');
} finally {
setBundlingSubs(false);
}
};
const publishToGit = async () => {
if (!gh.connected) {
setErr('Kết nối GitHub trong Tài khoản của tôi (sidebar dưới cùng) trước khi đẩy.');
return;
}
setErr(''); setMsg('');
setPublishingGit(true);
try {
const res = await apiExam.publishSubmissionsGit(examId);
const openUrl = res.openUrl || res.gitPublishUrl || res.url;
setGitPublishUrl(openUrl);
setMsg(res.message || 'Đã đẩy bài nộp lên GitHub');
if (openUrl) window.open(openUrl, '_blank', 'noopener,noreferrer');
} catch (e: any) {
setErr(e?.message || 'Đẩy Git thất bại');
} finally {
setPublishingGit(false);
}
};
const handlePublish = async () => {
setErr(''); setMsg('');
try {
await apiExam.publish(examId);
setMsg('Đã đẩy phòng thi — hệ thống sẽ mở khi tới giờ');
await load();
} catch (e: any) {
setErr(e?.message || 'Lỗi');
}
};
const handleUnpublish = async () => {
if (!confirm('Thu hồi lệnh đẩy? Phòng thi quay về trạng thái tạm thời.')) return;
setErr(''); setMsg('');
try {
await apiExam.unpublish(examId);
setMsg('Đã thu hồi — phòng thi ở trạng thái tạm thời');
await load();
} catch (e: any) {
setErr(e?.message || 'Lỗi');
}
};
const handleCancel = async () => {
if (!confirm('Hủy phòng thi ngay? Sinh viên sẽ thoát chế độ thi.')) return;
setErr(''); setMsg('');
try {
await apiExam.cancel(examId);
setMsg('Đã hủy phòng thi');
await load();
} catch (e: any) {
setErr(e?.message || 'Lỗi');
}
};
const filteredStudents = students.filter((s) => {
const term = searchQuery.toLowerCase().trim();
if (!term) return true;
return (
s.fullName.toLowerCase().includes(term) ||
s.studentCode.toLowerCase().includes(term) ||
s.email.toLowerCase().includes(term) ||
(s.paperTitle || '').toLowerCase().includes(term)
);
});
const toStudentItem = (s: ExamRoomStudent): StudentItem => ({
id: s.id,
rkId: s.studentRkId,
studentCode: s.studentCode,
fullName: s.fullName,
email: s.email,
avatar: s.avatar,
});
const openStudentChat = (s: ExamRoomStudent) => {
openStaffChat({
studentRkId: s.studentRkId,
fullName: s.fullName,
studentCode: s.studentCode,
email: s.email,
});
};
if (loading && !roomName) {
return (
<div className="loading-screen">
<div className="sync-spinner" style={{ width: '36px', height: '36px', borderWidth: '3px' }} />
<div style={{ fontWeight: 600, fontSize: '0.95rem', color: 'var(--text-secondary)' }}>
Đang tải không gian làm việc phòng thi...
</div>
</div>
);
}
const badge = statusBadge(displayStatus);
const enrolledIds = new Set(students.map((s) => s.studentRkId));
const onlineCount = students.filter((s) => onlineIds.includes(s.studentRkId)).length;
return (
<div className="workspace-layout workspace-embedded">
<NavHistoryBar classLabel={roomName || 'Phòng thi'} onBack={onBack} />
{(msg || err) && (
<div className={`alert-banner ${err ? 'alert-error' : 'alert-success'}`}>{err || msg}</div>
)}
<div className="workspace-header">
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<div className="brand-logo" style={{ width: 44, height: 44, fontSize: '1.1rem' }}>📝</div>
<div>
<h1 className="workspace-class-title">{roomName || 'Phòng thi'}</h1>
<div className="class-badge-container">
<span className={`badge ${badge.cls}`}>{badge.text}</span>
{start && end && (
<span className="badge badge-muted">
{fmtTime(localInputToISO(start))} {fmtTime(localInputToISO(end))}
</span>
)}
<span className="badge badge-info">ID: {examId}</span>
<span className="badge badge-muted">{papers.length} gói đ · {submissions.length} bài nộp</span>
</div>
</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '1.25rem', flexWrap: 'wrap' }}>
<div className="status-panel">
<div style={{ display: 'flex', flexDirection: 'column' }}>
<span style={{ fontSize: '0.72rem', color: 'var(--text-muted)', fontWeight: 600, textTransform: 'uppercase' }}>
Trạng thái thi
</span>
<span style={{ fontSize: '0.875rem', fontWeight: 700, color: statusColor(displayStatus) }}>
{badge.text}
</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
{canPublish && (
<button type="button" className="btn btn-primary btn-sm" onClick={handlePublish}>Đy phòng thi</button>
)}
{canUnpublish && (
<button type="button" className="btn btn-secondary btn-sm" onClick={handleUnpublish}>Thu hồi</button>
)}
{canCancel && (
<button type="button" className="btn btn-secondary btn-sm learning-btn-danger" onClick={handleCancel}>Hủy</button>
)}
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end' }}>
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)', fontWeight: 600, textTransform: 'uppercase' }}>
Online / số
</div>
<div className="workspace-stat-value">
<span style={{ color: 'var(--success)' }}>{onlineCount}</span>
<span style={{ color: 'var(--text-muted)', fontSize: '0.9rem', fontWeight: 500 }}> / {students.length} SV</span>
</div>
</div>
</div>
</div>
<div className="workspace-main">
{configOpen && (
<div className="workspace-drawer-backdrop" onClick={() => setConfigOpen(false)} aria-hidden />
)}
<aside className={`workspace-config-drawer ${configOpen ? 'open' : ''}`}>
<div className="workspace-drawer-header">
<strong>Cấu hình phòng thi</strong>
<button type="button" className="btn btn-secondary workspace-drawer-close" onClick={() => setConfigOpen(false)} aria-label="Đóng">×</button>
</div>
<div className="workspace-drawer-tabs">
<button type="button" className={`workspace-drawer-tab ${configTab === 'info' ? 'active' : ''}`} onClick={() => setConfigTab('info')}>
Thông tin
</button>
<button type="button" className={`workspace-drawer-tab ${configTab === 'apps' ? 'active' : ''}`} onClick={() => setConfigTab('apps')}>
ng dụng
</button>
<button type="button" className={`workspace-drawer-tab ${configTab === 'papers' ? 'active' : ''}`} onClick={() => setConfigTab('papers')}>
Gói đ & gửi
</button>
</div>
<div className="workspace-drawer-body">
{configTab === 'info' && (
<div className="config-card config-card-flat">
<div className="card-header-title">Thông tin phòng thi</div>
<p className="config-card-desc">
{editable ? 'Chỉnh sửa được khi phòng ở trạng thái tạm thời.' : 'Phòng đã khóa chỉnh sửa.'}
</p>
<label className="login-field">
<span>Tên phòng thi</span>
<input value={roomName} onChange={(e) => setRoomName(e.target.value)} disabled={!editable} />
</label>
<label className="login-field">
<span>Link trắc nghiệm</span>
<input value={quizUrl} onChange={(e) => setQuizUrl(e.target.value)} placeholder="https://..." disabled={!editable} />
</label>
<div className="exam-git-settings">
<p className="exam-git-status-line">
GitHub:{' '}
{gh.connected ? (
<strong>@{gh.githubLogin}</strong>
) : (
<>
<span className="exam-git-not-connected">chưa kết nối</span>
{' — '}
<button type="button" className="link-btn" onClick={() => navigate('profile')}>
Kết nối trong Tài khoản
</button>
</>
)}
</p>
<p className="exam-git-hint">
Bấm <strong>Đy lên Git</strong> sẽ tự tạo repo theo tên phòng thi mỗi sinh viên một folder, giống ZIP tải về.
</p>
{gitPublishUrl && (
<p className="exam-git-last">
Repo gần nhất:{' '}
<a href={gitPublishUrl} target="_blank" rel="noreferrer">{gitPublishUrl}</a>
</p>
)}
</div>
<label className="login-field">
<span>Bắt đu</span>
<input type="datetime-local" value={start} onChange={(e) => setStart(e.target.value)} disabled={!editable} />
</label>
<label className="login-field">
<span>Kết thúc</span>
<input type="datetime-local" value={end} onChange={(e) => setEnd(e.target.value)} disabled={!editable} />
</label>
{editable && (
<button type="button" className="btn btn-primary" style={{ width: '100%', justifyContent: 'center' }} disabled={saving} onClick={saveRoom}>
{saving ? 'Đang lưu...' : 'Lưu thông tin'}
</button>
)}
</div>
)}
{configTab === 'apps' && (
<div className="config-card config-card-flat">
<div className="card-header-title">ng dụng đưc phép (khi thi)</div>
<p className="config-card-desc">
Từ khóa tiến trình hoặc tiêu đ đưc phép (ngăn cách bằng dấu phẩy).
</p>
<textarea
className="app-textarea"
placeholder="chrome, msedge, acrobat, wails, simple_care, client"
value={allowedApps}
onChange={(e) => setAllowedApps(e.target.value)}
disabled={!editable}
/>
{editable && (
<>
<div className="config-suggestions">
<span className="config-suggestions-label">Gợi ý nhanh:</span>
<div className="config-suggestions-list">
{EXAM_APP_SUGGESTIONS.map((kw) => (
<span key={kw} className="app-suggestion-badge" onClick={() => addAppKeyword(kw)}>
+ {kw}
</span>
))}
</div>
</div>
<div className="app-pool-open-row">
<div>
<span className="config-suggestions-label">Kho ng dụng (toàn hệ thống)</span>
<p className="discovered-apps-hint" style={{ margin: '0.25rem 0 0' }}>
App bị chặn từ mọi lớp mở kho đ tìm 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">
{submissions.length > 0 && (
<div className="exam-submissions-toolbar">
<button
type="button"
className="btn btn-primary btn-sm"
disabled={bundlingSubs}
onClick={downloadAllSubs}
>
{bundlingSubs ? 'Đang gói ZIP...' : '📦 Tải tất cả (ZIP gộp)'}
</button>
<button
type="button"
className="btn btn-secondary btn-sm"
disabled={publishingGit || !gh.connected}
onClick={publishToGit}
title={gh.connected ? 'Tạo repo + đẩy từng folder sinh viên lên GitHub' : 'Kết nối GitHub trong Tài khoản trước'}
>
{publishingGit ? 'Đang đẩy Git...' : '🔗 Đẩy lên Git'}
</button>
{!gh.connected && (
<span className="exam-submissions-toolbar-hint">
<button type="button" className="link-btn" onClick={() => navigate('profile')}>
Kết nối GitHub
</button>
{' '}trong Tài khoản của tôi trước khi đy.
</span>
)}
<span className="exam-submissions-toolbar-hint">
ZIP gộp: mỗi sinh viên một folder giải nén chấm ngay.
</span>
</div>
)}
<div className="attendance-table-scroll table-wrapper" style={{ border: 'none' }}>
{submissions.length === 0 ? (
<div className="empty-state" style={{ minHeight: '300px' }}>
<div className="empty-state-icon">📦</div>
<h2>Chưa bài nộp</h2>
<p>Sinh viên nộp bài qua app Simple Care sẽ hiện tại đây.</p>
</div>
) : (
<table className="data-table">
<thead>
<tr>
<th>Sinh viên</th>
<th>File</th>
<th>Thời gian</th>
<th style={{ textAlign: 'right' }}>Tải</th>
</tr>
</thead>
<tbody>
{submissions.map((s) => (
<tr key={s.id}>
<td>
<div style={{ fontWeight: 600 }}>{s.fullName}</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>{s.studentCode}</div>
</td>
<td style={{ fontSize: '0.85rem' }}>{s.fileName}</td>
<td style={{ fontSize: '0.8rem', color: 'var(--text-secondary)' }}>{fmtTime(s.createdAt)}</td>
<td style={{ textAlign: 'right' }}>
<button type="button" className="btn btn-secondary btn-sm" onClick={() => downloadSub(s.id, s.fileName)}>Tải</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
)}
</div>
</div>
</div>
{selectedStudent && (
<StudentDetailModal
student={selectedStudent}
isOnline={onlineIds.includes(selectedStudent.rkId)}
onClose={() => setSelectedStudent(null)}
/>
)}
{studentPickerOpen && (
<div className="modal-overlay exam-student-overlay" onClick={() => setStudentPickerOpen(false)}>
<div className="modal-container exam-student-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header exam-student-modal-header">
<div>
<h2 className="modal-title">Thêm sinh viên</h2>
<p className="exam-student-modal-desc">Tìm theo lớp, họ tên, sinh viên</p>
</div>
<button type="button" className="modal-close-btn" onClick={() => setStudentPickerOpen(false)} aria-label="Đóng">
×
</button>
</div>
<div className="exam-student-modal-body">
<div className="exam-student-modal-search search-input-wrap">
<input
type="search"
className="search-input"
placeholder="Tìm sinh viên..."
value={searchQ}
onChange={(e) => setSearchQ(e.target.value)}
autoFocus
/>
</div>
<div className="exam-student-hits">
{searchQ.trim() === '' ? (
<div className="exam-student-hits-empty">
<span>🔍</span>
<p> ít nhất vài tự đ tìm sinh viên.</p>
</div>
) : searchHits.length === 0 ? (
<div className="exam-student-hits-empty">
<span>📭</span>
<p>Không tìm thấy sinh viên phù hợp.</p>
</div>
) : (
searchHits.map((h) => {
const added = enrolledIds.has(h.studentRkId);
const initials = h.fullName
.split(/\s+/)
.filter(Boolean)
.slice(-2)
.map((w) => w[0]?.toUpperCase() || '')
.join('');
return (
<div key={h.studentRkId} className="exam-student-hit">
<div className="exam-student-hit-avatar" aria-hidden="true">{initials || '?'}</div>
<div className="exam-student-hit-info">
<strong>{h.fullName}</strong>
<div className="exam-student-hit-meta">
SV: <code>{h.studentCode}</code>
{h.classCodes ? <> · Lớp: {h.classCodes}</> : null}
{h.email ? <> · {h.email}</> : null}
</div>
</div>
<button
type="button"
className={`btn btn-sm ${added ? 'btn-secondary' : 'btn-primary'}`}
disabled={added}
onClick={() => addStudent(h.studentRkId)}
>
{added ? '✓ Đã thêm' : '+ Thêm'}
</button>
</div>
);
})
)}
</div>
</div>
</div>
</div>
)}
<AppPoolModal
open={appPoolOpen}
onClose={() => setAppPoolOpen(false)}
onSelect={addAppKeyword}
allowedApps={allowedApps}
/>
</div>
);
};