552 lines
22 KiB
TypeScript
552 lines
22 KiB
TypeScript
import React, { useEffect, useMemo, useState } from 'react';
|
||
import {
|
||
apiFetchActiveSchedules,
|
||
apiFetchClassSchedule,
|
||
apiApplyScheduleTemplate,
|
||
apiDeleteClassSchedule,
|
||
apiFetchAllowedApps,
|
||
apiSaveAllowedApps,
|
||
apiFetchClassSessionLogs,
|
||
apiFetchClasses
|
||
} from '../api';
|
||
import { DEFAULT_ALLOWED_APPS } from '../constants';
|
||
import type {
|
||
ClassItem,
|
||
ClassScheduleItem,
|
||
StudentSessionLogItem
|
||
} from '../api';
|
||
import { LiveProctorModal } from './LiveProctorModal';
|
||
import { openClass } from './NavHistoryBar';
|
||
import { ScheduleEditor } from './ScheduleEditor';
|
||
import { LearningTimetable } from './LearningTimetable';
|
||
import {
|
||
WEEK_DAYS,
|
||
filterActiveClasses,
|
||
countWeeklyShifts,
|
||
classHasToday,
|
||
schedulesByDay,
|
||
getDayLabel,
|
||
type ActiveClassItem,
|
||
} from './learningUtils';
|
||
|
||
type ViewMode = 'list' | 'timetable';
|
||
|
||
export const LearningTab: React.FC = () => {
|
||
const [activeClasses, setActiveClasses] = useState<ActiveClassItem[]>([]);
|
||
const [loading, setLoading] = useState<boolean>(false);
|
||
const [searchQuery, setSearchQuery] = useState('');
|
||
const [dayFilter, setDayFilter] = useState<number | null>(null);
|
||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||
|
||
const [configClass, setConfigClass] = useState<ActiveClassItem | null>(null);
|
||
const [schedules, setSchedules] = useState<ClassScheduleItem[]>([]);
|
||
const [allowedApps, setAllowedApps] = useState<string>('');
|
||
|
||
const [monitorClass, setMonitorClass] = useState<ActiveClassItem | null>(null);
|
||
const [studentsLogs, setStudentsLogs] = useState<StudentSessionLogItem[]>([]);
|
||
|
||
const [showAddClassModal, setShowAddClassModal] = useState<boolean>(false);
|
||
const [allClasses, setAllClasses] = useState<ClassItem[]>([]);
|
||
const [searchClassQuery, setSearchClassQuery] = useState<string>('');
|
||
|
||
const [proctorStudent, setProctorStudent] = useState<{ id: number; name: string; code: string } | null>(null);
|
||
|
||
const loadActiveClasses = async () => {
|
||
setLoading(true);
|
||
try {
|
||
const res = await apiFetchActiveSchedules();
|
||
setActiveClasses(res.data || []);
|
||
} catch (err) {
|
||
console.error('Failed to load active classes:', err);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
loadActiveClasses();
|
||
}, []);
|
||
|
||
const filteredClasses = useMemo(
|
||
() => filterActiveClasses(activeClasses, searchQuery, dayFilter),
|
||
[activeClasses, searchQuery, dayFilter]
|
||
);
|
||
|
||
const weeklyShifts = useMemo(() => countWeeklyShifts(activeClasses), [activeClasses]);
|
||
const todayClassCount = useMemo(
|
||
() => activeClasses.filter(classHasToday).length,
|
||
[activeClasses]
|
||
);
|
||
|
||
const handleOpenAddClass = async () => {
|
||
setShowAddClassModal(true);
|
||
try {
|
||
const res = await apiFetchClasses({ page: 1, pageSize: 1000 });
|
||
setAllClasses(res.data || []);
|
||
} catch (err) {
|
||
console.error('Error loading classes:', err);
|
||
}
|
||
};
|
||
|
||
const handleActivateClass = async (cls: ClassItem) => {
|
||
setShowAddClassModal(false);
|
||
try {
|
||
await apiApplyScheduleTemplate(cls.rkId);
|
||
await apiSaveAllowedApps(cls.rkId, DEFAULT_ALLOWED_APPS);
|
||
await loadActiveClasses();
|
||
handleOpenConfig(cls as ActiveClassItem);
|
||
} catch (err) {
|
||
alert('Không thể kích hoạt lớp: ' + err);
|
||
}
|
||
};
|
||
|
||
const handleOpenConfig = async (cls: ActiveClassItem) => {
|
||
setConfigClass(cls);
|
||
try {
|
||
const schedRes = await apiFetchClassSchedule(cls.rkId);
|
||
setSchedules(schedRes.data || []);
|
||
const appRes = await apiFetchAllowedApps(cls.rkId);
|
||
setAllowedApps(appRes.keywords || '');
|
||
} catch (err) {
|
||
console.error('Error fetching config:', err);
|
||
}
|
||
};
|
||
|
||
const handleOpenMonitor = async (cls: ActiveClassItem) => {
|
||
setMonitorClass(cls);
|
||
setLoading(true);
|
||
try {
|
||
const logRes = await apiFetchClassSessionLogs(cls.rkId);
|
||
setStudentsLogs(logRes.data || []);
|
||
} catch (err) {
|
||
console.error('Error fetching logs:', err);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const reloadConfigSchedules = async () => {
|
||
if (!configClass) return;
|
||
try {
|
||
const schedRes = await apiFetchClassSchedule(configClass.rkId);
|
||
setSchedules(schedRes.data || []);
|
||
loadActiveClasses();
|
||
} catch (err) {
|
||
console.error('Error reloading schedules:', err);
|
||
}
|
||
};
|
||
|
||
const handleSaveApps = async () => {
|
||
if (!configClass) return;
|
||
try {
|
||
await apiSaveAllowedApps(configClass.rkId, allowedApps);
|
||
alert('Lưu cấu hình ứng dụng được phép thành công!');
|
||
loadActiveClasses();
|
||
} catch (err) {
|
||
alert('Không thể lưu cấu hình app: ' + err);
|
||
}
|
||
};
|
||
|
||
const handleDeleteClass = async (cls: ActiveClassItem) => {
|
||
if (window.confirm(`Bạn có chắc chắn muốn hủy cấu hình và dừng giám sát lớp ${cls.name}?`)) {
|
||
try {
|
||
await apiDeleteClassSchedule(cls.rkId);
|
||
loadActiveClasses();
|
||
if (monitorClass?.rkId === cls.rkId) setMonitorClass(null);
|
||
if (configClass?.rkId === cls.rkId) setConfigClass(null);
|
||
} catch (err) {
|
||
alert('Không thể hủy kích hoạt: ' + err);
|
||
}
|
||
}
|
||
};
|
||
|
||
const handleOpenWorkspace = (cls: ActiveClassItem) => {
|
||
openClass('learning', cls.rkId, cls.name);
|
||
};
|
||
|
||
const formatSeconds = (totalSeconds: number) => {
|
||
if (!totalSeconds) return '0s';
|
||
const hrs = Math.floor(totalSeconds / 3600);
|
||
const mins = Math.floor((totalSeconds % 3600) / 60);
|
||
const secs = totalSeconds % 60;
|
||
const parts = [];
|
||
if (hrs > 0) parts.push(`${hrs}h`);
|
||
if (mins > 0) parts.push(`${mins}m`);
|
||
if (secs > 0 || parts.length === 0) parts.push(`${secs}s`);
|
||
return parts.join(' ');
|
||
};
|
||
|
||
const filteredAddClasses = allClasses.filter(c =>
|
||
(c.name.toLowerCase().includes(searchClassQuery.toLowerCase()) ||
|
||
c.classCode.toLowerCase().includes(searchClassQuery.toLowerCase())) &&
|
||
!activeClasses.some(ac => ac.rkId === c.rkId)
|
||
);
|
||
|
||
return (
|
||
<div className="tab-page">
|
||
<div className="tab-page-toolbar">
|
||
<div className="page-header">
|
||
<div className="page-title">
|
||
<h1>Quản lý lịch học & Giám sát</h1>
|
||
<p>Chọn lớp, lập lịch theo tuần và giám sát sinh viên — xem dạng danh sách hoặc thời khóa biểu</p>
|
||
</div>
|
||
<button className="btn btn-primary" onClick={handleOpenAddClass}>
|
||
+ Chọn lớp & Tạo lịch
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="tab-page-body tab-page-scroll">
|
||
<div className="learning-stats-row">
|
||
<div className="learning-stat">
|
||
<span className="learning-stat-value">{activeClasses.length}</span>
|
||
<span className="learning-stat-label">Lớp đang giám sát</span>
|
||
</div>
|
||
<div className="learning-stat">
|
||
<span className="learning-stat-value">{weeklyShifts}</span>
|
||
<span className="learning-stat-label">Ca học / tuần</span>
|
||
</div>
|
||
<div className="learning-stat learning-stat--accent">
|
||
<span className="learning-stat-value">{todayClassCount}</span>
|
||
<span className="learning-stat-label">Lớp có ca hôm nay</span>
|
||
</div>
|
||
<div className="learning-stat">
|
||
<span className="learning-stat-value">{filteredClasses.length}</span>
|
||
<span className="learning-stat-label">Kết quả lọc</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="content-card learning-toolbar-card">
|
||
<div className="learning-toolbar">
|
||
<input
|
||
type="search"
|
||
className="app-pool-search learning-search"
|
||
placeholder="Tìm mã lớp, tên lớp, môn học, chuyên ngành..."
|
||
value={searchQuery}
|
||
onChange={e => setSearchQuery(e.target.value)}
|
||
/>
|
||
<div className="learning-view-toggle">
|
||
<button
|
||
type="button"
|
||
className={`learning-view-btn${viewMode === 'list' ? ' learning-view-btn--active' : ''}`}
|
||
onClick={() => setViewMode('list')}
|
||
>
|
||
Danh sách
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`learning-view-btn${viewMode === 'timetable' ? ' learning-view-btn--active' : ''}`}
|
||
onClick={() => setViewMode('timetable')}
|
||
>
|
||
Thời khóa biểu
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="learning-day-filters">
|
||
<button
|
||
type="button"
|
||
className={`learning-day-chip${dayFilter === null ? ' learning-day-chip--active' : ''}`}
|
||
onClick={() => setDayFilter(null)}
|
||
>
|
||
Tất cả
|
||
</button>
|
||
{WEEK_DAYS.map((label, day) => (
|
||
<button
|
||
key={label}
|
||
type="button"
|
||
className={`learning-day-chip${dayFilter === day ? ' learning-day-chip--active' : ''}`}
|
||
onClick={() => setDayFilter(dayFilter === day ? null : day)}
|
||
>
|
||
{label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="content-card">
|
||
{loading && (
|
||
<div className="learning-empty">
|
||
<span className="learning-empty-icon">🔄</span>
|
||
<p>Đang tải...</p>
|
||
</div>
|
||
)}
|
||
|
||
{!loading && activeClasses.length === 0 && (
|
||
<div className="learning-empty">
|
||
<span className="learning-empty-icon">📅</span>
|
||
<p>Chưa có lớp nào được tạo lịch học.</p>
|
||
<button className="btn btn-secondary" onClick={handleOpenAddClass}>
|
||
Bắt đầu thêm lớp đầu tiên
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{!loading && activeClasses.length > 0 && filteredClasses.length === 0 && (
|
||
<div className="learning-empty">
|
||
<span className="learning-empty-icon">🔍</span>
|
||
<p>Không tìm thấy lớp phù hợp với bộ lọc hiện tại.</p>
|
||
<button
|
||
className="btn btn-secondary"
|
||
onClick={() => {
|
||
setSearchQuery('');
|
||
setDayFilter(null);
|
||
}}
|
||
>
|
||
Xóa bộ lọc
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{!loading && filteredClasses.length > 0 && viewMode === 'timetable' && (
|
||
<LearningTimetable classes={filteredClasses} onOpenClass={handleOpenWorkspace} />
|
||
)}
|
||
|
||
{!loading && filteredClasses.length > 0 && viewMode === 'list' && (
|
||
<div className="learning-class-grid">
|
||
{filteredClasses.map(cls => {
|
||
const byDay = schedulesByDay(cls);
|
||
const today = classHasToday(cls);
|
||
return (
|
||
<article key={cls.rkId} className={`learning-class-card${today ? ' learning-class-card--today' : ''}`}>
|
||
<header className="learning-class-card-head">
|
||
<div>
|
||
<div className="learning-class-code">{cls.classCode}</div>
|
||
<h3 className="learning-class-name" title={cls.name}>{cls.name}</h3>
|
||
{(cls.specializeName || cls.systemName) && (
|
||
<p className="learning-class-meta">
|
||
{[cls.specializeName, cls.systemName].filter(Boolean).join(' · ')}
|
||
</p>
|
||
)}
|
||
</div>
|
||
<div className="learning-class-badges">
|
||
{today && <span className="learning-badge learning-badge--today">Hôm nay</span>}
|
||
{cls.studentCount != null && cls.studentCount > 0 && (
|
||
<span className="learning-badge">{cls.studentCount} SV</span>
|
||
)}
|
||
</div>
|
||
</header>
|
||
|
||
<div className="learning-class-schedule">
|
||
{byDay.size === 0 ? (
|
||
<p className="learning-class-no-sched">Chưa cấu hình ca học</p>
|
||
) : (
|
||
Array.from(byDay.entries()).map(([day, shifts]) => (
|
||
<div key={day} className="learning-schedule-day">
|
||
<span className="learning-schedule-day-label">{getDayLabel(day)}</span>
|
||
<div className="learning-schedule-shifts">
|
||
{shifts.map((s, idx) => (
|
||
<span key={idx} className="course-tag">
|
||
Ca{s.period || 1} {s.startTime && s.endTime ? `${s.startTime}–${s.endTime}` : ''}
|
||
{s.courseName ? ` · ${s.courseName}` : ''}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
|
||
{cls.allowedApps && (
|
||
<p className="learning-class-apps" title={cls.allowedApps}>
|
||
App: <code>{cls.allowedApps}</code>
|
||
</p>
|
||
)}
|
||
|
||
<footer className="learning-class-actions">
|
||
<button
|
||
type="button"
|
||
className="btn btn-primary btn-sm"
|
||
onClick={() => handleOpenWorkspace(cls)}
|
||
>
|
||
Vào Workspace
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary btn-sm"
|
||
onClick={() => handleOpenConfig(cls)}
|
||
>
|
||
Cấu hình
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary btn-sm learning-btn-danger"
|
||
onClick={() => handleDeleteClass(cls)}
|
||
>
|
||
Hủy
|
||
</button>
|
||
</footer>
|
||
</article>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{configClass && (
|
||
<div className="modal-overlay">
|
||
<div className="modal-container" style={{ maxWidth: '800px', width: '90%' }}>
|
||
<div className="modal-header">
|
||
<div>
|
||
<h2 className="modal-title">Cấu hình lịch học & ứng dụng</h2>
|
||
<p style={{ margin: '4px 0 0 0', color: 'var(--text-muted)', fontSize: '0.85rem' }}>
|
||
{configClass.name} ({configClass.classCode})
|
||
</p>
|
||
</div>
|
||
<button className="btn btn-secondary close-btn" onClick={() => setConfigClass(null)}>Đóng</button>
|
||
</div>
|
||
<div style={{ padding: '1rem 0', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1.5rem' }}>
|
||
<div style={{ borderRight: '1px solid var(--border-color)', paddingRight: '1.5rem' }}>
|
||
<h4 style={{ margin: '0 0 1rem 0' }}>Ca học trong tuần</h4>
|
||
<ScheduleEditor
|
||
classId={configClass.rkId}
|
||
schedules={schedules}
|
||
onSaved={reloadConfigSchedules}
|
||
/>
|
||
</div>
|
||
<div style={{ display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
|
||
<div>
|
||
<h4 style={{ margin: '0 0 1rem 0' }}>App được phép chạy</h4>
|
||
<p style={{ color: 'var(--text-muted)', fontSize: '0.8rem', lineHeight: '1.4', marginBottom: '8px' }}>
|
||
Từ khóa ngăn chặn các ứng dụng ngoài luồng trên máy sinh viên.
|
||
</p>
|
||
<textarea
|
||
rows={6}
|
||
value={allowedApps}
|
||
onChange={e => setAllowedApps(e.target.value)}
|
||
placeholder="chrome,idea64,vscode,wails,simple_care"
|
||
className="app-textarea"
|
||
/>
|
||
</div>
|
||
<button className="btn btn-primary" onClick={handleSaveApps} style={{ width: '100%', marginTop: '15px' }}>
|
||
Lưu whitelist app
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{monitorClass && (
|
||
<div className="modal-overlay">
|
||
<div className="modal-container" style={{ maxWidth: '950px', width: '95%' }}>
|
||
<div className="modal-header">
|
||
<div>
|
||
<h2 className="modal-title">Nhật ký & giám sát sinh viên</h2>
|
||
<p style={{ margin: '4px 0 0 0', color: 'var(--text-muted)', fontSize: '0.85rem' }}>
|
||
Lớp: <strong>{monitorClass.name}</strong> ({monitorClass.classCode})
|
||
</p>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: '8px' }}>
|
||
<button className="btn btn-secondary" onClick={() => handleOpenMonitor(monitorClass)}>Tải lại</button>
|
||
<button className="btn btn-secondary close-btn" onClick={() => setMonitorClass(null)}>Đóng</button>
|
||
</div>
|
||
</div>
|
||
<div style={{ padding: '1rem 0', maxHeight: '450px', overflowY: 'auto' }}>
|
||
<div className="table-responsive">
|
||
<table className="table">
|
||
<thead>
|
||
<tr>
|
||
<th>Học viên</th>
|
||
<th>Mã SV</th>
|
||
<th>Online</th>
|
||
<th>Offline</th>
|
||
<th>WiFi</th>
|
||
<th>Cập nhật</th>
|
||
<th style={{ textAlign: 'right' }}>Thao tác</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{studentsLogs.length === 0 ? (
|
||
<tr>
|
||
<td colSpan={7} style={{ textAlign: 'center', color: 'var(--text-muted)' }}>
|
||
Chưa có dữ liệu sinh viên hôm nay.
|
||
</td>
|
||
</tr>
|
||
) : (
|
||
studentsLogs.map(log => (
|
||
<tr key={log.studentRkId}>
|
||
<td>
|
||
<div style={{ fontWeight: 700 }}>{log.fullName}</div>
|
||
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>{log.email}</div>
|
||
</td>
|
||
<td><code>{log.studentCode}</code></td>
|
||
<td style={{ color: '#4caf50', fontWeight: 700 }}>{formatSeconds(log.onlineSeconds)}</td>
|
||
<td style={{ color: '#f44336', fontWeight: 700 }}>{formatSeconds(log.offlineSeconds)}</td>
|
||
<td>
|
||
<span className="course-tag">{log.wifiSsids || 'Chưa nhận'}</span>
|
||
</td>
|
||
<td>{log.lastActiveAt ? new Date(log.lastActiveAt).toLocaleTimeString('vi-VN') : '—'}</td>
|
||
<td style={{ textAlign: 'right' }}>
|
||
<button
|
||
className="btn btn-primary btn-sm"
|
||
onClick={() =>
|
||
setProctorStudent({ id: log.studentRkId, name: log.fullName, code: log.studentCode })
|
||
}
|
||
>
|
||
Xem màn hình
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
))
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{showAddClassModal && (
|
||
<div className="modal-overlay">
|
||
<div className="modal-container" style={{ maxWidth: '500px' }}>
|
||
<div className="modal-header">
|
||
<h2 className="modal-title">Chọn lớp học từ hệ thống</h2>
|
||
<button className="btn btn-secondary close-btn" onClick={() => setShowAddClassModal(false)}>Đóng</button>
|
||
</div>
|
||
<div style={{ padding: '1rem 0' }}>
|
||
<input
|
||
type="search"
|
||
className="app-pool-search"
|
||
placeholder="Tìm tên lớp hoặc mã lớp..."
|
||
value={searchClassQuery}
|
||
onChange={e => setSearchClassQuery(e.target.value)}
|
||
/>
|
||
<div style={{ maxHeight: '300px', overflowY: 'auto', marginTop: '1rem' }}>
|
||
{filteredAddClasses.length === 0 ? (
|
||
<p style={{ color: 'var(--text-muted)', textAlign: 'center' }}>Không tìm thấy lớp phù hợp.</p>
|
||
) : (
|
||
<ul className="learning-add-list">
|
||
{filteredAddClasses.map(c => (
|
||
<li key={c.rkId} className="learning-add-item">
|
||
<div>
|
||
<div className="learning-class-code">{c.classCode}</div>
|
||
<div className="learning-class-meta">{c.name}</div>
|
||
</div>
|
||
<button className="btn btn-primary btn-sm" onClick={() => handleActivateClass(c)}>
|
||
Chọn
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{proctorStudent && (
|
||
<LiveProctorModal
|
||
studentId={proctorStudent.id}
|
||
studentName={proctorStudent.name}
|
||
studentCode={proctorStudent.code}
|
||
onClose={() => setProctorStudent(null)}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|