This commit is contained in:
@@ -61,6 +61,9 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
|||||||
const [editable, setEditable] = useState(false);
|
const [editable, setEditable] = useState(false);
|
||||||
const [prepEditable, setPrepEditable] = useState(false);
|
const [prepEditable, setPrepEditable] = useState(false);
|
||||||
const [displayStatus, setDisplayStatus] = useState('draft');
|
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 [canPublish, setCanPublish] = useState(false);
|
||||||
const [canUnpublish, setCanUnpublish] = useState(false);
|
const [canUnpublish, setCanUnpublish] = useState(false);
|
||||||
const [canCancel, setCanCancel] = useState(false);
|
const [canCancel, setCanCancel] = useState(false);
|
||||||
@@ -189,14 +192,18 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
|||||||
setErr(''); setMsg('');
|
setErr(''); setMsg('');
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await apiExam.update(examId, {
|
const payload: any = {};
|
||||||
name: roomName.trim(),
|
if (editable) {
|
||||||
startTime: localInputToISO(start),
|
payload.name = roomName.trim();
|
||||||
endTime: localInputToISO(end),
|
payload.startTime = localInputToISO(start);
|
||||||
allowedApps,
|
payload.endTime = localInputToISO(end);
|
||||||
quizUrl: quizUrl.trim(),
|
payload.allowedApps = allowedApps;
|
||||||
});
|
payload.quizUrl = quizUrl.trim();
|
||||||
setMsg('Đã lưu cấu hình phòng thi');
|
} 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();
|
await load();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setErr(e?.message || 'Lỗi');
|
setErr(e?.message || 'Lỗi');
|
||||||
@@ -211,6 +218,29 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
|||||||
await fetchOnline();
|
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) => {
|
const toggleHitSelection = (studentRkId: number) => {
|
||||||
setSelectedPickerRkIds((prev) =>
|
setSelectedPickerRkIds((prev) =>
|
||||||
prev.includes(studentRkId) ? prev.filter((id) => id !== studentRkId) : [...prev, studentRkId]
|
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">
|
<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).
|
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>
|
</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
|
<textarea
|
||||||
className="app-textarea"
|
className="app-textarea"
|
||||||
placeholder="chrome, msedge, acrobat, wails, simple_care, client"
|
placeholder="chrome, msedge, acrobat, wails, simple_care, client"
|
||||||
value={allowedApps}
|
value={allowedApps}
|
||||||
onChange={(e) => setAllowedApps(e.target.value)}
|
onChange={(e) => setAllowedApps(e.target.value)}
|
||||||
disabled={!editable}
|
disabled={!canEditApps}
|
||||||
/>
|
/>
|
||||||
{editable && (
|
{canEditApps && (
|
||||||
<>
|
<>
|
||||||
<div className="config-suggestions">
|
<div className="config-suggestions">
|
||||||
<span className="config-suggestions-label">Gợi ý nhanh:</span>
|
<span className="config-suggestions-label">Gợi ý nhanh:</span>
|
||||||
@@ -1202,38 +1237,108 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="exam-student-modal-body">
|
<div className="exam-student-modal-body">
|
||||||
<div className="exam-student-modal-search search-input-wrap" style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
<div className="tab-btn-group" style={{ marginBottom: '1rem' }}>
|
||||||
<input
|
<button
|
||||||
type="search"
|
type="button"
|
||||||
className="search-input"
|
className={`tab-sub-btn ${searchMode === 'single' ? 'active' : ''}`}
|
||||||
placeholder="Tìm sinh viên..."
|
onClick={() => handleSwitchTab('single')}
|
||||||
value={searchQ}
|
>
|
||||||
onChange={(e) => {
|
Tìm từng bạn
|
||||||
setSearchQ(e.target.value);
|
</button>
|
||||||
setSelectedPickerRkIds([]);
|
<button
|
||||||
}}
|
type="button"
|
||||||
autoFocus
|
className={`tab-sub-btn ${searchMode === 'bulk' ? 'active' : ''}`}
|
||||||
style={{ flex: 1 }}
|
onClick={() => handleSwitchTab('bulk')}
|
||||||
/>
|
>
|
||||||
{searchQ.trim() !== '' && searchHits.some((h) => !enrolledIds.has(h.studentRkId)) && (
|
Tìm hàng loạt
|
||||||
<button
|
</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>
|
||||||
|
|
||||||
|
{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">
|
<div className="exam-student-hits">
|
||||||
{searchQ.trim() === '' ? (
|
{searchMode === 'single' && searchQ.trim() === '' ? (
|
||||||
<div className="exam-student-hits-empty">
|
<div className="exam-student-hits-empty">
|
||||||
<span>🔍</span>
|
<span>🔍</span>
|
||||||
<p>Gõ ít nhất vài ký tự để tìm sinh viên.</p>
|
<p>Gõ ít nhất vài ký tự để tìm sinh viên.</p>
|
||||||
</div>
|
</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 ? (
|
) : searchHits.length === 0 ? (
|
||||||
<div className="exam-student-hits-empty">
|
<div className="exam-student-hits-empty">
|
||||||
<span>📭</span>
|
<span>📭</span>
|
||||||
|
|||||||
@@ -302,9 +302,6 @@ func UpdateExamRoomHandler(db *gorm.DB) fiber.Handler {
|
|||||||
if err := db.First(&room, id).Error; err != nil {
|
if err := db.First(&room, id).Error; err != nil {
|
||||||
return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy"})
|
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 {
|
var req struct {
|
||||||
Name *string `json:"name"`
|
Name *string `json:"name"`
|
||||||
StartTime *string `json:"startTime"`
|
StartTime *string `json:"startTime"`
|
||||||
@@ -315,6 +312,15 @@ func UpdateExamRoomHandler(db *gorm.DB) fiber.Handler {
|
|||||||
if err := c.BodyParser(&req); err != nil {
|
if err := c.BodyParser(&req); err != nil {
|
||||||
return c.Status(400).JSON(fiber.Map{"error": "Invalid body"})
|
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 {
|
if req.Name != nil {
|
||||||
room.Name = strings.TrimSpace(*req.Name)
|
room.Name = strings.TrimSpace(*req.Name)
|
||||||
}
|
}
|
||||||
@@ -462,19 +468,42 @@ func SearchExamStudentsHandler(db *gorm.DB) fiber.Handler {
|
|||||||
if q == "" {
|
if q == "" {
|
||||||
return c.JSON(fiber.Map{"data": []any{}})
|
return c.JSON(fiber.Map{"data": []any{}})
|
||||||
}
|
}
|
||||||
pat := "%" + q + "%"
|
// Split search query by newline, comma, or semicolon
|
||||||
var ids []int64
|
var terms []string
|
||||||
_ = db.Model(&models.Student{}).
|
for _, line := range strings.FieldsFunc(q, func(r rune) bool {
|
||||||
Where("full_name LIKE ? OR student_code LIKE ? OR email LIKE ?", pat, pat, pat).
|
return r == '\n' || r == '\r' || r == ';' || r == ','
|
||||||
Limit(30).
|
}) {
|
||||||
Pluck("rk_id", &ids).Error
|
t := strings.TrimSpace(line)
|
||||||
var classIDs []int64
|
if t != "" {
|
||||||
_ = db.Model(&models.Class{}).Where("class_code LIKE ? OR name LIKE ?", pat, pat).Pluck("rk_id", &classIDs).Error
|
terms = append(terms, t)
|
||||||
if len(classIDs) > 0 {
|
}
|
||||||
var more []int64
|
|
||||||
_ = db.Model(&models.ClassStudent{}).Where("class_rk_id IN ?", classIDs).Distinct().Pluck("student_rk_id", &more).Error
|
|
||||||
ids = append(ids, more...)
|
|
||||||
}
|
}
|
||||||
|
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", &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 {
|
||||||
|
var more []int64
|
||||||
|
_ = 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{}
|
seen := map[int64]bool{}
|
||||||
uniq := make([]int64, 0)
|
uniq := make([]int64, 0)
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
@@ -483,12 +512,12 @@ func SearchExamStudentsHandler(db *gorm.DB) fiber.Handler {
|
|||||||
uniq = append(uniq, id)
|
uniq = append(uniq, id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(uniq) > 40 {
|
if len(uniq) > 100 {
|
||||||
uniq = uniq[:40]
|
uniq = uniq[:100]
|
||||||
}
|
}
|
||||||
var students []models.Student
|
var students []models.Student
|
||||||
if len(uniq) > 0 {
|
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 {
|
type row struct {
|
||||||
StudentRkID int64 `json:"studentRkId"`
|
StudentRkID int64 `json:"studentRkId"`
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user