This commit is contained in:
2026-06-30 09:31:33 +07:00
parent c736326162
commit bbf8336664
77 changed files with 12601 additions and 556 deletions

View File

@@ -0,0 +1,167 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { apiFetchAppPool } from '../api';
import type { AppPoolItem } from '../api';
interface AppPoolModalProps {
open: boolean;
onClose: () => void;
onSelect: (keyword: string) => void;
allowedApps: string;
}
const formatWhen = (iso: string) => {
if (!iso) return '—';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '—';
return d.toLocaleString('vi-VN', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
};
export const AppPoolModal: React.FC<AppPoolModalProps> = ({
open,
onClose,
onSelect,
allowedApps,
}) => {
const [search, setSearch] = useState('');
const [debouncedQ, setDebouncedQ] = useState('');
const [items, setItems] = useState<AppPoolItem[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const allowedSet = useMemo(() => {
return new Set(
allowedApps.split(',').map(s => s.trim().toLowerCase()).filter(Boolean)
);
}, [allowedApps]);
useEffect(() => {
if (!open) return;
const t = setTimeout(() => setDebouncedQ(search.trim()), 300);
return () => clearTimeout(t);
}, [search, open]);
const loadPool = useCallback(async (q: string) => {
try {
setLoading(true);
setError(null);
const res = await apiFetchAppPool(q, 80);
setItems(res.data || []);
} catch (e: any) {
setItems([]);
setError(e?.message || 'Không tải được kho ứng dụng');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (!open) return;
loadPool(debouncedQ);
const interval = setInterval(() => loadPool(debouncedQ), 20000);
return () => clearInterval(interval);
}, [open, debouncedQ, loadPool]);
useEffect(() => {
if (!open) {
setSearch('');
setDebouncedQ('');
setItems([]);
setError(null);
}
}, [open]);
if (!open) return null;
const handleSelect = (keyword: string) => {
const kw = keyword.trim().toLowerCase();
if (!kw || allowedSet.has(kw)) return;
onSelect(kw);
};
return (
<div className="modal-overlay app-pool-overlay" onClick={onClose}>
<div className="modal-container app-pool-modal" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<div>
<h2 className="modal-title" style={{ margin: 0 }}>Kho ng dụng</h2>
<p className="app-pool-modal-sub">
Toàn hệ thống app bị chặn từ mọi lớp. Chọn đ thêm vào whitelist lớp hiện tại.
</p>
</div>
<button type="button" className="btn btn-secondary" onClick={onClose}>Đóng</button>
</div>
<div className="app-pool-toolbar">
<input
type="search"
className="app-pool-search"
placeholder="Tìm theo tên app, keyword, tiêu đề cửa sổ..."
value={search}
onChange={e => setSearch(e.target.value)}
autoFocus
/>
<button
type="button"
className="btn btn-secondary"
onClick={() => loadPool(debouncedQ)}
disabled={loading}
>
{loading ? 'Đang tải...' : 'Làm mới'}
</button>
</div>
<div className="app-pool-body">
{error ? (
<div className="discovered-apps-empty discovered-apps-error">{error}</div>
) : loading && items.length === 0 ? (
<div className="app-pool-status">Đang tải...</div>
) : items.length === 0 ? (
<div className="app-pool-status">
{debouncedQ ? `Không tìm thấy "${debouncedQ}"` : 'Chưa có app nào trong kho.'}
</div>
) : (
<table className="data-table app-pool-table">
<thead>
<tr>
<th>Keyword</th>
<th>Tiến trình</th>
<th>Tiêu đ</th>
<th>Lần chặn</th>
<th>Gần nhất</th>
<th></th>
</tr>
</thead>
<tbody>
{items.map(app => {
const added = allowedSet.has(app.keyword.toLowerCase());
return (
<tr key={app.id} className={added ? 'app-pool-row--added' : ''}>
<td><code className="app-pool-kw">{app.keyword}</code></td>
<td className="app-pool-muted">{app.processName}</td>
<td className="app-pool-muted app-pool-title" title={app.windowTitle}>{app.windowTitle || '—'}</td>
<td className="app-pool-hit">{app.hitCount}×</td>
<td className="app-pool-muted">{formatWhen(app.lastSeenAt)}</td>
<td>
{added ? (
<span className="app-pool-added-tag">Đã </span>
) : (
<button type="button" className="btn btn-primary app-pool-add-btn" onClick={() => handleSelect(app.keyword)}>
+ Thêm
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
<div className="app-pool-footer">
Hiển thị {items.length} kết quả{debouncedQ ? ` cho "${debouncedQ}"` : ''} (tối đa 80 mỗi lần tải)
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,163 @@
import React, { useCallback, useEffect, useState } from 'react';
import {
ATTENDANCE_STATUS_OPTIONS,
apiFetchAttendance,
apiFetchAttendanceShifts,
apiPushAttendanceQLDT,
apiUpdateAttendanceStatus,
type AttendanceRow,
} from '../api';
interface AttendancePanelProps {
classId: number;
}
export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) => {
const today = new Date().toISOString().slice(0, 10);
const [date, setDate] = useState(today);
const [period, setPeriod] = useState(1);
const [rows, setRows] = useState<AttendanceRow[]>([]);
const [shifts, setShifts] = useState<any[]>([]);
const [shiftInfo, setShiftInfo] = useState<any>(null);
const [loading, setLoading] = useState(false);
const [pushing, setPushing] = useState(false);
const loadShifts = useCallback(async () => {
try {
const res = await apiFetchAttendanceShifts(classId, date);
setShifts(res.data || []);
if (res.data?.length && !res.data.find((s: any) => s.period === period)) {
setPeriod(res.data[0].period || 1);
}
} catch {
setShifts([]);
}
}, [classId, date, period]);
const loadAttendance = useCallback(async () => {
try {
setLoading(true);
const res = await apiFetchAttendance(classId, date, period);
setRows(res.data || []);
setShiftInfo(res.shift || null);
} catch (e: any) {
alert(e.message || 'Không tải được điểm danh');
} finally {
setLoading(false);
}
}, [classId, date, period]);
useEffect(() => { loadShifts(); }, [loadShifts]);
useEffect(() => { loadAttendance(); }, [loadAttendance]);
const handleStatusChange = async (studentRkId: number, status: number) => {
try {
await apiUpdateAttendanceStatus(classId, { date, period, studentRkId, status });
await loadAttendance();
} catch (e: any) {
alert(e.message || 'Cập nhật trạng thái thất bại');
}
};
const handlePushQLDT = async () => {
if (!shiftInfo?.courseId) {
alert('Ca học này chưa chọn môn học. Vui lòng cấu hình trong lịch học.');
return;
}
if (!confirm(`Đẩy điểm danh Ca ${period} ngày ${date} lên QLĐT?`)) return;
try {
setPushing(true);
const res = await apiPushAttendanceQLDT(classId, date, period);
alert(res.message || 'Đã đẩy lên QLĐT');
} catch (e: any) {
alert(e.message || 'Đẩy QLĐT thất bại');
} finally {
setPushing(false);
}
};
const currentShift = shifts.find(s => s.period === period);
return (
<div className="attendance-panel">
<div className="attendance-toolbar">
<label className="attendance-field">
<span>Ngày</span>
<input type="date" className="search-input" style={{ padding: '0.5rem 0.75rem' }} value={date} onChange={e => setDate(e.target.value)} />
</label>
<label className="attendance-field">
<span>Ca học</span>
<select className="select-filter" value={period} onChange={e => setPeriod(Number(e.target.value))}>
{(shifts.length ? shifts : [{ period: 1 }, { period: 2 }, { period: 3 }, { period: 4 }]).map((s: any) => (
<option key={s.period} value={s.period}>
Ca {s.period}{s.startTime ? ` (${s.startTime}${s.endTime})` : ''}
</option>
))}
</select>
</label>
<button type="button" className="btn btn-secondary" onClick={loadAttendance} disabled={loading}>Tải lại</button>
<button type="button" className="btn btn-primary" onClick={handlePushQLDT} disabled={pushing}>
{pushing ? 'Đang đẩy...' : 'Đẩy QLĐT'}
</button>
</div>
{currentShift && (
<div className="attendance-shift-info">
<span>{currentShift.startTime}{currentShift.endTime}</span>
<span>{currentShift.courseName || 'Chưa chọn môn'}</span>
{!currentShift.isActive && <span className="badge badge-muted">Ca tắt</span>}
</div>
)}
<p className="schedule-hint" style={{ margin: '0.25rem 0 0.5rem' }}>
Sửa trạng thái thủ công sẽ đưc khóa hệ thống tự tính sẽ không ghi đè.
</p>
<div className="attendance-table-scroll table-wrapper">
{loading ? (
<div className="empty-state"><div className="sync-spinner" style={{ width: 28, height: 28 }} /></div>
) : (
<table className="data-table">
<thead>
<tr>
<th>Sinh viên</th>
<th>Online (phút)</th>
<th>Trạng thái</th>
<th>Ghi chú</th>
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr><td colSpan={4} style={{ textAlign: 'center', color: 'var(--text-muted)' }}>Chưa dữ liệu điểm danh</td></tr>
) : rows.map(row => (
<tr key={row.studentRkId}>
<td>
<div style={{ fontWeight: 600 }}>{row.fullName}</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>{row.studentCode}</div>
</td>
<td style={{ fontFamily: 'monospace' }}>{row.onlineMinutes}</td>
<td>
<select
className="select-filter"
value={row.status}
onChange={e => handleStatusChange(row.studentRkId, Number(e.target.value))}
>
{ATTENDANCE_STATUS_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</td>
<td>
{row.statusEditedByTeacher && (
<span className="badge badge-info" title="Giáo viên đã sửa — không bị ghi đè">Đã khóa</span>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,547 @@
import React, { useEffect, useState } from 'react';
import {
api,
apiSaveAllowedApps,
apiFetchClassStudents,
apiFetchOnlineStudents,
apiFetchClassSessionLogs,
apiFetchClassSchedule,
apiFetchAllowedApps,
apiFetchAttendanceShifts,
} from '../api';
import type {
ClassItem,
StudentItem,
ClassScheduleItem,
StudentSessionLogItem,
} from '../api';
import { BASE_APP_SUGGESTIONS } from '../constants';
import { pushNav, type TabId } from '../navigation';
import { ScheduleEditor } from './ScheduleEditor';
import { AttendancePanel } from './AttendancePanel';
import { StudentAvatar } from './StudentAvatar';
import { StudentDetailModal } from './StudentDetailModal';
import { AppPoolModal } from './AppPoolModal';
interface ClassWorkspaceProps {
classId: number;
sourceTab?: TabId;
onBack?: () => void;
renderNavBar?: (className: string) => React.ReactNode;
}
export const ClassWorkspace: React.FC<ClassWorkspaceProps> = ({ classId, sourceTab = 'classes', renderNavBar }) => {
const [classInfo, setClassInfo] = useState<ClassItem | null>(null);
const [students, setStudents] = useState<StudentItem[]>([]);
const [onlineIds, setOnlineIds] = useState<number[]>([]);
const [allowedApps, setAllowedApps] = useState('');
const [schedules, setSchedules] = useState<ClassScheduleItem[]>([]);
const [sessionLogs, setSessionLogs] = useState<StudentSessionLogItem[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const today = new Date().toISOString().slice(0, 10);
const [logDate, setLogDate] = useState(today);
const [logPeriod, setLogPeriod] = useState(1);
const [logShifts, setLogShifts] = useState<any[]>([]);
const [selectedStudent, setSelectedStudent] = useState<StudentItem | null>(null);
// Loading & tab states
const [loading, setLoading] = useState(true);
const [logsLoading, setLogsLoading] = useState(false);
const [savingApps, setSavingApps] = useState(false);
const [activeSubTab, setActiveSubTab] = useState<'roster' | 'logs' | 'attendance'>('roster');
const [configOpen, setConfigOpen] = useState(false);
const [configTab, setConfigTab] = useState<'apps' | 'schedule'>('schedule');
const [appPoolOpen, setAppPoolOpen] = useState(false);
const loadAllData = async () => {
try {
setLoading(true);
// Fetch specific class detail directly
const targetClassRes = await fetch(`http://127.0.0.1:8080/api/classes/${classId}`);
if (targetClassRes.ok) {
const classData = await targetClassRes.json();
setClassInfo(classData);
pushNav({
kind: 'class',
tab: sourceTab,
classId,
label: classData.name || `Lớp ${classId}`,
});
}
// Fetch students roster
const studentsRes = await apiFetchClassStudents(classId);
setStudents(studentsRes.data);
// Fetch allowed apps
try {
const appsRes = await apiFetchAllowedApps(classId);
setAllowedApps(appsRes.keywords || '');
} catch (e) {
console.log("No allowed apps configured yet");
}
// Fetch schedules
try {
const schedRes = await apiFetchClassSchedule(classId);
setSchedules(schedRes.data || []);
} catch (e) {
console.log("No schedule configured yet");
}
// Fetch online status
await fetchOnlineStatus();
// Fetch logs
await fetchLogs();
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
const fetchOnlineStatus = async () => {
try {
const res = await apiFetchOnlineStudents(classId);
setOnlineIds(res.onlineStudentIds || []);
} catch (e) {
console.error("Failed to load online students list", e);
}
};
const fetchLogs = async (date = logDate, period = logPeriod) => {
try {
setLogsLoading(true);
const res = await apiFetchClassSessionLogs(classId, date, period);
setSessionLogs(res.data || []);
if (res.period) setLogPeriod(res.period);
} catch (e) {
console.error("Failed to load session logs", e);
} finally {
setLogsLoading(false);
}
};
const loadLogShifts = async (date = logDate) => {
try {
const res = await apiFetchAttendanceShifts(classId, date);
setLogShifts(res.data || []);
} catch {
setLogShifts([]);
}
};
useEffect(() => {
loadAllData();
}, [classId]);
useEffect(() => {
const interval = setInterval(() => {
fetchOnlineStatus();
fetchLogs(logDate, logPeriod);
}, 5000);
return () => clearInterval(interval);
}, [classId, logDate, logPeriod]);
useEffect(() => {
loadLogShifts(logDate);
if (activeSubTab === 'logs') {
fetchLogs(logDate, logPeriod);
}
}, [activeSubTab, logDate, logPeriod, classId]);
useEffect(() => {
fetchLogs(logDate, logPeriod);
loadLogShifts(logDate);
}, [classId]);
const handleToggleStudying = async () => {
if (!classInfo) return;
const nextVal = !classInfo.isStudying;
try {
setClassInfo(prev => prev ? { ...prev, isStudying: nextVal } : null);
await api.updateClassStudying(classId, nextVal);
} catch (err: any) {
setClassInfo(prev => prev ? { ...prev, isStudying: !nextVal } : null);
alert(err.message || 'Không thể cập nhật trạng thái lớp học');
}
};
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 handleSaveAllowedApps = async () => {
try {
setSavingApps(true);
await apiSaveAllowedApps(classId, allowedApps);
alert('Đã lưu cấu hình ứng dụng được phép thành công!');
} catch (e: any) {
alert(e.message || 'Lỗi khi lưu cấu hình ứng dụng');
} finally {
setSavingApps(false);
}
};
const reloadSchedules = async () => {
try {
const schedRes = await apiFetchClassSchedule(classId);
setSchedules(schedRes.data || []);
} catch {
setSchedules([]);
}
};
const filteredStudents = students.filter(st => {
const term = searchQuery.toLowerCase().trim();
if (!term) return true;
return (
st.fullName.toLowerCase().includes(term) ||
st.studentCode.toLowerCase().includes(term) ||
st.email.toLowerCase().includes(term) ||
(st.phone && st.phone.includes(term))
);
});
const formatDuration = (totalSeconds: number) => {
const hrs = Math.floor(totalSeconds / 3600);
const mins = Math.floor((totalSeconds % 3600) / 60);
const secs = totalSeconds % 60;
const pad = (num: number) => String(num).padStart(2, '0');
return `${pad(hrs)}:${pad(mins)}:${pad(secs)}`;
};
if (loading) {
return (
<div className="loading-screen">
<div className="sync-spinner" style={{ width: '36px', height: '36px', borderWidth: '3px' }}></div>
<div style={{ fontWeight: 600, fontSize: '0.95rem', color: 'var(--text-secondary)' }}>Đang tải không gian làm việc của lớp...</div>
</div>
);
}
if (!classInfo) {
return (
<div className="loading-screen">
<div className="empty-state-icon">🏫</div>
<div style={{ fontWeight: 700, fontSize: '1.15rem', color: 'var(--danger)' }}>Không tìm thấy thông tin lớp học</div>
<button className="btn btn-secondary" onClick={() => window.close()}>Đóng tab</button>
</div>
);
}
const onlineCount = students.filter(st => onlineIds.includes(st.rkId)).length;
const getSessionLogForStudent = (rkId: number) =>
sessionLogs.find(log => log.studentRkId === rkId) || null;
return (
<div className="workspace-layout workspace-embedded">
{renderNavBar && classInfo && renderNavBar(classInfo.name)}
{/* Header section */}
<div className="workspace-header">
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<div className="brand-logo" style={{ width: 44, height: 44, fontSize: '1.1rem' }}>🏫</div>
<div>
<h1 className="workspace-class-title">{classInfo.name}</h1>
<div className="class-badge-container">
<span className="badge badge-muted" style={{ fontFamily: 'monospace' }}>CODE: {classInfo.classCode}</span>
<span className="badge badge-info">ID: {classInfo.rkId}</span>
<span className="badge badge-muted">{classInfo.systemName || 'Hệ thống'}</span>
</div>
</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '1.25rem', flexWrap: 'wrap' }}>
<div className="status-panel">
<div style={{ display: 'flex', flexDirection: 'column' }}>
<span style={{ fontSize: '0.72rem', color: 'var(--text-muted)', fontWeight: 600, textTransform: 'uppercase' }}>Trạng thái dạy</span>
<span style={{ fontSize: '0.875rem', fontWeight: 700, color: classInfo.isStudying ? 'var(--success)' : 'var(--text-muted)' }}>
{classInfo.isStudying ? 'Đang học' : 'Tạm dừng'}
</span>
</div>
<label className="switch">
<input type="checkbox" checked={classInfo.isStudying} onChange={handleToggleStudying} />
<span className="slider"></span>
</label>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end' }}>
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)', fontWeight: 600, textTransform: 'uppercase' }}>Online / số</div>
<div className="workspace-stat-value">
<span style={{ color: 'var(--success)' }}>{onlineCount}</span>
<span style={{ color: 'var(--text-muted)', fontSize: '0.9rem', fontWeight: 500 }}> / {students.length} SV</span>
</div>
</div>
</div>
</div>
{/* Main area */}
<div className="workspace-main">
{configOpen && (
<div className="workspace-drawer-backdrop" onClick={() => setConfigOpen(false)} aria-hidden />
)}
<aside className={`workspace-config-drawer ${configOpen ? 'open' : ''}`}>
<div className="workspace-drawer-header">
<strong>Cấu hình lớp</strong>
<button type="button" className="btn btn-secondary workspace-drawer-close" onClick={() => setConfigOpen(false)} aria-label="Đóng">×</button>
</div>
<div className="workspace-drawer-tabs">
<button type="button" className={`workspace-drawer-tab ${configTab === 'apps' ? 'active' : ''}`} onClick={() => setConfigTab('apps')}>
ng dụng
</button>
<button type="button" className={`workspace-drawer-tab ${configTab === 'schedule' ? 'active' : ''}`} onClick={() => setConfigTab('schedule')}>
Lịch học
</button>
</div>
<div className="workspace-drawer-body">
{configTab === 'apps' ? (
<div className="config-card config-card-flat">
<div className="card-header-title">ng dụng đưc phép</div>
<p className="config-card-desc">
Từ khóa tiến trình hoặc tiêu đ đưc phép (ngăn cách bằng dấu phẩy).
</p>
<textarea
className="app-textarea"
placeholder="chrome, vscode, cursor, goland, client, zoom"
value={allowedApps}
onChange={e => setAllowedApps(e.target.value)}
/>
<div className="config-suggestions">
<span className="config-suggestions-label">Gợi ý nhanh:</span>
<div className="config-suggestions-list">
{BASE_APP_SUGGESTIONS.map(kw => (
<span key={kw} className="app-suggestion-badge" onClick={() => addAppKeyword(kw)}>
+ {kw}
</span>
))}
</div>
</div>
<div className="app-pool-open-row">
<div>
<span className="config-suggestions-label">Kho ng dụng (toàn hệ thống)</span>
<p className="discovered-apps-hint" style={{ margin: '0.25rem 0 0' }}>
App bị chặn từ mọi lớp mở kho đ tìm thêm nhanh.
</p>
</div>
<button
type="button"
className="btn btn-secondary app-pool-open-btn"
onClick={() => setAppPoolOpen(true)}
>
Mở kho & tìm kiếm
</button>
</div>
<button
className="btn btn-primary"
style={{ width: '100%', justifyContent: 'center' }}
onClick={handleSaveAllowedApps}
disabled={savingApps}
>
{savingApps ? 'Đang lưu...' : 'Lưu cấu hình apps'}
</button>
</div>
) : (
<div className="config-card config-card-flat">
<div className="card-header-title">Lịch học trong tuần</div>
<p className="config-card-desc">Tối đa 4 ca/ngày. Chọn môn từ QLĐT cho từng ca.</p>
<ScheduleEditor classId={classId} schedules={schedules} onSaved={reloadSchedules} />
</div>
)}
</div>
</aside>
<div className="right-panel workspace-content-panel">
<div className="workspace-panel-toolbar">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '1rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.65rem', flexWrap: 'wrap' }}>
<button
type="button"
className={`btn btn-secondary workspace-config-toggle ${configOpen ? 'active' : ''}`}
onClick={() => setConfigOpen(v => !v)}
>
{configOpen ? 'Ẩn cấu hình' : 'Cấu hình lớp'}
</button>
<div className="tab-btn-group">
<button
className={`tab-sub-btn ${activeSubTab === 'roster' ? 'active' : ''}`}
onClick={() => setActiveSubTab('roster')}
>
đ học viên
</button>
<button
className={`tab-sub-btn ${activeSubTab === 'logs' ? 'active' : ''}`}
onClick={() => setActiveSubTab('logs')}
>
Nhật theo ca
</button>
<button
className={`tab-sub-btn ${activeSubTab === 'attendance' ? 'active' : ''}`}
onClick={() => setActiveSubTab('attendance')}
>
Điểm danh theo ca
</button>
</div>
</div>
{/* Student Search */}
{activeSubTab !== 'attendance' && <div className="search-input-wrapper">
<input
type="text"
className="search-input"
placeholder="Tìm học viên..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
/>
<span className="search-icon">🔍</span>
</div>}
</div>
<div className="divider" style={{ opacity: 0.3, margin: '0.25rem 0' }}></div>
</div>
<div className={`workspace-panel-body ${activeSubTab === 'attendance' || activeSubTab === 'logs' ? 'workspace-panel-fill' : ''}`}>
{activeSubTab === 'attendance' ? (
<AttendancePanel classId={classId} />
) : activeSubTab === 'roster' ? (
/* Student Roster Grid */
filteredStudents.length === 0 ? (
<div className="empty-state" style={{ minHeight: '300px' }}>
<div className="empty-state-icon">👤</div>
<h2>Không tìm thấy học viên nào</h2>
<p>Hãy thử tìm kiếm với từ khóa khác hoặc kiểm tra lại danh sách lớp.</p>
</div>
) : (
<div className="student-status-grid">
{filteredStudents.map(st => {
const isOnline = onlineIds.includes(st.rkId);
return (
<div
key={st.rkId}
className={`student-status-card student-status-card-clickable ${isOnline ? 'online' : 'offline'}`}
onClick={() => setSelectedStudent(st)}
role="button"
tabIndex={0}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') setSelectedStudent(st); }}
>
<StudentAvatar fullName={st.fullName} avatar={st.avatar} isOnline={isOnline} size={52} />
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, overflow: 'hidden' }}>
<span style={{ fontSize: '0.875rem', fontWeight: 600, color: 'var(--text-primary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{st.fullName}
</span>
<span style={{ fontSize: '0.78rem', color: 'var(--accent)', fontFamily: 'monospace', fontWeight: 600 }}>
{st.studentCode}
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.35rem', marginTop: '0.4rem' }}>
<span className={isOnline ? 'pulse-dot-online' : 'status-badge-offline'}></span>
<span style={{ fontSize: '0.75rem', fontWeight: 700, color: isOnline ? 'var(--success)' : 'var(--text-muted)' }}>
{isOnline ? 'ONLINE' : 'OFFLINE'}
</span>
</div>
</div>
</div>
);
})}
</div>
)
) : (
<div className="session-logs-panel">
<div className="attendance-toolbar" style={{ marginBottom: '0.5rem' }}>
<label className="attendance-field">
<span>Ngày</span>
<input type="date" className="search-input" style={{ padding: '0.5rem 0.75rem' }} value={logDate} onChange={e => setLogDate(e.target.value)} />
</label>
<label className="attendance-field">
<span>Ca học</span>
<select className="select-filter" value={logPeriod} onChange={e => setLogPeriod(Number(e.target.value))}>
{(logShifts.length ? logShifts : [{ period: 1 }, { period: 2 }, { period: 3 }, { period: 4 }]).map((s: any) => (
<option key={s.period} value={s.period}>
Ca {s.period}{s.startTime ? ` (${s.startTime}${s.endTime})` : ''}
</option>
))}
</select>
</label>
</div>
<div className="attendance-table-scroll table-wrapper" style={{ border: 'none' }}>
{logsLoading ? (
<div className="empty-state" style={{ minHeight: '300px' }}>
<div className="sync-spinner" style={{ width: '32px', height: '32px' }}></div>
<p style={{ marginTop: '0.5rem' }}>Đang tải nhật điểm danh học tập...</p>
</div>
) : sessionLogs.length === 0 ? (
<div className="empty-state" style={{ minHeight: '300px' }}>
<div className="empty-state-icon">📊</div>
<h2>Chưa nhật Ca {logPeriod}</h2>
<p>Hệ thống ghi nhận theo từng ca khi học viên mở app trong khung giờ học.</p>
</div>
) : (
<table className="data-table">
<thead>
<tr>
<th>Học viên</th>
<th>Thời gian Online</th>
<th>Thời gian Offline</th>
<th>WiFi SSID</th>
<th>Hoạt đng cuối</th>
</tr>
</thead>
<tbody>
{sessionLogs.map(logItem => (
<tr key={logItem.studentRkId}>
<td>
<div style={{ fontWeight: 600, color: 'var(--text-primary)' }}>{logItem.fullName}</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>{logItem.studentCode} | {logItem.email}</div>
</td>
<td style={{ color: 'var(--success)', fontWeight: 700, fontFamily: 'monospace' }}>
{formatDuration(logItem.onlineSeconds)}
</td>
<td style={{ color: 'var(--danger)', fontWeight: 700, fontFamily: 'monospace' }}>
{formatDuration(logItem.offlineSeconds)}
</td>
<td style={{ fontSize: '0.85rem', fontFamily: 'monospace' }}>📶 {logItem.wifiSsids}</td>
<td style={{ fontSize: '0.8rem', color: 'var(--text-secondary)' }}>
{logItem.lastActiveAt ? new Date(logItem.lastActiveAt).toLocaleTimeString() : '—'}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
)}
</div>
</div>
</div>
{selectedStudent && (
<StudentDetailModal
student={selectedStudent}
isOnline={onlineIds.includes(selectedStudent.rkId)}
sessionLog={getSessionLogForStudent(selectedStudent.rkId)}
onClose={() => setSelectedStudent(null)}
/>
)}
<AppPoolModal
open={appPoolOpen}
onClose={() => setAppPoolOpen(false)}
onSelect={addAppKeyword}
allowedApps={allowedApps}
/>
</div>
);
};

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react';
import { api } from '../api';
import type { ClassItem, StudentItem, SyncStatus } from '../api';
import type { ClassItem, SyncStatus } from '../api';
import { openClass } from './NavHistoryBar';
export const ClassesTab: React.FC = () => {
const [classes, setClasses] = useState<ClassItem[]>([]);
@@ -9,17 +10,11 @@ export const ClassesTab: React.FC = () => {
const [pageSize] = useState(15);
const [search, setSearch] = useState('');
const [systemFilter, setSystemFilter] = useState<number | undefined>(undefined);
const [studyingOnly, setStudyingOnly] = useState(false);
const [studyingOnly, setStudyingOnly] = useState(true);
const [loading, setLoading] = useState(true);
// Sync state
const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null);
// Modal detail class
const [activeClass, setActiveClass] = useState<ClassItem | null>(null);
const [modalStudents, setModalStudents] = useState<StudentItem[]>([]);
const [modalLoading, setModalLoading] = useState(false);
const fetchClasses = async () => {
try {
setLoading(true);
@@ -98,24 +93,10 @@ export const ClassesTab: React.FC = () => {
// Optimistic update
setClasses(prev => prev.map(c => c.rkId === cItem.rkId ? { ...c, isStudying: nextVal } : c));
await api.updateClassStudying(cItem.rkId, nextVal);
} catch (err) {
} catch (err: any) {
// Revert if error
setClasses(prev => prev.map(c => c.rkId === cItem.rkId ? { ...c, isStudying: !nextVal } : c));
alert('Không thể cập nhật trạng thái lớp học');
}
};
const handleOpenStudentsModal = async (cItem: ClassItem) => {
setActiveClass(cItem);
setModalStudents([]);
try {
setModalLoading(true);
const res = await api.getClassStudents(cItem.rkId);
setModalStudents(res.data);
} catch (err) {
alert('Không thể tải danh sách học sinh của lớp');
} finally {
setModalLoading(false);
alert(err.message || 'Không thể cập nhật trạng thái lớp học');
}
};
@@ -124,8 +105,11 @@ export const ClassesTab: React.FC = () => {
? Math.round((syncStatus.synced / syncStatus.total) * 100)
: 0;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
<div className="tab-page">
<div className="tab-page-toolbar">
<div className="page-header">
<div className="page-title">
<h1>Quản Lớp Học</h1>
@@ -221,9 +205,11 @@ export const ClassesTab: React.FC = () => {
</label>
</div>
</div>
</div>
{/* Classes list table */}
<div className="table-wrapper">
<div className="tab-page-body">
<div className="table-wrapper table-fill">
{loading ? (
<div className="empty-state">
<div className="sync-spinner" style={{ width: '32px', height: '32px' }}></div>
@@ -250,20 +236,26 @@ export const ClassesTab: React.FC = () => {
</thead>
<tbody>
{classes.map(cl => (
<tr key={cl.rkId}>
<tr key={cl.rkId} style={cl.isStudying ? { background: 'rgba(16, 185, 129, 0.02)' } : {}}>
<td>
<div style={{ fontWeight: 600 }}>{cl.classCode}</div>
<div style={{ fontWeight: 700, color: 'var(--text-primary)' }}>{cl.classCode}</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>ID: {cl.rkId}</div>
</td>
<td>
<div style={{ fontWeight: 500, color: 'white' }}>{cl.name}</div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)' }}>
{cl.specializeName || '—'}
<div
style={{ fontWeight: 600, color: 'var(--accent)', fontSize: '0.95rem', cursor: 'pointer', textDecoration: 'underline' }}
onClick={() => openClass('classes', cl.rkId, cl.name)}
title="Mở Không gian làm việc của lớp"
>
{cl.name}
</div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', marginTop: '0.15rem' }}>
💼 {cl.specializeName || 'Chưa có chuyên ngành'}
</div>
</td>
<td>
<span className="badge badge-muted">
{cl.systemName || `Hệ thống ${cl.systemRkId || 'Khác'}`}
<span className="badge badge-muted" style={{ fontWeight: 600 }}>
📍 {cl.systemName || `Hệ thống ${cl.systemRkId || 'Khác'}`}
</span>
</td>
<td>
@@ -280,29 +272,39 @@ export const ClassesTab: React.FC = () => {
</div>
</td>
<td>
<span className="badge badge-info">
👤 {cl.studentCount} sinh viên
<span className="badge badge-info" style={{ padding: '0.3rem 0.65rem', borderRadius: '6px', fontWeight: 600 }}>
👤 {cl.studentCount} SV
</span>
</td>
<td>
<div className="switch-container">
<label className="switch">
<input
type="checkbox"
checked={cl.isStudying}
onChange={() => handleToggleStudying(cl)}
/>
<span className="slider"></span>
</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
<div className="switch-container">
<label className="switch">
<input
type="checkbox"
checked={cl.isStudying}
onChange={() => handleToggleStudying(cl)}
/>
<span className="slider"></span>
</label>
</div>
{cl.isStudying ? (
<span style={{ color: 'var(--success)', fontSize: '0.72rem', display: 'inline-flex', alignItems: 'center', gap: '0.25rem', fontWeight: 700, textTransform: 'uppercase' }}>
<span className="pulse-dot-active" style={{ display: 'inline-block', width: '6px', height: '6px', background: '#10b981', borderRadius: '50%', boxShadow: '0 0 6px #10b981' }}></span>
Đang học
</span>
) : (
<span style={{ color: 'var(--text-muted)', fontSize: '0.72rem', textTransform: 'uppercase', fontWeight: 600 }}>Tạm dừng</span>
)}
</div>
</td>
<td style={{ textAlign: 'right' }}>
<button
className="btn btn-secondary"
style={{ padding: '0.45rem 0.85rem', fontSize: '0.8rem' }}
onClick={() => handleOpenStudentsModal(cl)}
style={{ padding: '0.5rem 0.95rem', fontSize: '0.8rem', fontWeight: 600, display: 'inline-flex', alignItems: 'center', gap: '0.35rem' }}
onClick={() => openClass('classes', cl.rkId, cl.name)}
>
Chi tiết 👥
👥 Xem lớp
</button>
</td>
</tr>
@@ -311,9 +313,11 @@ export const ClassesTab: React.FC = () => {
</table>
)}
</div>
</div>
{/* Pagination control bar */}
{!loading && classes.length > 0 && (
<div className="tab-page-footer">
<div className="pagination-row">
<div>
Hiển thị lớp thứ <b>{((page - 1) * pageSize) + 1}</b> đến <b>{Math.min(page * pageSize, total)}</b> trong tổng số <b>{total}</b> lớp học
@@ -326,7 +330,7 @@ export const ClassesTab: React.FC = () => {
>
</button>
<span style={{ display: 'flex', alignItems: 'center', padding: '0 1rem', fontWeight: 600, color: 'white' }}>
<span style={{ display: 'flex', alignItems: 'center', padding: '0 1rem', fontWeight: 600, color: 'var(--text-primary)' }}>
Trang {page} / {Math.ceil(total / pageSize) || 1}
</span>
<button
@@ -338,91 +342,6 @@ export const ClassesTab: React.FC = () => {
</button>
</div>
</div>
)}
{/* Students roster modal */}
{activeClass && (
<div className="modal-overlay" onClick={() => setActiveClass(null)}>
<div className="modal-container" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<div className="modal-title">
<h3>Danh sách sinh viên lớp: {activeClass.name}</h3>
<p style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', marginTop: '0.2rem' }}>
lớp: {activeClass.classCode} | số: {activeClass.studentCount}
</p>
</div>
<button className="modal-close-btn" onClick={() => setActiveClass(null)}>
&times;
</button>
</div>
<div className="modal-body">
<div className="detail-grid">
<div>
<div className="detail-item-label">Chuyên Ngành</div>
<div className="detail-item-value">{activeClass.specializeName || 'Chưa phân phối'}</div>
</div>
<div>
<div className="detail-item-label">Chi Nhánh / Phân Hệ</div>
<div className="detail-item-value">{activeClass.systemName || 'Chung'}</div>
</div>
<div>
<div className="detail-item-label">Loại Lớp</div>
<div className="detail-item-value" style={{ textTransform: 'capitalize' }}>{activeClass.type || 'Chính thức'}</div>
</div>
</div>
{modalLoading ? (
<div className="empty-state" style={{ minHeight: '200px' }}>
<div className="sync-spinner"></div>
<p style={{ marginTop: '0.5rem' }}>Đang tải danh sách học sinh...</p>
</div>
) : modalStudents.length === 0 ? (
<div className="empty-state" style={{ minHeight: '200px' }}>
<div className="empty-state-icon">👥</div>
<h3>Lớp trống hoặc chưa đng bộ danh sách</h3>
<p style={{ fontSize: '0.85rem', maxWidth: '380px', margin: '0 auto' }}>
Hãy kiểm tra xem lớp gán môn học nào hoạt đng không. Danh sách sinh viên đưc kéo từ kết quả học tập của lớp.
</p>
</div>
) : (
<table className="data-table" style={{ fontSize: '0.85rem' }}>
<thead>
<tr>
<th> SV</th>
<th>Họ Tên</th>
<th>Email</th>
<th>Số Điện Thoại</th>
<th>Vị Trí</th>
<th>Trạng Thái</th>
</tr>
</thead>
<tbody>
{modalStudents.map(st => (
<tr key={st.rkId}>
<td style={{ fontWeight: 600 }}>{st.studentCode}</td>
<td style={{ color: 'white', fontWeight: 500 }}>{st.fullName}</td>
<td>{st.email}</td>
<td>{st.phone || '—'}</td>
<td>{st.location || '—'}</td>
<td>
<span className={`badge ${st.status === 'Đang học' || st.status === 'active' ? 'badge-success' : 'badge-muted'}`}>
{st.status || 'Đang học'}
</span>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={() => setActiveClass(null)}>
Đóng
</button>
</div>
</div>
</div>
)}
</div>

View File

@@ -27,14 +27,17 @@ export const DashboardTab: React.FC<DashboardTabProps> = ({ onNavigate }) => {
}, []);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
<div className="page-header">
<div className="page-title">
<h1>Tổng Quan Hệ Thống</h1>
<p>Giám sát đng bộ quản lớp học, sinh viên từ h thống chính</p>
<div className="tab-page">
<div className="tab-page-toolbar">
<div className="page-header">
<div className="page-title">
<h1>Tổng Quan H Thống</h1>
<p>Giám sát đng bộ quản lớp học, sinh viên từ hệ thống chính</p>
</div>
</div>
</div>
<div className="tab-page-body tab-page-scroll">
{loading && (
<div className="empty-state">
<div className="sync-spinner" style={{ width: '40px', height: '40px', borderWidth: '3px' }}></div>
@@ -65,7 +68,7 @@ export const DashboardTab: React.FC<DashboardTabProps> = ({ onNavigate }) => {
<div className="stat-label">Lớp đang giảng dạy</div>
<div className="stat-value" style={{ color: 'var(--success)' }}>{stats.activeClasses}</div>
</div>
<div className="stat-icon" style={{ color: 'var(--success)' }}>🟢</div>
<div className="stat-icon success"></div>
</div>
<div className="stat-card" onClick={() => onNavigate('students')} style={{ cursor: 'pointer' }}>
@@ -77,61 +80,44 @@ export const DashboardTab: React.FC<DashboardTabProps> = ({ onNavigate }) => {
</div>
</div>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(360px, 1fr))',
gap: '1.5rem',
marginTop: '1rem'
}}>
<div style={{
backgroundColor: 'var(--bg-card)',
border: '1px solid var(--border-color)',
padding: '2rem',
borderRadius: '18px',
backdropFilter: 'blur(10px)'
}}>
<h2 style={{ marginBottom: '1rem', color: 'white', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
🚀 Khởi đng nhanh
</h2>
<p style={{ color: 'var(--text-secondary)', fontSize: '0.95rem', lineHeight: '1.6', marginBottom: '1.5rem' }}>
Chào mừng bạn đến với trang quản trị <b>Simple Care</b>.
Hệ thống hỗ trợ đng bộ hóa thông tin tự đng từ cổng đào tạo chính (QLĐT), giúp lưu trữ, cập nhật trạng thái lớp học danh sách sinh viên nội bộ không lo nh hưởng đến dữ liệu hiện .
<div className="content-grid-2">
<div className="content-card">
<h2>Khởi đng nhanh</h2>
<p>
Chào mừng bạn đến với trang quản trị <strong>Simple Care</strong>.
Hệ thống hỗ trợ đng bộ hóa thông tin tự đng từ cổng đào tạo chính (QLĐT), giúp lưu trữ, cập nhật trạng thái lớp học danh sách sinh viên nội bộ.
</p>
<div style={{ display: 'flex', gap: '1rem' }}>
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<button className="btn btn-primary" onClick={() => onNavigate('classes')}>
Quản Lớp học
Quản lớp học
</button>
<button className="btn btn-secondary" onClick={() => onNavigate('students')}>
Danh sách Sinh viên
Danh sách sinh viên
</button>
</div>
</div>
<div style={{
backgroundColor: 'var(--bg-card)',
border: '1px solid var(--border-color)',
padding: '2rem',
borderRadius: '18px',
backdropFilter: 'blur(10px)'
}}>
<h2 style={{ marginBottom: '1rem', color: 'white' }}>📋 Quy trình hoạt đng</h2>
<div className="content-card">
<h2>Quy trình hoạt đng</h2>
<ul style={{
color: 'var(--text-secondary)',
fontSize: '0.95rem',
lineHeight: '1.8',
paddingLeft: '1.25rem',
fontSize: '0.875rem',
lineHeight: 1.75,
paddingLeft: '1.15rem',
display: 'flex',
flexDirection: 'column',
gap: '0.5rem'
gap: '0.5rem',
margin: 0
}}>
<li><b>Đng bộ Lớp học:</b> Tải toàn bộ danh sách lớp học các môn học tương ng. Đng thời kéo thông tin sinh viên thuộc từng lớp thông qua bảng kết quả lớp học.</li>
<li><b>Quản Lớp:</b> Lọc danh sách lớp theo Phân hệ (System ID), Tìm kiếm tên hoặc lớp Bật/Tắt cờ <b>Đang học</b>.</li>
<li><b>Đng bộ Sinh viên:</b> Thực hiện kéo toàn bộ sinh viên trên hệ thống chính (kèm thông tin liên hệ, hệ học) lưu trữ vào Database phục vụ cho học tập thi cử.</li>
<li><strong>Đng bộ lớp học:</strong> Tải danh sách lớp môn học, kéo thông tin sinh viên từng lớp qua bảng kết quả.</li>
<li><strong>Quản lớp:</strong> Lọc theo phân hệ, tìm kiếm tên/ lớp bật/tắt cờ <strong>Đang học</strong>.</li>
<li><strong>Đng bộ sinh viên:</strong> Kéo toàn bộ sinh viên t hệ thống chính (kèm thông tin liên hệ, hệ học) vào database nội bộ.</li>
</ul>
</div>
</div>
</>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,424 @@
import React, { useEffect, useState } from 'react';
import {
apiFetchActiveSchedules,
apiFetchClassSchedule,
apiApplyScheduleTemplate,
apiDeleteClassSchedule,
apiFetchAllowedApps,
apiSaveAllowedApps,
apiFetchClassSessionLogs,
apiFetchClasses
} from '../api';
import { DEFAULT_ALLOWED_APPS } from '../constants';
import type {
ClassItem,
ClassScheduleItem,
StudentSessionLogItem
} from '../api';
import { LiveProctorModal } from './LiveProctorModal';
import { openClass } from './NavHistoryBar';
import { ScheduleEditor } from './ScheduleEditor';
export const LearningTab: React.FC = () => {
const [activeClasses, setActiveClasses] = useState<any[]>([]);
const [loading, setLoading] = useState<boolean>(false);
// Lớp đang cấu hình cấu trúc lịch/app (modal)
const [configClass, setConfigClass] = useState<any | 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 [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 () => {
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 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) {
console.error('Error loading classes:', err);
}
};
const handleActivateClass = async (cls: ClassItem) => {
setShowAddClassModal(false);
try {
await apiApplyScheduleTemplate(cls.rkId);
await apiSaveAllowedApps(cls.rkId, DEFAULT_ALLOWED_APPS);
await loadActiveClasses();
handleOpenConfig(cls);
} catch (err) {
alert('Không thể kích hoạt lớp: ' + err);
}
};
const handleOpenConfig = async (cls: any) => {
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: any) => {
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 () => {
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: any) => {
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 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 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())) &&
!activeClasses.some(ac => ac.rkId === c.rkId)
);
return (
<div className="tab-page">
<div className="tab-page-toolbar">
<div className="page-header">
<div className="page-title">
<h1>Quản lịch học & Giám sát</h1>
<p>Chọn 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>
</div>
<button className="btn btn-primary" onClick={handleOpenAddClass}>
Chọn lớp & Tạo lịch học
</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>
) : (
!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>
)
)}
</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>
</div>
<button className="btn btn-secondary close-btn" onClick={() => setConfigClass(null)}> Đóng</button>
</div>
<div style={{ padding: '1rem 0', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1.5rem' }}>
{/* 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>
<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>
<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.
</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' }}
/>
<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
</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 & Giám Sát Sinh Viên</h2>
<p style={{ margin: '4px 0 0 0', color: 'var(--text-muted)', fontSize: '0.85rem' }}>Lớp: <strong>{monitorClass.name}</strong> ({monitorClass.classCode})</p>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button className="btn btn-secondary" onClick={() => handleOpenMonitor(monitorClass)}>🔄 Tải lại</button>
<button className="btn btn-secondary close-btn" onClick={() => setMonitorClass(null)}> Đóng</button>
</div>
</div>
<div style={{ padding: '1rem 0', maxHeight: '450px', overflowY: 'auto' }}>
<div className="table-responsive">
<table className="table">
<thead>
<tr>
<th>Học Viên</th>
<th> SV</th>
<th>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 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>
</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 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>
</td>
<td>{log.lastActiveAt ? new Date(log.lastActiveAt).toLocaleTimeString() : 'Chưa hoạt động'}</td>
<td style={{ textAlign: 'right' }}>
<button
className="btn btn-primary btn-sm"
onClick={() => setProctorStudent({ id: log.studentRkId, name: log.fullName, code: log.studentCode })}
>
🖥 Xem Camera & Screen
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
</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>
</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..."
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' }}>
{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>
) : (
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{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>
</div>
<button className="btn btn-primary btn-sm" onClick={() => handleActivateClass(c)}>
Chọn
</button>
</li>
))}
</ul>
)}
</div>
</div>
</div>
</div>
)}
{/* MODAL XEM STREAM CAMERA / SCREEN TRỰC TIẾP */}
{proctorStudent && (
<LiveProctorModal
studentId={proctorStudent.id}
studentName={proctorStudent.name}
studentCode={proctorStudent.code}
onClose={() => setProctorStudent(null)}
/>
)}
</div>
);
};

View File

@@ -0,0 +1,35 @@
import React from 'react';
import { ProctorStreamPanels } from './ProctorStreamPanels';
interface LiveProctorModalProps {
studentId: number;
studentName: string;
studentCode: string;
onClose: () => void;
}
export const LiveProctorModal: React.FC<LiveProctorModalProps> = ({
studentId,
studentName,
studentCode,
onClose,
}) => {
return (
<div className="modal-overlay" style={{ zIndex: 1100 }}>
<div className="modal-container proctor-container">
<div className="modal-header">
<div>
<h2 className="modal-title">Giám sát trực tiếp</h2>
<p style={{ margin: '4px 0 0 0', color: 'var(--text-muted)', fontSize: '0.85rem' }}>
Sinh viên: <strong style={{ color: 'var(--text-primary)' }}>{studentName}</strong> ({studentCode})
</p>
</div>
<button className="btn btn-secondary" onClick={onClose}>Đóng</button>
</div>
<div className="proctor-content">
<ProctorStreamPanels studentId={studentId} />
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,111 @@
import React, { useEffect, useState } from 'react';
import {
TAB_LABELS,
type NavEntry,
type TabId,
parseRoute,
navigate,
readHistory,
} from '../navigation';
interface NavHistoryBarProps {
classLabel?: string;
onBack: () => void;
}
export const NavHistoryBar: React.FC<NavHistoryBarProps> = ({ classLabel, onBack }) => {
const [history, setHistory] = useState<NavEntry[]>([]);
const route = parseRoute();
useEffect(() => {
const refresh = () => setHistory(readHistory());
refresh();
window.addEventListener('popstate', refresh);
return () => window.removeEventListener('popstate', refresh);
}, []);
const currentTabLabel = TAB_LABELS[route.tab];
const recent = history.slice(0, 6);
const handlePillClick = (entry: NavEntry) => {
if (entry.kind === 'class' && entry.classId) {
navigate(entry.tab, entry.classId, entry.label);
} else {
navigate(entry.tab);
}
};
const isCurrent = (entry: NavEntry) => {
if (entry.kind === 'class' && route.classId) {
return entry.classId === route.classId;
}
return entry.kind === 'tab' && !route.classId && entry.tab === route.tab;
};
return (
<div className="nav-history-bar">
<div className="breadcrumb-row">
<button type="button" className="breadcrumb-back" onClick={onBack} title="Quay lại">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" width="16" height="16">
<polyline points="15 18 9 12 15 6" />
</svg>
Quay lại
</button>
<span className="breadcrumb-sep">/</span>
<button type="button" className="breadcrumb-link" onClick={() => navigate('dashboard')}>
Simple Care
</button>
<span className="breadcrumb-sep">/</span>
<button type="button" className="breadcrumb-link" onClick={() => navigate(route.tab)}>
{currentTabLabel}
</button>
{route.classId && classLabel && (
<>
<span className="breadcrumb-sep">/</span>
<span className="breadcrumb-current">{classLabel}</span>
</>
)}
</div>
{recent.length > 0 && (
<div className="history-pills-row">
<span className="history-pills-label">Gần đây</span>
<div className="history-pills">
{recent.map((entry, idx) => (
<button
key={entry.id}
type="button"
className={`history-pill ${isCurrent(entry) ? 'active' : ''}`}
onClick={() => handlePillClick(entry)}
title={entry.label}
>
<span className="history-pill-index">P{idx + 1}</span>
<span className="history-pill-label">{entry.label}</span>
</button>
))}
</div>
</div>
)}
</div>
);
};
export function useRoute() {
const [route, setRoute] = useState(parseRoute);
useEffect(() => {
const onPop = () => setRoute(parseRoute());
window.addEventListener('popstate', onPop);
return () => window.removeEventListener('popstate', onPop);
}, []);
return route;
}
export function openClass(tab: TabId, classId: number, className: string) {
navigate(tab, classId, className);
}
export function openTab(tab: TabId) {
navigate(tab);
}

View File

@@ -0,0 +1,223 @@
import React, { useEffect, useState } from 'react';
import {
apiFetchAcceptedWifis,
apiSaveAcceptedWifis,
type WifiAcceptItem,
} from '../api';
import { WifiPoolModal } from './WifiPoolModal';
function bssidKey(bssid: string): string {
return bssid.toLowerCase().replace(/[^0-9a-f]/g, '');
}
function formatBssid(raw: string): string | null {
const key = bssidKey(raw);
if (key.length !== 12) return null;
return key.match(/.{2}/g)!.join(':');
}
export const NetworkTab: React.FC = () => {
const [acceptedItems, setAcceptedItems] = useState<WifiAcceptItem[]>([]);
const [saving, setSaving] = useState(false);
const [loading, setLoading] = useState(true);
const [poolOpen, setPoolOpen] = useState(false);
const [manualSsid, setManualSsid] = useState('');
const [manualBssid, setManualBssid] = useState('');
const [manualError, setManualError] = useState<string | null>(null);
const loadAccepted = async () => {
try {
setLoading(true);
const res = await apiFetchAcceptedWifis();
setAcceptedItems(
(res.data || [])
.filter(r => r.ssid && r.bssid)
.map(r => ({ ssid: r.ssid, bssid: r.bssid }))
);
} catch {
setAcceptedItems([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadAccepted();
}, []);
const addWifi = (ssid: string, bssid: string) => {
const trimmedSsid = ssid.trim();
const trimmedBssid = bssid.trim();
const key = bssidKey(trimmedBssid);
if (!trimmedSsid || key.length !== 12) return;
setAcceptedItems(prev => {
if (prev.some(item => bssidKey(item.bssid) === key)) return prev;
return [...prev, { ssid: trimmedSsid, bssid: trimmedBssid }];
});
};
const removeWifi = (bssid: string) => {
const key = bssidKey(bssid);
setAcceptedItems(prev => prev.filter(item => bssidKey(item.bssid) !== key));
};
const handleManualAdd = () => {
const ssid = manualSsid.trim();
const bssid = formatBssid(manualBssid);
if (!ssid) {
setManualError('Nhập tên WiFi (SSID).');
return;
}
if (!bssid) {
setManualError('BSSID không hợp lệ — nhập 12 ký tự hex (vd: f0:61:c0:b0:fd:d2).');
return;
}
const key = bssidKey(bssid);
if (acceptedItems.some(item => bssidKey(item.bssid) === key)) {
setManualError('BSSID này đã có trong danh sách.');
return;
}
setAcceptedItems(prev => [...prev, { ssid, bssid }]);
setManualSsid('');
setManualBssid('');
setManualError(null);
};
const handleSave = async () => {
try {
setSaving(true);
await apiSaveAcceptedWifis(acceptedItems);
alert('Đã lưu danh sách WiFi được phép!');
await loadAccepted();
} catch (e: any) {
alert(e?.message || 'Không thể lưu cấu hình WiFi');
} finally {
setSaving(false);
}
};
const acceptedBssidKeys = acceptedItems.map(item => bssidKey(item.bssid)).join(',');
return (
<div className="page-container">
<div className="page-header">
<div>
<h1 className="page-title">Quản mạng</h1>
<p className="page-subtitle">
Cấu hình điểm phát WiFi đưc phép (SSID + BSSID/MAC) áp dụng <strong>toàn hệ thống</strong>.
Sinh viên không thể giả mạo bằng hotspot trùng tên.
</p>
</div>
</div>
<div className="network-grid">
<div className="card config-card-flat">
<div className="card-header-title">WiFi đưc chấp nhận</div>
<p className="config-card-desc">
Chọn từ kho WiFi hoặc thêm thủ công SSID + BSSID. Đ trống = chưa bật kiểm tra WiFi.
</p>
<form
className="network-manual-form"
onSubmit={e => {
e.preventDefault();
handleManualAdd();
}}
>
<p className="network-manual-hint">Thêm thủ công khi cần (vd: lấy BSSID từ router hoặc lệnh netsh)</p>
<input
type="text"
className="app-pool-search"
placeholder="SSID (tên WiFi)"
value={manualSsid}
onChange={e => {
setManualSsid(e.target.value);
setManualError(null);
}}
disabled={loading}
/>
<input
type="text"
className="app-pool-search"
placeholder="BSSID / MAC (f0:61:c0:b0:fd:d2)"
value={manualBssid}
onChange={e => {
setManualBssid(e.target.value);
setManualError(null);
}}
disabled={loading}
spellCheck={false}
/>
<button type="submit" className="btn btn-secondary" disabled={loading}>
+ Thêm thủ công
</button>
{manualError && <p className="network-manual-error">{manualError}</p>}
</form>
{loading ? (
<div className="app-pool-status">Đang tải...</div>
) : acceptedItems.length === 0 ? (
<div className="app-pool-status" style={{ marginBottom: '0.75rem' }}>
Chưa điểm phát nào. Mở kho WiFi đ thêm từ máy sinh viên đang kết nối đúng mạng trường.
</div>
) : (
<table className="data-table app-pool-table" style={{ marginBottom: '0.75rem' }}>
<thead>
<tr>
<th>SSID</th>
<th>BSSID (MAC)</th>
<th></th>
</tr>
</thead>
<tbody>
{acceptedItems.map(item => (
<tr key={bssidKey(item.bssid)}>
<td><code className="app-pool-kw">{item.ssid}</code></td>
<td className="app-pool-muted" style={{ fontFamily: 'monospace', fontSize: '0.85rem' }}>
{item.bssid}
</td>
<td>
<button
type="button"
className="btn btn-secondary app-pool-add-btn"
onClick={() => removeWifi(item.bssid)}
>
Xóa
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
<div className="network-actions">
<button type="button" className="btn btn-secondary" onClick={() => setPoolOpen(true)}>
Mở kho WiFi
</button>
<button type="button" className="btn btn-primary" onClick={handleSave} disabled={saving || loading}>
{saving ? 'Đang lưu...' : 'Lưu cấu hình'}
</button>
</div>
</div>
<div className="card config-card-flat network-info-card">
<div className="card-header-title">Cách hoạt đng</div>
<ul className="network-info-list">
<li>Sinh viên kết nối WiFi app gửi <strong>SSID + BSSID</strong> (MAC điểm phát) lên kho</li>
<li>Thầy chọn từ kho hoặc <strong>thêm thủ công</strong> SSID + BSSID khi cần</li>
<li>Hotspot giả trùng tên nhưng MAC khác app báo lỗi thoát</li>
<li>Chưa cấu hình không chặn (đ thu thập kho trước)</li>
</ul>
</div>
</div>
<WifiPoolModal
open={poolOpen}
onClose={() => setPoolOpen(false)}
onSelect={addWifi}
acceptedBssidKeys={acceptedBssidKeys}
/>
</div>
);
};

View File

@@ -0,0 +1,224 @@
import React, { useEffect, useState, useRef, useCallback } from 'react';
interface ProctorStreamPanelsProps {
studentId: number;
layout?: 'default' | 'focus';
}
const ZOOM_STEPS = [0.5, 0.75, 1, 1.25, 1.5, 2, 2.5, 3];
export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
studentId,
layout = 'focus',
}) => {
const [screenFrame, setScreenFrame] = useState<string | null>(null);
const [webcamFrame, setWebcamFrame] = useState<string | null>(null);
const [streaming, setStreaming] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [showWebcam, setShowWebcam] = useState(true);
const [screenZoomIdx, setScreenZoomIdx] = useState(2); // 1x
const [webcamZoomIdx, setWebcamZoomIdx] = useState(2);
const [isFullscreen, setIsFullscreen] = useState(false);
const wsRef = useRef<WebSocket | null>(null);
const intentionalClose = useRef(false);
const hasOpened = useRef(false);
const hasFrames = useRef(false);
const screenPanelRef = useRef<HTMLDivElement>(null);
const screenZoom = ZOOM_STEPS[screenZoomIdx];
const webcamZoom = ZOOM_STEPS[webcamZoomIdx];
useEffect(() => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.hostname}:8080/ws?role=teacher`;
intentionalClose.current = false;
hasOpened.current = false;
hasFrames.current = false;
setStreaming(false);
setErrorMessage(null);
setScreenFrame(null);
setWebcamFrame(null);
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
const markStreaming = () => {
if (!hasFrames.current) {
hasFrames.current = true;
setStreaming(true);
setErrorMessage(null);
}
};
ws.onopen = () => {
hasOpened.current = true;
setStreaming(true);
ws.send(JSON.stringify({ event: 'teacher:subscribe', data: { studentId } }));
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.event === 'teacher:screenshot-stream-frame' && msg.data.studentId === studentId) {
setScreenFrame(msg.data.imageBuffer);
markStreaming();
} else if (msg.event === 'teacher:webcam-stream-frame' && msg.data.studentId === studentId) {
setWebcamFrame(msg.data.imageBuffer);
markStreaming();
} else if (msg.event === 'teacher:stream-stopped' && msg.data.studentId === studentId) {
setScreenFrame(null);
setWebcamFrame(null);
setStreaming(false);
setErrorMessage('Sinh viên đã dừng stream');
}
} catch (err) {
console.error('Error parsing WS frame:', err);
}
};
ws.onerror = () => {};
ws.onclose = () => {
if (intentionalClose.current) return;
setStreaming(false);
if (!hasOpened.current && !hasFrames.current) {
setErrorMessage('Không thể kết nối máy chủ giám sát');
}
};
return () => {
intentionalClose.current = true;
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ event: 'teacher:unsubscribe', data: { studentId } }));
}
ws.close();
};
}, [studentId]);
useEffect(() => {
const onFsChange = () => {
setIsFullscreen(document.fullscreenElement === screenPanelRef.current);
};
document.addEventListener('fullscreenchange', onFsChange);
return () => document.removeEventListener('fullscreenchange', onFsChange);
}, []);
const toggleFullscreen = useCallback(async () => {
const el = screenPanelRef.current;
if (!el) return;
try {
if (document.fullscreenElement === el) {
await document.exitFullscreen();
} else {
await el.requestFullscreen();
}
} catch (e) {
console.error('Fullscreen error', e);
}
}, []);
const zoomIn = (target: 'screen' | 'webcam') => {
const setter = target === 'screen' ? setScreenZoomIdx : setWebcamZoomIdx;
setter(i => Math.min(i + 1, ZOOM_STEPS.length - 1));
};
const zoomOut = (target: 'screen' | 'webcam') => {
const setter = target === 'screen' ? setScreenZoomIdx : setWebcamZoomIdx;
setter(i => Math.max(i - 1, 0));
};
const zoomReset = (target: 'screen' | 'webcam') => {
if (target === 'screen') setScreenZoomIdx(2);
else setWebcamZoomIdx(2);
};
const renderZoomToolbar = (target: 'screen' | 'webcam', zoom: number, onFs?: () => void) => (
<div className="panel-toolbar">
<button type="button" className="proctor-tool-btn" title="Thu nhỏ" onClick={() => zoomOut(target)}></button>
<span className="proctor-zoom-label">{Math.round(zoom * 100)}%</span>
<button type="button" className="proctor-tool-btn" title="Phóng to" onClick={() => zoomIn(target)}>+</button>
<button type="button" className="proctor-tool-btn" title="Về 100%" onClick={() => zoomReset(target)}>1:1</button>
{onFs && (
<button type="button" className="proctor-tool-btn proctor-tool-btn-wide" title="Toàn màn hình" onClick={onFs}>
{isFullscreen ? 'Thu nhỏ' : 'Phóng to'}
</button>
)}
</div>
);
return (
<div className="proctor-stream-wrap">
<div className="proctor-stream-toolbar">
<span className={`status-pill ${streaming || screenFrame || webcamFrame ? 'connected' : 'connecting'}`}>
{streaming || screenFrame || webcamFrame ? '● Đang phát' : '○ Đang kết nối...'}
</span>
<div className="proctor-stream-actions">
<button
type="button"
className={`btn btn-secondary proctor-action-btn ${showWebcam ? '' : 'active'}`}
onClick={() => setShowWebcam(v => !v)}
>
{showWebcam ? 'Ẩn webcam' : 'Hiện webcam'}
</button>
</div>
</div>
{errorMessage && !screenFrame && !webcamFrame && (
<div className="alert-error proctor-stream-error">{errorMessage}</div>
)}
<div className={`proctor-grid proctor-grid-${layout} ${!showWebcam ? 'proctor-grid--no-webcam' : ''}`}>
<div
className={`proctor-panel screen-panel ${isFullscreen ? 'screen-panel--fullscreen' : ''}`}
ref={screenPanelRef}
>
<div className="panel-title-row">
<span className="panel-title-text">Màn hình sinh viên</span>
{renderZoomToolbar('screen', screenZoom, toggleFullscreen)}
</div>
<div className="panel-body screen-body" onDoubleClick={toggleFullscreen} title="Double-click để phóng to">
<div className="proctor-zoom-viewport">
{screenFrame ? (
<img
src={screenFrame}
alt="Màn hình sinh viên"
className="live-frame screen-img"
style={{ transform: `scale(${screenZoom})` }}
draggable={false}
/>
) : (
<div className="no-stream-placeholder"><p>Đang chờ màn hình...</p></div>
)}
</div>
</div>
</div>
{showWebcam && (
<div className="proctor-panel webcam-panel">
<div className="panel-title-row">
<span className="panel-title-text">Webcam</span>
{renderZoomToolbar('webcam', webcamZoom)}
</div>
<div className="panel-body webcam-body">
<div className="proctor-zoom-viewport proctor-zoom-viewport--webcam">
{webcamFrame ? (
<img
src={webcamFrame}
alt="Webcam sinh viên"
className="live-frame webcam-img"
style={{ transform: `scale(${webcamZoom})` }}
draggable={false}
/>
) : (
<div className="no-stream-placeholder"><p>Đang chờ webcam...</p></div>
)}
</div>
</div>
</div>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,244 @@
import React, { useEffect, useState } from 'react';
import {
apiApplyScheduleTemplate,
apiFetchClassCourses,
apiSaveClassSchedule,
type ClassCourseItem,
type ClassScheduleItem,
} from '../api';
const DAYS = ['Thứ 2', 'Thứ 3', 'Thứ 4', 'Thứ 5', 'Thứ 6', 'Thứ 7', 'Chủ Nhật'];
const PERIODS = [1, 2, 3, 4];
interface ScheduleEditorProps {
classId: number;
schedules: ClassScheduleItem[];
onSaved: () => void;
}
const emptyShift = (day: number, period: number): ClassScheduleItem => ({
dayOfWeek: day,
period,
startTime: '',
endTime: '',
courseId: 0,
courseName: '',
isActive: false,
});
export const ScheduleEditor: React.FC<ScheduleEditorProps> = ({ classId, schedules, onSaved }) => {
const [local, setLocal] = useState<ClassScheduleItem[]>([]);
const [courses, setCourses] = useState<ClassCourseItem[]>([]);
const [coursesWarning, setCoursesWarning] = useState('');
const [coursesError, setCoursesError] = useState('');
const [saving, setSaving] = useState(false);
const [applying, setApplying] = useState(false);
const [expandedDay, setExpandedDay] = useState<number>(0);
useEffect(() => {
const mapped = schedules.map(s => ({ ...s, period: s.period || 1, isActive: s.isActive ?? true }));
const deduped = new Map<string, ClassScheduleItem>();
for (const s of mapped) {
deduped.set(`${s.dayOfWeek}-${s.period}`, s);
}
setLocal(Array.from(deduped.values()));
}, [schedules]);
const loadCourses = async () => {
try {
setCoursesError('');
setCoursesWarning('');
const res = await apiFetchClassCourses(classId);
setCourses(res.data || []);
if (res.warning) {
setCoursesWarning(res.warning);
} else if (res.cached) {
setCoursesWarning('Đang dùng danh sách môn đã lưu. Cập nhật QLDT_TOKEN và sync lớp để làm mới.');
}
if (!res.data?.length) {
setCoursesError('Chưa có môn học. Chạy Sync lớp học hoặc cập nhật QLDT_TOKEN trong server/.env');
}
} catch (e: any) {
setCourses([]);
setCoursesError(e.message || 'Không tải được môn học');
}
};
useEffect(() => {
loadCourses();
}, [classId]);
const shiftsForDay = (day: number) =>
local.filter(s => s.dayOfWeek === day).sort((a, b) => a.period - b.period);
const updateShift = (day: number, period: number, patch: Partial<ClassScheduleItem>) => {
setLocal(prev => {
const idx = prev.findIndex(s => s.dayOfWeek === day && s.period === period);
if (idx >= 0) {
const next = [...prev];
next[idx] = { ...next[idx], ...patch };
return next;
}
return [...prev, { ...emptyShift(day, period), ...patch }];
});
};
const addShift = (day: number) => {
const existing = shiftsForDay(day).map(s => s.period);
const nextPeriod = PERIODS.find(p => !existing.includes(p));
if (!nextPeriod) {
alert('Mỗi ngày tối đa 4 ca học.');
return;
}
setLocal(prev => [...prev, emptyShift(day, nextPeriod)]);
};
const removeShift = (day: number, period: number) => {
setLocal(prev => prev.filter(s => !(s.dayOfWeek === day && s.period === period)));
};
const handleApplyTemplate = async () => {
if (!confirm('Áp dụng template 4 ca/ngày (Thứ 26)? Các ca trùng sẽ được bỏ qua.')) return;
try {
setApplying(true);
const res = await apiApplyScheduleTemplate(classId);
alert(`Đã thêm ${res.created} ca${res.skipped ? `, bỏ qua ${res.skipped} ca trùng` : ''}.`);
onSaved();
} catch (e: any) {
alert(e.message || 'Áp dụng template thất bại');
} finally {
setApplying(false);
}
};
const handleSave = async () => {
const payload = local.filter(s => s.startTime?.trim() && s.endTime?.trim());
try {
setSaving(true);
await apiSaveClassSchedule(classId, payload);
alert('Đã lưu lịch học!');
onSaved();
} catch (e: any) {
alert(e.message || 'Lưu lịch học thất bại');
} finally {
setSaving(false);
}
};
return (
<div className="schedule-editor">
<div className="schedule-editor-toolbar">
<button type="button" className="btn btn-secondary" onClick={handleApplyTemplate} disabled={applying}>
{applying ? 'Đang áp dụng...' : 'Template 4 ca (T2T6)'}
</button>
</div>
{coursesWarning && (
<p className="schedule-hint schedule-hint-warn">{coursesWarning}</p>
)}
{coursesError && (
<p className="schedule-hint schedule-hint-error">{coursesError}</p>
)}
<div className="schedule-day-tabs">
{DAYS.map((name, day) => (
<button
key={day}
type="button"
className={`schedule-day-tab ${expandedDay === day ? 'active' : ''}`}
onClick={() => setExpandedDay(day)}
>
{name}
{shiftsForDay(day).length > 0 && (
<span className="schedule-day-count">{shiftsForDay(day).filter(s => s.isActive).length}</span>
)}
</button>
))}
</div>
<div className="schedule-day-panel">
<div className="schedule-day-header">
<strong>{DAYS[expandedDay]}</strong>
<button type="button" className="btn btn-secondary" style={{ padding: '0.35rem 0.65rem', fontSize: '0.78rem' }} onClick={() => addShift(expandedDay)}>
+ Thêm ca
</button>
</div>
{shiftsForDay(expandedDay).length === 0 ? (
<p className="schedule-hint">Chưa ca học. Bấm "Thêm ca" hoặc dùng template.</p>
) : (
shiftsForDay(expandedDay).map(shift => (
<div key={`${expandedDay}-${shift.period}`} className={`schedule-shift-card ${shift.isActive ? '' : 'inactive'}`}>
<div className="schedule-shift-head">
<label className="schedule-field schedule-field-ca">
<span>Ca học</span>
<select
className="schedule-input"
value={shift.period}
onChange={e => {
const newPeriod = Number(e.target.value);
if (newPeriod !== shift.period && shiftsForDay(expandedDay).some(s => s.period === newPeriod)) {
alert(`Ca ${newPeriod} đã tồn tại trong ngày này.`);
return;
}
setLocal(prev => prev.map(s =>
s.dayOfWeek === expandedDay && s.period === shift.period
? { ...s, period: newPeriod }
: s
));
}}
>
{PERIODS.map(p => (
<option key={p} value={p}>Ca {p}</option>
))}
</select>
</label>
<label className="schedule-active-toggle">
<input type="checkbox" checked={shift.isActive} onChange={e => updateShift(expandedDay, shift.period, { isActive: e.target.checked })} />
<span>Bật</span>
</label>
<button type="button" className="btn btn-secondary schedule-remove-btn" onClick={() => removeShift(expandedDay, shift.period)} title="Xóa ca">×</button>
</div>
<div className="schedule-time-row">
<label className="schedule-field">
<span>Bắt đu</span>
<input className="schedule-input time-input" placeholder="08:00" value={shift.startTime} onChange={e => updateShift(expandedDay, shift.period, { startTime: e.target.value })} />
</label>
<label className="schedule-field">
<span>Kết thúc</span>
<input className="schedule-input time-input" placeholder="12:00" value={shift.endTime} onChange={e => updateShift(expandedDay, shift.period, { endTime: e.target.value })} />
</label>
</div>
<label className="schedule-field">
<span>Môn học (QLĐT)</span>
<select
className="schedule-input"
value={shift.courseId || ''}
onChange={e => {
const cid = Number(e.target.value);
const course = courses.find(c => c.id === cid);
updateShift(expandedDay, shift.period, {
courseId: cid,
courseName: course?.name || '',
});
}}
>
<option value=""> Chọn môn </option>
{courses.map(c => (
<option key={c.id} value={c.id}>{c.name}{c.courseCode ? ` (${c.courseCode})` : ''}</option>
))}
</select>
</label>
</div>
))
)}
</div>
<button className="btn btn-primary" style={{ width: '100%', justifyContent: 'center' }} onClick={handleSave} disabled={saving}>
{saving ? 'Đang lưu...' : 'Lưu lịch học'}
</button>
</div>
);
};

View File

@@ -0,0 +1,50 @@
import React, { useEffect, useState } from 'react';
function normalizeAvatarUrl(raw?: string | null): string {
const trimmed = raw?.trim() || '';
if (!trimmed) return '';
if (trimmed.startsWith('//')) return `https:${trimmed}`;
return trimmed;
}
interface StudentAvatarProps {
fullName: string;
avatar?: string | null;
isOnline?: boolean;
size?: number;
}
export const StudentAvatar: React.FC<StudentAvatarProps> = ({
fullName,
avatar,
isOnline = false,
size = 48,
}) => {
const [imgFailed, setImgFailed] = useState(false);
const avatarUrl = normalizeAvatarUrl(avatar);
const initial = fullName ? fullName.trim().charAt(0).toUpperCase() : 'S';
const showImage = !!avatarUrl && !imgFailed;
useEffect(() => {
setImgFailed(false);
}, [avatarUrl]);
return (
<div
className={`student-workspace-avatar ${isOnline ? 'online' : ''} ${showImage ? 'has-image' : ''}`}
style={{ width: size, height: size, fontSize: size * 0.38 }}
>
{showImage ? (
<img
src={avatarUrl}
alt={fullName}
className="student-avatar-img"
referrerPolicy="no-referrer"
onError={() => setImgFailed(true)}
/>
) : (
initial
)}
</div>
);
};

View File

@@ -0,0 +1,111 @@
import React, { useState } from 'react';
import type { StudentItem, StudentSessionLogItem } from '../api';
import { StudentAvatar } from './StudentAvatar';
import { ProctorStreamPanels } from './ProctorStreamPanels';
interface StudentDetailModalProps {
student: StudentItem;
isOnline: boolean;
sessionLog?: StudentSessionLogItem | null;
onClose: () => void;
}
const formatDuration = (totalSeconds: number) => {
const hrs = Math.floor(totalSeconds / 3600);
const mins = Math.floor((totalSeconds % 3600) / 60);
const secs = totalSeconds % 60;
const pad = (n: number) => String(n).padStart(2, '0');
return `${pad(hrs)}:${pad(mins)}:${pad(secs)}`;
};
export const StudentDetailModal: React.FC<StudentDetailModalProps> = ({
student,
isOnline,
sessionLog,
onClose,
}) => {
const [showProctor, setShowProctor] = useState(false);
return (
<div className="modal-overlay student-detail-overlay" onClick={onClose}>
<div className={`modal-container student-detail-modal ${showProctor ? 'student-detail-modal--proctor' : ''}`} onClick={e => e.stopPropagation()}>
<div className="modal-header">
<div className="student-detail-header">
<StudentAvatar fullName={student.fullName} avatar={student.avatar} isOnline={isOnline} size={56} />
<div>
<h2 className="modal-title" style={{ margin: 0 }}>{student.fullName}</h2>
<p style={{ margin: '4px 0 0', color: 'var(--text-muted)', fontSize: '0.85rem' }}>
<span style={{ fontFamily: 'monospace', fontWeight: 700, color: 'var(--accent)' }}>{student.studentCode}</span>
{student.email && <> · {student.email}</>}
</p>
</div>
</div>
<button type="button" className="btn btn-secondary" onClick={onClose}>Đóng</button>
</div>
<div className="student-detail-body">
<div className="student-detail-info">
<div className="student-detail-meta">
<div className="student-meta-item">
<span className="student-meta-label">Trạng thái</span>
<span className={`student-meta-value ${isOnline ? 'online' : ''}`}>
{isOnline ? '● Online' : '○ Offline'}
</span>
</div>
{student.phone && (
<div className="student-meta-item">
<span className="student-meta-label">Điện thoại</span>
<span className="student-meta-value">{student.phone}</span>
</div>
)}
{sessionLog && (
<>
<div className="student-meta-item">
<span className="student-meta-label">Online (ca)</span>
<span className="student-meta-value" style={{ color: 'var(--success)', fontFamily: 'monospace' }}>
{formatDuration(sessionLog.onlineSeconds)}
</span>
</div>
<div className="student-meta-item">
<span className="student-meta-label">Offline (ca)</span>
<span className="student-meta-value" style={{ color: 'var(--danger)', fontFamily: 'monospace' }}>
{formatDuration(sessionLog.offlineSeconds)}
</span>
</div>
{sessionLog.wifiSsids && sessionLog.wifiSsids !== '—' && (
<div className="student-meta-item">
<span className="student-meta-label">WiFi</span>
<span className="student-meta-value" style={{ fontFamily: 'monospace', fontSize: '0.8rem' }}>
{sessionLog.wifiSsids}
</span>
</div>
)}
</>
)}
</div>
<button
type="button"
className="btn btn-primary"
style={{ width: '100%', justifyContent: 'center' }}
onClick={() => setShowProctor(v => !v)}
>
{showProctor ? 'Ẩn giám sát' : 'Xem webcam & màn hình'}
</button>
{!isOnline && !showProctor && (
<p className="schedule-hint" style={{ margin: 0 }}>
Sinh viên offline stream chỉ khi app Simple Care đang chạy.
</p>
)}
</div>
{showProctor && (
<div className="student-detail-proctor">
<ProctorStreamPanels studentId={student.rkId} />
</div>
)}
</div>
</div>
</div>
);
};

View File

@@ -88,7 +88,8 @@ export const StudentsTab: React.FC = () => {
: 0;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
<div className="tab-page">
<div className="tab-page-toolbar">
<div className="page-header">
<div className="page-title">
<h1>Danh Sách Sinh Viên</h1>
@@ -153,9 +154,11 @@ export const StudentsTab: React.FC = () => {
<span className="search-icon">🔍</span>
</div>
</div>
</div>
{/* Students Table */}
<div className="table-wrapper">
<div className="tab-page-body">
<div className="table-wrapper table-fill">
{loading ? (
<div className="empty-state">
<div className="sync-spinner" style={{ width: '32px', height: '32px' }}></div>
@@ -184,7 +187,7 @@ export const StudentsTab: React.FC = () => {
{students.map(st => (
<tr key={st.id}>
<td style={{ fontWeight: 600 }}>{st.studentCode}</td>
<td style={{ color: 'white', fontWeight: 500 }}>
<td style={{ color: 'var(--text-primary)', fontWeight: 500 }}>
{st.fullName}
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>ID: {st.rkId}</div>
</td>
@@ -215,9 +218,11 @@ export const StudentsTab: React.FC = () => {
</table>
)}
</div>
</div>
{/* Pagination controls */}
{!loading && students.length > 0 && (
<div className="tab-page-footer">
<div className="pagination-row">
<div>
Hiển thị sinh viên thứ <b>{((page - 1) * pageSize) + 1}</b> đến <b>{Math.min(page * pageSize, total)}</b> trong tổng số <b>{total}</b> sinh viên
@@ -230,7 +235,7 @@ export const StudentsTab: React.FC = () => {
>
</button>
<span style={{ display: 'flex', alignItems: 'center', padding: '0 1rem', fontWeight: 600, color: 'white' }}>
<span style={{ display: 'flex', alignItems: 'center', padding: '0 1rem', fontWeight: 600, color: 'var(--text-primary)' }}>
Trang {page} / {Math.ceil(total / pageSize) || 1}
</span>
<button
@@ -242,6 +247,7 @@ export const StudentsTab: React.FC = () => {
</button>
</div>
</div>
</div>
)}
</div>
);

View File

@@ -0,0 +1,168 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { apiFetchWifiPool } from '../api';
import type { WifiPoolItem } from '../api';
function bssidKey(bssid: string): string {
return bssid.toLowerCase().replace(/[^0-9a-f]/g, '');
}
interface WifiPoolModalProps {
open: boolean;
onClose: () => void;
onSelect: (ssid: string, bssid: string) => void;
acceptedBssidKeys: string;
}
export const WifiPoolModal: React.FC<WifiPoolModalProps> = ({
open,
onClose,
onSelect,
acceptedBssidKeys,
}) => {
const [search, setSearch] = useState('');
const [debouncedQ, setDebouncedQ] = useState('');
const [items, setItems] = useState<WifiPoolItem[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const acceptedSet = useMemo(() => {
return new Set(
acceptedBssidKeys.split(',').map(s => s.trim()).filter(s => s.length === 12)
);
}, [acceptedBssidKeys]);
useEffect(() => {
if (!open) return;
const t = setTimeout(() => setDebouncedQ(search.trim()), 300);
return () => clearTimeout(t);
}, [search, open]);
const loadPool = useCallback(async (q: string) => {
try {
setLoading(true);
setError(null);
const res = await apiFetchWifiPool(q, 80);
setItems(res.data || []);
} catch (e: any) {
setItems([]);
setError(e?.message || 'Không tải được kho WiFi');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (!open) return;
loadPool(debouncedQ);
const interval = setInterval(() => loadPool(debouncedQ), 20000);
return () => clearInterval(interval);
}, [open, debouncedQ, loadPool]);
useEffect(() => {
if (!open) {
setSearch('');
setDebouncedQ('');
setItems([]);
setError(null);
}
}, [open]);
if (!open) return null;
const handleSelect = (ssid: string, bssid: string) => {
const key = bssidKey(bssid);
if (!ssid.trim() || key.length !== 12 || acceptedSet.has(key)) return;
onSelect(ssid, bssid);
};
return (
<div className="modal-overlay app-pool-overlay" onClick={onClose}>
<div className="modal-container app-pool-modal" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<div>
<h2 className="modal-title" style={{ margin: 0 }}>Kho WiFi</h2>
<p className="app-pool-modal-sub">
Điểm phát thật từ máy sinh viên (SSID + BSSID). Chọn đ thêm vào danh sách chấp nhận.
</p>
</div>
<button type="button" className="btn btn-secondary" onClick={onClose}>Đóng</button>
</div>
<div className="app-pool-toolbar">
<input
type="search"
className="app-pool-search"
placeholder="Tìm SSID hoặc BSSID..."
value={search}
onChange={e => setSearch(e.target.value)}
autoFocus
/>
<button type="button" className="btn btn-secondary" onClick={() => loadPool(debouncedQ)} disabled={loading}>
{loading ? 'Đang tải...' : 'Làm mới'}
</button>
</div>
<div className="app-pool-body">
{error ? (
<div className="discovered-apps-empty discovered-apps-error">{error}</div>
) : loading && items.length === 0 ? (
<div className="app-pool-status">Đang tải...</div>
) : items.length === 0 ? (
<div className="app-pool-status">
{debouncedQ ? `Không tìm thấy "${debouncedQ}"` : 'Chưa có WiFi nào trong kho.'}
</div>
) : (
<table className="data-table app-pool-table">
<thead>
<tr>
<th>SSID</th>
<th>BSSID (MAC)</th>
<th>Lần ghi nhận</th>
<th>Gần nhất</th>
<th></th>
</tr>
</thead>
<tbody>
{items.map(item => {
const key = bssidKey(item.bssid || '');
const added = key.length === 12 && acceptedSet.has(key);
return (
<tr key={item.id} className={added ? 'app-pool-row--added' : ''}>
<td><code className="app-pool-kw">{item.ssid}</code></td>
<td className="app-pool-muted" style={{ fontFamily: 'monospace', fontSize: '0.8rem' }}>
{item.bssid || '—'}
</td>
<td className="app-pool-hit">{item.hitCount}×</td>
<td className="app-pool-muted">
{item.lastSeenAt ? new Date(item.lastSeenAt).toLocaleString('vi-VN') : '—'}
</td>
<td>
{!item.bssid || key.length !== 12 ? (
<span className="app-pool-muted">Thiếu BSSID</span>
) : added ? (
<span className="app-pool-added-tag">Đã </span>
) : (
<button
type="button"
className="btn btn-primary app-pool-add-btn"
onClick={() => handleSelect(item.ssid, item.bssid)}
>
+ Thêm
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
<div className="app-pool-footer">
Hiển thị {items.length} kết quả{debouncedQ ? ` cho "${debouncedQ}"` : ''}
</div>
</div>
</div>
);
};