This commit is contained in:
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user