This commit is contained in:
@@ -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 sơ đồ
|
||||
@@ -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 sơ đồ để 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à 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()}
|
||||
|
||||
Reference in New Issue
Block a user