This commit is contained in:
638
management/src/components/WorkspaceSeatingChart.tsx
Normal file
638
management/src/components/WorkspaceSeatingChart.tsx
Normal file
@@ -0,0 +1,638 @@
|
||||
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[];
|
||||
}
|
||||
|
||||
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,
|
||||
}) => {
|
||||
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);
|
||||
|
||||
// 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));
|
||||
} else {
|
||||
localStorage.removeItem(`sc_seating_layout_${workspaceId}`);
|
||||
}
|
||||
setLayout(newLayout);
|
||||
};
|
||||
|
||||
// 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%' }}>
|
||||
{!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 }}>
|
||||
{/* 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 }}>
|
||||
{/* 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(0, 1fr))`,
|
||||
gap: '8px',
|
||||
flex: 1,
|
||||
padding: '8px',
|
||||
backgroundColor: 'var(--bg-app)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
border: '1px solid var(--border-color)',
|
||||
alignContent: 'start',
|
||||
overflowY: 'auto',
|
||||
minHeight: '400px',
|
||||
}}
|
||||
>
|
||||
{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', height: '100%', minHeight: '450px' }}>
|
||||
|
||||
{!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 style={{ display: 'grid', gridTemplateColumns: '1fr 300px', gap: '1.5rem', height: '100%' }}>
|
||||
|
||||
{/* Main Seating Panel */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
{/* 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 style={{ borderLeft: '1px solid var(--border-color)', paddingLeft: '1.5rem', display: 'flex', flexDirection: 'column', gap: '1rem', overflow: 'hidden' }}>
|
||||
<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}`)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
padding: '0.5rem',
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: '#ffffff',
|
||||
cursor: '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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user