This commit is contained in:
@@ -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 { DashboardTab } from './components/DashboardTab';
|
||||||
import { ClassesTab } from './components/ClassesTab';
|
import { ClassesTab } from './components/ClassesTab';
|
||||||
import { StudentsTab } from './components/StudentsTab';
|
import { StudentsTab } from './components/StudentsTab';
|
||||||
@@ -117,25 +118,22 @@ function App() {
|
|||||||
setSidebarCollapsed(nextVal);
|
setSidebarCollapsed(nextVal);
|
||||||
localStorage.setItem('sc_sidebar_collapsed', String(nextVal));
|
localStorage.setItem('sc_sidebar_collapsed', String(nextVal));
|
||||||
};
|
};
|
||||||
const [myClasses, setMyClasses] = useState<{ id: number; name: string; code: string }[]>(() => {
|
const [myClasses, setMyClasses] = useState<{ id: number; name: string; code: string }[]>([]);
|
||||||
try {
|
|
||||||
const stored = localStorage.getItem('sc_my_classes_details');
|
const refreshMyClasses = useCallback(() => {
|
||||||
return stored ? JSON.parse(stored) : [];
|
apiMyClasses.list().then((items) => {
|
||||||
} catch {
|
setMyClasses(items.map((c) => ({ id: c.rkId, name: c.name, code: c.classCode })));
|
||||||
return [];
|
}).catch(() => {});
|
||||||
}
|
}, []);
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleUpdate = () => {
|
refreshMyClasses();
|
||||||
try {
|
}, [refreshMyClasses]);
|
||||||
const stored = localStorage.getItem('sc_my_classes_details');
|
|
||||||
setMyClasses(stored ? JSON.parse(stored) : []);
|
useEffect(() => {
|
||||||
} catch (e) { }
|
window.addEventListener('my-classes-updated', refreshMyClasses);
|
||||||
};
|
return () => window.removeEventListener('my-classes-updated', refreshMyClasses);
|
||||||
window.addEventListener('my-classes-updated', handleUpdate);
|
}, [refreshMyClasses]);
|
||||||
return () => window.removeEventListener('my-classes-updated', handleUpdate);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const initial = parseRoute();
|
const initial = parseRoute();
|
||||||
|
|||||||
@@ -1124,6 +1124,14 @@ export const apiExam = {
|
|||||||
if (!res.ok) await parseError(res, 'Chia gói đề thất bại');
|
if (!res.ok) await parseError(res, 'Chia gói đề thất bại');
|
||||||
return res.json() as Promise<{ assigned: number; packages: number }>;
|
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[] }) => {
|
sendPapers: async (id: number, payload?: { scheduledAt?: string; studentRkIds?: number[] }) => {
|
||||||
const res = await staffFetch(`/exam-rooms/${id}/send-papers`, { method: 'POST', body: JSON.stringify(payload || {}) });
|
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');
|
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<MyClassItem[]> => {
|
||||||
|
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<void> => {
|
||||||
|
await staffFetch(`/staff/my-classes/${classRkId}`, { method: 'POST' });
|
||||||
|
},
|
||||||
|
remove: async (classRkId: number): Promise<void> => {
|
||||||
|
await staffFetch(`/staff/my-classes/${classRkId}`, { method: 'DELETE' });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const apiSeatingLayout = {
|
||||||
|
get: async (classRkId: number): Promise<string | null> => {
|
||||||
|
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<void> => {
|
||||||
|
await staffFetch(`/classes/${classRkId}/seating-layout`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ layoutJson }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
delete: async (classRkId: number): Promise<void> => {
|
||||||
|
await staffFetch(`/classes/${classRkId}/seating-layout`, { method: 'DELETE' });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const apiQldt = {
|
export const apiQldt = {
|
||||||
getToken: async (): Promise<{ token: string }> => {
|
getToken: async (): Promise<{ token: string }> => {
|
||||||
const res = await staffFetch('/qldt-token');
|
const res = await staffFetch('/qldt-token');
|
||||||
|
|||||||
@@ -325,8 +325,62 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const assignRandomPackages = async () => {
|
const assignRandomPackages = async () => {
|
||||||
const res = await apiExam.assignRandom(examId);
|
if (papers.length === 0) return;
|
||||||
setMsg(`Đã chia ${res.assigned} sinh viên vào ${res.packages} gói đề`);
|
|
||||||
|
// Đọc sơ đồ chỗ ngồi từ localStorage (exam rooms vẫn lưu local)
|
||||||
|
let seatingSeats: Record<string, number> | null = null;
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(`sc_seating_layout_exam-${examId}`);
|
||||||
|
if (raw) {
|
||||||
|
const parsed = JSON.parse(raw) as { seats?: Record<string, number> };
|
||||||
|
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<number, { col: number; rkId: number }[]>();
|
||||||
|
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<number>();
|
||||||
|
|
||||||
|
// 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();
|
await load();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
apiFetchClassSessionLogs,
|
apiFetchClassSessionLogs,
|
||||||
apiFetchClasses,
|
apiFetchClasses,
|
||||||
apiFetchScheduleConflicts,
|
apiFetchScheduleConflicts,
|
||||||
|
apiMyClasses,
|
||||||
} from '../api';
|
} from '../api';
|
||||||
import { DEFAULT_ALLOWED_APPS } from '../constants';
|
import { DEFAULT_ALLOWED_APPS } from '../constants';
|
||||||
import type {
|
import type {
|
||||||
@@ -40,32 +41,29 @@ export const LearningTab: React.FC = () => {
|
|||||||
const [dayFilter, setDayFilter] = useState<number | null>(null);
|
const [dayFilter, setDayFilter] = useState<number | null>(null);
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||||
const [onlyMyClasses, setOnlyMyClasses] = useState<boolean>(false);
|
const [onlyMyClasses, setOnlyMyClasses] = useState<boolean>(false);
|
||||||
const [myClasses, setMyClasses] = useState<number[]>(() => {
|
const [myClasses, setMyClasses] = useState<number[]>([]);
|
||||||
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 toggleMyClass = (classId: number, name: string, code: string) => {
|
const refreshMyClasses = () => {
|
||||||
let list: any[] = [];
|
apiMyClasses.list().then((items) => {
|
||||||
try {
|
setMyClasses(items.map((c) => c.rkId));
|
||||||
const stored = localStorage.getItem('sc_my_classes_details');
|
}).catch(() => {});
|
||||||
list = stored ? JSON.parse(stored) : [];
|
};
|
||||||
} catch (e) {}
|
|
||||||
|
|
||||||
const exists = list.some(item => item.id === classId);
|
useEffect(() => {
|
||||||
let nextList;
|
refreshMyClasses();
|
||||||
if (exists) {
|
window.addEventListener('my-classes-updated', refreshMyClasses);
|
||||||
nextList = list.filter(item => item.id !== classId);
|
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 {
|
} 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'));
|
window.dispatchEvent(new Event('my-classes-updated'));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 type { SeatingTemplate } from './SeatingTemplatesSection';
|
||||||
import { StudentAvatar, StudentOnlineBadge } from './StudentAvatar';
|
import { StudentAvatar, StudentOnlineBadge } from './StudentAvatar';
|
||||||
|
import { apiSeatingLayout } from '../api';
|
||||||
|
|
||||||
interface WorkspaceSeatingChartProps {
|
interface WorkspaceSeatingChartProps {
|
||||||
workspaceId: string; // 'class-<id>' or 'exam-<id>'
|
workspaceId: string; // 'class-<id>' or 'exam-<id>'
|
||||||
@@ -42,6 +43,9 @@ export const WorkspaceSeatingChart: React.FC<WorkspaceSeatingChartProps> = ({
|
|||||||
|
|
||||||
// Edit mode for structural changes
|
// Edit mode for structural changes
|
||||||
const [isEditingStructure, setIsEditingStructure] = useState(false);
|
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<string | null>(null);
|
const [dragOverCell, setDragOverCell] = useState<string | null>(null);
|
||||||
const [saveNotice, setSaveNotice] = useState<string | null>(null);
|
const [saveNotice, setSaveNotice] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -49,6 +53,17 @@ export const WorkspaceSeatingChart: React.FC<WorkspaceSeatingChartProps> = ({
|
|||||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||||
const [listSearchQuery, setListSearchQuery] = useState('');
|
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<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
// Reverse map seat positions for list view
|
// Reverse map seat positions for list view
|
||||||
const studentSeats = useMemo(() => {
|
const studentSeats = useMemo(() => {
|
||||||
if (!layout) return {} as Record<number, string>;
|
if (!layout) return {} as Record<number, string>;
|
||||||
@@ -78,7 +93,7 @@ export const WorkspaceSeatingChart: React.FC<WorkspaceSeatingChartProps> = ({
|
|||||||
|
|
||||||
// Load templates and layout
|
// Load templates and layout
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Load templates
|
// Load templates (always from localStorage — shared across all rooms)
|
||||||
const storedTemplates = localStorage.getItem('sc_seating_templates');
|
const storedTemplates = localStorage.getItem('sc_seating_templates');
|
||||||
if (storedTemplates) {
|
if (storedTemplates) {
|
||||||
try {
|
try {
|
||||||
@@ -88,27 +103,58 @@ export const WorkspaceSeatingChart: React.FC<WorkspaceSeatingChartProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load layout for this room
|
if (classRkId !== null) {
|
||||||
const storedLayout = localStorage.getItem(`sc_seating_layout_${workspaceId}`);
|
// Load layout từ server theo lớp
|
||||||
if (storedLayout) {
|
apiSeatingLayout.get(classRkId).then((json) => {
|
||||||
try {
|
if (json) {
|
||||||
setLayout(JSON.parse(storedLayout));
|
try {
|
||||||
} catch {
|
setLayout(JSON.parse(json));
|
||||||
setLayout(null);
|
} 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) => {
|
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);
|
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(() => {
|
useEffect(() => {
|
||||||
@@ -240,11 +286,11 @@ export const WorkspaceSeatingChart: React.FC<WorkspaceSeatingChartProps> = ({
|
|||||||
saveLayout({ ...layout, seats: newSeats });
|
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 = () => {
|
const handleAutoAssign = () => {
|
||||||
if (!layout) return;
|
if (!layout) return;
|
||||||
|
|
||||||
// Find all empty, unlocked cells
|
// Collect empty unlocked cells in row-major order (left→right, top→bottom)
|
||||||
const emptyCells: string[] = [];
|
const emptyCells: string[] = [];
|
||||||
for (let r = 0; r < layout.rows; r++) {
|
for (let r = 0; r < layout.rows; r++) {
|
||||||
for (let c = 0; c < layout.cols; c++) {
|
for (let c = 0; c < layout.cols; c++) {
|
||||||
@@ -259,19 +305,45 @@ export const WorkspaceSeatingChart: React.FC<WorkspaceSeatingChartProps> = ({
|
|||||||
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ỗ.`);
|
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
|
// Shuffle only students, cells keep row-major order
|
||||||
const shuffledCells = [...emptyCells].sort(() => Math.random() - 0.5);
|
const shuffledStudents = [...unseatedStudents].sort(() => Math.random() - 0.5);
|
||||||
|
|
||||||
const newSeats = { ...layout.seats };
|
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++) {
|
for (let i = 0; i < assignCount; i++) {
|
||||||
newSeats[shuffledCells[i]] = unseatedStudents[i].rkId;
|
newSeats[emptyCells[i]] = shuffledStudents[i].rkId;
|
||||||
}
|
}
|
||||||
|
|
||||||
saveLayout({ ...layout, seats: newSeats });
|
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<string, number> = {};
|
||||||
|
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
|
// Clear all seats
|
||||||
const handleClearAllSeats = () => {
|
const handleClearAllSeats = () => {
|
||||||
if (!layout) return;
|
if (!layout) return;
|
||||||
@@ -575,14 +647,21 @@ export const WorkspaceSeatingChart: React.FC<WorkspaceSeatingChartProps> = ({
|
|||||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||||
<button
|
<button
|
||||||
className={`btn btn-sm ${isEditingStructure ? 'btn-primary' : 'btn-secondary'}`}
|
className={`btn btn-sm ${isEditingStructure ? 'btn-primary' : 'btn-secondary'}`}
|
||||||
onClick={() => setIsEditingStructure(!isEditingStructure)}
|
onClick={() => {
|
||||||
|
if (!isEditingStructure && layout) {
|
||||||
|
setEditRows(layout.rows);
|
||||||
|
setEditCols(layout.cols);
|
||||||
|
setEditBoardPos(layout.boardPosition);
|
||||||
|
}
|
||||||
|
setIsEditingStructure(!isEditingStructure);
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: isEditingStructure ? 'var(--accent)' : '',
|
backgroundColor: isEditingStructure ? 'var(--accent)' : '',
|
||||||
borderColor: isEditingStructure ? 'var(--accent)' : '',
|
borderColor: isEditingStructure ? 'var(--accent)' : '',
|
||||||
color: isEditingStructure ? '#fff' : '',
|
color: isEditingStructure ? '#fff' : '',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isEditingStructure ? '✔️ Xong thiết lập' : '⚙️ Khóa/Mở ô chỗ'}
|
{isEditingStructure ? '✔️ Xong chỉnh sửa' : '⚙️ Chỉnh sửa sơ đồ'}
|
||||||
</button>
|
</button>
|
||||||
<button className="btn btn-secondary btn-sm text-danger" style={{ borderColor: '#fca5a5' }} onClick={handleResetLayout}>
|
<button className="btn btn-secondary btn-sm text-danger" style={{ borderColor: '#fca5a5' }} onClick={handleResetLayout}>
|
||||||
Reset sơ đồ
|
Reset sơ đồ
|
||||||
@@ -594,8 +673,47 @@ export const WorkspaceSeatingChart: React.FC<WorkspaceSeatingChartProps> = ({
|
|||||||
{viewMode === 'grid' ? (
|
{viewMode === 'grid' ? (
|
||||||
<>
|
<>
|
||||||
{isEditingStructure && (
|
{isEditingStructure && (
|
||||||
<div style={{ backgroundColor: '#fffbeb', border: '1px solid #fef3c7', padding: '0.65rem 1rem', borderRadius: '8px', color: '#b45309', fontSize: '0.8rem', fontWeight: 600 }}>
|
<div style={{ backgroundColor: '#fffbeb', border: '1px solid #fef3c7', padding: '0.75rem 1rem', borderRadius: '8px', display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
|
||||||
💡 Đang ở chế độ chỉnh sửa: Click chuột vào các ô trong sơ đồ để thay đổi khóa/mở các vị trí.
|
<div style={{ color: '#b45309', fontSize: '0.8rem', fontWeight: 600 }}>
|
||||||
|
💡 Chế độ chỉnh sửa: Click vào ô để khóa/mở vị trí. Thay đổi kích thước và vị trí bảng bên dưới.
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<label style={{ fontSize: '0.8rem', fontWeight: 600, color: '#92400e' }}>Hàng:</label>
|
||||||
|
<input
|
||||||
|
type="number" min={1} max={15}
|
||||||
|
value={editRows}
|
||||||
|
onChange={(e) => setEditRows(Math.max(1, Math.min(15, Number(e.target.value))))}
|
||||||
|
className="search-input"
|
||||||
|
style={{ width: '60px', padding: '0.25rem 0.4rem', fontSize: '0.8rem' }}
|
||||||
|
/>
|
||||||
|
<label style={{ fontSize: '0.8rem', fontWeight: 600, color: '#92400e' }}>Cột:</label>
|
||||||
|
<input
|
||||||
|
type="number" min={1} max={20}
|
||||||
|
value={editCols}
|
||||||
|
onChange={(e) => setEditCols(Math.max(1, Math.min(20, Number(e.target.value))))}
|
||||||
|
className="search-input"
|
||||||
|
style={{ width: '60px', padding: '0.25rem 0.4rem', fontSize: '0.8rem' }}
|
||||||
|
/>
|
||||||
|
<label style={{ fontSize: '0.8rem', fontWeight: 600, color: '#92400e' }}>Bảng:</label>
|
||||||
|
<select
|
||||||
|
value={editBoardPos}
|
||||||
|
onChange={(e) => setEditBoardPos(e.target.value as 'top' | 'bottom' | 'left' | 'right')}
|
||||||
|
className="select-filter"
|
||||||
|
style={{ padding: '0.25rem 0.4rem', fontSize: '0.8rem' }}
|
||||||
|
>
|
||||||
|
<option value="top">Top</option>
|
||||||
|
<option value="bottom">Bottom</option>
|
||||||
|
<option value="left">Left</option>
|
||||||
|
<option value="right">Right</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
onClick={handleApplyResize}
|
||||||
|
style={{ fontSize: '0.8rem' }}
|
||||||
|
>
|
||||||
|
Áp dụng thay đổi
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{renderSeatingGrid()}
|
{renderSeatingGrid()}
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ func AutoMigrate(db *gorm.DB) error {
|
|||||||
&models.ClassSchedule{},
|
&models.ClassSchedule{},
|
||||||
&models.ClassCourse{},
|
&models.ClassCourse{},
|
||||||
&models.ClassAllowedApp{},
|
&models.ClassAllowedApp{},
|
||||||
|
&models.ClassSeatingLayout{},
|
||||||
|
&models.StaffMyClass{},
|
||||||
&models.AppPoolEntry{},
|
&models.AppPoolEntry{},
|
||||||
&models.AppTemplate{},
|
&models.AppTemplate{},
|
||||||
&models.WifiPoolEntry{},
|
&models.WifiPoolEntry{},
|
||||||
|
|||||||
@@ -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
|
// POST /api/exam-rooms/:id/send-papers
|
||||||
func SendExamPapersHandler(db *gorm.DB) fiber.Handler {
|
func SendExamPapersHandler(db *gorm.DB) fiber.Handler {
|
||||||
return func(c *fiber.Ctx) error {
|
return func(c *fiber.Ctx) error {
|
||||||
|
|||||||
125
server/internal/handlers/handlers_seating.go
Normal file
125
server/internal/handlers/handlers_seating.go
Normal file
@@ -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})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -97,6 +97,15 @@ type AttendanceResult struct {
|
|||||||
|
|
||||||
func (AttendanceResult) TableName() string { return "attendance_results" }
|
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
|
// ClassAllowedApp lưu từ khóa app được mở của lớp
|
||||||
type ClassAllowedApp struct {
|
type ClassAllowedApp struct {
|
||||||
ClassRkID int64 `gorm:"column:class_rk_id;primaryKey;not null" json:"classRkId"`
|
ClassRkID int64 `gorm:"column:class_rk_id;primaryKey;not null" json:"classRkId"`
|
||||||
|
|||||||
@@ -38,6 +38,14 @@ type PasswordResetToken struct {
|
|||||||
|
|
||||||
func (PasswordResetToken) TableName() string { return "password_reset_tokens" }
|
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
|
// ChatMessage — tin nhắn thầy cô ↔ sinh viên
|
||||||
type ChatMessage struct {
|
type ChatMessage struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
|||||||
@@ -130,6 +130,14 @@ func main() {
|
|||||||
staff.Get("/classes/:rkId", handlers.GetClassHandler(gormDB))
|
staff.Get("/classes/:rkId", handlers.GetClassHandler(gormDB))
|
||||||
staff.Patch("/classes/:rkId/studying", handlers.PatchClassStudyingHandler(gormDB))
|
staff.Patch("/classes/:rkId/studying", handlers.PatchClassStudyingHandler(gormDB))
|
||||||
staff.Get("/classes/:rkId/students", handlers.ListClassStudentsHandler(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
|
// Students endpoints
|
||||||
staff.Get("/students", handlers.ListAllStudentsHandler(gormDB))
|
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/unpublish", handlers.UnpublishExamRoomHandler(gormDB))
|
||||||
staff.Post("/exam-rooms/:id/cancel", handlers.CancelExamRoomHandler(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-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.Post("/exam-rooms/:id/send-papers", handlers.SendExamPapersHandler(gormDB))
|
||||||
staff.Get("/exam-rooms/:id/submissions", handlers.ListExamSubmissionsHandler(gormDB))
|
staff.Get("/exam-rooms/:id/submissions", handlers.ListExamSubmissionsHandler(gormDB))
|
||||||
staff.Get("/exam-rooms/:id/submissions/download-all", handlers.DownloadAllExamSubmissionsHandler(gormDB))
|
staff.Get("/exam-rooms/:id/submissions/download-all", handlers.DownloadAllExamSubmissionsHandler(gormDB))
|
||||||
|
|||||||
Reference in New Issue
Block a user