This commit is contained in:
@@ -61,6 +61,9 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
const [editable, setEditable] = useState(false);
|
||||
const [prepEditable, setPrepEditable] = useState(false);
|
||||
const [displayStatus, setDisplayStatus] = useState('draft');
|
||||
const canEditApps = displayStatus === 'draft' || displayStatus === 'ready' || displayStatus === 'active';
|
||||
const [searchMode, setSearchMode] = useState<'single' | 'bulk'>('single');
|
||||
const [bulkInput, setBulkInput] = useState('');
|
||||
const [canPublish, setCanPublish] = useState(false);
|
||||
const [canUnpublish, setCanUnpublish] = useState(false);
|
||||
const [canCancel, setCanCancel] = useState(false);
|
||||
@@ -189,14 +192,18 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
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');
|
||||
const payload: any = {};
|
||||
if (editable) {
|
||||
payload.name = roomName.trim();
|
||||
payload.startTime = localInputToISO(start);
|
||||
payload.endTime = localInputToISO(end);
|
||||
payload.allowedApps = allowedApps;
|
||||
payload.quizUrl = quizUrl.trim();
|
||||
} else {
|
||||
payload.allowedApps = allowedApps;
|
||||
}
|
||||
await apiExam.update(examId, payload);
|
||||
setMsg(editable ? 'Đã lưu cấu hình phòng thi' : 'Đã cập nhật danh sách ứng dụng được phép');
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || 'Lỗi');
|
||||
@@ -211,6 +218,29 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
await fetchOnline();
|
||||
};
|
||||
|
||||
const handleBulkSearch = async () => {
|
||||
const q = bulkInput.trim();
|
||||
if (!q) return;
|
||||
try {
|
||||
const res = await apiExam.searchStudents(q);
|
||||
setSearchHits(res.data);
|
||||
// Auto-select all unadded hits
|
||||
const enrolledIds = new Set(students.map((s) => s.studentRkId));
|
||||
const unaddedHits = res.data.filter((h) => !enrolledIds.has(h.studentRkId));
|
||||
setSelectedPickerRkIds(unaddedHits.map((h) => h.studentRkId));
|
||||
} catch (e: any) {
|
||||
alert(e?.message || 'Tìm kiếm thất bại');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSwitchTab = (mode: 'single' | 'bulk') => {
|
||||
setSearchMode(mode);
|
||||
setSearchHits([]);
|
||||
setSelectedPickerRkIds([]);
|
||||
setSearchQ('');
|
||||
setBulkInput('');
|
||||
};
|
||||
|
||||
const toggleHitSelection = (studentRkId: number) => {
|
||||
setSelectedPickerRkIds((prev) =>
|
||||
prev.includes(studentRkId) ? prev.filter((id) => id !== studentRkId) : [...prev, studentRkId]
|
||||
@@ -643,14 +673,19 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
<p className="config-card-desc">
|
||||
Từ khóa tiến trình hoặc tiêu đề được phép (ngăn cách bằng dấu phẩy).
|
||||
</p>
|
||||
{!editable && canEditApps && (
|
||||
<p className="config-card-desc" style={{ color: 'var(--success)', fontWeight: 600, display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
|
||||
<span>💡</span> Phòng thi đã đẩy hoặc đang diễn ra, bạn vẫn có thể thay đổi danh sách ứng dụng được phép bên dưới.
|
||||
</p>
|
||||
)}
|
||||
<textarea
|
||||
className="app-textarea"
|
||||
placeholder="chrome, msedge, acrobat, wails, simple_care, client"
|
||||
value={allowedApps}
|
||||
onChange={(e) => setAllowedApps(e.target.value)}
|
||||
disabled={!editable}
|
||||
disabled={!canEditApps}
|
||||
/>
|
||||
{editable && (
|
||||
{canEditApps && (
|
||||
<>
|
||||
<div className="config-suggestions">
|
||||
<span className="config-suggestions-label">Gợi ý nhanh:</span>
|
||||
@@ -1202,38 +1237,108 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
</button>
|
||||
</div>
|
||||
<div className="exam-student-modal-body">
|
||||
<div className="exam-student-modal-search search-input-wrap" style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||
<input
|
||||
type="search"
|
||||
className="search-input"
|
||||
placeholder="Tìm sinh viên..."
|
||||
value={searchQ}
|
||||
onChange={(e) => {
|
||||
setSearchQ(e.target.value);
|
||||
setSelectedPickerRkIds([]);
|
||||
}}
|
||||
autoFocus
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
{searchQ.trim() !== '' && searchHits.some((h) => !enrolledIds.has(h.studentRkId)) && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={toggleSelectAllHits}
|
||||
style={{ whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{searchHits.filter((h) => !enrolledIds.has(h.studentRkId)).every((h) => selectedPickerRkIds.includes(h.studentRkId))
|
||||
? 'Bỏ chọn tất cả'
|
||||
: 'Chọn tất cả'}
|
||||
</button>
|
||||
)}
|
||||
<div className="tab-btn-group" style={{ marginBottom: '1rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
className={`tab-sub-btn ${searchMode === 'single' ? 'active' : ''}`}
|
||||
onClick={() => handleSwitchTab('single')}
|
||||
>
|
||||
Tìm từng bạn
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`tab-sub-btn ${searchMode === 'bulk' ? 'active' : ''}`}
|
||||
onClick={() => handleSwitchTab('bulk')}
|
||||
>
|
||||
Tìm hàng loạt
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{searchMode === 'single' ? (
|
||||
<div className="exam-student-modal-search search-input-wrap" style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||
<input
|
||||
type="search"
|
||||
className="search-input"
|
||||
placeholder="Tìm sinh viên..."
|
||||
value={searchQ}
|
||||
onChange={(e) => {
|
||||
setSearchQ(e.target.value);
|
||||
setSelectedPickerRkIds([]);
|
||||
}}
|
||||
autoFocus
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
{searchQ.trim() !== '' && searchHits.some((h) => !enrolledIds.has(h.studentRkId)) && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={toggleSelectAllHits}
|
||||
style={{ whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{searchHits.filter((h) => !enrolledIds.has(h.studentRkId)).every((h) => selectedPickerRkIds.includes(h.studentRkId))
|
||||
? 'Bỏ chọn tất cả'
|
||||
: 'Chọn tất cả'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="exam-student-modal-search" style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
<textarea
|
||||
className="search-input"
|
||||
placeholder="Nhập hoặc paste danh sách sinh viên tại đây (Họ tên, mã sinh viên, email...). Mỗi dòng một bạn, hoặc ngăn cách bằng dấu phẩy (,) hoặc chấm phẩy (;)."
|
||||
value={bulkInput}
|
||||
onChange={(e) => setBulkInput(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100px',
|
||||
resize: 'vertical',
|
||||
padding: '0.5rem 0.75rem',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: '0.9rem',
|
||||
lineHeight: '1.4'
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '0.25rem' }}>
|
||||
<span style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>
|
||||
Mẹo: Có thể copy một cột Excel dán trực tiếp vào đây.
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
{searchHits.length > 0 && searchHits.some((h) => !enrolledIds.has(h.studentRkId)) && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={toggleSelectAllHits}
|
||||
>
|
||||
{searchHits.filter((h) => !enrolledIds.has(h.studentRkId)).every((h) => selectedPickerRkIds.includes(h.studentRkId))
|
||||
? 'Bỏ chọn tất cả'
|
||||
: 'Chọn tất cả'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={handleBulkSearch}
|
||||
disabled={!bulkInput.trim()}
|
||||
>
|
||||
Tìm hàng loạt
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="exam-student-hits">
|
||||
{searchQ.trim() === '' ? (
|
||||
{searchMode === 'single' && searchQ.trim() === '' ? (
|
||||
<div className="exam-student-hits-empty">
|
||||
<span>🔍</span>
|
||||
<p>Gõ ít nhất vài ký tự để tìm sinh viên.</p>
|
||||
</div>
|
||||
) : searchMode === 'bulk' && searchHits.length === 0 ? (
|
||||
<div className="exam-student-hits-empty">
|
||||
<span>📋</span>
|
||||
<p>Paste danh sách và bấm "Tìm hàng loạt" để hiển thị kết quả.</p>
|
||||
</div>
|
||||
) : searchHits.length === 0 ? (
|
||||
<div className="exam-student-hits-empty">
|
||||
<span>📭</span>
|
||||
|
||||
Reference in New Issue
Block a user