t1
This commit is contained in:
430
management/src/components/ClassesTab.tsx
Normal file
430
management/src/components/ClassesTab.tsx
Normal file
@@ -0,0 +1,430 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { api } from '../api';
|
||||
import type { ClassItem, StudentItem, SyncStatus } from '../api';
|
||||
|
||||
export const ClassesTab: React.FC = () => {
|
||||
const [classes, setClasses] = useState<ClassItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize] = useState(15);
|
||||
const [search, setSearch] = useState('');
|
||||
const [systemFilter, setSystemFilter] = useState<number | undefined>(undefined);
|
||||
const [studyingOnly, setStudyingOnly] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Sync state
|
||||
const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null);
|
||||
|
||||
// Modal detail class
|
||||
const [activeClass, setActiveClass] = useState<ClassItem | null>(null);
|
||||
const [modalStudents, setModalStudents] = useState<StudentItem[]>([]);
|
||||
const [modalLoading, setModalLoading] = useState(false);
|
||||
|
||||
const fetchClasses = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await api.getClasses({
|
||||
page,
|
||||
pageSize,
|
||||
q: search || undefined,
|
||||
systemRkId: systemFilter,
|
||||
studyingOnly,
|
||||
});
|
||||
setClasses(res.data);
|
||||
setTotal(res.total);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSyncStatus = async () => {
|
||||
try {
|
||||
const status = await api.getClassesSyncStatus();
|
||||
setSyncStatus(status);
|
||||
return status.running;
|
||||
} catch (err) {
|
||||
console.error('Lỗi khi lấy status sync:', err);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchClasses();
|
||||
}, [page, search, systemFilter, studyingOnly]);
|
||||
|
||||
// Check sync status on load and start polling if running
|
||||
useEffect(() => {
|
||||
fetchSyncStatus();
|
||||
}, []);
|
||||
|
||||
// Sync polling logic
|
||||
useEffect(() => {
|
||||
let timer: any;
|
||||
if (syncStatus?.running) {
|
||||
timer = setInterval(async () => {
|
||||
const isRunning = await fetchSyncStatus();
|
||||
if (!isRunning) {
|
||||
clearInterval(timer);
|
||||
fetchClasses(); // Reload data when sync completes
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
return () => {
|
||||
if (timer) clearInterval(timer);
|
||||
};
|
||||
}, [syncStatus?.running]);
|
||||
|
||||
const handleStartSync = async () => {
|
||||
try {
|
||||
await api.startClassesSync();
|
||||
// Set local state to running to trigger useEffect poller
|
||||
setSyncStatus({
|
||||
running: true,
|
||||
done: false,
|
||||
total: 0,
|
||||
synced: 0,
|
||||
updatedAt: Date.now() / 1000
|
||||
});
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Không thể bắt đầu đồng bộ lớp học');
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleStudying = async (cItem: ClassItem) => {
|
||||
const nextVal = !cItem.isStudying;
|
||||
try {
|
||||
// Optimistic update
|
||||
setClasses(prev => prev.map(c => c.rkId === cItem.rkId ? { ...c, isStudying: nextVal } : c));
|
||||
await api.updateClassStudying(cItem.rkId, nextVal);
|
||||
} catch (err) {
|
||||
// Revert if error
|
||||
setClasses(prev => prev.map(c => c.rkId === cItem.rkId ? { ...c, isStudying: !nextVal } : c));
|
||||
alert('Không thể cập nhật trạng thái lớp học');
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenStudentsModal = async (cItem: ClassItem) => {
|
||||
setActiveClass(cItem);
|
||||
setModalStudents([]);
|
||||
try {
|
||||
setModalLoading(true);
|
||||
const res = await api.getClassStudents(cItem.rkId);
|
||||
setModalStudents(res.data);
|
||||
} catch (err) {
|
||||
alert('Không thể tải danh sách học sinh của lớp');
|
||||
} finally {
|
||||
setModalLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Tính toán % tiến trình sync
|
||||
const syncPercent = syncStatus && syncStatus.total > 0
|
||||
? Math.round((syncStatus.synced / syncStatus.total) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
|
||||
<div className="page-header">
|
||||
<div className="page-title">
|
||||
<h1>Quản Lý Lớp Học</h1>
|
||||
<p>Đồng bộ dữ liệu lớp học và sinh viên từ hệ thống chính</p>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleStartSync}
|
||||
disabled={syncStatus?.running}
|
||||
>
|
||||
{syncStatus?.running ? (
|
||||
<>
|
||||
<div className="sync-spinner"></div> Đồng bộ...
|
||||
</>
|
||||
) : (
|
||||
'🔄 Đồng bộ Lớp học'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sync Status Banner */}
|
||||
{syncStatus && (syncStatus.running || syncStatus.done || syncStatus.error) && (
|
||||
<div className="sync-progress-banner">
|
||||
<div className="sync-header">
|
||||
<div className="sync-title">
|
||||
{syncStatus.running && <div className="sync-spinner"></div>}
|
||||
<span>
|
||||
{syncStatus.running && `Đang đồng bộ lớp học... (${syncPercent}%)`}
|
||||
{syncStatus.done && '🎉 Đồng bộ lớp học hoàn tất!'}
|
||||
{syncStatus.error && `❌ Đồng bộ thất bại: ${syncStatus.error}`}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>
|
||||
Đã đồng bộ: {syncStatus.synced} / {syncStatus.total} lớp
|
||||
</div>
|
||||
</div>
|
||||
{syncStatus.running && (
|
||||
<div className="sync-bar-container">
|
||||
<div className="sync-bar" style={{ width: `${syncPercent}%` }}></div>
|
||||
</div>
|
||||
)}
|
||||
<div className="sync-meta">
|
||||
<span>Cập nhật lần cuối: {new Date(syncStatus.updatedAt * 1000).toLocaleString()}</span>
|
||||
{syncStatus.running && <span style={{ color: 'var(--accent-hover)' }}>Vui lòng không đóng trình duyệt</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Control bar filters */}
|
||||
<div className="control-bar">
|
||||
<div className="search-input-wrapper">
|
||||
<input
|
||||
type="text"
|
||||
className="search-input"
|
||||
placeholder="Tìm theo tên, mã lớp, chuyên ngành..."
|
||||
value={search}
|
||||
onChange={e => {
|
||||
setSearch(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<span className="search-icon">🔍</span>
|
||||
</div>
|
||||
|
||||
<div className="filters-wrapper">
|
||||
<select
|
||||
className="select-filter"
|
||||
value={systemFilter === undefined ? '' : systemFilter}
|
||||
onChange={e => {
|
||||
const val = e.target.value;
|
||||
setSystemFilter(val === '' ? undefined : Number(val));
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="">Tất cả phân hệ</option>
|
||||
<option value="1">Hà Nội (System 1)</option>
|
||||
<option value="2">Hồ Chí Minh (System 2)</option>
|
||||
<option value="3">Đà Nẵng (System 3)</option>
|
||||
<option value="4">Cần Thơ (System 4)</option>
|
||||
</select>
|
||||
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', fontSize: '0.9rem' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={studyingOnly}
|
||||
onChange={e => {
|
||||
setStudyingOnly(e.target.checked);
|
||||
setPage(1);
|
||||
}}
|
||||
style={{ width: '16px', height: '16px', accentColor: 'var(--accent)' }}
|
||||
/>
|
||||
Chỉ xem lớp đang học
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Classes list table */}
|
||||
<div className="table-wrapper">
|
||||
{loading ? (
|
||||
<div className="empty-state">
|
||||
<div className="sync-spinner" style={{ width: '32px', height: '32px' }}></div>
|
||||
<p style={{ marginTop: '0.5rem' }}>Đang tải danh sách lớp học...</p>
|
||||
</div>
|
||||
) : classes.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon">🏫</div>
|
||||
<h2>Không tìm thấy lớp học nào</h2>
|
||||
<p>Hãy thử thay đổi bộ lọc hoặc bấm nút "Đồng bộ Lớp học" để kéo dữ liệu mới.</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Mã lớp / ID</th>
|
||||
<th>Tên Lớp học</th>
|
||||
<th>Phân hệ</th>
|
||||
<th>Môn học của lớp</th>
|
||||
<th>Sinh viên</th>
|
||||
<th>Đang dạy</th>
|
||||
<th style={{ textAlign: 'right' }}>Hành động</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{classes.map(cl => (
|
||||
<tr key={cl.rkId}>
|
||||
<td>
|
||||
<div style={{ fontWeight: 600 }}>{cl.classCode}</div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>ID: {cl.rkId}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div style={{ fontWeight: 500, color: 'white' }}>{cl.name}</div>
|
||||
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)' }}>
|
||||
{cl.specializeName || '—'}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className="badge badge-muted">
|
||||
{cl.systemName || `Hệ thống ${cl.systemRkId || 'Khác'}`}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className="courses-tag-list">
|
||||
{cl.courses && cl.courses.length > 0 ? (
|
||||
cl.courses.map((co, i) => (
|
||||
<span key={i} className="course-tag">
|
||||
{co.courseName}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>Chưa có môn học</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className="badge badge-info">
|
||||
👤 {cl.studentCount} sinh viên
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className="switch-container">
|
||||
<label className="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={cl.isStudying}
|
||||
onChange={() => handleToggleStudying(cl)}
|
||||
/>
|
||||
<span className="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
style={{ padding: '0.45rem 0.85rem', fontSize: '0.8rem' }}
|
||||
onClick={() => handleOpenStudentsModal(cl)}
|
||||
>
|
||||
Chi tiết 👥
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination control bar */}
|
||||
{!loading && classes.length > 0 && (
|
||||
<div className="pagination-row">
|
||||
<div>
|
||||
Hiển thị lớp thứ <b>{((page - 1) * pageSize) + 1}</b> đến <b>{Math.min(page * pageSize, total)}</b> trong tổng số <b>{total}</b> lớp học
|
||||
</div>
|
||||
<div className="pagination-btn-group">
|
||||
<button
|
||||
className="pagination-btn"
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
◀
|
||||
</button>
|
||||
<span style={{ display: 'flex', alignItems: 'center', padding: '0 1rem', fontWeight: 600, color: 'white' }}>
|
||||
Trang {page} / {Math.ceil(total / pageSize) || 1}
|
||||
</span>
|
||||
<button
|
||||
className="pagination-btn"
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
disabled={page >= Math.ceil(total / pageSize)}
|
||||
>
|
||||
▶
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Students roster modal */}
|
||||
{activeClass && (
|
||||
<div className="modal-overlay" onClick={() => setActiveClass(null)}>
|
||||
<div className="modal-container" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<div className="modal-title">
|
||||
<h3>Danh sách sinh viên lớp: {activeClass.name}</h3>
|
||||
<p style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', marginTop: '0.2rem' }}>
|
||||
Mã lớp: {activeClass.classCode} | Sĩ số: {activeClass.studentCount}
|
||||
</p>
|
||||
</div>
|
||||
<button className="modal-close-btn" onClick={() => setActiveClass(null)}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
<div className="detail-grid">
|
||||
<div>
|
||||
<div className="detail-item-label">Chuyên Ngành</div>
|
||||
<div className="detail-item-value">{activeClass.specializeName || 'Chưa phân phối'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="detail-item-label">Chi Nhánh / Phân Hệ</div>
|
||||
<div className="detail-item-value">{activeClass.systemName || 'Chung'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="detail-item-label">Loại Lớp</div>
|
||||
<div className="detail-item-value" style={{ textTransform: 'capitalize' }}>{activeClass.type || 'Chính thức'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{modalLoading ? (
|
||||
<div className="empty-state" style={{ minHeight: '200px' }}>
|
||||
<div className="sync-spinner"></div>
|
||||
<p style={{ marginTop: '0.5rem' }}>Đang tải danh sách học sinh...</p>
|
||||
</div>
|
||||
) : modalStudents.length === 0 ? (
|
||||
<div className="empty-state" style={{ minHeight: '200px' }}>
|
||||
<div className="empty-state-icon">👥</div>
|
||||
<h3>Lớp trống hoặc chưa đồng bộ danh sách</h3>
|
||||
<p style={{ fontSize: '0.85rem', maxWidth: '380px', margin: '0 auto' }}>
|
||||
Hãy kiểm tra xem lớp có gán môn học nào hoạt động không. Danh sách sinh viên được kéo từ kết quả học tập của lớp.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="data-table" style={{ fontSize: '0.85rem' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Mã SV</th>
|
||||
<th>Họ Tên</th>
|
||||
<th>Email</th>
|
||||
<th>Số Điện Thoại</th>
|
||||
<th>Vị Trí</th>
|
||||
<th>Trạng Thái</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{modalStudents.map(st => (
|
||||
<tr key={st.rkId}>
|
||||
<td style={{ fontWeight: 600 }}>{st.studentCode}</td>
|
||||
<td style={{ color: 'white', fontWeight: 500 }}>{st.fullName}</td>
|
||||
<td>{st.email}</td>
|
||||
<td>{st.phone || '—'}</td>
|
||||
<td>{st.location || '—'}</td>
|
||||
<td>
|
||||
<span className={`badge ${st.status === 'Đang học' || st.status === 'active' ? 'badge-success' : 'badge-muted'}`}>
|
||||
{st.status || 'Đang học'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<button className="btn btn-secondary" onClick={() => setActiveClass(null)}>
|
||||
Đóng
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user