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 {
apiFetchActiveSchedules,
apiFetchClassSchedule,
@@ -18,26 +18,37 @@ import type {
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<any[]>([]);
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');
// Lớp đang cấu hình cấu trúc lịch/app (modal)
const [configClass, setConfigClass] = useState<any | null>(null);
const [configClass, setConfigClass] = useState<ActiveClassItem | null>(null);
const [schedules, setSchedules] = useState<ClassScheduleItem[]>([]);
const [allowedApps, setAllowedApps] = useState<string>('');
// Lớp đang xem giám sát sinh viên
const [monitorClass, setMonitorClass] = useState<any | null>(null);
const [monitorClass, setMonitorClass] = useState<ActiveClassItem | null>(null);
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 [allClasses, setAllClasses] = useState<ClassItem[]>([]);
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 loadActiveClasses = async () => {
@@ -56,10 +67,20 @@ export const LearningTab: React.FC = () => {
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 {
// 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 });
setAllClasses(res.data || []);
} catch (err) {
@@ -73,18 +94,17 @@ export const LearningTab: React.FC = () => {
await apiApplyScheduleTemplate(cls.rkId);
await apiSaveAllowedApps(cls.rkId, DEFAULT_ALLOWED_APPS);
await loadActiveClasses();
handleOpenConfig(cls);
handleOpenConfig(cls as ActiveClassItem);
} catch (err) {
alert('Không thể kích hoạt lớp: ' + err);
}
};
const handleOpenConfig = async (cls: any) => {
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) {
@@ -92,7 +112,7 @@ export const LearningTab: React.FC = () => {
}
};
const handleOpenMonitor = async (cls: any) => {
const handleOpenMonitor = async (cls: ActiveClassItem) => {
setMonitorClass(cls);
setLoading(true);
try {
@@ -117,6 +137,7 @@ export const LearningTab: React.FC = () => {
};
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!');
@@ -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}?`)) {
try {
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) => {
if (!totalSeconds) return '0s';
const hrs = Math.floor(totalSeconds / 3600);
@@ -151,14 +176,9 @@ export const LearningTab: React.FC = () => {
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 =>
(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)
);
@@ -168,152 +188,259 @@ export const LearningTab: React.FC = () => {
<div className="page-header">
<div className="page-title">
<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>
<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>
</div>
</div>
<div className="tab-page-body tab-page-scroll">
<div className="content-card">
<h2 style={{ margin: '0 0 1rem 0', fontSize: '1rem', fontWeight: 700 }}>Lớp đang hoạt đng tuần này</h2>
{loading && <div style={{ textAlign: 'center', padding: '2rem', color: 'var(--text-muted)' }}>🔄 Đang tải...</div>}
{!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 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>
) : (
!loading && (
<div className="table-responsive">
<table className="table">
<thead>
<tr>
<th> Lớp / Tên Lớp</th>
<th>Lịch Học Trong Tuần</th>
<th>App Đưc Phép</th>
<th style={{ textAlign: 'right' }}>Thao Tác</th>
</tr>
</thead>
<tbody>
{activeClasses.map((cls) => (
<tr key={cls.rkId}>
<td style={{ maxWidth: '300px' }}>
<div style={{ fontWeight: 700, fontSize: '0.95rem', color: 'var(--primary-color)' }}>{cls.classCode}</div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{cls.name}
</div>
</td>
<td>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px' }}>
{cls.schedules && cls.schedules.length > 0 ? (
cls.schedules.map((s: any, idx: number) => (
<span key={idx} className="course-tag">
{getDayString(s.dayOfWeek)} Ca{s.period || 1}: {s.startTime}-{s.endTime}
{s.courseName ? ` · ${s.courseName}` : ''}
</span>
))
) : (
<span style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>Chưa lịch</span>
)}
</div>
</td>
<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}>
{cls.allowedApps || 'Mặc định'}
</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 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>
<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>
{/* MODAL CẤU HÌNH LỊCH HỌC & APP */}
{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>
<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>
<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' }}>
{/* Cột trái: Quản lý Ca học */}
<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
classId={configClass.rkId}
schedules={schedules}
onSaved={reloadConfigSchedules}
/>
</div>
{/* Cột phải: Quản lý App Whitelist */}
<div style={{ display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
<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' }}>
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>
<textarea
rows={6}
value={allowedApps}
onChange={e => setAllowedApps(e.target.value)}
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>
<button className="btn btn-primary" onClick={handleSaveApps} style={{ width: '100%', marginTop: '15px' }}>
💾 Lưu Whitelist App
Lưu whitelist app
</button>
</div>
</div>
</div>
</div>
)}
{/* MODAL GIÁM SÁT CHI TIẾT SINH VIÊN */}
{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>
<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>
<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' }}>
@@ -321,22 +448,24 @@ export const LearningTab: React.FC = () => {
<table className="table">
<thead>
<tr>
<th>Học Viên</th>
<th>Học viên</th>
<th> SV</th>
<th>Thời gian Online</th>
<th>Thời gian Offline</th>
<th>Lịch sử Wifi</th>
<th>Cập nhật cuối</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 đ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>
) : (
studentsLogs.map((log) => (
studentsLogs.map(log => (
<tr key={log.studentRkId}>
<td>
<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: '#f44336', fontWeight: 700 }}>{formatSeconds(log.offlineSeconds)}</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)' }}>
{log.wifiSsids || 'Chưa nhận'}
</span>
<span className="course-tag">{log.wifiSsids || 'Chưa nhận'}</span>
</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' }}>
<button
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>
</td>
</tr>
@@ -370,32 +499,31 @@ export const LearningTab: React.FC = () => {
</div>
)}
{/* MODAL THÊM LỚP VÀO LỊCH GIÁM SÁT */}
{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)}></button>
<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="text"
placeholder="Nhập tên lớp hoặc mã lớp cần tìm..."
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)}
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 ? (
<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 => (
<li key={c.rkId} style={{ padding: '10px', borderBottom: '1px solid rgba(255,255,255,0.05)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ maxWidth: '350px' }}>
<div style={{ fontWeight: 700, color: 'var(--primary-color)' }}>{c.classCode}</div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>{c.name}</div>
<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
@@ -410,7 +538,6 @@ export const LearningTab: React.FC = () => {
</div>
)}
{/* MODAL XEM STREAM CAMERA / SCREEN TRỰC TIẾP */}
{proctorStudent && (
<LiveProctorModal
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;
}
}
/* ── 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;
}
}