This commit is contained in:
2026-06-30 09:40:29 +07:00
parent bbf8336664
commit 8ecc9c5664
4 changed files with 934 additions and 141 deletions

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { import {
apiFetchActiveSchedules, apiFetchActiveSchedules,
apiFetchClassSchedule, apiFetchClassSchedule,
@@ -18,26 +18,37 @@ import type {
import { LiveProctorModal } from './LiveProctorModal'; import { LiveProctorModal } from './LiveProctorModal';
import { openClass } from './NavHistoryBar'; import { openClass } from './NavHistoryBar';
import { ScheduleEditor } from './ScheduleEditor'; 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 = () => { export const LearningTab: React.FC = () => {
const [activeClasses, setActiveClasses] = useState<any[]>([]); const [activeClasses, setActiveClasses] = useState<ActiveClassItem[]>([]);
const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
const [searchQuery, setSearchQuery] = useState('');
const [dayFilter, setDayFilter] = useState<number | null>(null);
const [viewMode, setViewMode] = useState<ViewMode>('list');
// Lớp đang cấu hình cấu trúc lịch/app (modal) const [configClass, setConfigClass] = useState<ActiveClassItem | null>(null);
const [configClass, setConfigClass] = useState<any | null>(null);
const [schedules, setSchedules] = useState<ClassScheduleItem[]>([]); const [schedules, setSchedules] = useState<ClassScheduleItem[]>([]);
const [allowedApps, setAllowedApps] = useState<string>(''); const [allowedApps, setAllowedApps] = useState<string>('');
// Lớp đang xem giám sát sinh viên const [monitorClass, setMonitorClass] = useState<ActiveClassItem | null>(null);
const [monitorClass, setMonitorClass] = useState<any | null>(null);
const [studentsLogs, setStudentsLogs] = useState<StudentSessionLogItem[]>([]); const [studentsLogs, setStudentsLogs] = useState<StudentSessionLogItem[]>([]);
// Trạng thái modal Thêm lớp vào lịch
const [showAddClassModal, setShowAddClassModal] = useState<boolean>(false); const [showAddClassModal, setShowAddClassModal] = useState<boolean>(false);
const [allClasses, setAllClasses] = useState<ClassItem[]>([]); const [allClasses, setAllClasses] = useState<ClassItem[]>([]);
const [searchClassQuery, setSearchClassQuery] = useState<string>(''); const [searchClassQuery, setSearchClassQuery] = useState<string>('');
// Trạng thái modal Live stream proctor
const [proctorStudent, setProctorStudent] = useState<{ id: number; name: string; code: string } | null>(null); const [proctorStudent, setProctorStudent] = useState<{ id: number; name: string; code: string } | null>(null);
const loadActiveClasses = async () => { const loadActiveClasses = async () => {
@@ -56,10 +67,20 @@ export const LearningTab: React.FC = () => {
loadActiveClasses(); 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 () => { const handleOpenAddClass = async () => {
setShowAddClassModal(true); setShowAddClassModal(true);
try { try {
// Tải tối đa 1000 lớp trong hệ thống để người dùng thoải mái tìm kiếm
const res = await apiFetchClasses({ page: 1, pageSize: 1000 }); const res = await apiFetchClasses({ page: 1, pageSize: 1000 });
setAllClasses(res.data || []); setAllClasses(res.data || []);
} catch (err) { } catch (err) {
@@ -73,18 +94,17 @@ export const LearningTab: React.FC = () => {
await apiApplyScheduleTemplate(cls.rkId); await apiApplyScheduleTemplate(cls.rkId);
await apiSaveAllowedApps(cls.rkId, DEFAULT_ALLOWED_APPS); await apiSaveAllowedApps(cls.rkId, DEFAULT_ALLOWED_APPS);
await loadActiveClasses(); await loadActiveClasses();
handleOpenConfig(cls); handleOpenConfig(cls as ActiveClassItem);
} catch (err) { } catch (err) {
alert('Không thể kích hoạt lớp: ' + err); alert('Không thể kích hoạt lớp: ' + err);
} }
}; };
const handleOpenConfig = async (cls: any) => { const handleOpenConfig = async (cls: ActiveClassItem) => {
setConfigClass(cls); setConfigClass(cls);
try { try {
const schedRes = await apiFetchClassSchedule(cls.rkId); const schedRes = await apiFetchClassSchedule(cls.rkId);
setSchedules(schedRes.data || []); setSchedules(schedRes.data || []);
const appRes = await apiFetchAllowedApps(cls.rkId); const appRes = await apiFetchAllowedApps(cls.rkId);
setAllowedApps(appRes.keywords || ''); setAllowedApps(appRes.keywords || '');
} catch (err) { } catch (err) {
@@ -92,7 +112,7 @@ export const LearningTab: React.FC = () => {
} }
}; };
const handleOpenMonitor = async (cls: any) => { const handleOpenMonitor = async (cls: ActiveClassItem) => {
setMonitorClass(cls); setMonitorClass(cls);
setLoading(true); setLoading(true);
try { try {
@@ -117,6 +137,7 @@ export const LearningTab: React.FC = () => {
}; };
const handleSaveApps = async () => { const handleSaveApps = async () => {
if (!configClass) return;
try { try {
await apiSaveAllowedApps(configClass.rkId, allowedApps); await apiSaveAllowedApps(configClass.rkId, allowedApps);
alert('Lưu cấu hình ứng dụng được phép thành công!'); alert('Lưu cấu hình ứng dụng được phép thành công!');
@@ -126,7 +147,7 @@ export const LearningTab: React.FC = () => {
} }
}; };
const handleDeleteClass = async (cls: any) => { 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}?`)) { 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 { try {
await apiDeleteClassSchedule(cls.rkId); await apiDeleteClassSchedule(cls.rkId);
@@ -139,6 +160,10 @@ export const LearningTab: React.FC = () => {
} }
}; };
const handleOpenWorkspace = (cls: ActiveClassItem) => {
openClass('learning', cls.rkId, cls.name);
};
const formatSeconds = (totalSeconds: number) => { const formatSeconds = (totalSeconds: number) => {
if (!totalSeconds) return '0s'; if (!totalSeconds) return '0s';
const hrs = Math.floor(totalSeconds / 3600); const hrs = Math.floor(totalSeconds / 3600);
@@ -151,14 +176,9 @@ export const LearningTab: React.FC = () => {
return parts.join(' '); return parts.join(' ');
}; };
const getDayString = (day: number) => {
const days = ['Thứ 2', 'Thứ 3', 'Thứ 4', 'Thứ 5', 'Thứ 6', 'Thứ 7', 'Chủ Nhật'];
return days[day] || `Thứ ${day + 2}`;
};
const filteredAddClasses = allClasses.filter(c => const filteredAddClasses = allClasses.filter(c =>
(c.name.toLowerCase().includes(searchClassQuery.toLowerCase()) || (c.name.toLowerCase().includes(searchClassQuery.toLowerCase()) ||
c.classCode.toLowerCase().includes(searchClassQuery.toLowerCase())) && c.classCode.toLowerCase().includes(searchClassQuery.toLowerCase())) &&
!activeClasses.some(ac => ac.rkId === c.rkId) !activeClasses.some(ac => ac.rkId === c.rkId)
); );
@@ -168,152 +188,259 @@ export const LearningTab: React.FC = () => {
<div className="page-header"> <div className="page-header">
<div className="page-title"> <div className="page-title">
<h1>Quản lịch học & Giám sát</h1> <h1>Quản lịch học & Giám sát</h1>
<p>Chọn các lớp học từ hệ thống chính, tạo lịch học theo tuần giám sát sinh viên</p> <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> </div>
<button className="btn btn-primary" onClick={handleOpenAddClass}> <button className="btn btn-primary" onClick={handleOpenAddClass}>
Chọn lớp & Tạo lịch học + Chọn lớp & Tạo lịch
</button> </button>
</div> </div>
</div> </div>
<div className="tab-page-body tab-page-scroll"> <div className="tab-page-body tab-page-scroll">
<div className="content-card"> <div className="learning-stats-row">
<h2 style={{ margin: '0 0 1rem 0', fontSize: '1rem', fontWeight: 700 }}>Lớp đang hoạt đng tuần này</h2> <div className="learning-stat">
{loading && <div style={{ textAlign: 'center', padding: '2rem', color: 'var(--text-muted)' }}>🔄 Đang tải...</div>} <span className="learning-stat-value">{activeClasses.length}</span>
<span className="learning-stat-label">Lớp đang giám sát</span>
{!loading && activeClasses.length === 0 ? (
<div style={{ textAlign: 'center', padding: '3rem 0', color: 'var(--text-muted)' }}>
<span style={{ fontSize: '2.5rem' }}>📅</span>
<p style={{ marginTop: '10px' }}>Chưa lớp nào đưc tạo lịch học.</p>
<button className="btn btn-secondary" onClick={handleOpenAddClass} style={{ marginTop: '10px' }}>
Bắt đu thêm lớp đu tiên
</button>
</div> </div>
) : ( <div className="learning-stat">
!loading && ( <span className="learning-stat-value">{weeklyShifts}</span>
<div className="table-responsive"> <span className="learning-stat-label">Ca học / tuần</span>
<table className="table"> </div>
<thead> <div className="learning-stat learning-stat--accent">
<tr> <span className="learning-stat-value">{todayClassCount}</span>
<th> Lớp / Tên Lớp</th> <span className="learning-stat-label">Lớp ca hôm nay</span>
<th>Lịch Học Trong Tuần</th> </div>
<th>App Đưc Phép</th> <div className="learning-stat">
<th style={{ textAlign: 'right' }}>Thao Tác</th> <span className="learning-stat-value">{filteredClasses.length}</span>
</tr> <span className="learning-stat-label">Kết quả lọc</span>
</thead> </div>
<tbody> </div>
{activeClasses.map((cls) => (
<tr key={cls.rkId}> <div className="content-card learning-toolbar-card">
<td style={{ maxWidth: '300px' }}> <div className="learning-toolbar">
<div style={{ fontWeight: 700, fontSize: '0.95rem', color: 'var(--primary-color)' }}>{cls.classCode}</div> <input
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> type="search"
{cls.name} className="app-pool-search learning-search"
</div> placeholder="Tìm mã lớp, tên lớp, môn học, chuyên ngành..."
</td> value={searchQuery}
<td> onChange={e => setSearchQuery(e.target.value)}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px' }}> />
{cls.schedules && cls.schedules.length > 0 ? ( <div className="learning-view-toggle">
cls.schedules.map((s: any, idx: number) => ( <button
<span key={idx} className="course-tag"> type="button"
{getDayString(s.dayOfWeek)} Ca{s.period || 1}: {s.startTime}-{s.endTime} className={`learning-view-btn${viewMode === 'list' ? ' learning-view-btn--active' : ''}`}
{s.courseName ? ` · ${s.courseName}` : ''} onClick={() => setViewMode('list')}
</span> >
)) Danh sách
) : ( </button>
<span style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>Chưa lịch</span> <button
)} type="button"
</div> className={`learning-view-btn${viewMode === 'timetable' ? ' learning-view-btn--active' : ''}`}
</td> onClick={() => setViewMode('timetable')}
<td> >
<span style={{ fontSize: '0.8rem', color: 'var(--text-muted)', background: 'rgba(255,255,255,0.03)', padding: '4px 8px', borderRadius: '6px', fontFamily: 'monospace', display: 'inline-block', maxWidth: '250px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={cls.allowedApps}> Thời khóa biểu
{cls.allowedApps || 'Mặc định'} </button>
</span>
</td>
<td style={{ textAlign: 'right' }}>
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}>
<button
className="btn btn-primary btn-sm"
style={{ padding: '0.45rem 0.85rem', fontSize: '0.8rem', fontWeight: 600, display: 'inline-flex', alignItems: 'center', gap: '0.35rem' }}
onClick={() => openClass('learning', cls.rkId, cls.name)}
>
💻 Vào Workspace
</button>
<button className="btn btn-sm" style={{ background: 'rgba(198,40,40,0.1)', color: '#ff8a80', padding: '0.45rem 0.85rem', fontSize: '0.8rem' }} onClick={() => handleDeleteClass(cls)}>
🗑 Hủy
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div> </div>
) </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> </div>
{/* MODAL CẤU HÌNH LỊCH HỌC & APP */}
{configClass && ( {configClass && (
<div className="modal-overlay"> <div className="modal-overlay">
<div className="modal-container" style={{ maxWidth: '800px', width: '90%' }}> <div className="modal-container" style={{ maxWidth: '800px', width: '90%' }}>
<div className="modal-header"> <div className="modal-header">
<div> <div>
<h2 className="modal-title"> Cấu Hình Lịch Học & ng Dụng</h2> <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> <p style={{ margin: '4px 0 0 0', color: 'var(--text-muted)', fontSize: '0.85rem' }}>
{configClass.name} ({configClass.classCode})
</p>
</div> </div>
<button className="btn btn-secondary close-btn" onClick={() => setConfigClass(null)}> Đóng</button> <button className="btn btn-secondary close-btn" onClick={() => setConfigClass(null)}>Đóng</button>
</div> </div>
<div style={{ padding: '1rem 0', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1.5rem' }}> <div style={{ padding: '1rem 0', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1.5rem' }}>
{/* Cột trái: Quản lý Ca học */}
<div style={{ borderRight: '1px solid var(--border-color)', paddingRight: '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> <h4 style={{ margin: '0 0 1rem 0' }}>Ca học trong tuần</h4>
<ScheduleEditor <ScheduleEditor
classId={configClass.rkId} classId={configClass.rkId}
schedules={schedules} schedules={schedules}
onSaved={reloadConfigSchedules} onSaved={reloadConfigSchedules}
/> />
</div> </div>
{/* Cột phải: Quản lý App Whitelist */}
<div style={{ display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}> <div style={{ display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
<div> <div>
<h4 style={{ margin: '0 0 1rem 0' }}>🚫 App Đưc Phép Chạy</h4> <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' }}> <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. Các ng dụng giao diện chứa từ khóa trong danh sách này mới đưc chạy trên máy sinh viên. Từ khóa ngăn chặn các ng dụng ngoài luồng trên máy sinh viên.
</p> </p>
<textarea <textarea
rows={6} rows={6}
value={allowedApps} value={allowedApps}
onChange={e => setAllowedApps(e.target.value)} onChange={e => setAllowedApps(e.target.value)}
placeholder="chrome,idea64,vscode,wails,simple_care" placeholder="chrome,idea64,vscode,wails,simple_care"
style={{ width: '100%', padding: '10px', background: '#25262b', color: '#fff', border: '1px solid var(--border-color)', borderRadius: '6px', fontFamily: 'monospace', fontSize: '0.85rem', boxSizing: 'border-box' }} className="app-textarea"
/> />
<small style={{ color: 'var(--text-muted)', display: 'block', marginTop: '4px' }}>Ngăn cách bằng dấu phẩy. Mặc đnh: <code>chrome,idea64,vscode,wails,simple_care</code></small>
</div> </div>
<button className="btn btn-primary" onClick={handleSaveApps} style={{ width: '100%', marginTop: '15px' }}> <button className="btn btn-primary" onClick={handleSaveApps} style={{ width: '100%', marginTop: '15px' }}>
💾 Lưu Whitelist App Lưu whitelist app
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
)} )}
{/* MODAL GIÁM SÁT CHI TIẾT SINH VIÊN */}
{monitorClass && ( {monitorClass && (
<div className="modal-overlay"> <div className="modal-overlay">
<div className="modal-container" style={{ maxWidth: '950px', width: '95%' }}> <div className="modal-container" style={{ maxWidth: '950px', width: '95%' }}>
<div className="modal-header"> <div className="modal-header">
<div> <div>
<h2 className="modal-title">📊 Nhật Ký & Giám Sát Sinh Viên</h2> <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> <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>
<div style={{ display: 'flex', gap: '8px' }}> <div style={{ display: 'flex', gap: '8px' }}>
<button className="btn btn-secondary" onClick={() => handleOpenMonitor(monitorClass)}>🔄 Tải lại</button> <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> <button className="btn btn-secondary close-btn" onClick={() => setMonitorClass(null)}>Đóng</button>
</div> </div>
</div> </div>
<div style={{ padding: '1rem 0', maxHeight: '450px', overflowY: 'auto' }}> <div style={{ padding: '1rem 0', maxHeight: '450px', overflowY: 'auto' }}>
@@ -321,22 +448,24 @@ export const LearningTab: React.FC = () => {
<table className="table"> <table className="table">
<thead> <thead>
<tr> <tr>
<th>Học Viên</th> <th>Học viên</th>
<th> SV</th> <th> SV</th>
<th>Thời gian Online</th> <th>Online</th>
<th>Thời gian Offline</th> <th>Offline</th>
<th>Lịch sử Wifi</th> <th>WiFi</th>
<th>Cập nhật cuối</th> <th>Cập nhật</th>
<th style={{ textAlign: 'right' }}>Thao tác</th> <th style={{ textAlign: 'right' }}>Thao tác</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{studentsLogs.length === 0 ? ( {studentsLogs.length === 0 ? (
<tr> <tr>
<td colSpan={7} style={{ textAlign: 'center', color: 'var(--text-muted)' }}>Chưa dữ liệu sinh viên điểm danh hôm nay.</td> <td colSpan={7} style={{ textAlign: 'center', color: 'var(--text-muted)' }}>
Chưa dữ liệu sinh viên hôm nay.
</td>
</tr> </tr>
) : ( ) : (
studentsLogs.map((log) => ( studentsLogs.map(log => (
<tr key={log.studentRkId}> <tr key={log.studentRkId}>
<td> <td>
<div style={{ fontWeight: 700 }}>{log.fullName}</div> <div style={{ fontWeight: 700 }}>{log.fullName}</div>
@@ -346,17 +475,17 @@ export const LearningTab: React.FC = () => {
<td style={{ color: '#4caf50', fontWeight: 700 }}>{formatSeconds(log.onlineSeconds)}</td> <td style={{ color: '#4caf50', fontWeight: 700 }}>{formatSeconds(log.onlineSeconds)}</td>
<td style={{ color: '#f44336', fontWeight: 700 }}>{formatSeconds(log.offlineSeconds)}</td> <td style={{ color: '#f44336', fontWeight: 700 }}>{formatSeconds(log.offlineSeconds)}</td>
<td> <td>
<span style={{ fontSize: '0.8rem', background: 'rgba(255,255,255,0.05)', padding: '3px 8px', borderRadius: '4px', border: '1px solid var(--border-color)' }}> <span className="course-tag">{log.wifiSsids || 'Chưa nhận'}</span>
{log.wifiSsids || 'Chưa nhận'}
</span>
</td> </td>
<td>{log.lastActiveAt ? new Date(log.lastActiveAt).toLocaleTimeString() : 'Chưa hoạt động'}</td> <td>{log.lastActiveAt ? new Date(log.lastActiveAt).toLocaleTimeString('vi-VN') : ''}</td>
<td style={{ textAlign: 'right' }}> <td style={{ textAlign: 'right' }}>
<button <button
className="btn btn-primary btn-sm" className="btn btn-primary btn-sm"
onClick={() => setProctorStudent({ id: log.studentRkId, name: log.fullName, code: log.studentCode })} onClick={() =>
setProctorStudent({ id: log.studentRkId, name: log.fullName, code: log.studentCode })
}
> >
🖥 Xem Camera & Screen Xem màn hình
</button> </button>
</td> </td>
</tr> </tr>
@@ -370,32 +499,31 @@ export const LearningTab: React.FC = () => {
</div> </div>
)} )}
{/* MODAL THÊM LỚP VÀO LỊCH GIÁM SÁT */}
{showAddClassModal && ( {showAddClassModal && (
<div className="modal-overlay"> <div className="modal-overlay">
<div className="modal-container" style={{ maxWidth: '500px' }}> <div className="modal-container" style={{ maxWidth: '500px' }}>
<div className="modal-header"> <div className="modal-header">
<h2 className="modal-title">🏫 Chọn Lớp Học T H Thống</h2> <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)}></button> <button className="btn btn-secondary close-btn" onClick={() => setShowAddClassModal(false)}>Đóng</button>
</div> </div>
<div style={{ padding: '1rem 0' }}> <div style={{ padding: '1rem 0' }}>
<input <input
type="text" type="search"
placeholder="Nhập tên lớp hoặc mã lớp cần tìm..." className="app-pool-search"
placeholder="Tìm tên lớp hoặc mã lớp..."
value={searchClassQuery} value={searchClassQuery}
onChange={e => setSearchClassQuery(e.target.value)} onChange={e => setSearchClassQuery(e.target.value)}
style={{ width: '100%', padding: '10px', background: '#25262b', color: '#fff', border: '1px solid var(--border-color)', borderRadius: '6px', marginBottom: '1rem', boxSizing: 'border-box' }}
/> />
<div style={{ maxHeight: '300px', overflowY: 'auto' }}> <div style={{ maxHeight: '300px', overflowY: 'auto', marginTop: '1rem' }}>
{filteredAddClasses.length === 0 ? ( {filteredAddClasses.length === 0 ? (
<p style={{ color: 'var(--text-muted)', textAlign: 'center' }}>Không tìm thấy lớp học phù hợp nào.</p> <p style={{ color: 'var(--text-muted)', textAlign: 'center' }}>Không tìm thấy lớp phù hợp.</p>
) : ( ) : (
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}> <ul className="learning-add-list">
{filteredAddClasses.map(c => ( {filteredAddClasses.map(c => (
<li key={c.rkId} style={{ padding: '10px', borderBottom: '1px solid rgba(255,255,255,0.05)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <li key={c.rkId} className="learning-add-item">
<div style={{ maxWidth: '350px' }}> <div>
<div style={{ fontWeight: 700, color: 'var(--primary-color)' }}>{c.classCode}</div> <div className="learning-class-code">{c.classCode}</div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>{c.name}</div> <div className="learning-class-meta">{c.name}</div>
</div> </div>
<button className="btn btn-primary btn-sm" onClick={() => handleActivateClass(c)}> <button className="btn btn-primary btn-sm" onClick={() => handleActivateClass(c)}>
Chọn Chọn
@@ -410,7 +538,6 @@ export const LearningTab: React.FC = () => {
</div> </div>
)} )}
{/* MODAL XEM STREAM CAMERA / SCREEN TRỰC TIẾP */}
{proctorStudent && ( {proctorStudent && (
<LiveProctorModal <LiveProctorModal
studentId={proctorStudent.id} studentId={proctorStudent.id}

View File

@@ -0,0 +1,94 @@
import React from 'react';
import {
WEEK_DAYS,
PERIODS,
getTodayDayOfWeek,
classAccentColor,
buildTimetableMap,
type ActiveClassItem,
} from './learningUtils';
interface LearningTimetableProps {
classes: ActiveClassItem[];
onOpenClass: (cls: ActiveClassItem) => void;
}
export const LearningTimetable: React.FC<LearningTimetableProps> = ({ classes, onOpenClass }) => {
const today = getTodayDayOfWeek();
const cellMap = buildTimetableMap(classes);
return (
<div className="learning-tkb-wrap">
<div className="learning-tkb-scroll">
<table className="learning-tkb">
<thead>
<tr>
<th className="learning-tkb-corner">Ca</th>
{WEEK_DAYS.map((label, day) => (
<th
key={label}
className={`learning-tkb-dayhead${day === today ? ' learning-tkb-dayhead--today' : ''}`}
>
<span>{label}</span>
{day === today && <em>Hôm nay</em>}
</th>
))}
</tr>
</thead>
<tbody>
{PERIODS.map(period => (
<tr key={period}>
<td className="learning-tkb-period">
<span className="learning-tkb-period-num">Ca {period}</span>
</td>
{WEEK_DAYS.map((_, day) => {
const entries = cellMap.get(`${day}-${period}`) || [];
const isToday = day === today;
return (
<td
key={`${day}-${period}`}
className={`learning-tkb-cell${isToday ? ' learning-tkb-cell--today' : ''}${
entries.length ? ' learning-tkb-cell--filled' : ''
}`}
>
{entries.length === 0 ? (
<span className="learning-tkb-empty"></span>
) : (
<div className="learning-tkb-blocks">
{entries.map(({ cls, schedule }) => {
const accent = classAccentColor(cls.classCode);
return (
<button
key={`${cls.rkId}-${schedule.id ?? schedule.dayOfWeek}`}
type="button"
className={`learning-tkb-block${schedule.isActive === false ? ' learning-tkb-block--inactive' : ''}`}
style={{ '--tkb-accent': accent } as React.CSSProperties}
onClick={() => onOpenClass(cls)}
title={`${cls.name}\n${schedule.courseName || ''}\n${schedule.startTime}-${schedule.endTime}`}
>
<span className="learning-tkb-block-code">{cls.classCode}</span>
{schedule.courseName && (
<span className="learning-tkb-block-course">{schedule.courseName}</span>
)}
{(schedule.startTime || schedule.endTime) && (
<span className="learning-tkb-block-time">
{schedule.startTime}
{schedule.endTime ? `${schedule.endTime}` : ''}
</span>
)}
</button>
);
})}
</div>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
};

View File

@@ -0,0 +1,129 @@
import type { ClassScheduleItem } from '../api';
export const WEEK_DAYS = ['Thứ 2', 'Thứ 3', 'Thứ 4', 'Thứ 5', 'Thứ 6', 'Thứ 7', 'Chủ Nhật'] as const;
export const PERIODS = [1, 2, 3, 4] as const;
export interface ActiveClassItem {
rkId: number;
name: string;
classCode: string;
type?: string;
studentCount?: number;
specializeName?: string;
systemName?: string;
schedules?: ClassScheduleItem[];
allowedApps?: string;
}
export function getDayLabel(day: number): string {
return WEEK_DAYS[day] ?? `Ngày ${day}`;
}
/** Thứ 2 = 0 … Chủ nhật = 6 */
export function getTodayDayOfWeek(): number {
const js = new Date().getDay();
return js === 0 ? 6 : js - 1;
}
/** Ca có dữ liệu hiển thị (có giờ hoặc môn) — kể cả ca template chưa bật */
export function isScheduleVisible(s: ClassScheduleItem): boolean {
return Boolean(s.startTime?.trim() || s.courseName?.trim());
}
export function filterActiveClasses(
classes: ActiveClassItem[],
query: string,
dayFilter: number | null
): ActiveClassItem[] {
const q = query.trim().toLowerCase();
return classes.filter(cls => {
if (dayFilter !== null) {
const hasDay = (cls.schedules || []).some(
s => s.dayOfWeek === dayFilter && isScheduleVisible(s)
);
if (!hasDay) return false;
}
if (!q) return true;
const haystack = [
cls.name,
cls.classCode,
cls.specializeName,
cls.systemName,
cls.type,
...(cls.schedules || []).map(s => `${s.courseName} ${s.startTime} ${s.endTime}`),
]
.filter(Boolean)
.join(' ')
.toLowerCase();
return haystack.includes(q);
});
}
export function countWeeklyShifts(classes: ActiveClassItem[]): number {
return classes.reduce(
(sum, cls) => sum + (cls.schedules || []).filter(isScheduleVisible).length,
0
);
}
export function classHasToday(cls: ActiveClassItem): boolean {
const today = getTodayDayOfWeek();
return (cls.schedules || []).some(s => s.dayOfWeek === today && isScheduleVisible(s) && s.isActive !== false);
}
export function schedulesByDay(cls: ActiveClassItem): Map<number, ClassScheduleItem[]> {
const map = new Map<number, ClassScheduleItem[]>();
for (const s of cls.schedules || []) {
if (!isScheduleVisible(s)) continue;
const list = map.get(s.dayOfWeek) || [];
list.push(s);
map.set(s.dayOfWeek, list);
}
for (const [, list] of map) {
list.sort((a, b) => (a.period || 1) - (b.period || 1));
}
return map;
}
const CLASS_COLORS = [
'#c62828',
'#1565c0',
'#2e7d32',
'#6a1b9a',
'#ef6c00',
'#00838f',
'#ad1457',
'#4527a0',
];
export function classAccentColor(classCode: string): string {
let hash = 0;
for (let i = 0; i < classCode.length; i++) {
hash = classCode.charCodeAt(i) + ((hash << 5) - hash);
}
return CLASS_COLORS[Math.abs(hash) % CLASS_COLORS.length];
}
export interface TimetableCellEntry {
cls: ActiveClassItem;
schedule: ClassScheduleItem;
}
export function buildTimetableMap(
classes: ActiveClassItem[]
): Map<string, TimetableCellEntry[]> {
const map = new Map<string, TimetableCellEntry[]>();
for (const cls of classes) {
for (const s of cls.schedules || []) {
if (!isScheduleVisible(s)) continue;
const key = `${s.dayOfWeek}-${s.period || 1}`;
const list = map.get(key) || [];
list.push({ cls, schedule: s });
map.set(key, list);
}
}
for (const [, list] of map) {
list.sort((a, b) => a.cls.classCode.localeCompare(b.cls.classCode));
}
return map;
}

View File

@@ -2547,3 +2547,446 @@ input:checked + .slider:before {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
/* ── Learning tab: search, cards, timetable ── */
.learning-stats-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.75rem;
margin-bottom: 1rem;
}
.learning-stat {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 0.85rem 1rem;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.learning-stat--accent {
border-color: rgba(198, 40, 40, 0.35);
background: rgba(198, 40, 40, 0.06);
}
.learning-stat-value {
font-size: 1.45rem;
font-weight: 800;
color: var(--text-primary);
line-height: 1.1;
}
.learning-stat-label {
font-size: 0.78rem;
color: var(--text-muted);
}
.learning-toolbar-card {
margin-bottom: 1rem;
padding: 1rem !important;
}
.learning-toolbar {
display: flex;
gap: 0.75rem;
align-items: center;
flex-wrap: wrap;
}
.learning-search {
flex: 1;
min-width: 220px;
}
.learning-view-toggle {
display: inline-flex;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
overflow: hidden;
flex-shrink: 0;
}
.learning-view-btn {
border: none;
background: transparent;
color: var(--text-secondary);
padding: 0.5rem 0.9rem;
font-size: 0.82rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.learning-view-btn--active {
background: var(--primary-color);
color: #fff;
}
.learning-day-filters {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-top: 0.85rem;
}
.learning-day-chip {
border: 1px solid var(--border-color);
background: var(--bg-subtle);
color: var(--text-secondary);
border-radius: 999px;
padding: 0.28rem 0.65rem;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
}
.learning-day-chip--active {
border-color: var(--primary-color);
background: rgba(198, 40, 40, 0.12);
color: var(--primary-color);
}
.learning-empty {
text-align: center;
padding: 2.5rem 1rem;
color: var(--text-muted);
}
.learning-empty-icon {
font-size: 2.2rem;
display: block;
margin-bottom: 0.5rem;
}
.learning-class-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
}
.learning-class-card {
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background: var(--bg-subtle);
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
transition: border-color 0.15s, box-shadow 0.15s;
}
.learning-class-card:hover {
border-color: rgba(198, 40, 40, 0.35);
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.12);
}
.learning-class-card--today {
border-color: rgba(198, 40, 40, 0.45);
background: linear-gradient(145deg, rgba(198, 40, 40, 0.07), var(--bg-subtle));
}
.learning-class-card-head {
display: flex;
justify-content: space-between;
gap: 0.75rem;
align-items: flex-start;
}
.learning-class-code {
font-weight: 800;
font-size: 0.95rem;
color: var(--primary-color);
}
.learning-class-name {
margin: 0.2rem 0 0;
font-size: 0.88rem;
font-weight: 600;
line-height: 1.35;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.learning-class-meta {
margin: 0.25rem 0 0;
font-size: 0.75rem;
color: var(--text-muted);
}
.learning-class-badges {
display: flex;
flex-direction: column;
gap: 0.3rem;
align-items: flex-end;
flex-shrink: 0;
}
.learning-badge {
font-size: 0.68rem;
font-weight: 700;
padding: 0.2rem 0.45rem;
border-radius: 4px;
background: var(--bg-card);
border: 1px solid var(--border-color);
color: var(--text-secondary);
white-space: nowrap;
}
.learning-badge--today {
background: rgba(198, 40, 40, 0.15);
border-color: rgba(198, 40, 40, 0.4);
color: #ff8a80;
}
.learning-class-schedule {
display: flex;
flex-direction: column;
gap: 0.45rem;
max-height: 140px;
overflow-y: auto;
}
.learning-schedule-day {
display: flex;
gap: 0.5rem;
align-items: flex-start;
}
.learning-schedule-day-label {
font-size: 0.72rem;
font-weight: 700;
color: var(--text-muted);
min-width: 3.2rem;
flex-shrink: 0;
padding-top: 0.15rem;
}
.learning-schedule-shifts {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.learning-class-no-sched {
margin: 0;
font-size: 0.8rem;
color: var(--text-muted);
}
.learning-class-apps {
margin: 0;
font-size: 0.72rem;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.learning-class-apps code {
font-size: 0.7rem;
}
.learning-class-actions {
display: flex;
gap: 0.45rem;
flex-wrap: wrap;
margin-top: auto;
padding-top: 0.35rem;
border-top: 1px solid var(--border-color);
}
.learning-btn-danger {
color: #ff8a80 !important;
border-color: rgba(198, 40, 40, 0.25) !important;
}
.learning-add-list {
list-style: none;
padding: 0;
margin: 0;
}
.learning-add-item {
padding: 0.65rem 0;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
}
/* Timetable (TKB) */
.learning-tkb-wrap {
margin: -0.25rem 0 0;
}
.learning-tkb-scroll {
overflow-x: auto;
padding-bottom: 0.5rem;
}
.learning-tkb {
width: 100%;
min-width: 720px;
border-collapse: separate;
border-spacing: 0;
table-layout: fixed;
}
.learning-tkb-corner {
width: 64px;
background: var(--bg-subtle);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm) 0 0 0;
padding: 0.6rem;
font-size: 0.75rem;
color: var(--text-muted);
}
.learning-tkb-dayhead {
background: var(--bg-subtle);
border: 1px solid var(--border-color);
border-left: none;
padding: 0.55rem 0.4rem;
text-align: center;
font-size: 0.78rem;
font-weight: 700;
color: var(--text-secondary);
}
.learning-tkb-dayhead span {
display: block;
}
.learning-tkb-dayhead em {
display: block;
font-style: normal;
font-size: 0.65rem;
font-weight: 700;
color: var(--primary-color);
margin-top: 0.15rem;
}
.learning-tkb-dayhead--today {
background: rgba(198, 40, 40, 0.1);
color: var(--primary-color);
}
.learning-tkb-period {
background: var(--bg-subtle);
border: 1px solid var(--border-color);
border-top: none;
padding: 0.5rem;
text-align: center;
vertical-align: top;
}
.learning-tkb-period-num {
font-size: 0.75rem;
font-weight: 700;
color: var(--text-muted);
}
.learning-tkb-cell {
border: 1px solid var(--border-color);
border-left: none;
border-top: none;
padding: 0.35rem;
vertical-align: top;
min-height: 72px;
background: var(--bg-card);
}
.learning-tkb-cell--today {
background: rgba(198, 40, 40, 0.04);
}
.learning-tkb-cell--filled {
background: rgba(255, 255, 255, 0.02);
}
.learning-tkb-empty {
display: block;
text-align: center;
color: var(--text-muted);
font-size: 0.8rem;
padding: 1rem 0;
opacity: 0.4;
}
.learning-tkb-blocks {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.learning-tkb-block {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.1rem;
width: 100%;
text-align: left;
border: none;
border-left: 3px solid var(--tkb-accent, var(--primary-color));
border-radius: 4px;
background: rgba(255, 255, 255, 0.04);
padding: 0.35rem 0.45rem;
cursor: pointer;
transition: transform 0.12s, background 0.12s;
}
.learning-tkb-block:hover {
background: rgba(255, 255, 255, 0.08);
transform: translateY(-1px);
}
.learning-tkb-block-code {
font-size: 0.72rem;
font-weight: 800;
color: var(--tkb-accent, var(--primary-color));
}
.learning-tkb-block-course {
font-size: 0.68rem;
color: var(--text-primary);
line-height: 1.25;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.learning-tkb-block--inactive {
opacity: 0.55;
border-left-style: dashed;
}
.learning-tkb-block-time {
font-size: 0.62rem;
color: var(--text-muted);
font-family: monospace;
}
@media (max-width: 900px) {
.learning-stats-row {
grid-template-columns: repeat(2, 1fr);
}
.learning-toolbar {
flex-direction: column;
align-items: stretch;
}
.learning-view-toggle {
width: 100%;
}
.learning-view-btn {
flex: 1;
}
}