Files
rikkei_simple_care/management/src/components/ClassesTab.tsx
2026-06-30 09:31:33 +07:00

350 lines
13 KiB
TypeScript

import React, { useEffect, useState } from 'react';
import { api } from '../api';
import type { ClassItem, SyncStatus } from '../api';
import { openClass } from './NavHistoryBar';
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(true);
const [loading, setLoading] = useState(true);
// Sync state
const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null);
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: any) {
// Revert if error
setClasses(prev => prev.map(c => c.rkId === cItem.rkId ? { ...c, isStudying: !nextVal } : c));
alert(err.message || 'Không thể cập nhật trạng thái lớp học');
}
};
// 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 className="tab-page">
<div className="tab-page-toolbar">
<div className="page-header">
<div className="page-title">
<h1>Quản Lớp Học</h1>
<p>Đng bộ dữ liệu lớp học 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"> 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>
</div>
{/* Classes list table */}
<div className="tab-page-body">
<div className="table-wrapper table-fill">
{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> 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} style={cl.isStudying ? { background: 'rgba(16, 185, 129, 0.02)' } : {}}>
<td>
<div style={{ fontWeight: 700, color: 'var(--text-primary)' }}>{cl.classCode}</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>ID: {cl.rkId}</div>
</td>
<td>
<div
style={{ fontWeight: 600, color: 'var(--accent)', fontSize: '0.95rem', cursor: 'pointer', textDecoration: 'underline' }}
onClick={() => openClass('classes', cl.rkId, cl.name)}
title="Mở Không gian làm việc của lớp"
>
{cl.name}
</div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', marginTop: '0.15rem' }}>
💼 {cl.specializeName || 'Chưa có chuyên ngành'}
</div>
</td>
<td>
<span className="badge badge-muted" style={{ fontWeight: 600 }}>
📍 {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 môn học</span>
)}
</div>
</td>
<td>
<span className="badge badge-info" style={{ padding: '0.3rem 0.65rem', borderRadius: '6px', fontWeight: 600 }}>
👤 {cl.studentCount} SV
</span>
</td>
<td>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
<div className="switch-container">
<label className="switch">
<input
type="checkbox"
checked={cl.isStudying}
onChange={() => handleToggleStudying(cl)}
/>
<span className="slider"></span>
</label>
</div>
{cl.isStudying ? (
<span style={{ color: 'var(--success)', fontSize: '0.72rem', display: 'inline-flex', alignItems: 'center', gap: '0.25rem', fontWeight: 700, textTransform: 'uppercase' }}>
<span className="pulse-dot-active" style={{ display: 'inline-block', width: '6px', height: '6px', background: '#10b981', borderRadius: '50%', boxShadow: '0 0 6px #10b981' }}></span>
Đang học
</span>
) : (
<span style={{ color: 'var(--text-muted)', fontSize: '0.72rem', textTransform: 'uppercase', fontWeight: 600 }}>Tạm dừng</span>
)}
</div>
</td>
<td style={{ textAlign: 'right' }}>
<button
className="btn btn-secondary"
style={{ padding: '0.5rem 0.95rem', fontSize: '0.8rem', fontWeight: 600, display: 'inline-flex', alignItems: 'center', gap: '0.35rem' }}
onClick={() => openClass('classes', cl.rkId, cl.name)}
>
👥 Xem lớp
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
{/* Pagination control bar */}
{!loading && classes.length > 0 && (
<div className="tab-page-footer">
<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: 'var(--text-primary)' }}>
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>
)}
</div>
);
};