Compare commits

3 Commits

Author SHA1 Message Date
b6dfe705f5 add linux ide
All checks were successful
Deploy on Master Change / deploy (push) Successful in 1m37s
2026-07-29 16:27:11 +07:00
42172db9f4 add rescide
All checks were successful
Deploy on Master Change / deploy (push) Successful in 1m43s
2026-07-29 12:32:01 +07:00
3b80550c76 tt (#1)
All checks were successful
Deploy on Master Change / deploy (push) Successful in 1m40s
Reviewed-on: #1
Co-authored-by: Pham Hung <22.phamviethung.12a12@gmail.com>
Co-committed-by: Pham Hung <22.phamviethung.12a12@gmail.com>
2026-07-23 10:16:52 +07:00
22 changed files with 7835 additions and 2841 deletions

View File

@@ -57,6 +57,7 @@
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
@@ -266,29 +267,6 @@
"node": ">=6.9.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
@@ -540,9 +518,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -560,9 +535,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -580,9 +552,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -600,9 +569,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -620,9 +586,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -640,9 +603,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -660,9 +620,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -680,9 +637,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -853,9 +807,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -873,9 +824,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -893,9 +841,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -913,9 +858,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -933,9 +875,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -953,9 +892,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1041,6 +977,7 @@
"integrity": "sha512-+zEk16yGlz1F9STiRr6uG9hmIXb6nprjLczV/htGptYuLoCuxb+itZ03RKCEeOhBpDDd1NU7qF6x1VLMUp62bw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"picomatch": "^4.0.4"
},
@@ -1135,6 +1072,7 @@
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.18.0"
}
@@ -1145,6 +1083,7 @@
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -1243,6 +1182,7 @@
"integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/types": "^7.26.0"
}
@@ -1298,6 +1238,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.38",
"caniuse-lite": "^1.0.30001799",
@@ -1822,9 +1763,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1846,9 +1784,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1870,9 +1805,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1894,9 +1826,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2311,6 +2240,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
"integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -2365,6 +2295,7 @@
"integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@oxc-project/types": "=0.137.0",
"@rolldown/pluginutils": "^1.0.0"
@@ -2648,6 +2579,7 @@
"integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",

View File

