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,6 +1237,24 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
</button>
|
||||
</div>
|
||||
<div className="exam-student-modal-body">
|
||||
<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"
|
||||
@@ -1228,12 +1281,64 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
</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>
|
||||
|
||||
@@ -302,9 +302,6 @@ func UpdateExamRoomHandler(db *gorm.DB) fiber.Handler {
|
||||
if err := db.First(&room, id).Error; err != nil {
|
||||
return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy"})
|
||||
}
|
||||
if !internalDb.ExamRoomEditable(room) {
|
||||
return c.Status(403).JSON(fiber.Map{"error": "Chỉ sửa được phòng thi ở trạng thái tạm thời"})
|
||||
}
|
||||
var req struct {
|
||||
Name *string `json:"name"`
|
||||
StartTime *string `json:"startTime"`
|
||||
@@ -315,6 +312,15 @@ func UpdateExamRoomHandler(db *gorm.DB) fiber.Handler {
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "Invalid body"})
|
||||
}
|
||||
if !internalDb.ExamRoomEditable(room) {
|
||||
if room.Status == models.ExamStatusReady {
|
||||
if req.Name != nil || req.StartTime != nil || req.EndTime != nil || req.QuizURL != nil {
|
||||
return c.Status(403).JSON(fiber.Map{"error": "Chỉ sửa được cấu hình ứng dụng được phép khi phòng thi đã đẩy hoặc đang thi"})
|
||||
}
|
||||
} else {
|
||||
return c.Status(403).JSON(fiber.Map{"error": "Chỉ sửa được phòng thi ở trạng thái tạm thời hoặc đang sẵn sàng/đang thi"})
|
||||
}
|
||||
}
|
||||
if req.Name != nil {
|
||||
room.Name = strings.TrimSpace(*req.Name)
|
||||
}
|
||||
@@ -462,12 +468,33 @@ func SearchExamStudentsHandler(db *gorm.DB) fiber.Handler {
|
||||
if q == "" {
|
||||
return c.JSON(fiber.Map{"data": []any{}})
|
||||
}
|
||||
pat := "%" + q + "%"
|
||||
// Split search query by newline, comma, or semicolon
|
||||
var terms []string
|
||||
for _, line := range strings.FieldsFunc(q, func(r rune) bool {
|
||||
return r == '\n' || r == '\r' || r == ';' || r == ','
|
||||
}) {
|
||||
t := strings.TrimSpace(line)
|
||||
if t != "" {
|
||||
terms = append(terms, t)
|
||||
}
|
||||
}
|
||||
if len(terms) == 0 {
|
||||
return c.JSON(fiber.Map{"data": []any{}})
|
||||
}
|
||||
if len(terms) > 100 {
|
||||
terms = terms[:100]
|
||||
}
|
||||
|
||||
var ids []int64
|
||||
for _, term := range terms {
|
||||
pat := "%" + term + "%"
|
||||
var termIDs []int64
|
||||
_ = db.Model(&models.Student{}).
|
||||
Where("full_name LIKE ? OR student_code LIKE ? OR email LIKE ?", pat, pat, pat).
|
||||
Limit(30).
|
||||
Pluck("rk_id", &ids).Error
|
||||
Pluck("rk_id", &termIDs).Error
|
||||
ids = append(ids, termIDs...)
|
||||
|
||||
var classIDs []int64
|
||||
_ = db.Model(&models.Class{}).Where("class_code LIKE ? OR name LIKE ?", pat, pat).Pluck("rk_id", &classIDs).Error
|
||||
if len(classIDs) > 0 {
|
||||
@@ -475,6 +502,8 @@ func SearchExamStudentsHandler(db *gorm.DB) fiber.Handler {
|
||||
_ = db.Model(&models.ClassStudent{}).Where("class_rk_id IN ?", classIDs).Distinct().Pluck("student_rk_id", &more).Error
|
||||
ids = append(ids, more...)
|
||||
}
|
||||
}
|
||||
|
||||
seen := map[int64]bool{}
|
||||
uniq := make([]int64, 0)
|
||||
for _, id := range ids {
|
||||
@@ -483,12 +512,12 @@ func SearchExamStudentsHandler(db *gorm.DB) fiber.Handler {
|
||||
uniq = append(uniq, id)
|
||||
}
|
||||
}
|
||||
if len(uniq) > 40 {
|
||||
uniq = uniq[:40]
|
||||
if len(uniq) > 100 {
|
||||
uniq = uniq[:100]
|
||||
}
|
||||
var students []models.Student
|
||||
if len(uniq) > 0 {
|
||||
_ = db.Where("rk_id IN ?", uniq).Limit(40).Find(&students).Error
|
||||
_ = db.Where("rk_id IN ?", uniq).Limit(100).Find(&students).Error
|
||||
}
|
||||
type row struct {
|
||||
StudentRkID int64 `json:"studentRkId"`
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user