This commit is contained in:
@@ -95,6 +95,7 @@ const SYSTEM_NAV: { section: SystemSection }[] = [
|
||||
{ section: 'organization' },
|
||||
{ section: 'network' },
|
||||
{ section: 'templates' },
|
||||
{ section: 'seating' },
|
||||
];
|
||||
|
||||
function App() {
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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 có 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}
|
||||
|
||||
355
management/src/components/SeatingTemplatesSection.tsx
Normal file
355
management/src/components/SeatingTemplatesSection.tsx
Normal 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 sơ đồ 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 và 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 sơ đồ 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 sơ đồ</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> và <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 × {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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -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 } {
|
||||
|
||||
Reference in New Issue
Block a user