All checks were successful
Deploy on Master Change / deploy (push) Successful in 1m1s
682 lines
27 KiB
TypeScript
682 lines
27 KiB
TypeScript
import React, { useEffect, useState, useMemo } from 'react';
|
||
import type { SeatingTemplate } from './SeatingTemplatesSection';
|
||
import { StudentAvatar } from './StudentAvatar';
|
||
|
||
interface WorkspaceSeatingChartProps {
|
||
workspaceId: string; // 'class-<id>' or 'exam-<id>'
|
||
students: {
|
||
rkId: number;
|
||
fullName: string;
|
||
studentCode: string;
|
||
avatar?: string;
|
||
}[];
|
||
onlineIds: number[];
|
||
onStudentClick?: (studentRkId: number) => void;
|
||
}
|
||
|
||
interface SeatingLayout {
|
||
rows: number;
|
||
cols: number;
|
||
boardPosition: 'top' | 'bottom' | 'left' | 'right';
|
||
lockedSeats: string[]; // "row,col"
|
||
seats: Record<string, number>; // "row,col" -> studentRkId
|
||
}
|
||
|
||
export const WorkspaceSeatingChart: React.FC<WorkspaceSeatingChartProps> = ({
|
||
workspaceId,
|
||
students,
|
||
onlineIds,
|
||
onStudentClick,
|
||
}) => {
|
||
const [layout, setLayout] = useState<SeatingLayout | null>(null);
|
||
const [templates, setTemplates] = useState<SeatingTemplate[]>([]);
|
||
const [searchQuery, setSearchQuery] = useState('');
|
||
|
||
// Custom setup states
|
||
const [setupMode, setSetupMode] = useState<'template' | 'custom'>('template');
|
||
const [customRows, setCustomRows] = useState(5);
|
||
const [customCols, setCustomCols] = useState(6);
|
||
const [customBoard, setCustomBoard] = useState<'top' | 'bottom' | 'left' | 'right'>('top');
|
||
const [selectedTemplateId, setSelectedTemplateId] = useState('');
|
||
|
||
// Edit mode for structural changes
|
||
const [isEditingStructure, setIsEditingStructure] = useState(false);
|
||
const [dragOverCell, setDragOverCell] = useState<string | null>(null);
|
||
const [saveNotice, setSaveNotice] = useState<string | null>(null);
|
||
|
||
// Load templates and layout
|
||
useEffect(() => {
|
||
// Load templates
|
||
const storedTemplates = localStorage.getItem('sc_seating_templates');
|
||
if (storedTemplates) {
|
||
try {
|
||
setTemplates(JSON.parse(storedTemplates));
|
||
} catch {
|
||
setTemplates([]);
|
||
}
|
||
}
|
||
|
||
// Load layout for this room
|
||
const storedLayout = localStorage.getItem(`sc_seating_layout_${workspaceId}`);
|
||
if (storedLayout) {
|
||
try {
|
||
setLayout(JSON.parse(storedLayout));
|
||
} catch {
|
||
setLayout(null);
|
||
}
|
||
}
|
||
}, [workspaceId]);
|
||
|
||
// Save layout
|
||
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);
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (saveNotice) {
|
||
const timer = setTimeout(() => {
|
||
setSaveNotice(null);
|
||
}, 2000);
|
||
return () => clearTimeout(timer);
|
||
}
|
||
}, [saveNotice]);
|
||
|
||
// Get active seated list
|
||
const seatedStudentIds = useMemo(() => {
|
||
if (!layout) return new Set<number>();
|
||
return new Set<number>(Object.values(layout.seats));
|
||
}, [layout]);
|
||
|
||
// Filter unseated students list
|
||
const unseatedStudents = useMemo(() => {
|
||
return students.filter((s) => !seatedStudentIds.has(s.rkId));
|
||
}, [students, seatedStudentIds]);
|
||
|
||
// Search unseated students
|
||
const filteredUnseatedStudents = useMemo(() => {
|
||
return unseatedStudents.filter(
|
||
(s) =>
|
||
s.fullName.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||
s.studentCode.toLowerCase().includes(searchQuery.toLowerCase())
|
||
);
|
||
}, [unseatedStudents, searchQuery]);
|
||
|
||
// Initialize with Template
|
||
const handleApplyTemplate = () => {
|
||
const tpl = templates.find((t) => t.id === selectedTemplateId);
|
||
if (!tpl) {
|
||
alert('Vui lòng chọn sơ đồ mẫu.');
|
||
return;
|
||
}
|
||
const newLayout: SeatingLayout = {
|
||
rows: tpl.rows,
|
||
cols: tpl.cols,
|
||
boardPosition: tpl.boardPosition,
|
||
lockedSeats: [...tpl.lockedSeats],
|
||
seats: {},
|
||
};
|
||
saveLayout(newLayout);
|
||
};
|
||
|
||
// Initialize with Custom values
|
||
const handleApplyCustom = () => {
|
||
const newLayout: SeatingLayout = {
|
||
rows: customRows,
|
||
cols: customCols,
|
||
boardPosition: customBoard,
|
||
lockedSeats: [],
|
||
seats: {},
|
||
};
|
||
saveLayout(newLayout);
|
||
};
|
||
|
||
// Drag and drop handlers
|
||
const handleDragStart = (e: React.DragEvent, source: string) => {
|
||
e.dataTransfer.setData('text/plain', source);
|
||
};
|
||
|
||
const handleDragOver = (e: React.DragEvent, cellKey: string) => {
|
||
e.preventDefault();
|
||
if (dragOverCell !== cellKey) {
|
||
setDragOverCell(cellKey);
|
||
}
|
||
};
|
||
|
||
const handleDragLeave = () => {
|
||
setDragOverCell(null);
|
||
};
|
||
|
||
const handleDrop = (e: React.DragEvent, targetRow: number, targetCol: number) => {
|
||
e.preventDefault();
|
||
setDragOverCell(null);
|
||
if (!layout) return;
|
||
|
||
const source = e.dataTransfer.getData('text/plain');
|
||
if (!source) return;
|
||
|
||
const targetKey = `${targetRow},${targetCol}`;
|
||
if (layout.lockedSeats.includes(targetKey)) return;
|
||
|
||
const newSeats = { ...layout.seats };
|
||
const targetStudentId = newSeats[targetKey];
|
||
|
||
if (source.startsWith('unseated:')) {
|
||
const studentId = Number(source.replace('unseated:', ''));
|
||
|
||
// Place in target cell
|
||
newSeats[targetKey] = studentId;
|
||
|
||
// If target had a student, B is kicked to unseated automatically (since they are no longer in newSeats)
|
||
// Clean up B from any other slots (shouldn't exist, but safety)
|
||
Object.keys(newSeats).forEach((key) => {
|
||
if (key !== targetKey && newSeats[key] === studentId) {
|
||
delete newSeats[key];
|
||
}
|
||
});
|
||
|
||
} else if (source.startsWith('cell:')) {
|
||
const sourceKey = source.replace('cell:', '');
|
||
const studentId = newSeats[sourceKey];
|
||
if (!studentId) return;
|
||
|
||
if (targetStudentId) {
|
||
// Swap students
|
||
newSeats[targetKey] = studentId;
|
||
newSeats[sourceKey] = targetStudentId;
|
||
} else {
|
||
// Move to empty cell
|
||
newSeats[targetKey] = studentId;
|
||
delete newSeats[sourceKey];
|
||
}
|
||
}
|
||
|
||
saveLayout({ ...layout, seats: newSeats });
|
||
};
|
||
|
||
// Unseat student
|
||
const handleUnseat = (cellKey: string) => {
|
||
if (!layout) return;
|
||
const newSeats = { ...layout.seats };
|
||
delete newSeats[cellKey];
|
||
saveLayout({ ...layout, seats: newSeats });
|
||
};
|
||
|
||
// Auto assign random
|
||
const handleAutoAssign = () => {
|
||
if (!layout) return;
|
||
|
||
// Find all empty, unlocked cells
|
||
const emptyCells: string[] = [];
|
||
for (let r = 0; r < layout.rows; r++) {
|
||
for (let c = 0; c < layout.cols; c++) {
|
||
const key = `${r},${c}`;
|
||
if (!layout.lockedSeats.includes(key) && !layout.seats[key]) {
|
||
emptyCells.push(key);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (unseatedStudents.length > emptyCells.length) {
|
||
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);
|
||
|
||
const newSeats = { ...layout.seats };
|
||
const assignCount = Math.min(unseatedStudents.length, shuffledCells.length);
|
||
|
||
for (let i = 0; i < assignCount; i++) {
|
||
newSeats[shuffledCells[i]] = unseatedStudents[i].rkId;
|
||
}
|
||
|
||
saveLayout({ ...layout, seats: newSeats });
|
||
};
|
||
|
||
// Clear all seats
|
||
const handleClearAllSeats = () => {
|
||
if (!layout) return;
|
||
if (confirm('Bạn có chắc chắn muốn giải tán tất cả chỗ ngồi?')) {
|
||
saveLayout({ ...layout, seats: {} });
|
||
}
|
||
};
|
||
|
||
// Reset entire layout configuration
|
||
const handleResetLayout = () => {
|
||
if (confirm('Lưu ý: Xóa sơ đồ sẽ xóa toàn bộ vị trí chỗ ngồi hiện tại. Bạn có chắc chắn muốn thực hiện?')) {
|
||
saveLayout(null);
|
||
setIsEditingStructure(false);
|
||
}
|
||
};
|
||
|
||
// Toggle cell lock dynamically
|
||
const handleToggleCellLock = (r: number, c: number) => {
|
||
if (!layout) return;
|
||
const key = `${r},${c}`;
|
||
let newLocked = [...layout.lockedSeats];
|
||
let newSeats = { ...layout.seats };
|
||
|
||
if (newLocked.includes(key)) {
|
||
newLocked = newLocked.filter((k) => k !== key);
|
||
} else {
|
||
newLocked.push(key);
|
||
delete newSeats[key]; // remove student if locked
|
||
}
|
||
|
||
saveLayout({
|
||
...layout,
|
||
lockedSeats: newLocked,
|
||
seats: newSeats,
|
||
});
|
||
};
|
||
|
||
// Render Seating Grid
|
||
const renderSeatingGrid = () => {
|
||
if (!layout) return null;
|
||
|
||
const gridItems = [];
|
||
for (let r = 0; r < layout.rows; r++) {
|
||
for (let c = 0; c < layout.cols; c++) {
|
||
const key = `${r},${c}`;
|
||
const isLocked = layout.lockedSeats.includes(key);
|
||
const studentId = layout.seats[key];
|
||
const student = studentId ? students.find((s) => s.rkId === studentId) : null;
|
||
const isOnline = student ? onlineIds.includes(student.rkId) : false;
|
||
const isHovered = dragOverCell === key;
|
||
|
||
gridItems.push(
|
||
<div
|
||
key={key}
|
||
onDragOver={(e) => !isLocked && handleDragOver(e, key)}
|
||
onDragLeave={handleDragLeave}
|
||
onDrop={(e) => !isLocked && handleDrop(e, r, c)}
|
||
onClick={() => isEditingStructure && handleToggleCellLock(r, c)}
|
||
className={`seating-grid-cell ${isLocked ? 'locked' : ''} ${isHovered ? 'dragover' : ''}`}
|
||
style={{
|
||
minHeight: '80px',
|
||
border: isEditingStructure ? '1.5px dashed var(--border-color)' : '1px solid var(--border-color)',
|
||
borderRadius: '8px',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
justifyContent: 'center',
|
||
alignItems: 'center',
|
||
padding: '0.4rem',
|
||
position: 'relative',
|
||
backgroundColor: isLocked
|
||
? '#e5e7eb'
|
||
: isHovered
|
||
? '#dbeafe'
|
||
: student
|
||
? '#ffffff'
|
||
: '#f9fafb',
|
||
color: isLocked ? '#9ca3af' : 'inherit',
|
||
cursor: isEditingStructure ? 'pointer' : student ? 'grab' : 'default',
|
||
boxShadow: student ? 'var(--shadow-sm)' : 'none',
|
||
transition: 'var(--transition)',
|
||
borderColor: isHovered ? 'var(--accent)' : isLocked ? '#e5e7eb' : 'var(--border-color)',
|
||
}}
|
||
draggable={!!student && !isEditingStructure}
|
||
onDragStart={(e) => student && handleDragStart(e, `cell:${key}`)}
|
||
>
|
||
{isLocked ? (
|
||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '4px' }}>
|
||
<span style={{ fontSize: '1rem' }}>🔒</span>
|
||
<span style={{ fontSize: '0.72rem', color: '#9ca3af', fontWeight: 600 }}>Khóa</span>
|
||
</div>
|
||
) : student ? (
|
||
<div
|
||
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', textAlign: 'center', width: '100%', cursor: (!isEditingStructure && onStudentClick) ? 'pointer' : 'inherit' }}
|
||
onClick={() => {
|
||
if (!isEditingStructure && onStudentClick) {
|
||
onStudentClick(student.rkId);
|
||
}
|
||
}}
|
||
>
|
||
{!isEditingStructure && (
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); handleUnseat(key); }}
|
||
style={{
|
||
position: 'absolute',
|
||
top: '2px',
|
||
right: '2px',
|
||
border: 'none',
|
||
background: '#fee2e2',
|
||
color: 'var(--danger)',
|
||
width: '18px',
|
||
height: '18px',
|
||
borderRadius: '50%',
|
||
fontSize: '0.65rem',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
cursor: 'pointer',
|
||
fontWeight: 700,
|
||
}}
|
||
title="Cho học viên rời chỗ"
|
||
>
|
||
×
|
||
</button>
|
||
)}
|
||
<StudentAvatar fullName={student.fullName} avatar={student.avatar} isOnline={isOnline} size={32} />
|
||
<span style={{ fontSize: '0.75rem', fontWeight: 700, marginTop: '4px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', width: '100%', padding: '0 2px' }}>
|
||
{student.fullName.split(' ').pop()}
|
||
</span>
|
||
<span style={{ fontSize: '0.65rem', color: 'var(--text-secondary)', fontFamily: 'monospace' }}>
|
||
{student.studentCode}
|
||
</span>
|
||
</div>
|
||
) : (
|
||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', opacity: 0.4 }}>
|
||
<span style={{ fontSize: '0.72rem', color: 'var(--text-muted)', fontWeight: 600 }}>
|
||
H{r + 1}-C{c + 1}
|
||
</span>
|
||
<span style={{ fontSize: '0.65rem', color: 'var(--text-muted)' }}>Trống</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', flex: 1, minWidth: 0, minHeight: 0 }}>
|
||
{/* Board Top */}
|
||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||
{layout.boardPosition === 'top' && (
|
||
<div className="seating-board-label" style={{ width: '50%', textAlign: 'center', background: '#4b5563', color: '#fff', padding: '0.35rem 0.5rem', borderRadius: '6px', fontSize: '0.85rem', fontWeight: 700 }}>
|
||
📢 BẢNG GIẢNG ĐƯỜNG
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', alignItems: 'stretch', gap: '0.75rem', flex: 1, minHeight: 0 }}>
|
||
{/* Board Left */}
|
||
{layout.boardPosition === 'left' && (
|
||
<div className="seating-board-label" style={{ writingMode: 'vertical-lr', textOrientation: 'mixed', textAlign: 'center', background: '#4b5563', color: '#fff', padding: '0.5rem 0.35rem', borderRadius: '6px', fontSize: '0.85rem', fontWeight: 700, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||
📢 BẢNG GIẢNG ĐƯỜNG
|
||
</div>
|
||
)}
|
||
|
||
{/* Grid Container */}
|
||
<div
|
||
style={{
|
||
display: 'grid',
|
||
gridTemplateColumns: `repeat(${layout.cols}, minmax(75px, 1fr))`,
|
||
gap: '8px',
|
||
flex: 1,
|
||
padding: '8px',
|
||
backgroundColor: 'var(--bg-app)',
|
||
borderRadius: 'var(--radius-md)',
|
||
border: '1px solid var(--border-color)',
|
||
alignContent: 'start',
|
||
overflow: 'auto',
|
||
minHeight: 0,
|
||
}}
|
||
>
|
||
{gridItems}
|
||
</div>
|
||
|
||
{/* Board Right */}
|
||
{layout.boardPosition === 'right' && (
|
||
<div className="seating-board-label" style={{ writingMode: 'vertical-lr', textOrientation: 'mixed', textAlign: 'center', background: '#4b5563', color: '#fff', padding: '0.5rem 0.35rem', borderRadius: '6px', fontSize: '0.85rem', fontWeight: 700, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||
📢 BẢNG GIẢNG ĐƯỜNG
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Board Bottom */}
|
||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||
{layout.boardPosition === 'bottom' && (
|
||
<div className="seating-board-label" style={{ width: '50%', textAlign: 'center', background: '#4b5563', color: '#fff', padding: '0.35rem 0.5rem', borderRadius: '6px', fontSize: '0.85rem', fontWeight: 700 }}>
|
||
📢 BẢNG GIẢNG ĐƯỜNG
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem', flex: 1, minHeight: 0, height: '100%', overflow: 'hidden' }}>
|
||
|
||
{!layout ? (
|
||
// Empty state: select template or customize
|
||
<div className="empty-state" style={{ minHeight: '350px', padding: '2rem', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
|
||
<div className="empty-state-icon" style={{ fontSize: '3rem', marginBottom: '1rem' }}>🪑</div>
|
||
<h2>Chưa thiết lập sơ đồ chỗ ngồi</h2>
|
||
<p style={{ color: 'var(--text-secondary)', maxWidth: '450px', textAlign: 'center', marginBottom: '1.5rem', fontSize: '0.9rem' }}>
|
||
Thiết lập nhanh sơ đồ từ mẫu lớp học có sẵn hoặc tạo tự do kích thước mong muốn để xếp vị trí cho sinh viên.
|
||
</p>
|
||
|
||
<div style={{ display: 'flex', gap: '1rem', width: '100%', maxWidth: '600px', justifyContent: 'center' }}>
|
||
<div className="content-card" style={{ flex: 1, padding: '1.25rem', display: 'flex', flexDirection: 'column', gap: '0.75rem', border: setupMode === 'template' ? '2px solid var(--accent)' : '1px solid var(--border-color)' }} onClick={() => setSetupMode('template')}>
|
||
<h4 style={{ margin: 0, display: 'flex', alignItems: 'center', gap: '6px', fontSize: '0.9rem', cursor: 'pointer' }}>
|
||
<input type="radio" checked={setupMode === 'template'} readOnly />
|
||
Chọn từ Sơ đồ mẫu
|
||
</h4>
|
||
<select
|
||
className="select-filter"
|
||
style={{ width: '100%', padding: '0.4rem', fontSize: '0.8rem' }}
|
||
value={selectedTemplateId}
|
||
onChange={(e) => setSelectedTemplateId(e.target.value)}
|
||
disabled={setupMode !== 'template'}
|
||
>
|
||
<option value="">-- Chọn sơ đồ mẫu --</option>
|
||
{templates.map((t) => (
|
||
<option key={t.id} value={t.id}>
|
||
{t.name} ({t.rows}x{t.cols})
|
||
</option>
|
||
))}
|
||
</select>
|
||
<button className="btn btn-primary btn-sm" style={{ marginTop: 'auto' }} onClick={handleApplyTemplate} disabled={setupMode !== 'template'}>
|
||
Áp dụng mẫu
|
||
</button>
|
||
</div>
|
||
|
||
<div className="content-card" style={{ flex: 1, padding: '1.25rem', display: 'flex', flexDirection: 'column', gap: '0.75rem', border: setupMode === 'custom' ? '2px solid var(--accent)' : '1px solid var(--border-color)' }} onClick={() => setSetupMode('custom')}>
|
||
<h4 style={{ margin: 0, display: 'flex', alignItems: 'center', gap: '6px', fontSize: '0.9rem', cursor: 'pointer' }}>
|
||
<input type="radio" checked={setupMode === 'custom'} readOnly />
|
||
Tạo tự do tùy ý
|
||
</h4>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px' }}>
|
||
<input
|
||
type="number"
|
||
placeholder="Hàng"
|
||
min={1}
|
||
max={10}
|
||
className="search-input"
|
||
style={{ padding: '0.4rem', fontSize: '0.8rem' }}
|
||
value={customRows}
|
||
onChange={(e) => setCustomRows(Math.max(1, Number(e.target.value)))}
|
||
disabled={setupMode !== 'custom'}
|
||
title="Số hàng"
|
||
/>
|
||
<input
|
||
type="number"
|
||
placeholder="Cột"
|
||
min={1}
|
||
max={15}
|
||
className="search-input"
|
||
style={{ padding: '0.4rem', fontSize: '0.8rem' }}
|
||
value={customCols}
|
||
onChange={(e) => setCustomCols(Math.max(1, Number(e.target.value)))}
|
||
disabled={setupMode !== 'custom'}
|
||
title="Số cột"
|
||
/>
|
||
</div>
|
||
<select
|
||
className="select-filter"
|
||
style={{ width: '100%', padding: '0.4rem', fontSize: '0.8rem' }}
|
||
value={customBoard}
|
||
onChange={(e) => setCustomBoard(e.target.value as any)}
|
||
disabled={setupMode !== 'custom'}
|
||
>
|
||
<option value="top">Bảng ở TOP</option>
|
||
<option value="bottom">Bảng ở BOTTOM</option>
|
||
<option value="left">Bảng ở LEFT</option>
|
||
<option value="right">Bảng ở RIGHT</option>
|
||
</select>
|
||
<button className="btn btn-primary btn-sm" style={{ marginTop: 'auto' }} onClick={handleApplyCustom} disabled={setupMode !== 'custom'}>
|
||
Tạo sơ đồ trống
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
// Seating Workspace View
|
||
<div className="seating-workspace-grid">
|
||
|
||
{/* Main Seating Panel */}
|
||
<div className="seating-workspace-main">
|
||
{/* Toolbar */}
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '10px' }}>
|
||
<div style={{ display: 'flex', gap: '8px' }}>
|
||
<button className="btn btn-primary btn-sm" onClick={handleAutoAssign}>
|
||
⚡ Xếp ngẫu nhiên
|
||
</button>
|
||
<button className="btn btn-secondary btn-sm" onClick={handleClearAllSeats}>
|
||
🗑️ Giải tán chỗ
|
||
</button>
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||
<button
|
||
className={`btn btn-sm ${isEditingStructure ? 'btn-primary' : 'btn-secondary'}`}
|
||
onClick={() => setIsEditingStructure(!isEditingStructure)}
|
||
style={{
|
||
backgroundColor: isEditingStructure ? 'var(--accent)' : '',
|
||
borderColor: isEditingStructure ? 'var(--accent)' : '',
|
||
color: isEditingStructure ? '#fff' : '',
|
||
}}
|
||
>
|
||
{isEditingStructure ? '✔️ Xong thiết lập' : '⚙️ Khóa/Mở ô chỗ'}
|
||
</button>
|
||
<button className="btn btn-secondary btn-sm text-danger" style={{ borderColor: '#fca5a5' }} onClick={handleResetLayout}>
|
||
Reset sơ đồ
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{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>
|
||
)}
|
||
|
||
{/* Render Grid */}
|
||
{renderSeatingGrid()}
|
||
</div>
|
||
|
||
{/* Right Panel: Unseated Students list */}
|
||
<div className="seating-workspace-sidebar">
|
||
<div>
|
||
<h4 style={{ margin: 0, fontSize: '0.95rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||
<span>Chưa vào chỗ</span>
|
||
<span className="badge" style={{ background: 'var(--accent-light)', color: 'var(--accent)', fontSize: '0.75rem', fontWeight: 700, padding: '0.15rem 0.4rem', borderRadius: '10px' }}>
|
||
{unseatedStudents.length} SV
|
||
</span>
|
||
</h4>
|
||
<p style={{ margin: '4px 0 0 0', color: 'var(--text-secondary)', fontSize: '0.78rem' }}>
|
||
Kéo thả sinh viên bên dưới vào vị trí trống trên sơ đồ.
|
||
</p>
|
||
</div>
|
||
|
||
<input
|
||
type="search"
|
||
placeholder="Tìm tên, mã sinh viên..."
|
||
className="search-input"
|
||
style={{ width: '100%', padding: '0.4rem 0.6rem', fontSize: '0.8rem' }}
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
/>
|
||
|
||
<div
|
||
style={{
|
||
flex: 1,
|
||
overflowY: 'auto',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: '8px',
|
||
paddingRight: '4px',
|
||
minHeight: '300px',
|
||
}}
|
||
>
|
||
{filteredUnseatedStudents.length === 0 ? (
|
||
<div style={{ textAlign: 'center', padding: '2rem 0', color: 'var(--text-muted)', fontSize: '0.8rem' }}>
|
||
Không tìm thấy sinh viên nào.
|
||
</div>
|
||
) : (
|
||
filteredUnseatedStudents.map((s) => {
|
||
const isOnline = onlineIds.includes(s.rkId);
|
||
return (
|
||
<div
|
||
key={s.rkId}
|
||
draggable
|
||
onDragStart={(e) => handleDragStart(e, `unseated:${s.rkId}`)}
|
||
onClick={() => onStudentClick && onStudentClick(s.rkId)}
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '0.5rem',
|
||
padding: '0.5rem',
|
||
border: '1px solid var(--border-color)',
|
||
borderRadius: '8px',
|
||
backgroundColor: '#ffffff',
|
||
cursor: onStudentClick ? 'pointer' : 'grab',
|
||
transition: 'var(--transition)',
|
||
userSelect: 'none',
|
||
boxShadow: 'var(--shadow-sm)',
|
||
}}
|
||
className="student-drag-item"
|
||
>
|
||
<StudentAvatar fullName={s.fullName} avatar={s.avatar} isOnline={isOnline} size={28} />
|
||
<div style={{ display: 'flex', flexDirection: 'column', overflow: 'hidden', flex: 1 }}>
|
||
<span style={{ fontSize: '0.8rem', fontWeight: 600, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||
{s.fullName}
|
||
</span>
|
||
<span style={{ fontSize: '0.7rem', color: 'var(--text-secondary)', fontFamily: 'monospace' }}>
|
||
{s.studentCode}
|
||
</span>
|
||
</div>
|
||
<span className={isOnline ? 'pulse-dot-online' : 'status-badge-offline'} style={{ width: '6px', height: '6px' }} />
|
||
</div>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
)}
|
||
|
||
{saveNotice && (
|
||
<div style={{
|
||
position: 'fixed',
|
||
bottom: '24px',
|
||
right: '24px',
|
||
background: '#10b981',
|
||
color: '#fff',
|
||
padding: '0.65rem 1.25rem',
|
||
borderRadius: '8px',
|
||
boxShadow: 'var(--shadow-lg)',
|
||
fontSize: '0.85rem',
|
||
fontWeight: 600,
|
||
zIndex: 1000,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '8px',
|
||
}}>
|
||
<span style={{ fontSize: '1.1rem' }}>✓</span> {saveNotice}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|