import React, { useEffect, useState, useMemo, useRef } from 'react'; import type { SeatingTemplate } from './SeatingTemplatesSection'; import { StudentAvatar, StudentOnlineBadge } from './StudentAvatar'; import { apiSeatingLayout } from '../api'; // ── Inline SVG icons (stroke-only, matching LearningTab / ExamsTab style) ───── type IconProps = { size?: number }; const IconChair = ({ size = 32 }: IconProps) => ( ); const IconGrid = ({ size = 14 }: IconProps) => ( ); const IconList = ({ size = 14 }: IconProps) => ( ); const IconZap = ({ size = 13 }: IconProps) => ( ); const IconTrash = ({ size = 13 }: IconProps) => ( ); const IconSettings = ({ size = 13 }: IconProps) => ( ); const IconCheck = ({ size = 13 }: IconProps) => ( ); const IconLock = ({ size = 14 }: IconProps) => ( ); const IconX = ({ size = 10 }: IconProps) => ( ); const IconSearch = ({ size = 15 }: IconProps) => ( ); const IconFileText = ({ size = 11 }: IconProps) => ( ); const IconBoard = ({ size = 13 }: IconProps) => ( ); const IconTemplate = ({ size = 15 }: IconProps) => ( ); const IconSliders = ({ size = 15 }: IconProps) => ( ); const IconRefresh = ({ size = 13 }: IconProps) => ( ); // ── Component ───────────────────────────────────────────────────────────────── interface WorkspaceSeatingChartProps { workspaceId: string; students: { rkId: number; fullName: string; studentCode: string; avatar?: string; paperTitle?: string; }[]; onlineIds: number[]; onStudentClick?: (studentRkId: number) => void; } interface SeatingLayout { rows: number; cols: number; boardPosition: 'top' | 'bottom' | 'left' | 'right'; lockedSeats: string[]; seats: Record; } export const WorkspaceSeatingChart: React.FC = ({ workspaceId, students, onlineIds, onStudentClick, }) => { const [layout, setLayout] = useState(null); const [templates, setTemplates] = useState([]); const [searchQuery, setSearchQuery] = useState(''); 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(''); 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(null); const [saveNotice, setSaveNotice] = useState(null); const [saveNoticeKind, setSaveNoticeKind] = useState<'success' | 'error'>('success'); const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid'); const [listSearchQuery, setListSearchQuery] = useState(''); const workspaceInfo = useMemo(() => { if (workspaceId.startsWith('class-')) { const n = Number(workspaceId.replace('class-', '')); return isNaN(n) ? null : { kind: 'class' as const, id: n }; } if (workspaceId.startsWith('exam-')) { const n = Number(workspaceId.replace('exam-', '')); return isNaN(n) ? null : { kind: 'exam' as const, id: n }; } return null; }, [workspaceId]); const saveTimerRef = useRef | null>(null); const pendingLayoutRef = useRef(undefined); const legacyLayoutKey = `sc_seating_layout_${workspaceId}`; const showSaveNotice = (message: string, kind: 'success' | 'error' = 'success') => { setSaveNoticeKind(kind); setSaveNotice(message); }; const persistLayout = async (newLayout: SeatingLayout | null) => { if (!workspaceInfo) return; try { if (newLayout) { const json = JSON.stringify(newLayout); const saver = workspaceInfo.kind === 'class' ? apiSeatingLayout.saveClass(workspaceInfo.id, json) : apiSeatingLayout.saveExam(workspaceInfo.id, json); await saver; localStorage.removeItem(legacyLayoutKey); showSaveNotice('Đã lưu sơ đồ chỗ ngồi lên server'); } else { const deleter = workspaceInfo.kind === 'class' ? apiSeatingLayout.deleteClass(workspaceInfo.id) : apiSeatingLayout.deleteExam(workspaceInfo.id); await deleter; localStorage.removeItem(legacyLayoutKey); showSaveNotice('Đã xóa sơ đồ chỗ ngồi trên server'); } } catch { if (newLayout) { localStorage.setItem(legacyLayoutKey, JSON.stringify(newLayout)); } showSaveNotice('Lưu server thất bại — đã giữ bản cục bộ tạm. Kiểm tra server đã deploy API sơ đồ chưa.', 'error'); } finally { pendingLayoutRef.current = undefined; } }; const studentSeats = useMemo(() => { if (!layout) return {} as Record; const mapping: Record = {}; Object.entries(layout.seats).forEach(([cellKey, rkId]) => { const [rStr, cStr] = cellKey.split(','); const r = Number(rStr) + 1; const c = Number(cStr) + 1; mapping[rkId] = `Hàng ${r}, Cột ${c}`; }); return mapping; }, [layout]); const filteredListStudents = useMemo(() => { return students.filter( (s) => s.fullName.toLowerCase().includes(listSearchQuery.toLowerCase()) || s.studentCode.toLowerCase().includes(listSearchQuery.toLowerCase()) ); }, [students, listSearchQuery]); const hasPaperColumn = useMemo(() => { return students.some((s) => s.paperTitle); }, [students]); useEffect(() => { const storedTemplates = localStorage.getItem('sc_seating_templates'); if (storedTemplates) { try { setTemplates(JSON.parse(storedTemplates)); } catch { setTemplates([]); } } if (workspaceInfo !== null) { const getter = workspaceInfo.kind === 'class' ? apiSeatingLayout.getClass(workspaceInfo.id) : apiSeatingLayout.getExam(workspaceInfo.id); getter.then(async (json) => { if (json) { try { setLayout(JSON.parse(json)); localStorage.removeItem(legacyLayoutKey); } catch { setLayout(null); } return; } const legacy = localStorage.getItem(legacyLayoutKey); if (!legacy) { setLayout(null); return; } try { const parsed = JSON.parse(legacy) as SeatingLayout; setLayout(parsed); await persistLayout(parsed); } catch { setLayout(null); } }).catch(() => setLayout(null)); } else { setLayout(null); } }, [workspaceId, workspaceInfo, legacyLayoutKey]); const saveLayout = (newLayout: SeatingLayout | null) => { setLayout(newLayout); if (!workspaceInfo) return; pendingLayoutRef.current = newLayout; if (saveTimerRef.current) clearTimeout(saveTimerRef.current); saveTimerRef.current = setTimeout(() => { void persistLayout(newLayout); }, 600); }; useEffect(() => { return () => { if (saveTimerRef.current) { clearTimeout(saveTimerRef.current); saveTimerRef.current = null; } if (pendingLayoutRef.current !== undefined && workspaceInfo) { void persistLayout(pendingLayoutRef.current); } }; }, [workspaceInfo, legacyLayoutKey]); useEffect(() => { if (saveNotice) { const timer = setTimeout(() => setSaveNotice(null), 2500); return () => clearTimeout(timer); } }, [saveNotice]); const seatedStudentIds = useMemo(() => { if (!layout) return new Set(); return new Set(Object.values(layout.seats)); }, [layout]); const unseatedStudents = useMemo(() => { return students.filter((s) => !seatedStudentIds.has(s.rkId)); }, [students, seatedStudentIds]); const filteredUnseatedStudents = useMemo(() => { return unseatedStudents.filter( (s) => s.fullName.toLowerCase().includes(searchQuery.toLowerCase()) || s.studentCode.toLowerCase().includes(searchQuery.toLowerCase()) ); }, [unseatedStudents, searchQuery]); 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); }; const handleApplyCustom = () => { const newLayout: SeatingLayout = { rows: customRows, cols: customCols, boardPosition: customBoard, lockedSeats: [], seats: {}, }; saveLayout(newLayout); }; const handleDragStart = (e: React.DragEvent, source: string) => { e.dataTransfer.setData('text/plain', source); }; const handleDragOver = (e: React.DragEvent, cellKey: string) => { e.preventDefault(); 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:', '')); newSeats[targetKey] = studentId; 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) { newSeats[targetKey] = studentId; newSeats[sourceKey] = targetStudentId; } else { newSeats[targetKey] = studentId; delete newSeats[sourceKey]; } } saveLayout({ ...layout, seats: newSeats }); }; const handleUnseat = (cellKey: string) => { if (!layout) return; const newSeats = { ...layout.seats }; delete newSeats[cellKey]; saveLayout({ ...layout, seats: newSeats }); }; const handleAutoAssign = () => { if (!layout) return; 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ỗ.`); } const shuffledStudents = [...unseatedStudents].sort(() => Math.random() - 0.5); const newSeats = { ...layout.seats }; const assignCount = Math.min(shuffledStudents.length, emptyCells.length); for (let i = 0; i < assignCount; i++) { newSeats[emptyCells[i]] = shuffledStudents[i].rkId; } saveLayout({ ...layout, seats: newSeats }); }; 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 = {}; 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, }); }; 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: {} }); } }; 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); } }; 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]; } saveLayout({ ...layout, lockedSeats: newLocked, seats: newSeats }); }; // ── Board label ────────────────────────────────────────────────────────────── const BoardLabel = ({ vertical = false }: { vertical?: boolean }) => (
BẢNG GIẢNG ĐƯỜNG
); // ── 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; let cellCls = 'wsc-grid-cell'; if (isLocked) cellCls += ' wsc-cell--locked'; else if (isHovered) cellCls += ' wsc-cell--dragover'; else if (student) cellCls += isOnline ? ' wsc-cell--online' : ' wsc-cell--occupied'; if (isEditingStructure) cellCls += ' wsc-cell--editing'; gridItems.push(
!isLocked && handleDragOver(e, key)} onDragLeave={handleDragLeave} onDrop={(e) => !isLocked && handleDrop(e, r, c)} onClick={() => isEditingStructure && handleToggleCellLock(r, c)} className={cellCls} draggable={!!student && !isEditingStructure} onDragStart={(e) => student && handleDragStart(e, `cell:${key}`)} > {isLocked ? (
Khóa
) : student ? (
{ if (!isEditingStructure && onStudentClick) onStudentClick(student.rkId); }} > {!isEditingStructure && ( )} {student.fullName} {student.studentCode} {!isEditingStructure && } {student.paperTitle && ( {student.paperTitle} )}
) : (
H{r + 1}-C{c + 1} Trống
)}
); } } return (
{layout.boardPosition === 'top' && }
{layout.boardPosition === 'left' && }
{gridItems}
{layout.boardPosition === 'right' && }
{layout.boardPosition === 'bottom' && }
); }; // ── Render ──────────────────────────────────────────────────────────────────── return (
{/* ── Empty / Setup state ─────────────────────────────────────────────── */} {!layout ? (

Chưa thiết lập sơ đồ chỗ ngồi

Thiết lập nhanh 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.

{/* Mode switcher */}
{/* Setup panels */}
{setupMode === 'template' ? (
) : (
setCustomRows(Math.max(1, Number(e.target.value)))} />
setCustomCols(Math.max(1, Number(e.target.value)))} />
)}
) : ( /* ── Active workspace ──────────────────────────────────────────────── */
{/* Left: main content */}
{/* Toolbar */}
{/* View-mode segmented control */}
{viewMode === 'grid' && ( <> )}
{viewMode === 'grid' && (
)}
{/* Edit-structure banner */} {viewMode === 'grid' && isEditingStructure && (
Chế độ chỉnh sửa: Click vào ô để khóa / mở vị trí. Chỉnh kích thước bên dưới.
setEditRows(Math.max(1, Math.min(15, Number(e.target.value))))} className="search-input wsc-edit-num" /> setEditCols(Math.max(1, Math.min(20, Number(e.target.value))))} className="search-input wsc-edit-num" />
)} {/* Grid or list content */} {viewMode === 'grid' ? (
{renderSeatingGrid()}
) : (
setListSearchQuery(e.target.value)} />
{filteredListStudents.length === 0 ? (
Không tìm thấy sinh viên nào.
) : ( {hasPaperColumn && } {filteredListStudents.map((s) => { const isOnline = onlineIds.includes(s.rkId); const seatLabel = studentSeats[s.rkId] || 'Chưa xếp chỗ'; return ( {hasPaperColumn && } ); })}
Sinh viên Mã SVGói đềVị trí Trạng thái Thao tác
{s.fullName}
{s.studentCode}{s.paperTitle || '—'} {seatLabel}
)}
)}
{/* Right: unseated sidebar */} {viewMode === 'grid' && (
Chưa vào chỗ {unseatedStudents.length} SV

Kéo thả vào vị trí trống trên sơ đồ.

setSearchQuery(e.target.value)} />
{filteredUnseatedStudents.length === 0 ? (
Không tìm thấy sinh viên.
) : ( filteredUnseatedStudents.map((s) => { const isOnline = onlineIds.includes(s.rkId); return (
handleDragStart(e, `unseated:${s.rkId}`)} onClick={() => onStudentClick && onStudentClick(s.rkId)} className="wsc-unseated-card" >
{s.fullName} {s.studentCode}{s.paperTitle && <> · {s.paperTitle}}
); }) )}
)}
)} {/* ── Save toast ──────────────────────────────────────────────────────── */} {saveNotice && (
{saveNoticeKind === 'error' ? : } {saveNotice}
)} {/* ── Scoped styles ───────────────────────────────────────────────────── */}
); };