356 lines
14 KiB
TypeScript
356 lines
14 KiB
TypeScript
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>
|
|
);
|
|
};
|