Files
rikkei_simple_care/management/src/components/LearningTab.tsx
2026-06-30 09:40:29 +07:00

552 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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ịch học & Giám sát</h1>
<p>Chọn lớp, lập lịch theo tuần 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 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 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 & 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> 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 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>
);
};