From 06f2edb19538aa6b499cbafe263e8fa9994c9806 Mon Sep 17 00:00:00 2001 From: PhuocNTB Date: Mon, 13 Jul 2026 10:29:34 +0700 Subject: [PATCH] fix random --- management/src/App.tsx | 34 ++-- management/src/api.ts | 47 +++++ .../src/components/ExamRoomWorkspace.tsx | 58 +++++- management/src/components/LearningTab.tsx | 42 ++--- .../src/components/WorkspaceSeatingChart.tsx | 176 +++++++++++++++--- server/internal/db/db.go | 2 + server/internal/handlers/handlers_exam.go | 37 ++++ server/internal/handlers/handlers_seating.go | 125 +++++++++++++ server/internal/models/models.go | 9 + server/internal/models/staff.go | 8 + server/main.go | 9 + 11 files changed, 476 insertions(+), 71 deletions(-) create mode 100644 server/internal/handlers/handlers_seating.go diff --git a/management/src/App.tsx b/management/src/App.tsx index 0fc5c80..b4c957b 100644 --- a/management/src/App.tsx +++ b/management/src/App.tsx @@ -1,4 +1,5 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useCallback, useState } from 'react'; +import { apiMyClasses } from './api'; import { DashboardTab } from './components/DashboardTab'; import { ClassesTab } from './components/ClassesTab'; import { StudentsTab } from './components/StudentsTab'; @@ -117,25 +118,22 @@ function App() { setSidebarCollapsed(nextVal); localStorage.setItem('sc_sidebar_collapsed', String(nextVal)); }; - const [myClasses, setMyClasses] = useState<{ id: number; name: string; code: string }[]>(() => { - try { - const stored = localStorage.getItem('sc_my_classes_details'); - return stored ? JSON.parse(stored) : []; - } catch { - return []; - } - }); + const [myClasses, setMyClasses] = useState<{ id: number; name: string; code: string }[]>([]); + + const refreshMyClasses = useCallback(() => { + apiMyClasses.list().then((items) => { + setMyClasses(items.map((c) => ({ id: c.rkId, name: c.name, code: c.classCode }))); + }).catch(() => {}); + }, []); useEffect(() => { - const handleUpdate = () => { - try { - const stored = localStorage.getItem('sc_my_classes_details'); - setMyClasses(stored ? JSON.parse(stored) : []); - } catch (e) { } - }; - window.addEventListener('my-classes-updated', handleUpdate); - return () => window.removeEventListener('my-classes-updated', handleUpdate); - }, []); + refreshMyClasses(); + }, [refreshMyClasses]); + + useEffect(() => { + window.addEventListener('my-classes-updated', refreshMyClasses); + return () => window.removeEventListener('my-classes-updated', refreshMyClasses); + }, [refreshMyClasses]); useEffect(() => { const initial = parseRoute(); diff --git a/management/src/api.ts b/management/src/api.ts index 1a84d03..818c7d3 100644 --- a/management/src/api.ts +++ b/management/src/api.ts @@ -1124,6 +1124,14 @@ export const apiExam = { if (!res.ok) await parseError(res, 'Chia gói đề thất bại'); return res.json() as Promise<{ assigned: number; packages: number }>; }, + assignPapersBatch: async (id: number, assignments: { studentRkId: number; paperId: number }[]) => { + const res = await staffFetch(`/exam-rooms/${id}/assign-papers-batch`, { + method: 'POST', + body: JSON.stringify({ assignments }), + }); + if (!res.ok) await parseError(res, 'Chia gói đề thất bại'); + return res.json() as Promise<{ assigned: number }>; + }, sendPapers: async (id: number, payload?: { scheduledAt?: string; studentRkIds?: number[] }) => { const res = await staffFetch(`/exam-rooms/${id}/send-papers`, { method: 'POST', body: JSON.stringify(payload || {}) }); if (!res.ok) await parseError(res, 'Gửi gói đề thất bại'); @@ -1186,6 +1194,45 @@ export const apiExam = { }, }; +export interface MyClassItem { + rkId: number; + name: string; + classCode: string; +} + +export const apiMyClasses = { + list: async (): Promise => { + const res = await staffFetch('/staff/my-classes'); + if (!res.ok) return []; + const json = await res.json() as { data: MyClassItem[] }; + return json.data ?? []; + }, + add: async (classRkId: number): Promise => { + await staffFetch(`/staff/my-classes/${classRkId}`, { method: 'POST' }); + }, + remove: async (classRkId: number): Promise => { + await staffFetch(`/staff/my-classes/${classRkId}`, { method: 'DELETE' }); + }, +}; + +export const apiSeatingLayout = { + get: async (classRkId: number): Promise => { + const res = await staffFetch(`/classes/${classRkId}/seating-layout`); + if (!res.ok) return null; + const json = await res.json() as { layoutJson: string | null }; + return json.layoutJson ?? null; + }, + save: async (classRkId: number, layoutJson: string): Promise => { + await staffFetch(`/classes/${classRkId}/seating-layout`, { + method: 'PUT', + body: JSON.stringify({ layoutJson }), + }); + }, + delete: async (classRkId: number): Promise => { + await staffFetch(`/classes/${classRkId}/seating-layout`, { method: 'DELETE' }); + }, +}; + export const apiQldt = { getToken: async (): Promise<{ token: string }> => { const res = await staffFetch('/qldt-token'); diff --git a/management/src/components/ExamRoomWorkspace.tsx b/management/src/components/ExamRoomWorkspace.tsx index 1d5d426..d5212ed 100644 --- a/management/src/components/ExamRoomWorkspace.tsx +++ b/management/src/components/ExamRoomWorkspace.tsx @@ -325,8 +325,62 @@ export const ExamRoomWorkspace: React.FC = ({ examId, onBack }) => { }; const assignRandomPackages = async () => { - const res = await apiExam.assignRandom(examId); - setMsg(`Đã chia ${res.assigned} sinh viên vào ${res.packages} gói đề`); + if (papers.length === 0) return; + + // Đọc sơ đồ chỗ ngồi từ localStorage (exam rooms vẫn lưu local) + let seatingSeats: Record | null = null; + try { + const raw = localStorage.getItem(`sc_seating_layout_exam-${examId}`); + if (raw) { + const parsed = JSON.parse(raw) as { seats?: Record }; + if (parsed.seats && Object.keys(parsed.seats).length > 0) { + seatingSeats = parsed.seats; + } + } + } catch { /* ignore */ } + + if (!seatingSeats) { + // Không có sơ đồ → fallback random server-side + const res = await apiExam.assignRandom(examId); + setMsg(`Đã chia ${res.assigned} sinh viên vào ${res.packages} gói đề`); + await load(); + return; + } + + // Nhóm sinh viên theo hàng: row → [(col, studentRkId)] tăng dần theo cột + const rowMap = new Map(); + Object.entries(seatingSeats).forEach(([key, rkId]) => { + const [r, c] = key.split(',').map(Number); + if (!rowMap.has(r)) rowMap.set(r, []); + rowMap.get(r)!.push({ col: c, rkId }); + }); + rowMap.forEach((arr) => arr.sort((a, b) => a.col - b.col)); + + const numPapers = papers.length; + const assignments: { studentRkId: number; paperId: number }[] = []; + const assignedIds = new Set(); + + // Chia đề theo hàng ngang: mỗi hàng có random offset → cạnh nhau không trùng + [...rowMap.keys()].sort((a, b) => a - b).forEach((row) => { + const seatsInRow = rowMap.get(row)!; + const offset = Math.floor(Math.random() * numPapers); + seatsInRow.forEach(({ rkId }, relIdx) => { + const paper = papers[(offset + relIdx) % numPapers]; + assignments.push({ studentRkId: rkId, paperId: paper.id }); + assignedIds.add(rkId); + }); + }); + + // Sinh viên chưa có chỗ ngồi → chia tuần tự ngẫu nhiên + const unseated = students + .filter((s) => !assignedIds.has(s.studentRkId)) + .sort(() => Math.random() - 0.5); + unseated.forEach((s, i) => { + assignments.push({ studentRkId: s.studentRkId, paperId: papers[i % numPapers].id }); + }); + + const res = await apiExam.assignPapersBatch(examId, assignments); + setMsg(`Đã chia ${res.assigned} sinh viên vào ${numPapers} gói đề (theo sơ đồ chỗ ngồi)`); await load(); }; diff --git a/management/src/components/LearningTab.tsx b/management/src/components/LearningTab.tsx index 41f088c..2a483df 100644 --- a/management/src/components/LearningTab.tsx +++ b/management/src/components/LearningTab.tsx @@ -8,6 +8,7 @@ import { apiFetchClassSessionLogs, apiFetchClasses, apiFetchScheduleConflicts, + apiMyClasses, } from '../api'; import { DEFAULT_ALLOWED_APPS } from '../constants'; import type { @@ -40,32 +41,29 @@ export const LearningTab: React.FC = () => { const [dayFilter, setDayFilter] = useState(null); const [viewMode, setViewMode] = useState('list'); const [onlyMyClasses, setOnlyMyClasses] = useState(false); - const [myClasses, setMyClasses] = useState(() => { - try { - const stored = localStorage.getItem('sc_my_classes_details'); - const list = stored ? JSON.parse(stored) : []; - return Array.isArray(list) ? list.map((item: any) => item.id) : []; - } catch { - return []; - } - }); + const [myClasses, setMyClasses] = useState([]); - const toggleMyClass = (classId: number, name: string, code: string) => { - let list: any[] = []; - try { - const stored = localStorage.getItem('sc_my_classes_details'); - list = stored ? JSON.parse(stored) : []; - } catch (e) {} + const refreshMyClasses = () => { + apiMyClasses.list().then((items) => { + setMyClasses(items.map((c) => c.rkId)); + }).catch(() => {}); + }; - const exists = list.some(item => item.id === classId); - let nextList; - if (exists) { - nextList = list.filter(item => item.id !== classId); + useEffect(() => { + refreshMyClasses(); + window.addEventListener('my-classes-updated', refreshMyClasses); + return () => window.removeEventListener('my-classes-updated', refreshMyClasses); + }, []); + + const toggleMyClass = (classId: number, _name: string, _code: string) => { + const isMyClass = myClasses.includes(classId); + if (isMyClass) { + setMyClasses((prev) => prev.filter((id) => id !== classId)); + apiMyClasses.remove(classId).catch(() => {}); } else { - nextList = [...list, { id: classId, name, code }]; + setMyClasses((prev) => [...prev, classId]); + apiMyClasses.add(classId).catch(() => {}); } - localStorage.setItem('sc_my_classes_details', JSON.stringify(nextList)); - setMyClasses(nextList.map(item => item.id)); window.dispatchEvent(new Event('my-classes-updated')); }; diff --git a/management/src/components/WorkspaceSeatingChart.tsx b/management/src/components/WorkspaceSeatingChart.tsx index 282a35d..06e14f7 100644 --- a/management/src/components/WorkspaceSeatingChart.tsx +++ b/management/src/components/WorkspaceSeatingChart.tsx @@ -1,6 +1,7 @@ -import React, { useEffect, useState, useMemo } from 'react'; +import React, { useEffect, useState, useMemo, useRef } from 'react'; import type { SeatingTemplate } from './SeatingTemplatesSection'; import { StudentAvatar, StudentOnlineBadge } from './StudentAvatar'; +import { apiSeatingLayout } from '../api'; interface WorkspaceSeatingChartProps { workspaceId: string; // 'class-' or 'exam-' @@ -42,6 +43,9 @@ export const WorkspaceSeatingChart: React.FC = ({ // Edit mode for structural changes const [isEditingStructure, setIsEditingStructure] = useState(false); + const [editRows, setEditRows] = useState(5); + const [editCols, setEditCols] = useState(6); + const [editBoardPos, setEditBoardPos] = useState<'top' | 'bottom' | 'left' | 'right'>('top'); const [dragOverCell, setDragOverCell] = useState(null); const [saveNotice, setSaveNotice] = useState(null); @@ -49,6 +53,17 @@ export const WorkspaceSeatingChart: React.FC = ({ const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid'); const [listSearchQuery, setListSearchQuery] = useState(''); + // Detect if this is a class workspace (vs exam) to use API storage + const classRkId = useMemo(() => { + if (workspaceId.startsWith('class-')) { + const n = Number(workspaceId.replace('class-', '')); + return isNaN(n) ? null : n; + } + return null; + }, [workspaceId]); + + const saveTimerRef = useRef | null>(null); + // Reverse map seat positions for list view const studentSeats = useMemo(() => { if (!layout) return {} as Record; @@ -78,7 +93,7 @@ export const WorkspaceSeatingChart: React.FC = ({ // Load templates and layout useEffect(() => { - // Load templates + // Load templates (always from localStorage — shared across all rooms) const storedTemplates = localStorage.getItem('sc_seating_templates'); if (storedTemplates) { try { @@ -88,27 +103,58 @@ export const WorkspaceSeatingChart: React.FC = ({ } } - // Load layout for this room - const storedLayout = localStorage.getItem(`sc_seating_layout_${workspaceId}`); - if (storedLayout) { - try { - setLayout(JSON.parse(storedLayout)); - } catch { - setLayout(null); + if (classRkId !== null) { + // Load layout từ server theo lớp + apiSeatingLayout.get(classRkId).then((json) => { + if (json) { + try { + setLayout(JSON.parse(json)); + } catch { + setLayout(null); + } + } else { + setLayout(null); + } + }).catch(() => setLayout(null)); + } else { + // Exam rooms — vẫn dùng localStorage + const storedLayout = localStorage.getItem(`sc_seating_layout_${workspaceId}`); + if (storedLayout) { + try { + setLayout(JSON.parse(storedLayout)); + } catch { + setLayout(null); + } } } - }, [workspaceId]); + }, [workspaceId, classRkId]); - // Save layout + // Save layout — debounce API calls, fallback to localStorage for exam rooms const saveLayout = (newLayout: SeatingLayout | null) => { - if (newLayout) { - localStorage.setItem(`sc_seating_layout_${workspaceId}`, JSON.stringify(newLayout)); - setSaveNotice('Đã lưu thay đổi sơ đồ chỗ ngồi!'); - } else { - localStorage.removeItem(`sc_seating_layout_${workspaceId}`); - setSaveNotice('Đã xóa sơ đồ chỗ ngồi!'); - } setLayout(newLayout); + + if (classRkId !== null) { + // Debounce để tránh gọi API quá nhiều khi kéo thả nhanh + if (saveTimerRef.current) clearTimeout(saveTimerRef.current); + saveTimerRef.current = setTimeout(() => { + if (newLayout) { + apiSeatingLayout.save(classRkId, JSON.stringify(newLayout)).catch(() => {}); + setSaveNotice('Đã lưu sơ đồ chỗ ngồi!'); + } else { + apiSeatingLayout.delete(classRkId).catch(() => {}); + setSaveNotice('Đã xóa sơ đồ chỗ ngồi!'); + } + }, 600); + } else { + // Exam rooms — localStorage + if (newLayout) { + localStorage.setItem(`sc_seating_layout_${workspaceId}`, JSON.stringify(newLayout)); + setSaveNotice('Đã lưu thay đổi sơ đồ chỗ ngồi!'); + } else { + localStorage.removeItem(`sc_seating_layout_${workspaceId}`); + setSaveNotice('Đã xóa sơ đồ chỗ ngồi!'); + } + } }; useEffect(() => { @@ -240,11 +286,11 @@ export const WorkspaceSeatingChart: React.FC = ({ saveLayout({ ...layout, seats: newSeats }); }; - // Auto assign random + // Auto assign: điền theo hàng ngang (row-major), sinh viên xếp ngẫu nhiên const handleAutoAssign = () => { if (!layout) return; - // Find all empty, unlocked cells + // Collect empty unlocked cells in row-major order (left→right, top→bottom) const emptyCells: string[] = []; for (let r = 0; r < layout.rows; r++) { for (let c = 0; c < layout.cols; c++) { @@ -259,19 +305,45 @@ export const WorkspaceSeatingChart: React.FC = ({ alert(`Không đủ chỗ ngồi cho tất cả học viên! Số học viên chưa có chỗ: ${unseatedStudents.length}, Số chỗ trống khả dụng: ${emptyCells.length}. Thiếu ${unseatedStudents.length - emptyCells.length} chỗ.`); } - // Shuffle empty cells - const shuffledCells = [...emptyCells].sort(() => Math.random() - 0.5); - + // Shuffle only students, cells keep row-major order + const shuffledStudents = [...unseatedStudents].sort(() => Math.random() - 0.5); const newSeats = { ...layout.seats }; - const assignCount = Math.min(unseatedStudents.length, shuffledCells.length); + const assignCount = Math.min(shuffledStudents.length, emptyCells.length); for (let i = 0; i < assignCount; i++) { - newSeats[shuffledCells[i]] = unseatedStudents[i].rkId; + newSeats[emptyCells[i]] = shuffledStudents[i].rkId; } saveLayout({ ...layout, seats: newSeats }); }; + // Resize/update layout structure (giữ nguyên chỗ ngồi hợp lệ) + const handleApplyResize = () => { + if (!layout) return; + const newRows = Math.max(1, Math.min(editRows, 15)); + const newCols = Math.max(1, Math.min(editCols, 20)); + + const newSeats: Record = {}; + const newLocked: string[] = []; + + Object.entries(layout.seats).forEach(([key, rkId]) => { + const [r, c] = key.split(',').map(Number); + if (r < newRows && c < newCols) newSeats[key] = rkId; + }); + layout.lockedSeats.forEach((key) => { + const [r, c] = key.split(',').map(Number); + if (r < newRows && c < newCols) newLocked.push(key); + }); + + saveLayout({ + rows: newRows, + cols: newCols, + boardPosition: editBoardPos, + lockedSeats: newLocked, + seats: newSeats, + }); + }; + // Clear all seats const handleClearAllSeats = () => { if (!layout) return; @@ -575,14 +647,21 @@ export const WorkspaceSeatingChart: React.FC = ({
+
)} {renderSeatingGrid()} diff --git a/server/internal/db/db.go b/server/internal/db/db.go index 1e85df9..0f142af 100644 --- a/server/internal/db/db.go +++ b/server/internal/db/db.go @@ -48,6 +48,8 @@ func AutoMigrate(db *gorm.DB) error { &models.ClassSchedule{}, &models.ClassCourse{}, &models.ClassAllowedApp{}, + &models.ClassSeatingLayout{}, + &models.StaffMyClass{}, &models.AppPoolEntry{}, &models.AppTemplate{}, &models.WifiPoolEntry{}, diff --git a/server/internal/handlers/handlers_exam.go b/server/internal/handlers/handlers_exam.go index f27e6c8..dfa4600 100644 --- a/server/internal/handlers/handlers_exam.go +++ b/server/internal/handlers/handlers_exam.go @@ -811,6 +811,43 @@ func RandomAssignExamPapersHandler(db *gorm.DB) fiber.Handler { } } +// POST /api/exam-rooms/:id/assign-papers-batch — chia đề theo sơ đồ chỗ ngồi (frontend tính toán) +func AssignExamPapersBatchHandler(db *gorm.DB) fiber.Handler { + return func(c *fiber.Ctx) error { + roomID, err := parseUintParam(c, "id") + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "Invalid id"}) + } + var room models.ExamRoom + if err := db.First(&room, roomID).Error; err != nil { + return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy phòng"}) + } + if !canModifyExamPapers(db, room) { + return c.Status(403).JSON(fiber.Map{"error": "Không thể chia gói đề khi đề đã gửi hoặc phòng thi đã kết thúc"}) + } + var req struct { + Assignments []struct { + StudentRkID int64 `json:"studentRkId"` + PaperID uint `json:"paperId"` + } `json:"assignments"` + } + if err := c.BodyParser(&req); err != nil || len(req.Assignments) == 0 { + return c.Status(400).JSON(fiber.Map{"error": "assignments rỗng hoặc không hợp lệ"}) + } + count := 0 + for _, a := range req.Assignments { + pid := a.PaperID + res := db.Model(&models.ExamRoomStudent{}). + Where("exam_room_id = ? AND student_rk_id = ?", roomID, a.StudentRkID). + Update("assigned_paper_id", pid) + if res.RowsAffected > 0 { + count++ + } + } + return c.JSON(fiber.Map{"assigned": count}) + } +} + // POST /api/exam-rooms/:id/send-papers func SendExamPapersHandler(db *gorm.DB) fiber.Handler { return func(c *fiber.Ctx) error { diff --git a/server/internal/handlers/handlers_seating.go b/server/internal/handlers/handlers_seating.go new file mode 100644 index 0000000..22abc68 --- /dev/null +++ b/server/internal/handlers/handlers_seating.go @@ -0,0 +1,125 @@ +package handlers + +import ( + "errors" + "strconv" + "time" + + "server/internal/middleware" + "server/internal/models" + + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// GET /classes/:rkId/seating-layout +func GetClassSeatingLayoutHandler(db *gorm.DB) fiber.Handler { + return func(c *fiber.Ctx) error { + classRkID, err := strconv.ParseInt(c.Params("rkId"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid class ID"}) + } + var layout models.ClassSeatingLayout + if err := db.Where("class_rk_id = ?", classRkID).First(&layout).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return c.JSON(fiber.Map{"layoutJson": nil}) + } + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"layoutJson": layout.LayoutJSON}) + } +} + +// PUT /classes/:rkId/seating-layout +func PutClassSeatingLayoutHandler(db *gorm.DB) fiber.Handler { + return func(c *fiber.Ctx) error { + classRkID, err := strconv.ParseInt(c.Params("rkId"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid class ID"}) + } + var body struct { + LayoutJSON string `json:"layoutJson"` + } + if err := c.BodyParser(&body); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid body"}) + } + if body.LayoutJSON == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "layoutJson is required"}) + } + layout := models.ClassSeatingLayout{ + ClassRkID: classRkID, + LayoutJSON: body.LayoutJSON, + UpdatedAt: time.Now(), + } + result := db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "class_rk_id"}}, + DoUpdates: clause.AssignmentColumns([]string{"layout_json", "updated_at"}), + }).Create(&layout) + if result.Error != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": result.Error.Error()}) + } + return c.JSON(fiber.Map{"ok": true}) + } +} + +// DELETE /classes/:rkId/seating-layout +func DeleteClassSeatingLayoutHandler(db *gorm.DB) fiber.Handler { + return func(c *fiber.Ctx) error { + classRkID, err := strconv.ParseInt(c.Params("rkId"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid class ID"}) + } + db.Where("class_rk_id = ?", classRkID).Delete(&models.ClassSeatingLayout{}) + return c.JSON(fiber.Map{"ok": true}) + } +} + +// GET /staff/my-classes +func GetMyClassesHandler(db *gorm.DB) fiber.Handler { + return func(c *fiber.Ctx) error { + staffID := middleware.StaffIDFromCtx(c) + type classRow struct { + RkID int64 `json:"rkId"` + Name string `json:"name"` + ClassCode string `json:"classCode"` + } + var rows []classRow + db.Table("staff_my_classes"). + Select("classes.rk_id, classes.name, classes.class_code"). + Joins("JOIN classes ON classes.rk_id = staff_my_classes.class_rk_id"). + Where("staff_my_classes.staff_id = ?", staffID). + Scan(&rows) + if rows == nil { + rows = []classRow{} + } + return c.JSON(fiber.Map{"data": rows}) + } +} + +// POST /staff/my-classes/:classRkId +func AddMyClassHandler(db *gorm.DB) fiber.Handler { + return func(c *fiber.Ctx) error { + staffID := middleware.StaffIDFromCtx(c) + classRkID, err := strconv.ParseInt(c.Params("classRkId"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid class ID"}) + } + rec := models.StaffMyClass{StaffID: staffID, ClassRkID: classRkID} + db.Clauses(clause.OnConflict{DoNothing: true}).Create(&rec) + return c.JSON(fiber.Map{"ok": true}) + } +} + +// DELETE /staff/my-classes/:classRkId +func RemoveMyClassHandler(db *gorm.DB) fiber.Handler { + return func(c *fiber.Ctx) error { + staffID := middleware.StaffIDFromCtx(c) + classRkID, err := strconv.ParseInt(c.Params("classRkId"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid class ID"}) + } + db.Where("staff_id = ? AND class_rk_id = ?", staffID, classRkID).Delete(&models.StaffMyClass{}) + return c.JSON(fiber.Map{"ok": true}) + } +} diff --git a/server/internal/models/models.go b/server/internal/models/models.go index c78ffc0..24decb9 100644 --- a/server/internal/models/models.go +++ b/server/internal/models/models.go @@ -97,6 +97,15 @@ type AttendanceResult struct { func (AttendanceResult) TableName() string { return "attendance_results" } +// ClassSeatingLayout lưu sơ đồ chỗ ngồi theo lớp +type ClassSeatingLayout struct { + ClassRkID int64 `gorm:"column:class_rk_id;primaryKey;not null" json:"classRkId"` + LayoutJSON string `gorm:"column:layout_json;type:longtext;not null" json:"layoutJson"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func (ClassSeatingLayout) TableName() string { return "class_seating_layouts" } + // ClassAllowedApp lưu từ khóa app được mở của lớp type ClassAllowedApp struct { ClassRkID int64 `gorm:"column:class_rk_id;primaryKey;not null" json:"classRkId"` diff --git a/server/internal/models/staff.go b/server/internal/models/staff.go index 3a5c2c6..1ac27e6 100644 --- a/server/internal/models/staff.go +++ b/server/internal/models/staff.go @@ -38,6 +38,14 @@ type PasswordResetToken struct { func (PasswordResetToken) TableName() string { return "password_reset_tokens" } +// StaffMyClass lưu danh sách lớp học cá nhân của giáo viên +type StaffMyClass struct { + StaffID uint `gorm:"column:staff_id;primaryKey;not null;index" json:"staffId"` + ClassRkID int64 `gorm:"column:class_rk_id;primaryKey;not null" json:"classRkId"` +} + +func (StaffMyClass) TableName() string { return "staff_my_classes" } + // ChatMessage — tin nhắn thầy cô ↔ sinh viên type ChatMessage struct { ID uint `gorm:"primaryKey" json:"id"` diff --git a/server/main.go b/server/main.go index e0efae4..02c6cb9 100644 --- a/server/main.go +++ b/server/main.go @@ -130,6 +130,14 @@ func main() { staff.Get("/classes/:rkId", handlers.GetClassHandler(gormDB)) staff.Patch("/classes/:rkId/studying", handlers.PatchClassStudyingHandler(gormDB)) staff.Get("/classes/:rkId/students", handlers.ListClassStudentsHandler(gormDB)) + staff.Get("/classes/:rkId/seating-layout", handlers.GetClassSeatingLayoutHandler(gormDB)) + staff.Put("/classes/:rkId/seating-layout", handlers.PutClassSeatingLayoutHandler(gormDB)) + staff.Delete("/classes/:rkId/seating-layout", handlers.DeleteClassSeatingLayoutHandler(gormDB)) + + // My classes (lớp cá nhân theo tài khoản) + staff.Get("/staff/my-classes", handlers.GetMyClassesHandler(gormDB)) + staff.Post("/staff/my-classes/:classRkId", handlers.AddMyClassHandler(gormDB)) + staff.Delete("/staff/my-classes/:classRkId", handlers.RemoveMyClassHandler(gormDB)) // Students endpoints staff.Get("/students", handlers.ListAllStudentsHandler(gormDB)) @@ -204,6 +212,7 @@ func main() { staff.Post("/exam-rooms/:id/unpublish", handlers.UnpublishExamRoomHandler(gormDB)) staff.Post("/exam-rooms/:id/cancel", handlers.CancelExamRoomHandler(gormDB)) staff.Post("/exam-rooms/:id/assign-random", handlers.RandomAssignExamPapersHandler(gormDB)) + staff.Post("/exam-rooms/:id/assign-papers-batch", handlers.AssignExamPapersBatchHandler(gormDB)) staff.Post("/exam-rooms/:id/send-papers", handlers.SendExamPapersHandler(gormDB)) staff.Get("/exam-rooms/:id/submissions", handlers.ListExamSubmissionsHandler(gormDB)) staff.Get("/exam-rooms/:id/submissions/download-all", handlers.DownloadAllExamSubmissionsHandler(gormDB))