Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b6dfe705f5 | |||
| 42172db9f4 | |||
| 3b80550c76 |
86
management/package-lock.json
generated
86
management/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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 lý học tập</div>
|
||||
<li className="nav-section" aria-hidden="true">
|
||||
<span className="nav-header">Quản lý 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 lý hệ thống
|
||||
<span className="nav-label">Quản lý 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 />
|
||||
</>
|
||||
|
||||
@@ -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">Đã có</span>
|
||||
<span className="app-pool-added-tag">
|
||||
<IconCheck />
|
||||
Đã có
|
||||
</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>
|
||||
|
||||
@@ -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 lý khung tại Hệ thống → Khung ứng dụng.
|
||||
Ghép bộ keyword đã lưu vào whitelist. Quản lý 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 có {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>
|
||||
|
||||
@@ -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 lý đ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}
|
||||
>
|
||||
×
|
||||
</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 — có 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 "Đẩy QLĐT" 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 — có 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ộ và <strong>KHÔNG tự động lưu lên QLĐT</strong>. Thầy cô 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ộ và{' '}
|
||||
<strong>KHÔNG tự động lưu lên QLĐT</strong>. Thầy cô 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>"Nghỉ có phép"</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 cô bấm nút <strong>"Đẩy QLĐT"</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>Lý 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 "{opt.label}"
|
||||
<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
@@ -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ý Lớp Học</h1>
|
||||
<h1 className="page-title-heading">
|
||||
<span className="page-title-icon" aria-hidden>
|
||||
<IconGraduation />
|
||||
</span>
|
||||
Quản lý lớp học
|
||||
</h1>
|
||||
<p>Đồng bộ dữ liệu lớp học và 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">Hà 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">Hà 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>Mã 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">Mã 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 có môn học</span>
|
||||
<span className="text-muted-small">Chưa có 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>
|
||||
);
|
||||
};
|
||||
@@ -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 }}
|
||||
>
|
||||
← 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 →
|
||||
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,
|
||||
}}
|
||||
>
|
||||
×
|
||||
|
||||
{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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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ĩ số
|
||||
</div>
|
||||
<div className="workspace-stat-value">
|
||||
<div className="ew-online-stat">
|
||||
<span className="ew-meta-label">Online / Sĩ 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')}
|
||||
>
|
||||
Sơ đồ sinh viên
|
||||
<IconMap size={14} />
|
||||
Sơ đồ
|
||||
</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 có sinh viên</h2>
|
||||
<p>Thêm sinh viên để xem chi tiết đề gán và 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 có 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>Gõ ít nhất vài ký 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 và 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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">×</button>
|
||||
<p className="class-picker-subtitle">
|
||||
Đặt tên và khung giờ thi. Sau khi tạo sẽ mở workspace để cấu hình đề và 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">
|
||||
×
|
||||
</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 mô</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
@@ -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>
|
||||
|
||||
118
management/src/components/RescideAnnouncePopup.tsx
Normal file
118
management/src/components/RescideAnnouncePopup.tsx
Normal 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á và 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>
|
||||
);
|
||||
};
|
||||
182
management/src/components/RescideTab.tsx
Normal file
182
management/src/components/RescideTab.tsx
Normal 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á và 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 và 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 lý 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>
|
||||
);
|
||||
};
|
||||
@@ -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ỉ có khi app Simple Care đang chạy.
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -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 có 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>Mã 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">Mã 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 có vi phạm trong ngày đã chọn.</p>
|
||||
<div className="vp-empty">
|
||||
<IconShieldOff size={36} />
|
||||
<span>Không có 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
@@ -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
73
management/src/rescide.ts
Normal 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;
|
||||
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user