All checks were successful
Deploy on Master Change / deploy (push) Successful in 1m17s
763 lines
34 KiB
TypeScript
763 lines
34 KiB
TypeScript
import React, { useEffect, useMemo, useState } from 'react';
|
||
import {
|
||
apiFetchActiveSchedules,
|
||
apiFetchClassSchedule,
|
||
apiDeleteClassSchedule,
|
||
apiFetchAllowedApps,
|
||
apiSaveAllowedApps,
|
||
apiFetchClassSessionLogs,
|
||
apiFetchClasses,
|
||
apiFetchScheduleConflicts,
|
||
apiMyClasses,
|
||
} from '../api';
|
||
import { DEFAULT_ALLOWED_APPS } from '../constants';
|
||
import type {
|
||
ClassItem,
|
||
ClassScheduleItem,
|
||
StudentSessionLogItem,
|
||
ScheduleConflictItem,
|
||
} from '../api';
|
||
import { LiveProctorModal } from './LiveProctorModal';
|
||
import { AppPoolModal } from './AppPoolModal';
|
||
import { AppTemplatePickerModal } from './AppTemplatePickerModal';
|
||
import { mergeKeywordCSV } from '../utils/appKeywords';
|
||
import { openClass } from './NavHistoryBar';
|
||
import { ScheduleEditor } from './ScheduleEditor';
|
||
import { LearningTimetable } from './LearningTimetable';
|
||
import {
|
||
WEEK_DAYS,
|
||
filterActiveClasses,
|
||
countWeeklyShifts,
|
||
classHasToday,
|
||
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 [onlyMyClasses, setOnlyMyClasses] = useState<boolean>(false);
|
||
const [myClasses, setMyClasses] = useState<number[]>([]);
|
||
|
||
const refreshMyClasses = () => {
|
||
apiMyClasses.list().then((items) => {
|
||
setMyClasses(items.map((c) => c.rkId));
|
||
}).catch(() => {});
|
||
};
|
||
|
||
useEffect(() => {
|
||
refreshMyClasses();
|
||
window.addEventListener('my-classes-updated', refreshMyClasses);
|
||
return () => window.removeEventListener('my-classes-updated', refreshMyClasses);
|
||
}, []);
|
||
|
||
const toggleMyClass = (classId: number, _name: string, _code: string) => {
|
||
const isMyClass = myClasses.includes(classId);
|
||
if (isMyClass) {
|
||
setMyClasses((prev) => prev.filter((id) => id !== classId));
|
||
apiMyClasses.remove(classId).catch(() => {});
|
||
} else {
|
||
setMyClasses((prev) => [...prev, classId]);
|
||
apiMyClasses.add(classId).catch(() => {});
|
||
}
|
||
window.dispatchEvent(new Event('my-classes-updated'));
|
||
};
|
||
|
||
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 [conflictWarning, setConflictWarning] = useState<{ cls: ClassItem; conflicts: ScheduleConflictItem[] } | null>(null);
|
||
|
||
const [appPoolOpen, setAppPoolOpen] = useState(false);
|
||
const [templatePickerOpen, setTemplatePickerOpen] = useState(false);
|
||
|
||
const addAppKeyword = (kw: string) => {
|
||
const trimmed = kw.trim().toLowerCase();
|
||
if (!trimmed) return;
|
||
setAllowedApps(prev => {
|
||
const parts = prev.split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
|
||
if (parts.includes(trimmed)) return prev;
|
||
return prev ? `${prev}, ${trimmed}` : trimmed;
|
||
});
|
||
};
|
||
|
||
const applyTemplate = (keywords: string) => {
|
||
setAllowedApps((prev) => mergeKeywordCSV(prev, keywords));
|
||
};
|
||
|
||
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(() => {
|
||
const list = filterActiveClasses(activeClasses, searchQuery, dayFilter);
|
||
if (onlyMyClasses) {
|
||
return list.filter(c => myClasses.includes(c.rkId));
|
||
}
|
||
return list;
|
||
}, [activeClasses, searchQuery, dayFilter, onlyMyClasses, myClasses]);
|
||
|
||
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 {
|
||
// Kiểm tra trùng sinh viên với lớp đang giám sát có lịch học
|
||
const conflictRes = await apiFetchScheduleConflicts(cls.rkId);
|
||
if (conflictRes.conflicts && conflictRes.conflicts.length > 0) {
|
||
setConflictWarning({ cls, conflicts: conflictRes.conflicts });
|
||
return;
|
||
}
|
||
await doActivateClass(cls);
|
||
} catch (err) {
|
||
alert('Không thể kích hoạt lớp: ' + err);
|
||
}
|
||
};
|
||
|
||
const doActivateClass = async (cls: ClassItem) => {
|
||
try {
|
||
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" style={{ gap: '1rem' }}>
|
||
<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)}
|
||
/>
|
||
<label className="checkbox-label" style={{ display: 'flex', alignItems: 'center', gap: '0.35rem', cursor: 'pointer', fontSize: '0.88rem', fontWeight: 600, color: 'var(--text-primary)', whiteSpace: 'nowrap', userSelect: 'none', marginLeft: '0.5rem' }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={onlyMyClasses}
|
||
onChange={e => setOnlyMyClasses(e.target.checked)}
|
||
/>
|
||
<span>⭐ Lớp của tôi</span>
|
||
</label>
|
||
<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="tab-page-scroll">
|
||
<div className="content-card">
|
||
{loading && (
|
||
<div className="learning-empty" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.75rem', padding: '3rem 1.5rem' }}>
|
||
<div className="sync-spinner" style={{ width: '36px', height: '36px', borderWidth: '3px' }}></div>
|
||
<p style={{ color: 'var(--text-secondary)', fontSize: '0.9rem' }}>Đang tải dữ liệu...</p>
|
||
</div>
|
||
)}
|
||
|
||
{!loading && activeClasses.length === 0 && (
|
||
<div className="learning-empty" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.75rem', padding: '3rem 1.5rem' }}>
|
||
<svg viewBox="0 0 24 24" width="48" height="48" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ color: 'var(--text-muted)', marginBottom: '0.5rem' }}>
|
||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
||
<line x1="16" y1="2" x2="16" y2="6" />
|
||
<line x1="8" y1="2" x2="8" y2="6" />
|
||
<line x1="3" y1="10" x2="21" y2="10" />
|
||
</svg>
|
||
<p style={{ color: 'var(--text-secondary)', fontSize: '0.95rem', fontWeight: 500 }}>Chưa có lớp nào được tạo lịch học.</p>
|
||
<button className="btn btn-secondary" onClick={handleOpenAddClass} style={{ marginTop: '0.5rem' }}>
|
||
Bắt đầu thêm lớp đầu tiên
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{!loading && activeClasses.length > 0 && filteredClasses.length === 0 && (
|
||
<div className="learning-empty" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.75rem', padding: '3rem 1.5rem' }}>
|
||
<svg viewBox="0 0 24 24" width="48" height="48" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ color: 'var(--text-muted)', marginBottom: '0.5rem' }}>
|
||
<circle cx="11" cy="11" r="8" />
|
||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||
</svg>
|
||
<p style={{ color: 'var(--text-secondary)', fontSize: '0.95rem', fontWeight: 500 }}>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);
|
||
setOnlyMyClasses(false);
|
||
}}
|
||
style={{ marginTop: '0.5rem' }}
|
||
>
|
||
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 today = classHasToday(cls);
|
||
const isMyClass = myClasses.includes(cls.rkId);
|
||
return (
|
||
<article key={cls.rkId} className={`learning-class-card${today ? ' learning-class-card--today' : ''}`}>
|
||
<header className="learning-class-card-head">
|
||
<div style={{ display: 'flex', gap: '0.65rem', alignItems: 'flex-start', flex: 1, minWidth: 0 }}>
|
||
<button
|
||
type="button"
|
||
className={`btn-my-class-star ${isMyClass ? 'active' : ''}`}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
toggleMyClass(cls.rkId, cls.name, cls.classCode);
|
||
}}
|
||
title={isMyClass ? 'Xóa khỏi Lớp của tôi' : 'Thêm vào Lớp của tôi'}
|
||
style={{
|
||
background: 'none',
|
||
border: 'none',
|
||
fontSize: '1.3rem',
|
||
cursor: 'pointer',
|
||
padding: 0,
|
||
lineHeight: 1,
|
||
color: isMyClass ? '#eab308' : '#d1d5db',
|
||
transition: 'color 0.2s',
|
||
marginTop: '0.1rem'
|
||
}}
|
||
>
|
||
{isMyClass ? '★' : '☆'}
|
||
</button>
|
||
<div style={{ minWidth: 0, flex: 1 }}>
|
||
<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>
|
||
<div className="learning-class-badges" style={{ flexShrink: 0 }}>
|
||
{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>
|
||
|
||
<footer className="learning-class-actions" style={{ marginTop: 'auto', paddingTop: '1rem' }}>
|
||
<button
|
||
type="button"
|
||
className="btn btn-primary btn-sm"
|
||
style={{ flex: 1 }}
|
||
onClick={() => handleOpenWorkspace(cls)}
|
||
>
|
||
Vào Workspace
|
||
</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>
|
||
|
||
{configClass && (
|
||
<div className="modal-overlay" onClick={() => setConfigClass(null)}>
|
||
<div className="modal-container" style={{ maxWidth: '800px', width: '90%' }} onClick={e => e.stopPropagation()}>
|
||
<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="modal-close-btn" onClick={() => setConfigClass(null)} aria-label="Đóng">×</button>
|
||
</div>
|
||
<div className="modal-body" style={{ padding: '1.75rem', 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={8}
|
||
value={allowedApps}
|
||
onChange={e => setAllowedApps(e.target.value)}
|
||
placeholder="chrome,idea64,vscode,wails,simple_care"
|
||
style={{
|
||
width: '100%',
|
||
padding: '0.75rem',
|
||
borderRadius: 'var(--radius-sm)',
|
||
border: '1px solid var(--border-color)',
|
||
fontFamily: 'monospace',
|
||
fontSize: '0.85rem',
|
||
color: 'var(--text-primary)',
|
||
backgroundColor: '#fff',
|
||
outline: 'none',
|
||
transition: 'var(--transition)',
|
||
resize: 'vertical',
|
||
boxSizing: 'border-box'
|
||
}}
|
||
onFocus={(e) => e.target.style.borderColor = 'var(--accent)'}
|
||
onBlur={(e) => e.target.style.borderColor = 'var(--border-color)'}
|
||
/>
|
||
<div style={{ display: 'flex', gap: '8px', marginTop: '10px' }}>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary btn-sm"
|
||
style={{ flex: 1 }}
|
||
onClick={() => setTemplatePickerOpen(true)}
|
||
>
|
||
Chọn khung
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary btn-sm"
|
||
style={{ flex: 1 }}
|
||
onClick={() => setAppPoolOpen(true)}
|
||
>
|
||
Mở kho
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<button className="btn btn-primary" onClick={handleSaveApps} style={{ width: '100%', marginTop: '15px' }}>
|
||
Lưu whitelist app
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="modal-footer">
|
||
<button className="btn btn-secondary" onClick={() => setConfigClass(null)}>Đóng</button>
|
||
</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" onClick={() => setShowAddClassModal(false)}>
|
||
<div className="modal-container" style={{ maxWidth: '500px' }} onClick={e => e.stopPropagation()}>
|
||
<div className="modal-header">
|
||
<h2 className="modal-title">Chọn lớp học từ hệ thống</h2>
|
||
<button className="modal-close-btn" onClick={() => setShowAddClassModal(false)} aria-label="Đóng">×</button>
|
||
</div>
|
||
<div className="modal-body" style={{ padding: '1.5rem' }}>
|
||
<div className="search-input-wrapper" style={{ width: '100%', marginBottom: '1.25rem' }}>
|
||
<input
|
||
type="search"
|
||
className="search-input"
|
||
placeholder="Tìm tên lớp hoặc mã lớp..."
|
||
value={searchClassQuery}
|
||
onChange={e => setSearchClassQuery(e.target.value)}
|
||
/>
|
||
<span className="search-icon">
|
||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<circle cx="11" cy="11" r="8" />
|
||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||
</svg>
|
||
</span>
|
||
</div>
|
||
<div style={{ maxHeight: '300px', overflowY: 'auto' }}>
|
||
{filteredAddClasses.length === 0 ? (
|
||
<p style={{ color: 'var(--text-muted)', textAlign: 'center', padding: '1rem 0' }}>Không tìm thấy lớp phù hợp.</p>
|
||
) : (
|
||
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||
{filteredAddClasses.map(c => (
|
||
<li key={c.rkId} style={{
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center',
|
||
padding: '0.85rem 1rem',
|
||
backgroundColor: 'var(--bg-app)',
|
||
border: '1px solid var(--border-color)',
|
||
borderRadius: 'var(--radius-sm)',
|
||
transition: 'var(--transition)'
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
e.currentTarget.style.borderColor = 'rgba(187, 33, 38, 0.25)';
|
||
e.currentTarget.style.backgroundColor = '#fff';
|
||
e.currentTarget.style.boxShadow = 'var(--shadow-sm)';
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
e.currentTarget.style.borderColor = 'var(--border-color)';
|
||
e.currentTarget.style.backgroundColor = 'var(--bg-app)';
|
||
e.currentTarget.style.boxShadow = 'none';
|
||
}}
|
||
>
|
||
<div>
|
||
<div style={{ fontWeight: 700, color: 'var(--text-primary)', fontSize: '0.9rem' }}>{c.classCode}</div>
|
||
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', marginTop: '2px' }}>{c.name}</div>
|
||
</div>
|
||
<button className="btn btn-primary btn-sm" onClick={() => handleActivateClass(c)} style={{ display: 'inline-flex', alignItems: 'center', gap: '4px' }}>
|
||
Kích hoạt
|
||
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M5 12h14M12 5l7 7-7 7" />
|
||
</svg>
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="modal-footer">
|
||
<button className="btn btn-secondary" onClick={() => setShowAddClassModal(false)}>Đóng</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{proctorStudent && (
|
||
<LiveProctorModal
|
||
studentId={proctorStudent.id}
|
||
studentName={proctorStudent.name}
|
||
studentCode={proctorStudent.code}
|
||
onClose={() => setProctorStudent(null)}
|
||
/>
|
||
)}
|
||
{conflictWarning && (
|
||
<div className="modal-overlay">
|
||
<div className="modal-container" style={{ maxWidth: '560px' }}>
|
||
<div className="modal-header">
|
||
<div>
|
||
<h2 className="modal-title" style={{ color: 'var(--danger)' }}>⚠️ Cảnh báo trùng sinh viên</h2>
|
||
<p style={{ margin: '4px 0 0 0', color: 'var(--text-muted)', fontSize: '0.85rem' }}>
|
||
Lớp <strong>{conflictWarning.cls.classCode}</strong> có sinh viên đang được giám sát ở lớp khác có lịch học
|
||
</p>
|
||
</div>
|
||
<button className="modal-close-btn" onClick={() => setConflictWarning(null)} aria-label="Đóng">×</button>
|
||
</div>
|
||
<div className="modal-body" style={{ padding: '1.25rem 1.5rem', maxHeight: '340px', overflowY: 'auto' }}>
|
||
<p style={{ fontSize: '0.85rem', color: 'var(--text-secondary)', marginTop: 0, marginBottom: '0.85rem' }}>
|
||
Những sinh viên bên dưới đang thuộc lớp đang giám sát có lịch học — hệ thống sẽ gắn họ vào lớp có lịch, không phải lớp mới này.
|
||
Bạn vẫn có thể tiếp tục kích hoạt, nhưng nên kiểm tra lại danh sách lớp để tránh nhầm lẫn.
|
||
</p>
|
||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.82rem' }}>
|
||
<thead>
|
||
<tr style={{ borderBottom: '2px solid var(--border-color)', textAlign: 'left' }}>
|
||
<th style={{ padding: '0.4rem 0.5rem', color: 'var(--text-muted)', fontWeight: 600 }}>Sinh viên</th>
|
||
<th style={{ padding: '0.4rem 0.5rem', color: 'var(--text-muted)', fontWeight: 600 }}>Đang ở lớp</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{conflictWarning.conflicts.map((cf, idx) => (
|
||
<tr key={idx} style={{ borderBottom: '1px solid var(--border-light)' }}>
|
||
<td style={{ padding: '0.45rem 0.5rem' }}>
|
||
<div style={{ fontWeight: 600 }}>{cf.fullName}</div>
|
||
<div style={{ color: 'var(--text-muted)', fontFamily: 'monospace' }}>{cf.studentCode}</div>
|
||
</td>
|
||
<td style={{ padding: '0.45rem 0.5rem' }}>
|
||
<div style={{ fontWeight: 600, color: 'var(--danger)' }}>{cf.conflictClassCode}</div>
|
||
<div style={{ color: 'var(--text-secondary)', fontSize: '0.78rem' }}>{cf.conflictClassName}</div>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div className="modal-footer" style={{ gap: '0.65rem' }}>
|
||
<button className="btn btn-secondary" onClick={() => setConflictWarning(null)}>Hủy</button>
|
||
<button
|
||
className="btn btn-primary"
|
||
style={{ background: 'var(--danger)', borderColor: 'var(--danger)' }}
|
||
onClick={() => { const cls = conflictWarning.cls; setConflictWarning(null); doActivateClass(cls); }}
|
||
>
|
||
Vẫn kích hoạt
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<AppPoolModal
|
||
open={appPoolOpen}
|
||
onClose={() => setAppPoolOpen(false)}
|
||
onSelect={addAppKeyword}
|
||
allowedApps={allowedApps}
|
||
/>
|
||
<AppTemplatePickerModal
|
||
open={templatePickerOpen}
|
||
onClose={() => setTemplatePickerOpen(false)}
|
||
onSelect={applyTemplate}
|
||
allowedApps={allowedApps}
|
||
/>
|
||
</div>
|
||
);
|
||
};
|