test so do lop
Some checks failed
Deploy on Master Change / deploy (push) Failing after 39s

This commit is contained in:
2026-07-03 22:07:31 +07:00
parent 8e3704f5c4
commit 39dcb37d51
7 changed files with 1036 additions and 104 deletions

View 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ỗ"
>
&times;
</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 đ 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 đ từ mẫu lớp họ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ừ đ 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 đ 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 đ 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 đ
</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 đ đ 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 đ.
</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>
);
};