@@ -10,6 +10,8 @@ import { SystemTab } from './components/SystemTab';
import { MyAccountTab } from './components/AccountsTab';
import { StudentAffairsTab } from './components/StudentAffairsTab';
import { ApplicationsTab } from './components/ApplicationsTab';
import { RescideTab } from './components/RescideTab';
import { RescideAnnouncePopup } from './components/RescideAnnouncePopup';
import { ChatWidget } from './components/ChatWidget';
import { StaffChatSocket } from './hooks/useStaffChatSocket';
import { ClassWorkspace } from './components/ClassWorkspace';
@@ -28,67 +30,95 @@ import {
import { useAuth } from './auth/AuthContext';
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 viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<rect x="3" y="3" width="7" height="7" rx="1.5" />
<rect x="14" y="3" width="7" height="7" rx="1.5" />
<rect x="3" y="14" width="7" height="7" rx="1.5" />
<rect x="14" y="14" width="7" height="7" rx="1.5" />
</svg>
);
const IconClass = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<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 viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="3.5" />
<path d="M22 21v-2a3.5 3.5 0 0 0-2.5-3.35" />
<path d="M16 3.5a3.5 3.5 0 0 1 0 7" />
</svg>
);
const IconLearning = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<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 IconExam = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" /><line x1="16" y1="17" x2="8" y2="17" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
</svg>
);
const IconSystem = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<circle cx="12" cy="12" r="3" />
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z" />
</svg>
);
const IconStudentAffairs = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z" />
</svg>
);
const IconApplications = () => (
<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="14" y="14" width="7" height="7" rx="1" />
<path d="M3 14h7v7H3z" />
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<rect x="2" y="3" width="20" height="14" rx="2" />
<path d="M8 21h8M12 17v4" />
<path d="M7 8h.01M12 8h.01M17 8h.01M7 12h10" />
</svg>
);
const IconRescide = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<polyline points="16 18 22 12 16 6" />
<polyline points="8 6 2 12 8 18" />
<line x1="14" y1="4" x2="10" y2="20" />
</svg>
);
const IconMyClass = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M12 3 2 9l10 6 10-6-10-6Z" />
<path d="M6 12v5c0 1.7 2.7 3 6 3s6-1.3 6-3v-5" />
</svg>
);
const IconLogout = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
);
const IconMenu = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="3" y1="12" x2="21" y2="12" /><line x1="3" y1="6" x2="21" y2="6" /><line x1="3" y1="18" x2="21" y2="18" />
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
);
@@ -210,122 +240,144 @@ function App() {
</div>
</div>
<nav>
<nav className="sidebar-nav" aria-label="Menu chính">
<ul className="nav-links">
<div className="nav-header">Tổng quan</div>
<li className="nav-section" aria-hidden="true">
<span className="nav-header">Tổng quan</span>
</li>
<li className="nav-item">
<button
type="button"
className={navBtn(route.tab === 'dashboard' && !inWorkspace)}
onClick={() => setActiveTab('dashboard')}
>
<span className="nav-icon"><IconDashboard /></span>
Tổng quan & Tài Liệu
<span className="nav-label">Tổng quan & Tài liệu</span>
</button>
</li>
<div className="nav-header">CSDL liên kết</div>
<li className="nav-section" aria-hidden="true">
<span className="nav-header">CSDL liên kết</span>
</li>
<li className="nav-item">
<button className={navBtn(classesActive)} onClick={() => setActiveTab('classes')}>
<button type="button" className={navBtn(classesActive)} onClick={() => setActiveTab('classes')}>
<span className="nav-icon"><IconClass /></span>
Lớp học
<span className="nav-label">Lớp học</span>
</button>
</li>
<li className="nav-item">
<button
type="button"
className={navBtn(route.tab === 'students' && !inWorkspace)}
onClick={() => setActiveTab('students')}
>
<span className="nav-icon"><IconStudent /></span>
Sinh viên
<span className="nav-label">Sinh viên</span>
</button>
</li>
<div className="nav-header">Quản học tập</div>
<li className="nav-section" aria-hidden="true">
<span className="nav-header">Quản học tập</span>
</li>
<li className="nav-item">
<button className={navBtn(learningActive)} onClick={() => setActiveTab('learning')}>
<button type="button" className={navBtn(learningActive)} onClick={() => setActiveTab('learning')}>
<span className="nav-icon"><IconLearning /></span>
Phòng Học
<span className="nav-label">Phòng học</span>
</button>
</li>
<li className="nav-item">
<button className={navBtn(examsActive)} onClick={() => setActiveTab('exams')}>
<button type="button" className={navBtn(examsActive)} onClick={() => setActiveTab('exams')}>
<span className="nav-icon"><IconExam /></span>
Phòng thi
<span className="nav-label">Phòng thi</span>
</button>
</li>
{myClasses.length > 0 && (
<>
<div className="nav-header">Lớp của tôi</div>
<li className="nav-section" aria-hidden="true">
<span className="nav-header">Lớp của tôi</span>
</li>
{myClasses.map((c) => (
<li key={c.id} className="nav-item">
<button
className={navBtn(route.classId === c.id && route.tab === 'learning')}
type="button"
className={`${navBtn(route.classId === c.id && route.tab === 'learning')} nav-btn--class`}
onClick={() => {
navigate('learning', c.id, c.name, 'class');
setSidebarOpen(false);
}}
title={c.name}
style={{
paddingLeft: '1.75rem',
fontSize: '0.82rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
display: 'block',
width: '100%',
textAlign: 'left'
}}
>
🏫 {c.code || c.name}
<span className="nav-icon"><IconMyClass /></span>
<span className="nav-label">{c.code || c.name}</span>
</button>
</li>
))}
</>
)}
<div className="nav-header">Công tác sinh viên</div>
<li className="nav-section" aria-hidden="true">
<span className="nav-header">Công tác sinh viên</span>
</li>
<li className="nav-item">
<button
type="button"
className={navBtn(route.tab === 'student-affairs' && !inWorkspace)}
onClick={() => setActiveTab('student-affairs')}
>
<span className="nav-icon"><IconStudentAffairs /></span>
Công Tác Sinh Viên
<span className="nav-label">Công tác sinh viên</span>
</button>
</li>
<li className="nav-item">
<button
type="button"
className={navBtn(route.tab === 'applications' && !inWorkspace)}
onClick={() => setActiveTab('applications')}
>
<span className="nav-icon"><IconApplications /></span>
ng Dụng
<span className="nav-label">ng dụng</span>
</button>
</li>
<li className="nav-item">
<button
type="button"
className={navBtn(route.tab === 'rescide' && !inWorkspace)}
onClick={() => setActiveTab('rescide')}
>
<span className="nav-icon"><IconRescide /></span>
<span className="nav-label">RESCIDE IDE</span>
</button>
</li>
<div className="nav-header">Hệ thống</div>
<li className="nav-section" aria-hidden="true">
<span className="nav-header">Hệ thống</span>
</li>
<li className="nav-item">
<button
type="button"
className={navBtn(systemActive)}
onClick={() => goSystem('organization')}
>
<span className="nav-icon"><IconSystem /></span>
Quản hệ thống
<span className="nav-label">Quản hệ thống</span>
</button>
</li>
<li className="nav-item nav-item--sub">
<ul className="nav-sub">
{SYSTEM_NAV.map(({ section }) => (
<li key={section} className="nav-item">
<li key={section}>
<button
type="button"
className={navBtn(systemActive && route.systemSection === section)}
onClick={() => goSystem(section)}
>
{SYSTEM_SECTION_LABELS[section]}
<span className="nav-label">{SYSTEM_SECTION_LABELS[section]}</span>
</button>
</li>
))}
</ul>
</li>
</ul>
</nav>
@@ -336,12 +388,24 @@ function App() {
onClick={() => setActiveTab('profile')}
title="Tài khoản của tôi"
>
<span className="sidebar-user-avatar" aria-hidden>
{(staff?.fullName?.trim() || staff?.email || '?')
.split(/\s+/)
.filter(Boolean)
.slice(-2)
.map((w) => w[0]?.toUpperCase() || '')
.join('')
.slice(0, 2) || '?'}
</span>
<span className="sidebar-user-meta">
<span className="sidebar-user-name">
{staff?.fullName?.trim() || staff?.email?.split('@')[0] || 'Tài khoản'}
</span>
<span className="sidebar-user-email">{staff?.email}</span>
</span>
</button>
<button type="button" className="link-btn" onClick={logout}>
<button type="button" className="sidebar-logout" onClick={logout}>
<IconLogout />
Đăng xuất
</button>
</div>
@@ -352,7 +416,7 @@ function App() {
onClick={toggleSidebarCollapse}
title={sidebarCollapsed ? 'Mở rộng menu' : 'Thu gọn menu'}
>
<svg viewBox="0 0 10 18" width="10" height="18" fill="currentColor">
<svg viewBox="0 0 10 18" width="10" height="18" fill="currentColor" aria-hidden>
{sidebarCollapsed
? <path d="M1 1 L9 9 L1 17" />
: <path d="M9 1 L1 9 L9 17" />
@@ -385,10 +449,12 @@ function App() {
{route.tab === 'profile' && <MyAccountTab />}
{route.tab === 'student-affairs' && <StudentAffairsTab />}
{route.tab === 'applications' && <ApplicationsTab />}
{route.tab === 'rescide' && <RescideTab />}
</>
)}
</div>
</main>
<RescideAnnouncePopup disabled={inWorkspace} />
<StaffChatSocket />
<ChatWidget />
</>

View File

@@ -9,6 +9,48 @@ interface AppPoolModalProps {
allowedApps: string;
}
type IconProps = { size?: number };
const IconBox = ({ size = 20 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
<path d="M3.27 6.96 12 12.01l8.73-5.05M12 22.08V12" />
</svg>
);
const IconSearch = ({ size = 15 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<circle cx="11" cy="11" r="7" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
);
const IconRefresh = ({ size = 15 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M21 12a9 9 0 1 1-2.6-6.3" />
<polyline points="21 3 21 9 15 9" />
</svg>
);
const IconPlus = ({ size = 13 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M12 5v14M5 12h14" />
</svg>
);
const IconCheck = ({ size = 12 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M20 6 9 17l-5-5" />
</svg>
);
const IconInbox = ({ size = 32 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<polyline points="22 12 16 12 14 15 10 15 8 12 2 12" />
<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
</svg>
);
const formatWhen = (iso: string) => {
if (!iso) return '—';
const d = new Date(iso);
@@ -80,18 +122,29 @@ export const AppPoolModal: React.FC<AppPoolModalProps> = ({
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>
<div className="modal-container app-pool-modal app-picker-modal" onClick={e => e.stopPropagation()}>
<div className="modal-header app-picker-header">
<div className="app-picker-header-text">
<h2 className="modal-title app-picker-title">
<span className="app-picker-title-icon" aria-hidden>
<IconBox />
</span>
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.
Toàn hệ thống app bị chặn từ mọi lớp. Chọn đ thêm vào whitelist hiện tại.
</p>
</div>
<button type="button" className="btn btn-secondary" onClick={onClose}>Đóng</button>
<button type="button" className="btn btn-secondary app-picker-close" onClick={onClose}>
Đóng
</button>
</div>
<div className="app-pool-toolbar">
<div className="app-picker-search-wrap">
<span className="app-picker-search-icon" aria-hidden>
<IconSearch />
</span>
<input
type="search"
className="app-pool-search"
@@ -100,24 +153,32 @@ export const AppPoolModal: React.FC<AppPoolModalProps> = ({
onChange={e => setSearch(e.target.value)}
autoFocus
/>
</div>
<button
type="button"
className="btn btn-secondary"
className="btn btn-secondary app-picker-refresh"
onClick={() => loadPool(debouncedQ)}
disabled={loading}
>
<IconRefresh />
{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>
<div className="app-picker-state app-picker-state--error">{error}</div>
) : loading && items.length === 0 ? (
<div className="app-pool-status">Đang tải...</div>
<div className="app-picker-state">
<div className="sync-spinner" style={{ width: 28, height: 28 }} />
<p>Đang tải kho ng dụng...</p>
</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 className="app-picker-state">
<IconInbox />
<p>
{debouncedQ ? `Không tìm thấy “${debouncedQ}` : 'Chưa có app nào trong kho.'}
</p>
</div>
) : (
<table className="data-table app-pool-table">
@@ -128,7 +189,7 @@ export const AppPoolModal: React.FC<AppPoolModalProps> = ({
<th>Tiêu đ</th>
<th>Lần chặn</th>
<th>Gần nhất</th>
<th></th>
<th style={{ textAlign: 'right' }}>Thao tác</th>
</tr>
</thead>
<tbody>
@@ -136,17 +197,33 @@ export const AppPoolModal: React.FC<AppPoolModalProps> = ({
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>
<code className="app-pool-kw">{app.keyword}</code>
</td>
<td className="app-pool-muted" title={app.processName}>
{app.processName || '—'}
</td>
<td className="app-pool-muted app-pool-title" title={app.windowTitle}>
{app.windowTitle || '—'}
</td>
<td>
<span className="app-pool-hit">{app.hitCount}×</span>
</td>
<td className="app-pool-muted app-pool-when">{formatWhen(app.lastSeenAt)}</td>
<td style={{ textAlign: 'right' }}>
{added ? (
<span className="app-pool-added-tag">Đã </span>
<span className="app-pool-added-tag">
<IconCheck />
Đã
</span>
) : (
<button type="button" className="btn btn-primary app-pool-add-btn" onClick={() => handleSelect(app.keyword)}>
+ Thêm
<button
type="button"
className="btn btn-primary app-pool-add-btn"
onClick={() => handleSelect(app.keyword)}
>
<IconPlus />
Thêm
</button>
)}
</td>
@@ -159,7 +236,9 @@ export const AppPoolModal: React.FC<AppPoolModalProps> = ({
</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)
Hiển thị <strong>{items.length}</strong> kết quả
{debouncedQ ? ` cho “${debouncedQ}` : ''}
{' '}(tối đa 80 mỗi lần tải)
</div>
</div>
</div>

View File

@@ -9,6 +9,47 @@ interface AppTemplatePickerModalProps {
allowedApps: string;
}
type IconProps = { size?: number };
const IconLayers = ({ size = 20 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="m12 2 9 4.5-9 4.5L3 6.5 12 2z" />
<path d="m3 12 9 4.5 9-4.5" />
<path d="m3 17.5 9 4.5 9-4.5" />
</svg>
);
const IconSearch = ({ size = 15 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<circle cx="11" cy="11" r="7" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
);
const IconRefresh = ({ size = 15 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M21 12a9 9 0 1 1-2.6-6.3" />
<polyline points="21 3 21 9 15 9" />
</svg>
);
const IconCheck = ({ size = 14 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M20 6 9 17l-5-5" />
</svg>
);
const IconInbox = ({ size = 32 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<polyline points="22 12 16 12 14 15 10 15 8 12 2 12" />
<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
</svg>
);
function keywordList(csv: string): string[] {
return csv.split(',').map((s) => s.trim()).filter(Boolean);
}
export const AppTemplatePickerModal: React.FC<AppTemplatePickerModalProps> = ({
open,
onClose,
@@ -60,20 +101,29 @@ export const AppTemplatePickerModal: React.FC<AppTemplatePickerModalProps> = ({
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 }}>Chọn khung ng dụng</h2>
<div className="modal-container app-pool-modal app-picker-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header app-picker-header">
<div className="app-picker-header-text">
<h2 className="modal-title app-picker-title">
<span className="app-picker-title-icon" aria-hidden>
<IconLayers />
</span>
Chọn khung ng dụng
</h2>
<p className="app-pool-modal-sub">
Ghép bộ keyword đã lưu vào danh sách ng dụng đưc phép. Quản khung tại Hệ thống Khung ng dụng.
Ghép bộ keyword đã lưu vào whitelist. Quản khung tại Hệ thống Khung ng dụng.
</p>
</div>
<button type="button" className="btn btn-secondary" onClick={onClose}>
<button type="button" className="btn btn-secondary app-picker-close" onClick={onClose}>
Đóng
</button>
</div>
<div className="app-pool-toolbar">
<div className="app-picker-search-wrap">
<span className="app-picker-search-icon" aria-hidden>
<IconSearch />
</span>
<input
type="search"
className="app-pool-search"
@@ -82,30 +132,58 @@ export const AppTemplatePickerModal: React.FC<AppTemplatePickerModalProps> = ({
onChange={(e) => setSearch(e.target.value)}
autoFocus
/>
<button type="button" className="btn btn-secondary" onClick={load} disabled={loading}>
</div>
<button
type="button"
className="btn btn-secondary app-picker-refresh"
onClick={load}
disabled={loading}
>
<IconRefresh />
{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>
<div className="app-picker-state app-picker-state--error">{error}</div>
) : loading && items.length === 0 ? (
<div className="app-pool-status">Đang tải...</div>
<div className="app-picker-state">
<div className="sync-spinner" style={{ width: 28, height: 28 }} />
<p>Đang tải khung ng dụng...</p>
</div>
) : filtered.length === 0 ? (
<div className="app-pool-status">
{q ? `Không tìm thấy "${search}"` : 'Chưa có khung nào. Tạo tại Hệ thống → Khung ứng dụng.'}
<div className="app-picker-state">
<IconInbox />
<p>
{q
? `Không tìm thấy “${search}`
: 'Chưa có khung nào. Tạo tại Hệ thống → Khung ứng dụng.'}
</p>
</div>
) : (
<div className="template-picker-list">
{filtered.map((tpl) => (
<div key={tpl.id} className="template-picker-card">
{filtered.map((tpl) => {
const kws = keywordList(tpl.keywords);
const count = countKeywords(tpl.keywords);
return (
<article key={tpl.id} className="template-picker-card">
<div className="template-picker-card-main">
<div className="template-picker-card-head">
<strong>{tpl.name}</strong>
<span className="template-picker-count">{countKeywords(tpl.keywords)} keyword</span>
<strong className="template-picker-name">{tpl.name}</strong>
<span className="template-picker-count">{count} keyword</span>
</div>
{tpl.description ? (
<p className="template-picker-desc">{tpl.description}</p>
) : null}
<div className="template-picker-chips" title={tpl.keywords}>
{kws.map((kw) => (
<span key={kw} className="template-picker-chip">
{kw}
</span>
))}
</div>
</div>
{tpl.description && <p className="template-picker-desc">{tpl.description}</p>}
<code className="template-picker-kw">{tpl.keywords}</code>
<button
type="button"
className="btn btn-primary btn-sm template-picker-apply"
@@ -114,16 +192,19 @@ export const AppTemplatePickerModal: React.FC<AppTemplatePickerModalProps> = ({
onClose();
}}
>
Áp dụng khung
<IconCheck size={13} />
Áp dụng
</button>
</div>
))}
</article>
);
})}
</div>
)}
</div>
<div className="app-pool-footer">
Đang {countKeywords(allowedApps)} keyword trong whitelist hiện tại
Whitelist hiện tại: <strong>{countKeywords(allowedApps)}</strong> keyword
{filtered.length > 0 ? ` · ${filtered.length} khung` : ''}
</div>
</div>
</div>

View File

@@ -14,6 +14,98 @@ import {
type LeaveRequestItem,
} from '../api';
/* ─── Icon components ─────────────────────────────────────────────────────── */
type IconProps = { size?: number; className?: string };
const IconClipboard = ({ size = 18 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<rect x="8" y="2" width="8" height="4" rx="1" />
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
</svg>
);
const IconRefresh = ({ size = 15 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M21 12a9 9 0 1 1-2.6-6.3" />
<polyline points="21 3 21 9 15 9" />
</svg>
);
const IconChevronDown = ({ size = 15 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="m6 9 6 6 6-6" />
</svg>
);
const IconChevronUp = ({ size = 15 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="m18 15-6-6-6 6" />
</svg>
);
const IconMail = ({ size = 15 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<rect x="2" y="4" width="20" height="16" rx="2" />
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
</svg>
);
const IconUpload = ({ size = 15 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
);
const IconClose = ({ size = 14 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
);
const IconInfo = ({ size = 15 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<circle cx="12" cy="12" r="9" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
);
const IconCheck = ({ size = 14 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M20 6 9 17l-5-5" />
</svg>
);
const IconXMark = ({ size = 14 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
);
const IconImage = ({ size = 13 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
);
const IconSync = ({ size = 15 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M4 12c0-4.42 3.58-8 8-8 2.21 0 4.21.9 5.66 2.34" />
<polyline points="14 8 20 8 20 2" />
<path d="M20 12c0 4.42-3.58 8-8 8-2.21 0-4.21-.9-5.66-2.34" />
<polyline points="10 16 4 16 4 22" />
</svg>
);
/* ─── Helpers ────────────────────────────────────────────────────────────── */
interface AttendancePanelProps {
classId: number;
onClose?: () => void;
@@ -29,6 +121,8 @@ function formatQldtTime(iso?: string): string {
});
}
/* ─── Component ─────────────────────────────────────────────────────────── */
export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId, onClose }) => {
const today = new Date().toISOString().slice(0, 10);
const [date, setDate] = useState(today);
@@ -47,11 +141,7 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId, onClo
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
const scrollTop = e.currentTarget.scrollTop;
if (scrollTop > 20) {
setIsCollapsed(true);
} else if (scrollTop <= 5) {
setIsCollapsed(false);
}
setIsCollapsed(scrollTop > 20);
}, []);
const loadShifts = useCallback(async () => {
@@ -61,9 +151,7 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId, onClo
if (res.data?.length && !res.data.find((s: any) => s.period === period)) {
setPeriod(res.data[0].period || 1);
}
} catch {
setShifts([]);
}
} catch { setShifts([]); }
}, [classId, date, period]);
const loadAttendance = useCallback(async (isBackground: boolean | any = false) => {
@@ -73,7 +161,7 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId, onClo
const res = await apiFetchAttendance(classId, date, period);
setRows(res.data || []);
setShiftInfo(res.shift || null);
if (!isBg) setSelectedStudentRkIds([]); // Clear selection when data changes
if (!isBg) setSelectedStudentRkIds([]);
} catch (e: any) {
alert(e.message || 'Không tải được điểm danh');
} finally {
@@ -81,26 +169,16 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId, onClo
}
}, [classId, date, period]);
const currentShift = useMemo(() => {
return shifts.find(s => s.period === period);
}, [shifts, period]);
const currentShift = useMemo(() => shifts.find(s => s.period === period), [shifts, period]);
const courseId = currentShift?.courseId;
const loadLeaveRequests = useCallback(async () => {
if (!courseId) {
setLeaveRequests([]);
return;
}
if (!courseId) { setLeaveRequests([]); return; }
try {
setLoadingLeave(true);
const res = await apiFetchLeaveRequests(classId, courseId, date);
setLeaveRequests(res || []);
} catch {
setLeaveRequests([]);
} finally {
setLoadingLeave(false);
}
} catch { setLeaveRequests([]); } finally { setLoadingLeave(false); }
}, [classId, courseId, date]);
useEffect(() => { loadShifts(); }, [loadShifts]);
@@ -109,12 +187,7 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId, onClo
const handleUpdateLeaveStatus = async (leaveId: number, status: string, studentRkId: number) => {
try {
await apiUpdateLeaveStatus(classId, leaveId, {
status,
studentRkId,
date,
period,
});
await apiUpdateLeaveStatus(classId, leaveId, { status, studentRkId, date, period });
await loadAttendance();
await loadLeaveRequests();
} catch (e: any) {
@@ -126,87 +199,61 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId, onClo
const q = searchQuery.trim().toLowerCase();
if (!q) return rows;
return rows.filter(row => {
const haystack = [row.fullName, row.studentCode, row.email, row.statusLabel]
.filter(Boolean)
.join(' ')
.toLowerCase();
const haystack = [row.fullName, row.studentCode, row.email, row.statusLabel].filter(Boolean).join(' ').toLowerCase();
return haystack.includes(q);
});
}, [rows, searchQuery]);
const statusCounts = useMemo(() => {
const counts: Record<number, number> = { 0: 0, 1: 0, 2: 0, 3: 0, 4: 0 };
for (const row of rows) {
if (counts[row.status] !== undefined) counts[row.status]++;
}
for (const row of rows) { if (counts[row.status] !== undefined) counts[row.status]++; }
return counts;
}, [rows]);
const toggleStudentSelection = useCallback((studentRkId: number) => {
const toggleStudentSelection = (studentRkId: number) => {
setSelectedStudentRkIds(prev =>
prev.includes(studentRkId) ? prev.filter(id => id !== studentRkId) : [...prev, studentRkId]
);
}, []);
};
const handleStatusChange = async (studentRkId: number, status: number) => {
// Optimistic state update so the UI reacts instantly
setRows(prevRows =>
prevRows.map(row => {
if (row.studentRkId === studentRkId) {
const matchedOpt = ATTENDANCE_STATUS_OPTIONS.find(o => o.value === status);
return {
...row,
status,
statusLabel: matchedOpt ? matchedOpt.label : row.statusLabel,
statusEditedByTeacher: true,
};
return { ...row, status, statusLabel: matchedOpt ? matchedOpt.label : row.statusLabel, statusEditedByTeacher: true };
}
return row;
})
);
try {
await apiUpdateAttendanceStatus(classId, { date, period, studentRkId, status });
await loadAttendance(true);
} catch (e: any) {
alert(e.message || 'Cập nhật trạng thái thất bại');
await loadAttendance(); // Rollback on failure
await loadAttendance();
}
};
const handleBulkStatusChange = async (status: number) => {
if (selectedStudentRkIds.length === 0) return;
// Optimistic state update for selected students
setRows(prevRows =>
prevRows.map(row => {
if (selectedStudentRkIds.includes(row.studentRkId)) {
const matchedOpt = ATTENDANCE_STATUS_OPTIONS.find(o => o.value === status);
return {
...row,
status,
statusLabel: matchedOpt ? matchedOpt.label : row.statusLabel,
statusEditedByTeacher: true,
};
return { ...row, status, statusLabel: matchedOpt ? matchedOpt.label : row.statusLabel, statusEditedByTeacher: true };
}
return row;
})
);
const idsToUpdate = [...selectedStudentRkIds];
setSelectedStudentRkIds([]);
try {
await apiUpdateAttendanceBulkStatus(classId, {
date,
period,
studentRkIds: idsToUpdate,
status,
});
await apiUpdateAttendanceBulkStatus(classId, { date, period, studentRkIds: idsToUpdate, status });
await loadAttendance(true);
} catch (e: any) {
alert(e.message || 'Cập nhật hàng loạt thất bại');
await loadAttendance(); // Rollback
await loadAttendance();
}
};
@@ -223,202 +270,257 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId, onClo
await loadAttendance();
} catch (e: any) {
alert(e.message || 'Đẩy QLĐT thất bại');
} finally {
setPushing(false);
}
} finally { setPushing(false); }
};
const qldtSynced = Boolean(shiftInfo?.pushedToQldtAt);
const qldtDirty = Boolean(shiftInfo?.qldtDirty);
return (
<div className="modal-overlay attendance-modal-overlay">
<div className="modal-container attendance-modal-container">
<div className="modal-header attendance-modal-header" style={{ padding: '0.75rem 1.25rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<h3 className="modal-title" style={{ margin: 0, fontSize: '1.1rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
📋 Quản điểm danh ca học
</h3>
</div>
<button
type="button"
className="modal-close-btn"
style={{ fontSize: '1.5rem', border: 'none', background: 'none', color: 'var(--text-muted)', cursor: 'pointer', padding: '0 0.5rem' }}
onClick={onClose}
>
&times;
</button>
</div>
const hasPendingLeave = leaveRequests.some(r => r.status === 'Đang chờ');
<div className="modal-body attendance-modal-body" style={{ padding: '1rem', display: 'flex', flexDirection: 'column', gap: '0.75rem', flex: 1, minHeight: 0, overflowY: 'auto' }}>
<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>
<label className="attendance-field attendance-field--grow">
<span>Tìm sinh viên</span>
<input
type="search"
className="app-pool-search attendance-search"
placeholder="Mã SV, tên, email..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
/>
</label>
return (
<div className="ap-panel">
<style>{`
.ap-panel { display: flex; flex-direction: column; height: 100%; gap: 0.65rem; }
/* Header */
.ap-header { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem; }
.ap-header-title { display: flex; align-items: center; gap: 0.5rem; margin: 0; font-size: 0.95rem; font-weight: 700; color: var(--text-primary); }
.ap-header-title svg { color: var(--accent); flex-shrink: 0; }
.ap-header-actions { display: flex; gap: 0.3rem; align-items: center; flex-wrap: wrap; }
/* Buttons */
.ap-btn { display: inline-flex; align-items: center; gap: 0.3rem; padding: 0.25rem 0.6rem; font-size: 0.75rem; font-weight: 500; border-radius: var(--radius-sm); border: 1px solid var(--border-color); background: var(--bg-card); color: var(--text-primary); cursor: pointer; transition: background 0.15s, border-color 0.15s; line-height: 1.4; white-space: nowrap; }
.ap-btn:hover:not(:disabled) { background: var(--bg-hover); border-color: var(--border-hover); }
.ap-btn:disabled { opacity: 0.55; cursor: not-allowed; }
.ap-btn--primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.ap-btn--primary:hover:not(:disabled) { filter: brightness(1.08); background: var(--accent); }
.ap-btn--close { padding: 0.22rem 0.45rem; }
.ap-btn--leave-badge { background: var(--accent); color: #fff; font-size: 0.62rem; font-weight: 700; padding: 0.05rem 0.3rem; border-radius: 10px; line-height: 1.4; }
.ap-btn--leave-badge.muted { background: var(--text-secondary); }
/* Collapsible toolbar area */
.ap-collapsible { display: flex; flex-direction: column; gap: 0.5rem; overflow: hidden; transition: max-height 0.3s ease; }
.ap-collapsible.collapsed { max-height: 0 !important; }
/* Toolbar */
.ap-toolbar { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: flex-end; }
.ap-field { display: flex; flex-direction: column; gap: 0.18rem; font-size: 0.71rem; font-weight: 600; color: var(--text-muted); }
.ap-field--grow { flex: 1; }
.ap-input { padding: 0.3rem 0.5rem; font-size: 0.78rem; border: 1px solid var(--border-color); border-radius: var(--radius-sm); background: var(--bg-card); color: var(--text-primary); }
.ap-input:focus { outline: none; border-color: var(--accent); }
/* QLĐT sync banner */
.ap-qldt-banner { padding: 0.45rem 0.7rem; border-radius: var(--radius-sm); background: var(--bg-subtle); border: 1px solid var(--border-color); display: flex; flex-direction: column; gap: 0.1rem; font-size: 0.77rem; }
.ap-qldt-banner--synced { background: rgba(46,125,50,0.06); border-color: rgba(46,125,50,0.3); color: #2e7d32; }
.ap-qldt-banner--stale { background: rgba(180,83,9,0.06); border-color: rgba(180,83,9,0.35); color: #92400e; }
/* Shift info strip */
.ap-shift-info { display: flex; flex-wrap: wrap; gap: 0.5rem; font-size: 0.77rem; color: var(--text-secondary); padding: 0.3rem 0.6rem; background: var(--bg-subtle); border-radius: var(--radius-sm); border: 1px solid var(--border-color); }
/* Status chips */
.ap-chips { display: flex; flex-wrap: wrap; gap: 0.22rem; align-items: center; }
.ap-chip { border: 1px solid transparent; border-radius: 99px; padding: 0.14rem 0.4rem; font-size: 0.68rem; font-weight: 700; cursor: default; background: transparent; display: inline-flex; align-items: center; gap: 0.18rem; }
.ap-chip-count { min-width: 1rem; text-align: center; padding: 0.04rem 0.18rem; border-radius: 99px; background: rgba(0,0,0,0.12); }
.ap-chips-total { margin-left: auto; font-size: 0.71rem; color: var(--text-muted); font-weight: 600; }
/* Notice banner */
.ap-notice { padding: 0.55rem 0.75rem; border: 1px solid var(--border-color); border-left: 3px solid var(--accent); border-radius: var(--radius-sm); background: var(--bg-subtle); font-size: 0.75rem; color: var(--text-secondary); line-height: 1.5; display: flex; align-items: flex-start; gap: 0.45rem; }
.ap-notice svg { color: var(--accent); flex-shrink: 0; margin-top: 0.05rem; }
.ap-notice--amber { border-left-color: #d97706; background: rgba(253,230,138,0.18); color: #78350f; }
.ap-notice--amber svg { color: #d97706; }
/* Bulk actions bar */
.ap-bulk { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0.75rem; background: var(--bg-card); border: 1px solid var(--border-color); border-radius: var(--radius-sm); flex-wrap: wrap; }
.ap-bulk-title { font-weight: 600; font-size: 0.82rem; }
.ap-bulk-btns { display: flex; gap: 0.2rem; flex-wrap: wrap; }
/* Table */
.ap-table-scroll { flex: 1; overflow: auto; border: 1px solid var(--border-color); border-radius: var(--radius-sm); }
.ap-table { width: 100%; border-collapse: collapse; }
.ap-table thead th { padding: 0.5rem 0.8rem; background: var(--bg-subtle); font-weight: 700; font-size: 0.68rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.04em; border-bottom: 1px solid var(--border-color); white-space: nowrap; }
.ap-table thead th:first-child { width: 40px; text-align: center; padding: 0.5rem 0.5rem; }
.ap-table tbody tr { border-bottom: 1px solid var(--border-light); transition: background 0.1s; }
.ap-table tbody tr:last-child { border-bottom: none; }
.ap-table tbody td { padding: 0.42rem 0.8rem; font-size: 0.82rem; vertical-align: middle; }
.ap-table tbody td:first-child { text-align: center; padding: 0.42rem 0.5rem; }
/* QLĐT inline tags */
.ap-tag { font-size: 0.62rem; font-weight: 700; padding: 0.1rem 0.3rem; border-radius: 3px; }
.ap-tag--ok { background: rgba(46,125,50,0.1); color: #2e7d32; border: 1px solid rgba(46,125,50,0.3); }
.ap-tag--pending { background: var(--bg-subtle); color: var(--text-muted); border: 1px solid var(--border-color); }
.ap-tag--locked { background: rgba(21,101,192,0.08); color: #1565c0; border: 1px solid rgba(21,101,192,0.25); }
/* Leave modal notice */
.ap-leave-notice { padding: 0.55rem 0.75rem; border: 1px solid #bae6fd; border-left: 3px solid #0ea5e9; border-radius: var(--radius-sm); background: rgba(224,242,254,0.5); font-size: 0.76rem; color: #0369a1; line-height: 1.5; display: flex; align-items: flex-start; gap: 0.45rem; }
.ap-leave-notice svg { color: #0ea5e9; flex-shrink: 0; margin-top: 0.05rem; }
.ap-leave-card { display: flex; flex-direction: column; gap: 8px; padding: 0.75rem; border: 1px solid var(--border-color); border-radius: var(--radius-sm); background: var(--bg-card); }
.ap-leave-card-reason { font-size: 0.77rem; color: var(--text-primary); background: var(--bg-subtle); padding: 0.4rem 0.65rem; border-radius: var(--radius-sm); border-left: 3px solid #cbd5e1; line-height: 1.45; }
.ap-leave-status { font-size: 0.69rem; font-weight: 600; padding: 0.1rem 0.4rem; border-radius: 4px; }
.ap-leave-status--pending { background: #fef3c7; color: #b45309; }
.ap-leave-status--approved { background: #d1fae5; color: #065f46; }
.ap-leave-status--rejected { background: #fee2e2; color: #991b1b; }
/* Empty & loading states */
.ap-empty { min-height: 200px; display: flex; align-items: center; justify-content: center; color: var(--text-muted); font-size: 0.85rem; }
`}</style>
{/* ── Header ── */}
<div className="ap-header">
<h3 className="ap-header-title">
<IconClipboard size={18} />
Điểm danh ca học
</h3>
<div className="ap-header-actions">
<button
type="button"
className="btn btn-secondary"
className="ap-btn"
onClick={() => setIsCollapsed(!isCollapsed)}
title={isCollapsed ? 'Hiện đầy đủ thông tin chi tiết' : 'Ẩn bớt thông tin chi tiết để xem bảng to hơn'}
>
{isCollapsed ? '📂 Chi tiết' : '📁 Thu gọn'}
{isCollapsed ? <><IconChevronDown size={14} /> Chi tiết</> : <><IconChevronUp size={14} /> Thu gọn</>}
</button>
<button type="button" className="btn btn-secondary" onClick={() => loadAttendance(false)} disabled={loading}>
<button
type="button"
className="ap-btn"
onClick={() => loadAttendance(false)}
disabled={loading}
>
<IconRefresh size={14} />
Tải lại
</button>
{courseId && (
<button
type="button"
className="btn btn-secondary"
className="ap-btn"
onClick={() => setShowLeaveModal(true)}
style={{ display: 'flex', alignItems: 'center', gap: '6px' }}
>
Đơn phép
<IconMail size={14} />
Đơn phép
{leaveRequests.length > 0 && (
<span
className="badge"
style={{
background: leaveRequests.some(r => r.status === 'Đang chờ') ? 'var(--accent)' : 'var(--text-secondary)',
color: '#fff',
fontSize: '0.72rem',
padding: '0.1rem 0.35rem',
borderRadius: '10px',
fontWeight: 700
}}
>
<span className={`ap-btn--leave-badge${hasPendingLeave ? '' : ' muted'}`}>
{leaveRequests.length}
</span>
)}
</button>
)}
<button type="button" className="btn btn-primary" onClick={handlePushQLDT} disabled={pushing || rows.length === 0}>
<button
type="button"
className="ap-btn ap-btn--primary"
onClick={handlePushQLDT}
disabled={pushing || rows.length === 0}
>
<IconUpload size={14} />
{pushing ? 'Đang tải...' : 'Đẩy QLĐT'}
</button>
</div>
<div className={`attendance-collapsible-wrapper ${isCollapsed ? 'collapsed' : ''}`}>
<div
className={`attendance-qldt-banner${
qldtSynced ? (qldtDirty ? ' attendance-qldt-banner--stale' : ' attendance-qldt-banner--synced') : ''
}`}
>
{qldtSynced ? (
qldtDirty ? (
<>
<strong>Đã lưu QLĐT thay đi mới</strong>
<span>
Lần đy: {formatQldtTime(shiftInfo?.pushedToQldtAt)} · Cần đy lại sau khi chỉnh sửa
</span>
</>
) : (
<>
<strong>Đã lưu QLĐT</strong>
<span>Lần đy gần nhất: {formatQldtTime(shiftInfo?.pushedToQldtAt)}</span>
</>
)
) : (
<>
<strong>Chưa đy lên QLĐT</strong>
<span>Ca {period} ngày {date} bấm &quot;Đy QLĐT&quot; sau khi kiểm tra trạng thái</span>
</>
{onClose && (
<button type="button" className="ap-btn ap-btn--close" onClick={onClose} title="Đóng">
<IconClose size={14} />
</button>
)}
</div>
</div>
{/* ── Collapsible toolbar area ── */}
<div className={`ap-collapsible${isCollapsed ? ' collapsed' : ''}`}>
{/* Toolbar: date / shift / search */}
<div className="ap-toolbar">
<label className="ap-field">
<span>Ngày</span>
<input type="date" className="ap-input search-input" value={date} onChange={e => setDate(e.target.value)} />
</label>
<label className="ap-field">
<span>Ca học</span>
<select className="ap-input 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>
<label className="ap-field ap-field--grow">
<span>Tìm sinh viên</span>
<input type="search" className="ap-input app-pool-search attendance-search" placeholder="Mã SV, tên, email..." value={searchQuery} onChange={e => setSearchQuery(e.target.value)} />
</label>
</div>
{/* QLĐT sync status banner */}
<div className={`ap-qldt-banner${qldtSynced ? (qldtDirty ? ' ap-qldt-banner--stale' : ' ap-qldt-banner--synced') : ''}`}>
{qldtSynced
? qldtDirty
? <><strong>Đã lưu QLĐT thay đi mới</strong><span>Lần đy: {formatQldtTime(shiftInfo?.pushedToQldtAt)} · Cần đy lại</span></>
: <><strong>Đã lưu QLĐT</strong><span>Lần đy: {formatQldtTime(shiftInfo?.pushedToQldtAt)}</span></>
: <><strong>Chưa đy lên QLĐT</strong><span>Ca {period} ngày {date} bấm "Đẩy QLĐT" sau khi kiểm tra</span></>
}
</div>
{/* Shift info strip */}
{currentShift && (
<div className="attendance-shift-info">
<div className="ap-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>}
{!currentShift.isActive && <span className="badge badge-muted" style={{ fontSize: '0.65rem' }}>Ca tắt</span>}
</div>
)}
<div className="attendance-status-summary">
{/* Status summary chips */}
<div className="ap-chips">
{ATTENDANCE_STATUS_OPTIONS.map(opt => (
<button
key={opt.value}
type="button"
className={`attendance-summary-chip ${attendanceStatusClass(opt.value)}`}
onClick={() => setSearchQuery('')}
title={`${opt.label}: ${statusCounts[opt.value] ?? 0} sinh viên`}
>
<span className="attendance-summary-chip-label">{opt.short}</span>
<span className="attendance-summary-chip-count">{statusCounts[opt.value] ?? 0}</span>
<button key={opt.value} type="button" className={`ap-chip attendance-summary-chip ${attendanceStatusClass(opt.value)}`} onClick={() => setSearchQuery('')}>
<span>{opt.short}</span>
<span className="ap-chip-count">{statusCounts[opt.value] ?? 0}</span>
</button>
))}
<span className="attendance-summary-total">
{filteredRows.length}/{rows.length} SV
{searchQuery.trim() ? ' (đã lọc)' : ''}
</span>
<span className="ap-chips-total">{filteredRows.length}/{rows.length} SV{searchQuery.trim() ? ' (đã lọc)' : ''}</span>
</div>
<p className="schedule-hint" style={{ margin: '0 0 4px 0' }}>
<p className="schedule-hint" style={{ margin: '0 0 2px 0', fontSize: '0.71rem', color: 'var(--text-muted)' }}>
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 style={{ padding: '0.65rem 0.85rem', background: '#fef3c7', border: '1px solid #fcd34d', borderRadius: '4px', color: '#92400e', fontSize: '0.82rem', display: 'flex', alignItems: 'center', gap: '0.35rem', margin: '4px 0 4px 0', lineHeight: '1.4' }}>
💡 <strong>Lưu ý:</strong> Hệ thống hiện tại chỉ ghi nhận điểm danh học tập nội bộ <strong>KHÔNG tự đng lưu lên QLĐT</strong>. Thầy vui lòng kiểm tra kỹ trạng thái của sinh viên, sau đó bấm nút <strong>Đy QLĐT</strong> góc phải bên trên đ đng bộ điểm danh chính thức.
{/* Notice banner — replaces yellow emoji block */}
<div className="ap-notice ap-notice--amber">
<IconInfo size={15} />
<span>
<strong>Lưu ý:</strong> Hệ thống hiện tại chỉ ghi nhận điểm danh học tập nội bộ {' '}
<strong>KHÔNG tự đng lưu lên QLĐT</strong>. Thầy vui lòng kiểm tra kỹ trạng thái của sinh viên,
sau đó bấm nút <strong>Đy QLĐT</strong> góc phải bên trên đ đng bộ điểm danh chính thức.
</span>
</div>
</div>
{/* Leave Requests Approval Modal */}
{/* ── Leave Requests Modal ── */}
{showLeaveModal && courseId && (
<div className="modal-overlay" style={{ zIndex: 200 }} onClick={() => setShowLeaveModal(false)}>
<div className="modal-container" style={{ maxWidth: '680px', width: '95%', display: 'flex', flexDirection: 'column' }} onClick={e => e.stopPropagation()}>
<div className="modal-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<h3 className="modal-title" style={{ margin: 0 }}> Đơn xin nghỉ phép trong ca</h3>
<p style={{ margin: '4px 0 0 0', color: 'var(--text-secondary)', fontSize: '0.8rem' }}>
Ca {period} ngày {date} - Giao diện duyệt đơn xin nghỉ học.
</p>
<h3 className="modal-title" style={{ margin: 0, display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
<IconMail size={17} />
Đơn xin nghỉ phép
</h3>
<p style={{ margin: '4px 0 0 0', color: 'var(--text-secondary)', fontSize: '0.8rem' }}>Ca {period} ngày {date}</p>
</div>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={loadLeaveRequests}
disabled={loadingLeave}
style={{ padding: '0.3rem 0.6rem', fontSize: '0.78rem' }}
>
{loadingLeave ? 'Đang tải...' : '🔄 Làm mới'}
</button>
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setShowLeaveModal(false)}>
Đóng
<button type="button" className="ap-btn" onClick={loadLeaveRequests} disabled={loadingLeave}>
<IconRefresh size={14} />
{loadingLeave ? 'Đang tải...' : 'Làm mới'}
</button>
<button type="button" className="ap-btn" onClick={() => setShowLeaveModal(false)}>Đóng</button>
</div>
</div>
<div className="modal-body" style={{ padding: '1rem', overflowY: 'auto', maxHeight: '60vh', display: 'flex', flexDirection: 'column', gap: '10px' }}>
<div style={{ padding: '0.65rem 0.85rem', background: '#e0f2fe', border: '1px solid #bae6fd', borderRadius: '6px', color: '#0369a1', fontSize: '0.82rem', display: 'flex', flexDirection: 'column', gap: '0.25rem', lineHeight: '1.4' }}>
<div>💡 <strong>Lưu ý duyệt đơn phép:</strong> Thao tác duyệt/từ chối đơn tại đây chỉ đưc ghi nhận <strong>nội bộ trên hệ thống Simple Care</strong> (không đng bộ ngược lên QLĐT).</div>
<div style={{ paddingLeft: '1.15rem' }}> Khi duyệt đơn, hệ thống sẽ tự đng đi trạng thái điểm danh của sinh viên này thành <strong>&quot;Nghỉ phép&quot;</strong>.</div>
<div style={{ paddingLeft: '1.15rem' }}> Trạng thái này sẽ đưc cập nhật lên QLĐT khi thầy bấm nút <strong>&quot;Đy QLĐT&quot;</strong> giao diện điểm danh ca học.</div>
{/* Info notice */}
<div className="ap-leave-notice">
<IconInfo size={15} />
<div>
<strong>Lưu ý duyệt đơn phép:</strong> Thao tác duyệt/từ chối đơn tại đây chỉ đưc ghi nhận{' '}
<strong>nội bộ trên hệ thống Simple Care</strong> (không đng bộ ngược lên QLĐT).
<div style={{ marginTop: '0.25rem' }}>
Khi duyệt đơn, hệ thống sẽ tự đng đi trạng thái điểm danh thành <strong>"Nghỉ có phép"</strong>.<br />
Trạng thái này sẽ đưc cập nhật lên QLĐT khi bấm nút <strong>"Đẩy QLĐT"</strong>.
</div>
</div>
</div>
{loadingLeave ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '2rem 0' }}>
<div className="sync-spinner" style={{ width: 28, height: 28 }} />
@@ -429,81 +531,43 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId, onClo
</div>
) : (
leaveRequests.map((req) => (
<div
key={req.id}
style={{
display: 'flex',
flexDirection: 'column',
gap: '8px',
padding: '0.85rem',
border: '1px solid var(--border-color)',
borderRadius: '8px',
backgroundColor: '#ffffff',
boxShadow: 'var(--shadow-sm)'
}}
>
<div key={req.id} className="ap-leave-card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: '8px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<div style={{ fontWeight: 600, fontSize: '0.9rem' }}>{req.student.fullName}</div>
<code style={{ fontSize: '0.8rem', color: 'var(--text-secondary)' }}>{req.student.studentCode}</code>
<span
className="badge"
style={{
fontSize: '0.72rem',
fontWeight: 600,
backgroundColor: req.status === 'Đang chờ' ? '#fef3c7' : req.status === 'Phê duyệt' ? '#d1fae5' : '#fee2e2',
color: req.status === 'Đang chờ' ? '#b45309' : req.status === 'Phê duyệt' ? '#065f46' : '#991b1b',
padding: '0.15rem 0.45rem',
borderRadius: '4px'
}}
>
<span className={`ap-leave-status ${req.status === 'Đang chờ' ? 'ap-leave-status--pending' : req.status === 'Phê duyệt' ? 'ap-leave-status--approved' : 'ap-leave-status--rejected'}`}>
{req.status}
</span>
</div>
{req.status === 'Đang chờ' && (
<div style={{ display: 'flex', gap: '6px' }}>
<button
type="button"
className="btn btn-primary btn-sm"
style={{ padding: '0.25rem 0.65rem', fontSize: '0.78rem', backgroundColor: 'var(--success)', borderColor: 'var(--success)' }}
onClick={() => {
if (confirm(`Phê duyệt đơn xin nghỉ phép của ${req.student.fullName}? Trạng thái điểm danh ca sẽ chuyển thành Nghỉ có phép.`)) {
handleUpdateLeaveStatus(req.id, 'Phê duyệt', req.student.id);
}
}}
className="ap-btn ap-btn--primary"
style={{ background: 'var(--success)', borderColor: 'var(--success)' }}
onClick={() => { if (confirm(`Phê duyệt đơn xin nghỉ phép của ${req.student.fullName}?`)) handleUpdateLeaveStatus(req.id, 'Phê duyệt', req.student.id); }}
>
Phê duyệt
<IconCheck size={13} /> Phê duyệt
</button>
<button
type="button"
className="btn btn-secondary btn-sm text-danger"
style={{ padding: '0.25rem 0.65rem', fontSize: '0.78rem', borderColor: '#fca5a5' }}
onClick={() => {
if (confirm(`Từ chối đơn xin nghỉ phép của ${req.student.fullName}?`)) {
handleUpdateLeaveStatus(req.id, 'Từ chối', req.student.id);
}
}}
className="ap-btn"
style={{ borderColor: '#fca5a5' }}
onClick={() => { if (confirm(`Từ chối đơn xin nghỉ phép của ${req.student.fullName}?`)) handleUpdateLeaveStatus(req.id, 'Từ chối', req.student.id); }}
>
Từ chối
<IconXMark size={13} /> Từ chối
</button>
</div>
)}
</div>
<div style={{ fontSize: '0.82rem', color: 'var(--text-primary)', background: '#f9fafb', padding: '8px 10px', borderRadius: '4px', borderLeft: '4px solid #cbd5e1', lineHeight: '1.4' }}>
<div className="ap-leave-card-reason">
<strong> do nghỉ:</strong> {req.note || 'Không có ghi chú'}
</div>
{req.reasonImage && (
<div style={{ marginTop: '4px' }}>
<a
href={req.reasonImage}
target="_blank"
rel="noopener noreferrer"
style={{ fontSize: '0.75rem', color: 'var(--accent)', textDecoration: 'underline', display: 'inline-flex', alignItems: 'center', gap: '4px', fontWeight: 500 }}
>
🖼 Xem nh minh chứng phép
<div>
<a href={req.reasonImage} target="_blank" rel="noopener noreferrer" style={{ fontSize: '0.72rem', color: 'var(--accent)', textDecoration: 'underline', display: 'inline-flex', alignItems: 'center', gap: '4px' }}>
<IconImage size={13} /> Xem nh minh chứng
</a>
</div>
)}
@@ -515,43 +579,34 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId, onClo
</div>
)}
{/* ── Bulk status actions bar ── */}
{selectedStudentRkIds.length > 0 && (
<div className="attendance-bulk-actions">
<span className="attendance-bulk-title">
Đang chọn {selectedStudentRkIds.length} sinh viên:
</span>
<div className="attendance-bulk-btns">
<div className="ap-bulk">
<span className="ap-bulk-title">Đang chọn {selectedStudentRkIds.length} SV:</span>
<div className="ap-bulk-btns">
{ATTENDANCE_STATUS_OPTIONS.map(opt => (
<button
key={opt.value}
type="button"
className={`btn btn-secondary ${attendanceStatusClass(opt.value)}`}
style={{ padding: '0.25rem 0.75rem', fontSize: '0.875rem', border: '1px solid var(--att-border)' }}
onClick={() => handleBulkStatusChange(opt.value)}
>
Gắn &quot;{opt.label}&quot;
<button key={opt.value} type="button" className={`ap-btn ${attendanceStatusClass(opt.value)}`} style={{ border: '1px solid var(--att-border)' }} onClick={() => handleBulkStatusChange(opt.value)}>
Gắn "{opt.label}"
</button>
))}
</div>
<button
type="button"
className="btn btn-muted attendance-bulk-btn-close"
style={{ padding: '0.25rem 0.75rem', fontSize: '0.875rem' }}
onClick={() => setSelectedStudentRkIds([])}
>
<button type="button" className="ap-btn" style={{ marginLeft: 'auto' }} onClick={() => setSelectedStudentRkIds([])}>
Hủy chọn
</button>
</div>
)}
<div className="attendance-table-scroll table-wrapper" onScroll={handleScroll}>
{/* ── Attendance table ── */}
<div className="ap-table-scroll attendance-table-scroll table-wrapper" onScroll={handleScroll}>
{loading ? (
<div className="empty-state"><div className="sync-spinner" style={{ width: 28, height: 28 }} /></div>
<div className="ap-empty">
<div className="sync-spinner" style={{ width: 28, height: 28 }} />
</div>
) : (
<table className="data-table attendance-table">
<table className="ap-table data-table attendance-table">
<thead>
<tr>
<th style={{ width: '45px', textAlign: 'center' }}>
<th>
<input
type="checkbox"
checked={filteredRows.length > 0 && filteredRows.every(r => selectedStudentRkIds.includes(r.studentRkId))}
@@ -576,7 +631,7 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId, onClo
<tbody>
{filteredRows.length === 0 ? (
<tr>
<td colSpan={5} style={{ textAlign: 'center', color: 'var(--text-muted)' }}>
<td colSpan={5} style={{ textAlign: 'center', color: 'var(--text-muted)', padding: '1.5rem 0', fontStyle: 'italic', fontSize: '0.83rem' }}>
{rows.length === 0 ? 'Chưa có dữ liệu điểm danh' : 'Không tìm thấy sinh viên phù hợp'}
</td>
</tr>
@@ -584,60 +639,38 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId, onClo
filteredRows.map(row => {
const isSelected = selectedStudentRkIds.includes(row.studentRkId);
return (
<tr key={row.studentRkId} className={attendanceStatusClass(row.status)}>
<td
style={{ textAlign: 'center', cursor: 'pointer' }}
onClick={() => toggleStudentSelection(row.studentRkId)}
>
<input
type="checkbox"
checked={isSelected}
onChange={() => {}}
onClick={(e) => {
e.stopPropagation();
toggleStudentSelection(row.studentRkId);
}}
/>
<tr key={row.studentRkId} className={attendanceStatusClass(row.status)} style={{ background: isSelected ? 'rgba(var(--accent-rgb, 99 102 241) / 0.05)' : undefined }}>
<td>
<input type="checkbox" checked={isSelected} onChange={() => toggleStudentSelection(row.studentRkId)} />
</td>
<td
style={{ cursor: 'pointer' }}
onClick={() => toggleStudentSelection(row.studentRkId)}
>
<div className="attendance-student-name">{row.fullName}</div>
<div className="attendance-student-meta">
<td style={{ cursor: 'pointer' }} onClick={() => toggleStudentSelection(row.studentRkId)}>
<div className="attendance-student-name" style={{ fontWeight: 700, fontSize: '0.85rem' }}>{row.fullName}</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem 0.5rem', fontSize: '0.7rem', color: 'var(--text-muted)', marginTop: '1px' }}>
<code>{row.studentCode}</code>
{row.email && <span>{row.email}</span>}
</div>
</td>
<td>
<span className="attendance-online-mins">{row.onlineMinutes}</span>
<span className="attendance-online-unit">phút</span>
<span style={{ fontFamily: 'monospace', fontWeight: 800, fontSize: '0.95rem' }}>{row.onlineMinutes}</span>
<span style={{ fontSize: '0.65rem', color: 'var(--text-muted)', marginLeft: '2px' }}>phút</span>
</td>
<td>
<select
className={`attendance-status-select ${attendanceStatusClass(row.status)}`}
style={{ padding: '0.2rem 0.4rem', fontSize: '0.72rem', fontWeight: 600, border: '1px solid var(--att-border)', background: 'var(--att-bg)', color: 'var(--att-fg)', borderRadius: '4px', cursor: 'pointer' }}
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>
))}
{ATTENDANCE_STATUS_OPTIONS.map(opt => (<option key={opt.value} value={opt.value}>{opt.label}</option>))}
</select>
</td>
<td>
<div className="attendance-notes">
{row.pushedToQldtAt ? (
<span className="attendance-qldt-tag attendance-qldt-tag--ok" title={row.pushedToQldtAt}>
QLĐT
</span>
) : (
<span className="attendance-qldt-tag attendance-qldt-tag--pending">Chưa QLĐT</span>
)}
{row.statusEditedByTeacher && (
<span className="attendance-lock-tag" title="Giáo viên đã sửa — không bị ghi đè">
Đã khóa
</span>
)}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.2rem' }}>
{row.pushedToQldtAt
? <span className="ap-tag ap-tag--ok">QLĐT <IconSync size={10} /></span>
: <span className="ap-tag ap-tag--pending">Chưa QLĐT</span>
}
{row.statusEditedByTeacher && <span className="ap-tag ap-tag--locked">Đã khóa</span>}
</div>
</td>
</tr>
@@ -649,7 +682,5 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId, onClo
)}
</div>
</div>
</div>
</div>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,85 @@ import { api } from '../api';
import type { ClassItem, SyncStatus } from '../api';
import { openClass } from './NavHistoryBar';
type IconProps = { size?: number; className?: string };
const IconGraduation = ({ size = 22 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M22 10v6M2 10l10-5 10 5-10 5z" />
<path d="M6 12v5c0 1 2.5 3 6 3s6-2 6-3v-5" />
</svg>
);
const IconRefresh = ({ size = 18 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M21 12a9 9 0 1 1-2.6-6.3" />
<polyline points="21 3 21 9 15 9" />
</svg>
);
const IconCheck = ({ size = 16 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M20 6 9 17l-5-5" />
</svg>
);
const IconAlert = ({ size = 16 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<circle cx="12" cy="12" r="9" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
);
const IconSearch = ({ size = 16 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<circle cx="11" cy="11" r="7" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
);
const IconUsers = ({ size = 14 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="3.5" />
<path d="M22 21v-2a3.5 3.5 0 0 0-2.5-3.35" />
<path d="M16 3.5a3.5 3.5 0 0 1 0 7" />
</svg>
);
const IconSpecialize = ({ size = 12 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
</svg>
);
const IconOpen = ({ size = 15 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M5 12h14" />
<path d="m13 6 6 6-6 6" />
</svg>
);
const IconInbox = ({ size = 40 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<polyline points="22 12 16 12 14 15 10 15 8 12 2 12" />
<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
</svg>
);
const IconChevronLeft = ({ size = 16 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="m15 18-6-6 6-6" />
</svg>
);
const IconChevronRight = ({ size = 16 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="m9 18 6-6-6-6" />
</svg>
);
export const ClassesTab: React.FC = () => {
const [classes, setClasses] = useState<ClassItem[]>([]);
const [total, setTotal] = useState(0);
@@ -12,7 +91,6 @@ export const ClassesTab: React.FC = () => {
const [systemFilter, setSystemFilter] = useState<number | undefined>(undefined);
const [studyingOnly, setStudyingOnly] = useState(false);
const [loading, setLoading] = useState(true);
// Sync state
const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null);
const fetchClasses = async () => {
@@ -49,12 +127,10 @@ export const ClassesTab: React.FC = () => {
fetchClasses();
}, [page, search, systemFilter, studyingOnly]);
// Check sync status on load and start polling if running
useEffect(() => {
fetchSyncStatus();
}, []);
// Sync polling logic
useEffect(() => {
let timer: any;
if (syncStatus?.running) {
@@ -62,7 +138,7 @@ export const ClassesTab: React.FC = () => {
const isRunning = await fetchSyncStatus();
if (!isRunning) {
clearInterval(timer);
fetchClasses(); // Reload data when sync completes
fetchClasses();
}
}, 2000);
}
@@ -74,13 +150,12 @@ export const ClassesTab: React.FC = () => {
const handleStartSync = async () => {
try {
await api.startClassesSync();
// Set local state to running to trigger useEffect poller
setSyncStatus({
running: true,
done: false,
total: 0,
synced: 0,
updatedAt: Date.now() / 1000
updatedAt: Date.now() / 1000,
});
} catch (err: any) {
alert(err.message || 'Không thể bắt đầu đồng bộ lớp học');
@@ -90,76 +165,83 @@ export const ClassesTab: React.FC = () => {
const handleToggleStudying = async (cItem: ClassItem) => {
const nextVal = !cItem.isStudying;
try {
// Optimistic update
setClasses(prev => prev.map(c => c.rkId === cItem.rkId ? { ...c, isStudying: nextVal } : c));
setClasses(prev =>
prev.map(c => (c.rkId === cItem.rkId ? { ...c, isStudying: nextVal } : c))
);
await api.updateClassStudying(cItem.rkId, nextVal);
} catch (err: any) {
// Revert if error
setClasses(prev => prev.map(c => c.rkId === cItem.rkId ? { ...c, isStudying: !nextVal } : c));
setClasses(prev =>
prev.map(c => (c.rkId === cItem.rkId ? { ...c, isStudying: !nextVal } : c))
);
alert(err.message || 'Không thể cập nhật trạng thái lớp học');
}
};
// Tính toán % tiến trình sync
const syncPercent = syncStatus && syncStatus.total > 0
const syncPercent =
syncStatus && syncStatus.total > 0
? Math.round((syncStatus.synced / syncStatus.total) * 100)
: 0;
const totalPages = Math.ceil(total / pageSize) || 1;
return (
<div className="tab-page">
<div className="tab-page-toolbar">
<div className="page-header">
<div className="page-title">
<h1>Quản Lớp Học</h1>
<h1 className="page-title-heading">
<span className="page-title-icon" aria-hidden>
<IconGraduation />
</span>
Quản lớp học
</h1>
<p>Đng bộ dữ liệu lớp học sinh viên từ hệ thống chính</p>
</div>
<button
className="btn btn-primary"
onClick={handleStartSync}
disabled={syncStatus?.running}
style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '8px',
padding: '0.75rem 1.5rem',
borderRadius: '12px',
fontWeight: 700,
boxShadow: '0 4px 14px rgba(187,33,38,0.25)',
}}
>
{syncStatus?.running ? (
<>
<div className="sync-spinner"></div> Đng bộ...
<div className="sync-spinner" style={{ width: 18, height: 18 }} />
Đng bộ... {syncPercent}%
</>
) : (
<>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ display: 'inline-block' }}>
<path d="M23 4v6h-6M1 20v-6h6M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
</svg>
<IconRefresh />
Đng bộ Lớp học
</>
)}
</button>
</div>
{/* Sync Status Banner */}
{syncStatus && (syncStatus.running || syncStatus.done || syncStatus.error) && (
<div className="sync-progress-banner">
<div className="sync-header">
<div className="sync-title">
{syncStatus.running && <div className="sync-spinner"></div>}
{syncStatus.running && <div className="sync-spinner" />}
<span>
{syncStatus.running && `Đang đồng bộ lớp học... (${syncPercent}%)`}
{syncStatus.running && `Đang đồng bộ... ${syncPercent}%`}
{syncStatus.done && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="var(--success)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
Đng bộ lớp học hoàn tất!
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, color: 'var(--success)' }}>
<IconCheck />
Đng bộ hoàn tất!
</span>
)}
{syncStatus.error && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '6px', color: 'var(--danger)' }}>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="var(--danger)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
Đng bộ thất bại: {syncStatus.error}
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, color: 'var(--danger)' }}>
<IconAlert />
{syncStatus.error}
</span>
)}
</span>
@@ -170,7 +252,7 @@ export const ClassesTab: React.FC = () => {
</div>
{syncStatus.running && (
<div className="sync-bar-container">
<div className="sync-bar" style={{ width: `${syncPercent}%` }}></div>
<div className="sync-bar" style={{ width: `${syncPercent}%` }} />
</div>
)}
<div className="sync-meta">
@@ -180,7 +262,6 @@ export const ClassesTab: React.FC = () => {
</div>
)}
{/* Control bar filters */}
<div className="control-bar">
<div className="search-input-wrapper">
<input
@@ -194,10 +275,7 @@ export const ClassesTab: React.FC = () => {
}}
/>
<span className="search-icon">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<IconSearch />
</span>
</div>
@@ -212,10 +290,10 @@ export const ClassesTab: React.FC = () => {
}}
>
<option value="">Tất cả phân hệ</option>
<option value="1"> Nội (System 1)</option>
<option value="2">Hồ Chí Minh (System 2)</option>
<option value="3">Đà Nẵng (System 3)</option>
<option value="4">Cần Thơ (System 4)</option>
<option value="1"> Nội</option>
<option value="2">Hồ Chí Minh</option>
<option value="3">Đà Nẵng</option>
<option value="4">Cần Thơ</option>
</select>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', fontSize: '0.9rem' }}>
@@ -226,7 +304,7 @@ export const ClassesTab: React.FC = () => {
setStudyingOnly(e.target.checked);
setPage(1);
}}
style={{ width: '16px', height: '16px', accentColor: 'var(--accent)' }}
style={{ width: '18px', height: '18px', accentColor: 'var(--accent)', cursor: 'pointer' }}
/>
Chỉ xem lớp đang học
</label>
@@ -234,172 +312,613 @@ export const ClassesTab: React.FC = () => {
</div>
</div>
{/* Classes list table */}
<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>
<p style={{ marginTop: '0.5rem' }}>Đang tải danh sách lớp học...</p>
<div className="sync-spinner" style={{ width: '40px', height: '40px' }} />
<p style={{ marginTop: '1rem', fontWeight: 600 }}>Đang tải danh sách lớp học...</p>
</div>
) : classes.length === 0 ? (
<div className="empty-state">
<div className="empty-state-icon" style={{ color: 'var(--text-muted)' }}>
<svg viewBox="0 0 24 24" width="48" height="48" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 10v6M2 10l10-5 10 5-10 5z" />
<path d="M6 12v5c0 2 2 3 6 3s6-1 6-3v-5" />
</svg>
<IconInbox />
</div>
<h2>Không tìm thấy lớp học nào</h2>
<p>Hãy thử thay đi bộ lọc hoặc bấm nút "Đồng bộ Lớp học" đ kéo dữ liệu mới.</p>
<h2>Không tìm thấy lớp học</h2>
<p>Hãy thay đi bộ lọc hoặc bấm "Đồng bộ Lớp học" đ kéo dữ liệu mới.</p>
</div>
) : (
<table className="data-table">
<div className="table-scroll-container">
<table className="data-table class-table">
<thead>
<tr>
<th> lớp / ID</th>
<th>Tên Lớp học</th>
<th>Phân hệ</th>
<th>Môn học của lớp</th>
<th>Sinh viên</th>
<th>Đang dạy</th>
<th style={{ textAlign: 'right' }}>Hành đng</th>
<th className="col-code"> lớp / ID</th>
<th className="col-name">Tên lớp học</th>
<th className="col-system">Phân hệ</th>
<th className="col-courses">Môn học</th>
<th className="col-students">Sinh viên</th>
<th className="col-status">Trạng thái</th>
<th className="col-action">Hành đng</th>
</tr>
</thead>
<tbody>
{classes.map(cl => (
<tr key={cl.rkId} style={cl.isStudying ? { background: 'rgba(16, 185, 129, 0.02)' } : {}}>
<td>
<div style={{ fontWeight: 700, color: 'var(--text-primary)' }}>{cl.classCode}</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>ID: {cl.rkId}</div>
{classes.map(cl => {
const isActive = cl.isStudying;
return (
<tr key={cl.rkId} className={isActive ? 'row-active' : ''}>
<td className="col-code" data-label="Mã lớp / ID">
<div className="class-code" title={cl.classCode}>{cl.classCode}</div>
<div className="class-id">ID: {cl.rkId}</div>
</td>
<td>
<td className="col-name" data-label="Tên lớp học">
<div
style={{ fontWeight: 600, color: 'var(--accent)', fontSize: '0.95rem', cursor: 'pointer', textDecoration: 'underline' }}
className="class-name-link"
onClick={() => openClass('classes', cl.rkId, cl.name)}
title="Mở Không gian làm việc của lớp"
title={cl.name}
>
{cl.name}
</div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', marginTop: '0.15rem', display: 'flex', alignItems: 'center', gap: '4px' }}>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="7" width="20" height="14" rx="2" ry="2" />
<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16" />
</svg>
<div className="class-specialize">
<IconSpecialize />
<span className="class-specialize-text">
{cl.specializeName || 'Chưa có chuyên ngành'}
</span>
</div>
</td>
<td>
<span className="badge badge-muted" style={{ fontWeight: 600, display: 'inline-flex', alignItems: 'center', gap: '4px' }}>
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
<circle cx="12" cy="10" r="3" />
</svg>
<td className="col-system" data-label="Phân hệ">
<span className="badge badge-system">
{cl.systemName || `Hệ thống ${cl.systemRkId || 'Khác'}`}
</span>
</td>
<td>
<div className="courses-tag-list">
<td className="col-courses" data-label="Môn học">
<div className="course-tags">
{cl.courses && cl.courses.length > 0 ? (
cl.courses.map((co, i) => (
<span key={i} className="course-tag">
<span key={i} className="course-tag" title={co.courseName}>
{co.courseName}
</span>
))
) : (
<span style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>Chưa môn học</span>
<span className="text-muted-small">Chưa môn học</span>
)}
</div>
</td>
<td>
<span className="badge badge-info" style={{ padding: '0.3rem 0.65rem', borderRadius: '6px', fontWeight: 600, display: 'inline-flex', alignItems: 'center', gap: '4px' }}>
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
{cl.studentCount} SV
<td className="col-students" data-label="Sinh viên">
<span className="badge badge-student">
<IconUsers />
<span className="student-count">{cl.studentCount}</span>
<span>SV</span>
</span>
</td>
<td>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
<td className="col-status" data-label="Trạng thái">
<div className="status-toggle-wrap">
<div className="switch-container">
<label className="switch">
<input
type="checkbox"
checked={cl.isStudying}
checked={isActive}
onChange={() => handleToggleStudying(cl)}
/>
<span className="slider"></span>
<span className="slider" />
</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 className={`status-label ${isActive ? 'active' : 'inactive'}`}>
{isActive ? 'Đang học' : 'Tạm dừng'}
</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' }}>
<td className="col-action" data-label="Hành động">
<button
className="btn btn-secondary"
style={{ padding: '0.5rem 0.95rem', fontSize: '0.8rem', fontWeight: 600, display: 'inline-flex', alignItems: 'center', gap: '0.35rem' }}
className="btn btn-outline"
onClick={() => openClass('classes', cl.rkId, cl.name)}
>
<svg viewBox="0 0 24 24" width="14" height="14" 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>
Xem lớp
<span className="btn-outline-label">Xem lớp</span>
<IconOpen />
</button>
</td>
</tr>
))}
);
})}
</tbody>
</table>
</div>
)}
</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
<div className="pagination-info">
Hiển thị <b>{(page - 1) * pageSize + 1}</b> {' '}
<b>{Math.min(page * pageSize, total)}</b> trong tổng số <b>{total}</b> lớp
</div>
<div className="pagination-btn-group">
<button
className="pagination-btn"
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}
aria-label="Trang trước"
>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 18 9 12 15 6" />
</svg>
<IconChevronLeft />
</button>
<span style={{ display: 'flex', alignItems: 'center', padding: '0 1rem', fontWeight: 600, color: 'var(--text-primary)' }}>
Trang {page} / {Math.ceil(total / pageSize) || 1}
<span className="pagination-current">
Trang {page} / {totalPages}
</span>
<button
className="pagination-btn"
onClick={() => setPage(p => p + 1)}
disabled={page >= Math.ceil(total / pageSize)}
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}
disabled={page >= totalPages}
aria-label="Trang sau"
>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6" />
</svg>
<IconChevronRight />
</button>
</div>
</div>
</div>
)}
<style>{`
.table-wrapper.table-fill {
overflow-x: hidden;
overflow-y: hidden;
}
.table-scroll-container {
overflow-x: hidden;
overflow-y: auto;
max-height: 100%;
height: 100%;
}
.class-table {
width: 100%;
table-layout: fixed;
border-collapse: collapse;
font-size: 0.9rem;
}
.class-table thead th {
position: sticky;
top: 0;
z-index: 10;
background: var(--bg-subtle);
border-bottom: 2px solid var(--border-color);
padding: 0.75rem 0.9rem;
font-weight: 700;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.class-table td {
padding: 0.85rem 0.9rem;
border-bottom: 1px solid var(--border-light);
vertical-align: middle;
overflow: hidden;
}
.class-table .col-code { width: 16%; }
.class-table .col-name { width: 22%; }
.class-table .col-system { width: 14%; }
.class-table .col-courses { width: 16%; }
.class-table .col-students { width: 9%; }
.class-table .col-status { width: 12%; }
.class-table .col-action { width: 11%; text-align: right; }
.class-table td.col-students,
.class-table td.col-status,
.class-table td.col-action {
vertical-align: middle;
}
.class-table tbody tr {
transition: background 0.15s;
}
.class-table tbody tr:hover {
background: var(--bg-subtle) !important;
}
.class-table tbody tr.row-active {
background: rgba(13, 159, 110, 0.02);
}
.class-table tbody tr.row-active:hover {
background: rgba(13, 159, 110, 0.05) !important;
}
.class-code {
font-weight: 700;
color: var(--text-primary);
font-size: 0.9rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.class-id {
font-size: 0.72rem;
color: var(--text-muted);
margin-top: 0.1rem;
}
.class-name-link {
font-weight: 600;
color: var(--accent);
cursor: pointer;
font-size: 0.9rem;
transition: color 0.15s;
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.class-name-link:hover {
color: var(--accent-dark);
text-decoration: underline;
}
.page-title-heading {
display: flex;
align-items: center;
gap: 0.55rem;
}
.page-title-icon {
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--accent);
flex-shrink: 0;
}
.class-specialize {
font-size: 0.75rem;
color: var(--text-secondary);
margin-top: 0.15rem;
display: flex;
align-items: center;
gap: 5px;
min-width: 0;
}
.class-specialize svg {
opacity: 0.65;
flex-shrink: 0;
}
.class-specialize-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.badge-system {
background: var(--bg-subtle);
color: var(--text-secondary);
border: 1px solid var(--border-color);
padding: 0.2rem 0.55rem;
border-radius: 20px;
font-weight: 600;
font-size: 0.72rem;
display: inline-flex;
align-items: center;
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.badge-student {
background: var(--accent-light);
color: var(--accent);
border: 1px solid rgba(187,33,38,0.15);
padding: 0.2rem 0.55rem;
border-radius: 20px;
font-weight: 600;
font-size: 0.75rem;
display: inline-flex;
align-items: center;
justify-content: flex-start;
gap: 4px;
white-space: nowrap;
min-width: 4.75rem;
box-sizing: border-box;
line-height: 1.2;
}
.badge-student svg {
flex-shrink: 0;
display: block;
}
.badge-student .student-count {
display: inline-block;
min-width: 2ch;
text-align: right;
font-variant-numeric: tabular-nums;
}
.course-tags {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
max-width: 100%;
align-items: center;
}
.course-tag {
background: var(--bg-subtle);
border: 1px solid var(--border-light);
padding: 0.1rem 0.45rem;
border-radius: 12px;
font-size: 0.68rem;
color: var(--text-secondary);
white-space: nowrap;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
.text-muted-small {
font-size: 0.75rem;
color: var(--text-muted);
}
.status-toggle-wrap {
display: flex;
align-items: center;
gap: 0.45rem;
min-width: 0;
}
.status-label {
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
white-space: nowrap;
}
.status-label.active {
color: var(--success);
}
.status-label.inactive {
color: var(--text-muted);
}
.btn-outline {
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 0.35rem 0.75rem;
border-radius: 8px;
font-weight: 600;
font-size: 0.78rem;
transition: all 0.15s;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.3rem;
cursor: pointer;
white-space: nowrap;
line-height: 1;
height: 32px;
box-sizing: border-box;
}
.btn-outline:hover {
border-color: var(--accent);
color: var(--accent);
background: var(--accent-light);
}
.pagination-info {
font-size: 0.85rem;
color: var(--text-secondary);
display: inline-flex;
align-items: center;
line-height: 1;
}
.pagination-current {
display: inline-flex;
align-items: center;
justify-content: center;
height: 34px;
line-height: 1;
font-weight: 700;
color: var(--text-primary);
padding: 0 0.75rem;
white-space: nowrap;
}
.pagination-btn-group {
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
/* Medium: drop secondary columns, no horizontal scroll */
@media (max-width: 1280px) {
.class-table .col-courses {
display: none;
}
.class-table .col-code { width: 18%; }
.class-table .col-name { width: 28%; }
.class-table .col-system { width: 16%; }
.class-table .col-students { width: 10%; }
.class-table .col-status { width: 14%; }
.class-table .col-action { width: 14%; }
}
@media (max-width: 1100px) {
.class-table .col-system {
display: none;
}
.class-table .col-code { width: 20%; }
.class-table .col-name { width: 36%; }
.class-table .col-students { width: 12%; }
.class-table .col-status { width: 16%; }
.class-table .col-action { width: 16%; }
.status-label {
display: none;
}
.btn-outline {
padding: 0.35rem 0.55rem;
}
.btn-outline-label {
display: none;
}
}
/* Card layout before horizontal overflow kicks in */
@media (max-width: 960px) {
.table-scroll-container {
overflow-x: hidden;
}
.class-table {
table-layout: auto;
}
.class-table thead {
display: none;
}
.class-table tbody tr {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem 1rem;
padding: 1rem;
border-radius: var(--radius-lg);
background: var(--bg-card);
box-shadow: var(--shadow-sm);
border: 1px solid var(--border-color);
margin-bottom: 0.75rem;
}
.class-table tbody tr.row-active {
border-left: 4px solid var(--success);
}
.class-table .col-courses,
.class-table .col-system {
display: flex;
}
.class-table td {
display: flex;
flex-direction: column;
padding: 0 !important;
border: none !important;
gap: 0.1rem;
overflow: visible;
width: auto !important;
}
.class-table td::before {
content: attr(data-label);
font-size: 0.6rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 0.1rem;
}
.class-table td.col-action {
grid-column: span 2;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-top: 0.5rem;
padding-top: 0.5rem !important;
border-top: 1px dashed var(--border-color) !important;
}
.class-table td.col-action::before {
margin-bottom: 0;
line-height: 1;
}
.class-code,
.class-name-link,
.class-specialize-text,
.badge-system {
white-space: normal;
overflow: visible;
text-overflow: unset;
}
.status-label {
display: inline;
}
.btn-outline-label {
display: inline;
}
.class-code {
font-size: 0.85rem;
}
.class-name-link {
font-size: 0.85rem;
}
.class-id {
font-size: 0.65rem;
}
.class-specialize {
font-size: 0.7rem;
}
.badge-system,
.badge-student {
font-size: 0.65rem;
padding: 0.15rem 0.5rem;
}
.course-tag {
font-size: 0.6rem;
}
.status-label {
font-size: 0.6rem;
}
.btn-outline {
font-size: 0.7rem;
padding: 0.3rem 0.8rem;
}
.pagination-row {
flex-direction: column;
gap: 0.75rem;
align-items: center;
text-align: center;
}
.pagination-info {
font-size: 0.75rem;
}
.page-header {
flex-direction: column;
align-items: stretch;
}
.page-header .btn {
width: 100%;
justify-content: center;
}
.control-bar {
flex-direction: column;
align-items: stretch;
}
.search-input-wrapper {
max-width: 100%;
}
.filters-wrapper {
flex-direction: column;
align-items: stretch;
gap: 0.6rem;
}
.select-filter {
width: 100%;
}
}
@media (max-width: 480px) {
.class-table tbody tr {
padding: 0.75rem;
gap: 0.4rem 0.75rem;
}
.class-code {
font-size: 0.8rem;
}
.class-name-link {
font-size: 0.8rem;
}
.badge-system,
.badge-student {
font-size: 0.6rem;
padding: 0.1rem 0.4rem;
}
.btn-outline {
font-size: 0.65rem;
padding: 0.2rem 0.6rem;
}
.pagination-btn-group .pagination-btn {
width: 32px;
height: 32px;
}
}
`}</style>
</div>
);
};

View File

@@ -3,6 +3,85 @@ import { type ExamRoomStudent } from '../api';
import { openStaffChat } from '../chatEvents';
import { StudentStreamImage } from './StudentStreamImage';
/* ─── Icons ──────────────────────────────────────────────────────────────── */
type IconProps = { size?: number };
const IconSearch = ({ size = 14 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<circle cx="11" cy="11" r="7" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
);
const IconMaximize = ({ size = 14 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M8 3H5a2 2 0 0 0-2 2v3" />
<path d="M21 8V5a2 2 0 0 0-2-2h-3" />
<path d="M3 16v3a2 2 0 0 0 2 2h3" />
<path d="M16 21h3a2 2 0 0 0 2-2v-3" />
</svg>
);
const IconMinimize = ({ size = 14 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M8 3v3a2 2 0 0 1-2 2H3" />
<path d="M21 8h-3a2 2 0 0 1-2-2V3" />
<path d="M3 16h3a2 2 0 0 1 2 2v3" />
<path d="M16 21v-3a2 2 0 0 1 2-2h3" />
</svg>
);
const IconOffline = ({ size = 28 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<rect x="2" y="3" width="20" height="14" rx="2" />
<line x1="8" y1="21" x2="16" y2="21" />
<line x1="12" y1="17" x2="12" y2="21" />
<line x1="2" y1="3" x2="22" y2="21" />
</svg>
);
const IconMessage = ({ size = 13 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
);
const IconEye = ({ size = 13 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
);
const IconFile = ({ size = 12 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
);
const IconClose = ({ size = 18 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
);
const IconChevronLeft = ({ size = 14 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<polyline points="15 18 9 12 15 6" />
</svg>
);
const IconChevronRight = ({ size = 14 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<polyline points="9 18 15 12 9 6" />
</svg>
);
/* ─── Component ──────────────────────────────────────────────────────────── */
interface ExamGridProctorProps {
students: ExamRoomStudent[];
onlineIds: number[];
@@ -27,9 +106,7 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setZoomedStudent(null);
}
if (e.key === 'Escape') setZoomedStudent(null);
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
@@ -41,8 +118,7 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
s.fullName.toLowerCase().includes(searchQuery.toLowerCase()) ||
s.studentCode.toLowerCase().includes(searchQuery.toLowerCase());
const isOnline = onlineIds.includes(s.studentRkId);
const matchesOnlineFilter = !onlyOnline || isOnline;
return matchesSearch && matchesOnlineFilter;
return matchesSearch && (!onlyOnline || isOnline);
});
}, [students, onlineIds, searchQuery, onlyOnline]);
@@ -58,9 +134,7 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
}, [filteredStudents, pageSize]);
useEffect(() => {
if (currentPage > totalPages) {
setCurrentPage(totalPages);
}
if (currentPage > totalPages) setCurrentPage(totalPages);
}, [totalPages, currentPage]);
const handleOpenChat = (s: ExamRoomStudent, e: React.MouseEvent) => {
@@ -95,39 +169,31 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
<div className={`grid-proctor-container ${isFullscreen ? 'fullscreen-active' : ''}`} ref={gridContainerRef}>
<div className="grid-proctor-toolbar">
<div className="grid-proctor-toolbar-left">
<div className="search-input-wrapper">
<div className="gp-search">
<span className="gp-search-icon"><IconSearch size={14} /></span>
<input
type="text"
className="search-input"
type="search"
className="search-input gp-search-input"
placeholder="Lọc sinh viên..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<span className="search-icon">🔍</span>
</div>
<label className="checkbox-label" style={{ display: 'flex', alignItems: 'center', gap: '0.35rem', cursor: 'pointer', fontSize: '0.85rem', color: 'var(--text-primary)' }}>
<input
type="checkbox"
checked={onlyOnline}
onChange={(e) => setOnlyOnline(e.target.checked)}
/>
<label className="gp-check">
<input type="checkbox" checked={onlyOnline} onChange={(e) => setOnlyOnline(e.target.checked)} />
<span>Chỉ hiện Online</span>
</label>
<label className="checkbox-label" style={{ display: 'flex', alignItems: 'center', gap: '0.35rem', cursor: 'pointer', fontSize: '0.85rem', color: 'var(--text-primary)' }}>
<input
type="checkbox"
checked={showWebcamOverlay}
onChange={(e) => setShowWebcamOverlay(e.target.checked)}
/>
<label className="gp-check">
<input type="checkbox" checked={showWebcamOverlay} onChange={(e) => setShowWebcamOverlay(e.target.checked)} />
<span>Đè webcam góc màn hình</span>
</label>
</div>
<div className="grid-proctor-toolbar-right">
<div className="grid-cols-selector" style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
<span style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>Xem tối đa:</span>
<label className="gp-page-size">
<span>Xem tối đa</span>
<select
value={pageSize}
onChange={(e) => {
@@ -135,74 +201,39 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
setPageSize(val === 'all' ? 'all' : Number(val));
setCurrentPage(1);
}}
style={{
fontSize: '0.75rem',
padding: '0.2rem 0.4rem',
borderRadius: '4px',
border: '1px solid var(--border-color)',
backgroundColor: 'var(--bg-card)',
color: 'var(--text-primary)',
fontWeight: 600,
cursor: 'pointer',
}}
>
<option value={12}>12 bạn</option>
<option value={24}>24 bạn</option>
<option value={48}>48 bạn</option>
<option value="all">Tất cả</option>
</select>
</label>
<div className="grid-cols-selector" role="group" aria-label="Số cột">
<span className="gp-cols-label">Cột</span>
{[2, 3, 4, 6].map((n) => (
<button
key={n}
type="button"
className={gridCols === n ? 'active' : ''}
onClick={() => setGridCols(n)}
>
{n}
</button>
))}
</div>
<div className="grid-cols-selector">
<span style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>Cột:</span>
<button
type="button"
className={`btn btn-secondary btn-xs ${gridCols === 2 ? 'active' : ''}`}
onClick={() => setGridCols(2)}
>
2
</button>
<button
type="button"
className={`btn btn-secondary btn-xs ${gridCols === 3 ? 'active' : ''}`}
onClick={() => setGridCols(3)}
>
3
</button>
<button
type="button"
className={`btn btn-secondary btn-xs ${gridCols === 4 ? 'active' : ''}`}
onClick={() => setGridCols(4)}
>
4
</button>
<button
type="button"
className={`btn btn-secondary btn-xs ${gridCols === 6 ? 'active' : ''}`}
onClick={() => setGridCols(6)}
>
6
</button>
</div>
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={toggleFullscreen}
>
{isFullscreen ? '🖥️ Thu nhỏ' : '🖥️ Toàn màn hình'}
<button type="button" className="btn btn-secondary btn-sm gp-fs-btn" onClick={toggleFullscreen}>
{isFullscreen ? <IconMinimize size={13} /> : <IconMaximize size={13} />}
{isFullscreen ? 'Thu nhỏ' : 'Toàn màn hình'}
</button>
</div>
</div>
<div className="grid-proctor-scroll">
<div
className={`grid-proctor-layout`}
style={{
display: 'grid',
gridTemplateColumns: `repeat(${gridCols}, minmax(0, 1fr))`,
gap: '1rem',
padding: '1rem 0',
}}
className="grid-proctor-layout"
style={{ gridTemplateColumns: `repeat(${gridCols}, minmax(0, 1fr))` }}
>
{pagedStudents.map((s) => {
const isOnline = onlineIds.includes(s.studentRkId);
@@ -216,9 +247,7 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
<div className="grid-proctor-card-header">
<div className="student-info-left">
<span className={`status-dot ${isOnline ? 'online' : 'offline'}`} />
<span className="student-name" title={s.fullName}>
{s.fullName}
</span>
<span className="student-name" title={s.fullName}>{s.fullName}</span>
</div>
<span className="student-code">{s.studentCode}</span>
</div>
@@ -231,14 +260,12 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
e.stopPropagation();
setZoomedStudent(s);
}}
style={{ cursor: 'zoom-in' }}
>
<StudentStreamImage
studentId={s.studentRkId}
kind="screen"
className="proctor-screen-image"
/>
{showWebcamOverlay && (
<div className="proctor-webcam-overlay">
<StudentStreamImage
@@ -251,32 +278,36 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
</div>
) : (
<div className="proctor-placeholder offline">
<span className="icon">📴</span>
<span>Ngoại tuyến (Offline)</span>
<span className="gp-offline-icon"><IconOffline size={26} /></span>
<span className="gp-offline-label">Ngoại tuyến</span>
<span className="gp-offline-sub">Offline</span>
</div>
)}
</div>
<div className="grid-proctor-card-footer" onClick={(e) => e.stopPropagation()}>
<span className="assigned-paper" title={s.paperTitle || 'Chưa gán đề'}>
{s.paperTitle ? `Đề: ${s.paperTitle}` : 'Chưa gán đề'}
<IconFile size={12} />
{s.paperTitle ? s.paperTitle : 'Chưa gán đề'}
</span>
<div className="footer-actions">
<button
type="button"
className="btn btn-ghost btn-xs text-primary"
className="gp-action-btn"
onClick={(e) => handleOpenChat(s, e)}
title="Nhắn tin cho sinh viên"
>
💬 Nhắn tin
<IconMessage size={13} />
Nhắn tin
</button>
<button
type="button"
className="btn btn-ghost btn-xs"
className="gp-action-btn gp-action-btn--primary"
onClick={() => onSelectStudent(s)}
title="Xem chi tiết giám sát"
>
🔍 Giám sát
<IconEye size={13} />
Giám sát
</button>
</div>
</div>
@@ -285,185 +316,69 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
})}
{filteredStudents.length === 0 && (
<div className="grid-proctor-empty" style={{ gridColumn: '1 / -1' }}>
<span>🔍</span>
<div className="grid-proctor-empty">
<span className="gp-empty-icon"><IconSearch size={28} /></span>
<p>Không tìm thấy sinh viên nào.</p>
</div>
)}
</div>
{/* Pagination Controls */}
{totalPages > 1 && (
<div
className="grid-proctor-pagination"
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
gap: '1rem',
padding: '1rem 0',
borderTop: '1px solid var(--border-light)',
marginTop: '1.25rem',
}}
>
<div className="grid-proctor-pagination">
<button
type="button"
className="btn btn-secondary btn-sm"
className="btn btn-secondary btn-sm gp-page-btn"
disabled={currentPage === 1}
onClick={() => setCurrentPage((p) => Math.max(p - 1, 1))}
style={{ fontWeight: 600 }}
>
&larr; Trang trước
<IconChevronLeft size={14} />
Trang trước
</button>
<span style={{ fontSize: '0.85rem', color: 'var(--text-secondary)', fontWeight: 600 }}>
Trang {currentPage} / {totalPages} (Tổng {filteredStudents.length} bạn)
<span className="gp-page-info">
Trang {currentPage} / {totalPages}
<span className="gp-page-total"> · {filteredStudents.length} bạn</span>
</span>
<button
type="button"
className="btn btn-secondary btn-sm"
className="btn btn-secondary btn-sm gp-page-btn"
disabled={currentPage === totalPages}
onClick={() => setCurrentPage((p) => Math.min(p + 1, totalPages))}
style={{ fontWeight: 600 }}
>
Trang sau &rarr;
Trang sau
<IconChevronRight size={14} />
</button>
</div>
)}
{/* Zoom Modal Overlay (Rendered inside the container so it works in fullscreen mode) */}
{zoomedStudent && (
<div
className="zoomed-proctor-overlay"
onClick={() => setZoomedStudent(null)}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
backgroundColor: 'rgba(10, 15, 30, 0.9)',
backdropFilter: 'blur(8px)',
zIndex: 9999,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '2rem',
animation: 'fadeIn 0.2s ease-out',
}}
>
<div
className="zoomed-proctor-modal"
onClick={(e) => e.stopPropagation()}
style={{
width: '100%',
maxWidth: '1000px',
backgroundColor: 'var(--bg-card)',
border: '1px solid var(--border-color)',
borderRadius: '16px',
boxShadow: '0 20px 40px rgba(0, 0, 0, 0.4)',
display: 'flex',
flexDirection: 'column',
maxHeight: '90%',
overflow: 'hidden',
}}
>
{/* Modal Header */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '1rem 1.5rem',
borderBottom: '1px solid var(--border-color)',
backgroundColor: 'var(--bg-subtle)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<span
style={{
width: '10px',
height: '10px',
borderRadius: '50%',
backgroundColor: onlineIds.includes(zoomedStudent.studentRkId) ? 'var(--success)' : 'var(--danger)',
boxShadow: onlineIds.includes(zoomedStudent.studentRkId) ? '0 0 8px var(--success)' : 'none',
}}
/>
<h3 style={{ margin: 0, color: 'var(--text-primary)', fontSize: '1.1rem', fontWeight: 600 }}>
{zoomedStudent.fullName}
</h3>
<span
style={{
fontFamily: 'monospace',
fontSize: '0.85rem',
color: 'var(--text-secondary)',
backgroundColor: 'var(--bg-subtle)',
padding: '2px 8px',
borderRadius: '4px',
}}
>
{zoomedStudent.studentCode}
</span>
</div>
<button
onClick={() => setZoomedStudent(null)}
style={{
background: 'none',
border: 'none',
color: 'var(--text-secondary)',
fontSize: '1.5rem',
cursor: 'pointer',
padding: '4px 8px',
lineHeight: 1,
}}
>
&times;
{zoomedStudent && (
<div className="zoomed-proctor-overlay" onClick={() => setZoomedStudent(null)}>
<div className="zoomed-proctor-modal" onClick={(e) => e.stopPropagation()}>
<div className="zoomed-proctor-header">
<div className="zoomed-proctor-title">
<span
className={`status-dot ${onlineIds.includes(zoomedStudent.studentRkId) ? 'online' : 'offline'}`}
/>
<h3>{zoomedStudent.fullName}</h3>
<span className="student-code">{zoomedStudent.studentCode}</span>
</div>
<button type="button" className="zoomed-proctor-close" onClick={() => setZoomedStudent(null)} aria-label="Đóng">
<IconClose size={18} />
</button>
</div>
{/* Modal Body */}
<div
style={{
flex: 1,
padding: '1.5rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#000',
position: 'relative',
minHeight: '400px',
overflow: 'hidden',
}}
>
<div style={{ position: 'relative', width: '100%', height: '100%', display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
<div className="zoomed-proctor-body">
<div className="zoomed-proctor-frame">
<StudentStreamImage
studentId={zoomedStudent.studentRkId}
kind="screen"
style={{
maxWidth: '100%',
maxHeight: '65vh',
objectFit: 'contain',
borderRadius: '8px',
}}
className="zoomed-proctor-screen"
/>
{showWebcamOverlay && (
<div
style={{
position: 'absolute',
bottom: '20px',
right: '20px',
width: '240px',
aspectRatio: '4/3',
borderRadius: '8px',
border: '2px solid #ffffff',
boxShadow: '0 8px 16px rgba(0, 0, 0, 0.4)',
overflow: 'hidden',
backgroundColor: '#000',
}}
>
<div className="zoomed-proctor-webcam">
<StudentStreamImage
studentId={zoomedStudent.studentRkId}
kind="webcam"
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
className="proctor-webcam-image"
/>
</div>
)}
@@ -472,20 +387,6 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
</div>
</div>
)}
<style>{`
.cursor-zoom-in {
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.cursor-zoom-in:hover {
transform: scale(1.015);
box-shadow: 0 6px 16px rgba(0,0,0,0.3);
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
`}</style>
</div>
);
};

View File

@@ -26,6 +26,101 @@ import { ViolationsPanel } from './ViolationsPanel';
const EXAM_APP_SUGGESTIONS = [...new Set([...BASE_APP_SUGGESTIONS, 'msedge', 'edge', 'acrobat', 'foxit'])];
/* ─── Icons (match ClassWorkspace) ───────────────────────────────────────── */
type IconProps = { size?: number };
const IconExam = ({ size = 20 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="9" y1="13" x2="15" y2="13" />
<line x1="9" y1="17" x2="13" y2="17" />
</svg>
);
const IconSettings = ({ size = 14 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z" />
</svg>
);
const IconClose = ({ size = 16 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M18 6 6 18M6 6l12 12" />
</svg>
);
const IconMap = ({ size = 14 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<polygon points="3 6 9 3 15 6 21 3 21 18 15 21 9 18 3 21" />
<line x1="9" y1="3" x2="9" y2="18" />
<line x1="15" y1="6" x2="15" y2="21" />
</svg>
);
const IconMonitor = ({ size = 14 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<rect x="2" y="3" width="20" height="14" rx="2" />
<path d="M8 21h8M12 17v4" />
</svg>
);
const IconList = ({ size = 14 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<line x1="8" y1="6" x2="21" y2="6" />
<line x1="8" y1="12" x2="21" y2="12" />
<line x1="8" y1="18" x2="21" y2="18" />
<line x1="3" y1="6" x2="3.01" y2="6" />
<line x1="3" y1="12" x2="3.01" y2="12" />
<line x1="3" y1="18" x2="3.01" y2="18" />
</svg>
);
const IconPackage = ({ size = 14 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
<line x1="12" y1="22.08" x2="12" y2="12" />
</svg>
);
const IconShield = ({ size = 14 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg>
);
const IconSearch = ({ size = 14 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<circle cx="11" cy="11" r="7" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
);
const IconUsers = ({ size = 13 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="3.5" />
<path d="M22 21v-2a3.5 3.5 0 0 0-2.5-3.35" />
<path d="M16 3.5a3.5 3.5 0 0 1 0 7" />
</svg>
);
const IconPlus = ({ size = 14 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
);
const IconInbox = ({ size = 36 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<polyline points="22 12 16 12 14 15 10 15 8 12 2 12" />
<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
</svg>
);
interface Props {
examId: number;
onBack: () => void;
@@ -660,11 +755,12 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
<div className={`alert-banner ${err ? 'alert-error' : 'alert-success'}`}>{err || msg}</div>
)}
<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>
<div className="workspace-header ew-header">
<div className="ew-header-left">
<div className="ew-brand-icon">
<IconExam size={20} />
</div>
<div className="ew-title-block">
<h1 className="workspace-class-title">{roomName || 'Phòng thi'}</h1>
<div className="class-badge-container">
<span className={`badge ${badge.cls}`}>{badge.text}</span>
@@ -678,19 +774,16 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
</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 thi
</span>
<span style={{ fontSize: '0.875rem', fontWeight: 700, color: statusColor(displayStatus) }}>
<div className="ew-header-right">
<div className="status-panel ew-status-panel">
<div className="ew-status-label-group">
<span className="ew-meta-label">Trạng thái thi</span>
<span className="ew-status-value" style={{ color: statusColor(displayStatus) }}>
{badge.text}
</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
<div className="ew-status-actions">
{canPublish && (
<button type="button" className="btn btn-primary btn-sm" onClick={handlePublish}>Đy phòng thi</button>
)}
@@ -701,8 +794,8 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
<button type="button" className="btn btn-secondary btn-sm learning-btn-danger" onClick={handleCancel}>Hủy</button>
)}
{canExtend && (
<div style={{ display: 'flex', gap: '0.35rem', flexWrap: 'wrap', alignItems: 'center' }}>
<span style={{ fontSize: '0.72rem', color: 'var(--text-muted)', fontWeight: 600 }}>Gia hạn</span>
<div className="ew-extend-row">
<span className="ew-meta-label">Gia hạn</span>
{[15, 30, 60].map((m) => (
<button
key={m}
@@ -718,20 +811,20 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
</div>
)}
{papers.length > 0 && (
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setPapersModalOpen(true)}>
📄 Xem đ
<button type="button" className="btn btn-secondary btn-sm ew-icon-btn" onClick={() => setPapersModalOpen(true)}>
<IconExam size={13} />
Xem đ
</button>
)}
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end' }}>
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)', fontWeight: 600, textTransform: 'uppercase' }}>
Online / số
</div>
<div className="workspace-stat-value">
<div className="ew-online-stat">
<span className="ew-meta-label">Online / số</span>
<div className="workspace-stat-value ew-online-value">
<IconUsers size={13} />
<span style={{ color: 'var(--success)' }}>{onlineCount}</span>
<span style={{ color: 'var(--text-muted)', fontSize: '0.9rem', fontWeight: 500 }}> / {students.length} SV</span>
<span className="ew-online-total">/ {students.length} SV</span>
</div>
</div>
</div>
@@ -744,8 +837,13 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
<aside className={`workspace-config-drawer ${configOpen ? 'open' : ''}`}>
<div className="workspace-drawer-header">
<strong>Cấu hình phòng thi</strong>
<button type="button" className="btn btn-secondary workspace-drawer-close" onClick={() => setConfigOpen(false)} aria-label="Đóng">×</button>
<span className="ew-drawer-title">
<IconSettings size={16} />
Cấu hình phòng thi
</span>
<button type="button" className="ew-drawer-close" onClick={() => setConfigOpen(false)} aria-label="Đóng">
<IconClose size={16} />
</button>
</div>
<div className="workspace-drawer-tabs">
<button type="button" className={`workspace-drawer-tab ${configTab === 'info' ? 'active' : ''}`} onClick={() => setConfigTab('info')}>
@@ -1030,87 +1128,103 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
</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' }}>
<div className={`right-panel workspace-content-panel${activeSubTab === 'grid' ? ' workspace-content-panel--grid' : ''}`}>
<div className="workspace-panel-toolbar ew-toolbar">
<div className="ew-toolbar-left">
<button
type="button"
className={`btn btn-secondary workspace-config-toggle ${configOpen ? 'active' : ''}`}
className={`btn btn-secondary workspace-config-toggle ew-config-toggle ${configOpen ? 'active' : ''}`}
onClick={() => setConfigOpen((v) => !v)}
>
<IconSettings size={14} />
{configOpen ? 'Ẩn cấu hình' : 'Cấu hình phòng thi'}
</button>
<div className="tab-btn-group">
<div className="ew-seg" role="tablist">
<button
type="button"
className={`tab-sub-btn ${activeSubTab === 'roster' ? 'active' : ''}`}
role="tab"
aria-selected={activeSubTab === 'roster'}
className={`ew-seg-btn ${activeSubTab === 'roster' ? 'ew-seg-btn--active' : ''}`}
onClick={() => setActiveSubTab('roster')}
>
đ sinh viên
<IconMap size={14} />
đ
</button>
<button
type="button"
className={`tab-sub-btn ${activeSubTab === 'grid' ? 'active' : ''}`}
role="tab"
aria-selected={activeSubTab === 'grid'}
className={`ew-seg-btn ${activeSubTab === 'grid' ? 'ew-seg-btn--active' : ''}`}
onClick={() => setActiveSubTab('grid')}
>
Giám sát camera 🖥
<IconMonitor size={14} />
Giám sát
</button>
<button
type="button"
className={`tab-sub-btn ${activeSubTab === 'detail' ? 'active' : ''}`}
role="tab"
aria-selected={activeSubTab === 'detail'}
className={`ew-seg-btn ${activeSubTab === 'detail' ? 'ew-seg-btn--active' : ''}`}
onClick={() => setActiveSubTab('detail')}
>
<IconList size={14} />
Chi tiết thi
</button>
<button
type="button"
className={`tab-sub-btn ${activeSubTab === 'submissions' ? 'active' : ''}`}
role="tab"
aria-selected={activeSubTab === 'submissions'}
className={`ew-seg-btn ${activeSubTab === 'submissions' ? 'ew-seg-btn--active' : ''}`}
onClick={() => setActiveSubTab('submissions')}
>
<IconPackage size={14} />
Bài nộp ({submissions.length})
</button>
<button
type="button"
className={`tab-sub-btn ${activeSubTab === 'violations' ? 'active' : ''}`}
role="tab"
aria-selected={activeSubTab === 'violations'}
className={`ew-seg-btn ${activeSubTab === 'violations' ? 'ew-seg-btn--active' : ''}`}
onClick={() => setActiveSubTab('violations')}
>
<IconShield size={14} />
Vi phạm
</button>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
<div className="ew-toolbar-right">
{activeSubTab !== 'submissions' && activeSubTab !== 'grid' && activeSubTab !== 'violations' && (
<div className="search-input-wrapper">
<div className="ew-search-wrapper">
<span className="ew-search-icon"><IconSearch size={14} /></span>
<input
type="text"
className="search-input"
type="search"
className="search-input ew-search-input"
placeholder="Tìm sinh viên..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<span className="search-icon">🔍</span>
</div>
)}
{prepEditable && (
<button
type="button"
className="btn btn-primary btn-sm"
className="btn btn-primary btn-sm ew-icon-btn"
onClick={() => { setStudentPickerOpen(true); setSearchQ(''); setSearchHits([]); setSelectedPickerRkIds([]); }}
>
+ Thêm sinh viên
<IconPlus size={14} />
Thêm sinh viên
</button>
)}
{papers.length > 0 && (
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setPapersModalOpen(true)}>
📄 Xem đ ({papers.length})
<button type="button" className="btn btn-secondary btn-sm ew-icon-btn" onClick={() => setPapersModalOpen(true)}>
<IconExam size={13} />
Xem đ ({papers.length})
</button>
)}
</div>
</div>
<div className="divider" style={{ opacity: 0.3, margin: '0.25rem 0' }} />
</div>
<div className="ew-toolbar-divider" />
<div className="workspace-panel-body workspace-panel-fill">
{activeSubTab === 'roster' ? (
@@ -1139,8 +1253,8 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
<div className="session-logs-panel">
<div className="attendance-table-scroll table-wrapper" style={{ border: 'none' }}>
{students.length === 0 ? (
<div className="empty-state" style={{ minHeight: '300px' }}>
<div className="empty-state-icon">📋</div>
<div className="empty-state ew-empty" style={{ minHeight: '300px' }}>
<div className="ew-empty-icon"><IconInbox size={36} /></div>
<h2>Chưa sinh viên</h2>
<p>Thêm sinh viên đ xem chi tiết đ gán trạng thái nộp bài.</p>
</div>
@@ -1228,10 +1342,11 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
{papers.length > 0 && (
<button
type="button"
className="btn btn-secondary btn-sm"
className="btn btn-secondary btn-sm ew-icon-btn"
onClick={() => setPapersModalOpen(true)}
>
📄 Xem đ phòng thi
<IconExam size={13} />
Xem đ phòng thi
</button>
)}
<button
@@ -1240,7 +1355,7 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
disabled={bundlingSubs}
onClick={downloadAllSubs}
>
{bundlingSubs ? 'Đang gói ZIP...' : '📦 Tải tất cả (ZIP gộp)'}
{bundlingSubs ? 'Đang gói ZIP...' : 'Tải tất cả (ZIP gộp)'}
</button>
<button
type="button"
@@ -1275,8 +1390,8 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
)}
<div className="attendance-table-scroll table-wrapper" style={{ border: 'none' }}>
{submissions.length === 0 ? (
<div className="empty-state" style={{ minHeight: '300px' }}>
<div className="empty-state-icon">📦</div>
<div className="empty-state ew-empty" style={{ minHeight: '300px' }}>
<div className="ew-empty-icon"><IconPackage size={36} /></div>
<h2>Chưa bài nộp</h2>
<p>Sinh viên nộp bài qua app Simple Care sẽ hiện tại đây.</p>
</div>
@@ -1400,6 +1515,7 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
student={selectedStudent}
isOnline={onlineIds.includes(selectedStudent.rkId)}
onClose={() => setSelectedStudent(null)}
initialShowProctor={activeSubTab === 'grid' || activeSubTab === 'roster'}
/>
)}
@@ -1510,17 +1626,17 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
<div className="exam-student-hits">
{searchMode === 'single' && searchQ.trim() === '' ? (
<div className="exam-student-hits-empty">
<span>🔍</span>
<span className="ew-empty-icon"><IconSearch size={28} /></span>
<p> ít nhất vài tự đ tìm sinh viên.</p>
</div>
) : searchMode === 'bulk' && searchHits.length === 0 ? (
<div className="exam-student-hits-empty">
<span>📋</span>
<span className="ew-empty-icon"><IconList size={28} /></span>
<p>Paste danh sách bấm "Tìm hàng loạt" đ hiển thị kết quả.</p>
</div>
) : searchHits.length === 0 ? (
<div className="exam-student-hits-empty">
<span>📭</span>
<span className="ew-empty-icon"><IconInbox size={28} /></span>
<p>Không tìm thấy sinh viên phù hợp.</p>
</div>
) : (
@@ -1622,6 +1738,103 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
onSelect={applyTemplate}
allowedApps={allowedApps}
/>
<style>{`
.ew-header { padding: 0.7rem 1.1rem; gap: 0.65rem; }
.ew-header-left { display: flex; align-items: center; gap: 0.75rem; min-width: 0; }
.ew-brand-icon {
width: 40px; height: 40px; border-radius: var(--radius-sm);
background: var(--accent-light); color: var(--accent);
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.ew-title-block { display: flex; flex-direction: column; gap: 0.15rem; min-width: 0; }
.ew-header .workspace-class-title { font-size: 1.2rem; font-weight: 700; margin: 0; line-height: 1.25; }
.ew-header .class-badge-container { margin-top: 0.15rem; gap: 0.35rem; }
.ew-header-right { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; }
.ew-status-panel { gap: 0.75rem; padding: 0.4rem 0.85rem; }
.ew-status-label-group { display: flex; flex-direction: column; }
.ew-meta-label {
font-size: 0.68rem; color: var(--text-muted); font-weight: 600;
text-transform: uppercase; letter-spacing: 0.04em;
}
.ew-status-value { font-size: 0.88rem; font-weight: 700; }
.ew-status-actions { display: flex; flex-direction: column; gap: 0.3rem; }
.ew-extend-row { display: flex; gap: 0.3rem; flex-wrap: wrap; align-items: center; }
.ew-online-stat { display: flex; flex-direction: column; align-items: flex-end; gap: 0.1rem; }
.ew-online-value {
display: flex; align-items: center; gap: 0.3rem; font-size: 1.1rem !important;
}
.ew-online-total { color: var(--text-muted); font-size: 0.82rem; font-weight: 500; }
.ew-drawer-title {
display: inline-flex; align-items: center; gap: 0.45rem;
font-weight: 700; font-size: 0.92rem; color: var(--text-primary);
}
.ew-drawer-close {
display: flex; align-items: center; justify-content: center;
width: 28px; height: 28px; border: none; background: var(--bg-subtle);
border-radius: var(--radius-sm); color: var(--text-secondary); cursor: pointer;
}
.ew-drawer-close:hover { background: var(--border-color); color: var(--text-primary); }
.workspace-content-panel.right-panel {
padding: 0.65rem 0.85rem 0.85rem; gap: 0;
}
.workspace-content-panel--grid { padding-top: 0.55rem; }
.ew-toolbar {
display: flex; justify-content: space-between; align-items: center;
flex-wrap: wrap; gap: 0.55rem 0.75rem; flex-shrink: 0;
}
.ew-toolbar-left, .ew-toolbar-right {
display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;
}
.ew-config-toggle {
display: inline-flex !important; align-items: center; gap: 0.35rem;
padding: 0.38rem 0.85rem !important; font-size: 0.8rem !important; font-weight: 600 !important;
}
.ew-toolbar-divider {
margin: 0.5rem 0 0.35rem; border-top: 1px solid var(--border-light);
opacity: 0.45; flex-shrink: 0;
}
.workspace-content-panel--grid .ew-toolbar-divider { margin: 0.4rem 0 0; opacity: 0.35; }
.ew-seg {
display: flex; background: var(--bg-subtle); border: 1px solid var(--border-color);
border-radius: var(--radius-sm); padding: 0.18rem; gap: 0.15rem; flex-wrap: wrap;
}
.ew-seg-btn {
display: flex; align-items: center; gap: 0.3rem; padding: 0.3rem 0.65rem;
font-size: 0.78rem; font-weight: 600; background: transparent; color: var(--text-secondary);
border: none; border-radius: 4px; cursor: pointer; white-space: nowrap;
font-family: var(--font-sans); transition: background 0.14s, color 0.14s;
}
.ew-seg-btn:hover:not(.ew-seg-btn--active) {
background: var(--border-light); color: var(--text-primary);
}
.ew-seg-btn--active {
background: #fff; color: var(--accent); box-shadow: 0 1px 3px rgba(0,0,0,0.08);
}
.ew-search-wrapper { position: relative; max-width: 220px; width: 100%; }
.ew-search-icon {
position: absolute; left: 0.6rem; top: 50%; transform: translateY(-50%);
color: var(--text-muted); display: flex; pointer-events: none;
}
.ew-search-input { padding: 0.38rem 0.8rem 0.38rem 2rem !important; font-size: 0.82rem !important; width: 100%; }
.ew-icon-btn {
display: inline-flex !important; align-items: center; gap: 0.3rem;
}
.ew-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 0.45rem; }
.ew-empty-icon { display: flex; color: var(--text-muted); opacity: 0.55; }
.exam-student-hits-empty .ew-empty-icon { margin-bottom: 0.25rem; }
@media (max-width: 960px) {
.ew-header { padding: 0.65rem 0.85rem; }
.ew-header .workspace-class-title { font-size: 1.05rem; }
.ew-seg-btn { padding: 0.28rem 0.5rem; font-size: 0.74rem; }
}
`}</style>
</div>
);
};

View File

@@ -11,13 +11,13 @@ function fmtTime(iso: string) {
}
}
function statusLabel(st: string) {
if (st === 'draft') return { text: 'Tạm thời', cls: 'badge-muted' };
if (st === 'ready') return { text: 'Sẵn sàng', cls: 'badge-info' };
if (st === 'active') return { text: 'Đang thi', cls: 'badge-success' };
if (st === 'cancelled') return { text: 'Đã hủy', cls: 'badge-warning' };
if (st === 'ended') return { text: 'Đã kết thúc', cls: 'badge-muted' };
return { text: st, cls: 'badge-muted' };
function statusMeta(st: string) {
if (st === 'draft') return { text: 'Tạm thời', tone: 'muted' };
if (st === 'ready') return { text: 'Sẵn sàng', tone: 'info' };
if (st === 'active') return { text: 'Đang thi', tone: 'active' };
if (st === 'cancelled') return { text: 'Đã hủy', tone: 'warn' };
if (st === 'ended') return { text: 'Đã kết thúc', tone: 'muted' };
return { text: st, tone: 'muted' };
}
function toLocalInput(iso?: string) {
@@ -33,6 +33,64 @@ function localInputToISO(v: string) {
}
type RoomScope = 'mine' | 'all';
type IconProps = { size?: number; filled?: boolean };
const IconClipboard = ({ size = 22 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2" />
<rect x="9" y="3" width="6" height="4" rx="1" />
<path d="M9 12h6M9 16h4" />
</svg>
);
const IconPlus = ({ size = 16 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M12 5v14M5 12h14" />
</svg>
);
const IconStar = ({ size = 14, filled }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill={filled ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="m12 3.5 2.6 5.3 5.8.8-4.2 4.1 1 5.8L12 16.8l-5.2 2.7 1-5.8-4.2-4.1 5.8-.8L12 3.5z" />
</svg>
);
const IconSearch = ({ size = 16 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<circle cx="11" cy="11" r="7" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
);
const IconCalendar = ({ size = 13 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<rect x="3" y="5" width="18" height="16" rx="2" />
<path d="M16 3v4M8 3v4M3 11h18" />
</svg>
);
const IconUsers = ({ size = 12 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="3.5" />
<path d="M22 21v-2a3.5 3.5 0 0 0-2.5-3.35" />
<path d="M16 3.5a3.5 3.5 0 0 1 0 7" />
</svg>
);
const IconOpen = ({ size = 14 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M5 12h14" />
<path d="m13 6 6 6-6 6" />
</svg>
);
const IconInbox = ({ size = 36 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<polyline points="22 12 16 12 14 15 10 15 8 12 2 12" />
<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
</svg>
);
export const ExamsTab: React.FC = () => {
const { staff } = useAuth();
@@ -52,7 +110,6 @@ export const ExamsTab: React.FC = () => {
const load = async () => {
setLoading(true);
try {
// Luôn lấy đủ danh sách để toggle mine/all mượt; lọc phía client theo createdByStaffId
const res = await apiExam.list();
setRooms(res.data);
} catch (e) {
@@ -112,7 +169,11 @@ export const ExamsTab: React.FC = () => {
};
const handleDeleteRoom = async (roomId: number, roomName: string) => {
if (window.confirm(`Bạn có chắc chắn muốn xóa hoàn toàn phòng thi "${roomName}" không? Hành động này sẽ xóa sạch dữ liệu phòng thi, các bài nộp, và không thể khôi phục.`)) {
if (
window.confirm(
`Bạn có chắc chắn muốn xóa hoàn toàn phòng thi "${roomName}" không? Hành động này sẽ xóa sạch dữ liệu phòng thi, các bài nộp, và không thể khôi phục.`
)
) {
try {
await apiExam.remove(roomId);
alert('Đã xóa phòng thi thành công!');
@@ -125,24 +186,37 @@ export const ExamsTab: React.FC = () => {
return (
<div className="tab-page exams-page">
<header className="page-header page-header--row">
<div>
<h1 className="page-title">Danh sách phòng thi</h1>
<p className="page-desc">
<div className="tab-page-toolbar">
<div className="page-header">
<div className="page-title">
<h1 className="page-title-heading">
<span className="page-title-icon" aria-hidden>
<IconClipboard />
</span>
Danh sách phòng thi
</h1>
<p>
{roomScope === 'mine'
? 'Mặc định chỉ hiện phòng thi do bạn tạo — chuyển sang xem tất cả khi cần'
: 'Đang xem toàn bộ phòng thi trong hệ thống'}
</p>
</div>
<div className="exams-header-actions">
<button type="button" className="btn btn-primary" onClick={() => setShowCreate(true)}>
+ Tạo phòng thi
<IconPlus />
Tạo phòng thi
</button>
</header>
</div>
</div>
</div>
<div className="tab-page-body" style={{ gap: '0.85rem' }}>
<div className="exams-stats-row">
<div className="learning-stat">
<span className="learning-stat-value">{stats.total}</span>
<span className="learning-stat-label">{roomScope === 'mine' ? 'Phòng của tôi' : 'Tổng phòng'}</span>
<span className="learning-stat-label">
{roomScope === 'mine' ? 'Phòng của tôi' : 'Tổng phòng'}
</span>
</div>
<div className="learning-stat learning-stat--accent">
<span className="learning-stat-value">{stats.active}</span>
@@ -154,68 +228,174 @@ export const ExamsTab: React.FC = () => {
</div>
</div>
<div className="exams-toolbar" style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', alignItems: 'center' }}>
<div className="learning-view-toggle">
<div className="content-card learning-toolbar-card exams-toolbar-card">
<div className="learning-toolbar exams-toolbar-inner">
<div className="learning-seg" role="group" aria-label="Phạm vi phòng thi">
<button
type="button"
className={`learning-view-btn${roomScope === 'mine' ? ' learning-view-btn--active' : ''}`}
className={`learning-seg-btn${roomScope === 'mine' ? ' learning-seg-btn--active' : ''}`}
onClick={() => setRoomScope('mine')}
>
Phòng của tôi
<IconStar size={13} filled={roomScope === 'mine'} />
<span>Phòng của tôi</span>
</button>
<button
type="button"
className={`learning-view-btn${roomScope === 'all' ? ' learning-view-btn--active' : ''}`}
className={`learning-seg-btn${roomScope === 'all' ? ' learning-seg-btn--active' : ''}`}
onClick={() => setRoomScope('all')}
>
Xem tất cả
<span>Xem tất cả</span>
</button>
</div>
<input
type="search"
className="app-pool-search exams-search"
className="app-pool-search learning-search exams-search"
placeholder="Tìm theo tên phòng thi..."
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{ flex: 1, minWidth: 200 }}
/>
</div>
</div>
<div className="tab-page-scroll">
<div className="content-card" style={{ padding: 0 }}>
{loading ? (
<div className="exams-empty-panel">
<div className="sync-spinner" style={{ width: 36, height: 36, borderWidth: 3 }} />
<p>Đang tải danh sách phòng thi...</p>
</div>
) : filtered.length === 0 ? (
<div className="exams-empty-panel">
<div className="exams-empty-icon">
{search.trim() ? <IconSearch size={32} /> : <IconInbox />}
</div>
<p>
{search.trim()
? 'Không tìm thấy phòng thi phù hợp.'
: roomScope === 'mine'
? 'Bạn chưa tạo phòng thi nào.'
: 'Chưa có phòng thi — bấm Tạo phòng thi để bắt đầu.'}
</p>
{!search.trim() && (
<div className="exams-empty-actions">
<button type="button" className="btn btn-primary" onClick={() => setShowCreate(true)}>
<IconPlus />
Tạo phòng thi
</button>
{roomScope === 'mine' && (
<button type="button" className="btn btn-secondary" onClick={() => setRoomScope('all')}>
Xem tất cả
</button>
)}
</div>
)}
</div>
) : (
<div className="exams-card-grid">
{filtered.map((r) => {
const st = statusMeta(r.displayStatus || r.status);
const mine = myStaffId > 0 && Number(r.createdByStaffId) === myStaffId;
const isActive = (r.displayStatus || r.status) === 'active';
return (
<article
key={r.id}
className={`exam-list-card${isActive ? ' exam-list-card--active' : ''}`}
>
<header className="exam-list-card-head">
<div className="exam-list-card-info">
<h3 className="exam-list-card-title" title={r.name}>{r.name}</h3>
<div className="exam-list-card-meta-block">
<div className="exam-meta-row">
<IconCalendar />
<span>{fmtTime(r.startTime)}</span>
<span className="exam-list-card-sep"></span>
<span>{fmtTime(r.endTime)}</span>
</div>
<div className="exam-meta-row exam-meta-row--secondary">
<IconUsers />
<span>
{r.studentCount} SV · {r.paperCount} đ
</span>
</div>
</div>
</div>
<div className="exam-list-card-badges">
{mine && <span className="exam-tag exam-tag--mine">Của tôi</span>}
<span className={`exam-tag exam-tag--${st.tone}`}>{st.text}</span>
</div>
</header>
<footer className="exam-list-card-actions">
<button
type="button"
className="btn btn-primary btn-sm"
onClick={() => openExam(r.id, r.name)}
>
Mở phòng thi
<IconOpen />
</button>
{canDeleteRoom(r) && (
<button
type="button"
className="btn btn-secondary btn-sm exam-btn-danger"
onClick={(e) => {
e.stopPropagation();
handleDeleteRoom(r.id, r.name);
}}
>
Xóa
</button>
)}
</footer>
</article>
);
})}
</div>
)}
</div>
</div>
</div>
{showCreate && (
<div className="modal-overlay" onClick={() => setShowCreate(false)}>
<div className="modal-container" style={{ maxWidth: '500px' }} onClick={e => e.stopPropagation()}>
<div className="modal-header">
<div className="modal-container class-picker-modal exam-create-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header class-picker-header">
<div>
<h2 className="modal-title">Tạo phòng thi mới</h2>
<button className="modal-close-btn" onClick={() => setShowCreate(false)} aria-label="Đóng">&times;</button>
<p className="class-picker-subtitle">
Đt tên khung giờ thi. Sau khi tạo sẽ mở workspace đ cấu hình đ sinh viên.
</p>
</div>
<div className="modal-body" style={{ padding: '1.5rem', display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
<label className="login-field" style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
<span style={{ fontWeight: 600, fontSize: '0.85rem', color: 'var(--text-secondary)' }}>Tên phòng thi</span>
<button className="modal-close-btn" onClick={() => setShowCreate(false)} aria-label="Đóng">
&times;
</button>
</div>
<div className="modal-body class-picker-body exam-create-body">
<label className="exam-field">
<span className="exam-field-label">Tên phòng thi</span>
<input
className="search-input"
style={{ width: '100%', boxSizing: 'border-box' }}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="VD: Thi cuối kỳ Java"
autoFocus
/>
</label>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
<label className="login-field" style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
<span style={{ fontWeight: 600, fontSize: '0.85rem', color: 'var(--text-secondary)' }}>Bắt đu</span>
<div className="exam-field-grid">
<label className="exam-field">
<span className="exam-field-label">Bắt đu</span>
<input
type="datetime-local"
className="search-input"
style={{ width: '100%', boxSizing: 'border-box', padding: '0.5rem 0.75rem' }}
className="search-input exam-datetime"
value={start}
onChange={(e) => setStart(e.target.value)}
/>
</label>
<label className="login-field" style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
<span style={{ fontWeight: 600, fontSize: '0.85rem', color: 'var(--text-secondary)' }}>Kết thúc</span>
<label className="exam-field">
<span className="exam-field-label">Kết thúc</span>
<input
type="datetime-local"
className="search-input"
style={{ width: '100%', boxSizing: 'border-box', padding: '0.5rem 0.75rem' }}
className="search-input exam-datetime"
value={end}
onChange={(e) => setEnd(e.target.value)}
/>
@@ -226,7 +406,12 @@ export const ExamsTab: React.FC = () => {
<button type="button" className="btn btn-secondary" onClick={() => setShowCreate(false)}>
Hủy
</button>
<button type="button" className="btn btn-primary" disabled={busy} onClick={create}>
<button
type="button"
className="btn btn-primary"
disabled={busy || !name.trim() || !start || !end}
onClick={create}
>
{busy ? 'Đang tạo...' : 'Tạo & mở phòng'}
</button>
</div>
@@ -234,85 +419,327 @@ export const ExamsTab: React.FC = () => {
</div>
)}
{loading ? (
<div className="system-empty">Đang tải...</div>
) : filtered.length === 0 ? (
<div className="system-empty" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.75rem' }}>
<p style={{ margin: 0 }}>
{search.trim()
? 'Không tìm thấy phòng thi phù hợp.'
: roomScope === 'mine'
? 'Bạn chưa tạo phòng thi nào.'
: 'Chưa có phòng thi — bấm Tạo phòng thi để bắt đầu.'}
</p>
{!search.trim() && roomScope === 'mine' && (
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button type="button" className="btn btn-primary" onClick={() => setShowCreate(true)}>
+ Tạo phòng thi
</button>
<button type="button" className="btn btn-secondary" onClick={() => setRoomScope('all')}>
Xem tất cả
</button>
</div>
)}
</div>
) : (
<div className="exams-card-grid">
{filtered.map((r) => {
const st = statusLabel(r.displayStatus || r.status);
const mine = myStaffId > 0 && Number(r.createdByStaffId) === myStaffId;
return (
<article key={r.id} className="exam-list-card">
<div className="exam-list-card-head">
<h3>{r.name}</h3>
<div style={{ display: 'flex', gap: '0.35rem', alignItems: 'center', flexWrap: 'wrap' }}>
{mine && <span className="badge badge-warning" style={{ fontSize: '0.7rem' }}>Của tôi</span>}
<span className={`badge ${st.cls}`}>{st.text}</span>
</div>
</div>
<div className="exam-list-card-meta">
<div>
<span className="exam-list-card-label">Thời gian</span>
<span>{fmtTime(r.startTime)}</span>
<span className="exam-list-card-sep"></span>
<span>{fmtTime(r.endTime)}</span>
</div>
<div>
<span className="exam-list-card-label">Quy </span>
<span>{r.studentCount} sinh viên · {r.paperCount} đ</span>
</div>
</div>
<div style={{ display: 'flex', gap: '8px', marginTop: '1.25rem' }}>
<button type="button" className="btn btn-primary btn-sm exam-list-card-open" onClick={() => openExam(r.id, r.name)} style={{ flex: 1, margin: 0 }}>
Mở phòng thi
</button>
{canDeleteRoom(r) && (
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={(e) => {
e.stopPropagation();
handleDeleteRoom(r.id, r.name);
}}
style={{
borderColor: 'var(--danger)',
color: 'var(--danger)',
backgroundColor: 'transparent',
padding: '0.5rem 0.75rem',
fontWeight: 600,
transition: 'var(--transition)',
margin: 0
}}
>
Xóa
</button>
)}
</div>
</article>
);
})}
</div>
)}
<style>{`
.page-title-heading {
display: flex;
align-items: center;
gap: 0.55rem;
}
.page-title-icon {
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--accent);
flex-shrink: 0;
}
.exams-header-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.exams-header-actions .btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.exams-toolbar-card {
margin-bottom: 0 !important;
}
.exams-toolbar-inner {
margin: 0;
}
.exams-search {
max-width: none !important;
}
.exams-empty-panel {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.65rem;
padding: 2.75rem 1.25rem;
text-align: center;
}
.exams-empty-panel p {
margin: 0;
color: var(--text-secondary);
font-size: 0.9rem;
font-weight: 500;
max-width: 36ch;
line-height: 1.45;
}
.exams-empty-icon {
color: var(--text-muted);
display: flex;
}
.exams-empty-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
justify-content: center;
margin-top: 0.25rem;
}
.exams-empty-actions .btn {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.exams-card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 0.75rem;
padding: 0.85rem;
padding-bottom: 4.75rem;
}
.exam-list-card {
background: #fff;
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 0.85rem;
display: flex;
flex-direction: column;
gap: 0.55rem;
box-shadow: var(--shadow-sm);
transition: border-color 0.15s, box-shadow 0.15s;
min-height: 0;
}
.exam-list-card:hover {
border-color: #c5cdd8;
box-shadow: 0 3px 12px rgba(26, 35, 50, 0.08);
}
.exam-list-card--active {
border-left: 3px solid var(--accent);
background: rgba(187, 33, 38, 0.03);
}
.exam-list-card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
flex: 1;
}
.exam-list-card-info {
min-width: 0;
flex: 1;
}
.exam-list-card-title {
margin: 0 0 0.4rem;
font-size: 0.88rem;
font-weight: 700;
color: var(--text-primary);
line-height: 1.35;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.exam-list-card-meta-block {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.exam-meta-row {
display: flex;
align-items: flex-start;
gap: 0.35rem;
font-size: 0.7rem;
color: var(--text-secondary);
line-height: 1.4;
flex-wrap: wrap;
}
.exam-meta-row svg {
flex-shrink: 0;
color: var(--text-muted);
margin-top: 0.1rem;
}
.exam-meta-row--secondary {
color: var(--text-muted);
font-size: 0.68rem;
}
.exam-list-card-sep {
color: var(--text-muted);
opacity: 0.7;
}
.exam-list-card-badges {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.25rem;
flex-shrink: 0;
}
.exam-tag {
display: inline-flex;
align-items: center;
padding: 0.15rem 0.45rem;
border-radius: 999px;
font-size: 0.62rem;
font-weight: 700;
line-height: 1.2;
white-space: nowrap;
border: 1px solid transparent;
}
.exam-tag--mine {
background: rgba(234, 179, 8, 0.14);
color: #a16207;
border-color: rgba(234, 179, 8, 0.35);
}
.exam-tag--active {
background: rgba(13, 159, 110, 0.12);
color: #0b7a55;
border-color: rgba(13, 159, 110, 0.28);
}
.exam-tag--info {
background: rgba(37, 99, 235, 0.1);
color: #1d4ed8;
border-color: rgba(37, 99, 235, 0.22);
}
.exam-tag--warn {
background: rgba(245, 158, 11, 0.12);
color: #b45309;
border-color: rgba(245, 158, 11, 0.3);
}
.exam-tag--muted {
background: var(--bg-subtle);
color: var(--text-muted);
border-color: var(--border-color);
}
.exam-list-card-actions {
display: flex;
gap: 0.4rem;
margin-top: auto;
padding-top: 0.55rem;
border-top: 1px solid var(--border-light);
}
.exam-list-card-actions .btn {
flex: 1;
justify-content: center;
align-items: center;
gap: 0.25rem;
height: 30px;
padding: 0.35rem 0.5rem;
font-size: 0.72rem;
border-radius: 7px;
line-height: 1;
}
.exam-btn-danger {
color: #b91c1c !important;
border-color: #fecaca !important;
background: #fef2f2 !important;
flex: 0 0 auto !important;
min-width: 52px;
}
.exam-btn-danger:hover {
background: #fee2e2 !important;
border-color: #fca5a5 !important;
}
.exam-create-modal {
max-width: 520px !important;
}
.exam-create-modal .class-picker-header {
align-items: flex-start !important;
padding: 1.1rem 1.35rem !important;
}
.exam-create-modal .class-picker-subtitle {
margin: 0.3rem 0 0;
color: var(--text-muted);
font-size: 0.78rem;
line-height: 1.4;
max-width: 42ch;
}
.exam-create-modal .class-picker-body {
padding: 1rem 1.35rem 1.15rem !important;
display: flex;
flex-direction: column;
min-height: 0;
}
.exam-create-body {
gap: 0.9rem !important;
}
.exam-field {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.exam-field-label {
font-weight: 600;
font-size: 0.75rem;
color: var(--text-secondary);
}
.exam-field .search-input {
width: 100%;
box-sizing: border-box;
}
.exam-field-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
.exam-datetime {
padding: 0.5rem 0.75rem !important;
}
@media (max-width: 900px) {
.exams-header-actions {
width: 100%;
}
.exams-header-actions .btn {
flex: 1;
justify-content: center;
}
.page-header {
flex-direction: column;
align-items: stretch;
gap: 0.75rem;
}
.learning-seg {
width: 100%;
}
.learning-seg-btn {
flex: 1;
}
.exams-stats-row {
grid-template-columns: repeat(3, 1fr) !important;
gap: 0.45rem;
}
}
@media (max-width: 768px) {
.exams-card-grid {
grid-template-columns: 1fr;
padding: 0.75rem;
gap: 0.65rem;
}
.exam-list-card-actions .btn {
height: 32px;
font-size: 0.75rem;
}
}
@media (max-width: 560px) {
.exams-stats-row {
grid-template-columns: 1fr !important;
}
.exam-field-grid {
grid-template-columns: 1fr;
}
.exam-list-card-actions {
flex-direction: column;
}
.exam-btn-danger {
width: 100%;
}
}
`}</style>
</div>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,54 @@ interface ProctorStreamPanelsProps {
layout?: 'default' | 'focus';
}
type IconProps = { size?: number };
const IconMinus = ({ size = 14 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden>
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
);
const IconPlus = ({ size = 14 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
);
const IconMaximize = ({ size = 13 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M8 3H5a2 2 0 0 0-2 2v3" />
<path d="M21 8V5a2 2 0 0 0-2-2h-3" />
<path d="M3 16v3a2 2 0 0 0 2 2h3" />
<path d="M16 21h3a2 2 0 0 0 2-2v-3" />
</svg>
);
const IconMinimize = ({ size = 13 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M8 3v3a2 2 0 0 1-2 2H3" />
<path d="M21 8h-3a2 2 0 0 1-2-2V3" />
<path d="M3 16h3a2 2 0 0 1 2 2v3" />
<path d="M16 21v-3a2 2 0 0 1 2-2h3" />
</svg>
);
const IconCamera = ({ size = 13 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" />
<circle cx="12" cy="13" r="4" />
</svg>
);
const IconCameraOff = ({ size = 13 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<line x1="1" y1="1" x2="23" y2="23" />
<path d="M21 21H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h3m3-3h6l2 3h4a2 2 0 0 1 2 2v7" />
<path d="M9.4 9.4A4 4 0 0 0 12 17a4 4 0 0 0 3.6-5.6" />
</svg>
);
const ZOOM_STEPS = [0.5, 0.75, 1, 1.25, 1.5, 2, 2.5, 3];
export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
@@ -13,7 +61,7 @@ export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
layout = 'focus',
}) => {
const [showWebcam, setShowWebcam] = useState(true);
const [screenZoomIdx, setScreenZoomIdx] = useState(2); // 1x
const [screenZoomIdx, setScreenZoomIdx] = useState(2);
const [webcamZoomIdx, setWebcamZoomIdx] = useState(2);
const [isFullscreen, setIsFullscreen] = useState(false);
@@ -46,12 +94,12 @@ export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
const zoomIn = (target: 'screen' | 'webcam') => {
const setter = target === 'screen' ? setScreenZoomIdx : setWebcamZoomIdx;
setter(i => Math.min(i + 1, ZOOM_STEPS.length - 1));
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));
setter((i) => Math.max(i - 1, 0));
};
const zoomReset = (target: 'screen' | 'webcam') => {
@@ -61,12 +109,19 @@ export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
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>
<button type="button" className="proctor-tool-btn" title="Thu nhỏ" onClick={() => zoomOut(target)}>
<IconMinus size={14} />
</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>
<button type="button" className="proctor-tool-btn" title="Phóng to" onClick={() => zoomIn(target)}>
<IconPlus size={14} />
</button>
<button type="button" className="proctor-tool-btn proctor-tool-btn-label" 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 ? <IconMinimize size={13} /> : <IconMaximize size={13} />}
{isFullscreen ? 'Thu nhỏ' : 'Phóng to'}
</button>
)}
@@ -77,14 +132,16 @@ export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
<div className="proctor-stream-wrap">
<div className="proctor-stream-toolbar">
<span className="status-pill connected">
Đang phát (HTTP)
<span className="status-pill-dot" />
Đang phát (HTTP)
</span>
<div className="proctor-stream-actions">
<button
type="button"
className={`btn btn-secondary proctor-action-btn ${showWebcam ? '' : 'active'}`}
onClick={() => setShowWebcam(v => !v)}
onClick={() => setShowWebcam((v) => !v)}
>
{showWebcam ? <IconCameraOff size={13} /> : <IconCamera size={13} />}
{showWebcam ? 'Ẩn webcam' : 'Hiện webcam'}
</button>
</div>

View File

@@ -0,0 +1,118 @@
import React, { useEffect, useState } from 'react';
import { useAuth } from '../auth/AuthContext';
import {
isRescidePopupHidden,
RESCIDE_DEFAULT_DOWNLOADS,
RESCIDE_FEATURES,
setRescidePopupHidden,
} from '../rescide';
import { navigate } from '../navigation';
type Props = {
/** Khi true, không hiện popup (ví dụ đang ở workspace lớp/thi). */
disabled?: boolean;
};
export const RescideAnnouncePopup: React.FC<Props> = ({ disabled }) => {
const { staff } = useAuth();
const [open, setOpen] = useState(false);
useEffect(() => {
if (disabled || !staff?.email) {
setOpen(false);
return;
}
setOpen(!isRescidePopupHidden(staff.email));
}, [staff?.email, disabled]);
if (!open || !staff?.email) return null;
const dismissOnce = () => setOpen(false);
const dismissForever = () => {
setRescidePopupHidden(staff.email, true);
setOpen(false);
};
const goTab = () => {
setOpen(false);
navigate('rescide');
};
return (
<div className="modal-overlay" style={{ zIndex: 1200 }} onClick={dismissOnce}>
<div
className="modal-container"
style={{ maxWidth: 520, width: '92%' }}
onClick={(e) => e.stopPropagation()}
>
<div className="modal-header" style={{ borderBottom: '1px solid var(--border-light)' }}>
<h2 className="modal-title" style={{ margin: 0 }}>
Thông báo: RESCIDE IDE
</h2>
<button type="button" className="modal-close-btn" aria-label="Đóng" onClick={dismissOnce}>
×
</button>
</div>
<div className="modal-body" style={{ padding: '1.25rem 1.5rem' }}>
<p style={{ fontSize: '0.95rem', color: 'var(--text-secondary)', lineHeight: 1.6, marginBottom: '1rem' }}>
<strong style={{ color: 'var(--text-primary)' }}>RESCIDE</strong> IDE thi thực hành đang mở test thử đ
đánh giá cải thiện chức năng. Mục đích: môi trường IDE chuyên cho kỳ thi thực hành, chống gian lận cùng
RAIA DESKTOP.
</p>
<ul
style={{
margin: '0 0 1.25rem',
paddingLeft: '1.15rem',
fontSize: '0.875rem',
color: 'var(--text-secondary)',
lineHeight: 1.65,
}}
>
{RESCIDE_FEATURES.map((f) => (
<li key={f} style={{ marginBottom: '0.35rem' }}>
{f}
</li>
))}
</ul>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginBottom: '0.25rem' }}>
{RESCIDE_DEFAULT_DOWNLOADS.map((d) => (
<a
key={d.downloadUrl}
href={d.downloadUrl}
target="_blank"
rel="noopener noreferrer"
className="btn btn-secondary"
style={{ justifyContent: 'space-between', textDecoration: 'none', width: '100%' }}
>
<span>{d.name}</span>
<span style={{ fontWeight: 700, color: 'var(--accent)' }}>Tải</span>
</a>
))}
</div>
</div>
<div
className="modal-footer"
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '0.5rem',
justifyContent: 'flex-end',
borderTop: '1px solid var(--border-light)',
padding: '1rem 1.5rem',
}}
>
<button type="button" className="btn btn-secondary" onClick={dismissForever}>
n với tài khoản này
</button>
<button type="button" className="btn btn-secondary" onClick={goTab}>
Xem chi tiết
</button>
<button type="button" className="btn btn-primary" onClick={dismissOnce}>
Đã hiểu
</button>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,182 @@
import React, { useEffect, useState } from 'react';
import { api, type AppDownloadItem } from '../api';
import { useAuth } from '../auth/AuthContext';
import {
isRescideManager,
isRescidePopupHidden,
RESCIDE_DEFAULT_DOWNLOADS,
RESCIDE_FEATURES,
setRescidePopupHidden,
type RescideDownload,
} from '../rescide';
import { navigate } from '../navigation';
function mergeDownloads(fromApi: AppDownloadItem[]): RescideDownload[] {
const rescide = fromApi.filter((a) => a.name.toUpperCase().includes('RESCIDE'));
if (rescide.length > 0) {
return rescide.map((a) => ({
name: a.name,
description: a.description || '',
downloadUrl: a.downloadUrl,
platform: a.platform || '',
}));
}
return RESCIDE_DEFAULT_DOWNLOADS;
}
export const RescideTab: React.FC = () => {
const { staff } = useAuth();
const canManage = isRescideManager(staff?.email);
const [downloads, setDownloads] = useState<RescideDownload[]>(RESCIDE_DEFAULT_DOWNLOADS);
const [popupHidden, setPopupHidden] = useState(() => isRescidePopupHidden(staff?.email));
useEffect(() => {
setPopupHidden(isRescidePopupHidden(staff?.email));
}, [staff?.email]);
useEffect(() => {
let cancelled = false;
api
.listAppDownloads()
.then((res) => {
if (!cancelled) setDownloads(mergeDownloads(res.data || []));
})
.catch(() => {
if (!cancelled) setDownloads(RESCIDE_DEFAULT_DOWNLOADS);
});
return () => {
cancelled = true;
};
}, []);
const togglePopupHide = () => {
if (!staff?.email) return;
const next = !popupHidden;
setRescidePopupHidden(staff.email, next);
setPopupHidden(next);
};
return (
<div className="tab-page">
<div className="tab-page-toolbar">
<div className="page-header">
<div className="page-title">
<h1>RESCIDE IDE thi thực hành</h1>
<p>
Mở test thử đ đánh giá cải thiện chức năng. IDE chuyên dụng cho kỳ thi thực hành, chống gian lận
cùng RAIA DESKTOP.
</p>
</div>
</div>
</div>
<div className="tab-page-body tab-page-scroll">
<div
className="content-card"
style={{
marginBottom: '1.25rem',
padding: '1.25rem 1.5rem',
borderColor: 'rgba(187, 33, 38, 0.25)',
background: 'linear-gradient(135deg, #fff 0%, var(--accent-light) 100%)',
}}
>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '1rem', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<div style={{ fontSize: '0.75rem', fontWeight: 700, color: 'var(--accent)', letterSpacing: '0.04em', textTransform: 'uppercase', marginBottom: '0.35rem' }}>
Thông báo thử nghiệm
</div>
<p style={{ fontSize: '0.95rem', color: 'var(--text-secondary)', lineHeight: 1.55, margin: 0, maxWidth: 640 }}>
Vui lòng tải RESCIDE, dùng thử trong môi trường thi thực hành góp ý đ đi ngũ hoàn thiện sản phẩm.
</p>
</div>
<button
type="button"
className="btn btn-secondary"
onClick={togglePopupHide}
title={popupHidden ? 'Hiện popup RESCIDE mỗi khi vào web' : 'Ẩn popup RESCIDE với tài khoản này'}
>
{popupHidden ? 'Hiện lại popup khi vào web' : 'Ẩn popup với tài khoản này'}
</button>
</div>
</div>
<div className="content-card" style={{ marginBottom: '1.25rem', padding: '1.5rem' }}>
<h2 style={{ fontSize: '1.05rem', fontWeight: 700, marginBottom: '0.75rem' }}>Chức năng tiêu biểu</h2>
<ul style={{ margin: 0, paddingLeft: '1.2rem', color: 'var(--text-secondary)', lineHeight: 1.7, fontSize: '0.95rem' }}>
{RESCIDE_FEATURES.map((f) => (
<li key={f} style={{ marginBottom: '0.4rem' }}>
{f}
</li>
))}
</ul>
</div>
<div className="content-card" style={{ padding: '1.5rem' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.75rem', alignItems: 'baseline', justifyContent: 'space-between', marginBottom: '1rem' }}>
<h2 style={{ fontSize: '1.05rem', fontWeight: 700, margin: 0 }}>Tải RESCIDE</h2>
{canManage && (
<button type="button" className="btn btn-secondary" onClick={() => navigate('applications')}>
Quản link tải (ng dụng)
</button>
)}
</div>
<p style={{ fontSize: '0.875rem', color: 'var(--text-muted)', marginBottom: '1.25rem', lineHeight: 1.5 }}>
Chọn bản cài đt phù hợp hệ điều hành. Link tải đưc đng bộ từ khu vực ng dụng
{canManage ? ' — bạn có thể chỉnh sửa tại tab Ứng dụng.' : '.'}
</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: '1rem' }}>
{downloads.map((d) => (
<a
key={`${d.name}-${d.downloadUrl}`}
href={d.downloadUrl}
target="_blank"
rel="noopener noreferrer"
className="content-card"
style={{
padding: '1.25rem',
textDecoration: 'none',
color: 'inherit',
border: '1px solid var(--border-color)',
transition: 'var(--transition)',
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'rgba(187, 33, 38, 0.35)';
e.currentTarget.style.boxShadow = 'var(--shadow-md)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--border-color)';
e.currentTarget.style.boxShadow = 'none';
}}
>
<span
style={{
display: 'inline-flex',
alignSelf: 'flex-start',
fontSize: '0.7rem',
fontWeight: 700,
color: 'var(--accent)',
background: 'var(--accent-light)',
padding: '0.2rem 0.55rem',
borderRadius: 6,
}}
>
{d.platform || 'Download'}
</span>
<strong style={{ fontSize: '0.95rem', color: 'var(--text-primary)' }}>{d.name}</strong>
{d.description && (
<span style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', lineHeight: 1.45 }}>{d.description}</span>
)}
<span style={{ marginTop: 'auto', paddingTop: '0.75rem', fontSize: '0.85rem', fontWeight: 600, color: 'var(--accent)' }}>
Tải về
</span>
</a>
))}
</div>
</div>
</div>
</div>
);
};

View File

@@ -8,8 +8,27 @@ interface StudentDetailModalProps {
isOnline: boolean;
sessionLog?: StudentSessionLogItem | null;
onClose: () => void;
/** Mở sẵn phần giám sát (từ tab Giám sát / sơ đồ) */
initialShowProctor?: boolean;
}
type IconProps = { size?: number };
const IconClose = ({ size = 16 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
);
const IconMonitor = ({ size = 14 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<rect x="2" y="3" width="20" height="14" rx="2" />
<line x1="8" y1="21" x2="16" y2="21" />
<line x1="12" y1="17" x2="12" y2="21" />
</svg>
);
const formatDuration = (totalSeconds: number) => {
const hrs = Math.floor(totalSeconds / 3600);
const mins = Math.floor((totalSeconds % 3600) / 60);
@@ -23,24 +42,34 @@ export const StudentDetailModal: React.FC<StudentDetailModalProps> = ({
isOnline,
sessionLog,
onClose,
initialShowProctor = false,
}) => {
const [showProctor, setShowProctor] = useState(false);
const [showProctor, setShowProctor] = useState(initialShowProctor);
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={`modal-overlay student-detail-overlay${showProctor ? ' student-detail-overlay--proctor' : ''}`}
onClick={onClose}
>
<div
className={`modal-container student-detail-modal${showProctor ? ' student-detail-modal--proctor' : ''}`}
onClick={(e) => e.stopPropagation()}
>
<div className="modal-header student-detail-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}</>}
<StudentAvatar fullName={student.fullName} avatar={student.avatar} isOnline={isOnline} size={showProctor ? 40 : 52} />
<div className="student-detail-title-block">
<h2 className="modal-title student-detail-name">{student.fullName}</h2>
<p className="student-detail-sub">
<span className="student-detail-code">{student.studentCode}</span>
{student.email && <span className="student-detail-email">{student.email}</span>}
</p>
</div>
</div>
<button type="button" className="btn btn-secondary" onClick={onClose}>Đóng</button>
<button type="button" className="btn btn-secondary student-detail-close" onClick={onClose}>
<IconClose size={15} />
Đóng
</button>
</div>
<div className="student-detail-body">
@@ -62,20 +91,20 @@ export const StudentDetailModal: React.FC<StudentDetailModalProps> = ({
<>
<div className="student-meta-item">
<span className="student-meta-label">Online (ca)</span>
<span className="student-meta-value" style={{ color: 'var(--success)', fontFamily: 'monospace' }}>
<span className="student-meta-value student-meta-value--online">
{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' }}>
<span className="student-meta-value student-meta-value--offline">
{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' }}>
<span className="student-meta-value student-meta-value--mono">
{sessionLog.wifiSsids}
</span>
</div>
@@ -86,14 +115,14 @@ export const StudentDetailModal: React.FC<StudentDetailModalProps> = ({
<button
type="button"
className="btn btn-primary"
style={{ width: '100%', justifyContent: 'center' }}
onClick={() => setShowProctor(v => !v)}
className={`btn student-detail-proctor-toggle ${showProctor ? 'btn-secondary' : 'btn-primary'}`}
onClick={() => setShowProctor((v) => !v)}
>
<IconMonitor size={14} />
{showProctor ? 'Ẩn giám sát' : 'Xem webcam & màn hình'}
</button>
{!isOnline && !showProctor && (
<p className="schedule-hint" style={{ margin: 0 }}>
<p className="student-detail-hint">
Sinh viên offline stream chỉ khi app Simple Care đang chạy.
</p>
)}

View File

@@ -2,6 +2,90 @@ import React, { useEffect, useState } from 'react';
import { api } from '../api';
import type { StudentItem, SyncStatus } from '../api';
type IconProps = { size?: number };
const IconUsers = ({ size = 22 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="3.5" />
<path d="M22 21v-2a3.5 3.5 0 0 0-2.5-3.35" />
<path d="M16 3.5a3.5 3.5 0 0 1 0 7" />
</svg>
);
const IconRefresh = ({ size = 18 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M21 12a9 9 0 1 1-2.6-6.3" />
<polyline points="21 3 21 9 15 9" />
</svg>
);
const IconCheck = ({ size = 16 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M20 6 9 17l-5-5" />
</svg>
);
const IconAlert = ({ size = 16 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<circle cx="12" cy="12" r="9" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
);
const IconSearch = ({ size = 16 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<circle cx="11" cy="11" r="7" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
);
const IconMail = ({ size = 13 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<rect x="3" y="5" width="18" height="14" rx="2" />
<path d="m3 7 9 6 9-6" />
</svg>
);
const IconPhone = ({ size = 12 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.81.36 1.6.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c1.2.34 1.99.57 2.81.7A2 2 0 0 1 22 16.92z" />
</svg>
);
const IconCalendar = ({ size = 13 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<rect x="3" y="5" width="18" height="16" rx="2" />
<path d="M16 3v4M8 3v4M3 11h18" />
</svg>
);
const IconInbox = ({ size = 40 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<polyline points="22 12 16 12 14 15 10 15 8 12 2 12" />
<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
</svg>
);
const IconChevronLeft = ({ size = 16 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="m15 18-6-6 6-6" />
</svg>
);
const IconChevronRight = ({ size = 16 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="m9 18 6-6-6-6" />
</svg>
);
function genderLabel(gender: number | null | undefined) {
if (gender === 1) return 'Nam';
if (gender === 0) return 'Nữ';
return 'Khác';
}
export const StudentsTab: React.FC = () => {
const [students, setStudents] = useState<StudentItem[]>([]);
const [total, setTotal] = useState(0);
@@ -9,8 +93,6 @@ export const StudentsTab: React.FC = () => {
const [pageSize] = useState(15);
const [search, setSearch] = useState('');
const [loading, setLoading] = useState(true);
// Sync state
const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null);
const fetchStudents = async () => {
@@ -49,7 +131,6 @@ export const StudentsTab: React.FC = () => {
fetchSyncStatus();
}, []);
// Sync polling logic
useEffect(() => {
let timer: any;
if (syncStatus?.running) {
@@ -57,7 +138,7 @@ export const StudentsTab: React.FC = () => {
const isRunning = await fetchSyncStatus();
if (!isRunning) {
clearInterval(timer);
fetchStudents(); // Reload data when sync completes
fetchStudents();
}
}, 2000);
}
@@ -69,77 +150,83 @@ export const StudentsTab: React.FC = () => {
const handleStartSync = async () => {
try {
await api.startStudentsSync();
// Set local state to running to trigger useEffect poller
setSyncStatus({
running: true,
done: false,
total: 0,
synced: 0,
updatedAt: Date.now() / 1000
updatedAt: Date.now() / 1000,
});
} catch (err: any) {
alert(err.message || 'Không thể bắt đầu đồng bộ sinh viên');
}
};
// Tính toán % tiến trình sync
const syncPercent = syncStatus && syncStatus.total > 0
const syncPercent =
syncStatus && syncStatus.total > 0
? Math.round((syncStatus.synced / syncStatus.total) * 100)
: 0;
const totalPages = Math.ceil(total / pageSize) || 1;
return (
<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>
<h1 className="page-title-heading">
<span className="page-title-icon" aria-hidden>
<IconUsers />
</span>
Danh sách sinh viên
</h1>
<p>Danh sách toàn bộ sinh viên trong hệ thống đưc đng bộ</p>
</div>
<button
className="btn btn-primary"
onClick={handleStartSync}
disabled={syncStatus?.running}
style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '8px',
padding: '0.75rem 1.5rem',
borderRadius: '12px',
fontWeight: 700,
boxShadow: '0 4px 14px rgba(187,33,38,0.25)',
}}
>
{syncStatus?.running ? (
<>
<div className="sync-spinner"></div> Đng bộ...
<div className="sync-spinner" style={{ width: 18, height: 18 }} />
Đng bộ... {syncPercent}%
</>
) : (
<>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ display: 'inline-block' }}>
<path d="M23 4v6h-6M1 20v-6h6M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
</svg>
<IconRefresh />
Đng bộ toàn bộ SV
</>
)}
</button>
</div>
{/* Sync Status Banner */}
{syncStatus && (syncStatus.running || syncStatus.done || syncStatus.error) && (
<div className="sync-progress-banner" style={{ borderStyle: 'solid', borderColor: 'var(--success)' }}>
<div className="sync-progress-banner">
<div className="sync-header">
<div className="sync-title">
{syncStatus.running && <div className="sync-spinner"></div>}
{syncStatus.running && <div className="sync-spinner" />}
<span>
{syncStatus.running && `Đang tải sinh viên... Trang ${syncStatus.page || 0} (${syncPercent}%)`}
{syncStatus.done && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="var(--success)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
Đng bộ toàn bộ sinh viên hoàn tất!
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, color: 'var(--success)' }}>
<IconCheck />
Đng bộ hoàn tất!
</span>
)}
{syncStatus.error && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '6px', color: 'var(--danger)' }}>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="var(--danger)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
Đng bộ thất bại: {syncStatus.error}
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, color: 'var(--danger)' }}>
<IconAlert />
{syncStatus.error}
</span>
)}
</span>
@@ -150,19 +237,22 @@ export const StudentsTab: React.FC = () => {
</div>
{syncStatus.running && (
<div className="sync-bar-container">
<div className="sync-bar" style={{ width: `${syncPercent}%`, background: 'linear-gradient(to right, var(--success), #a7f3d0)' }}></div>
<div className="sync-bar" style={{ width: `${syncPercent}%` }} />
</div>
)}
<div className="sync-meta">
<span>Cập nhật lần cuối: {new Date(syncStatus.updatedAt * 1000).toLocaleString()}</span>
{syncStatus.running && <span style={{ color: 'var(--success)' }}>Hệ thống đang kéo dữ liệu trang {syncStatus.page}...</span>}
{syncStatus.running && (
<span style={{ color: 'var(--accent-hover)' }}>
Hệ thống đang kéo dữ liệu trang {syncStatus.page}...
</span>
)}
</div>
</div>
)}
{/* Control filters */}
<div className="control-bar">
<div className="search-input-wrapper" style={{ maxWidth: '400px' }}>
<div className="search-input-wrapper">
<input
type="text"
className="search-input"
@@ -174,142 +264,451 @@ export const StudentsTab: React.FC = () => {
}}
/>
<span className="search-icon">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<IconSearch />
</span>
</div>
</div>
</div>
{/* Students Table */}
<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>
<p style={{ marginTop: '0.5rem' }}>Đang tải danh sách sinh viên...</p>
<div className="sync-spinner" style={{ width: 40, height: 40 }} />
<p style={{ marginTop: '1rem', fontWeight: 600 }}>Đang tải danh sách sinh viên...</p>
</div>
) : students.length === 0 ? (
<div className="empty-state">
<div className="empty-state-icon" style={{ color: 'var(--text-muted)' }}>
<svg viewBox="0 0 24 24" width="48" height="48" fill="none" stroke="currentColor" strokeWidth="1.5" 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>
<IconInbox />
</div>
<h2>Không sinh viên nào</h2>
<p>Hãy thử thay đi bộ lọc hoặc bấm nút "Đồng bộ toàn bộ SV" đ tải dữ liệu.</p>
<p>Hãy thử thay đi bộ lọc hoặc bấm "Đồng bộ toàn bộ SV" đ tải dữ liệu.</p>
</div>
) : (
<table className="data-table">
<div className="table-scroll-container">
<table className="data-table student-table">
<thead>
<tr>
<th> SV</th>
<th>Họ Tên</th>
<th>Thông Tin Liên H</th>
<th>Ngày sinh / Giới tính</th>
<th>Phân hệ</th>
<th>Đa điểm</th>
<th>Trạng thái</th>
<th className="col-code"> SV</th>
<th className="col-name">Họ tên</th>
<th className="col-contact">Thông tin liên h</th>
<th className="col-birth">Ngày sinh / Giới tính</th>
<th className="col-system">Phân hệ</th>
<th className="col-location">Đa điểm</th>
<th className="col-status">Trạng thái</th>
</tr>
</thead>
<tbody>
{students.map(st => (
{students.map(st => {
const isActive = st.status === 'Đang học' || st.status === 'active';
return (
<tr key={st.id}>
<td style={{ fontWeight: 600 }}>{st.studentCode}</td>
<td style={{ color: 'var(--text-primary)', fontWeight: 500 }}>
<td className="col-code" data-label="Mã SV">
<div className="student-code" title={st.studentCode || undefined}>
{st.studentCode || '—'}
</div>
</td>
<td className="col-name" data-label="Họ tên">
<div className="student-name" title={st.fullName}>
{st.fullName}
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>ID: {st.rkId}</div>
</div>
<div className="student-id">ID: {st.rkId}</div>
</td>
<td>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
<polyline points="22,6 12,13 2,6" />
</svg>
{st.email}
<td className="col-contact" data-label="Thông tin liên hệ">
<div className="meta-row" title={st.email || undefined}>
<IconMail />
<span className="meta-text">{st.email || '—'}</span>
</div>
{st.phone && (
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)', display: 'flex', alignItems: 'center', gap: '6px', marginTop: '2px' }}>
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z" />
</svg>
{st.phone}
{st.phone ? (
<div className="meta-row meta-row--secondary">
<IconPhone />
<span className="meta-text">{st.phone}</span>
</div>
)}
) : null}
</td>
<td>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
{st.dateOfBirth ? new Date(st.dateOfBirth).toLocaleDateString('vi-VN') : '—'}
</div>
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)', marginTop: '2px' }}>
Giới tính: {st.gender === 1 ? 'Nam' : st.gender === 0 ? 'Nữ' : 'Khác'}
<td className="col-birth" data-label="Ngày sinh / Giới tính">
<div className="meta-row">
<IconCalendar />
<span className="meta-text">
{st.dateOfBirth
? new Date(st.dateOfBirth).toLocaleDateString('vi-VN')
: '—'}
</span>
</div>
<div className="meta-sub">{genderLabel(st.gender)}</div>
</td>
<td>
<span className="badge badge-muted">
<td className="col-system" data-label="Phân hệ">
<span className="badge badge-system">
{st.systemName || 'Chung'}
</span>
</td>
<td>{st.location || '—'}</td>
<td>
<span className={`badge ${st.status === 'Đang học' || st.status === 'active' ? 'badge-success' : 'badge-muted'}`}>
<td className="col-location" data-label="Địa điểm">
<span className="location-text">{st.location || '—'}</span>
</td>
<td className="col-status" data-label="Trạng thái">
<span className={`badge badge-status ${isActive ? 'is-active' : 'is-muted'}`}>
{st.status || 'Đang học'}
</span>
</td>
</tr>
))}
);
})}
</tbody>
</table>
</div>
)}
</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
<div className="pagination-info">
Hiển thị <b>{(page - 1) * pageSize + 1}</b> {' '}
<b>{Math.min(page * pageSize, total)}</b> trong tổng số <b>{total}</b> sinh viên
</div>
<div className="pagination-btn-group">
<button
className="pagination-btn"
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}
aria-label="Trang trước"
>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 18 9 12 15 6" />
</svg>
<IconChevronLeft />
</button>
<span style={{ display: 'flex', alignItems: 'center', padding: '0 1rem', fontWeight: 600, color: 'var(--text-primary)' }}>
Trang {page} / {Math.ceil(total / pageSize) || 1}
<span className="pagination-current">
Trang {page} / {totalPages}
</span>
<button
className="pagination-btn"
onClick={() => setPage(p => p + 1)}
disabled={page >= Math.ceil(total / pageSize)}
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}
disabled={page >= totalPages}
aria-label="Trang sau"
>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6" />
</svg>
<IconChevronRight />
</button>
</div>
</div>
</div>
)}
<style>{`
.table-wrapper.table-fill {
overflow-x: hidden;
overflow-y: hidden;
}
.table-scroll-container {
overflow-x: hidden;
overflow-y: auto;
max-height: 100%;
height: 100%;
}
.page-title-heading {
display: flex;
align-items: center;
gap: 0.55rem;
}
.page-title-icon {
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--accent);
flex-shrink: 0;
}
.student-table {
width: 100%;
table-layout: fixed;
border-collapse: collapse;
font-size: 0.9rem;
}
.student-table thead th {
position: sticky;
top: 0;
z-index: 10;
background: var(--bg-subtle);
border-bottom: 2px solid var(--border-color);
padding: 0.75rem 0.9rem;
font-weight: 700;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.student-table td {
padding: 0.85rem 0.9rem;
border-bottom: 1px solid var(--border-light);
vertical-align: middle;
overflow: hidden;
}
.student-table .col-code { width: 12%; }
.student-table .col-name { width: 18%; }
.student-table .col-contact { width: 24%; }
.student-table .col-birth { width: 14%; }
.student-table .col-system { width: 12%; }
.student-table .col-location { width: 8%; }
.student-table .col-status { width: 12%; }
.student-table tbody tr {
transition: background 0.15s;
}
.student-table tbody tr:hover {
background: var(--bg-subtle) !important;
}
.student-code {
font-weight: 700;
color: var(--text-primary);
font-size: 0.9rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.student-name {
font-weight: 600;
color: var(--text-primary);
font-size: 0.9rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.student-id {
font-size: 0.72rem;
color: var(--text-muted);
margin-top: 0.1rem;
}
.meta-row {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
color: var(--text-primary);
font-size: 0.85rem;
line-height: 1.25;
}
.meta-row svg {
flex-shrink: 0;
color: var(--text-muted);
display: block;
}
.meta-row--secondary {
margin-top: 0.2rem;
color: var(--text-secondary);
font-size: 0.8rem;
}
.meta-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.meta-sub {
margin-top: 0.2rem;
font-size: 0.8rem;
color: var(--text-secondary);
padding-left: 19px;
}
.badge-system {
background: var(--bg-subtle);
color: var(--text-secondary);
border: 1px solid var(--border-color);
padding: 0.2rem 0.55rem;
border-radius: 20px;
font-weight: 600;
font-size: 0.72rem;
display: inline-flex;
align-items: center;
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.2;
}
.location-text {
font-weight: 600;
font-size: 0.85rem;
color: var(--text-primary);
white-space: nowrap;
}
.badge-status {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 5.5rem;
padding: 0.2rem 0.55rem;
border-radius: 20px;
font-weight: 700;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.03em;
white-space: nowrap;
line-height: 1.2;
box-sizing: border-box;
}
.badge-status.is-active {
background: rgba(59, 130, 246, 0.1);
color: #2563eb;
border: 1px solid rgba(37, 99, 235, 0.2);
}
.badge-status.is-muted {
background: var(--bg-subtle);
color: var(--text-muted);
border: 1px solid var(--border-color);
}
.pagination-info {
font-size: 0.85rem;
color: var(--text-secondary);
display: inline-flex;
align-items: center;
line-height: 1;
}
@media (max-width: 1280px) {
.student-table .col-birth {
display: none;
}
.student-table .col-code { width: 14%; }
.student-table .col-name { width: 20%; }
.student-table .col-contact { width: 28%; }
.student-table .col-system { width: 14%; }
.student-table .col-location { width: 10%; }
.student-table .col-status { width: 14%; }
}
@media (max-width: 1100px) {
.student-table .col-system,
.student-table .col-location {
display: none;
}
.student-table .col-code { width: 18%; }
.student-table .col-name { width: 28%; }
.student-table .col-contact { width: 36%; }
.student-table .col-status { width: 18%; }
}
@media (max-width: 960px) {
.student-table {
table-layout: auto;
}
.student-table thead {
display: none;
}
.student-table tbody tr {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem 1rem;
padding: 1rem;
border-radius: var(--radius-lg);
background: var(--bg-card);
box-shadow: var(--shadow-sm);
border: 1px solid var(--border-color);
margin-bottom: 0.75rem;
}
.student-table .col-birth,
.student-table .col-system,
.student-table .col-location {
display: flex;
}
.student-table td {
display: flex;
flex-direction: column;
padding: 0 !important;
border: none !important;
gap: 0.15rem;
overflow: visible;
width: auto !important;
}
.student-table td::before {
content: attr(data-label);
font-size: 0.6rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.student-table td.col-status {
grid-column: span 2;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-top: 0.35rem;
padding-top: 0.5rem !important;
border-top: 1px dashed var(--border-color) !important;
}
.student-table td.col-status::before {
margin-bottom: 0;
}
.student-code,
.student-name,
.meta-text,
.badge-system {
white-space: normal;
overflow: visible;
text-overflow: unset;
word-break: break-word;
}
.meta-sub {
padding-left: 0;
}
.page-header {
flex-direction: column;
align-items: stretch;
}
.page-header .btn {
width: 100%;
justify-content: center;
}
.control-bar {
flex-direction: column;
align-items: stretch;
}
.search-input-wrapper {
max-width: 100%;
}
.pagination-row {
flex-direction: column;
gap: 0.75rem;
align-items: center;
text-align: center;
}
.pagination-info {
font-size: 0.75rem;
}
}
@media (max-width: 480px) {
.student-table tbody tr {
padding: 0.75rem;
gap: 0.4rem 0.75rem;
}
.student-code,
.student-name {
font-size: 0.8rem;
}
.badge-status {
min-width: 0;
font-size: 0.6rem;
}
.pagination-btn-group .pagination-btn {
width: 32px;
height: 32px;
}
}
`}</style>
</div>
);
};

View File

@@ -7,6 +7,42 @@ import {
} from '../api';
import { kindLabel, onStudentViolation } from '../hooks/useStaffChatSocket';
/* ─── Icon components ─────────────────────────────────────────────────────── */
type IconProps = { size?: number };
const IconAlert = ({ size = 15 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="m10.29 3.86-8.19 14A2 2 0 0 0 3.82 21h16.36a2 2 0 0 0 1.72-3l-8.19-14a2 2 0 0 0-3.44 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
);
const IconInfo = ({ size = 15 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<circle cx="12" cy="12" r="9" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
);
const IconRefresh = ({ size = 14 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M21 12a9 9 0 1 1-2.6-6.3" />
<polyline points="21 3 21 9 15 9" />
</svg>
);
const IconShieldOff = ({ size = 36 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
</svg>
);
/* ─── Helpers ────────────────────────────────────────────────────────────── */
type Props =
| { mode: 'class'; classId: number }
| { mode: 'exam'; examId: number };
@@ -32,6 +68,8 @@ function modeLabel(mode?: string): string {
}
}
/* ─── Component ─────────────────────────────────────────────────────────── */
export const ViolationsPanel = (props: Props) => {
const [date, setDate] = useState(todayLocal);
const [kind, setKind] = useState('');
@@ -56,11 +94,8 @@ export const ViolationsPanel = (props: Props) => {
}
}, [props, date, kind]);
useEffect(() => {
void load();
}, [load]);
useEffect(() => { void load(); }, [load]);
// Live: khi SV tắt app / vi phạm → tự làm mới nếu thuộc lớp/phòng đang xem
useEffect(() => {
return onStudentViolation((v) => {
const matches =
@@ -74,57 +109,113 @@ export const ViolationsPanel = (props: Props) => {
});
}, [props, date, load]);
const isExam = props.mode === 'exam';
return (
<div className="session-logs-panel" style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', height: '100%' }}>
<div style={{
padding: '0.65rem 0.85rem',
background: props.mode === 'exam' ? '#fef3c7' : '#eff6ff',
border: props.mode === 'exam' ? '1px solid #fcd34d' : '1px solid #bfdbfe',
borderRadius: '6px',
color: props.mode === 'exam' ? '#92400e' : '#1e40af',
fontSize: '0.82rem',
lineHeight: '1.4',
}}>
{props.mode === 'exam'
? '⚠ Vi phạm trong phòng thi (tắt app, WiFi, môi trường…) được ghi nhận theo thời gian thực.'
: ' Vi phạm trong giờ học (tắt app, WiFi, môi trường…) hiển thị tại đây theo từng lớp.'}
<div className="vp-panel">
<style>{`
.vp-panel { display: flex; flex-direction: column; gap: 0.65rem; height: 100%; }
/* Notice banner */
.vp-notice { padding: 0.55rem 0.75rem; border-radius: var(--radius-sm); border: 1px solid; border-left-width: 3px; font-size: 0.76rem; line-height: 1.5; display: flex; align-items: flex-start; gap: 0.45rem; }
.vp-notice--exam { background: rgba(254,243,199,0.6); border-color: #fcd34d; border-left-color: #d97706; color: #78350f; }
.vp-notice--exam svg { color: #d97706; }
.vp-notice--class { background: rgba(239,246,255,0.7); border-color: #bfdbfe; border-left-color: #3b82f6; color: #1e3a8a; }
.vp-notice--class svg { color: #3b82f6; }
.vp-notice svg { flex-shrink: 0; margin-top: 0.1rem; }
/* Toolbar */
.vp-toolbar { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: flex-end; }
.vp-field { display: flex; flex-direction: column; gap: 0.18rem; font-size: 0.71rem; font-weight: 600; color: var(--text-muted); }
.vp-input { padding: 0.3rem 0.5rem; font-size: 0.78rem; border: 1px solid var(--border-color); border-radius: var(--radius-sm); background: var(--bg-card); color: var(--text-primary); }
.vp-input:focus { outline: none; border-color: var(--accent); }
.vp-refresh { display: inline-flex; align-items: center; gap: 0.3rem; padding: 0.28rem 0.65rem; font-size: 0.75rem; font-weight: 500; border-radius: var(--radius-sm); border: 1px solid var(--border-color); background: var(--bg-card); color: var(--text-primary); cursor: pointer; transition: background 0.15s; white-space: nowrap; }
.vp-refresh:hover:not(:disabled) { background: var(--bg-hover); border-color: var(--border-hover); }
.vp-refresh:disabled { opacity: 0.55; cursor: not-allowed; }
.vp-count { margin-left: auto; font-size: 0.77rem; color: var(--text-muted); font-weight: 600; align-self: flex-end; padding-bottom: 2px; }
/* Table wrapper */
.vp-table-scroll { flex: 1; overflow: auto; border: 1px solid var(--border-color); border-radius: var(--radius-sm); }
.vp-table { width: 100%; border-collapse: collapse; }
.vp-table thead th { padding: 0.5rem 0.8rem; background: var(--bg-subtle); font-weight: 700; font-size: 0.68rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.04em; border-bottom: 1px solid var(--border-color); white-space: nowrap; }
.vp-table tbody tr { border-bottom: 1px solid var(--border-light); transition: background 0.1s; }
.vp-table tbody tr:last-child { border-bottom: none; }
.vp-table tbody tr:hover { background: var(--bg-hover); }
.vp-table tbody tr.vp-row--close { background: rgba(239,68,68,0.04); }
.vp-table tbody tr.vp-row--close:hover { background: rgba(239,68,68,0.08); }
.vp-table tbody td { padding: 0.44rem 0.8rem; font-size: 0.82rem; vertical-align: middle; }
/* Empty state */
.vp-empty { min-height: 180px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 0.5rem; color: var(--text-muted); font-size: 0.85rem; }
.vp-empty svg { opacity: 0.35; }
.vp-empty span { font-style: italic; }
/* Error */
.vp-error { font-size: 0.78rem; color: var(--danger); }
`}</style>
{/* ── Notice banner ── */}
<div className={`vp-notice${isExam ? ' vp-notice--exam' : ' vp-notice--class'}`}>
{isExam ? <IconAlert size={15} /> : <IconInfo size={15} />}
<span>
{isExam
? 'Vi phạm trong phòng thi (tắt app, WiFi, môi trường…) được ghi nhận theo thời gian thực.'
: 'Vi phạm trong giờ học (tắt app, WiFi, môi trường…) hiển thị tại đây theo từng lớp.'}
</span>
</div>
<div className="attendance-toolbar" style={{ marginBottom: 0, flexWrap: 'wrap' }}>
<label className="attendance-field">
{/* ── Toolbar ── */}
<div className="vp-toolbar attendance-toolbar">
<label className="vp-field 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)} />
<input
type="date"
className="vp-input search-input"
value={date}
onChange={(e) => setDate(e.target.value)}
/>
</label>
<label className="attendance-field">
<span>Loại</span>
<select className="select-filter" value={kind} onChange={(e) => setKind(e.target.value)}>
<label className="vp-field attendance-field">
<span>Loại vi phạm</span>
<select
className="vp-input select-filter"
value={kind}
onChange={(e) => setKind(e.target.value)}
>
{VIOLATION_KIND_OPTIONS.map((o) => (
<option key={o.value || 'all'} value={o.value}>{o.label}</option>
))}
</select>
</label>
<button type="button" className="btn btn-secondary btn-sm" onClick={() => void load()} disabled={loading}>
<button
type="button"
className="vp-refresh btn btn-secondary btn-sm"
onClick={() => void load()}
disabled={loading}
style={{ alignSelf: 'flex-end' }}
>
<IconRefresh size={14} />
{loading ? 'Đang tải...' : 'Làm mới'}
</button>
<span style={{ marginLeft: 'auto', fontSize: '0.82rem', color: 'var(--text-muted)', fontWeight: 600 }}>
{rows.length} vi phạm
</span>
<span className="vp-count">{rows.length} vi phạm</span>
</div>
{err && <div className="form-error" style={{ margin: 0 }}>{err}</div>}
{/* ── Error message ── */}
{err && <div className="vp-error form-error">{err}</div>}
<div className="attendance-table-scroll table-wrapper" style={{ border: 'none', flex: 1, overflowY: 'auto' }}>
{/* ── Table ── */}
<div className="vp-table-scroll attendance-table-scroll table-wrapper">
{loading && rows.length === 0 ? (
<div className="empty-state" style={{ minHeight: '220px' }}>
<div className="sync-spinner" style={{ width: '32px', height: '32px' }} />
<p style={{ marginTop: '0.5rem' }}>Đang tải vi phạm...</p>
<div className="vp-empty">
<div className="sync-spinner" style={{ width: 28, height: 28 }} />
</div>
) : rows.length === 0 ? (
<div className="empty-state" style={{ minHeight: '220px' }}>
<p>Không vi phạm trong ngày đã chọn.</p>
<div className="vp-empty">
<IconShieldOff size={36} />
<span>Không vi phạm trong ngày đã chọn.</span>
</div>
) : (
<table className="data-table">
<table className="vp-table data-table">
<thead>
<tr>
<th>Thời gian</th>
@@ -139,26 +230,19 @@ export const ViolationsPanel = (props: Props) => {
{rows.map((r) => {
const isClose = r.kind === 'app_closed' || r.kind === 'unclean_shutdown';
return (
<tr key={r.id} style={isClose ? { background: 'rgba(239, 68, 68, 0.06)' } : undefined}>
<td style={{ whiteSpace: 'nowrap', fontFamily: 'monospace', fontSize: '0.8rem' }}>
<tr key={r.id} className={isClose ? 'vp-row--close' : ''}>
<td style={{ whiteSpace: 'nowrap', fontFamily: 'monospace', fontSize: '0.75rem' }}>
{formatTime(r.createdAt || r.clientAt)}
</td>
<td style={{ fontWeight: 600 }}>{r.fullName || '—'}</td>
<td><code>{r.studentCode || r.studentRkId}</code></td>
<td><code style={{ fontSize: '0.78rem' }}>{r.studentCode || r.studentRkId}</code></td>
<td>
<span
className={`badge ${isClose ? 'badge-danger' : 'badge-warning'}`}
style={{ fontSize: '0.72rem' }}
>
<span className={`badge ${isClose ? 'badge-danger' : 'badge-warning'}`} style={{ fontSize: '0.68rem', fontWeight: 600, padding: '0.15rem 0.4rem' }}>
{kindLabel(r.kind)}
</span>
</td>
<td style={{ maxWidth: 360, fontSize: '0.85rem' }} title={r.reason}>
{r.reason}
</td>
<td style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>
{modeLabel(r.monitorMode)}
</td>
<td style={{ maxWidth: 320, fontSize: '0.8rem' }} title={r.reason}>{r.reason}</td>
<td style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>{modeLabel(r.monitorMode)}</td>
</tr>
);
})}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
export type SystemSection = 'organization' | 'network' | 'templates' | 'seating';
export type TabId = 'dashboard' | 'classes' | 'students' | 'learning' | 'exams' | 'system' | 'profile' | 'student-affairs' | 'applications';
export type TabId = 'dashboard' | 'classes' | 'students' | 'learning' | 'exams' | 'system' | 'profile' | 'student-affairs' | 'applications' | 'rescide';
export interface NavEntry {
id: string;
@@ -30,6 +30,7 @@ export const TAB_LABELS: Record<TabId, string> = {
profile: 'Tài khoản của tôi',
'student-affairs': 'Công Tác Sinh Viên',
applications: 'Ứng Dụng',
rescide: 'RESCIDE IDE',
};
const HISTORY_KEY = 'sc_nav_history';
@@ -45,7 +46,8 @@ function isTabId(value: string | null): value is TabId {
value === 'system' ||
value === 'profile' ||
value === 'student-affairs' ||
value === 'applications'
value === 'applications' ||
value === 'rescide'
);
}

73
management/src/rescide.ts Normal file
View File

@@ -0,0 +1,73 @@
/** RESCIDE IDE — thông báo & link tải (quản lý CRUD tại tab Ứng dụng, tài khoản super-admin). */
export const RESCIDE_SUPER_ADMIN_EMAIL = 'phuocntb@rikkeiacademy.com';
export const RESCIDE_POPUP_STORAGE_PREFIX = 'sc_rescide_popup_hidden:';
export function rescidePopupStorageKey(email: string) {
return `${RESCIDE_POPUP_STORAGE_PREFIX}${email.trim().toLowerCase()}`;
}
export function isRescidePopupHidden(email: string | undefined | null): boolean {
if (!email) return true;
try {
return localStorage.getItem(rescidePopupStorageKey(email)) === '1';
} catch {
return false;
}
}
export function setRescidePopupHidden(email: string, hidden: boolean) {
try {
const key = rescidePopupStorageKey(email);
if (hidden) localStorage.setItem(key, '1');
else localStorage.removeItem(key);
} catch {
/* ignore */
}
}
export function isRescideManager(email: string | undefined | null): boolean {
return email?.trim().toLowerCase() === RESCIDE_SUPER_ADMIN_EMAIL;
}
export type RescideDownload = {
name: string;
description: string;
downloadUrl: string;
platform: string;
};
export const RESCIDE_DEFAULT_DOWNLOADS: RescideDownload[] = [
{
name: 'RESCIDE (macOS Intel)',
description: 'IDE thi thực hành — macOS chip Intel',
downloadUrl: 'https://save.rikkeiraia.org/RESCIDE_intel-installer.dmg',
platform: 'macOS',
},
{
name: 'RESCIDE (macOS Apple Silicon)',
description: 'IDE thi thực hành — macOS chip Apple ARM',
downloadUrl: 'https://save.rikkeiraia.org/RESCIDE_arm-installer.dmg',
platform: 'macOS',
},
{
name: 'RESCIDE (Windows)',
description: 'IDE thi thực hành — Windows 10+',
downloadUrl: 'https://save.rikkeiraia.org/RESCIDE-1.73.100-win.zip',
platform: 'Windows',
},
{
name: 'RESCIDE (Linux)',
description: 'IDE thi thực hành — Linux x64',
downloadUrl: 'https://save.rikkeiraia.org/RESCIDE-1.73.100-x64.tar.gz',
platform: 'Linux',
},
];
export const RESCIDE_FEATURES = [
'Kiểm soát code thời gian thực, cộng hưởng cùng RAIA DESKTOP chặn gian lận.',
'Ngăn chặn paste code từ bên ngoài workspace.',
'Chặn trình duyệt online và extension AI.',
'Chặn Emmet / snippet code gian lận và nhiều chức năng ẩn khác.',
] as const;

View File

@@ -80,9 +80,67 @@ func AutoMigrate(db *gorm.DB) error {
if err := purgeLegacyWifiWithoutBSSID(db); err != nil {
return err
}
if err := seedRescideAppDownloads(db); err != nil {
log.Printf("Warning: seed RESCIDE app downloads: %v", err)
}
return migrateLegacyDiscoveredApps(db)
}
// seedRescideAppDownloads thêm link tải RESCIDE nếu chưa có (quản lý tiếp tại tab Ứng dụng — super-admin).
func seedRescideAppDownloads(db *gorm.DB) error {
type seedItem struct {
Name string
Description string
DownloadURL string
Platform string
}
items := []seedItem{
{
Name: "RESCIDE (macOS Intel)",
Description: "IDE thi thực hành — macOS chip Intel",
DownloadURL: "https://save.rikkeiraia.org/RESCIDE_intel-installer.dmg",
Platform: "macOS",
},
{
Name: "RESCIDE (macOS Apple Silicon)",
Description: "IDE thi thực hành — macOS chip Apple ARM",
DownloadURL: "https://save.rikkeiraia.org/RESCIDE_arm-installer.dmg",
Platform: "macOS",
},
{
Name: "RESCIDE (Windows)",
Description: "IDE thi thực hành — Windows 10+",
DownloadURL: "https://save.rikkeiraia.org/RESCIDE-1.73.100-win.zip",
Platform: "Windows",
},
{
Name: "RESCIDE (Linux)",
Description: "IDE thi thực hành — Linux x64",
DownloadURL: "https://save.rikkeiraia.org/RESCIDE-1.73.100-x64.tar.gz",
Platform: "Linux",
},
}
for _, item := range items {
var count int64
if err := db.Model(&models.AppDownload{}).Where("download_url = ?", item.DownloadURL).Count(&count).Error; err != nil {
return err
}
if count > 0 {
continue
}
if err := db.Create(&models.AppDownload{
Name: item.Name,
Description: item.Description,
DownloadURL: item.DownloadURL,
Platform: item.Platform,
}).Error; err != nil {
return err
}
log.Printf("Seeded app download: %s", item.Name)
}
return nil
}
// Bảng WiFi cũ dùng ssid_key làm PK; schema mới cần id + bssid_key — GORM không tự đổi PK.
func migrateLegacyWifiTables(db *gorm.DB) error {
if db.Migrator().HasTable("accepted_wifis") {