From 232f9873a0c6e8074ed7a28997906584e421488e Mon Sep 17 00:00:00 2001 From: PhuocNTB Date: Mon, 20 Jul 2026 12:38:02 +0700 Subject: [PATCH] up --- management/src/api.ts | 14 +- .../src/components/ExamRoomWorkspace.tsx | 104 ++++++- management/src/components/ExamsTab.tsx | 90 ++++-- management/src/components/LearningTab.tsx | 281 ++++++++++++++---- server/internal/db/exam_status.go | 5 + server/internal/handlers/handlers_exam.go | 117 ++++++-- server/internal/models/exam.go | 25 +- server/main.go | 1 + 8 files changed, 516 insertions(+), 121 deletions(-) diff --git a/management/src/api.ts b/management/src/api.ts index 83695ed..b2c0dcd 100644 --- a/management/src/api.ts +++ b/management/src/api.ts @@ -933,6 +933,7 @@ export interface ExamRoomItem { studentCount: number; paperCount: number; displayStatus: 'draft' | 'ready' | 'active' | 'ended' | 'cancelled'; + createdByStaffId?: number; } export interface ExamPaperResource { @@ -985,8 +986,9 @@ function decodeBase64ToArrayBuffer(b64: string): ArrayBuffer { } export const apiExam = { - list: async (): Promise<{ data: ExamRoomItem[] }> => { - const res = await staffFetch('/exam-rooms'); + list: async (opts?: { mine?: boolean }): Promise<{ data: ExamRoomItem[] }> => { + const q = opts?.mine ? '?mine=1' : ''; + const res = await staffFetch(`/exam-rooms${q}`); if (!res.ok) await parseError(res, 'Failed'); return res.json(); }, @@ -1031,6 +1033,14 @@ export const apiExam = { if (!res.ok) await parseError(res, 'Hủy phòng thi thất bại'); return res.json(); }, + extend: async (id: number, minutes: number) => { + const res = await staffFetch(`/exam-rooms/${id}/extend`, { + method: 'POST', + body: JSON.stringify({ minutes }), + }); + if (!res.ok) await parseError(res, 'Gia hạn thất bại'); + return res.json() as Promise<{ ok: boolean; endTime: string; addedMinutes: number; displayStatus: string }>; + }, update: async (id: number, payload: Partial<{ name: string; startTime: string; endTime: string; allowedApps: string; quizUrl: string }>) => { const res = await staffFetch(`/exam-rooms/${id}`, { method: 'PATCH', body: JSON.stringify(payload) }); if (!res.ok) await parseError(res, 'Cập nhật thất bại'); diff --git a/management/src/components/ExamRoomWorkspace.tsx b/management/src/components/ExamRoomWorkspace.tsx index 994672b..8777204 100644 --- a/management/src/components/ExamRoomWorkspace.tsx +++ b/management/src/components/ExamRoomWorkspace.tsx @@ -70,6 +70,8 @@ export const ExamRoomWorkspace: React.FC = ({ examId, onBack }) => { const [canPublish, setCanPublish] = useState(false); const [canUnpublish, setCanUnpublish] = useState(false); const [canCancel, setCanCancel] = useState(false); + const [canExtend, setCanExtend] = useState(false); + const [extending, setExtending] = useState(false); const [papers, setPapers] = useState([]); const [students, setStudents] = useState([]); const [submissions, setSubmissions] = useState([]); @@ -101,6 +103,7 @@ export const ExamRoomWorkspace: React.FC = ({ examId, onBack }) => { const canEditApps = displayStatus === 'draft' || displayStatus === 'ready' || displayStatus === 'active'; const anyPaperSent = students.some((s) => s.paperSentAt); const canModifyPapers = prepEditable || (!anyPaperSent && (displayStatus === 'draft' || displayStatus === 'ready' || displayStatus === 'active')); + const canRemoveStudents = displayStatus !== 'ended' && displayStatus !== 'cancelled'; const load = useCallback(async () => { setLoading(true); @@ -118,6 +121,7 @@ export const ExamRoomWorkspace: React.FC = ({ examId, onBack }) => { setCanPublish(data.canPublish); setCanUnpublish(data.canUnpublish); setCanCancel(data.canCancel); + setCanExtend(!!data.canExtend || data.displayStatus === 'active'); setPapers(data.papers); setStudents(data.students); pushNav({ @@ -231,6 +235,20 @@ export const ExamRoomWorkspace: React.FC = ({ examId, onBack }) => { } }; + const saveExtendedEndTime = async () => { + setErr(''); setMsg(''); + setSaving(true); + try { + await apiExam.update(examId, { endTime: localInputToISO(end) }); + setMsg('Đã gia hạn giờ kết thúc 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(); @@ -575,6 +593,21 @@ export const ExamRoomWorkspace: React.FC = ({ examId, onBack }) => { } }; + const handleExtend = async (minutes: number) => { + if (!confirm(`Gia hạn thêm ${minutes} phút cho phòng thi đang diễn ra?`)) return; + setErr(''); setMsg(''); + setExtending(true); + try { + const res = await apiExam.extend(examId, minutes); + setMsg(`Đã gia hạn +${minutes} phút — kết thúc lúc ${fmtTime(res.endTime)}`); + await load(); + } catch (e: any) { + setErr(e?.message || 'Gia hạn thất bại'); + } finally { + setExtending(false); + } + }; + const filteredStudents = students.filter((s) => { const term = searchQuery.toLowerCase().trim(); if (!term) return true; @@ -667,6 +700,23 @@ export const ExamRoomWorkspace: React.FC = ({ examId, onBack }) => { {canCancel && ( )} + {canExtend && ( +
+ Gia hạn + {[15, 30, 60].map((m) => ( + + ))} +
+ )} {papers.length > 0 && ( )} + {canExtend && ( +
+ {[15, 30, 60].map((m) => ( + + ))} +
+ )} )} @@ -1112,14 +1192,22 @@ export const ExamRoomWorkspace: React.FC = ({ examId, onBack }) => { - {prepEditable && ( + {canRemoveStudents && ( )} diff --git a/management/src/components/ExamsTab.tsx b/management/src/components/ExamsTab.tsx index da1e116..874c242 100644 --- a/management/src/components/ExamsTab.tsx +++ b/management/src/components/ExamsTab.tsx @@ -32,9 +32,12 @@ function localInputToISO(v: string) { return new Date(v).toISOString(); } +type RoomScope = 'mine' | 'all'; + export const ExamsTab: React.FC = () => { const { staff } = useAuth(); const isSuperAdmin = staff?.email === 'phuocntb@rikkeiacademy.com'; + const myStaffId = staff?.id ?? 0; const [rooms, setRooms] = useState([]); const [loading, setLoading] = useState(true); @@ -44,10 +47,12 @@ export const ExamsTab: React.FC = () => { const [end, setEnd] = useState(''); const [busy, setBusy] = useState(false); const [search, setSearch] = useState(''); + const [roomScope, setRoomScope] = useState('mine'); const load = async () => { setLoading(true); try { + // Luôn lấy đủ danh sách để toggle mine/all mượt; lọc phía client theo createdByStaffId const res = await apiExam.list(); setRooms(res.data); } catch (e) { @@ -61,20 +66,28 @@ export const ExamsTab: React.FC = () => { load(); }, []); + const scopedRooms = useMemo(() => { + if (roomScope === 'all' || !myStaffId) return rooms; + return rooms.filter((r) => Number(r.createdByStaffId) === myStaffId); + }, [rooms, roomScope, myStaffId]); + const stats = useMemo(() => { - const active = rooms.filter((r) => (r.displayStatus || r.status) === 'active').length; - const upcoming = rooms.filter((r) => { + const active = scopedRooms.filter((r) => (r.displayStatus || r.status) === 'active').length; + const upcoming = scopedRooms.filter((r) => { const st = r.displayStatus || r.status; return st === 'ready' || st === 'draft'; }).length; - return { total: rooms.length, active, upcoming }; - }, [rooms]); + return { total: scopedRooms.length, active, upcoming }; + }, [scopedRooms]); const filtered = useMemo(() => { const q = search.trim().toLowerCase(); - if (!q) return rooms; - return rooms.filter((r) => r.name.toLowerCase().includes(q)); - }, [rooms, search]); + if (!q) return scopedRooms; + return scopedRooms.filter((r) => r.name.toLowerCase().includes(q)); + }, [scopedRooms, search]); + + const canDeleteRoom = (r: ExamRoomItem) => + isSuperAdmin || (myStaffId > 0 && Number(r.createdByStaffId) === myStaffId); const create = async () => { if (!name.trim() || !start || !end) return; @@ -115,7 +128,11 @@ export const ExamsTab: React.FC = () => {

Danh sách phòng thi

-

Tạo phòng, chia đề ngẫu nhiên, gửi đề và thu bài từ sinh viên.

+

+ {roomScope === 'mine' + ? 'Mặc định chỉ hiện phòng thi do bạn tạo — chuyển sang xem tất cả khi cần' + : 'Đang xem toàn bộ phòng thi trong hệ thống'} +

+ + setSearch(e.target.value)} + style={{ flex: 1, minWidth: 200 }} /> @@ -203,18 +237,38 @@ export const ExamsTab: React.FC = () => { {loading ? (
Đang tải...
) : filtered.length === 0 ? ( -
- {search.trim() ? 'Không tìm thấy phòng thi phù hợp.' : 'Chưa có phòng thi — bấm Tạo phòng thi để bắt đầu.'} +
+

+ {search.trim() + ? 'Không tìm thấy phòng thi phù hợp.' + : roomScope === 'mine' + ? 'Bạn chưa tạo phòng thi nào.' + : 'Chưa có phòng thi — bấm Tạo phòng thi để bắt đầu.'} +

+ {!search.trim() && roomScope === 'mine' && ( +
+ + +
+ )}
) : (
{filtered.map((r) => { const st = statusLabel(r.displayStatus || r.status); + const mine = myStaffId > 0 && Number(r.createdByStaffId) === myStaffId; return (

{r.name}

- {st.text} +
+ {mine && Của tôi} + {st.text} +
@@ -232,7 +286,7 @@ export const ExamsTab: React.FC = () => { - {isSuperAdmin && ( + {canDeleteRoom(r) && ( diff --git a/management/src/components/LearningTab.tsx b/management/src/components/LearningTab.tsx index 2a483df..85de943 100644 --- a/management/src/components/LearningTab.tsx +++ b/management/src/components/LearningTab.tsx @@ -33,6 +33,7 @@ import { } from './learningUtils'; type ViewMode = 'list' | 'timetable'; +type RoomScope = 'mine' | 'all'; export const LearningTab: React.FC = () => { const [activeClasses, setActiveClasses] = useState([]); @@ -40,7 +41,7 @@ export const LearningTab: React.FC = () => { const [searchQuery, setSearchQuery] = useState(''); const [dayFilter, setDayFilter] = useState(null); const [viewMode, setViewMode] = useState('list'); - const [onlyMyClasses, setOnlyMyClasses] = useState(false); + const [roomScope, setRoomScope] = useState('mine'); const [myClasses, setMyClasses] = useState([]); const refreshMyClasses = () => { @@ -75,8 +76,10 @@ export const LearningTab: React.FC = () => { const [studentsLogs, setStudentsLogs] = useState([]); const [showAddClassModal, setShowAddClassModal] = useState(false); + const [showAddMyClassModal, setShowAddMyClassModal] = useState(false); const [allClasses, setAllClasses] = useState([]); const [searchClassQuery, setSearchClassQuery] = useState(''); + const [loadingClassList, setLoadingClassList] = useState(false); const [proctorStudent, setProctorStudent] = useState<{ id: number; name: string; code: string } | null>(null); const [conflictWarning, setConflictWarning] = useState<{ cls: ClassItem; conflicts: ScheduleConflictItem[] } | null>(null); @@ -116,32 +119,53 @@ export const LearningTab: React.FC = () => { const filteredClasses = useMemo(() => { const list = filterActiveClasses(activeClasses, searchQuery, dayFilter); - if (onlyMyClasses) { + if (roomScope === 'mine') { return list.filter(c => myClasses.includes(c.rkId)); } return list; - }, [activeClasses, searchQuery, dayFilter, onlyMyClasses, myClasses]); + }, [activeClasses, searchQuery, dayFilter, roomScope, myClasses]); - const weeklyShifts = useMemo(() => countWeeklyShifts(activeClasses), [activeClasses]); - const todayClassCount = useMemo( - () => activeClasses.filter(classHasToday).length, - [activeClasses] + const myActiveClasses = useMemo( + () => activeClasses.filter(c => myClasses.includes(c.rkId)), + [activeClasses, myClasses] ); - const handleOpenAddClass = async () => { - setShowAddClassModal(true); + const weeklyShifts = useMemo( + () => countWeeklyShifts(roomScope === 'mine' ? myActiveClasses : activeClasses), + [roomScope, myActiveClasses, activeClasses] + ); + const todayClassCount = useMemo( + () => (roomScope === 'mine' ? myActiveClasses : activeClasses).filter(classHasToday).length, + [roomScope, myActiveClasses, activeClasses] + ); + + const loadAllClassesForModal = async () => { + setLoadingClassList(true); try { const res = await apiFetchClasses({ page: 1, pageSize: 1000 }); setAllClasses(res.data || []); } catch (err) { console.error('Error loading classes:', err); + } finally { + setLoadingClassList(false); } }; + const handleOpenAddClass = async () => { + setSearchClassQuery(''); + setShowAddClassModal(true); + await loadAllClassesForModal(); + }; + + const handleOpenAddMyClass = async () => { + setSearchClassQuery(''); + setShowAddMyClassModal(true); + await loadAllClassesForModal(); + }; + const handleActivateClass = async (cls: ClassItem) => { setShowAddClassModal(false); try { - // Kiểm tra trùng sinh viên với lớp đang giám sát có lịch học const conflictRes = await apiFetchScheduleConflicts(cls.rkId); if (conflictRes.conflicts && conflictRes.conflicts.length > 0) { setConflictWarning({ cls, conflicts: conflictRes.conflicts }); @@ -156,6 +180,12 @@ export const LearningTab: React.FC = () => { const doActivateClass = async (cls: ClassItem) => { try { await apiSaveAllowedApps(cls.rkId, DEFAULT_ALLOWED_APPS); + // Tự thêm vào "lớp của tôi" khi kích hoạt lịch + if (!myClasses.includes(cls.rkId)) { + setMyClasses((prev) => [...prev, cls.rkId]); + apiMyClasses.add(cls.rkId).catch(() => {}); + window.dispatchEvent(new Event('my-classes-updated')); + } await loadActiveClasses(); handleOpenConfig(cls as ActiveClassItem); } catch (err) { @@ -245,25 +275,45 @@ export const LearningTab: React.FC = () => { !activeClasses.some(ac => ac.rkId === c.rkId) ); + const filteredMyClassCandidates = allClasses.filter(c => + c.name.toLowerCase().includes(searchClassQuery.toLowerCase()) || + c.classCode.toLowerCase().includes(searchClassQuery.toLowerCase()) + ); + return (

Quản lý lịch học & Giám sát

-

Chọn lớp, lập lịch theo tuần và giám sát sinh viên — xem dạng danh sách hoặc thời khóa biểu

+

+ {roomScope === 'mine' + ? 'Mặc định chỉ hiện lớp của bạn — thêm lớp hoặc chuyển sang xem tất cả khi cần' + : 'Đang xem toàn bộ lớp đang giám sát trong hệ thống'} +

+
+
+ + {roomScope === 'all' && ( + + )}
-
- {activeClasses.length} - Lớp đang giám sát + + {roomScope === 'mine' ? myActiveClasses.length : activeClasses.length} + + + {roomScope === 'mine' ? 'Lớp của tôi' : 'Lớp đang giám sát'} +
{weeklyShifts} @@ -281,6 +331,22 @@ export const LearningTab: React.FC = () => {
+
+ + +
{ value={searchQuery} onChange={e => setSearchQuery(e.target.value)} /> -
{WEEK_DAYS.map((label, day) => (
)} - {!loading && activeClasses.length === 0 && ( + {!loading && roomScope === 'mine' && myClasses.length === 0 && ( +
+
+

+ Chưa có lớp nào trong danh sách của bạn. +
+ Tìm và thêm lớp để theo dõi nhanh hơn. +

+
+ + +
+
+ )} + + {!loading && roomScope === 'all' && activeClasses.length === 0 && (
@@ -359,24 +436,33 @@ export const LearningTab: React.FC = () => {
)} - {!loading && activeClasses.length > 0 && filteredClasses.length === 0 && ( + {!loading && !(roomScope === 'mine' && myClasses.length === 0) && !(roomScope === 'all' && activeClasses.length === 0) && filteredClasses.length === 0 && (
-

Không tìm thấy lớp phù hợp với bộ lọc hiện tại.

- +

+ {roomScope === 'mine' + ? 'Lớp của bạn chưa có lịch giám sát khớp bộ lọc, hoặc chưa được kích hoạt lịch.' + : 'Không tìm thấy lớp phù hợp với bộ lọc hiện tại.'} +

+
+ + {roomScope === 'mine' && ( + + )} +
)} @@ -634,7 +720,9 @@ export const LearningTab: React.FC = () => {
- {filteredAddClasses.length === 0 ? ( + {loadingClassList ? ( +

Đang tải danh sách lớp...

+ ) : filteredAddClasses.length === 0 ? (

Không tìm thấy lớp phù hợp.

) : (
    @@ -648,27 +736,13 @@ export const LearningTab: React.FC = () => { border: '1px solid var(--border-color)', borderRadius: 'var(--radius-sm)', transition: 'var(--transition)' - }} - onMouseEnter={(e) => { - e.currentTarget.style.borderColor = 'rgba(187, 33, 38, 0.25)'; - e.currentTarget.style.backgroundColor = '#fff'; - e.currentTarget.style.boxShadow = 'var(--shadow-sm)'; - }} - onMouseLeave={(e) => { - e.currentTarget.style.borderColor = 'var(--border-color)'; - e.currentTarget.style.backgroundColor = 'var(--bg-app)'; - e.currentTarget.style.boxShadow = 'none'; - }} - > + }}>
    {c.classCode}
    {c.name}
    ))} @@ -683,6 +757,103 @@ export const LearningTab: React.FC = () => {
)} + {showAddMyClassModal && ( +
setShowAddMyClassModal(false)}> +
e.stopPropagation()}> +
+
+

Thêm lớp của tôi

+

+ Tìm lớp và thêm vào danh sách cá nhân. Có thể kích hoạt lịch nếu lớp chưa giám sát. +

+
+ +
+
+
+ setSearchClassQuery(e.target.value)} + autoFocus + /> + + + + + + +
+
+ {loadingClassList ? ( +

Đang tải danh sách lớp...

+ ) : filteredMyClassCandidates.length === 0 ? ( +

Không tìm thấy lớp phù hợp.

+ ) : ( +
    + {filteredMyClassCandidates.map(c => { + const isMine = myClasses.includes(c.rkId); + const isActive = activeClasses.some(ac => ac.rkId === c.rkId); + return ( +
  • +
    +
    + {c.classCode} + {isMine && Đã thêm} + {isActive && Đang giám sát} +
    +
    {c.name}
    +
    +
    + + {!isActive && ( + + )} +
    +
  • + ); + })} +
+ )} +
+
+
+ +
+
+
+ )} + {proctorStudent && ( 0 { + q = q.Where("created_by_staff_id = ?", staffID) + } var rooms []models.ExamRoom - if err := db.Order("start_time desc").Find(&rooms).Error; err != nil { + if err := q.Find(&rooms).Error; err != nil { return c.Status(500).JSON(fiber.Map{"error": err.Error()}) } type row struct { models.ExamRoom - StudentCount int `json:"studentCount"` - PaperCount int `json:"paperCount"` - DisplayStatus string `json:"displayStatus"` + StudentCount int `json:"studentCount"` + PaperCount int `json:"paperCount"` + DisplayStatus string `json:"displayStatus"` + CreatedByStaffID uint `json:"createdByStaffId"` } out := make([]row, 0, len(rooms)) now := time.Now() @@ -140,10 +149,11 @@ func ListExamRoomsHandler(db *gorm.DB) fiber.Handler { _ = db.Model(&models.ExamRoomStudent{}).Where("exam_room_id = ?", r.ID).Count(&sc).Error _ = db.Model(&models.ExamPaper{}).Where("exam_room_id = ?", r.ID).Count(&pc).Error out = append(out, row{ - ExamRoom: r, - StudentCount: int(sc), - PaperCount: int(pc), - DisplayStatus: internalDb.ExamDisplayStatus(r, now), + ExamRoom: r, + StudentCount: int(sc), + PaperCount: int(pc), + DisplayStatus: internalDb.ExamDisplayStatus(r, now), + CreatedByStaffID: r.CreatedByStaffID, }) } return c.JSON(fiber.Map{"data": out}) @@ -177,12 +187,13 @@ func CreateExamRoomHandler(db *gorm.DB) fiber.Handler { } apps := strings.TrimSpace(req.AllowedApps) room := models.ExamRoom{ - Name: req.Name, - StartTime: start, - EndTime: end, - AllowedApps: apps, - QuizURL: strings.TrimSpace(req.QuizURL), - Status: models.ExamStatusDraft, + Name: req.Name, + StartTime: start, + EndTime: end, + AllowedApps: apps, + QuizURL: strings.TrimSpace(req.QuizURL), + Status: models.ExamStatusDraft, + CreatedByStaffID: middleware.StaffIDFromCtx(c), } if err := db.Create(&room).Error; err != nil { return c.Status(500).JSON(fiber.Map{"error": err.Error()}) @@ -259,8 +270,9 @@ func GetExamRoomHandler(db *gorm.DB) fiber.Handler { "editable": internalDb.ExamRoomEditable(room), "prepEditable": internalDb.ExamRoomPrepEditable(room, now), "canPublish": room.Status == models.ExamStatusDraft, - "canUnpublish": internalDb.ExamRoomCanUnpublish(room, time.Now()), - "canCancel": internalDb.ExamRoomCanCancel(room, time.Now()), + "canUnpublish": internalDb.ExamRoomCanUnpublish(room, now), + "canCancel": internalDb.ExamRoomCanCancel(room, now), + "canExtend": internalDb.ExamRoomCanExtend(room, now), }) } } @@ -307,8 +319,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"}) } + now := time.Now() + ds := internalDb.ExamDisplayStatus(room, now) if !internalDb.ExamRoomEditable(room) { - if room.Status == models.ExamStatusReady { + if ds == "active" { + // Đang thi: chỉ cho sửa app được phép + giờ kết thúc (gia hạn) + if req.Name != nil || req.StartTime != nil || req.QuizURL != nil { + return c.Status(403).JSON(fiber.Map{"error": "Khi đang thi chỉ được sửa ứng dụng được phép hoặc gia hạn giờ kết thúc"}) + } + } else 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"}) } @@ -331,6 +350,14 @@ func UpdateExamRoomHandler(db *gorm.DB) fiber.Handler { if err != nil { return c.Status(400).JSON(fiber.Map{"error": "endTime không hợp lệ"}) } + if ds == "active" { + if !t.After(now) { + return c.Status(400).JSON(fiber.Map{"error": "Giờ kết thúc mới phải sau thời điểm hiện tại"}) + } + if !t.After(room.EndTime) { + return c.Status(400).JSON(fiber.Map{"error": "Gia hạn phải chọn giờ kết thúc muộn hơn giờ hiện tại của phòng"}) + } + } room.EndTime = t } if !room.EndTime.After(room.StartTime) { @@ -420,13 +447,54 @@ func CancelExamRoomHandler(db *gorm.DB) fiber.Handler { } } +// POST /api/exam-rooms/:id/extend — gia hạn thêm phút cho phòng đang thi +func ExtendExamRoomHandler(db *gorm.DB) fiber.Handler { + return func(c *fiber.Ctx) error { + id, err := parseUintParam(c, "id") + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "Invalid id"}) + } + var req struct { + Minutes int `json:"minutes"` + } + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(fiber.Map{"error": "Invalid body"}) + } + if req.Minutes < 1 || req.Minutes > 480 { + return c.Status(400).JSON(fiber.Map{"error": "Số phút gia hạn phải từ 1 đến 480"}) + } + var room models.ExamRoom + if err := db.First(&room, id).Error; err != nil { + return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy"}) + } + now := time.Now() + if !internalDb.ExamRoomCanExtend(room, now) { + return c.Status(403).JSON(fiber.Map{"error": "Chỉ gia hạn được phòng thi đang diễn ra"}) + } + base := room.EndTime + if !base.After(now) { + base = now + } + room.EndTime = base.Add(time.Duration(req.Minutes) * time.Minute) + if err := db.Save(&room).Error; err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{ + "ok": true, + "room": room, + "displayStatus": internalDb.ExamDisplayStatus(room, time.Now()), + "addedMinutes": req.Minutes, + "endTime": room.EndTime, + }) + } +} + // DELETE /api/exam-rooms/:id func DeleteExamRoomHandler(db *gorm.DB) fiber.Handler { return func(c *fiber.Ctx) error { + staffID := middleware.StaffIDFromCtx(c) email, _ := c.Locals("staffEmail").(string) - if email != "phuocntb@rikkeiacademy.com" { - return c.Status(403).JSON(fiber.Map{"error": "Bạn không có quyền thực hiện chức năng này"}) - } + isSuperAdmin := email == "phuocntb@rikkeiacademy.com" id, err := parseUintParam(c, "id") if err != nil { @@ -436,6 +504,9 @@ func DeleteExamRoomHandler(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 !isSuperAdmin && (room.CreatedByStaffID == 0 || room.CreatedByStaffID != staffID) { + return c.Status(403).JSON(fiber.Map{"error": "Chỉ người tạo phòng hoặc admin mới được xóa"}) + } if internalDb.ExamRoomIsLive(room, time.Now()) { return c.Status(403).JSON(fiber.Map{"error": "Không thể xóa phòng thi đang diễn ra — hãy hủy phòng trước"}) } @@ -451,6 +522,7 @@ func DeleteExamRoomHandler(db *gorm.DB) fiber.Handler { _ = db.Where("exam_paper_id = ?", p.ID).Delete(&models.ExamPaperResource{}).Error } _ = db.Where("exam_room_id = ?", id).Delete(&models.ExamPaper{}).Error + _ = db.Where("exam_room_id = ?", id).Delete(&models.ExamSeatingLayout{}).Error _ = db.Delete(&room).Error return c.JSON(fiber.Map{"ok": true}) } @@ -593,8 +665,9 @@ func RemoveExamRoomStudentHandler(db *gorm.DB) fiber.Handler { if err := db.First(&room, roomID).Error; err != nil { return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy"}) } - if !internalDb.ExamRoomPrepEditable(room, time.Now()) { - return c.Status(403).JSON(fiber.Map{"error": "Phòng thi đã bắt đầu — không thể xóa sinh viên"}) + ds := internalDb.ExamDisplayStatus(room, time.Now()) + if ds == models.ExamStatusEnded { + return c.Status(403).JSON(fiber.Map{"error": "Phòng thi đã kết thúc — không thể xóa sinh viên"}) } _ = db.Where("exam_room_id = ? AND student_rk_id = ?", roomID, studentRkID).Delete(&models.ExamRoomStudent{}).Error return c.JSON(fiber.Map{"ok": true}) diff --git a/server/internal/models/exam.go b/server/internal/models/exam.go index d0a3205..550ed5d 100644 --- a/server/internal/models/exam.go +++ b/server/internal/models/exam.go @@ -11,18 +11,19 @@ const ( // ExamRoom — phòng thi độc lập với lớp học type ExamRoom struct { - ID uint `gorm:"primaryKey" json:"id"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` - Name string `gorm:"column:name;size:255;not null" json:"name"` - StartTime time.Time `gorm:"column:start_time;not null;index" json:"startTime"` - EndTime time.Time `gorm:"column:end_time;not null;index" json:"endTime"` - AllowedApps string `gorm:"column:allowed_apps;type:text" json:"allowedApps"` - QuizURL string `gorm:"column:quiz_url;size:1024" json:"quizUrl"` - GitRepoURL string `gorm:"column:git_repo_url;size:512" json:"gitRepoUrl"` - GitBranch string `gorm:"column:git_branch;size:64;default:main" json:"gitBranch"` - GitPublishURL string `gorm:"column:git_publish_url;size:1024" json:"gitPublishUrl,omitempty"` - Status string `gorm:"column:status;size:16;not null;default:draft;index" json:"status"` + ID uint `gorm:"primaryKey" json:"id"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + Name string `gorm:"column:name;size:255;not null" json:"name"` + StartTime time.Time `gorm:"column:start_time;not null;index" json:"startTime"` + EndTime time.Time `gorm:"column:end_time;not null;index" json:"endTime"` + AllowedApps string `gorm:"column:allowed_apps;type:text" json:"allowedApps"` + QuizURL string `gorm:"column:quiz_url;size:1024" json:"quizUrl"` + GitRepoURL string `gorm:"column:git_repo_url;size:512" json:"gitRepoUrl"` + GitBranch string `gorm:"column:git_branch;size:64;default:main" json:"gitBranch"` + GitPublishURL string `gorm:"column:git_publish_url;size:1024" json:"gitPublishUrl,omitempty"` + Status string `gorm:"column:status;size:16;not null;default:draft;index" json:"status"` + CreatedByStaffID uint `gorm:"column:created_by_staff_id;index;default:0" json:"createdByStaffId"` } func (ExamRoom) TableName() string { return "exam_rooms" } diff --git a/server/main.go b/server/main.go index a139d91..1a871ec 100644 --- a/server/main.go +++ b/server/main.go @@ -213,6 +213,7 @@ func main() { staff.Post("/exam-rooms/:id/publish", handlers.PublishExamRoomHandler(gormDB)) staff.Post("/exam-rooms/:id/unpublish", handlers.UnpublishExamRoomHandler(gormDB)) staff.Post("/exam-rooms/:id/cancel", handlers.CancelExamRoomHandler(gormDB)) + staff.Post("/exam-rooms/:id/extend", handlers.ExtendExamRoomHandler(gormDB)) staff.Post("/exam-rooms/:id/assign-random", handlers.RandomAssignExamPapersHandler(gormDB)) staff.Post("/exam-rooms/:id/assign-papers-batch", handlers.AssignExamPapersBatchHandler(gormDB)) staff.Get("/exam-rooms/:id/seating-layout", handlers.GetExamSeatingLayoutHandler(gormDB))