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>
|
||||
);
|
||||
};
|
||||
137
management/src/components/DashboardTab.tsx
Normal file
137
management/src/components/DashboardTab.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { api } from '../api';
|
||||
import type { StatsResponse } from '../api';
|
||||
|
||||
interface DashboardTabProps {
|
||||
onNavigate: (tab: string) => void;
|
||||
}
|
||||
|
||||
export const DashboardTab: React.FC<DashboardTabProps> = ({ onNavigate }) => {
|
||||
const [stats, setStats] = useState<StatsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await api.getStats();
|
||||
setStats(data);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Không thể tải dữ liệu thống kê');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchStats();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
|
||||
<div className="page-header">
|
||||
<div className="page-title">
|
||||
<h1>Tổng Quan Hệ Thống</h1>
|
||||
<p>Giám sát đồng bộ và quản lý lớp học, sinh viên từ hệ thống chính</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="empty-state">
|
||||
<div className="sync-spinner" style={{ width: '40px', height: '40px', borderWidth: '3px' }}></div>
|
||||
<p style={{ marginTop: '1rem' }}>Đang tải thông số hệ thống...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="empty-state" style={{ border: '1px solid rgba(239, 68, 68, 0.2)', backgroundColor: 'rgba(239, 68, 68, 0.05)' }}>
|
||||
<div className="empty-state-icon" style={{ color: 'var(--danger)' }}>⚠️</div>
|
||||
<p style={{ color: 'var(--danger)', fontWeight: 600 }}>Lỗi: {error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && stats && (
|
||||
<>
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card" onClick={() => onNavigate('classes')} style={{ cursor: 'pointer' }}>
|
||||
<div>
|
||||
<div className="stat-label">Tổng lớp học đã đồng bộ</div>
|
||||
<div className="stat-value">{stats.totalClasses}</div>
|
||||
</div>
|
||||
<div className="stat-icon">🏫</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card" onClick={() => onNavigate('classes')} style={{ cursor: 'pointer' }}>
|
||||
<div>
|
||||
<div className="stat-label">Lớp đang giảng dạy</div>
|
||||
<div className="stat-value" style={{ color: 'var(--success)' }}>{stats.activeClasses}</div>
|
||||
</div>
|
||||
<div className="stat-icon" style={{ color: 'var(--success)' }}>🟢</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card" onClick={() => onNavigate('students')} style={{ cursor: 'pointer' }}>
|
||||
<div>
|
||||
<div className="stat-label">Tổng sinh viên đã đồng bộ</div>
|
||||
<div className="stat-value" style={{ color: 'var(--accent-hover)' }}>{stats.totalStudents}</div>
|
||||
</div>
|
||||
<div className="stat-icon">🎓</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(360px, 1fr))',
|
||||
gap: '1.5rem',
|
||||
marginTop: '1rem'
|
||||
}}>
|
||||
<div style={{
|
||||
backgroundColor: 'var(--bg-card)',
|
||||
border: '1px solid var(--border-color)',
|
||||
padding: '2rem',
|
||||
borderRadius: '18px',
|
||||
backdropFilter: 'blur(10px)'
|
||||
}}>
|
||||
<h2 style={{ marginBottom: '1rem', color: 'white', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
🚀 Khởi động nhanh
|
||||
</h2>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: '0.95rem', lineHeight: '1.6', marginBottom: '1.5rem' }}>
|
||||
Chào mừng bạn đến với trang quản trị <b>Simple Care</b>.
|
||||
Hệ thống hỗ trợ đồng bộ hóa thông tin tự động từ cổng đào tạo chính (QLĐT), giúp lưu trữ, cập nhật trạng thái lớp học và danh sách sinh viên nội bộ mà không lo ảnh hưởng đến dữ liệu hiện có.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: '1rem' }}>
|
||||
<button className="btn btn-primary" onClick={() => onNavigate('classes')}>
|
||||
Quản lý Lớp học
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={() => onNavigate('students')}>
|
||||
Danh sách Sinh viên
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
backgroundColor: 'var(--bg-card)',
|
||||
border: '1px solid var(--border-color)',
|
||||
padding: '2rem',
|
||||
borderRadius: '18px',
|
||||
backdropFilter: 'blur(10px)'
|
||||
}}>
|
||||
<h2 style={{ marginBottom: '1rem', color: 'white' }}>📋 Quy trình hoạt động</h2>
|
||||
<ul style={{
|
||||
color: 'var(--text-secondary)',
|
||||
fontSize: '0.95rem',
|
||||
lineHeight: '1.8',
|
||||
paddingLeft: '1.25rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.5rem'
|
||||
}}>
|
||||
<li><b>Đồng bộ Lớp học:</b> Tải toàn bộ danh sách lớp học và các môn học tương ứng. Đồng thời kéo thông tin sinh viên thuộc từng lớp thông qua bảng kết quả lớp học.</li>
|
||||
<li><b>Quản lý Lớp:</b> Lọc danh sách lớp theo Phân hệ (System ID), Tìm kiếm tên hoặc mã lớp và Bật/Tắt cờ <b>Đang học</b>.</li>
|
||||
<li><b>Đồng bộ Sinh viên:</b> Thực hiện kéo toàn bộ sinh viên trên hệ thống chính (kèm thông tin liên hệ, hệ học) lưu trữ vào Database phục vụ cho học tập và thi cử.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
248
management/src/components/StudentsTab.tsx
Normal file
248
management/src/components/StudentsTab.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { api } from '../api';
|
||||
import type { StudentItem, SyncStatus } from '../api';
|
||||
|
||||
export const StudentsTab: React.FC = () => {
|
||||
const [students, setStudents] = useState<StudentItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize] = useState(15);
|
||||
const [search, setSearch] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Sync state
|
||||
const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null);
|
||||
|
||||
const fetchStudents = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await api.getStudents({
|
||||
page,
|
||||
pageSize,
|
||||
q: search || undefined,
|
||||
});
|
||||
setStudents(res.data);
|
||||
setTotal(res.total);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSyncStatus = async () => {
|
||||
try {
|
||||
const status = await api.getStudentsSyncStatus();
|
||||
setSyncStatus(status);
|
||||
return status.running;
|
||||
} catch (err) {
|
||||
console.error('Lỗi khi lấy status sync:', err);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchStudents();
|
||||
}, [page, search]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSyncStatus();
|
||||
}, []);
|
||||
|
||||
// Sync polling logic
|
||||
useEffect(() => {
|
||||
let timer: any;
|
||||
if (syncStatus?.running) {
|
||||
timer = setInterval(async () => {
|
||||
const isRunning = await fetchSyncStatus();
|
||||
if (!isRunning) {
|
||||
clearInterval(timer);
|
||||
fetchStudents(); // Reload data when sync completes
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
return () => {
|
||||
if (timer) clearInterval(timer);
|
||||
};
|
||||
}, [syncStatus?.running]);
|
||||
|
||||
const handleStartSync = async () => {
|
||||
try {
|
||||
await api.startStudentsSync();
|
||||
// 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ộ sinh viên');
|
||||
}
|
||||
};
|
||||
|
||||
// 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>Danh Sách Sinh Viên</h1>
|
||||
<p>Danh sách toàn bộ sinh viên trong hệ thống được đồng bộ</p>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleStartSync}
|
||||
disabled={syncStatus?.running}
|
||||
>
|
||||
{syncStatus?.running ? (
|
||||
<>
|
||||
<div className="sync-spinner"></div> Đồng bộ...
|
||||
</>
|
||||
) : (
|
||||
'🔄 Đồng bộ toàn bộ SV'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sync Status Banner */}
|
||||
{syncStatus && (syncStatus.running || syncStatus.done || syncStatus.error) && (
|
||||
<div className="sync-progress-banner" style={{ borderStyle: 'solid', borderColor: 'var(--success)' }}>
|
||||
<div className="sync-header">
|
||||
<div className="sync-title">
|
||||
{syncStatus.running && <div className="sync-spinner"></div>}
|
||||
<span>
|
||||
{syncStatus.running && `Đang tải sinh viên... Trang ${syncStatus.page || 0} (${syncPercent}%)`}
|
||||
{syncStatus.done && '🎉 Đồng bộ toàn bộ sinh viên 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)' }}>
|
||||
Đã tải: {syncStatus.synced} / {syncStatus.total} sinh viên
|
||||
</div>
|
||||
</div>
|
||||
{syncStatus.running && (
|
||||
<div className="sync-bar-container">
|
||||
<div className="sync-bar" style={{ width: `${syncPercent}%`, background: 'linear-gradient(to right, var(--success), #a7f3d0)' }}></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(--success)' }}>Hệ thống đang kéo dữ liệu trang {syncStatus.page}...</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Control filters */}
|
||||
<div className="control-bar">
|
||||
<div className="search-input-wrapper" style={{ maxWidth: '400px' }}>
|
||||
<input
|
||||
type="text"
|
||||
className="search-input"
|
||||
placeholder="Tìm theo tên, mã SV, email, số điện thoại..."
|
||||
value={search}
|
||||
onChange={e => {
|
||||
setSearch(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<span className="search-icon">🔍</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Students 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 sinh viên...</p>
|
||||
</div>
|
||||
) : students.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon">👥</div>
|
||||
<h2>Không có sinh viên nào</h2>
|
||||
<p>Hãy thử thay đổi bộ lọc hoặc bấm nút "Đồng bộ toàn bộ SV" để tải dữ liệu.</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Mã SV</th>
|
||||
<th>Họ Tên</th>
|
||||
<th>Thông Tin Liên Hệ</th>
|
||||
<th>Ngày sinh / Giới tính</th>
|
||||
<th>Phân hệ</th>
|
||||
<th>Địa điểm</th>
|
||||
<th>Trạng thái</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{students.map(st => (
|
||||
<tr key={st.id}>
|
||||
<td style={{ fontWeight: 600 }}>{st.studentCode}</td>
|
||||
<td style={{ color: 'white', fontWeight: 500 }}>
|
||||
{st.fullName}
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>ID: {st.rkId}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div>📧 {st.email}</div>
|
||||
{st.phone && <div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>📞 {st.phone}</div>}
|
||||
</td>
|
||||
<td>
|
||||
<div>🎂 {st.dateOfBirth ? new Date(st.dateOfBirth).toLocaleDateString('vi-VN') : '—'}</div>
|
||||
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>
|
||||
Giới tính: {st.gender === 1 ? 'Nam' : st.gender === 0 ? 'Nữ' : 'Khác'}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className="badge badge-muted">
|
||||
{st.systemName || 'Chung'}
|
||||
</span>
|
||||
</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>
|
||||
|
||||
{/* Pagination controls */}
|
||||
{!loading && students.length > 0 && (
|
||||
<div className="pagination-row">
|
||||
<div>
|
||||
Hiển thị sinh viên thứ <b>{((page - 1) * pageSize) + 1}</b> đến <b>{Math.min(page * pageSize, total)}</b> trong tổng số <b>{total}</b> sinh viên
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user