fix ui thi
All checks were successful
Deploy on Master Change / deploy (push) Successful in 47s

This commit is contained in:
2026-07-02 15:51:29 +07:00
parent 4b8f4d288a
commit 1b424ab62d
3 changed files with 188 additions and 54 deletions

View File

@@ -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 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...).&#10;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: 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> ít nhất vài 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 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>