From bad51585db21d9cfb701ae50ad9465167f05c524 Mon Sep 17 00:00:00 2001 From: PhuocNTB Date: Mon, 13 Jul 2026 11:00:17 +0700 Subject: [PATCH] fix --- management/src/api.ts | 21 +++++- .../src/components/ExamRoomWorkspace.tsx | 9 +-- .../src/components/WorkspaceSeatingChart.tsx | 70 ++++++++----------- server/internal/db/db.go | 1 + server/internal/handlers/handlers_seating.go | 59 ++++++++++++++++ server/internal/models/models.go | 9 +++ server/main.go | 3 + 7 files changed, 125 insertions(+), 47 deletions(-) diff --git a/management/src/api.ts b/management/src/api.ts index 818c7d3..46a7263 100644 --- a/management/src/api.ts +++ b/management/src/api.ts @@ -1216,21 +1216,36 @@ export const apiMyClasses = { }; export const apiSeatingLayout = { - get: async (classRkId: number): Promise => { + getClass: 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 => { + saveClass: async (classRkId: number, layoutJson: string): Promise => { await staffFetch(`/classes/${classRkId}/seating-layout`, { method: 'PUT', body: JSON.stringify({ layoutJson }), }); }, - delete: async (classRkId: number): Promise => { + deleteClass: async (classRkId: number): Promise => { await staffFetch(`/classes/${classRkId}/seating-layout`, { method: 'DELETE' }); }, + getExam: async (examRoomId: number): Promise => { + const res = await staffFetch(`/exam-rooms/${examRoomId}/seating-layout`); + if (!res.ok) return null; + const json = await res.json() as { layoutJson: string | null }; + return json.layoutJson ?? null; + }, + saveExam: async (examRoomId: number, layoutJson: string): Promise => { + await staffFetch(`/exam-rooms/${examRoomId}/seating-layout`, { + method: 'PUT', + body: JSON.stringify({ layoutJson }), + }); + }, + deleteExam: async (examRoomId: number): Promise => { + await staffFetch(`/exam-rooms/${examRoomId}/seating-layout`, { method: 'DELETE' }); + }, }; export const apiQldt = { diff --git a/management/src/components/ExamRoomWorkspace.tsx b/management/src/components/ExamRoomWorkspace.tsx index d5212ed..4395684 100644 --- a/management/src/components/ExamRoomWorkspace.tsx +++ b/management/src/components/ExamRoomWorkspace.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { apiExam, + apiSeatingLayout, staffFetch, type ExamPaper, type ExamRoomStudent, @@ -327,12 +328,12 @@ export const ExamRoomWorkspace: React.FC = ({ examId, onBack }) => { const assignRandomPackages = async () => { if (papers.length === 0) return; - // Đọc sơ đồ chỗ ngồi từ localStorage (exam rooms vẫn lưu local) + // Đọc sơ đồ chỗ ngồi từ server 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 }; + const json = await apiSeatingLayout.getExam(examId); + if (json) { + const parsed = JSON.parse(json) as { seats?: Record }; if (parsed.seats && Object.keys(parsed.seats).length > 0) { seatingSeats = parsed.seats; } diff --git a/management/src/components/WorkspaceSeatingChart.tsx b/management/src/components/WorkspaceSeatingChart.tsx index 06e14f7..9816354 100644 --- a/management/src/components/WorkspaceSeatingChart.tsx +++ b/management/src/components/WorkspaceSeatingChart.tsx @@ -53,11 +53,15 @@ 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(() => { + // Parse workspace type and ID for API storage + const workspaceInfo = useMemo(() => { if (workspaceId.startsWith('class-')) { const n = Number(workspaceId.replace('class-', '')); - return isNaN(n) ? null : n; + return isNaN(n) ? null : { kind: 'class' as const, id: n }; + } + if (workspaceId.startsWith('exam-')) { + const n = Number(workspaceId.replace('exam-', '')); + return isNaN(n) ? null : { kind: 'exam' as const, id: n }; } return null; }, [workspaceId]); @@ -103,58 +107,44 @@ export const WorkspaceSeatingChart: React.FC = ({ } } - if (classRkId !== null) { - // Load layout từ server theo lớp - apiSeatingLayout.get(classRkId).then((json) => { + if (workspaceInfo !== null) { + const getter = workspaceInfo.kind === 'class' + ? apiSeatingLayout.getClass(workspaceInfo.id) + : apiSeatingLayout.getExam(workspaceInfo.id); + getter.then((json) => { if (json) { - try { - setLayout(JSON.parse(json)); - } catch { - setLayout(null); - } + 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); - } - } + setLayout(null); } - }, [workspaceId, classRkId]); + }, [workspaceId, workspaceInfo]); - // Save layout — debounce API calls, fallback to localStorage for exam rooms + // Save layout — debounce để tránh gọi API quá nhiều khi kéo thả nhanh const saveLayout = (newLayout: SeatingLayout | null) => { setLayout(newLayout); + if (!workspaceInfo) return; - 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 (saveTimerRef.current) clearTimeout(saveTimerRef.current); + saveTimerRef.current = setTimeout(() => { if (newLayout) { - localStorage.setItem(`sc_seating_layout_${workspaceId}`, JSON.stringify(newLayout)); - setSaveNotice('Đã lưu thay đổi sơ đồ chỗ ngồi!'); + const json = JSON.stringify(newLayout); + const saver = workspaceInfo.kind === 'class' + ? apiSeatingLayout.saveClass(workspaceInfo.id, json) + : apiSeatingLayout.saveExam(workspaceInfo.id, json); + saver.catch(() => {}); + setSaveNotice('Đã lưu sơ đồ chỗ ngồi!'); } else { - localStorage.removeItem(`sc_seating_layout_${workspaceId}`); + const deleter = workspaceInfo.kind === 'class' + ? apiSeatingLayout.deleteClass(workspaceInfo.id) + : apiSeatingLayout.deleteExam(workspaceInfo.id); + deleter.catch(() => {}); setSaveNotice('Đã xóa sơ đồ chỗ ngồi!'); } - } + }, 600); }; useEffect(() => { diff --git a/server/internal/db/db.go b/server/internal/db/db.go index 0f142af..bafeef8 100644 --- a/server/internal/db/db.go +++ b/server/internal/db/db.go @@ -49,6 +49,7 @@ func AutoMigrate(db *gorm.DB) error { &models.ClassCourse{}, &models.ClassAllowedApp{}, &models.ClassSeatingLayout{}, + &models.ExamSeatingLayout{}, &models.StaffMyClass{}, &models.AppPoolEntry{}, &models.AppTemplate{}, diff --git a/server/internal/handlers/handlers_seating.go b/server/internal/handlers/handlers_seating.go index 22abc68..cd6e08b 100644 --- a/server/internal/handlers/handlers_seating.go +++ b/server/internal/handlers/handlers_seating.go @@ -75,6 +75,65 @@ func DeleteClassSeatingLayoutHandler(db *gorm.DB) fiber.Handler { } } +// GET /exam-rooms/:id/seating-layout +func GetExamSeatingLayoutHandler(db *gorm.DB) fiber.Handler { + return func(c *fiber.Ctx) error { + examID, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid exam room ID"}) + } + var layout models.ExamSeatingLayout + if err := db.Where("exam_room_id = ?", examID).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 /exam-rooms/:id/seating-layout +func PutExamSeatingLayoutHandler(db *gorm.DB) fiber.Handler { + return func(c *fiber.Ctx) error { + examID, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid exam room ID"}) + } + var body struct { + LayoutJSON string `json:"layoutJson"` + } + if err := c.BodyParser(&body); err != nil || body.LayoutJSON == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "layoutJson is required"}) + } + layout := models.ExamSeatingLayout{ + ExamRoomID: uint(examID), + LayoutJSON: body.LayoutJSON, + UpdatedAt: time.Now(), + } + result := db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "exam_room_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 /exam-rooms/:id/seating-layout +func DeleteExamSeatingLayoutHandler(db *gorm.DB) fiber.Handler { + return func(c *fiber.Ctx) error { + examID, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid exam room ID"}) + } + db.Where("exam_room_id = ?", examID).Delete(&models.ExamSeatingLayout{}) + return c.JSON(fiber.Map{"ok": true}) + } +} + // GET /staff/my-classes func GetMyClassesHandler(db *gorm.DB) fiber.Handler { return func(c *fiber.Ctx) error { diff --git a/server/internal/models/models.go b/server/internal/models/models.go index 24decb9..2869225 100644 --- a/server/internal/models/models.go +++ b/server/internal/models/models.go @@ -106,6 +106,15 @@ type ClassSeatingLayout struct { func (ClassSeatingLayout) TableName() string { return "class_seating_layouts" } +// ExamSeatingLayout lưu sơ đồ chỗ ngồi theo phòng thi +type ExamSeatingLayout struct { + ExamRoomID uint `gorm:"column:exam_room_id;primaryKey;not null" json:"examRoomId"` + LayoutJSON string `gorm:"column:layout_json;type:longtext;not null" json:"layoutJson"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func (ExamSeatingLayout) TableName() string { return "exam_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/main.go b/server/main.go index 02c6cb9..2c57bca 100644 --- a/server/main.go +++ b/server/main.go @@ -213,6 +213,9 @@ func main() { 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.Get("/exam-rooms/:id/seating-layout", handlers.GetExamSeatingLayoutHandler(gormDB)) + staff.Put("/exam-rooms/:id/seating-layout", handlers.PutExamSeatingLayoutHandler(gormDB)) + staff.Delete("/exam-rooms/:id/seating-layout", handlers.DeleteExamSeatingLayoutHandler(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))