This commit is contained in:
@@ -44,6 +44,32 @@ export const WorkspaceSeatingChart: React.FC<WorkspaceSeatingChartProps> = ({
|
|||||||
const [dragOverCell, setDragOverCell] = useState<string | null>(null);
|
const [dragOverCell, setDragOverCell] = useState<string | null>(null);
|
||||||
const [saveNotice, setSaveNotice] = useState<string | null>(null);
|
const [saveNotice, setSaveNotice] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Toggle layout view modes
|
||||||
|
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||||
|
const [listSearchQuery, setListSearchQuery] = useState('');
|
||||||
|
|
||||||
|
// Reverse map seat positions for list view
|
||||||
|
const studentSeats = useMemo(() => {
|
||||||
|
if (!layout) return {} as Record<number, string>;
|
||||||
|
const mapping: Record<number, string> = {};
|
||||||
|
Object.entries(layout.seats).forEach(([cellKey, rkId]) => {
|
||||||
|
const [rStr, cStr] = cellKey.split(',');
|
||||||
|
const r = Number(rStr) + 1;
|
||||||
|
const c = Number(cStr) + 1;
|
||||||
|
mapping[rkId] = `Hàng ${r}, Cột ${c}`;
|
||||||
|
});
|
||||||
|
return mapping;
|
||||||
|
}, [layout]);
|
||||||
|
|
||||||
|
// Filter student list
|
||||||
|
const filteredListStudents = useMemo(() => {
|
||||||
|
return students.filter(
|
||||||
|
(s) =>
|
||||||
|
s.fullName.toLowerCase().includes(listSearchQuery.toLowerCase()) ||
|
||||||
|
s.studentCode.toLowerCase().includes(listSearchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
}, [students, listSearchQuery]);
|
||||||
|
|
||||||
// Load templates and layout
|
// Load templates and layout
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Load templates
|
// Load templates
|
||||||
@@ -531,127 +557,233 @@ export const WorkspaceSeatingChart: React.FC<WorkspaceSeatingChartProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
// Seating Workspace View
|
// Seating Workspace View
|
||||||
<div className="seating-workspace-grid">
|
<div
|
||||||
|
className={viewMode === 'grid' ? "seating-workspace-grid" : "seating-workspace-main"}
|
||||||
|
style={viewMode === 'list' ? { display: 'flex', flexDirection: 'column', height: '100%' } : undefined}
|
||||||
|
>
|
||||||
{/* Main Seating Panel */}
|
{/* Main Seating Panel */}
|
||||||
<div className="seating-workspace-main">
|
<div className="seating-workspace-main" style={{ height: '100%' }}>
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '10px' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '10px' }}>
|
||||||
<div style={{ display: 'flex', gap: '8px' }}>
|
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||||
<button className="btn btn-primary btn-sm" onClick={handleAutoAssign}>
|
<div style={{ display: 'flex', gap: '2px', marginRight: '10px', borderRight: '1px solid var(--border-color)', paddingRight: '10px' }}>
|
||||||
⚡ Xếp ngẫu nhiên
|
<button
|
||||||
</button>
|
type="button"
|
||||||
<button className="btn btn-secondary btn-sm" onClick={handleClearAllSeats}>
|
className={`btn btn-sm ${viewMode === 'grid' ? 'btn-primary' : 'btn-secondary'}`}
|
||||||
🗑️ Giải tán chỗ
|
onClick={() => setViewMode('grid')}
|
||||||
</button>
|
style={{
|
||||||
|
backgroundColor: viewMode === 'grid' ? 'var(--accent)' : '',
|
||||||
|
borderColor: viewMode === 'grid' ? 'var(--accent)' : '',
|
||||||
|
color: viewMode === 'grid' ? '#fff' : '',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🪑 Sơ đồ
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-sm ${viewMode === 'list' ? 'btn-primary' : 'btn-secondary'}`}
|
||||||
|
onClick={() => setViewMode('list')}
|
||||||
|
style={{
|
||||||
|
backgroundColor: viewMode === 'list' ? 'var(--accent)' : '',
|
||||||
|
borderColor: viewMode === 'list' ? 'var(--accent)' : '',
|
||||||
|
color: viewMode === 'list' ? '#fff' : '',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
📋 Danh sách
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{viewMode === 'grid' && (
|
||||||
|
<>
|
||||||
|
<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>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
{viewMode === 'grid' && (
|
||||||
<button
|
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||||
className={`btn btn-sm ${isEditingStructure ? 'btn-primary' : 'btn-secondary'}`}
|
<button
|
||||||
onClick={() => setIsEditingStructure(!isEditingStructure)}
|
className={`btn btn-sm ${isEditingStructure ? 'btn-primary' : 'btn-secondary'}`}
|
||||||
style={{
|
onClick={() => setIsEditingStructure(!isEditingStructure)}
|
||||||
backgroundColor: isEditingStructure ? 'var(--accent)' : '',
|
style={{
|
||||||
borderColor: isEditingStructure ? 'var(--accent)' : '',
|
backgroundColor: isEditingStructure ? 'var(--accent)' : '',
|
||||||
color: isEditingStructure ? '#fff' : '',
|
borderColor: isEditingStructure ? 'var(--accent)' : '',
|
||||||
}}
|
color: isEditingStructure ? '#fff' : '',
|
||||||
>
|
}}
|
||||||
{isEditingStructure ? '✔️ Xong thiết lập' : '⚙️ Khóa/Mở ô chỗ'}
|
>
|
||||||
</button>
|
{isEditingStructure ? '✔️ Xong thiết lập' : '⚙️ Khóa/Mở ô chỗ'}
|
||||||
<button className="btn btn-secondary btn-sm text-danger" style={{ borderColor: '#fca5a5' }} onClick={handleResetLayout}>
|
</button>
|
||||||
Reset sơ đồ
|
<button className="btn btn-secondary btn-sm text-danger" style={{ borderColor: '#fca5a5' }} onClick={handleResetLayout}>
|
||||||
</button>
|
Reset sơ đồ
|
||||||
</div>
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isEditingStructure && (
|
{viewMode === 'grid' ? (
|
||||||
<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í.
|
{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>
|
||||||
|
)}
|
||||||
|
{renderSeatingGrid()}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// List View of Students
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, gap: '10px' }}>
|
||||||
|
<div className="attendance-toolbar" style={{ display: 'flex', gap: '10px', margin: 0 }}>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
placeholder="Tìm sinh viên..."
|
||||||
|
className="search-input"
|
||||||
|
style={{ maxWidth: '320px', padding: '0.4rem 0.6rem', fontSize: '0.85rem' }}
|
||||||
|
value={listSearchQuery}
|
||||||
|
onChange={(e) => setListSearchQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="attendance-table-scroll table-wrapper" style={{ border: 'none', flex: 1, overflowY: 'auto' }}>
|
||||||
|
{filteredListStudents.length === 0 ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '3rem 0', color: 'var(--text-muted)', fontSize: '0.9rem' }}>
|
||||||
|
Không tìm thấy sinh viên nào.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Sinh viên</th>
|
||||||
|
<th>Mã SV</th>
|
||||||
|
<th>Vị trí chỗ ngồi</th>
|
||||||
|
<th>Trạng thái</th>
|
||||||
|
<th style={{ textAlign: 'right' }}>Thao tác</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredListStudents.map((s) => {
|
||||||
|
const isOnline = onlineIds.includes(s.rkId);
|
||||||
|
const seatLabel = studentSeats[s.rkId] || 'Chưa xếp chỗ';
|
||||||
|
return (
|
||||||
|
<tr key={s.rkId}>
|
||||||
|
<td>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
|
<StudentAvatar fullName={s.fullName} avatar={s.avatar} isOnline={isOnline} size={28} />
|
||||||
|
<div style={{ fontWeight: 600, color: 'var(--text-primary)' }}>{s.fullName}</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td><code>{s.studentCode}</code></td>
|
||||||
|
<td>
|
||||||
|
<span className={`badge ${studentSeats[s.rkId] ? 'badge-info' : 'badge-muted'}`}>
|
||||||
|
{seatLabel}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ color: isOnline ? 'var(--success)' : 'var(--text-muted)', fontWeight: 700 }}>
|
||||||
|
{isOnline ? 'Online' : 'Offline'}
|
||||||
|
</td>
|
||||||
|
<td style={{ textAlign: 'right' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
onClick={() => onStudentClick && onStudentClick(s.rkId)}
|
||||||
|
>
|
||||||
|
Giám sát
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Render Grid */}
|
|
||||||
{renderSeatingGrid()}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Panel: Unseated Students list */}
|
{/* Right Panel: Unseated Students list */}
|
||||||
<div className="seating-workspace-sidebar">
|
{viewMode === 'grid' && (
|
||||||
<div>
|
<div className="seating-workspace-sidebar">
|
||||||
<h4 style={{ margin: 0, fontSize: '0.95rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<div>
|
||||||
<span>Chưa vào chỗ</span>
|
<h4 style={{ margin: 0, fontSize: '0.95rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<span className="badge" style={{ background: 'var(--accent-light)', color: 'var(--accent)', fontSize: '0.75rem', fontWeight: 700, padding: '0.15rem 0.4rem', borderRadius: '10px' }}>
|
<span>Chưa vào chỗ</span>
|
||||||
{unseatedStudents.length} SV
|
<span className="badge" style={{ background: 'var(--accent-light)', color: 'var(--accent)', fontSize: '0.75rem', fontWeight: 700, padding: '0.15rem 0.4rem', borderRadius: '10px' }}>
|
||||||
</span>
|
{unseatedStudents.length} SV
|
||||||
</h4>
|
</span>
|
||||||
<p style={{ margin: '4px 0 0 0', color: 'var(--text-secondary)', fontSize: '0.78rem' }}>
|
</h4>
|
||||||
Kéo thả sinh viên bên dưới vào vị trí trống trên sơ đồ.
|
<p style={{ margin: '4px 0 0 0', color: 'var(--text-secondary)', fontSize: '0.78rem' }}>
|
||||||
</p>
|
Kéo thả sinh viên bên dưới vào vị trí trống trên sơ đồ.
|
||||||
</div>
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
placeholder="Tìm tên, mã sinh viên..."
|
placeholder="Tìm tên, mã sinh viên..."
|
||||||
className="search-input"
|
className="search-input"
|
||||||
style={{ width: '100%', padding: '0.4rem 0.6rem', fontSize: '0.8rem' }}
|
style={{ width: '100%', padding: '0.4rem 0.6rem', fontSize: '0.8rem' }}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
gap: '8px',
|
gap: '8px',
|
||||||
paddingRight: '4px',
|
paddingRight: '4px',
|
||||||
minHeight: '300px',
|
minHeight: '300px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{filteredUnseatedStudents.length === 0 ? (
|
{filteredUnseatedStudents.length === 0 ? (
|
||||||
<div style={{ textAlign: 'center', padding: '2rem 0', color: 'var(--text-muted)', fontSize: '0.8rem' }}>
|
<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.
|
Không tìm thấy sinh viên nào.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
filteredUnseatedStudents.map((s) => {
|
filteredUnseatedStudents.map((s) => {
|
||||||
const isOnline = onlineIds.includes(s.rkId);
|
const isOnline = onlineIds.includes(s.rkId);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={s.rkId}
|
key={s.rkId}
|
||||||
draggable
|
draggable
|
||||||
onDragStart={(e) => handleDragStart(e, `unseated:${s.rkId}`)}
|
onDragStart={(e) => handleDragStart(e, `unseated:${s.rkId}`)}
|
||||||
onClick={() => onStudentClick && onStudentClick(s.rkId)}
|
onClick={() => onStudentClick && onStudentClick(s.rkId)}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '0.5rem',
|
gap: '0.5rem',
|
||||||
padding: '0.5rem',
|
padding: '0.5rem',
|
||||||
border: '1px solid var(--border-color)',
|
border: '1px solid var(--border-color)',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: '#ffffff',
|
||||||
cursor: onStudentClick ? 'pointer' : 'grab',
|
cursor: onStudentClick ? 'pointer' : 'grab',
|
||||||
transition: 'var(--transition)',
|
transition: 'var(--transition)',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
boxShadow: 'var(--shadow-sm)',
|
boxShadow: 'var(--shadow-sm)',
|
||||||
}}
|
}}
|
||||||
className="student-drag-item"
|
className="student-drag-item"
|
||||||
>
|
>
|
||||||
<StudentAvatar fullName={s.fullName} avatar={s.avatar} isOnline={isOnline} size={28} />
|
<StudentAvatar fullName={s.fullName} avatar={s.avatar} isOnline={isOnline} size={28} />
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', overflow: 'hidden', flex: 1 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', overflow: 'hidden', flex: 1 }}>
|
||||||
<span style={{ fontSize: '0.8rem', fontWeight: 600, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
<span style={{ fontSize: '0.8rem', fontWeight: 600, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||||
{s.fullName}
|
{s.fullName}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ fontSize: '0.7rem', color: 'var(--text-secondary)', fontFamily: 'monospace' }}>
|
<span style={{ fontSize: '0.7rem', color: 'var(--text-secondary)', fontFamily: 'monospace' }}>
|
||||||
{s.studentCode}
|
{s.studentCode}
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className={isOnline ? 'pulse-dot-online' : 'status-badge-offline'} style={{ width: '6px', height: '6px' }} />
|
||||||
</div>
|
</div>
|
||||||
<span className={isOnline ? 'pulse-dot-online' : 'status-badge-offline'} style={{ width: '6px', height: '6px' }} />
|
);
|
||||||
</div>
|
})
|
||||||
);
|
)}
|
||||||
})
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user