fix random
All checks were successful
Deploy on Master Change / deploy (push) Successful in 1m17s

This commit is contained in:
2026-07-13 10:29:34 +07:00
parent 0583c7869a
commit 06f2edb195
11 changed files with 476 additions and 71 deletions

View File

@@ -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();

View File

@@ -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<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 = {
getToken: async (): Promise<{ token: string }> => {
const res = await staffFetch('/qldt-token');

View File

@@ -325,8 +325,62 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ 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<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();
};

View File

@@ -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<number | null>(null);
const [viewMode, setViewMode] = useState<ViewMode>('list');
const [onlyMyClasses, setOnlyMyClasses] = useState<boolean>(false);
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 [myClasses, setMyClasses] = useState<number[]>([]);
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'));
};

View File

@@ -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-<id>' or 'exam-<id>'
@@ -42,6 +43,9 @@ export const WorkspaceSeatingChart: React.FC<WorkspaceSeatingChartProps> = ({
// 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<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 [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
const studentSeats = useMemo(() => {
if (!layout) return {} as Record<number, string>;
@@ -78,7 +93,7 @@ export const WorkspaceSeatingChart: React.FC<WorkspaceSeatingChartProps> = ({
// 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<WorkspaceSeatingChartProps> = ({
}
}
// 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<WorkspaceSeatingChartProps> = ({
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<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ỗ.`);
}
// 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<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
const handleClearAllSeats = () => {
if (!layout) return;
@@ -575,14 +647,21 @@ export const WorkspaceSeatingChart: React.FC<WorkspaceSeatingChartProps> = ({
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<button
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={{
backgroundColor: isEditingStructure ? 'var(--accent)' : '',
borderColor: isEditingStructure ? 'var(--accent)' : '',
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 className="btn btn-secondary btn-sm text-danger" style={{ borderColor: '#fca5a5' }} onClick={handleResetLayout}>
Reset đ
@@ -594,8 +673,47 @@ export const WorkspaceSeatingChart: React.FC<WorkspaceSeatingChartProps> = ({
{viewMode === 'grid' ? (
<>
{isEditingStructure && (
<div style={{ backgroundColor: '#fffbeb', border: '1px solid #fef3c7', padding: '0.65rem 1rem', borderRadius: '8px', color: '#b45309', fontSize: '0.8rem', fontWeight: 600 }}>
💡 Đang chế đ chỉnh sửa: Click chuột vào các ô trong đ đ thay đi khóa/mở các vị trí.
<div style={{ backgroundColor: '#fffbeb', border: '1px solid #fef3c7', padding: '0.75rem 1rem', borderRadius: '8px', display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
<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ị 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>
)}
{renderSeatingGrid()}