core v1
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="vi">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>management</title>
|
||||
<title>Simple Care — Rikkei Education</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,65 +1,165 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { DashboardTab } from './components/DashboardTab';
|
||||
import { ClassesTab } from './components/ClassesTab';
|
||||
import { StudentsTab } from './components/StudentsTab';
|
||||
import { LearningTab } from './components/LearningTab';
|
||||
import { NetworkTab } from './components/NetworkTab';
|
||||
import { ClassWorkspace } from './components/ClassWorkspace';
|
||||
import { NavHistoryBar, useRoute } from './components/NavHistoryBar';
|
||||
import { goBack, navigate, parseRoute, pushNav, TAB_LABELS, type TabId } from './navigation';
|
||||
|
||||
const IconDashboard = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconClass = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||
<polyline points="9 22 9 12 15 12 15 22" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconStudent = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" /><path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconLearning = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" />
|
||||
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconNetwork = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M5 12.55a11 11 0 0 1 14.08 0" />
|
||||
<path d="M1.42 9a16 16 0 0 1 21.16 0" />
|
||||
<path d="M8.53 16.11a6 6 0 0 1 6.95 0" />
|
||||
<circle cx="12" cy="20" r="1" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
function App() {
|
||||
const [activeTab, setActiveTab] = useState<string>('dashboard');
|
||||
const route = useRoute();
|
||||
|
||||
useEffect(() => {
|
||||
const initial = parseRoute();
|
||||
if (initial.classId) {
|
||||
return;
|
||||
}
|
||||
pushNav({ kind: 'tab', tab: initial.tab, label: TAB_LABELS[initial.tab] });
|
||||
}, []);
|
||||
|
||||
const setActiveTab = (tab: string) => {
|
||||
navigate(tab as TabId);
|
||||
};
|
||||
|
||||
const handleBackFromClass = () => {
|
||||
goBack(route.tab);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Sidebar navigation panel */}
|
||||
<aside className="sidebar">
|
||||
<div className="brand">
|
||||
<div className="brand-logo">SC</div>
|
||||
<span className="brand-name">Simple Care</span>
|
||||
<div className="brand-logo">RE</div>
|
||||
<div className="brand-text">
|
||||
<span className="brand-name">Simple Care</span>
|
||||
<span className="brand-sub">Rikkei Education</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
<ul className="nav-links">
|
||||
<div className="nav-header">Tổng quan</div>
|
||||
<li className="nav-item">
|
||||
<button
|
||||
className={`nav-btn ${activeTab === 'dashboard' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('dashboard')}
|
||||
className={`nav-btn ${route.tab === 'dashboard' && !route.classId ? 'active' : ''}`}
|
||||
onClick={() => navigate('dashboard')}
|
||||
>
|
||||
<span className="nav-icon">📊</span>
|
||||
Tổng Quan
|
||||
<span className="nav-icon"><IconDashboard /></span>
|
||||
Tổng quan
|
||||
</button>
|
||||
</li>
|
||||
|
||||
<div className="nav-header">CSDL liên kết</div>
|
||||
<li className="nav-item">
|
||||
<button
|
||||
className={`nav-btn ${route.tab === 'classes' && !route.classId ? 'active' : ''}`}
|
||||
onClick={() => navigate('classes')}
|
||||
>
|
||||
<span className="nav-icon"><IconClass /></span>
|
||||
Lớp học
|
||||
</button>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<button
|
||||
className={`nav-btn ${activeTab === 'classes' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('classes')}
|
||||
className={`nav-btn ${route.tab === 'students' && !route.classId ? 'active' : ''}`}
|
||||
onClick={() => navigate('students')}
|
||||
>
|
||||
<span className="nav-icon">🏫</span>
|
||||
Lớp Học
|
||||
<span className="nav-icon"><IconStudent /></span>
|
||||
Sinh viên
|
||||
</button>
|
||||
</li>
|
||||
|
||||
<div className="nav-header">Quản lý học tập</div>
|
||||
<li className="nav-item">
|
||||
<button
|
||||
className={`nav-btn ${route.tab === 'learning' && !route.classId ? 'active' : ''}`}
|
||||
onClick={() => navigate('learning')}
|
||||
>
|
||||
<span className="nav-icon"><IconLearning /></span>
|
||||
Giám sát & Lịch học
|
||||
</button>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<button
|
||||
className={`nav-btn ${activeTab === 'students' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('students')}
|
||||
className={`nav-btn ${route.tab === 'network' && !route.classId ? 'active' : ''}`}
|
||||
onClick={() => navigate('network')}
|
||||
>
|
||||
<span className="nav-icon">🎓</span>
|
||||
Sinh Viên
|
||||
<span className="nav-icon"><IconNetwork /></span>
|
||||
Quản lý mạng
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>Cấu hình Token:</div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', fontWeight: 500 }}>Kết nối hệ thống</div>
|
||||
<div className="token-badge" title="Token QLDT_TOKEN load từ server .env">
|
||||
🔑 QLDT_TOKEN (Loaded)
|
||||
QLDT_TOKEN đã kết nối
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main dashboard content container */}
|
||||
<main className="main-content">
|
||||
{activeTab === 'dashboard' && <DashboardTab onNavigate={setActiveTab} />}
|
||||
{activeTab === 'classes' && <ClassesTab />}
|
||||
{activeTab === 'students' && <StudentsTab />}
|
||||
<div className="page-viewport">
|
||||
{route.classId ? (
|
||||
<ClassWorkspace
|
||||
classId={route.classId}
|
||||
sourceTab={route.tab}
|
||||
onBack={handleBackFromClass}
|
||||
renderNavBar={(className) => (
|
||||
<NavHistoryBar classLabel={className} onBack={handleBackFromClass} />
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{route.tab === 'dashboard' && <DashboardTab onNavigate={setActiveTab} />}
|
||||
{route.tab === 'classes' && <ClassesTab />}
|
||||
{route.tab === 'students' && <StudentsTab />}
|
||||
{route.tab === 'learning' && <LearningTab />}
|
||||
{route.tab === 'network' && <NetworkTab />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface StudentItem {
|
||||
location?: string;
|
||||
systemId?: number;
|
||||
systemName?: string;
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
export interface StatsResponse {
|
||||
@@ -54,6 +55,55 @@ export interface SyncStatus {
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface ClassScheduleItem {
|
||||
id?: number;
|
||||
classRkId?: number;
|
||||
dayOfWeek: number;
|
||||
period: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
courseId: number;
|
||||
courseName: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface ClassCourseItem {
|
||||
id: number;
|
||||
name: string;
|
||||
courseCode?: string;
|
||||
}
|
||||
|
||||
export interface AttendanceRow {
|
||||
studentRkId: number;
|
||||
studentCode: string;
|
||||
fullName: string;
|
||||
email: string;
|
||||
status: number;
|
||||
statusLabel: string;
|
||||
onlineMinutes: number;
|
||||
statusEditedByTeacher: boolean;
|
||||
}
|
||||
|
||||
export const ATTENDANCE_STATUS_OPTIONS = [
|
||||
{ value: 0, label: 'Nghỉ không phép' },
|
||||
{ value: 1, label: 'Nghỉ có phép' },
|
||||
{ value: 2, label: 'Nghỉ nửa buổi' },
|
||||
{ value: 3, label: 'Đi học muộn' },
|
||||
{ value: 4, label: 'Đi học đầy đủ' },
|
||||
];
|
||||
|
||||
export interface StudentSessionLogItem {
|
||||
id: number;
|
||||
studentRkId: number;
|
||||
studentCode: string;
|
||||
fullName: string;
|
||||
email: string;
|
||||
onlineSeconds: number;
|
||||
offlineSeconds: number;
|
||||
wifiSsids: string;
|
||||
lastActiveAt?: string;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
getStats: async (): Promise<StatsResponse> => {
|
||||
const res = await fetch(`${API_BASE}/stats`);
|
||||
@@ -87,7 +137,10 @@ export const api = {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ isStudying }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to update class studying status');
|
||||
if (!res.ok) {
|
||||
const errData = await res.json().catch(() => ({}));
|
||||
throw new Error(errData.error || 'Failed to update class studying status');
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
|
||||
@@ -143,3 +196,199 @@ export const api = {
|
||||
return res.json();
|
||||
},
|
||||
};
|
||||
|
||||
// Export individual learning functions to simplify imports in components
|
||||
export const apiFetchActiveSchedules = async (): Promise<{ data: any[] }> => {
|
||||
const res = await fetch(`${API_BASE}/classes/schedules`);
|
||||
if (!res.ok) throw new Error('Failed to fetch active schedules');
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export const apiFetchClassSchedule = async (rkId: number): Promise<{ data: ClassScheduleItem[] }> => {
|
||||
const res = await fetch(`${API_BASE}/classes/${rkId}/schedule`);
|
||||
if (!res.ok) throw new Error('Failed to fetch class schedule');
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export const apiSaveClassSchedule = async (rkId: number, schedules: ClassScheduleItem[]): Promise<any> => {
|
||||
const res = await fetch(`${API_BASE}/classes/${rkId}/schedule`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ schedules }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to save class schedule');
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export const apiDeleteClassSchedule = async (rkId: number): Promise<any> => {
|
||||
const res = await fetch(`${API_BASE}/classes/${rkId}/schedule`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error('Failed to delete class schedule');
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export const apiFetchAllowedApps = async (rkId: number): Promise<{ classRkId: number; keywords: string }> => {
|
||||
const res = await fetch(`${API_BASE}/classes/${rkId}/allowed-apps`);
|
||||
if (!res.ok) throw new Error('Failed to fetch allowed apps keywords');
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export const apiSaveAllowedApps = async (rkId: number, keywords: string): Promise<any> => {
|
||||
const res = await fetch(`${API_BASE}/classes/${rkId}/allowed-apps`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ keywords }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to save allowed apps keywords');
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export interface AppPoolItem {
|
||||
id: number;
|
||||
processName: string;
|
||||
windowTitle: string;
|
||||
keyword: string;
|
||||
hitCount: number;
|
||||
lastSeenAt: string;
|
||||
lastStudentRkId?: number;
|
||||
lastClassRkId?: number;
|
||||
}
|
||||
|
||||
export const apiFetchAppPool = async (q = '', limit = 50): Promise<{ data: AppPoolItem[]; q?: string; limit?: number }> => {
|
||||
const params = new URLSearchParams();
|
||||
if (q.trim()) params.set('q', q.trim());
|
||||
params.set('limit', String(limit));
|
||||
const query = params.toString() ? `?${params.toString()}` : '';
|
||||
const res = await fetch(`${API_BASE}/app-pool${query}`);
|
||||
if (!res.ok) throw new Error('Failed to fetch app pool');
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export interface WifiPoolItem {
|
||||
id: number;
|
||||
ssid: string;
|
||||
bssid: string;
|
||||
hitCount: number;
|
||||
lastSeenAt: string;
|
||||
lastStudentRkId?: number;
|
||||
}
|
||||
|
||||
export interface AcceptedWifiItem {
|
||||
id: number;
|
||||
ssid: string;
|
||||
bssid: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export type WifiAcceptItem = { ssid: string; bssid: string };
|
||||
|
||||
export const apiFetchWifiPool = async (q = '', limit = 50): Promise<{ data: WifiPoolItem[] }> => {
|
||||
const params = new URLSearchParams();
|
||||
if (q.trim()) params.set('q', q.trim());
|
||||
params.set('limit', String(limit));
|
||||
const query = params.toString() ? `?${params.toString()}` : '';
|
||||
const res = await fetch(`${API_BASE}/wifi-pool${query}`);
|
||||
if (!res.ok) throw new Error('Failed to fetch wifi pool');
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export const apiFetchAcceptedWifis = async (): Promise<{ data: AcceptedWifiItem[] }> => {
|
||||
const res = await fetch(`${API_BASE}/network/accepted-wifis`);
|
||||
if (!res.ok) throw new Error('Failed to fetch accepted wifis');
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export const apiSaveAcceptedWifis = async (items: WifiAcceptItem[]): Promise<any> => {
|
||||
const res = await fetch(`${API_BASE}/network/accepted-wifis`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ items }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to save accepted wifis');
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export const apiFetchClassStudents = api.getClassStudents;
|
||||
export const apiFetchClasses = api.getClasses;
|
||||
|
||||
export const apiFetchClassSessionLogs = async (
|
||||
rkId: number,
|
||||
date?: string,
|
||||
period?: number
|
||||
): Promise<{ data: StudentSessionLogItem[]; period?: number; date?: string }> => {
|
||||
const params = new URLSearchParams();
|
||||
if (date) params.set('date', date);
|
||||
if (period) params.set('period', String(period));
|
||||
const query = params.toString() ? `?${params.toString()}` : '';
|
||||
const res = await fetch(`${API_BASE}/classes/${rkId}/session-logs${query}`);
|
||||
if (!res.ok) throw new Error('Failed to fetch class session logs');
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export const apiFetchOnlineStudents = async (rkId: number): Promise<{ onlineStudentIds: number[] }> => {
|
||||
const res = await fetch(`${API_BASE}/classes/${rkId}/online-students`);
|
||||
if (!res.ok) throw new Error('Failed to fetch online students list');
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export const apiFetchClassCourses = async (rkId: number): Promise<{
|
||||
data: ClassCourseItem[];
|
||||
source?: string;
|
||||
cached?: boolean;
|
||||
warning?: string;
|
||||
hint?: string;
|
||||
}> => {
|
||||
const res = await fetch(`${API_BASE}/classes/${rkId}/courses`);
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
throw new Error(body.error || body.hint || 'Không tải được danh sách môn học từ QLĐT');
|
||||
}
|
||||
return body;
|
||||
};
|
||||
|
||||
export const apiApplyScheduleTemplate = async (rkId: number): Promise<{ created: number; skipped: number }> => {
|
||||
const res = await fetch(`${API_BASE}/classes/${rkId}/schedule/apply-template`, { method: 'POST' });
|
||||
if (!res.ok) throw new Error('Failed to apply schedule template');
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export const apiFetchAttendanceShifts = async (rkId: number, date?: string) => {
|
||||
const q = date ? `?date=${date}` : '';
|
||||
const res = await fetch(`${API_BASE}/classes/${rkId}/attendance/shifts${q}`);
|
||||
if (!res.ok) throw new Error('Failed to fetch attendance shifts');
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export const apiFetchAttendance = async (rkId: number, date: string, period: number) => {
|
||||
const res = await fetch(`${API_BASE}/classes/${rkId}/attendance?date=${date}&period=${period}`);
|
||||
if (!res.ok) throw new Error('Failed to fetch attendance');
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export const apiUpdateAttendanceStatus = async (
|
||||
rkId: number,
|
||||
payload: { date: string; period: number; studentRkId: number; status: number }
|
||||
) => {
|
||||
const res = await fetch(`${API_BASE}/classes/${rkId}/attendance/status`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.error || 'Failed to update attendance status');
|
||||
}
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export const apiPushAttendanceQLDT = async (rkId: number, date: string, period: number) => {
|
||||
const res = await fetch(`${API_BASE}/classes/${rkId}/attendance/push-qldt`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ date, period }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.error || 'Failed to push attendance to QLĐT');
|
||||
}
|
||||
return res.json();
|
||||
};
|
||||
|
||||
167
management/src/components/AppPoolModal.tsx
Normal file
167
management/src/components/AppPoolModal.tsx
Normal 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">Đã có</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>
|
||||
);
|
||||
};
|
||||
163
management/src/components/AttendancePanel.tsx
Normal file
163
management/src/components/AttendancePanel.tsx
Normal 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 có 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>
|
||||
);
|
||||
};
|
||||
547
management/src/components/ClassWorkspace.tsx
Normal file
547
management/src/components/ClassWorkspace.tsx
Normal 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ĩ 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 và 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')}
|
||||
>
|
||||
Sơ đồ học viên
|
||||
</button>
|
||||
<button
|
||||
className={`tab-sub-btn ${activeSubTab === 'logs' ? 'active' : ''}`}
|
||||
onClick={() => setActiveSubTab('logs')}
|
||||
>
|
||||
Nhật ký 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 ký đ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 có nhật ký 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>
|
||||
);
|
||||
};
|
||||
@@ -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ý 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' }}>
|
||||
Mã lớp: {activeClass.classCode} | Sĩ số: {activeClass.studentCount}
|
||||
</p>
|
||||
</div>
|
||||
<button className="modal-close-btn" onClick={() => setActiveClass(null)}>
|
||||
×
|
||||
</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 có 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>Mã 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>
|
||||
|
||||
@@ -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ộ và quản lý 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ộ và quản lý 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 và danh sách sinh viên nội bộ mà không lo ảnh hưởng đến dữ liệu hiện có.
|
||||
<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 và 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ý Lớp học
|
||||
Quản lý 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 và 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ý Lớp:</b> Lọc danh sách lớp theo Phân hệ (System ID), Tìm kiếm tên hoặc mã lớp và 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 và thi cử.</li>
|
||||
<li><strong>Đồng bộ lớp học:</strong> Tải danh sách lớp và 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ý lớp:</strong> Lọc theo phân hệ, tìm kiếm tên/mã lớp và 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>
|
||||
);
|
||||
};
|
||||
|
||||
424
management/src/components/LearningTab.tsx
Normal file
424
management/src/components/LearningTab.tsx
Normal 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ý 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 và 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 có 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>Mã 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 có 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 có 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 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>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 có 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>
|
||||
);
|
||||
};
|
||||
35
management/src/components/LiveProctorModal.tsx
Normal file
35
management/src/components/LiveProctorModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
111
management/src/components/NavHistoryBar.tsx
Normal file
111
management/src/components/NavHistoryBar.tsx
Normal 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);
|
||||
}
|
||||
223
management/src/components/NetworkTab.tsx
Normal file
223
management/src/components/NetworkTab.tsx
Normal 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 lý 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 có đ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 cô 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 và 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>
|
||||
);
|
||||
};
|
||||
224
management/src/components/ProctorStreamPanels.tsx
Normal file
224
management/src/components/ProctorStreamPanels.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
244
management/src/components/ScheduleEditor.tsx
Normal file
244
management/src/components/ScheduleEditor.tsx
Normal 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ứ 2–6)? 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 (T2–T6)'}
|
||||
</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 có 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>
|
||||
);
|
||||
};
|
||||
50
management/src/components/StudentAvatar.tsx
Normal file
50
management/src/components/StudentAvatar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
111
management/src/components/StudentDetailModal.tsx
Normal file
111
management/src/components/StudentDetailModal.tsx
Normal 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ỉ có khi app Simple Care đang chạy.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showProctor && (
|
||||
<div className="student-detail-proctor">
|
||||
<ProctorStreamPanels studentId={student.rkId} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
168
management/src/components/WifiPoolModal.tsx
Normal file
168
management/src/components/WifiPoolModal.tsx
Normal 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">Đã có</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>
|
||||
);
|
||||
};
|
||||
4
management/src/constants.ts
Normal file
4
management/src/constants.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
/** Whitelist mặc định khi kích hoạt lớp — khớp tên tiến trình hoặc tiêu đề cửa sổ. */
|
||||
export const DEFAULT_ALLOWED_APPS = 'chrome,idea64,vscode,wails,simple_care,client';
|
||||
|
||||
export const BASE_APP_SUGGESTIONS = ['chrome', 'vscode', 'idea64', 'wails', 'simple_care', 'client', 'cursor', 'goland', 'teams', 'zoom'];
|
||||
File diff suppressed because it is too large
Load Diff
112
management/src/navigation.ts
Normal file
112
management/src/navigation.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
export type TabId = 'dashboard' | 'classes' | 'students' | 'learning' | 'network';
|
||||
|
||||
export interface NavEntry {
|
||||
id: string;
|
||||
kind: 'tab' | 'class';
|
||||
tab: TabId;
|
||||
classId?: number;
|
||||
label: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export const TAB_LABELS: Record<TabId, string> = {
|
||||
dashboard: 'Tổng quan',
|
||||
classes: 'Lớp học',
|
||||
students: 'Sinh viên',
|
||||
learning: 'Giám sát & Lịch học',
|
||||
network: 'Quản lý mạng',
|
||||
};
|
||||
|
||||
const HISTORY_KEY = 'sc_nav_history';
|
||||
const MAX_HISTORY = 10;
|
||||
|
||||
function isTabId(value: string | null): value is TabId {
|
||||
return value === 'dashboard' || value === 'classes' || value === 'students' || value === 'learning' || value === 'network';
|
||||
}
|
||||
|
||||
export function parseRoute(): { tab: TabId; classId: number | null } {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const tabParam = params.get('tab');
|
||||
const tab: TabId = isTabId(tabParam) ? tabParam : 'dashboard';
|
||||
const classIdRaw = params.get('classId');
|
||||
const classId = classIdRaw ? Number(classIdRaw) : null;
|
||||
return {
|
||||
tab,
|
||||
classId: classId && !Number.isNaN(classId) ? classId : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildUrl(tab: TabId, classId?: number | null): string {
|
||||
const params = new URLSearchParams();
|
||||
params.set('tab', tab);
|
||||
if (classId) {
|
||||
params.set('classId', String(classId));
|
||||
}
|
||||
return `/?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function readHistory(): NavEntry[] {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(HISTORY_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw) as NavEntry[];
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeHistory(entries: NavEntry[]) {
|
||||
sessionStorage.setItem(HISTORY_KEY, JSON.stringify(entries.slice(0, MAX_HISTORY)));
|
||||
}
|
||||
|
||||
function entryKey(kind: NavEntry['kind'], tab: TabId, classId?: number) {
|
||||
return kind === 'class' ? `class:${classId}` : `tab:${tab}`;
|
||||
}
|
||||
|
||||
export function pushNav(entry: Omit<NavEntry, 'id' | 'timestamp'>) {
|
||||
const history = readHistory();
|
||||
const id = entryKey(entry.kind, entry.tab, entry.classId);
|
||||
const next: NavEntry = {
|
||||
...entry,
|
||||
id,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
const filtered = history.filter((h) => h.id !== id);
|
||||
writeHistory([next, ...filtered]);
|
||||
}
|
||||
|
||||
export function navigate(tab: TabId, classId?: number | null, className?: string) {
|
||||
const url = buildUrl(tab, classId);
|
||||
window.history.pushState({}, '', url);
|
||||
|
||||
if (classId && className) {
|
||||
pushNav({ kind: 'class', tab, classId, label: className });
|
||||
} else {
|
||||
pushNav({ kind: 'tab', tab, label: TAB_LABELS[tab] });
|
||||
}
|
||||
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
}
|
||||
|
||||
export function goBack(fallbackTab: TabId = 'classes') {
|
||||
const history = readHistory();
|
||||
const current = parseRoute();
|
||||
const currentId = current.classId
|
||||
? entryKey('class', current.tab, current.classId)
|
||||
: entryKey('tab', current.tab);
|
||||
|
||||
const remaining = history.filter((h) => h.id !== currentId);
|
||||
writeHistory(remaining);
|
||||
|
||||
const previous = remaining[0];
|
||||
if (previous) {
|
||||
const url = previous.kind === 'class' && previous.classId
|
||||
? buildUrl(previous.tab, previous.classId)
|
||||
: buildUrl(previous.tab);
|
||||
window.history.pushState({}, '', url);
|
||||
} else {
|
||||
window.history.pushState({}, '', buildUrl(fallbackTab));
|
||||
}
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
}
|
||||
Reference in New Issue
Block a user