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

@@ -95,6 +95,7 @@ const SYSTEM_NAV: { section: SystemSection }[] = [
{ section: 'organization' },
{ section: 'network' },
{ section: 'templates' },
{ section: 'seating' },
];
function App() {

View File

@@ -25,6 +25,7 @@ import { ExamGridProctor } from './ExamGridProctor';
import { AppPoolModal } from './AppPoolModal';
import { AppTemplatePickerModal } from './AppTemplatePickerModal';
import { mergeKeywordCSV } from '../utils/appKeywords';
import { WorkspaceSeatingChart } from './WorkspaceSeatingChart';
interface ClassWorkspaceProps {
classId: number;
@@ -433,7 +434,7 @@ export const ClassWorkspace: React.FC<ClassWorkspaceProps> = ({ classId, sourceT
<div className="divider" style={{ opacity: 0.3, margin: '0.25rem 0' }}></div>
</div>
<div className={`workspace-panel-body ${activeSubTab === 'attendance' || activeSubTab === 'logs' || activeSubTab === 'grid' ? 'workspace-panel-fill' : ''}`}>
<div className={`workspace-panel-body ${activeSubTab === 'attendance' || activeSubTab === 'logs' || activeSubTab === 'grid' || activeSubTab === 'roster' ? 'workspace-panel-fill' : ''}`}>
{activeSubTab === 'attendance' ? (
<AttendancePanel classId={classId} />
) : activeSubTab === 'grid' ? (
@@ -456,46 +457,16 @@ export const ClassWorkspace: React.FC<ClassWorkspaceProps> = ({ classId, sourceT
}}
/>
) : activeSubTab === 'roster' ? (
/* Student Roster Grid */
filteredStudents.length === 0 ? (
<div className="empty-state" style={{ minHeight: '300px' }}>
<div className="empty-state-icon">👤</div>
<h2>Không tìm thấy học viên nào</h2>
<p>Hãy thử tìm kiếm với từ khóa khác hoặc kiểm tra lại danh sách lớp.</p>
</div>
) : (
<div className="student-status-grid">
{filteredStudents.map(st => {
const isOnline = onlineIds.includes(st.rkId);
return (
<div
key={st.rkId}
className={`student-status-card student-status-card-clickable ${isOnline ? 'online' : 'offline'}`}
onClick={() => setSelectedStudent(st)}
role="button"
tabIndex={0}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') setSelectedStudent(st); }}
>
<StudentAvatar fullName={st.fullName} avatar={st.avatar} isOnline={isOnline} size={52} />
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, overflow: 'hidden' }}>
<span style={{ fontSize: '0.875rem', fontWeight: 600, color: 'var(--text-primary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{st.fullName}
</span>
<span style={{ fontSize: '0.78rem', color: 'var(--accent)', fontFamily: 'monospace', fontWeight: 600 }}>
{st.studentCode}
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.35rem', marginTop: '0.4rem' }}>
<span className={isOnline ? 'pulse-dot-online' : 'status-badge-offline'}></span>
<span style={{ fontSize: '0.75rem', fontWeight: 700, color: isOnline ? 'var(--success)' : 'var(--text-muted)' }}>
{isOnline ? 'ONLINE' : 'OFFLINE'}
</span>
</div>
</div>
</div>
);
})}
</div>
)
<WorkspaceSeatingChart
workspaceId={`class-${classId}`}
students={students.map((st) => ({
rkId: st.rkId,
fullName: st.fullName,
studentCode: st.studentCode,
avatar: st.avatar,
}))}
onlineIds={onlineIds}
/>
) : (
<div className="session-logs-panel">
<div className="attendance-toolbar" style={{ marginBottom: '0.5rem' }}>

View File

@@ -20,6 +20,7 @@ import { StudentAvatar } from './StudentAvatar';
import { StudentDetailModal } from './StudentDetailModal';
import { fmtTime, localInputToISO, toLocalInput } from './ExamsTab';
import { ExamGridProctor } from './ExamGridProctor';
import { WorkspaceSeatingChart } from './WorkspaceSeatingChart';
const EXAM_APP_SUGGESTIONS = [...new Set([...BASE_APP_SUGGESTIONS, 'msedge', 'edge', 'acrobat', 'foxit'])];
@@ -915,68 +916,18 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
<div className="divider" style={{ opacity: 0.3, margin: '0.25rem 0' }} />
</div>
<div className={`workspace-panel-body ${activeSubTab !== 'roster' ? 'workspace-panel-fill' : ''}`}>
<div className="workspace-panel-body workspace-panel-fill">
{activeSubTab === 'roster' ? (
students.length === 0 ? (
<div className="empty-state" style={{ minHeight: '300px' }}>
<div className="empty-state-icon">👤</div>
<h2>Chưa sinh viên</h2>
<p>Thêm sinh viên vào phòng thi đ bắt đu giám sát.</p>
{prepEditable && (
<button
type="button"
className="btn btn-primary"
onClick={() => { setStudentPickerOpen(true); setSearchQ(''); setSearchHits([]); setSelectedPickerRkIds([]); }}
>
+ Thêm sinh viên
</button>
)}
</div>
) : filteredStudents.length === 0 ? (
<div className="empty-state" style={{ minHeight: '300px' }}>
<div className="empty-state-icon">🔍</div>
<h2>Không tìm thấy sinh viên</h2>
<p>Hãy thử tìm kiếm với từ khóa khác.</p>
</div>
) : (
<div className="student-status-grid">
{filteredStudents.map((s) => {
const isOnline = onlineIds.includes(s.studentRkId);
return (
<div
key={s.id}
className={`student-status-card student-status-card-clickable ${isOnline ? 'online' : 'offline'}`}
onClick={() => setSelectedStudent(toStudentItem(s))}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') setSelectedStudent(toStudentItem(s)); }}
>
<StudentAvatar fullName={s.fullName} avatar={s.avatar} isOnline={isOnline} size={52} />
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, overflow: 'hidden' }}>
<span style={{ fontSize: '0.875rem', fontWeight: 600, color: 'var(--text-primary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{s.fullName}
</span>
<span style={{ fontSize: '0.78rem', color: 'var(--accent)', fontFamily: 'monospace', fontWeight: 600 }}>
{s.studentCode}
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.35rem', marginTop: '0.4rem' }}>
<span className={isOnline ? 'pulse-dot-online' : 'status-badge-offline'} />
<span style={{ fontSize: '0.75rem', fontWeight: 700, color: isOnline ? 'var(--success)' : 'var(--text-muted)' }}>
{isOnline ? 'ONLINE' : 'OFFLINE'}
</span>
</div>
{(s.paperTitle || s.submitted) && (
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)', marginTop: '0.35rem' }}>
{s.paperTitle && <span>{s.paperTitle}</span>}
{s.submitted && <span style={{ color: 'var(--success)', marginLeft: s.paperTitle ? '0.35rem' : 0 }}>· Đã nộp</span>}
</div>
)}
</div>
</div>
);
})}
</div>
)
<WorkspaceSeatingChart
workspaceId={`exam-${examId}`}
students={students.map((s) => ({
rkId: s.studentRkId,
fullName: s.fullName,
studentCode: s.studentCode,
avatar: s.avatar,
}))}
onlineIds={onlineIds}
/>
) : activeSubTab === 'grid' ? (
<ExamGridProctor
students={students}

View File

@@ -0,0 +1,355 @@
import React, { useEffect, useState } from 'react';
export interface SeatingTemplate {
id: string;
name: string;
rows: number;
cols: number;
boardPosition: 'top' | 'bottom' | 'left' | 'right';
lockedSeats: string[]; // e.g. ["0,0", "1,2"]
}
const DEFAULT_TEMPLATES: SeatingTemplate[] = [
{
id: 'tpl-standard-30',
name: 'Phòng Lab Standard (30 máy)',
rows: 5,
cols: 6,
boardPosition: 'top',
lockedSeats: [],
},
{
id: 'tpl-aisle-large',
name: 'Phòng Máy A1 (Có lối đi giữa)',
rows: 6,
cols: 8,
boardPosition: 'top',
lockedSeats: ['0,3', '1,3', '2,3', '3,3', '4,3', '5,3'],
},
];
export const SeatingTemplatesSection: React.FC = () => {
const [templates, setTemplates] = useState<SeatingTemplate[]>([]);
const [editing, setEditing] = useState<SeatingTemplate | null>(null);
const [creating, setCreating] = useState(false);
// Form states
const [name, setName] = useState('');
const [rows, setRows] = useState(5);
const [cols, setCols] = useState(6);
const [boardPosition, setBoardPosition] = useState<'top' | 'bottom' | 'left' | 'right'>('top');
const [lockedSeats, setLockedSeats] = useState<string[]>([]);
const [error, setError] = useState('');
useEffect(() => {
const stored = localStorage.getItem('sc_seating_templates');
if (stored) {
try {
setTemplates(JSON.parse(stored));
} catch {
setTemplates(DEFAULT_TEMPLATES);
}
} else {
localStorage.setItem('sc_seating_templates', JSON.stringify(DEFAULT_TEMPLATES));
setTemplates(DEFAULT_TEMPLATES);
}
}, []);
const saveTemplates = (newTemplates: SeatingTemplate[]) => {
localStorage.setItem('sc_seating_templates', JSON.stringify(newTemplates));
setTemplates(newTemplates);
};
const handleOpenCreate = () => {
setCreating(true);
setEditing(null);
setName('');
setRows(5);
setCols(6);
setBoardPosition('top');
setLockedSeats([]);
setError('');
};
const handleOpenEdit = (tpl: SeatingTemplate) => {
setEditing(tpl);
setCreating(false);
setName(tpl.name);
setRows(tpl.rows);
setCols(tpl.cols);
setBoardPosition(tpl.boardPosition);
setLockedSeats(tpl.lockedSeats || []);
setError('');
};
const handleCloseForm = () => {
setCreating(false);
setEditing(null);
setError('');
};
const handleToggleLock = (r: number, c: number) => {
const key = `${r},${c}`;
if (lockedSeats.includes(key)) {
setLockedSeats(lockedSeats.filter((k) => k !== key));
} else {
setLockedSeats([...lockedSeats, key]);
}
};
const handleSave = () => {
if (!name.trim()) {
setError('Vui lòng nhập tên template.');
return;
}
// Clean up locked seats that fall outside the current rows/cols boundary
const validLockedSeats = lockedSeats.filter((key) => {
const [r, c] = key.split(',').map(Number);
return r < rows && c < cols;
});
const newTemplate: SeatingTemplate = {
id: editing ? editing.id : `tpl-${Date.now()}`,
name: name.trim(),
rows,
cols,
boardPosition,
lockedSeats: validLockedSeats,
};
let updatedList: SeatingTemplate[];
if (editing) {
updatedList = templates.map((t) => (t.id === editing.id ? newTemplate : t));
} else {
updatedList = [...templates, newTemplate];
}
saveTemplates(updatedList);
handleCloseForm();
};
const handleDelete = (tpl: SeatingTemplate) => {
if (confirm(`Bạn có chắc chắn muốn xóa template "${tpl.name}"?`)) {
const updatedList = templates.filter((t) => t.id !== tpl.id);
saveTemplates(updatedList);
if (editing?.id === tpl.id) {
handleCloseForm();
}
}
};
// Helper to render preview grid in edit/create form
const renderDesignerGrid = () => {
const gridItems = [];
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const key = `${r},${c}`;
const isLocked = lockedSeats.includes(key);
gridItems.push(
<div
key={key}
onClick={() => handleToggleLock(r, c)}
className={`seating-designer-cell ${isLocked ? 'locked' : 'available'}`}
style={{
padding: '0.5rem',
border: '1px solid var(--border-color)',
borderRadius: '6px',
textAlign: 'center',
cursor: 'pointer',
fontSize: '0.78rem',
fontWeight: 600,
userSelect: 'none',
backgroundColor: isLocked ? '#e5e7eb' : '#eff6ff',
color: isLocked ? '#9ca3af' : '#1e40af',
borderColor: isLocked ? '#d1d5db' : '#bfdbfe',
transition: 'var(--transition)',
}}
>
{isLocked ? '🔒 Khóa' : `H${r + 1}-C${c + 1}`}
</div>
);
}
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', margin: '1rem 0' }}>
<div style={{ display: 'flex', justifyContent: 'center' }}>
{boardPosition === 'top' && (
<div className="seating-board-label" style={{ width: '60%', textAlign: 'center', background: '#374151', color: '#fff', padding: '0.25rem 0.5rem', borderRadius: '4px', fontSize: '0.8rem', fontWeight: 700 }}>
📢 BẢNG / GIẢNG ĐƯNG (TOP)
</div>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', justifyContent: 'center' }}>
{boardPosition === 'left' && (
<div className="seating-board-label" style={{ writingMode: 'vertical-lr', textOrientation: 'mixed', textAlign: 'center', background: '#374151', color: '#fff', padding: '0.5rem 0.25rem', borderRadius: '4px', fontSize: '0.8rem', fontWeight: 700, minHeight: '100px' }}>
📢 BẢNG (LEFT)
</div>
)}
<div
style={{
display: 'grid',
gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
gap: '6px',
flex: 1,
maxHeight: '320px',
overflowY: 'auto',
padding: '4px',
}}
>
{gridItems}
</div>
{boardPosition === 'right' && (
<div className="seating-board-label" style={{ writingMode: 'vertical-lr', textOrientation: 'mixed', textAlign: 'center', background: '#374151', color: '#fff', padding: '0.5rem 0.25rem', borderRadius: '4px', fontSize: '0.8rem', fontWeight: 700, minHeight: '100px' }}>
📢 BẢNG (RIGHT)
</div>
)}
</div>
<div style={{ display: 'flex', justifyContent: 'center' }}>
{boardPosition === 'bottom' && (
<div className="seating-board-label" style={{ width: '60%', textAlign: 'center', background: '#374151', color: '#fff', padding: '0.25rem 0.5rem', borderRadius: '4px', fontSize: '0.8rem', fontWeight: 700 }}>
📢 BẢNG / GIẢNG ĐƯNG (BOTTOM)
</div>
)}
</div>
</div>
);
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<h3 style={{ margin: 0, fontSize: '1.1rem', fontWeight: 700 }}>Danh sách mẫu đ chỗ ngồi</h3>
<p style={{ margin: '4px 0 0 0', color: 'var(--text-secondary)', fontSize: '0.82rem' }}>
Thiết lập sẵn cấu trúc hàng, cột, bảng các vị trí máy hỏng hoặc lối đi đ áp dụng nhanh cho lớp hoặc phòng thi.
</p>
</div>
{!creating && !editing && (
<button className="btn btn-primary" onClick={handleOpenCreate}>
+ Thêm đ mẫu
</button>
)}
</div>
{(creating || editing) ? (
<div className="content-card" style={{ padding: '1.5rem', display: 'grid', gridTemplateColumns: '1fr 1.5fr', gap: '2rem' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem', borderRight: '1px solid var(--border-color)', paddingRight: '2rem' }}>
<h4 style={{ margin: 0, fontSize: '0.95rem' }}>{creating ? 'Thêm sơ đồ mẫu mới' : 'Chỉnh sửa sơ đồ mẫu'}</h4>
{error && <div style={{ color: 'var(--danger)', fontSize: '0.82rem', fontWeight: 600 }}>{error}</div>}
<label className="checkbox-label" style={{ display: 'flex', flexDirection: 'column', gap: '4px', alignItems: 'flex-start' }}>
<span style={{ fontSize: '0.82rem', fontWeight: 600 }}>Tên đ</span>
<input
type="text"
className="search-input"
style={{ width: '100%' }}
placeholder="Ví dụ: Phòng Lab 302, Phòng A2"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</label>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
<label className="checkbox-label" style={{ display: 'flex', flexDirection: 'column', gap: '4px', alignItems: 'flex-start' }}>
<span style={{ fontSize: '0.82rem', fontWeight: 600 }}>Số hàng (dọc)</span>
<input
type="number"
min={1}
max={10}
className="search-input"
style={{ width: '100%' }}
value={rows}
onChange={(e) => setRows(Math.max(1, Math.min(10, Number(e.target.value))))}
/>
</label>
<label className="checkbox-label" style={{ display: 'flex', flexDirection: 'column', gap: '4px', alignItems: 'flex-start' }}>
<span style={{ fontSize: '0.82rem', fontWeight: 600 }}>Số cột (ngang)</span>
<input
type="number"
min={1}
max={15}
className="search-input"
style={{ width: '100%' }}
value={cols}
onChange={(e) => setCols(Math.max(1, Math.min(15, Number(e.target.value))))}
/>
</label>
</div>
<label className="checkbox-label" style={{ display: 'flex', flexDirection: 'column', gap: '4px', alignItems: 'flex-start' }}>
<span style={{ fontSize: '0.82rem', fontWeight: 600 }}>Vị trí bảng viết</span>
<select
className="select-filter"
style={{ width: '100%', padding: '0.5rem' }}
value={boardPosition}
onChange={(e) => setBoardPosition(e.target.value as any)}
>
<option value="top">Phía trước (Top)</option>
<option value="bottom">Phía sau (Bottom)</option>
<option value="left">Bên trái (Left)</option>
<option value="right">Bên phải (Right)</option>
</select>
</label>
<div style={{ display: 'flex', gap: '10px', marginTop: '1rem' }}>
<button className="btn btn-primary" style={{ flex: 1 }} onClick={handleSave}>
Lưu lại
</button>
<button className="btn btn-secondary" style={{ flex: 1 }} onClick={handleCloseForm}>
Hủy bỏ
</button>
</div>
</div>
<div>
<h4 style={{ margin: '0 0 4px 0', fontSize: '0.95rem' }}>Thiết kế vị trí khóa</h4>
<p style={{ margin: '0 0 1rem 0', color: 'var(--text-secondary)', fontSize: '0.78rem' }}>
Click chuột vào các ô bên dưới đ chuyển đi trạng thái giữa <strong>Chỗ trống khả dụng</strong> <strong>Vị trí khóa (Không thể xếp sinh viên)</strong>.
</p>
{renderDesignerGrid()}
</div>
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: '1rem' }}>
{templates.map((tpl) => (
<div key={tpl.id} className="content-card" style={{ padding: '1.25rem', display: 'flex', flexDirection: 'column', gap: '0.75rem', position: 'relative' }}>
<div>
<h4 style={{ margin: 0, fontSize: '0.95rem', fontWeight: 700 }}>{tpl.name}</h4>
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', marginTop: '4px' }}>
Kích thước: <strong>{tpl.rows} hàng &times; {tpl.cols} cột</strong> ({tpl.rows * tpl.cols} vị trí)
</div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)' }}>
Bảng : <strong>{tpl.boardPosition.toUpperCase()}</strong>
</div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)' }}>
Vị trí khóa: <strong>{tpl.lockedSeats ? tpl.lockedSeats.length : 0} ô</strong>
</div>
</div>
<div style={{ display: 'flex', gap: '8px', marginTop: 'auto' }}>
<button className="btn btn-secondary btn-sm" style={{ flex: 1 }} onClick={() => handleOpenEdit(tpl)}>
Chỉnh sửa
</button>
<button
className="btn btn-secondary btn-sm text-danger"
style={{ flex: 1, borderColor: '#fca5a5', color: 'var(--danger)' }}
onClick={() => handleDelete(tpl)}
>
Xóa
</button>
</div>
</div>
))}
</div>
)}
</div>
);
};

View File

@@ -8,6 +8,7 @@ import {
import { OrganizationSection } from './OrganizationSection';
import { NetworkSection } from './NetworkSection';
import { AppTemplatesSection } from './AppTemplatesSection';
import { SeatingTemplatesSection } from './SeatingTemplatesSection';
const SECTIONS: { id: SystemSection; icon: React.ReactNode; hint: string }[] = [
{
@@ -40,6 +41,19 @@ const SECTIONS: { id: SystemSection; icon: React.ReactNode; hint: string }[] = [
</svg>
),
},
{
id: 'seating',
hint: 'Sơ đồ chỗ ngồi',
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" />
<line x1="9" y1="3" x2="9" y2="21" />
<line x1="15" y1="3" x2="15" y2="21" />
<line x1="3" y1="9" x2="21" y2="9" />
<line x1="3" y1="15" x2="21" y2="15" />
</svg>
),
},
];
export const SystemTab: React.FC = () => {
@@ -86,6 +100,7 @@ export const SystemTab: React.FC = () => {
{section === 'organization' && <OrganizationSection />}
{section === 'network' && <NetworkSection />}
{section === 'templates' && <AppTemplatesSection />}
{section === 'seating' && <SeatingTemplatesSection />}
</div>
</div>
);

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>
);
};

View File

@@ -1,4 +1,4 @@
export type SystemSection = 'organization' | 'network' | 'templates';
export type SystemSection = 'organization' | 'network' | 'templates' | 'seating';
export type TabId = 'dashboard' | 'classes' | 'students' | 'learning' | 'exams' | 'system' | 'profile' | 'student-affairs' | 'applications';
@@ -17,6 +17,7 @@ export const SYSTEM_SECTION_LABELS: Record<SystemSection, string> = {
organization: 'Tổ chức',
network: 'Gói mạng',
templates: 'Khung ứng dụng',
seating: 'Sơ đồ chỗ ngồi',
};
export const TAB_LABELS: Record<TabId, string> = {
@@ -49,7 +50,7 @@ function isTabId(value: string | null): value is TabId {
}
function isSystemSection(value: string | null): value is SystemSection {
return value === 'organization' || value === 'network' || value === 'templates';
return value === 'organization' || value === 'network' || value === 'templates' || value === 'seating';
}
function resolveTabAndSection(tabParam: string | null): { tab: TabId; systemSection: SystemSection } {