Compare commits

...

75 Commits

Author SHA1 Message Date
c41f465d8a tt
Some checks failed
Deploy on Master Change / deploy (pull_request) Has been cancelled
2026-07-23 10:15:00 +07:00
2bdec7d7e7 up
All checks were successful
Deploy on Master Change / deploy (push) Successful in 1m47s
2026-07-20 12:42:03 +07:00
232f9873a0 up
Some checks failed
Deploy on Master Change / deploy (push) Failing after 1m23s
2026-07-20 12:38:02 +07:00
6ee9773f7f fix
All checks were successful
Deploy on Master Change / deploy (push) Successful in 1m45s
2026-07-20 06:06:54 +07:00
aed692cffe fix push 2026-07-20 06:01:51 +07:00
1ed246eec8 fix stream
All checks were successful
Deploy on Master Change / deploy (push) Successful in 1m48s
2026-07-14 08:14:10 +07:00
a55b0a4567 fix
All checks were successful
Deploy on Master Change / deploy (push) Successful in 1m38s
2026-07-14 08:02:47 +07:00
f4b9d99051 fix build sv
All checks were successful
Deploy on Master Change / deploy (push) Successful in 1m27s
2026-07-14 07:55:51 +07:00
487e9eb8ea fix ui
All checks were successful
Deploy on Master Change / deploy (push) Successful in 1m30s
2026-07-14 07:39:18 +07:00
7d3fdefa3f fix block git 2026-07-13 15:52:58 +07:00
0ff025fb15 fix block ci cd
Some checks failed
Deploy on Master Change / deploy (push) Has been cancelled
2026-07-13 15:52:35 +07:00
b56c6d722c fix tuong phan 2026-07-13 15:30:56 +07:00
83821b6b82 fix mang
All checks were successful
Deploy on Master Change / deploy (push) Successful in 1m23s
2026-07-13 15:23:48 +07:00
31943bf489 rollback fix
All checks were successful
Deploy on Master Change / deploy (push) Successful in 1m23s
2026-07-13 15:14:50 +07:00
24a4ef2c66 fixx
All checks were successful
Deploy on Master Change / deploy (push) Successful in 1m14s
2026-07-13 11:21:58 +07:00
120ff1f3c8 fix them de
All checks were successful
Deploy on Master Change / deploy (push) Successful in 1m15s
2026-07-13 11:12:55 +07:00
84ad60841c fix rebuild client
All checks were successful
Deploy on Master Change / deploy (push) Successful in 1m16s
2026-07-13 11:05:40 +07:00
bad51585db fix
All checks were successful
Deploy on Master Change / deploy (push) Successful in 1m17s
2026-07-13 11:00:17 +07:00
06f2edb195 fix random
All checks were successful
Deploy on Master Change / deploy (push) Successful in 1m17s
2026-07-13 10:29:34 +07:00
0583c7869a fix client
All checks were successful
Deploy on Master Change / deploy (push) Successful in 1m15s
2026-07-13 10:13:05 +07:00
b4c3155564 fix ui
All checks were successful
Deploy on Master Change / deploy (push) Successful in 1m15s
2026-07-13 10:04:56 +07:00
58c96609b6 fix 2026-07-13 09:44:34 +07:00
8803cd622a tam 2
All checks were successful
Deploy on Master Change / deploy (push) Successful in 1m25s
2026-07-13 09:04:09 +07:00
719704f19e tam 2026-07-13 08:12:04 +07:00
2bb856f2fd fix ui
All checks were successful
Deploy on Master Change / deploy (push) Successful in 1m19s
2026-07-07 06:52:33 +07:00
939097ddd9 unlock fix cung client 2026-07-06 11:54:47 +07:00
6cc520a0c4 fix qldt duyet phep
All checks were successful
Deploy on Master Change / deploy (push) Successful in 1m18s
2026-07-06 11:09:02 +07:00
6502531ad7 fix tam
All checks were successful
Deploy on Master Change / deploy (push) Successful in 1m7s
2026-07-06 11:04:12 +07:00
f5dce38e11 add phep
All checks were successful
Deploy on Master Change / deploy (push) Successful in 1m12s
2026-07-06 10:58:38 +07:00
573ff17581 fix t
All checks were successful
Deploy on Master Change / deploy (push) Successful in 1m5s
2026-07-06 10:29:08 +07:00
52d62dbc96 fix view
All checks were successful
Deploy on Master Change / deploy (push) Successful in 1m6s
2026-07-06 09:58:31 +07:00
d7a49d2b5c fix
All checks were successful
Deploy on Master Change / deploy (push) Successful in 1m1s
2026-07-06 09:25:16 +07:00
83018639fa new fix
All checks were successful
Deploy on Master Change / deploy (push) Successful in 1m8s
2026-07-06 09:12:35 +07:00
99aacb5d81 ci: add storage_server build and PM2 process management to deploy workflow
All checks were successful
Deploy on Master Change / deploy (push) Successful in 1m2s
2026-07-06 07:42:15 +07:00
dbd98dff37 fix git 2026-07-06 07:30:48 +07:00
647e13e988 chore: ignore server/uploads and storage folders to prevent deletion during git clean on deploy 2026-07-06 07:29:50 +07:00
db745b1bd9 fix tam
All checks were successful
Deploy on Master Change / deploy (push) Successful in 50s
2026-07-04 20:11:19 +07:00
d602e77ed3 fix
All checks were successful
Deploy on Master Change / deploy (push) Successful in 52s
2026-07-03 23:25:36 +07:00
e80569906c fix ui
All checks were successful
Deploy on Master Change / deploy (push) Successful in 49s
2026-07-03 23:01:57 +07:00
f67fc157ed fix
All checks were successful
Deploy on Master Change / deploy (push) Successful in 48s
2026-07-03 22:51:56 +07:00
f18d953a3a teamp tou uu giao dien
All checks were successful
Deploy on Master Change / deploy (push) Successful in 47s
2026-07-03 22:46:05 +07:00
1b43bfda74 teamp tou uu giao dien
All checks were successful
Deploy on Master Change / deploy (push) Successful in 49s
2026-07-03 22:40:04 +07:00
e8c9edb998 teamp tou uu giao dien
All checks were successful
Deploy on Master Change / deploy (push) Successful in 49s
2026-07-03 22:35:36 +07:00
ea53136068 teamp tou uu giao dien
All checks were successful
Deploy on Master Change / deploy (push) Successful in 49s
2026-07-03 22:28:20 +07:00
f93c2b8be6 fix tam ui
All checks were successful
Deploy on Master Change / deploy (push) Successful in 50s
2026-07-03 22:18:39 +07:00
58a089b812 fix ui
All checks were successful
Deploy on Master Change / deploy (push) Successful in 52s
2026-07-03 22:15:58 +07:00
9fe71c49bd test so do lop
All checks were successful
Deploy on Master Change / deploy (push) Successful in 51s
2026-07-03 22:09:46 +07:00
39dcb37d51 test so do lop
Some checks failed
Deploy on Master Change / deploy (push) Failing after 39s
2026-07-03 22:07:31 +07:00
8e3704f5c4 fix tam
All checks were successful
Deploy on Master Change / deploy (push) Successful in 50s
2026-07-03 21:59:52 +07:00
f60dd288ff fix ui
All checks were successful
Deploy on Master Change / deploy (push) Successful in 50s
2026-07-03 21:56:49 +07:00
391a1c23c0 fix ui
All checks were successful
Deploy on Master Change / deploy (push) Successful in 49s
2026-07-03 21:54:24 +07:00
9ea257a4fc fix
All checks were successful
Deploy on Master Change / deploy (push) Successful in 49s
2026-07-03 21:48:13 +07:00
e860fc9214 fix
Some checks failed
Deploy on Master Change / deploy (push) Failing after 39s
2026-07-03 19:05:33 +07:00
d054fce432 fix ui
All checks were successful
Deploy on Master Change / deploy (push) Successful in 48s
2026-07-03 18:33:49 +07:00
ea73c15738 fix ui
All checks were successful
Deploy on Master Change / deploy (push) Successful in 47s
2026-07-03 18:28:54 +07:00
4d17699775 fix ui
All checks were successful
Deploy on Master Change / deploy (push) Successful in 51s
2026-07-03 18:24:36 +07:00
51af268857 fix luong
All checks were successful
Deploy on Master Change / deploy (push) Successful in 54s
2026-07-03 18:18:51 +07:00
661f8e6267 fix ui
All checks were successful
Deploy on Master Change / deploy (push) Successful in 46s
2026-07-03 18:09:22 +07:00
96391b0706 fix
All checks were successful
Deploy on Master Change / deploy (push) Successful in 53s
2026-07-03 17:59:02 +07:00
0fa4a9aae2 optimize diem danh
All checks were successful
Deploy on Master Change / deploy (push) Successful in 42s
2026-07-03 06:50:04 +07:00
9a994da7f8 fix xem nhieu video
All checks were successful
Deploy on Master Change / deploy (push) Successful in 47s
2026-07-03 06:46:17 +07:00
b19bd7537e fix ui teacher
All checks were successful
Deploy on Master Change / deploy (push) Successful in 45s
2026-07-02 16:17:02 +07:00
3fa8778084 fix ui teacher
Some checks failed
Deploy on Master Change / deploy (push) Failing after 37s
2026-07-02 16:05:59 +07:00
1b424ab62d fix ui thi
All checks were successful
Deploy on Master Change / deploy (push) Successful in 47s
2026-07-02 15:51:29 +07:00
Your Name
4b8f4d288a add install guide for linux
All checks were successful
Deploy on Master Change / deploy (push) Successful in 40s
2026-07-02 13:35:53 +07:00
Your Name
2ad899d787 add linux 2026-07-02 13:34:40 +07:00
3a2653d02f f
All checks were successful
Deploy on Master Change / deploy (push) Successful in 40s
2026-07-01 15:15:22 +07:00
7e84ddeaf9 fix ui
All checks were successful
Deploy on Master Change / deploy (push) Successful in 44s
2026-07-01 15:08:31 +07:00
7a9c7a93ff fix
All checks were successful
Deploy on Master Change / deploy (push) Successful in 40s
2026-07-01 15:04:56 +07:00
0b02cc82b5 add docs
All checks were successful
Deploy on Master Change / deploy (push) Successful in 44s
2026-07-01 14:58:46 +07:00
dd766206f7 add tai lieu system
All checks were successful
Deploy on Master Change / deploy (push) Successful in 43s
2026-07-01 14:47:46 +07:00
6919513165 fix: resolve build.sh -ldflags parameter syntax error
All checks were successful
Deploy on Master Change / deploy (push) Successful in 37s
2026-07-01 14:14:58 +07:00
385103a0bf add bao mat
All checks were successful
Deploy on Master Change / deploy (push) Successful in 38s
2026-07-01 14:07:55 +07:00
a3b986674e fix: resolve concurrent write crash on gorilla/websocket by adding wsWriteMu mutex
All checks were successful
Deploy on Master Change / deploy (push) Successful in 42s
2026-07-01 13:42:13 +07:00
604ce9ea16 fix: restore Windows webcam stream support by conditionally delegating to frontend events
All checks were successful
Deploy on Master Change / deploy (push) Successful in 38s
2026-07-01 13:29:47 +07:00
122 changed files with 28426 additions and 2581 deletions

View File

@@ -51,13 +51,21 @@ jobs:
VITE_API_BASE=https://sv.rikkeiraia.org/api npm run build VITE_API_BASE=https://sv.rikkeiraia.org/api npm run build
cd .. cd ..
echo "Build storage server..."
cd storage_server
npm install
npm run build
pm2 delete storage_server || true
pm2 start dist/main.js --name storage_server
cd ..
echo "Build server backend..." echo "Build server backend..."
cd server cd server
go build -o serverlinux main.go go build -o serverlinux main.go
echo "Khởi động lại PM2..."
pm2 delete raia_v3_server || pm2 delete serverlinux || true pm2 delete raia_v3_server || pm2 delete serverlinux || true
pm2 start ./serverlinux --name raia_v3_server pm2 start ./serverlinux --name raia_v3_server
cd ..
pm2 save # lưu lại trạng thái pm2 pm2 save # lưu lại trạng thái pm2
echo "Deploy thành công!" echo "Deploy thành công!"

27
.gitignore vendored Normal file
View File

@@ -0,0 +1,27 @@
# Dependency directories
node_modules/
# Production builds
dist/
build/
# IDEs and OS files
.idea/
.vscode/
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Upload and storage directories (CRITICAL: Ignore to prevent git clean -fd from deleting them)
server/uploads/
storage/
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local

417
app_pool.sql Normal file
View File

@@ -0,0 +1,417 @@
/*
Navicat Premium Dump SQL
Source Server : RAIA_V3
Source Server Type : MySQL
Source Server Version : 80043 (8.0.43-0ubuntu0.24.04.1)
Source Host : 36.50.55.224:3306
Source Schema : simple_care
Target Server Type : MySQL
Target Server Version : 80043 (8.0.43-0ubuntu0.24.04.1)
File Encoding : 65001
Date: 13/07/2026 11:02:24
*/
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- Table structure for app_pool
-- ----------------------------
DROP TABLE IF EXISTS `app_pool`;
CREATE TABLE `app_pool` (
`id` bigint UNSIGNED NOT NULL AUTO_INCREMENT,
`created_at` datetime(3) NULL DEFAULT NULL,
`updated_at` datetime(3) NULL DEFAULT NULL,
`process_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
`process_key` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
`window_title` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL,
`keyword` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
`hit_count` bigint NULL DEFAULT 1,
`last_seen_at` datetime(3) NULL DEFAULT NULL,
`last_student_rk_id` bigint NULL DEFAULT NULL,
`last_class_rk_id` bigint NULL DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `idx_app_pool_process_key`(`process_key` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 374 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of app_pool
-- ----------------------------
INSERT INTO `app_pool` VALUES (1, '2026-07-01 08:14:10.265', '2026-07-13 09:21:02.887', 'Termius.exe', 'termius.exe', 'Termius - SFTP', 'termius', 8, '2026-07-13 09:21:02.886', 596, 63);
INSERT INTO `app_pool` VALUES (2, '2026-07-01 08:14:12.673', '2026-07-13 10:04:15.216', 'parsecd.exe', 'parsecd.exe', 'Parsec', 'parsecd', 2, '2026-07-13 10:04:15.215', 596, 63);
INSERT INTO `app_pool` VALUES (3, '2026-07-01 08:14:14.280', '2026-07-13 09:21:38.888', 'Lark.exe', 'lark.exe', 'Lark', 'lark', 627, '2026-07-13 09:21:38.888', 596, 63);
INSERT INTO `app_pool` VALUES (4, '2026-07-01 08:36:14.169', '2026-07-10 09:40:29.649', 'POWERPNT.EXE', 'powerpnt.exe', 'PowerPoint', 'powerpnt', 17, '2026-07-10 09:40:29.648', 1718, 82);
INSERT INTO `app_pool` VALUES (5, '2026-07-01 08:36:19.650', '2026-07-09 09:36:27.131', 'FoxitPDFReader.exe', 'foxitpdfreader.exe', 'Foxit PDF Reader', 'foxitpdfreader', 7, '2026-07-09 09:36:27.129', 1813, 82);
INSERT INTO `app_pool` VALUES (6, '2026-07-01 08:36:22.154', '2026-07-13 10:14:51.742', 'Zalo.exe', 'zalo.exe', 'Zalo', 'zalo', 252, '2026-07-13 10:14:51.741', 1770, 82);
INSERT INTO `app_pool` VALUES (7, '2026-07-01 08:36:53.186', '2026-07-13 08:49:31.545', 'chrome.exe', 'chrome.exe', 'Thẻ mới - Google Chrome', 'chrome', 207, '2026-07-13 08:49:31.544', 1340, 77);
INSERT INTO `app_pool` VALUES (8, '2026-07-01 08:44:12.805', '2026-07-13 08:59:49.288', 'brave.exe', 'brave.exe', 'MinhMario/hackathon-api - Brave', 'brave', 90, '2026-07-13 08:59:49.287', 1523, 77);
INSERT INTO `app_pool` VALUES (9, '2026-07-01 08:44:18.806', '2026-07-13 08:55:43.648', 'msedge.exe', 'msedge.exe', 'Rikkei Education - Personal - Microsoft Edge', 'msedge', 348, '2026-07-13 08:55:43.647', 1640, 77);
INSERT INTO `app_pool` VALUES (10, '2026-07-01 08:47:54.552', '2026-07-01 08:47:54.552', 'olk.exe', 'olk.exe', 'Outlook', 'olk', 1, '2026-07-01 08:47:54.551', 596, 63);
INSERT INTO `app_pool` VALUES (11, '2026-07-01 08:48:00.544', '2026-07-06 10:20:31.668', 'InetMgr.exe', 'inetmgr.exe', 'Internet Information Services (IIS) Manager', 'inetmgr', 3, '2026-07-06 10:20:31.665', 596, 63);
INSERT INTO `app_pool` VALUES (12, '2026-07-01 08:52:40.955', '2026-07-10 14:23:15.698', 'UltraViewer_Desktop.exe', 'ultraviewer_desktop.exe', 'UltraViewer 6.6.124 - Free', 'ultraviewer_desktop', 23, '2026-07-10 14:23:15.697', 1568, 142);
INSERT INTO `app_pool` VALUES (13, '2026-07-01 08:55:25.499', '2026-07-13 10:11:16.331', 'Discord.exe', 'discord.exe', 'Friends - Discord', 'discord', 48, '2026-07-13 10:11:16.331', 596, 63);
INSERT INTO `app_pool` VALUES (14, '2026-07-01 09:07:16.934', '2026-07-01 09:07:16.934', 'partitionwizard.exe', 'partitionwizard.exe', 'MiniTool Partition Wizard Free 13.6', 'partitionwizard', 1, '2026-07-01 09:07:16.934', 596, 63);
INSERT INTO `app_pool` VALUES (15, '2026-07-01 09:07:25.937', '2026-07-02 12:39:09.292', 'firefox.exe', 'firefox.exe', 'Google Drive Cảnh báo quét vi rút — Hồ sơ 1 — Mozilla Firefox', 'firefox', 7, '2026-07-02 12:39:09.291', 173, 156);
INSERT INTO `app_pool` VALUES (16, '2026-07-01 09:15:31.940', '2026-07-01 09:15:31.940', 'OpenKey64.exe', 'openkey64.exe', 'OpenKey 2.0.5 - Bộ gõ Tiếng Việt', 'openkey64', 1, '2026-07-01 09:15:31.938', 596, 63);
INSERT INTO `app_pool` VALUES (17, '2026-07-01 10:15:39.138', '2026-07-01 10:15:39.138', 'Transporter', 'transporter', 'Transporter', 'transporter', 1, '2026-07-01 10:15:39.137', 596, 63);
INSERT INTO `app_pool` VALUES (18, '2026-07-01 10:15:39.140', '2026-07-13 08:14:25.071', 'Safari', 'safari', 'Safari', 'safari', 36, '2026-07-13 08:14:25.070', 1608, 143);
INSERT INTO `app_pool` VALUES (19, '2026-07-01 10:15:39.147', '2026-07-13 10:19:46.582', 'Zalo', 'zalo', 'Zalo', 'zalo', 40, '2026-07-13 10:19:46.581', 596, 63);
INSERT INTO `app_pool` VALUES (20, '2026-07-01 10:15:39.152', '2026-07-13 09:21:51.901', 'Electron', 'electron', 'Electron', 'electron', 28, '2026-07-13 09:21:51.898', 1608, 143);
INSERT INTO `app_pool` VALUES (21, '2026-07-01 10:15:53.650', '2026-07-10 07:03:12.128', 'parsecd', 'parsecd', 'parsecd', 'parsecd', 5, '2026-07-10 07:03:12.127', 1613, 74);
INSERT INTO `app_pool` VALUES (22, '2026-07-01 10:16:38.620', '2026-07-01 10:16:38.620', 'GunnyChill', 'gunnychill', 'GunnyChill', 'gunnychill', 1, '2026-07-01 10:16:38.619', 596, 63);
INSERT INTO `app_pool` VALUES (23, '2026-07-01 10:17:02.639', '2026-07-06 14:34:15.337', 'Photos', 'photos', 'Photos', 'photos', 3, '2026-07-06 14:34:15.336', 1333, 141);
INSERT INTO `app_pool` VALUES (24, '2026-07-01 10:17:35.947', '2026-07-03 12:26:50.390', 'System Settings', 'system settings', 'System Settings', 'system settings', 27, '2026-07-03 12:26:50.389', 88, 156);
INSERT INTO `app_pool` VALUES (25, '2026-07-01 12:21:08.207', '2026-07-01 12:21:08.207', 'App Store', 'app store', 'App Store', 'app store', 1, '2026-07-01 12:21:08.206', 596, 63);
INSERT INTO `app_pool` VALUES (26, '2026-07-01 15:29:00.569', '2026-07-06 15:34:16.984', 'Slack', 'slack', 'Slack', 'slack', 3, '2026-07-06 15:34:16.982', 1661, 142);
INSERT INTO `app_pool` VALUES (27, '2026-07-01 15:29:00.569', '2026-07-13 07:34:27.525', 'Lark', 'lark', 'Lark', 'lark', 40, '2026-07-13 07:34:27.523', 289, 42);
INSERT INTO `app_pool` VALUES (28, '2026-07-01 15:29:00.604', '2026-07-01 15:29:00.604', 'Numbers', 'numbers', 'Numbers', 'numbers', 1, '2026-07-01 15:29:00.603', 1459, 143);
INSERT INTO `app_pool` VALUES (29, '2026-07-01 15:29:00.608', '2026-07-01 15:29:00.608', 'Obsidian', 'obsidian', 'Obsidian', 'obsidian', 1, '2026-07-01 15:29:00.607', 1459, 143);
INSERT INTO `app_pool` VALUES (30, '2026-07-01 15:29:00.611', '2026-07-13 09:17:30.680', 'TextEdit', 'textedit', 'TextEdit', 'textedit', 9, '2026-07-13 09:17:30.679', 644, 144);
INSERT INTO `app_pool` VALUES (31, '2026-07-01 15:29:00.678', '2026-07-01 15:29:00.678', 'Termius', 'termius', 'Termius', 'termius', 1, '2026-07-01 15:29:00.677', 1459, 143);
INSERT INTO `app_pool` VALUES (32, '2026-07-01 15:29:00.678', '2026-07-13 07:55:54.789', 'Notes', 'notes', 'Notes', 'notes', 26, '2026-07-13 07:55:54.788', 1675, 75);
INSERT INTO `app_pool` VALUES (33, '2026-07-01 15:29:00.695', '2026-07-03 12:18:21.310', 'Postman', 'postman', 'Postman', 'postman', 5, '2026-07-03 12:18:21.309', 111, 49);
INSERT INTO `app_pool` VALUES (34, '2026-07-01 15:29:37.629', '2026-07-13 07:03:58.538', 'Messages', 'messages', 'Messages', 'messages', 4, '2026-07-13 07:03:58.537', 626, 63);
INSERT INTO `app_pool` VALUES (35, '2026-07-01 15:29:55.637', '2026-07-08 12:06:07.339', 'Microsoft Edge', 'microsoft edge', 'Microsoft Edge', 'microsoft edge', 5, '2026-07-08 12:06:07.339', 1661, 142);
INSERT INTO `app_pool` VALUES (36, '2026-07-01 15:30:07.601', '2026-07-13 08:59:00.180', 'Music', 'music', 'Music', 'music', 17, '2026-07-13 08:59:00.179', 644, 144);
INSERT INTO `app_pool` VALUES (37, '2026-07-01 16:13:03.116', '2026-07-03 14:58:13.456', 'ApplicationFrameHost.exe', 'applicationframehost.exe', 'Camera', 'applicationframehost', 421, '2026-07-03 14:58:13.455', 1326, 142);
INSERT INTO `app_pool` VALUES (38, '2026-07-01 16:13:03.174', '2026-07-13 08:25:32.105', 'Notepad.exe', 'notepad.exe', 'venv.txt - Notepad', 'notepad', 155, '2026-07-13 08:25:32.105', 1437, 75);
INSERT INTO `app_pool` VALUES (39, '2026-07-01 16:13:03.184', '2026-07-13 07:39:37.502', 'WINWORD.EXE', 'winword.exe', 'New Microsoft Word Document - Word', 'winword', 107, '2026-07-13 07:39:37.501', 642, 144);
INSERT INTO `app_pool` VALUES (40, '2026-07-01 16:44:12.156', '2026-07-13 09:21:51.904', 'MySQLWorkbench', 'mysqlworkbench', 'MySQLWorkbench', 'mysqlworkbench', 11, '2026-07-13 09:21:51.903', 1608, 143);
INSERT INTO `app_pool` VALUES (41, '2026-07-01 18:44:17.370', '2026-07-08 14:23:48.834', 'IDMan.exe', 'idman.exe', 'Internet Download Manager 6.43', 'idman', 11, '2026-07-08 14:23:48.833', 195, 49);
INSERT INTO `app_pool` VALUES (42, '2026-07-01 20:50:16.973', '2026-07-13 09:18:08.284', 'browser.exe', 'browser.exe', 'Thẻ mới - Cốc Cốc', 'browser', 237, '2026-07-13 09:18:08.283', 1590, 144);
INSERT INTO `app_pool` VALUES (43, '2026-07-01 20:52:00.153', '2026-07-13 07:28:48.124', 'opera.exe', 'opera.exe', '(4) WINDOW Hướng dẫn tải, cài đặt chi tiết - YouTube - Opera', 'opera', 79, '2026-07-13 07:28:48.123', 1402, 77);
INSERT INTO `app_pool` VALUES (44, '2026-07-01 21:48:07.476', '2026-07-02 07:00:12.236', 'GoTiengViet', 'gotiengviet', 'GoTiengViet', 'gotiengviet', 2, '2026-07-02 07:00:12.235', 592, 63);
INSERT INTO `app_pool` VALUES (45, '2026-07-02 07:00:06.796', '2026-07-03 16:16:29.069', 'WindowsTerminal.exe', 'windowsterminal.exe', 'C:\\WINDOWS\\system32\\cmd.exe', 'windowsterminal', 58, '2026-07-03 16:16:29.068', 1466, 142);
INSERT INTO `app_pool` VALUES (46, '2026-07-02 07:00:06.797', '2026-07-03 15:55:07.518', 'Postman.exe', 'postman.exe', 'Postman', 'postman', 21, '2026-07-03 15:55:07.515', 276, 51);
INSERT INTO `app_pool` VALUES (47, '2026-07-02 07:00:06.803', '2026-07-13 09:16:46.738', 'MySQLWorkbench.exe', 'mysqlworkbench.exe', 'MySQL Workbench', 'mysqlworkbench', 123, '2026-07-13 09:16:46.737', 1572, 144);
INSERT INTO `app_pool` VALUES (48, '2026-07-02 07:00:06.804', '2026-07-02 07:00:06.804', 'Docker Desktop.exe', 'docker desktop.exe', 'Containers - Docker Desktop', 'docker desktop', 1, '2026-07-02 07:00:06.803', 613, 63);
INSERT INTO `app_pool` VALUES (49, '2026-07-02 07:00:10.234', '2026-07-13 07:03:36.228', 'Activity Monitor', 'activity monitor', 'Activity Monitor', 'activity monitor', 9, '2026-07-13 07:03:36.227', 1675, 75);
INSERT INTO `app_pool` VALUES (50, '2026-07-02 07:00:10.234', '2026-07-13 07:00:04.332', 'Passwords', 'passwords', 'Passwords', 'passwords', 2, '2026-07-13 07:00:04.331', 1608, 143);
INSERT INTO `app_pool` VALUES (51, '2026-07-02 07:00:10.235', '2026-07-10 07:03:12.292', 'Microsoft Word', 'microsoft word', 'Microsoft Word', 'microsoft word', 17, '2026-07-10 07:03:12.291', 1613, 74);
INSERT INTO `app_pool` VALUES (52, '2026-07-02 07:00:10.263', '2026-07-02 07:30:31.280', 'steam_osx', 'steam_osx', 'steam_osx', 'steam_osx', 2, '2026-07-02 07:30:31.279', 186, 51);
INSERT INTO `app_pool` VALUES (53, '2026-07-02 07:00:10.264', '2026-07-02 07:02:39.978', 'TeamViewer', 'teamviewer', 'TeamViewer', 'teamviewer', 8, '2026-07-02 07:02:39.977', 1608, 143);
INSERT INTO `app_pool` VALUES (54, '2026-07-02 07:00:10.265', '2026-07-13 07:03:36.223', 'Spotify', 'spotify', 'Spotify', 'spotify', 9, '2026-07-13 07:03:36.222', 1675, 75);
INSERT INTO `app_pool` VALUES (55, '2026-07-02 07:00:10.630', '2026-07-13 07:27:45.144', 'Spotify.exe', 'spotify.exe', 'Spotify Premium', 'spotify', 41, '2026-07-13 07:27:45.143', 1302, 77);
INSERT INTO `app_pool` VALUES (56, '2026-07-02 07:00:11.174', '2026-07-10 07:59:26.688', 'DCv2.exe', 'dcv2.exe', 'MSI Center', 'dcv2', 13, '2026-07-10 07:59:26.687', 174, 74);
INSERT INTO `app_pool` VALUES (57, '2026-07-02 07:00:14.504', '2026-07-09 10:24:41.405', 'ChatGPT.exe', 'chatgpt.exe', 'ChatGPT', 'chatgpt', 20, '2026-07-09 10:24:41.404', 1770, 82);
INSERT INTO `app_pool` VALUES (58, '2026-07-02 07:00:47.392', '2026-07-03 12:21:25.458', 'Codex.exe', 'codex.exe', 'Codex', 'codex', 6, '2026-07-03 12:21:25.457', 195, 49);
INSERT INTO `app_pool` VALUES (59, '2026-07-02 07:01:27.352', '2026-07-02 07:02:12.353', 'RobloxPlayerBeta.exe', 'robloxplayerbeta.exe', 'Roblox', 'robloxplayerbeta', 2, '2026-07-02 07:02:12.352', 1448, 143);
INSERT INTO `app_pool` VALUES (60, '2026-07-02 07:01:27.887', '2026-07-10 08:56:03.917', 'iPhone Mirroring', 'iphone mirroring', 'iPhone Mirroring', 'iphone mirroring', 13, '2026-07-10 08:56:03.916', 640, 63);
INSERT INTO `app_pool` VALUES (61, '2026-07-02 07:02:00.560', '2026-07-09 13:38:35.910', 'WinRAR.exe', 'winrar.exe', 'document_management_api.zip (evaluation copy)', 'winrar', 51, '2026-07-09 13:38:35.909', 1648, 141);
INSERT INTO `app_pool` VALUES (62, '2026-07-02 07:02:44.400', '2026-07-09 07:02:49.059', 'wallpaperui.exe', 'wallpaperui.exe', 'Wallpaper UI', 'wallpaperui', 4, '2026-07-09 07:02:49.058', 632, 63);
INSERT INTO `app_pool` VALUES (63, '2026-07-02 07:02:45.356', '2026-07-02 07:02:45.356', 'ceregreset.exe', 'ceregreset.exe', 'Registry Reset', 'ceregreset', 1, '2026-07-02 07:02:45.355', 680, 63);
INSERT INTO `app_pool` VALUES (64, '2026-07-02 07:02:49.036', '2026-07-02 07:02:49.036', 'TeamViewerUninstaller', 'teamvieweruninstaller', 'TeamViewerUninstaller', 'teamvieweruninstaller', 1, '2026-07-02 07:02:49.035', 1608, 143);
INSERT INTO `app_pool` VALUES (65, '2026-07-02 07:04:09.708', '2026-07-13 07:00:04.315', 'DeskIn', 'deskin', 'DeskIn', 'deskin', 7, '2026-07-13 07:00:04.314', 1608, 143);
INSERT INTO `app_pool` VALUES (66, '2026-07-02 07:04:29.663', '2026-07-10 14:26:48.669', 'Cloudflare WARP.exe', 'cloudflare warp.exe', 'Cloudflare WARP', 'cloudflare warp', 4, '2026-07-10 14:26:48.668', 1470, 141);
INSERT INTO `app_pool` VALUES (67, '2026-07-02 07:04:51.359', '2026-07-10 08:09:48.695', 'laragon.exe', 'laragon.exe', 'laragon', 'laragon', 9, '2026-07-10 08:09:48.695', 622, 63);
INSERT INTO `app_pool` VALUES (68, '2026-07-02 07:05:09.359', '2026-07-02 15:33:17.304', 'mmc.exe', 'mmc.exe', 'Device Manager', 'mmc', 5, '2026-07-02 15:33:17.303', 399, 49);
INSERT INTO `app_pool` VALUES (69, '2026-07-02 07:05:48.385', '2026-07-13 08:00:41.496', 'egui.exe', 'egui.exe', 'Thông báo - ESET NOD32 Antivirus', 'egui', 6, '2026-07-13 08:00:41.495', 1459, 143);
INSERT INTO `app_pool` VALUES (70, '2026-07-02 07:06:11.189', '2026-07-03 13:46:32.564', 'PhoneExperienceHost.exe', 'phoneexperiencehost.exe', 'Phone Link', 'phoneexperiencehost', 5, '2026-07-03 13:46:32.564', 1563, 78);
INSERT INTO `app_pool` VALUES (71, '2026-07-02 07:06:29.321', '2026-07-13 07:48:09.800', 'Google Chrome', 'google chrome', 'Google Chrome', 'google chrome', 11, '2026-07-13 07:48:09.800', 1675, 75);
INSERT INTO `app_pool` VALUES (72, '2026-07-02 07:07:03.991', '2026-07-02 07:07:03.991', 'GSAutoClicker.exe', 'gsautoclicker.exe', 'GS Auto Clicker 3.1.2', 'gsautoclicker', 1, '2026-07-02 07:07:03.990', 1627, 143);
INSERT INTO `app_pool` VALUES (73, '2026-07-02 07:07:10.439', '2026-07-13 09:48:19.011', 'mscopilot.exe', 'mscopilot.exe', 'Copilot', 'mscopilot', 125, '2026-07-13 09:48:19.010', 1698, 82);
INSERT INTO `app_pool` VALUES (74, '2026-07-02 07:07:30.197', '2026-07-13 07:46:26.758', 'LenovoVantage.exe', 'lenovovantage.exe', 'Lenovo Vantage', 'lenovovantage', 18, '2026-07-13 07:46:26.757', 1289, 75);
INSERT INTO `app_pool` VALUES (75, '2026-07-02 07:08:48.042', '2026-07-09 14:06:03.752', 'claude.exe', 'claude.exe', 'Claude', 'claude', 10, '2026-07-09 14:06:03.750', 1476, 141);
INSERT INTO `app_pool` VALUES (76, '2026-07-02 07:10:10.589', '2026-07-07 07:53:21.254', 'mc-web-view.exe', 'mc-web-view.exe', 'McAfee', 'mc-web-view', 6, '2026-07-07 07:53:21.253', 1781, 84);
INSERT INTO `app_pool` VALUES (77, '2026-07-02 07:12:44.599', '2026-07-02 07:14:29.620', 'seccenter.exe', 'seccenter.exe', 'Manage exceptions', 'seccenter', 6, '2026-07-02 07:14:29.620', 179, 51);
INSERT INTO `app_pool` VALUES (78, '2026-07-02 07:13:17.117', '2026-07-03 12:50:25.406', 'dwm.exe', 'dwm.exe', 'srs.docx - Word', 'dwm', 3, '2026-07-03 12:50:25.405', 195, 49);
INSERT INTO `app_pool` VALUES (79, '2026-07-02 07:14:12.249', '2026-07-13 08:30:25.046', 'Photos.exe', 'photos.exe', '20260625-103229.png', 'photos', 42, '2026-07-13 08:30:25.044', 1587, 75);
INSERT INTO `app_pool` VALUES (80, '2026-07-02 07:14:33.769', '2026-07-08 15:27:22.931', 'msiexec.exe', 'msiexec.exe', 'Node.js Setup', 'msiexec', 13, '2026-07-08 15:27:22.930', 150, 49);
INSERT INTO `app_pool` VALUES (81, '2026-07-02 07:14:48.700', '2026-07-03 15:29:32.674', 'consent.exe', 'consent.exe', 'User Account Control', 'consent', 4, '2026-07-03 15:29:32.673', 190, 51);
INSERT INTO `app_pool` VALUES (82, '2026-07-02 07:17:12.369', '2026-07-02 07:17:12.369', 'hola_cr.exe', 'hola_cr.exe', 'Hola App', 'hola_cr', 1, '2026-07-02 07:17:12.368', 1448, 143);
INSERT INTO `app_pool` VALUES (83, '2026-07-02 07:19:43.065', '2026-07-03 15:42:19.033', 'mintty.exe', 'mintty.exe', 'MINGW64:/d/hocki02-cntt6/fast-api/demo                                                  ', 'mintty', 15, '2026-07-03 15:42:19.032', 1423, 142);
INSERT INTO `app_pool` VALUES (84, '2026-07-02 07:21:09.312', '2026-07-02 07:21:09.312', 'LoadingBayInstaller.exe', 'loadingbayinstaller.exe', 'LoadingBayInstaller', 'loadingbayinstaller', 1, '2026-07-02 07:21:09.311', 1620, 143);
INSERT INTO `app_pool` VALUES (85, '2026-07-02 07:21:20.052', '2026-07-03 13:35:45.349', 'df_garena_launcher.exe', 'df_garena_launcher.exe', 'Delta Force', 'df_garena_launcher', 3, '2026-07-03 13:35:45.349', 68, 49);
INSERT INTO `app_pool` VALUES (86, '2026-07-02 07:25:56.720', '2026-07-07 14:24:40.498', 'devcpp.exe', 'devcpp.exe', 'Dev-C++', 'devcpp', 4, '2026-07-07 14:24:40.497', 1521, 72);
INSERT INTO `app_pool` VALUES (87, '2026-07-02 07:29:12.983', '2026-07-02 15:00:13.603', 'CareCenter.exe', 'carecenter.exe', 'Care Center', 'carecenter', 2, '2026-07-02 15:00:13.602', 211, 49);
INSERT INTO `app_pool` VALUES (88, '2026-07-02 07:32:19.647', '2026-07-13 09:00:05.197', 'SnippingTool.exe', 'snippingtool.exe', 'Snipping Tool', 'snippingtool', 35, '2026-07-13 09:00:05.196', 611, 144);
INSERT INTO `app_pool` VALUES (89, '2026-07-02 07:48:18.980', '2026-07-06 12:28:09.797', 'Calendar', 'calendar', 'Calendar', 'calendar', 3, '2026-07-06 12:28:09.796', 1719, 83);
INSERT INTO `app_pool` VALUES (90, '2026-07-02 08:06:53.669', '2026-07-03 16:00:18.038', 'CredentialUIBroker.exe', 'credentialuibroker.exe', 'Windows Security', 'credentialuibroker', 2, '2026-07-03 16:00:18.038', 1474, 142);
INSERT INTO `app_pool` VALUES (91, '2026-07-02 08:11:32.691', '2026-07-03 16:08:41.213', 'PickerHost.exe', 'pickerhost.exe', 'Windows Security', 'pickerhost', 19, '2026-07-03 16:08:41.213', 1331, 142);
INSERT INTO `app_pool` VALUES (92, '2026-07-02 08:22:03.384', '2026-07-09 07:00:07.509', 'NitroSense.exe', 'nitrosense.exe', 'NitroSense ', 'nitrosense', 16, '2026-07-09 07:00:07.508', 621, 63);
INSERT INTO `app_pool` VALUES (93, '2026-07-02 08:26:43.400', '2026-07-06 15:01:53.107', 'photolaunch.exe', 'photolaunch.exe', 'back (0-00-00-00).png WPS Photos', 'photolaunch', 4, '2026-07-06 15:01:53.106', 1576, 141);
INSERT INTO `app_pool` VALUES (94, '2026-07-02 08:39:29.967', '2026-07-03 13:52:16.884', 'SoftwareUpdate.exe', 'softwareupdate.exe', 'Cập nhật phần mềm Apple', 'softwareupdate', 2, '2026-07-03 13:52:16.883', 399, 49);
INSERT INTO `app_pool` VALUES (95, '2026-07-02 08:50:36.663', '2026-07-13 08:12:33.216', 'EaseOfAccessDialog.exe', 'easeofaccessdialog.exe', 'Filter Keys', 'easeofaccessdialog', 10, '2026-07-13 08:12:33.215', 1525, 77);
INSERT INTO `app_pool` VALUES (96, '2026-07-02 09:32:38.105', '2026-07-13 09:25:21.628', 'EXCEL.EXE', 'excel.exe', 'Template-Technical [Protected View] - Excel', 'excel', 24, '2026-07-13 09:25:21.627', 308, 156);
INSERT INTO `app_pool` VALUES (97, '2026-07-02 09:32:54.385', '2026-07-02 16:34:51.878', 'OneDrive.exe', 'onedrive.exe', 'Microsoft OneDrive', 'onedrive', 4, '2026-07-02 16:34:51.877', 1737, 84);
INSERT INTO `app_pool` VALUES (98, '2026-07-02 09:58:06.389', '2026-07-10 14:25:50.216', 'GetHelp.exe', 'gethelp.exe', 'Nhận Trợ giúp', 'gethelp', 5, '2026-07-10 14:25:50.216', 1598, 142);
INSERT INTO `app_pool` VALUES (99, '2026-07-02 11:10:22.535', '2026-07-13 09:03:02.818', 'wps.exe', 'wps.exe', 'WPS Office', 'wps', 27, '2026-07-13 09:03:02.817', 617, 144);
INSERT INTO `app_pool` VALUES (100, '2026-07-02 12:12:18.322', '2026-07-08 12:41:41.425', 'Dia', 'dia', 'Dia', 'dia', 35, '2026-07-08 12:41:41.424', 88, 156);
INSERT INTO `app_pool` VALUES (101, '2026-07-02 12:12:47.084', '2026-07-09 12:10:12.278', 'zoom.us', 'zoom.us', 'zoom.us', 'zoom.us', 2, '2026-07-09 12:10:12.278', 303, 156);
INSERT INTO `app_pool` VALUES (102, '2026-07-02 12:14:07.322', '2026-07-13 07:00:08.059', 'window_raia_c.exe', 'window_raia_c.exe', 'Raia Auth', 'window_raia_c', 14, '2026-07-13 07:00:08.059', 1334, 75);
INSERT INTO `app_pool` VALUES (103, '2026-07-02 12:15:19.146', '2026-07-13 08:22:50.727', 'Brave Browser', 'brave browser', 'Brave Browser', 'brave browser', 8, '2026-07-13 08:22:50.726', 289, 42);
INSERT INTO `app_pool` VALUES (104, '2026-07-02 12:17:51.501', '2026-07-10 14:26:47.817', 'bongo-cat.exe', 'bongo-cat.exe', 'BongoCat', 'bongo-cat', 8, '2026-07-10 14:26:47.816', 1406, 141);
INSERT INTO `app_pool` VALUES (105, '2026-07-02 12:19:46.798', '2026-07-02 12:25:23.849', 'RvRvpnGui.exe', 'rvrvpngui.exe', 'Radmin VPN', 'rvrvpngui', 2, '2026-07-02 12:25:23.848', 209, 156);
INSERT INTO `app_pool` VALUES (106, '2026-07-02 12:20:28.283', '2026-07-02 12:22:04.247', 'AvastBrowser.exe', 'avastbrowser.exe', 'Tab mới - Avast Secure Browser', 'avastbrowser', 2, '2026-07-02 12:22:04.246', 1648, 141);
INSERT INTO `app_pool` VALUES (107, '2026-07-02 12:23:15.491', '2026-07-07 16:05:35.276', 'Notion.exe', 'notion.exe', 'Habit tracker ', 'notion', 7, '2026-07-07 16:05:35.275', 1576, 141);
INSERT INTO `app_pool` VALUES (108, '2026-07-02 12:23:19.428', '2026-07-08 12:12:46.490', 'FxSound.exe', 'fxsound.exe', 'FxSound', 'fxsound', 6, '2026-07-08 12:12:46.489', 1432, 142);
INSERT INTO `app_pool` VALUES (109, '2026-07-02 12:23:26.936', '2026-07-02 13:46:42.272', 'Obsidian.exe', 'obsidian.exe', 'Xem biểu đồ - Obsidian Vault - Obsidian 1.12.7', 'obsidian', 10, '2026-07-02 13:46:42.271', 1414, 141);
INSERT INTO `app_pool` VALUES (110, '2026-07-02 12:23:45.465', '2026-07-08 14:54:18.853', 'app_mode_loader', 'app_mode_loader', 'app_mode_loader', 'app_mode_loader', 6, '2026-07-08 14:54:18.852', 1705, 83);
INSERT INTO `app_pool` VALUES (111, '2026-07-02 12:29:34.095', '2026-07-02 12:29:34.095', 'Attack Shark Driver.exe', 'attack shark driver.exe', 'Quantum Mechanical Kit', 'attack shark driver', 1, '2026-07-02 12:29:34.094', 273, 156);
INSERT INTO `app_pool` VALUES (112, '2026-07-02 12:30:09.476', '2026-07-03 14:16:55.336', 'wscript.exe', 'wscript.exe', 'Windows Script Host', 'wscript', 3, '2026-07-03 14:16:55.336', 1337, 81);
INSERT INTO `app_pool` VALUES (113, '2026-07-02 12:30:32.139', '2026-07-08 13:58:31.681', 'citra-qt.exe', 'citra-qt.exe', 'Citra Nightly 2104', 'citra-qt', 4, '2026-07-08 13:58:31.680', 194, 156);
INSERT INTO `app_pool` VALUES (114, '2026-07-02 12:32:07.676', '2026-07-13 07:49:09.781', 'Preview', 'preview', 'Preview', 'preview', 13, '2026-07-13 07:49:09.780', 1675, 75);
INSERT INTO `app_pool` VALUES (115, '2026-07-02 12:32:35.230', '2026-07-03 12:31:01.545', 'Update.exe', 'update.exe', 'Installing...', 'update', 2, '2026-07-03 12:31:01.543', 239, 156);
INSERT INTO `app_pool` VALUES (116, '2026-07-02 12:32:51.458', '2026-07-02 12:32:51.458', 'ClipOne', 'clipone', 'ClipOne', 'clipone', 1, '2026-07-02 12:32:51.457', 1488, 142);
INSERT INTO `app_pool` VALUES (117, '2026-07-02 12:33:27.422', '2026-07-08 07:00:44.301', 'Xmind.exe', 'xmind.exe', 'Xmind', 'xmind', 12, '2026-07-08 07:00:44.300', 1330, 75);
INSERT INTO `app_pool` VALUES (118, '2026-07-02 12:33:50.227', '2026-07-03 13:00:21.529', 'Claude Setup.exe', 'claude setup.exe', 'Claude Setup', 'claude setup', 3, '2026-07-03 13:00:21.528', 244, 156);
INSERT INTO `app_pool` VALUES (119, '2026-07-02 12:35:50.441', '2026-07-02 15:19:55.467', 'steam.exe', 'steam.exe', 'Steam', 'steam', 3, '2026-07-02 15:19:55.466', 119, 49);
INSERT INTO `app_pool` VALUES (120, '2026-07-02 12:36:05.531', '2026-07-02 12:36:05.531', 'lghub.exe', 'lghub.exe', 'G HUB', 'lghub', 1, '2026-07-02 12:36:05.530', 267, 156);
INSERT INTO `app_pool` VALUES (121, '2026-07-02 12:36:23.215', '2026-07-02 14:37:18.592', 'UniKeyNT.exe', 'unikeynt.exe', 'Warning', 'unikeynt', 7, '2026-07-02 14:37:18.592', 167, 49);
INSERT INTO `app_pool` VALUES (122, '2026-07-02 12:41:38.898', '2026-07-13 07:24:03.362', 'AsusMyASUS.exe', 'asusmyasus.exe', 'MyASUS', 'asusmyasus', 8, '2026-07-13 07:24:03.362', 1781, 84);
INSERT INTO `app_pool` VALUES (123, '2026-07-02 12:41:41.896', '2026-07-08 15:33:36.817', 'GHelper.exe', 'ghelper.exe', 'G-Helper — ROG Zephyrus G14 GA403UM', 'ghelper', 5, '2026-07-08 15:33:36.816', 1489, 141);
INSERT INTO `app_pool` VALUES (124, '2026-07-02 12:42:17.491', '2026-07-02 12:42:17.491', 'Lenovo Smart Meeting.exe', 'lenovo smart meeting.exe', 'Lenovo Smart Meeting', 'lenovo smart meeting', 1, '2026-07-02 12:42:17.490', 1529, 142);
INSERT INTO `app_pool` VALUES (125, '2026-07-02 12:44:05.888', '2026-07-09 08:32:17.255', 'PowerToys.Peek.UI.exe', 'powertoys.peek.ui.exe', 'Peek', 'powertoys.peek.ui', 2, '2026-07-09 08:32:17.254', 595, 63);
INSERT INTO `app_pool` VALUES (126, '2026-07-02 12:44:31.148', '2026-07-03 13:14:49.507', 'Obsidian-1.12.7.exe', 'obsidian-1.12.7.exe', 'Obsidian Setup ', 'obsidian-1.12.7', 6, '2026-07-03 13:14:49.506', 202, 49);
INSERT INTO `app_pool` VALUES (127, '2026-07-02 12:48:03.446', '2026-07-09 08:46:22.494', 'NVIDIA App.exe', 'nvidia app.exe', 'NVIDIA', 'nvidia app', 2, '2026-07-09 08:46:22.494', 642, 144);
INSERT INTO `app_pool` VALUES (128, '2026-07-02 12:56:28.881', '2026-07-13 07:39:00.729', 'VoiceAccess.exe', 'voiceaccess.exe', 'Voice access', 'voiceaccess', 6, '2026-07-13 07:39:00.728', 639, 63);
INSERT INTO `app_pool` VALUES (129, '2026-07-02 12:56:37.848', '2026-07-06 10:46:09.052', 'rundll32.exe', 'rundll32.exe', 'Windows Security Alert', 'rundll32', 6, '2026-07-06 10:46:09.051', 308, 156);
INSERT INTO `app_pool` VALUES (130, '2026-07-02 13:11:11.616', '2026-07-02 13:11:35.582', 'kitty', 'kitty', 'kitty', 'kitty', 2, '2026-07-02 13:11:35.580', 1492, 141);
INSERT INTO `app_pool` VALUES (131, '2026-07-02 13:13:20.422', '2026-07-13 09:10:04.337', 'Garena.exe', 'garena.exe', 'Garena - Trò chơi', 'garena', 23, '2026-07-13 09:10:04.330', 1779, 82);
INSERT INTO `app_pool` VALUES (132, '2026-07-02 13:16:44.576', '2026-07-13 07:34:27.451', 'raiav3', 'raiav3', 'raiav3', 'raiav3', 4, '2026-07-13 07:34:27.451', 289, 42);
INSERT INTO `app_pool` VALUES (133, '2026-07-02 13:17:51.172', '2026-07-02 13:17:51.172', 'python-3.12.10-amd64.exe', 'python-3.12.10-amd64.exe', 'Python 3.12.10 (64-bit) Setup', 'python-3.12.10-amd64', 1, '2026-07-02 13:17:51.172', 1424, 141);
INSERT INTO `app_pool` VALUES (134, '2026-07-02 13:17:51.581', '2026-07-02 13:17:51.581', 'WordNest.exe', 'wordnest.exe', 'WordNest Học Tiếng Anh', 'wordnest', 1, '2026-07-02 13:17:51.581', 1455, 141);
INSERT INTO `app_pool` VALUES (135, '2026-07-02 13:21:39.421', '2026-07-07 09:55:02.713', 'updatechecker.exe', 'updatechecker.exe', 'MiniTool Partition Wizard', 'updatechecker', 3, '2026-07-07 09:55:02.712', 1759, 82);
INSERT INTO `app_pool` VALUES (136, '2026-07-02 13:25:19.865', '2026-07-02 13:25:19.865', 'FoxitPhantomPDF.exe', 'foxitphantompdf.exe', 'Foxit PhantomPDF', 'foxitphantompdf', 1, '2026-07-02 13:25:19.864', 1558, 141);
INSERT INTO `app_pool` VALUES (137, '2026-07-02 13:25:21.474', '2026-07-07 15:38:04.503', 'Weather', 'weather', 'Weather', 'weather', 4, '2026-07-07 15:38:04.502', 1333, 141);
INSERT INTO `app_pool` VALUES (138, '2026-07-02 13:25:22.866', '2026-07-02 13:25:22.866', 'memreduct.exe', 'memreduct.exe', 'Mem Reduct', 'memreduct', 1, '2026-07-02 13:25:22.866', 1558, 141);
INSERT INTO `app_pool` VALUES (139, '2026-07-02 13:27:40.007', '2026-07-13 07:35:54.657', 'gnome-clocks', 'gnome-clocks', 'gnome-clocks', 'gnome-clocks', 2, '2026-07-13 07:35:54.656', 1655, 143);
INSERT INTO `app_pool` VALUES (140, '2026-07-02 13:27:40.009', '2026-07-02 13:27:40.009', 'Socket Process', 'socket process', 'Socket Process', 'socket process', 1, '2026-07-02 13:27:40.008', 596, 63);
INSERT INTO `app_pool` VALUES (141, '2026-07-02 13:27:40.012', '2026-07-02 13:27:40.012', 'Privileged Cont', 'privileged cont', 'Privileged Cont', 'privileged cont', 1, '2026-07-02 13:27:40.011', 596, 63);
INSERT INTO `app_pool` VALUES (142, '2026-07-02 13:27:40.012', '2026-07-13 07:37:03.669', 'nautilus', 'nautilus', 'nautilus', 'nautilus', 12, '2026-07-13 07:37:03.668', 1655, 143);
INSERT INTO `app_pool` VALUES (143, '2026-07-02 13:27:40.012', '2026-07-02 13:27:40.012', 'Isolated Web Co', 'isolated web co', 'Isolated Web Co', 'isolated web co', 1, '2026-07-02 13:27:40.011', 596, 63);
INSERT INTO `app_pool` VALUES (144, '2026-07-02 13:27:40.013', '2026-07-13 07:06:00.589', 'update-notifier', 'update-notifier', 'update-notifier', 'update-notifier', 7, '2026-07-13 07:06:00.588', 1655, 143);
INSERT INTO `app_pool` VALUES (145, '2026-07-02 13:27:40.068', '2026-07-02 13:27:40.068', 'Web Content', 'web content', 'Web Content', 'web content', 1, '2026-07-02 13:27:40.067', 596, 63);
INSERT INTO `app_pool` VALUES (146, '2026-07-02 13:27:40.067', '2026-07-02 13:27:40.067', 'forkserver', 'forkserver', 'forkserver', 'forkserver', 1, '2026-07-02 13:27:40.066', 596, 63);
INSERT INTO `app_pool` VALUES (147, '2026-07-02 13:27:40.068', '2026-07-02 13:27:40.068', 'glycin-svg', 'glycin-svg', 'glycin-svg', 'glycin-svg', 1, '2026-07-02 13:27:40.068', 596, 63);
INSERT INTO `app_pool` VALUES (148, '2026-07-02 13:27:40.068', '2026-07-13 07:35:54.655', 'gnome-control-c', 'gnome-control-c', 'gnome-control-c', 'gnome-control-c', 2, '2026-07-13 07:35:54.655', 1655, 143);
INSERT INTO `app_pool` VALUES (149, '2026-07-02 13:27:40.069', '2026-07-02 13:27:40.069', 'WebKitNetworkPr', 'webkitnetworkpr', 'WebKitNetworkPr', 'webkitnetworkpr', 1, '2026-07-02 13:27:40.069', 596, 63);
INSERT INTO `app_pool` VALUES (150, '2026-07-02 13:27:40.070', '2026-07-02 13:27:40.070', 'WebKitWebProces', 'webkitwebproces', 'WebKitWebProces', 'webkitwebproces', 1, '2026-07-02 13:27:40.069', 596, 63);
INSERT INTO `app_pool` VALUES (151, '2026-07-02 13:27:40.098', '2026-07-02 13:27:40.098', 'Utility Process', 'utility process', 'Utility Process', 'utility process', 1, '2026-07-02 13:27:40.097', 596, 63);
INSERT INTO `app_pool` VALUES (152, '2026-07-02 13:27:40.098', '2026-07-02 13:27:40.098', 'firefox', 'firefox', 'firefox', 'firefox', 1, '2026-07-02 13:27:40.097', 596, 63);
INSERT INTO `app_pool` VALUES (153, '2026-07-02 13:27:40.112', '2026-07-02 13:27:40.112', 'WebExtensions', 'webextensions', 'WebExtensions', 'webextensions', 1, '2026-07-02 13:27:40.111', 596, 63);
INSERT INTO `app_pool` VALUES (154, '2026-07-02 13:27:40.116', '2026-07-02 13:27:40.116', 'RDD Process', 'rdd process', 'RDD Process', 'rdd process', 1, '2026-07-02 13:27:40.116', 596, 63);
INSERT INTO `app_pool` VALUES (155, '2026-07-02 13:27:42.965', '2026-07-02 13:27:42.965', 'zenity', 'zenity', 'zenity', 'zenity', 1, '2026-07-02 13:27:42.964', 596, 63);
INSERT INTO `app_pool` VALUES (156, '2026-07-02 13:31:27.560', '2026-07-13 07:35:54.663', 'gnome-calculato', 'gnome-calculato', 'gnome-calculato', 'gnome-calculato', 2, '2026-07-13 07:35:54.662', 1655, 143);
INSERT INTO `app_pool` VALUES (157, '2026-07-02 13:31:36.551', '2026-07-02 13:31:36.551', 'snap-store', 'snap-store', 'snap-store', 'snap-store', 1, '2026-07-02 13:31:36.550', 596, 63);
INSERT INTO `app_pool` VALUES (158, '2026-07-02 13:45:54.719', '2026-07-03 14:58:12.314', 'UTM', 'utm', 'UTM', 'utm', 2, '2026-07-03 14:58:12.313', 1488, 142);
INSERT INTO `app_pool` VALUES (159, '2026-07-02 14:00:59.480', '2026-07-02 14:00:59.480', 'Magnify.exe', 'magnify.exe', 'Magnifier', 'magnify', 1, '2026-07-02 14:00:59.479', 1472, 141);
INSERT INTO `app_pool` VALUES (160, '2026-07-02 14:00:59.484', '2026-07-02 14:00:59.484', 'ShellHost.exe', 'shellhost.exe', 'Magnifier updates', 'shellhost', 1, '2026-07-02 14:00:59.484', 1472, 141);
INSERT INTO `app_pool` VALUES (161, '2026-07-02 14:27:09.431', '2026-07-02 14:27:09.431', 'UniKey.exe', 'unikey.exe', 'Warning', 'unikey', 1, '2026-07-02 14:27:09.430', 106, 49);
INSERT INTO `app_pool` VALUES (162, '2026-07-02 14:28:06.922', '2026-07-02 15:28:14.861', 'osk.exe', 'osk.exe', 'On-Screen Keyboard', 'osk', 5, '2026-07-02 15:28:14.860', 1521, 72);
INSERT INTO `app_pool` VALUES (163, '2026-07-02 14:31:03.603', '2026-07-02 14:31:03.603', 'BCUninstaller.exe', 'bcuninstaller.exe', 'Bulk Crap Uninstaller v6.1 x64', 'bcuninstaller', 1, '2026-07-02 14:31:03.602', 167, 49);
INSERT INTO `app_pool` VALUES (164, '2026-07-02 14:31:15.059', '2026-07-10 08:01:35.619', 'javaw.exe', 'javaw.exe', 'TLauncher', 'javaw', 4, '2026-07-10 08:01:35.618', 1681, 74);
INSERT INTO `app_pool` VALUES (165, '2026-07-02 14:32:02.171', '2026-07-07 14:23:04.390', 'Zoom.exe', 'zoom.exe', 'Zoom Workplace', 'zoom', 5, '2026-07-07 14:23:04.390', 1422, 72);
INSERT INTO `app_pool` VALUES (166, '2026-07-02 14:32:31.635', '2026-07-06 12:17:56.118', 'netbeans64.exe', 'netbeans64.exe', 'Error', 'netbeans64', 3, '2026-07-06 12:17:56.117', 314, 49);
INSERT INTO `app_pool` VALUES (167, '2026-07-02 14:43:47.299', '2026-07-02 14:43:47.299', 'Unknown', 'unknown', 'Welcome - Visual Studio Code', 'unknown', 1, '2026-07-02 14:43:47.298', 282, 49);
INSERT INTO `app_pool` VALUES (168, '2026-07-02 14:51:18.381', '2026-07-09 08:43:00.944', 'FoxitReader.exe', 'foxitreader.exe', 'Foxit Reader', 'foxitreader', 4, '2026-07-09 08:43:00.943', 599, 144);
INSERT INTO `app_pool` VALUES (169, '2026-07-02 14:53:47.294', '2026-07-08 14:36:24.571', 'wordpad.exe', 'wordpad.exe', 'prompy.txt - WordPad', 'wordpad', 2, '2026-07-08 14:36:24.571', 195, 49);
INSERT INTO `app_pool` VALUES (170, '2026-07-02 14:57:17.928', '2026-07-07 14:00:41.333', 'Legion Arena.exe', 'legion arena.exe', 'Legion Arena', 'legion arena', 4, '2026-07-07 14:00:41.332', 117, 49);
INSERT INTO `app_pool` VALUES (171, '2026-07-02 15:01:33.978', '2026-07-03 13:38:41.706', 'inno_updater.exe', 'inno_updater.exe', 'Visual Studio Code', 'inno_updater', 3, '2026-07-03 13:38:41.706', 170, 49);
INSERT INTO `app_pool` VALUES (172, '2026-07-02 15:06:31.577', '2026-07-03 12:31:11.604', 'pgAdmin4.exe', 'pgadmin4.exe', 'pgadmin4', 'pgadmin4', 3, '2026-07-03 12:31:11.604', 216, 49);
INSERT INTO `app_pool` VALUES (173, '2026-07-02 15:06:32.130', '2026-07-08 15:08:49.718', 'draw.io.exe', 'draw.io.exe', 'Flowchart Maker & Online Diagram Software', 'draw.io', 7, '2026-07-08 15:08:49.718', 117, 49);
INSERT INTO `app_pool` VALUES (174, '2026-07-02 15:09:03.138', '2026-07-13 09:58:21.746', 'mspaint.exe', 'mspaint.exe', 'Untitled - Paint', 'mspaint', 7, '2026-07-13 09:58:21.745', 1718, 82);
INSERT INTO `app_pool` VALUES (175, '2026-07-02 15:09:16.128', '2026-07-02 15:09:16.128', 'OemDrv.exe', 'oemdrv.exe', 'AULA F87 PRO', 'oemdrv', 1, '2026-07-02 15:09:16.127', 172, 49);
INSERT INTO `app_pool` VALUES (176, '2026-07-02 15:13:07.671', '2026-07-02 15:13:07.671', 'navicat.exe', 'navicat.exe', 'allowed_email_domains @raia_v3_db (RAIA_V3) - Table - Navicat Premium', 'navicat', 1, '2026-07-02 15:13:07.670', 596, 63);
INSERT INTO `app_pool` VALUES (177, '2026-07-02 15:13:07.704', '2026-07-09 14:50:52.629', 'DeskIn.exe', 'deskin.exe', 'DeskIn', 'deskin', 3, '2026-07-09 14:50:52.628', 1706, 83);
INSERT INTO `app_pool` VALUES (178, '2026-07-02 15:50:52.211', '2026-07-02 15:50:52.211', 'gnome-text-edit', 'gnome-text-edit', 'gnome-text-edit', 'gnome-text-edit', 1, '2026-07-02 15:50:52.210', 1655, 143);
INSERT INTO `app_pool` VALUES (179, '2026-07-02 15:51:13.208', '2026-07-13 07:39:12.597', 'localsearch-ext', 'localsearch-ext', 'localsearch-ext', 'localsearch-ext', 20, '2026-07-13 07:39:12.596', 1655, 143);
INSERT INTO `app_pool` VALUES (180, '2026-07-02 15:52:15.689', '2026-07-08 08:14:36.188', 'Calculator', 'calculator', 'Calculator', 'calculator', 4, '2026-07-08 08:14:36.187', 1717, 84);
INSERT INTO `app_pool` VALUES (181, '2026-07-02 15:53:12.243', '2026-07-02 15:53:12.243', '7zG.exe', '7zg.exe', 'Add to Archive', '7zg', 1, '2026-07-02 15:53:12.243', 159, 49);
INSERT INTO `app_pool` VALUES (182, '2026-07-02 16:55:39.805', '2026-07-06 12:22:32.690', 'BingWallpaper.exe', 'bingwallpaper.exe', 'Bing Wallpaper', 'bingwallpaper', 2, '2026-07-06 12:22:32.689', 1592, 74);
INSERT INTO `app_pool` VALUES (183, '2026-07-02 18:10:06.031', '2026-07-02 18:10:06.031', 'GitHubDesktop.exe', 'githubdesktop.exe', 'GitHub Desktop', 'githubdesktop', 1, '2026-07-02 18:10:06.030', 1525, 77);
INSERT INTO `app_pool` VALUES (184, '2026-07-03 09:14:36.166', '2026-07-07 09:10:05.128', 'Canva.exe', 'canva.exe', 'Canva', 'canva', 3, '2026-07-07 09:10:05.127', 1698, 82);
INSERT INTO `app_pool` VALUES (185, '2026-07-03 09:49:42.194', '2026-07-03 09:49:42.194', 'Deplao.exe', 'deplao.exe', 'Deplao', 'deplao', 1, '2026-07-03 09:49:42.193', 1759, 82);
INSERT INTO `app_pool` VALUES (186, '2026-07-03 12:18:19.341', '2026-07-03 12:18:19.341', 'Codex Installer.exe', 'codex installer.exe', 'Microsoft Store', 'codex installer', 1, '2026-07-03 12:18:19.341', 195, 49);
INSERT INTO `app_pool` VALUES (187, '2026-07-03 12:18:50.670', '2026-07-03 12:18:50.670', 'ATK HUB.exe', 'atk hub.exe', 'ATK HUB', 'atk hub', 1, '2026-07-03 12:18:50.669', 92, 156);
INSERT INTO `app_pool` VALUES (188, '2026-07-03 12:20:20.891', '2026-07-03 12:20:20.891', 'com.apple.WebKit.Networking', 'com.apple.webkit.networking', 'com.apple.WebKit.Networking', 'com.apple.webkit.networking', 1, '2026-07-03 12:20:20.890', 254, 156);
INSERT INTO `app_pool` VALUES (189, '2026-07-03 12:20:34.544', '2026-07-03 12:20:34.544', 'CMediaAudioControlPanel.exe', 'cmediaaudiocontrolpanel.exe', 'C-Media Audio Control Panel', 'cmediaaudiocontrolpanel', 1, '2026-07-03 12:20:34.544', 1337, 81);
INSERT INTO `app_pool` VALUES (190, '2026-07-03 12:28:52.528', '2026-07-10 08:49:51.244', 'Lively.UI.WinUI.exe', 'lively.ui.winui.exe', 'Lively Wallpaper', 'lively.ui.winui', 7, '2026-07-10 08:49:51.242', 1391, 74);
INSERT INTO `app_pool` VALUES (191, '2026-07-03 12:32:45.872', '2026-07-03 12:32:45.872', 'ChatGPT Installer.exe', 'chatgpt installer.exe', 'Microsoft Store', 'chatgpt installer', 1, '2026-07-03 12:32:45.871', 226, 156);
INSERT INTO `app_pool` VALUES (192, '2026-07-03 12:35:42.121', '2026-07-03 14:21:26.749', 'fcitx5', 'fcitx5', 'fcitx5', 'fcitx5', 4, '2026-07-03 14:21:26.749', 1624, 80);
INSERT INTO `app_pool` VALUES (193, '2026-07-03 12:35:42.122', '2026-07-03 14:21:26.824', 'kdeconnectd', 'kdeconnectd', 'kdeconnectd', 'kdeconnectd', 4, '2026-07-03 14:21:26.823', 1624, 80);
INSERT INTO `app_pool` VALUES (194, '2026-07-03 12:35:42.151', '2026-07-03 14:21:26.744', 'wl-paste', 'wl-paste', 'wl-paste', 'wl-paste', 4, '2026-07-03 14:21:26.743', 1624, 80);
INSERT INTO `app_pool` VALUES (195, '2026-07-03 12:35:42.158', '2026-07-03 14:21:26.810', 'wl-copy', 'wl-copy', 'wl-copy', 'wl-copy', 3, '2026-07-03 14:21:26.809', 1624, 80);
INSERT INTO `app_pool` VALUES (196, '2026-07-03 12:35:42.164', '2026-07-03 14:21:26.745', 'Hyprland', 'hyprland', 'Hyprland', 'hyprland', 5, '2026-07-03 14:21:26.744', 1624, 80);
INSERT INTO `app_pool` VALUES (197, '2026-07-03 12:35:42.183', '2026-07-06 14:20:03.533', 'python3', 'python3', 'python3', 'python3', 5, '2026-07-06 14:20:03.532', 1624, 141);
INSERT INTO `app_pool` VALUES (198, '2026-07-03 12:35:42.184', '2026-07-03 14:21:26.819', 'blueman-tray', 'blueman-tray', 'blueman-tray', 'blueman-tray', 3, '2026-07-03 14:21:26.819', 1624, 80);
INSERT INTO `app_pool` VALUES (199, '2026-07-03 12:35:42.187', '2026-07-03 14:21:26.798', 'nm-applet', 'nm-applet', 'nm-applet', 'nm-applet', 4, '2026-07-03 14:21:26.798', 1624, 80);
INSERT INTO `app_pool` VALUES (200, '2026-07-03 12:35:42.188', '2026-07-03 14:21:26.788', 'blueman-applet', 'blueman-applet', 'blueman-applet', 'blueman-applet', 4, '2026-07-03 14:21:26.787', 1624, 80);
INSERT INTO `app_pool` VALUES (201, '2026-07-03 12:35:42.205', '2026-07-03 14:21:26.812', 'swaync', 'swaync', 'swaync', 'swaync', 4, '2026-07-03 14:21:26.811', 1624, 80);
INSERT INTO `app_pool` VALUES (202, '2026-07-03 12:35:42.208', '2026-07-03 14:21:26.815', 'waybar', 'waybar', 'waybar', 'waybar', 5, '2026-07-03 14:21:26.814', 1624, 80);
INSERT INTO `app_pool` VALUES (203, '2026-07-03 12:38:37.501', '2026-07-03 12:38:37.501', 'brave', 'brave', 'brave', 'brave', 1, '2026-07-03 12:38:37.500', 1624, 80);
INSERT INTO `app_pool` VALUES (204, '2026-07-03 12:38:56.236', '2026-07-03 12:38:56.236', 'rofi', 'rofi', 'rofi', 'rofi', 1, '2026-07-03 12:38:56.235', 1624, 80);
INSERT INTO `app_pool` VALUES (205, '2026-07-03 12:39:47.139', '2026-07-08 12:14:53.324', 'updater.exe', 'updater.exe', 'Trình cài đặt Google Chrome', 'updater', 4, '2026-07-08 12:14:53.324', 1432, 142);
INSERT INTO `app_pool` VALUES (206, '2026-07-03 12:46:21.363', '2026-07-06 12:12:58.461', 'WeekToDo.exe', 'weektodo.exe', 'WeekToDo Planner', 'weektodo', 2, '2026-07-06 12:12:58.460', 106, 49);
INSERT INTO `app_pool` VALUES (207, '2026-07-03 13:06:57.955', '2026-07-03 13:06:57.955', 'android-studio-quail1-patch2-windows.exe', 'android-studio-quail1-patch2-windows.exe', 'verifying installer: 67%', 'android-studio-quail1-patch2-windows', 1, '2026-07-03 13:06:57.954', 250, 156);
INSERT INTO `app_pool` VALUES (208, '2026-07-03 13:11:41.447', '2026-07-08 12:37:31.068', 'DevinUserSetup-x64-3.3.18.tmp', 'devinusersetup-x64-3.3.18.tmp', 'Select Setup Language', 'devinusersetup-x64-3.3.18.tmp', 2, '2026-07-08 12:37:31.067', 285, 156);
INSERT INTO `app_pool` VALUES (209, '2026-07-03 13:19:15.809', '2026-07-09 07:04:24.345', 'jucheck.exe', 'jucheck.exe', 'Java Update - Update Available', 'jucheck', 3, '2026-07-09 07:04:24.344', 692, 144);
INSERT INTO `app_pool` VALUES (210, '2026-07-03 13:36:33.038', '2026-07-03 15:54:31.884', 'git-credential-manager.exe', 'git-credential-manager.exe', 'Connect to GitHub', 'git-credential-manager', 6, '2026-07-03 15:54:31.883', 164, 51);
INSERT INTO `app_pool` VALUES (211, '2026-07-03 14:00:25.744', '2026-07-03 14:00:25.744', 'AutoClicker.exe', 'autoclicker.exe', 'OP Auto Clicker 4.0', 'autoclicker', 1, '2026-07-03 14:00:25.743', 202, 49);
INSERT INTO `app_pool` VALUES (212, '2026-07-03 14:26:42.151', '2026-07-03 15:07:29.779', 'RadeonSoftware.exe', 'radeonsoftware.exe', 'AMD Software: Adrenalin Edition', 'radeonsoftware', 2, '2026-07-03 15:07:29.778', 1643, 142);
INSERT INTO `app_pool` VALUES (213, '2026-07-03 14:58:08.785', '2026-07-03 14:59:53.780', 'AvastUI.exe', 'avastui.exe', 'Avast Free Antivirus', 'avastui', 6, '2026-07-03 14:59:53.779', 1474, 142);
INSERT INTO `app_pool` VALUES (214, '2026-07-03 14:58:12.315', '2026-07-03 14:58:12.315', 'CrystalFetch', 'crystalfetch', 'CrystalFetch', 'crystalfetch', 1, '2026-07-03 14:58:12.314', 1488, 142);
INSERT INTO `app_pool` VALUES (215, '2026-07-03 15:28:01.241', '2026-07-09 14:42:21.559', 'ZaloCall.exe', 'zalocall.exe', 'ZaloCall', 'zalocall', 3, '2026-07-09 14:42:21.558', 1618, 72);
INSERT INTO `app_pool` VALUES (216, '2026-07-03 15:28:48.196', '2026-07-09 08:58:43.987', 'Kiro.exe', 'kiro.exe', 'workspace.json - Kiro', 'kiro', 3, '2026-07-09 08:58:43.986', 1565, 144);
INSERT INTO `app_pool` VALUES (217, '2026-07-03 15:40:10.008', '2026-07-03 15:40:10.008', 'calibre.exe', 'calibre.exe', 'calibre — || Thư viện Calibre ||', 'calibre', 1, '2026-07-03 15:40:10.007', 84, 51);
INSERT INTO `app_pool` VALUES (218, '2026-07-03 15:40:34.275', '2026-07-03 15:40:34.275', 'ebook-viewer.exe', 'ebook-viewer.exe', 'SRS [MD] — E-book viewer', 'ebook-viewer', 1, '2026-07-03 15:40:34.272', 84, 51);
INSERT INTO `app_pool` VALUES (219, '2026-07-03 15:53:05.839', '2026-07-03 15:53:50.836', 'python.exe', 'python.exe', 'Tùy biến Entry - Duy', 'python', 2, '2026-07-03 15:53:50.835', 1669, 142);
INSERT INTO `app_pool` VALUES (220, '2026-07-06 07:00:11.356', '2026-07-07 08:10:13.319', 'datagrip64.exe', 'datagrip64.exe', 'Global_DB', 'datagrip64', 2, '2026-07-07 08:10:13.318', 682, 63);
INSERT INTO `app_pool` VALUES (221, '2026-07-06 07:04:26.348', '2026-07-06 07:04:26.348', 'ToolEditPDF-dev.exe', 'tooleditpdf-dev.exe', 'ToolEditPDF', 'tooleditpdf-dev', 1, '2026-07-06 07:04:26.347', 682, 63);
INSERT INTO `app_pool` VALUES (222, '2026-07-06 07:15:21.438', '2026-07-13 08:17:19.344', 'ms-teams.exe', 'ms-teams.exe', 'Microsoft Teams', 'ms-teams', 7, '2026-07-13 08:17:19.341', 178, 75);
INSERT INTO `app_pool` VALUES (223, '2026-07-06 07:18:39.049', '2026-07-07 07:23:36.475', 'krita.exe', 'krita.exe', 'homework(4,7).kra (250.2 MiB) - Krita', 'krita', 2, '2026-07-07 07:23:36.474', 689, 63);
INSERT INTO `app_pool` VALUES (224, '2026-07-06 07:19:31.034', '2026-07-09 07:56:04.037', 'Photo Booth', 'photo booth', 'Photo Booth', 'photo booth', 5, '2026-07-09 07:56:04.036', 1608, 143);
INSERT INTO `app_pool` VALUES (225, '2026-07-06 07:46:45.450', '2026-07-06 07:46:45.450', 'open-design-0.13.0-win-x64-setup.exe', 'open-design-0.13.0-win-x64-setup.exe', 'Open Design Setup', 'open-design-0.13.0-win-x64-setup', 1, '2026-07-06 07:46:45.449', 637, 63);
INSERT INTO `app_pool` VALUES (226, '2026-07-06 07:52:33.103', '2026-07-06 07:52:33.103', 'AppleMusic.exe', 'applemusic.exe', 'Apple Music', 'applemusic', 1, '2026-07-06 07:52:33.102', 1707, 84);
INSERT INTO `app_pool` VALUES (227, '2026-07-06 07:54:42.691', '2026-07-06 07:54:42.691', 'dnmultiplayerex.exe', 'dnmultiplayerex.exe', 'LDMultiPlayer', 'dnmultiplayerex', 1, '2026-07-06 07:54:42.690', 1587, 75);
INSERT INTO `app_pool` VALUES (228, '2026-07-06 08:12:08.183', '2026-07-09 07:11:56.158', 'Mail', 'mail', 'Mail', 'mail', 3, '2026-07-09 07:11:56.157', 1608, 143);
INSERT INTO `app_pool` VALUES (229, '2026-07-06 08:45:24.144', '2026-07-07 07:42:27.881', 'Bulk Repository Creator.exe', 'bulk repository creator.exe', 'Bulk Repository Creator', 'bulk repository creator', 2, '2026-07-07 07:42:27.879', 1627, 143);
INSERT INTO `app_pool` VALUES (230, '2026-07-06 08:52:00.379', '2026-07-06 08:52:00.379', 'CanhCutTeam.exe', 'canhcutteam.exe', 'Cánh Cụt Team', 'canhcutteam', 1, '2026-07-06 08:52:00.377', 1563, 143);
INSERT INTO `app_pool` VALUES (231, '2026-07-06 09:28:58.648', '2026-07-08 12:51:35.075', 'xampp-control.exe', 'xampp-control.exe', 'XAMPP Control Panel v3.3.0 [ Compiled: Apr 6th 2021 ]', 'xampp-control', 5, '2026-07-08 12:51:35.074', 1288, 72);
INSERT INTO `app_pool` VALUES (232, '2026-07-06 12:10:12.433', '2026-07-06 12:18:04.993', 'Xmind', 'xmind', 'Xmind', 'xmind', 2, '2026-07-06 12:18:04.992', 1613, 74);
INSERT INTO `app_pool` VALUES (233, '2026-07-06 12:10:12.490', '2026-07-06 12:10:12.490', 'Skype for Business', 'skype for business', 'Skype for Business', 'skype for business', 1, '2026-07-06 12:10:12.489', 1760, 83);
INSERT INTO `app_pool` VALUES (234, '2026-07-06 12:10:12.843', '2026-07-10 07:00:04.528', 'Microsoft Excel', 'microsoft excel', 'Microsoft Excel', 'microsoft excel', 4, '2026-07-10 07:00:04.527', 1608, 143);
INSERT INTO `app_pool` VALUES (235, '2026-07-06 12:10:12.849', '2026-07-06 12:10:12.849', 'TV', 'tv', 'TV', 'tv', 1, '2026-07-06 12:10:12.846', 1760, 83);
INSERT INTO `app_pool` VALUES (236, '2026-07-06 12:12:03.573', '2026-07-06 12:12:03.573', 'nvcplui.exe', 'nvcplui.exe', 'NVIDIA Control Panel', 'nvcplui', 1, '2026-07-06 12:12:03.573', 157, 49);
INSERT INTO `app_pool` VALUES (237, '2026-07-06 12:12:12.326', '2026-07-06 12:15:28.921', 'gnome-session-b', 'gnome-session-b', 'gnome-session-b', 'gnome-session-b', 4, '2026-07-06 12:15:28.920', 82, 48);
INSERT INTO `app_pool` VALUES (238, '2026-07-06 12:12:12.326', '2026-07-06 12:15:28.910', 'gnome-software', 'gnome-software', 'gnome-software', 'gnome-software', 4, '2026-07-06 12:15:28.909', 82, 48);
INSERT INTO `app_pool` VALUES (239, '2026-07-06 12:12:23.954', '2026-07-07 12:10:06.306', 'QuickTime Player', 'quicktime player', 'QuickTime Player', 'quicktime player', 4, '2026-07-07 12:10:06.305', 1804, 83);
INSERT INTO `app_pool` VALUES (240, '2026-07-06 12:12:23.964', '2026-07-10 07:03:12.479', 'Canva', 'canva', 'Canva', 'canva', 6, '2026-07-10 07:03:12.479', 1613, 74);
INSERT INTO `app_pool` VALUES (241, '2026-07-06 12:12:59.240', '2026-07-06 12:12:59.240', 'update-service.exe', 'update-service.exe', 'update-service', 'update-service', 1, '2026-07-06 12:12:59.239', 292, 48);
INSERT INTO `app_pool` VALUES (242, '2026-07-06 12:13:33.242', '2026-07-06 12:17:31.852', 'tracker-extract', 'tracker-extract', 'tracker-extract', 'tracker-extract', 6, '2026-07-06 12:17:31.852', 82, 48);
INSERT INTO `app_pool` VALUES (243, '2026-07-06 12:13:33.310', '2026-07-13 09:00:45.674', 'wireplumber', 'wireplumber', 'wireplumber', 'wireplumber', 22, '2026-07-13 09:00:45.672', 1655, 143);
INSERT INTO `app_pool` VALUES (244, '2026-07-06 12:18:05.001', '2026-07-07 16:04:37.743', 'Tips', 'tips', 'Tips', 'tips', 3, '2026-07-07 16:04:37.742', 1333, 141);
INSERT INTO `app_pool` VALUES (245, '2026-07-06 12:18:05.047', '2026-07-10 07:03:12.411', 'ChatGPT', 'chatgpt', 'ChatGPT', 'chatgpt', 5, '2026-07-10 07:03:12.410', 1613, 74);
INSERT INTO `app_pool` VALUES (246, '2026-07-06 12:18:05.050', '2026-07-10 07:03:12.164', 'Web App', 'web app', 'Web App', 'web app', 3, '2026-07-10 07:03:12.164', 1613, 74);
INSERT INTO `app_pool` VALUES (247, '2026-07-06 12:25:00.617', '2026-07-09 08:54:31.966', 'M365Copilot.exe', 'm365copilot.exe', 'Microsoft 365 Copilot', 'm365copilot', 3, '2026-07-09 08:54:31.965', 1656, 143);
INSERT INTO `app_pool` VALUES (248, '2026-07-06 12:42:30.365', '2026-07-06 12:42:30.365', 'Archive Utility', 'archive utility', 'Archive Utility', 'archive utility', 1, '2026-07-06 12:42:30.364', 152, 48);
INSERT INTO `app_pool` VALUES (249, '2026-07-06 12:47:36.723', '2026-07-06 12:47:36.723', 'Visual Paradigm.exe', 'visual paradigm.exe', 'Visual Paradigm', 'visual paradigm', 1, '2026-07-06 12:47:36.721', 247, 48);
INSERT INTO `app_pool` VALUES (250, '2026-07-06 12:51:36.161', '2026-07-06 12:51:36.161', 'BackgroundTaskManagementAgent', 'backgroundtaskmanagementagent', 'BackgroundTaskManagementAgent', 'backgroundtaskmanagementagent', 1, '2026-07-06 12:51:36.160', 133, 48);
INSERT INTO `app_pool` VALUES (251, '2026-07-06 12:56:14.030', '2026-07-06 13:29:08.141', 'ZaloSetup-26.6.20.exe', 'zalosetup-26.6.20.exe', 'Cài đặt Zalo ', 'zalosetup-26.6.20', 2, '2026-07-06 13:29:08.140', 150, 49);
INSERT INTO `app_pool` VALUES (252, '2026-07-06 12:57:18.755', '2026-07-06 12:57:18.755', 'Chromium', 'chromium', 'Chromium', 'chromium', 1, '2026-07-06 12:57:18.754', 102, 48);
INSERT INTO `app_pool` VALUES (253, '2026-07-06 13:03:05.047', '2026-07-09 08:01:36.150', 'Figma.exe', 'figma.exe', 'Learning platform - Figma', 'figma', 2, '2026-07-09 08:01:36.150', 681, 144);
INSERT INTO `app_pool` VALUES (254, '2026-07-06 13:24:29.694', '2026-07-09 08:46:07.504', 'iCloudHome.exe', 'icloudhome.exe', 'iCloud', 'icloudhome', 2, '2026-07-09 08:46:07.503', 642, 144);
INSERT INTO `app_pool` VALUES (255, '2026-07-06 13:52:17.224', '2026-07-06 13:52:17.224', 'VoiceMemos', 'voicememos', 'VoiceMemos', 'voicememos', 1, '2026-07-06 13:52:17.222', 111, 49);
INSERT INTO `app_pool` VALUES (256, '2026-07-06 14:00:44.090', '2026-07-06 14:00:44.090', 'et.exe', 'et.exe', 'Loading application', 'et', 1, '2026-07-06 14:00:44.089', 188, 49);
INSERT INTO `app_pool` VALUES (257, '2026-07-06 14:00:56.086', '2026-07-06 14:00:56.086', 'transerr.exe', 'transerr.exe', 'Send error report', 'transerr', 1, '2026-07-06 14:00:56.085', 188, 49);
INSERT INTO `app_pool` VALUES (258, '2026-07-06 14:20:09.527', '2026-07-09 14:09:05.443', 'grim', 'grim', 'grim', 'grim', 119, '2026-07-09 14:09:05.443', 1558, 141);
INSERT INTO `app_pool` VALUES (259, '2026-07-06 14:20:13.575', '2026-07-06 14:20:13.575', 'MicrosoftWhiteboard.exe', 'microsoftwhiteboard.exe', 'Microsoft Whiteboard', 'microsoftwhiteboard', 1, '2026-07-06 14:20:13.574', 1556, 141);
INSERT INTO `app_pool` VALUES (260, '2026-07-06 14:20:32.780', '2026-07-08 07:19:43.445', 'OmenCommandCenterBackground.exe', 'omencommandcenterbackground.exe', 'OMEN Background Process', 'omencommandcenterbackground', 2, '2026-07-08 07:19:43.444', 1378, 75);
INSERT INTO `app_pool` VALUES (261, '2026-07-06 14:21:01.520', '2026-07-07 15:48:03.808', 'YourPhoneAppProxy.exe', 'yourphoneappproxy.exe', 'A25 của Đăng', 'yourphoneappproxy', 2, '2026-07-07 15:48:03.807', 1406, 141);
INSERT INTO `app_pool` VALUES (262, '2026-07-06 14:27:06.322', '2026-07-06 14:27:06.322', 'Clock', 'clock', 'Clock', 'clock', 1, '2026-07-06 14:27:06.321', 1333, 141);
INSERT INTO `app_pool` VALUES (263, '2026-07-06 14:37:39.540', '2026-07-06 15:25:33.395', 'feh', 'feh', 'feh', 'feh', 2, '2026-07-06 15:25:33.394', 1624, 141);
INSERT INTO `app_pool` VALUES (264, '2026-07-06 14:37:45.544', '2026-07-09 12:47:41.652', 'slurp', 'slurp', 'slurp', 'slurp', 7, '2026-07-09 12:47:41.651', 1558, 141);
INSERT INTO `app_pool` VALUES (265, '2026-07-06 15:24:20.930', '2026-07-07 08:16:54.832', 'HP.Omen.OmenCommandCenter.exe', 'hp.omen.omencommandcenter.exe', 'OMEN Gaming Hub', 'hp.omen.omencommandcenter', 2, '2026-07-07 08:16:54.831', 690, 63);
INSERT INTO `app_pool` VALUES (266, '2026-07-06 16:15:12.426', '2026-07-07 16:03:03.396', 'obs', 'obs', 'obs', 'obs', 2, '2026-07-07 16:03:03.395', 1624, 141);
INSERT INTO `app_pool` VALUES (267, '2026-07-07 07:00:15.719', '2026-07-07 07:00:15.719', 'jaxtinamobile', 'jaxtinamobile', 'jaxtinamobile', 'jaxtinamobile', 1, '2026-07-07 07:00:15.718', 303, 156);
INSERT INTO `app_pool` VALUES (268, '2026-07-07 07:02:39.109', '2026-07-08 07:03:38.865', 'EADesktop.exe', 'eadesktop.exe', 'EA', 'eadesktop', 2, '2026-07-08 07:03:38.865', 1565, 144);
INSERT INTO `app_pool` VALUES (269, '2026-07-07 07:03:06.229', '2026-07-13 07:59:21.454', 'PredatorSense.exe', 'predatorsense.exe', 'PredatorSense', 'predatorsense', 2, '2026-07-13 07:59:21.453', 586, 144);
INSERT INTO `app_pool` VALUES (270, '2026-07-07 07:04:40.595', '2026-07-07 07:04:40.595', 'OpenVPNConnect.exe', 'openvpnconnect.exe', 'OpenVPN Connect', 'openvpnconnect', 1, '2026-07-07 07:04:40.594', 687, 63);
INSERT INTO `app_pool` VALUES (271, '2026-07-07 07:19:23.971', '2026-07-07 07:19:23.971', 'Sniptool.exe', 'sniptool.exe', 'Sniptool 2.1', 'sniptool', 1, '2026-07-07 07:19:23.970', 643, 144);
INSERT INTO `app_pool` VALUES (272, '2026-07-07 07:35:55.891', '2026-07-08 07:43:50.626', 'Miro.exe', 'miro.exe', 'Miro', 'miro', 3, '2026-07-08 07:43:50.624', 605, 63);
INSERT INTO `app_pool` VALUES (273, '2026-07-07 08:09:26.349', '2026-07-10 08:05:31.149', 'video_conference_sdk', 'video_conference_sdk', 'video_conference_sdk', 'video_conference_sdk', 3, '2026-07-10 08:05:31.148', 640, 63);
INSERT INTO `app_pool` VALUES (274, '2026-07-07 08:35:12.201', '2026-07-07 08:35:12.201', 'laragon-wamp.tmp', 'laragon-wamp.tmp', 'Select Setup Language', 'laragon-wamp.tmp', 1, '2026-07-07 08:35:12.200', 1659, 143);
INSERT INTO `app_pool` VALUES (275, '2026-07-07 08:41:28.555', '2026-07-07 08:41:28.555', 'Lenovo Legion Toolkit.exe', 'lenovo legion toolkit.exe', 'Lenovo Legion Toolkit', 'lenovo legion toolkit', 1, '2026-07-07 08:41:28.554', 687, 63);
INSERT INTO `app_pool` VALUES (276, '2026-07-07 08:59:25.954', '2026-07-07 09:00:07.949', 'DevinUserSetup-x64-3.4.22.tmp', 'devinusersetup-x64-3.4.22.tmp', 'Setup - Devin (User)', 'devinusersetup-x64-3.4.22.tmp', 2, '2026-07-07 09:00:07.948', 244, 156);
INSERT INTO `app_pool` VALUES (277, '2026-07-07 09:47:22.092', '2026-07-07 09:47:22.092', 'Cloudflare WARP', 'cloudflare warp', 'Cloudflare WARP', 'cloudflare warp', 1, '2026-07-07 09:47:22.090', 640, 63);
INSERT INTO `app_pool` VALUES (278, '2026-07-07 10:06:01.286', '2026-07-07 10:06:01.286', 'TreeSizeFree.exe', 'treesizefree.exe', 'TreeSize Free', 'treesizefree', 1, '2026-07-07 10:06:01.285', 1306, 74);
INSERT INTO `app_pool` VALUES (279, '2026-07-07 10:17:46.599', '2026-07-07 10:17:46.599', 'MKTLogin.exe', 'mktlogin.exe', 'MKTLogin 2.1.2', 'mktlogin', 1, '2026-07-07 10:17:46.597', 621, 63);
INSERT INTO `app_pool` VALUES (280, '2026-07-07 10:55:04.893', '2026-07-07 10:55:04.893', 'WinSCP.exe', 'winscp.exe', 'Documents WinSCP', 'winscp', 1, '2026-07-07 10:55:04.892', 1655, 143);
INSERT INTO `app_pool` VALUES (281, '2026-07-07 12:14:01.078', '2026-07-10 14:34:05.169', 'akonadi_mailmer', 'akonadi_mailmer', 'akonadi_mailmer', 'akonadi_mailmer', 4, '2026-07-10 14:34:05.168', 1558, 141);
INSERT INTO `app_pool` VALUES (282, '2026-07-07 12:14:01.078', '2026-07-10 14:34:05.148', 'akonadi_mailfil', 'akonadi_mailfil', 'akonadi_mailfil', 'akonadi_mailfil', 4, '2026-07-10 14:34:05.147', 1558, 141);
INSERT INTO `app_pool` VALUES (283, '2026-07-07 12:14:01.083', '2026-07-10 14:34:05.098', 'akonadi_contact', 'akonadi_contact', 'akonadi_contact', 'akonadi_contact', 4, '2026-07-10 14:34:05.097', 1558, 141);
INSERT INTO `app_pool` VALUES (284, '2026-07-07 12:14:01.084', '2026-07-10 16:18:44.138', 'pulseaudio', 'pulseaudio', 'pulseaudio', 'pulseaudio', 1477, '2026-07-10 16:18:44.138', 1558, 141);
INSERT INTO `app_pool` VALUES (285, '2026-07-07 12:14:01.093', '2026-07-10 14:34:05.090', 'akonadi_indexin', 'akonadi_indexin', 'akonadi_indexin', 'akonadi_indexin', 4, '2026-07-10 14:34:05.089', 1558, 141);
INSERT INTO `app_pool` VALUES (286, '2026-07-07 12:14:01.131', '2026-07-10 14:34:05.144', 'akonadi_ical_re', 'akonadi_ical_re', 'akonadi_ical_re', 'akonadi_ical_re', 4, '2026-07-10 14:34:05.143', 1558, 141);
INSERT INTO `app_pool` VALUES (287, '2026-07-07 12:14:01.132', '2026-07-10 14:34:05.158', 'kalendarac', 'kalendarac', 'kalendarac', 'kalendarac', 4, '2026-07-10 14:34:05.158', 1558, 141);
INSERT INTO `app_pool` VALUES (288, '2026-07-07 12:14:01.140', '2026-07-10 14:34:05.157', 'akonadi_unified', 'akonadi_unified', 'akonadi_unified', 'akonadi_unified', 4, '2026-07-10 14:34:05.156', 1558, 141);
INSERT INTO `app_pool` VALUES (289, '2026-07-07 12:14:01.141', '2026-07-10 14:34:05.158', 'kgpg', 'kgpg', 'kgpg', 'kgpg', 4, '2026-07-10 14:34:05.157', 1558, 141);
INSERT INTO `app_pool` VALUES (290, '2026-07-07 12:14:01.142', '2026-07-10 14:34:05.154', 'akonadi_sendlat', 'akonadi_sendlat', 'akonadi_sendlat', 'akonadi_sendlat', 4, '2026-07-10 14:34:05.154', 1558, 141);
INSERT INTO `app_pool` VALUES (291, '2026-07-07 12:14:01.147', '2026-07-10 14:34:05.089', 'akonadi_migrati', 'akonadi_migrati', 'akonadi_migrati', 'akonadi_migrati', 4, '2026-07-10 14:34:05.088', 1558, 141);
INSERT INTO `app_pool` VALUES (292, '2026-07-07 12:14:01.147', '2026-07-10 14:34:05.149', 'akonadi_birthda', 'akonadi_birthda', 'akonadi_birthda', 'akonadi_birthda', 4, '2026-07-10 14:34:05.148', 1558, 141);
INSERT INTO `app_pool` VALUES (293, '2026-07-07 12:14:01.147', '2026-07-10 14:34:05.148', 'akonadi_archive', 'akonadi_archive', 'akonadi_archive', 'akonadi_archive', 4, '2026-07-10 14:34:05.147', 1558, 141);
INSERT INTO `app_pool` VALUES (294, '2026-07-07 12:14:01.150', '2026-07-10 14:34:05.154', 'akonadi_followu', 'akonadi_followu', 'akonadi_followu', 'akonadi_followu', 4, '2026-07-10 14:34:05.154', 1558, 141);
INSERT INTO `app_pool` VALUES (295, '2026-07-07 12:14:01.151', '2026-07-10 14:34:05.167', 'akonadi_maildis', 'akonadi_maildis', 'akonadi_maildis', 'akonadi_maildis', 4, '2026-07-10 14:34:05.166', 1558, 141);
INSERT INTO `app_pool` VALUES (296, '2026-07-07 12:14:01.152', '2026-07-10 16:02:06.154', 'plasma_waitforn', 'plasma_waitforn', 'plasma_waitforn', 'plasma_waitforn', 45, '2026-07-10 16:02:06.153', 1558, 141);
INSERT INTO `app_pool` VALUES (297, '2026-07-07 12:14:01.153', '2026-07-10 14:34:05.099', 'akonadi_newmail', 'akonadi_newmail', 'akonadi_newmail', 'akonadi_newmail', 4, '2026-07-10 14:34:05.098', 1558, 141);
INSERT INTO `app_pool` VALUES (298, '2026-07-07 12:14:01.158', '2026-07-10 14:34:05.146', 'ksecretd', 'ksecretd', 'ksecretd', 'ksecretd', 4, '2026-07-10 14:34:05.145', 1558, 141);
INSERT INTO `app_pool` VALUES (299, '2026-07-07 12:14:01.160', '2026-07-10 14:34:05.165', 'akonadi_maildir', 'akonadi_maildir', 'akonadi_maildir', 'akonadi_maildir', 4, '2026-07-10 14:34:05.164', 1558, 141);
INSERT INTO `app_pool` VALUES (300, '2026-07-07 12:14:01.163', '2026-07-10 14:34:05.161', 'akonadi_control', 'akonadi_control', 'akonadi_control', 'akonadi_control', 4, '2026-07-10 14:34:05.161', 1558, 141);
INSERT INTO `app_pool` VALUES (301, '2026-07-07 12:14:01.167', '2026-07-10 14:34:05.098', 'kclockd', 'kclockd', 'kclockd', 'kclockd', 4, '2026-07-10 14:34:05.098', 1558, 141);
INSERT INTO `app_pool` VALUES (302, '2026-07-07 12:14:01.169', '2026-07-10 14:34:05.089', 'akonadiserver', 'akonadiserver', 'akonadiserver', 'akonadiserver', 4, '2026-07-10 14:34:05.088', 1558, 141);
INSERT INTO `app_pool` VALUES (303, '2026-07-07 12:15:09.380', '2026-07-07 12:15:09.380', 'Discord', 'discord', 'Discord', 'discord', 1, '2026-07-07 12:15:09.379', 1333, 141);
INSERT INTO `app_pool` VALUES (304, '2026-07-07 12:29:43.138', '2026-07-07 12:29:43.138', 'drkonqi-coredum', 'drkonqi-coredum', 'drkonqi-coredum', 'drkonqi-coredum', 1, '2026-07-07 12:29:43.121', 1558, 141);
INSERT INTO `app_pool` VALUES (305, '2026-07-07 12:37:03.080', '2026-07-07 12:37:03.080', 'Dictionary', 'dictionary', 'Dictionary', 'dictionary', 1, '2026-07-07 12:37:03.079', 1492, 141);
INSERT INTO `app_pool` VALUES (306, '2026-07-07 12:39:18.135', '2026-07-07 15:17:08.687', 'Phone', 'phone', 'Phone', 'phone', 5, '2026-07-07 15:17:08.686', 1492, 141);
INSERT INTO `app_pool` VALUES (307, '2026-07-07 13:18:30.134', '2026-07-07 13:18:30.134', 'RunCat 365.exe', 'runcat 365.exe', 'Microsoft .NET', 'runcat 365', 1, '2026-07-07 13:18:30.133', 251, 49);
INSERT INTO `app_pool` VALUES (308, '2026-07-07 13:21:54.130', '2026-07-07 13:21:54.130', 'NoteBookFanControl.exe', 'notebookfancontrol.exe', 'NoteBook FanControl', 'notebookfancontrol', 1, '2026-07-07 13:21:54.129', 251, 49);
INSERT INTO `app_pool` VALUES (309, '2026-07-07 13:50:24.368', '2026-07-07 13:50:24.368', 'certutil.exe', 'certutil.exe', 'Security Warning', 'certutil', 1, '2026-07-07 13:50:24.367', 1414, 141);
INSERT INTO `app_pool` VALUES (310, '2026-07-07 13:53:00.622', '2026-07-07 13:53:00.622', 'Display Calibrator', 'display calibrator', 'Display Calibrator', 'display calibrator', 1, '2026-07-07 13:53:00.621', 1488, 142);
INSERT INTO `app_pool` VALUES (311, '2026-07-07 14:24:38.223', '2026-07-10 14:20:09.967', 'Real.exe', 'real.exe', 'Real', 'real', 2, '2026-07-10 14:20:09.966', 1584, 142);
INSERT INTO `app_pool` VALUES (312, '2026-07-07 14:35:32.221', '2026-07-07 14:35:32.221', 'wpsoffice', 'wpsoffice', 'wpsoffice', 'wpsoffice', 1, '2026-07-07 14:35:32.221', 1661, 142);
INSERT INTO `app_pool` VALUES (313, '2026-07-07 14:58:04.779', '2026-07-07 14:58:04.779', 'OllamaSetup.tmp', 'ollamasetup.tmp', 'Setup - Ollama version 0.31.1', 'ollamasetup.tmp', 1, '2026-07-07 14:58:04.778', 1406, 141);
INSERT INTO `app_pool` VALUES (314, '2026-07-07 15:13:13.468', '2026-07-07 15:13:13.468', 'dbeaver', 'dbeaver', 'dbeaver', 'dbeaver', 1, '2026-07-07 15:13:13.467', 1333, 141);
INSERT INTO `app_pool` VALUES (315, '2026-07-07 16:01:06.757', '2026-07-07 16:01:06.757', 'CapCut.exe', 'capcut.exe', 'CapCut', 'capcut', 1, '2026-07-07 16:01:06.756', 1669, 142);
INSERT INTO `app_pool` VALUES (316, '2026-07-08 07:05:20.504', '2026-07-13 08:04:19.520', 'vlc.exe', 'vlc.exe', 'v2.mp4 - VLC media player', 'vlc', 2, '2026-07-13 08:04:19.519', 621, 63);
INSERT INTO `app_pool` VALUES (317, '2026-07-08 07:05:34.817', '2026-07-09 07:06:03.285', 'goslynk-launcher.exe', 'goslynk-launcher.exe', 'Goslynk Launcher', 'goslynk-launcher', 2, '2026-07-09 07:06:03.285', 620, 63);
INSERT INTO `app_pool` VALUES (318, '2026-07-08 08:07:00.960', '2026-07-08 08:07:00.960', 'HWMonitor.exe', 'hwmonitor.exe', 'HWMonitor', 'hwmonitor', 1, '2026-07-08 08:07:00.959', 625, 63);
INSERT INTO `app_pool` VALUES (319, '2026-07-08 09:15:35.015', '2026-07-13 09:38:30.534', 'ZaloCap.exe', 'zalocap.exe', 'ZaloCap', 'zalocap', 2, '2026-07-13 09:38:30.534', 1751, 82);
INSERT INTO `app_pool` VALUES (320, '2026-07-08 09:32:11.010', '2026-07-08 09:32:11.010', 'OpenWith.exe', 'openwith.exe', 'Pick an app', 'openwith', 1, '2026-07-08 09:32:11.009', 611, 144);
INSERT INTO `app_pool` VALUES (321, '2026-07-08 10:37:02.860', '2026-07-10 08:02:34.655', 'Setup.exe', 'setup.exe', 'Setup Wizard', 'setup', 5, '2026-07-10 08:02:34.655', 618, 63);
INSERT INTO `app_pool` VALUES (322, '2026-07-08 12:06:56.296', '2026-07-10 14:26:55.413', 'ClassIn.exe', 'classin.exe', 'ClassIn', 'classin', 2, '2026-07-10 14:26:55.412', 1556, 141);
INSERT INTO `app_pool` VALUES (323, '2026-07-08 12:39:03.393', '2026-07-08 12:42:06.383', 'Devin.exe', 'devin.exe', 'Devin', 'devin', 3, '2026-07-08 12:42:06.383', 244, 156);
INSERT INTO `app_pool` VALUES (324, '2026-07-08 12:43:28.272', '2026-07-08 12:43:28.272', '7zFM.exe', '7zfm.exe', 'C:\\Users\\D365\\Downloads\\elearning_base-main.zip\\', '7zfm', 1, '2026-07-08 12:43:28.270', 162, 156);
INSERT INTO `app_pool` VALUES (325, '2026-07-08 12:49:06.961', '2026-07-08 12:49:27.952', 'LiveCaptions.exe', 'livecaptions.exe', 'Live Captions', 'livecaptions', 2, '2026-07-08 12:49:27.951', 1812, 142);
INSERT INTO `app_pool` VALUES (326, '2026-07-08 14:27:39.788', '2026-07-08 14:27:39.788', 'main.bin', 'main.bin', 'main.bin', 'main.bin', 1, '2026-07-08 14:27:39.788', 1624, 141);
INSERT INTO `app_pool` VALUES (327, '2026-07-08 14:36:19.885', '2026-07-08 16:30:42.609', 'CocCoc', 'coccoc', 'CocCoc', 'coccoc', 5, '2026-07-08 16:30:42.608', 1705, 83);
INSERT INTO `app_pool` VALUES (328, '2026-07-08 14:47:40.660', '2026-07-08 14:48:13.668', 'kdrmain.exe', 'kdrmain.exe', 'Document Repair', 'kdrmain', 2, '2026-07-08 14:48:13.667', 117, 49);
INSERT INTO `app_pool` VALUES (329, '2026-07-08 14:55:06.933', '2026-07-08 14:55:06.933', 'CloudNotifications.exe', 'cloudnotifications.exe', 'Low disk space', 'cloudnotifications', 1, '2026-07-08 14:55:06.932', 1666, 141);
INSERT INTO `app_pool` VALUES (330, '2026-07-08 15:00:20.590', '2026-07-13 09:00:03.633', 'mysql-workbench', 'mysql-workbench', 'mysql-workbench', 'mysql-workbench', 5, '2026-07-13 09:00:03.632', 1655, 143);
INSERT INTO `app_pool` VALUES (331, '2026-07-08 15:10:11.300', '2026-07-08 15:10:11.300', 'MSPCManager.exe', 'mspcmanager.exe', 'Microsoft PC Manager', 'mspcmanager', 1, '2026-07-08 15:10:11.299', 1424, 141);
INSERT INTO `app_pool` VALUES (332, '2026-07-08 15:50:37.798', '2026-07-08 15:50:37.798', 'ollama app.exe', 'ollama app.exe', 'Ollama', 'ollama app', 1, '2026-07-08 15:50:37.797', 1406, 141);
INSERT INTO `app_pool` VALUES (333, '2026-07-09 07:09:09.051', '2026-07-09 07:09:54.268', 'XPPenTablet.exe', 'xppentablet.exe', 'XPPentablet', 'xppentablet', 3, '2026-07-09 07:09:54.267', 689, 63);
INSERT INTO `app_pool` VALUES (334, '2026-07-09 07:25:34.911', '2026-07-09 07:25:34.911', 'eqMac', 'eqmac', 'eqMac', 'eqmac', 1, '2026-07-09 07:25:34.910', 640, 63);
INSERT INTO `app_pool` VALUES (335, '2026-07-09 07:31:33.290', '2026-07-09 07:31:33.290', 'LinkedIn.exe', 'linkedin.exe', 'LinkedIn', 'linkedin', 1, '2026-07-09 07:31:33.289', 682, 63);
INSERT INTO `app_pool` VALUES (336, '2026-07-09 07:41:35.961', '2026-07-09 07:41:35.961', 'FolderPainter.exe', 'folderpainter.exe', 'Folder Painter v1.3', 'folderpainter', 1, '2026-07-09 07:41:35.960', 625, 63);
INSERT INTO `app_pool` VALUES (337, '2026-07-09 08:06:15.949', '2026-07-09 09:00:48.955', 'node.exe', 'node.exe', 'C:\\Program Files\\nodejs\\node.exe', 'node', 2, '2026-07-09 09:00:48.955', 599, 144);
INSERT INTO `app_pool` VALUES (338, '2026-07-09 08:45:40.538', '2026-07-09 08:45:40.538', 'SamsungMagician.exe', 'samsungmagician.exe', 'Samsung Magician', 'samsungmagician', 1, '2026-07-09 08:45:40.538', 642, 144);
INSERT INTO `app_pool` VALUES (339, '2026-07-09 08:47:40.202', '2026-07-09 08:49:46.265', 'wmplayer.exe', 'wmplayer.exe', 'Windows Media Player', 'wmplayer', 4, '2026-07-09 08:49:46.264', 617, 144);
INSERT INTO `app_pool` VALUES (340, '2026-07-09 08:48:08.263', '2026-07-09 08:48:08.263', 'dnplayer.exe', 'dnplayer.exe', 'LDPlayer', 'dnplayer', 1, '2026-07-09 08:48:08.262', 1691, 143);
INSERT INTO `app_pool` VALUES (341, '2026-07-09 09:13:55.144', '2026-07-09 09:13:55.144', 'hubflow', 'hubflow', 'hubflow', 'hubflow', 1, '2026-07-09 09:13:55.143', 592, 63);
INSERT INTO `app_pool` VALUES (342, '2026-07-09 12:39:15.870', '2026-07-09 12:39:15.870', 'Unzip - RAR ZIP 7Z Unarchiver', 'unzip - rar zip 7z unarchiver', 'Unzip - RAR ZIP 7Z Unarchiver', 'unzip - rar zip 7z unarchiver', 1, '2026-07-09 12:39:15.869', 303, 156);
INSERT INTO `app_pool` VALUES (343, '2026-07-09 12:41:05.290', '2026-07-09 12:41:05.290', 'java.exe', 'java.exe', 'Update', 'java', 1, '2026-07-09 12:41:05.290', 1583, 141);
INSERT INTO `app_pool` VALUES (344, '2026-07-09 13:06:40.850', '2026-07-09 13:06:40.850', 'IDLE', 'idle', 'IDLE', 'idle', 1, '2026-07-09 13:06:40.849', 1333, 141);
INSERT INTO `app_pool` VALUES (345, '2026-07-10 07:03:12.404', '2026-07-10 07:03:12.404', 'TeraBox', 'terabox', 'TeraBox', 'terabox', 1, '2026-07-10 07:03:12.403', 1613, 74);
INSERT INTO `app_pool` VALUES (346, '2026-07-10 07:03:12.414', '2026-07-10 07:03:12.414', 'Freeform', 'freeform', 'Freeform', 'freeform', 1, '2026-07-10 07:03:12.413', 1613, 74);
INSERT INTO `app_pool` VALUES (347, '2026-07-10 07:03:50.421', '2026-07-10 07:03:50.421', 'WinAppHelper', 'winapphelper', 'WinAppHelper', 'winapphelper', 1, '2026-07-10 07:03:50.420', 1613, 74);
INSERT INTO `app_pool` VALUES (348, '2026-07-10 07:18:54.611', '2026-07-10 07:18:54.611', 'FaceTime', 'facetime', 'FaceTime', 'facetime', 1, '2026-07-10 07:18:54.611', 1717, 84);
INSERT INTO `app_pool` VALUES (349, '2026-07-10 07:29:48.463', '2026-07-10 07:29:48.463', 'MicrosoftSecurityApp.exe', 'microsoftsecurityapp.exe', 'Microsoft Defender', 'microsoftsecurityapp', 1, '2026-07-10 07:29:48.462', 1355, 74);
INSERT INTO `app_pool` VALUES (350, '2026-07-10 07:31:36.463', '2026-07-11 07:43:06.398', 'ChatGPT Classic.exe', 'chatgpt classic.exe', 'ChatGPT Classic', 'chatgpt classic', 6, '2026-07-11 07:43:06.397', 1384, 73);
INSERT INTO `app_pool` VALUES (351, '2026-07-10 07:37:11.844', '2026-07-10 07:37:11.844', 'NovelDocumentSplitter-dev.exe', 'noveldocumentsplitter-dev.exe', 'Novel Document Splitter', 'noveldocumentsplitter-dev', 1, '2026-07-10 07:37:11.843', 682, 63);
INSERT INTO `app_pool` VALUES (352, '2026-07-10 07:48:10.552', '2026-07-10 07:48:10.552', 'Open Design.exe', 'open design.exe', 'Open Design', 'open design', 1, '2026-07-10 07:48:10.551', 637, 63);
INSERT INTO `app_pool` VALUES (353, '2026-07-10 07:55:59.466', '2026-07-10 07:55:59.466', 'SMAILWOLF-M3.exe', 'smailwolf-m3.exe', 'SMAILWOLF M3', 'smailwolf-m3', 1, '2026-07-10 07:55:59.465', 174, 74);
INSERT INTO `app_pool` VALUES (354, '2026-07-10 07:56:02.470', '2026-07-10 07:56:02.470', 'MEG381_KC.tmp', 'meg381_kc.tmp', 'Setup', 'meg381_kc.tmp', 1, '2026-07-10 07:56:02.469', 174, 74);
INSERT INTO `app_pool` VALUES (355, '2026-07-10 08:01:11.683', '2026-07-10 08:01:11.683', 'gamingservicesui.exe', 'gamingservicesui.exe', 'Minecraft Launcher ', 'gamingservicesui', 1, '2026-07-10 08:01:11.682', 174, 74);
INSERT INTO `app_pool` VALUES (356, '2026-07-10 08:01:35.695', '2026-07-10 08:01:35.695', 'Minecraft.exe', 'minecraft.exe', 'Minecraft Launcher', 'minecraft', 1, '2026-07-10 08:01:35.694', 174, 74);
INSERT INTO `app_pool` VALUES (357, '2026-07-10 08:01:38.680', '2026-07-10 08:01:38.680', 'prismlauncher.exe', 'prismlauncher.exe', 'Prism Launcher 10.0.5', 'prismlauncher', 1, '2026-07-10 08:01:38.679', 174, 74);
INSERT INTO `app_pool` VALUES (358, '2026-07-10 08:05:31.146', '2026-07-10 08:05:31.146', 'IsExtensionEnabled', 'isextensionenabled', 'IsExtensionEnabled', 'isextensionenabled', 1, '2026-07-10 08:05:31.145', 640, 63);
INSERT INTO `app_pool` VALUES (359, '2026-07-10 08:51:56.595', '2026-07-10 08:51:56.595', 'aura-wallpaper-editor.exe', 'aura-wallpaper-editor.exe', 'Aura Wallpaper Creator', 'aura-wallpaper-editor', 1, '2026-07-10 08:51:56.594', 1306, 74);
INSERT INTO `app_pool` VALUES (360, '2026-07-10 08:57:23.585', '2026-07-10 08:57:23.585', 'colorcpl.exe', 'colorcpl.exe', 'Color Management', 'colorcpl', 1, '2026-07-10 08:57:23.584', 1306, 74);
INSERT INTO `app_pool` VALUES (361, '2026-07-10 14:20:10.730', '2026-07-10 14:20:10.730', 'ClassIn', 'classin', 'ClassIn', 'classin', 1, '2026-07-10 14:20:10.729', 1661, 142);
INSERT INTO `app_pool` VALUES (362, '2026-07-10 15:35:14.293', '2026-07-10 15:36:15.654', 'mpc-hc64.exe', 'mpc-hc64.exe', 'Quay màn hình 2026-07-10 005108.mp4', 'mpc-hc64', 2, '2026-07-10 15:36:15.652', 1474, 142);
INSERT INTO `app_pool` VALUES (363, '2026-07-10 15:51:25.583', '2026-07-10 15:51:25.583', 'launcher_main.exe', 'launcher_main.exe', 'Wuthering Waves', 'launcher_main', 1, '2026-07-10 15:51:25.582', 1542, 142);
INSERT INTO `app_pool` VALUES (364, '2026-07-10 16:03:00.515', '2026-07-10 16:03:00.515', 'Breach.exe', 'breach.exe', 'Into the Breach', 'breach', 1, '2026-07-10 16:03:00.514', 1557, 142);
INSERT INTO `app_pool` VALUES (365, '2026-07-11 07:09:32.221', '2026-07-11 07:09:32.221', 'rsAppUI.exe', 'rsappui.exe', 'RAV Endpoint Protection notification', 'rsappui', 1, '2026-07-11 07:09:32.220', 1619, 77);
INSERT INTO `app_pool` VALUES (366, '2026-07-13 07:06:00.569', '2026-07-13 07:06:00.569', 'caffeine-indica', 'caffeine-indica', 'caffeine-indica', 'caffeine-indica', 1, '2026-07-13 07:06:00.568', 1655, 143);
INSERT INTO `app_pool` VALUES (367, '2026-07-13 07:06:00.571', '2026-07-13 07:06:00.571', 'xprop', 'xprop', 'xprop', 'xprop', 1, '2026-07-13 07:06:00.570', 1655, 143);
INSERT INTO `app_pool` VALUES (368, '2026-07-13 07:06:00.580', '2026-07-13 07:06:00.580', 'caffeine', 'caffeine', 'caffeine', 'caffeine', 1, '2026-07-13 07:06:00.579', 1655, 143);
INSERT INTO `app_pool` VALUES (369, '2026-07-13 07:35:54.662', '2026-07-13 07:35:54.662', 'gnome-character', 'gnome-character', 'gnome-character', 'gnome-character', 1, '2026-07-13 07:35:54.661', 1655, 143);
INSERT INTO `app_pool` VALUES (370, '2026-07-13 07:36:45.661', '2026-07-13 07:36:45.661', 'apport-gtk', 'apport-gtk', 'apport-gtk', 'apport-gtk', 1, '2026-07-13 07:36:45.660', 1655, 143);
INSERT INTO `app_pool` VALUES (371, '2026-07-13 07:48:39.921', '2026-07-13 09:17:21.762', 'Opera', 'opera', 'Opera', 'opera', 4, '2026-07-13 09:17:21.761', 644, 144);
INSERT INTO `app_pool` VALUES (372, '2026-07-13 08:03:42.817', '2026-07-13 08:09:40.189', 'Python', 'python', 'Python', 'python', 2, '2026-07-13 08:09:40.188', 176, 77);
INSERT INTO `app_pool` VALUES (373, '2026-07-13 08:07:37.794', '2026-07-13 08:07:37.794', 'Reminders', 'reminders', 'Reminders', 'reminders', 1, '2026-07-13 08:07:37.791', 1717, 84);
SET FOREIGN_KEY_CHECKS = 1;

BIN
client/.DS_Store vendored

Binary file not shown.

86
client/INSTALL_LINUX.md Normal file
View File

@@ -0,0 +1,86 @@
# Hướng dẫn Cài đặt & Chạy ứng dụng Simple Care trên Linux
Tài liệu này hướng dẫn cách cài đặt công cụ lập trình, cài đặt thư viện chạy và tiến hành biên dịch ứng dụng Simple Care trên môi trường Linux.
---
## 1. Cho Nhà phát triển (Developer - Build ứng dụng)
### Bước 1: Cài đặt thư viện hệ thống
Cài đặt trình biên dịch C và các thư viện Webview (GTK/WebKit) tùy vào hệ điều hành đang dùng:
* **Ubuntu / Debian / Linux Mint:**
```bash
sudo apt update
sudo apt install -y build-essential libgtk-3-dev libwebkit2gtk-4.1-dev libx11-dev pkg-config zenity libcanberra-gtk3-module
```
* **Fedora / RHEL:**
```bash
sudo dnf groupinstall "Development Tools"
sudo dnf install -y gtk3-devel webkit2gtk4.1-devel libX11-devel pkgconf-pkg-config zenity
```
* **Arch Linux:**
```bash
sudo pacman -Syu --needed base-devel gtk3 webkit2gtk-4.1 libx11 pkgconf zenity
```
### Bước 2: Cài đặt Go & Node.js & Wails CLI
Nếu chưa cài đặt bộ công cụ biên dịch:
1. Tải và cài đặt **Go** (bản 1.23+): [golang.org](https://go.dev/dl/)
2. Tải và cài đặt **Node.js** (bản 18 hoặc 20 LTS): [nodejs.org](https://nodejs.org/)
3. Cài đặt **Wails CLI** thông qua Go:
```bash
go install github.com/wailsapp/wails/v2/cmd/wails@v2.12.0
```
4. Cài đặt công cụ mã hóa **Garble**:
```bash
go install mvdan.cc/garble@v0.13.0
```
### Bước 3: Tiến hành Biên dịch (Build)
Chạy script tự động phát hiện hệ điều hành để build bản bảo mật (obfuscated):
```bash
cd client
chmod +x build.sh
./build.sh
```
Sau khi build xong, file thực thi sẽ nằm tại: `client/build/bin/simple_care_v1.3`
---
## 2. Cho Người dùng cuối (End-user - Chỉ chạy ứng dụng)
Người dùng cuối **không cần** cài đặt Go, Node hay trình biên dịch C. Họ chỉ cần tệp thực thi `simple_care_v1.3` và cài đặt các thư viện đồ họa cơ bản của hệ thống:
* **Ubuntu / Debian / Linux Mint:**
```bash
sudo apt update
sudo apt install -y libwebkit2gtk-4.1-0 zenity
```
* **Fedora / RHEL:**
```bash
sudo dnf install -y webkit2gtk4.1 zenity
```
* **Arch Linux:**
```bash
sudo pacman -Sy webkit2gtk-4.1 zenity
```
### Lệnh chạy ứng dụng:
Cấp quyền chạy cho file và khởi chạy:
```bash
chmod +x simple_care_v1.3
./simple_care_v1.3
```
---
## 3. Lệnh dọn dẹp bộ nhớ đệm (Clean cache)
Nếu thư mục `client/build/` xuất hiện nhiều thư mục đệm tạm thời (dạng số hexa `00` đến `ff`), bạn có thể dọn dẹp bằng lệnh sau:
```bash
find build -mindepth 1 -maxdepth 1 -type d -name "[0-9a-f][0-9a-f]" -exec rm -rf {} +
```

File diff suppressed because it is too large Load Diff

View File

@@ -2,16 +2,51 @@
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
Write-Host "=============================================" -ForegroundColor Cyan Write-Host "=============================================" -ForegroundColor Cyan
Write-Host " BUILDING WAILS CLIENT " -ForegroundColor Cyan Write-Host " BUILDING SECURE WAILS CLIENT " -ForegroundColor Cyan
Write-Host "=============================================" -ForegroundColor Cyan Write-Host "=============================================" -ForegroundColor Cyan
# Setup Go path for tools like garble
try {
$goPath = go env GOPATH
if ($goPath) {
$goBin = Join-Path $goPath "bin"
if (Test-Path $goBin) {
if ($env:PATH -notlike "*$goBin*") {
$env:PATH = "$goBin;$env:PATH"
Write-Host "[*] Added $goBin to PATH temporarily" -ForegroundColor Gray
}
}
}
} catch {
Write-Host "[!] Could not resolve GOPATH. Proceeding with default PATH..." -ForegroundColor Yellow
}
# Ensure Garble is installed
$garbleInstalled = $null -ne (Get-Command garble -ErrorAction SilentlyContinue)
if (-not $garbleInstalled) {
Write-Host "[*] Garble is not installed. Installing mvdan.cc/garble@latest..." -ForegroundColor Cyan
try {
go install mvdan.cc/garble@latest
Write-Host "[+] Garble installed successfully!" -ForegroundColor Green
} catch {
Write-Host "[!] Failed to install Garble. Will fall back to standard Go compiler." -ForegroundColor Yellow
}
}
# Check if wails is installed # Check if wails is installed
$wailsInstalled = $null -ne (Get-Command wails -ErrorAction SilentlyContinue) $wailsInstalled = $null -ne (Get-Command wails -ErrorAction SilentlyContinue)
if ($wailsInstalled) { if ($wailsInstalled) {
Write-Host "[*] Found Wails CLI. Building via Wails..." -ForegroundColor Green Write-Host "[*] Found Wails CLI. Building via Wails with Obfuscation..." -ForegroundColor Green
try { try {
wails build $garbleInstalled = $null -ne (Get-Command garble -ErrorAction SilentlyContinue)
if ($garbleInstalled) {
Write-Host "[*] Compiling using Garble compiler..." -ForegroundColor Green
wails build -clean -compiler garble -obfuscated -trimpath -m -ldflags "-s -w"
} else {
Write-Host "[!] Garble not available. Compiling with default Go compiler..." -ForegroundColor Yellow
wails build -clean -ldflags "-s -w"
}
Write-Host "[+] Build completed successfully using Wails CLI!" -ForegroundColor Green Write-Host "[+] Build completed successfully using Wails CLI!" -ForegroundColor Green
exit 0 exit 0
} catch { } catch {
@@ -53,8 +88,14 @@ try {
Write-Host "[*] Building Go application..." -ForegroundColor Cyan Write-Host "[*] Building Go application..." -ForegroundColor Cyan
try { try {
# -H windowsgui: prevents console window from flashing on startup $garbleInstalled = $null -ne (Get-Command garble -ErrorAction SilentlyContinue)
if ($garbleInstalled) {
Write-Host "[*] Compiling manual build with Garble..." -ForegroundColor Green
garble build -ldflags="-s -w -H windowsgui" -o client.exe main.go app.go
} else {
Write-Host "[!] Garble not available. Compiling manual build with standard Go..." -ForegroundColor Yellow
go build -ldflags="-s -w -H windowsgui" -o client.exe main.go app.go go build -ldflags="-s -w -H windowsgui" -o client.exe main.go app.go
}
Write-Host "[+] Build completed successfully! Generated client.exe" -ForegroundColor Green Write-Host "[+] Build completed successfully! Generated client.exe" -ForegroundColor Green
} catch { } catch {
Write-Host "[-] Go build failed." -ForegroundColor Red Write-Host "[-] Go build failed." -ForegroundColor Red

View File

@@ -5,9 +5,24 @@ set -e
cd "$(dirname "$0")" cd "$(dirname "$0")"
echo "=============================================" echo "============================================="
echo " BUILDING WAILS CLIENT FOR MACOS " echo " BUILDING SECURE WAILS CLIENT "
echo "=============================================" echo "============================================="
# Setup Go path for tools like garble and wails
GOPATH=$(go env GOPATH)
if [ -n "$GOPATH" ] && [ -d "$GOPATH/bin" ]; then
export PATH="$GOPATH/bin:$PATH"
fi
if [ -d "$HOME/go/bin" ]; then
export PATH="$HOME/go/bin:$PATH"
fi
# Ensure Garble is installed
if ! command -v garble &> /dev/null; then
echo "[*] Garble is not installed. Installing mvdan.cc/garble@latest..."
go install mvdan.cc/garble@latest || echo "[!] Failed to install Garble. Will use default Go compiler."
fi
# Check if wails is installed # Check if wails is installed
WAILS_CMD="wails" WAILS_CMD="wails"
if ! command -v wails &> /dev/null; then if ! command -v wails &> /dev/null; then
@@ -21,10 +36,31 @@ if ! command -v wails &> /dev/null; then
fi fi
fi fi
echo "[*] Building macOS Apple Silicon (arm64)..." # Set compiler and obfuscation flags if garble is available
"$WAILS_CMD" build -platform darwin/arm64 BUILD_FLAGS=(-clean -obfuscated -trimpath -m)
if command -v garble &> /dev/null; then
echo "[*] Garble found. Compiling with Obfuscator..."
BUILD_FLAGS+=(-compiler garble)
else
echo "[!] Garble not found. Compiling with default Go compiler..."
fi
BUILD_FLAGS+=(-ldflags "-s -w")
echo "[*] Building macOS Intel (amd64)..." # Detect host OS
"$WAILS_CMD" build -platform darwin/amd64 HOST_OS=$(go env GOOS)
echo "[+] macOS arm64 and amd64 builds completed successfully!" if [ "$HOST_OS" = "darwin" ]; then
echo "[*] Building macOS Apple Silicon (arm64)..."
"$WAILS_CMD" build -platform darwin/arm64 "${BUILD_FLAGS[@]}"
echo "[*] Building macOS Intel (amd64)..."
"$WAILS_CMD" build -platform darwin/amd64 "${BUILD_FLAGS[@]}"
echo "[+] macOS arm64 and amd64 builds completed successfully!"
elif [ "$HOST_OS" = "linux" ]; then
echo "[*] Building Linux Intel/AMD64 (amd64)..."
"$WAILS_CMD" build -platform linux/amd64 -tags webkit2_41 "${BUILD_FLAGS[@]}"
echo "[+] Linux amd64 build completed successfully!"
else
echo "[!] Unsupported host OS: $HOST_OS. Please build manually using 'wails build'."
exit 1
fi

BIN
client/build/.DS_Store vendored

Binary file not shown.

1
client/build/trim.txt Normal file
View File

@@ -0,0 +1 @@
1782971860

View File

@@ -1,12 +1,12 @@
{ {
"name": "frontend", "name": "frontend",
"version": "0.0.0", "version": "1.3.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "frontend", "name": "frontend",
"version": "0.0.0", "version": "1.3.0",
"dependencies": { "dependencies": {
"pdfjs-dist": "^3.11.174" "pdfjs-dist": "^3.11.174"
}, },

View File

@@ -1,7 +1,7 @@
{ {
"name": "frontend", "name": "frontend",
"private": true, "private": true,
"version": "0.0.0", "version": "1.3.0",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",

View File

@@ -1 +1 @@
967eadb7b0ec47c936144695be4b0b15 792441f7861d7badf1335f3778fcdebd

View File

@@ -18,10 +18,12 @@
--offline-light: #fdeaea; --offline-light: #fdeaea;
--shadow-sm: 0 1px 3px rgba(26, 35, 50, 0.05); --shadow-sm: 0 1px 3px rgba(26, 35, 50, 0.05);
--shadow-md: 0 4px 16px rgba(26, 35, 50, 0.07); --shadow-md: 0 4px 16px rgba(26, 35, 50, 0.07);
--radius-sm: 8px; --radius-sm: 7px;
--radius-md: 12px; --radius-md: 10px;
--radius-lg: 16px; --radius-lg: 12px;
--transition: 0.2s ease; --transition: 0.2s ease;
--page-pad: 0.75rem 0.9rem;
--card-pad: 0.95rem 1rem;
} }
* { * {
@@ -32,7 +34,8 @@
html { html {
height: 100%; height: 100%;
overflow: hidden; overflow-x: hidden;
overflow-y: auto;
} }
body { body {
@@ -40,18 +43,22 @@ body {
background-color: var(--bg-main); background-color: var(--bg-main);
color: var(--text-primary); color: var(--text-primary);
font-family: 'Be Vietnam Pro', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-family: 'Be Vietnam Pro', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
overflow: hidden; font-size: 14px;
height: 100%; overflow-x: hidden;
overflow-y: auto;
min-height: 100%;
height: auto;
width: 100%; width: 100%;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
} }
#app { #app {
width: 100%; width: 100%;
height: 100%; min-height: 100%;
max-height: 100vh; height: auto;
display: flex; max-height: none;
overflow: hidden; display: block;
overflow: visible;
} }
/* ── Cards ── */ /* ── Cards ── */
@@ -60,7 +67,7 @@ body {
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
padding: 1.75rem; padding: var(--card-pad);
transition: var(--transition); transition: var(--transition);
} }
@@ -70,10 +77,10 @@ body {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 100%; width: 100%;
height: 100%; min-height: 100%;
padding: 1.5rem; padding: 1rem;
background: var(--bg-main); background: var(--bg-main);
overflow: hidden; overflow: visible;
} }
.login-prompt-container .card { .login-prompt-container .card {
@@ -94,8 +101,8 @@ body {
} }
.brand-logo-img { .brand-logo-img {
width: 42px; width: 34px;
height: 42px; height: 34px;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
object-fit: cover; object-fit: cover;
flex-shrink: 0; flex-shrink: 0;
@@ -103,8 +110,8 @@ body {
} }
.login-brand-row .brand-logo-img { .login-brand-row .brand-logo-img {
width: 48px; width: 40px;
height: 48px; height: 40px;
} }
.login-logo { .login-logo {
@@ -133,6 +140,10 @@ body {
line-height: 1.2; line-height: 1.2;
} }
.login-brand-name .app-version {
font-size: 0.72em;
}
.login-brand-sub { .login-brand-sub {
font-size: 0.72rem; font-size: 0.72rem;
font-weight: 500; font-weight: 500;
@@ -159,11 +170,13 @@ body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 100%; width: 100%;
height: 100%; min-height: 100%;
max-height: 100vh; height: auto;
padding: 1.25rem 1.5rem; max-height: none;
padding: var(--page-pad);
padding-bottom: 1.25rem;
box-sizing: border-box; box-sizing: border-box;
overflow: hidden; overflow: visible;
animation: fadeIn 0.35s ease-out; animation: fadeIn 0.35s ease-out;
} }
@@ -171,10 +184,14 @@ body {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 1rem; margin-bottom: 0.65rem;
padding-bottom: 0.85rem; padding-bottom: 0.55rem;
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
flex-shrink: 0; flex-shrink: 0;
position: sticky;
top: 0;
z-index: 20;
background: var(--bg-main);
} }
.brand { .brand {
@@ -184,15 +201,15 @@ body {
} }
.brand-logo { .brand-logo {
width: 38px; width: 32px;
height: 38px; height: 32px;
background: var(--accent); background: var(--accent);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-weight: 800; font-weight: 800;
font-size: 0.85rem; font-size: 0.75rem;
color: white; color: white;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -205,25 +222,36 @@ body {
.brand-name { .brand-name {
font-weight: 800; font-weight: 800;
font-size: 1.05rem; font-size: 0.95rem;
letter-spacing: -0.3px; letter-spacing: -0.3px;
color: var(--text-primary); color: var(--text-primary);
line-height: 1.2; line-height: 1.2;
} }
.app-version {
font-size: 0.68em;
font-weight: 700;
color: var(--accent);
letter-spacing: 0;
margin-left: 0.2rem;
vertical-align: middle;
}
.brand-sub { .brand-sub {
font-size: 0.7rem; font-size: 0.62rem;
font-weight: 500; font-weight: 500;
color: var(--accent); color: var(--accent);
} }
.main-grid { .main-grid {
display: grid; display: grid;
grid-template-columns: minmax(220px, 260px) 1fr; grid-template-columns: minmax(180px, 220px) 1fr;
gap: 1rem; gap: 0.75rem;
flex: 1; flex: none;
min-height: 0; min-height: 0;
overflow: hidden; height: auto;
overflow: visible;
align-items: start;
} }
/* ── Profile Card ── */ /* ── Profile Card ── */
@@ -232,25 +260,25 @@ body {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
text-align: center; text-align: center;
height: 100%; height: auto;
min-height: 0; min-height: 0;
overflow-y: auto; overflow: visible;
justify-content: flex-start; justify-content: flex-start;
padding-top: 1rem; padding-top: 0.75rem;
} }
.avatar-container { .avatar-container {
position: relative; position: relative;
margin-bottom: 1.25rem; margin-bottom: 0.75rem;
} }
.avatar { .avatar {
width: 84px; width: 68px;
height: 84px; height: 68px;
border-radius: 50%; border-radius: 50%;
object-fit: cover; object-fit: cover;
border: 3px solid var(--accent-light); border: 2px solid var(--accent-light);
padding: 3px; padding: 2px;
background: var(--bg-subtle); background: var(--bg-subtle);
} }
@@ -276,7 +304,7 @@ body {
.student-name { .student-name {
margin: 0; margin: 0;
font-size: 1.2rem; font-size: 1.05rem;
font-weight: 700; font-weight: 700;
color: var(--text-primary); color: var(--text-primary);
letter-spacing: -0.3px; letter-spacing: -0.3px;
@@ -285,12 +313,12 @@ body {
.student-code { .student-code {
background: var(--accent-light); background: var(--accent-light);
color: var(--accent); color: var(--accent);
padding: 3px 10px; padding: 2px 8px;
border-radius: 6px; border-radius: 6px;
font-size: 0.8rem; font-size: 0.72rem;
font-weight: 700; font-weight: 700;
margin-top: 0.4rem; margin-top: 0.3rem;
margin-bottom: 0.5rem; margin-bottom: 0.35rem;
border: 1px solid rgba(187, 33, 38, 0.15); border: 1px solid rgba(187, 33, 38, 0.15);
letter-spacing: 0.3px; letter-spacing: 0.3px;
display: inline-block; display: inline-block;
@@ -299,22 +327,22 @@ body {
.student-email { .student-email {
margin: 0; margin: 0;
color: var(--text-muted); color: var(--text-muted);
font-size: 0.82rem; font-size: 0.75rem;
} }
.divider { .divider {
width: 100%; width: 100%;
height: 1px; height: 1px;
background: var(--border-color); background: var(--border-color);
margin: 1.25rem 0; margin: 0.75rem 0;
} }
.profile-info-row { .profile-info-row {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
width: 100%; width: 100%;
font-size: 0.82rem; font-size: 0.75rem;
margin-bottom: 0.6rem; margin-bottom: 0.4rem;
} }
.profile-info-row span { .profile-info-row span {
@@ -330,17 +358,17 @@ body {
.right-column { .right-column {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.85rem; gap: 0.65rem;
overflow: hidden; overflow: visible;
height: 100%; height: auto;
min-height: 0; min-height: 0;
} }
.status-banner { .status-banner {
padding: 0.75rem 1rem; padding: 0.55rem 0.75rem;
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 0.75rem; gap: 0.55rem;
border-left: 3px solid var(--accent); border-left: 3px solid var(--accent);
flex-shrink: 0; flex-shrink: 0;
} }
@@ -393,13 +421,13 @@ body {
.exam-panel { .exam-panel {
border: 1px solid #c4b5fd; border: 1px solid #c4b5fd;
background: linear-gradient(135deg, #faf5ff 0%, #fff 100%); background: linear-gradient(135deg, #faf5ff 0%, #fff 100%);
padding: 1rem 1.15rem; padding: 0.75rem 0.9rem;
margin-bottom: 0.75rem; margin-bottom: 0;
} }
.exam-panel-desc { .exam-panel-desc {
margin: 0 0 0.85rem; margin: 0 0 0.65rem;
font-size: 0.85rem; font-size: 0.78rem;
color: var(--text-secondary); color: var(--text-secondary);
} }
@@ -410,6 +438,38 @@ body {
align-items: center; align-items: center;
} }
.tools-panel {
margin-bottom: 0;
padding: 0.75rem 0.9rem;
border: 1px solid #f5c2c4;
background: linear-gradient(135deg, #fff8f8 0%, #fff 100%);
}
.tools-panel-desc {
margin: 0 0 0.65rem;
font-size: 0.78rem;
color: var(--text-secondary);
}
.tools-panel-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.btn-tool {
background: #fff;
color: var(--accent);
border: 1px solid var(--accent);
padding: 0.4rem 0.75rem;
font-size: 0.78rem;
}
.btn-tool:hover {
background: var(--accent-light);
}
.exam-wait, .exam-done { .exam-wait, .exam-done {
font-size: 0.82rem; font-size: 0.82rem;
color: var(--text-muted); color: var(--text-muted);
@@ -608,7 +668,7 @@ body {
.stats-grid { .stats-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
gap: 0.65rem; gap: 0.5rem;
} }
.stats-grid--two { .stats-grid--two {
@@ -957,9 +1017,10 @@ body {
} }
.shifts-wrap { .shifts-wrap {
flex: 1; flex: none;
min-height: 0; min-height: 0;
overflow: auto; max-height: none;
overflow: visible;
} }
.shifts-table { .shifts-table {
@@ -1028,14 +1089,14 @@ body {
} }
.status-banner-icon { .status-banner-icon {
width: 36px; width: 30px;
height: 36px; height: 30px;
background: var(--accent-light); background: var(--accent-light);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 1.1rem; font-size: 0.95rem;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -1048,7 +1109,7 @@ body {
} }
.status-banner-value { .status-banner-value {
font-size: 0.9rem; font-size: 0.82rem;
font-weight: 600; font-weight: 600;
color: var(--text-primary); color: var(--text-primary);
} }
@@ -1056,19 +1117,19 @@ body {
.stat-card { .stat-card {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.85rem; gap: 0.65rem;
padding: 0.9rem 1rem; padding: 0.65rem 0.75rem;
} }
.stat-icon-wrap { .stat-icon-wrap {
width: 40px; width: 34px;
height: 40px; height: 34px;
background: var(--bg-subtle); background: var(--bg-subtle);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 1.2rem; font-size: 1rem;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -1088,7 +1149,7 @@ body {
} }
.stat-value { .stat-value {
font-size: 0.95rem; font-size: 0.85rem;
font-weight: 700; font-weight: 700;
color: var(--text-primary); color: var(--text-primary);
white-space: nowrap; white-space: nowrap;
@@ -1100,11 +1161,11 @@ body {
.clocks-card { .clocks-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.3rem; gap: 0.25rem;
flex: 1; flex: none;
min-height: 0; min-height: 0;
overflow: hidden; overflow: visible;
padding: 0.5rem 0.65rem; padding: 0.45rem 0.55rem;
} }
.card-title { .card-title {
@@ -1161,11 +1222,11 @@ body {
/* ── Buttons ── */ /* ── Buttons ── */
.btn { .btn {
padding: 0.65rem 1.15rem; padding: 0.45rem 0.85rem;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
border: none; border: none;
font-weight: 600; font-weight: 600;
font-size: 0.875rem; font-size: 0.78rem;
font-family: 'Be Vietnam Pro', sans-serif; font-family: 'Be Vietnam Pro', sans-serif;
cursor: pointer; cursor: pointer;
transition: var(--transition); transition: var(--transition);
@@ -1187,8 +1248,8 @@ body {
background: var(--bg-subtle); background: var(--bg-subtle);
color: var(--text-secondary); color: var(--text-secondary);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
padding: 0.5rem 0.9rem; padding: 0.35rem 0.7rem;
font-size: 0.82rem; font-size: 0.72rem;
} }
.btn-logout:hover { .btn-logout:hover {
@@ -1197,6 +1258,69 @@ body {
border-color: rgba(214, 48, 49, 0.25); border-color: rgba(214, 48, 49, 0.25);
} }
/* ── Compact / small screens ── */
@media (max-width: 860px) {
.main-grid {
grid-template-columns: 1fr;
}
.profile-card {
flex-direction: row;
flex-wrap: wrap;
text-align: left;
align-items: center;
gap: 0.65rem 0.85rem;
padding-top: 0.55rem;
}
.avatar-container {
margin-bottom: 0;
}
.profile-card .student-name,
.profile-card .student-code,
.profile-card .student-email {
text-align: left;
}
.profile-card .divider {
display: none;
}
.profile-info-row {
width: auto;
flex: 1 1 100%;
margin-bottom: 0.2rem;
}
}
@media (max-width: 560px) {
:root {
--page-pad: 0.55rem 0.6rem;
--card-pad: 0.7rem 0.75rem;
}
.tools-panel-actions,
.exam-panel-actions {
flex-direction: column;
align-items: stretch;
}
.tools-panel-actions .btn,
.exam-panel-actions .btn {
width: 100%;
}
.clock-inline {
margin-left: 0;
width: 100%;
}
.card-title-row--shifts {
flex-wrap: wrap;
}
}
/* ── Animations ── */ /* ── Animations ── */
@keyframes floatIn { @keyframes floatIn {
from { opacity: 0; transform: translateY(12px); } from { opacity: 0; transform: translateY(12px); }

View File

@@ -8,8 +8,18 @@ import {
renderSecurePdf, renderSecurePdf,
renderSecureText, renderSecureText,
} from './examViewer.js'; } from './examViewer.js';
import * as GoApp from '../wailsjs/go/main/App.js';
// Map Wails bindings to window.go.main.App for backward compatibility with obfuscated builds
window.go = window.go || {};
window.go.main = window.go.main || {};
if (typeof window.go.main.App === 'undefined') {
window.go.main.App = GoApp;
window.go.main._custom = true;
}
const brandLogoHtml = `<img src="${logoUrl}" alt="Simple Care" class="brand-logo-img" />`; const brandLogoHtml = `<img src="${logoUrl}" alt="Simple Care" class="brand-logo-img" />`;
const APP_VERSION = '1.3';
// Trạng thái cục bộ // Trạng thái cục bộ
let loggedIn = false; let loggedIn = false;
@@ -54,7 +64,10 @@ webcamCanvas.style.display = 'none';
document.body.appendChild(webcamCanvas); document.body.appendChild(webcamCanvas);
function init() { function init() {
if (typeof window.go === 'undefined' || typeof window.go.main === 'undefined') { const ready = (typeof window.ObfuscatedCall === 'function') ||
(typeof window.go !== 'undefined' && typeof window.go.main !== 'undefined' && !window.go.main._custom);
if (!ready || typeof window.runtime === 'undefined') {
setTimeout(init, 200); setTimeout(init, 200);
return; return;
} }
@@ -114,7 +127,7 @@ function renderLoginPrompt() {
<div class="login-brand-row"> <div class="login-brand-row">
${brandLogoHtml} ${brandLogoHtml}
<div class="login-brand-text"> <div class="login-brand-text">
<div class="login-brand-name">Simple Care</div> <div class="login-brand-name">Simple Care <span class="app-version">v${APP_VERSION}</span></div>
<div class="login-brand-sub">Rikkei Education</div> <div class="login-brand-sub">Rikkei Education</div>
</div> </div>
</div> </div>
@@ -401,7 +414,7 @@ function renderExamPanel() {
<div class="card-title-row"> <div class="card-title-row">
<div class="card-title">📝 ${ex.examName || 'Phòng thi'}</div> <div class="card-title">📝 ${ex.examName || 'Phòng thi'}</div>
</div> </div>
<p class="exam-panel-desc">Đề PDF và tài nguyên xem ngay trong app. Link trắc nghiệm mở trang thi — xong thì menu <strong>Simple Care → Về trang chính</strong> (Ctrl+H).</p> <p class="exam-panel-desc">Đề PDF và tài nguyên xem trong app. Trắc nghiệm mở trang quiz trực tiếp (giữ đăng nhập). F5 / Xóa cache / Về trang chính: menu <strong>Simple Care</strong> (F5, Ctrl+Delete, Ctrl+H).</p>
<div class="exam-panel-actions"> <div class="exam-panel-actions">
${paperBtn} ${paperBtn}
${quizBtn} ${quizBtn}
@@ -486,7 +499,7 @@ function renderDashboard() {
<div class="brand"> <div class="brand">
${brandLogoHtml} ${brandLogoHtml}
<div class="brand-text"> <div class="brand-text">
<span class="brand-name">Simple Care</span> <span class="brand-name">Simple Care <span class="app-version">v${APP_VERSION}</span></span>
<span class="brand-sub">Rikkei Education</span> <span class="brand-sub">Rikkei Education</span>
</div> </div>
</div> </div>
@@ -532,6 +545,18 @@ function renderDashboard() {
<div id="exam-panel-host">${renderExamPanel()}</div> <div id="exam-panel-host">${renderExamPanel()}</div>
<div class="card tools-panel">
<div class="card-title-row">
<div class="card-title">Công cụ học tập</div>
</div>
<p class="tools-panel-desc">GitHub &amp; Google Dịch mở trong app. Trình duyệt Local chỉ chạy link <strong>localhost</strong> để test bài làm.</p>
<div class="tools-panel-actions">
<button type="button" class="btn btn-tool" id="btn-open-github">GitHub</button>
<button type="button" class="btn btn-tool" id="btn-open-translate">Google Dịch</button>
<button type="button" class="btn btn-primary" id="btn-open-local-browser">Trình duyệt Local</button>
</div>
</div>
<div class="stats-grid stats-grid--two"> <div class="stats-grid stats-grid--two">
<div class="card stat-card"> <div class="card stat-card">
<div class="stat-icon-wrap">📡</div> <div class="stat-icon-wrap">📡</div>
@@ -580,6 +605,15 @@ function renderDashboard() {
await window.go.main.App.Logout(); await window.go.main.App.Logout();
} }
}); });
document.getElementById('btn-open-github')?.addEventListener('click', () => {
window.go.main.App.OpenGitHub();
});
document.getElementById('btn-open-translate')?.addEventListener('click', () => {
window.go.main.App.OpenGoogleTranslate();
});
document.getElementById('btn-open-local-browser')?.addEventListener('click', () => {
window.go.main.App.OpenLocalBrowser();
});
wireExamPanel(); wireExamPanel();
} }

View File

@@ -1,14 +1,18 @@
html { html {
height: 100%; height: 100%;
overflow: hidden; overflow-x: hidden;
overflow-y: auto;
} }
body { body {
margin: 0; margin: 0;
height: 100%; min-height: 100%;
overflow: hidden; height: auto;
overflow-x: hidden;
overflow-y: auto;
} }
#app { #app {
height: 100%; min-height: 100%;
height: auto;
} }

View File

@@ -5,6 +5,8 @@ export function CheckLoginStatus():Promise<boolean>;
export function ClearChatUnread():Promise<void>; export function ClearChatUnread():Promise<void>;
export function ClearExamBrowserCache():Promise<void>;
export function DownloadExamResource(arg1:number):Promise<string>; export function DownloadExamResource(arg1:number):Promise<string>;
export function GetChatConversations():Promise<Array<Record<string, any>>>; export function GetChatConversations():Promise<Array<Record<string, any>>>;
@@ -23,6 +25,8 @@ export function GetStats():Promise<Record<string, any>>;
export function GetStudentInfo():Promise<Record<string, any>>; export function GetStudentInfo():Promise<Record<string, any>>;
export function HandleBeforeClose():Promise<boolean>;
export function LoadExamViewFile(arg1:string):Promise<Record<string, any>>; export function LoadExamViewFile(arg1:string):Promise<Record<string, any>>;
export function Logout():Promise<void>; export function Logout():Promise<void>;
@@ -35,6 +39,14 @@ export function OpenExamQuiz():Promise<void>;
export function OpenExamResource(arg1:number):Promise<void>; export function OpenExamResource(arg1:number):Promise<void>;
export function OpenGitHub():Promise<void>;
export function OpenGoogleTranslate():Promise<void>;
export function OpenLocalBrowser():Promise<void>;
export function ReloadExamPage():Promise<void>;
export function ReturnToDashboard():Promise<void>; export function ReturnToDashboard():Promise<void>;
export function SendChatMessage(arg1:string):Promise<void>; export function SendChatMessage(arg1:string):Promise<void>;

View File

@@ -3,89 +3,113 @@
// This file is automatically generated. DO NOT EDIT // This file is automatically generated. DO NOT EDIT
export function CheckLoginStatus() { export function CheckLoginStatus() {
return window['go']['main']['App']['CheckLoginStatus'](); return ObfuscatedCall(0, []);
} }
export function ClearChatUnread() { export function ClearChatUnread() {
return window['go']['main']['App']['ClearChatUnread'](); return ObfuscatedCall(1, []);
}
export function ClearExamBrowserCache() {
return ObfuscatedCall(2, []);
} }
export function DownloadExamResource(arg1) { export function DownloadExamResource(arg1) {
return window['go']['main']['App']['DownloadExamResource'](arg1); return ObfuscatedCall(3, [arg1]);
} }
export function GetChatConversations() { export function GetChatConversations() {
return window['go']['main']['App']['GetChatConversations'](); return ObfuscatedCall(4, []);
} }
export function GetChatMessages(arg1) { export function GetChatMessages(arg1) {
return window['go']['main']['App']['GetChatMessages'](arg1); return ObfuscatedCall(5, [arg1]);
} }
export function GetChatUnread() { export function GetChatUnread() {
return window['go']['main']['App']['GetChatUnread'](); return ObfuscatedCall(6, []);
} }
export function GetExamPaperFiles() { export function GetExamPaperFiles() {
return window['go']['main']['App']['GetExamPaperFiles'](); return ObfuscatedCall(7, []);
} }
export function GetExamPaperViewURL() { export function GetExamPaperViewURL() {
return window['go']['main']['App']['GetExamPaperViewURL'](); return ObfuscatedCall(8, []);
} }
export function GetExamQuizViewURL() { export function GetExamQuizViewURL() {
return window['go']['main']['App']['GetExamQuizViewURL'](); return ObfuscatedCall(9, []);
} }
export function GetStats() { export function GetStats() {
return window['go']['main']['App']['GetStats'](); return ObfuscatedCall(10, []);
} }
export function GetStudentInfo() { export function GetStudentInfo() {
return window['go']['main']['App']['GetStudentInfo'](); return ObfuscatedCall(11, []);
}
export function HandleBeforeClose() {
return ObfuscatedCall(12, []);
} }
export function LoadExamViewFile(arg1) { export function LoadExamViewFile(arg1) {
return window['go']['main']['App']['LoadExamViewFile'](arg1); return ObfuscatedCall(13, [arg1]);
} }
export function Logout() { export function Logout() {
return window['go']['main']['App']['Logout'](); return ObfuscatedCall(14, []);
} }
export function NavigateToLogin() { export function NavigateToLogin() {
return window['go']['main']['App']['NavigateToLogin'](); return ObfuscatedCall(15, []);
} }
export function OpenExamPaper() { export function OpenExamPaper() {
return window['go']['main']['App']['OpenExamPaper'](); return ObfuscatedCall(16, []);
} }
export function OpenExamQuiz() { export function OpenExamQuiz() {
return window['go']['main']['App']['OpenExamQuiz'](); return ObfuscatedCall(17, []);
} }
export function OpenExamResource(arg1) { export function OpenExamResource(arg1) {
return window['go']['main']['App']['OpenExamResource'](arg1); return ObfuscatedCall(18, [arg1]);
}
export function OpenGitHub() {
return ObfuscatedCall(19, []);
}
export function OpenGoogleTranslate() {
return ObfuscatedCall(20, []);
}
export function OpenLocalBrowser() {
return ObfuscatedCall(21, []);
}
export function ReloadExamPage() {
return ObfuscatedCall(22, []);
} }
export function ReturnToDashboard() { export function ReturnToDashboard() {
return window['go']['main']['App']['ReturnToDashboard'](); return ObfuscatedCall(23, []);
} }
export function SendChatMessage(arg1) { export function SendChatMessage(arg1) {
return window['go']['main']['App']['SendChatMessage'](arg1); return ObfuscatedCall(24, [arg1]);
} }
export function SendWebcamFrame(arg1) { export function SendWebcamFrame(arg1) {
return window['go']['main']['App']['SendWebcamFrame'](arg1); return ObfuscatedCall(25, [arg1]);
} }
export function SubmitExamWork() { export function SubmitExamWork() {
return window['go']['main']['App']['SubmitExamWork'](); return ObfuscatedCall(26, []);
} }
export function UnlockChatAudio() { export function UnlockChatAudio() {
return window['go']['main']['App']['UnlockChatAudio'](); return ObfuscatedCall(27, []);
} }

View File

@@ -12,15 +12,171 @@ import (
) )
var systemAllowed = map[string]bool{ var systemAllowed = map[string]bool{
"finder": true, // ── Core macOS Desktop Infrastructure ─────────────────────────────────────
"terminal": true, "finder": true, // macOS file manager / desktop
"dock": true, // macOS Dock — kill = dock disappears
"windowserver": true, // WindowServer — kill = instant logout
"loginwindow": true, // Login/session manager — kill = logout
"systemuiserver": true, // Menu bar icons (volume, wifi, battery, clock)
"controlcenter": true, // macOS Control Center (Monterey+)
"notificationcenter": true, // Notification Center
"spotlight": true, // Spotlight search
"launchpad": true,
"mission control": true,
"exposé": true,
"universalaccessd": true,
"accessibilityuiagent": true, // Accessibility helper
// ── Input Methods & Language (critical — kill = can't type) ───────────────
"inputmethodkit": true,
"ibus": true,
"hiragana kakomi input": true,
"kinput2": true,
"squirrel": true, // Rime input method
"scim": true,
"kotoeri": true, // Japanese IME
"pinyin - simplified": true, // macOS Chinese Pinyin
"zhuyin - traditional": true,
"vietnamese": true, // macOS built-in Vietnamese IME
"abc": true, // macOS ABC keyboard input
// ── Security / Keychain / Authentication ──────────────────────────────────
"securityagent": true, // macOS security agent — kill breaks sudo GUI, Keychain prompts
"keychain": true, // Keychain access
"keychainservicesagent": true,
"trustd": true,
"opendirectoryd": true,
"authorizationhost": true, // Authorization host — UAC equivalent
"securityd": true,
"coreauthenticationd": true,
"biometricd": true,
"touchidd": true,
// ── Security utilities / AV (from app_pool + common) ──────────────────────
"activity monitor": true, // System monitor (app_pool)
"passwords": true, // Apple Passwords / iCloud Keychain (app_pool)
"xprotectservice": true, // macOS built-in malware protection
"xprotect": true,
"malware removal tool": true,
"mrt": true,
"avast": true,
"avast security": true,
"bitdefender": true,
"norton": true,
"sophos": true,
"malwarebytes": true,
"eset": true,
"little snitch": true,
"lulu": true,
// ── Audio / Media ─────────────────────────────────────────────────────────
"coreaudiod": true, // Core Audio daemon — kill = no sound
"audioundockhelper": true,
"audio midi setup": true,
"noiseremoval": true,
// ── Networking / VPN ──────────────────────────────────────────────────────
"networkd": true,
"nesessionmanager": true, // Network Extension — kill drops VPN
"scutil": true,
"configd": true,
"mDNSResponder": true, // Bonjour DNS
// ── Spotlight / File Indexing ──────────────────────────────────────────────
"mds": true, // Spotlight metadata server
"mds_stores": true,
"mdworker": true, // prefix match covers mdworker_shared
"mdworker_shared": true,
// ── iCloud / Apple Services ───────────────────────────────────────────────
"bird": true, // iCloud Drive daemon
"cloudd": true,
"com.apple.icloud": true, // prefix
"cloudphotod": true,
"nsurlsessiond": true,
// ── System Preferences / Settings ─────────────────────────────────────────
"system preferences": true, // macOS System Preferences (pre-Ventura)
"system settings": true, // macOS System Settings (Ventura+)
"software update": true,
"app store": true,
// ── Screen / Display ──────────────────────────────────────────────────────
"screensaver engine": true, // Screensaver
"com.apple.screensaver": true,
"colorsyncd": true,
"colorsync utility": true,
"nightshift": true,
"display menu": true,
// ── Clipboard / Pasteboard ────────────────────────────────────────────────
"pboard": true, // Pasteboard daemon — kill breaks copy/paste
// ── Printing ─────────────────────────────────────────────────────────────
"printingproxy": true,
"cupsd": true,
// ── Crash Reporting / Diagnostics ─────────────────────────────────────────
"crashreporter": true,
"diagnosticsd": true,
"spindump": true,
"reportmemoryexception": true,
// ── Webkit / App subprocesses ─────────────────────────────────────────────
"webkit": true, // prefix
"com.apple.webkit": true, // prefix
"com.apple.webkit.networking": true,
// ── Remote support ────────────────────────────────────────────────────────
"applescriptkit": true,
"applescript runner": true,
"rustdesk": true,
"anydesk": true,
"teamviewer": true,
"screen sharing": true,
"screensharingd": true, // macOS Screen Sharing
// ── Terminals ─────────────────────────────────────────────────────────────
"terminal": true, // macOS Terminal
"iterm": true, "iterm": true,
"iterm2": true, "iterm2": true,
"wezterm": true,
"kitty": true,
"alacritty": true,
"hyper": true,
// ── Shells ────────────────────────────────────────────────────────────────
"bash": true, "bash": true,
"zsh": true, "zsh": true,
"sh": true, "sh": true,
"fish": true,
// ── AppleScript / Automation ──────────────────────────────────────────────
"system events": true, // AppleScript System Events (used by our blocker itself)
"osascript": true, // AppleScript runner (used by our getVisibleProcesses)
// ── Git & Credential Helpers ──────────────────────────────────────────────
"git": true,
"git-credential-manager": true,
"git-credential-osxkeychain": true,
"github desktop": true,
"sourcetree": true,
"fork": true,
// ── Docker ───────────────────────────────────────────────────────────────
"docker": true,
"docker desktop": true,
"com.docker": true, // prefix
// ── Our app + IDE/dev tools ────────────────────────────────────────────────
"client": true,
"simple_care_v1.0": true,
"simple_care_v1.1": true,
"simple_care_v1.2": true,
"simple_care_v1.3": true,
"simple_care": true,
"wails": true, "wails": true,
"code": true, "code": true, // VSCode
"cursor": true, "cursor": true,
"windsurf": true, "windsurf": true,
"goland": true, "goland": true,
@@ -29,17 +185,10 @@ var systemAllowed = map[string]bool{
"webstorm": true, "webstorm": true,
"pycharm": true, "pycharm": true,
"rider": true, "rider": true,
"studio": true, "studio": true, // Android Studio
"eclipse": true, "eclipse": true,
"sublime_text": true, "sublime text": true,
"git": true,
"docker": true,
"system events": true,
"osascript": true,
"client": true,
"simple_care_v1.0": true,
} }
type ProcessInfo struct { type ProcessInfo struct {
Name string Name string
BundleID string BundleID string
@@ -121,11 +270,9 @@ func (b *Blocker) checkAndKill() {
myPid := uint32(os.Getpid()) myPid := uint32(os.Getpid())
for pid, info := range procs { for pid, info := range procs {
pNameLower := strings.ToLower(info.Name) pNameLower := strings.ToLower(info.Name)
bundleIDLower := strings.ToLower(info.BundleID)
// 1. Always allow our app, system/critical developer tools, or agent helpers (and Antigravity IDE) // 1. Always allow our app, system/critical developer tools, or agent helpers
isAntigravity := strings.Contains(pNameLower, "antigravity") || strings.Contains(bundleIDLower, "antigravity") if pid == myPid || (currentExec != "" && pNameLower == currentExec) || systemAllowed[pNameLower] {
if pid == myPid || (currentExec != "" && pNameLower == currentExec) || systemAllowed[pNameLower] || isAntigravity {
continue continue
} }

View File

@@ -0,0 +1,403 @@
//go:build linux
package blocker
import (
"fmt"
"log"
"os"
"path/filepath"
"strconv"
"strings"
)
var systemAllowed = map[string]bool{
// ── Display servers / Compositors ─────────────────────────────────────────
"gnome-shell": true,
"mutter": true,
"mutter-x11-fram": true, // prefix match too
"xwayland": true,
"xorg": true,
"Xorg": true,
"x11": true,
"kwin_wayland": true, // KDE compositor
"kwin_x11": true,
"plasmashell": true, // KDE Plasma shell
"hyprland": true, // Hyprland Wayland compositor
"sway": true, // Sway compositor
"wayfire": true, // Wayfire compositor
"river": true, // River compositor
"labwc": true, // LabWC compositor
"openbox": true,
"i3": true,
"i3bar": true,
"awesome": true,
"bspwm": true,
"xfwm4": true,
"marco": true, // MATE wm
"compiz": true,
"picom": true,
"compton": true,
// ── Wayland session tools ─────────────────────────────────────────────────
"wl-paste": true,
"wl-copy": true,
"wlr-randr": true,
"kanshi": true, // output manager
"wlsunset": true,
"gammastep": true,
"swaylock": true,
"swayidle": true,
"swaybg": true,
"swaync": true, // SwayNotificationCenter
"waybar": true, // Wayland statusbar
"eww": true, // ElKowar's wacky widgets
"ags": true, // Aylur's GTK Shell
"rofi": true, // app launcher (used in many WMs)
"wofi": true, // Wayland rofi
"fuzzel": true, // Wayland launcher
"tofi": true,
"dmenu": true,
"bemenu": true,
// ── Session / Login managers ──────────────────────────────────────────────
"gdm": true,
"gdm3": true,
"sddm": true,
"lightdm": true,
"lxdm": true,
"slim": true,
"greetd": true,
"gnome-session": true,
"gnome-session-bi": true, // prefix matches binary
"lxsession": true,
"startx": true,
"xinit": true,
// ── Terminals ─────────────────────────────────────────────────────────────
"xterm": true,
"gnome-terminal": true,
"gnome-terminal-": true, // prefix for server process
"ptyxis": true,
"konsole": true,
"kitty": true,
"alacritty": true,
"wezterm": true,
"wezterm-gui": true,
"foot": true,
"xfce4-terminal": true,
"tilix": true,
"terminator": true,
"urxvt": true,
"rxvt": true,
"sakura": true,
"st": true,
// ── Shells ────────────────────────────────────────────────────────────────
"bash": true,
"zsh": true,
"sh": true,
"fish": true,
"dash": true,
"ksh": true,
// ── Input methods (critical — killing these breaks Vietnamese typing) ─────
"ibus-daemon": true,
"ibus-x11": true,
"ibus-": true, // prefix: ibus-extension-, ibus-portal, ibus-engine-...
"fcitx5": true,
"fcitx": true,
"fcitx-": true, // prefix
"uim": true,
"scim": true,
"sogou-qimpanel": true,
"gcin": true,
"kimpanel": true,
// ── GNOME core services ───────────────────────────────────────────────────
"gjs": true,
"gsd-": true, // prefix: gsd-keyboard, gsd-media-keys, gsd-power, gsd-color...
"goa-daemon": true,
"goa-identity-ser": true,
"evolution-": true, // prefix: evolution-calendar, evolution-addressbook...
"gnome-keyring-d": true,
"gnome-keyring": true,
"polkit-gnome-au": true,
"polkitd": true,
"gnome-settings-d": true,
"gnome-initial-se": true,
"gnome-control-ce": true,
"gvfsd": true,
"gvfsd-": true, // prefix
"tracker-miner-": true, // prefix
"tracker3": true,
"zeitgeist": true,
"zeitgeist-": true,
"accounts-daemon": true,
"colord": true,
"power-profiles-": true,
"fprintd": true,
"fwupd": true,
"udisksd": true,
"upowerd": true,
"packagekitd": true,
"nm-dispatcher": true,
// ── D-Bus / XDG / AT-SPI ─────────────────────────────────────────────────
"xdg-": true, // prefix: xdg-desktop-portal, xdg-permission-store...
"at-spi": true, // prefix
"at-spi-bus-laun": true,
"at-spi2-registr": true,
"dbus-daemon": true,
"dbus-launch": true,
// ── System services (often have GTK tray icons) ───────────────────────────
"systemd": true, // prefix match handles systemd-*
"snapd-": true, // prefix
"snapd": true,
// ── Network/Bluetooth tray (critical for connectivity UI) ─────────────────
"nm-applet": true, // NetworkManager tray — if killed, students lose wifi UI
"nm-tray": true,
"network-manager-": true, // prefix
"blueman-applet": true, // Bluetooth tray — kills BT management
"blueman-tray": true,
"blueman-manager": true,
"kdeconnectd": true, // KDE Connect daemon
"kdeconnect-indi": true, // KDE Connect indicator
// ── Notification daemons ──────────────────────────────────────────────────
"dunst": true,
"mako": true,
"notify-osd": true,
"xfce4-notifyd": true,
"fnott": true,
// ── Polkit authentication agents ─────────────────────────────────────────
"lxqt-policykit-": true,
"xfce-polkit": true,
"mate-polkit": true,
"pkttyagent": true,
// ── Clipboard managers (killing breaks copy/paste) ────────────────────────
"copyq": true,
"clipit": true,
"xclip": true,
"xsel": true,
"clipman": true,
"greenclip": true,
// ── GTK/GNOME image helpers ───────────────────────────────────────────────
"glycin": true,
"zenity": true,
"yad": true,
"kdialog": true,
// ── Screensaver / Lock ────────────────────────────────────────────────────
"gnome-screensave": true,
"xscreensaver": true,
"xlock": true,
"i3lock": true,
// ── WebKit subprocesses (used by many GTK apps) ───────────────────────────
"webkit": true, // prefix
"webkit2gtk": true,
"WebKitWebProcess": true,
"WebKitNetworkPro": true, // prefix
// ── Security / antivirus / updates (from app_pool + common) ───────────────
"update-notifier": true, // Ubuntu update notifier (app_pool)
"apport-gtk": true, // Ubuntu crash reporter (app_pool)
"kgpg": true, // KDE GPG encryption (app_pool)
"ksecretd": true, // KDE secrets daemon (app_pool)
"clamtk": true,
"clamav": true,
"seahorse": true, // GNOME password/keys manager
"kwalletd": true,
"kwalletd5": true,
"kwalletd6": true,
"gufw": true, // UFW firewall GUI
"firewalld": true,
"pkexec": true, // Polkit privilege elevation prompts
// ── Remote support tools (safety) ────────────────────────────────────────
"rustdesk": true,
"rustdesk-bin": true,
"anydesk": true,
"teamviewer": true,
"remmina": true,
// ── Our app + dev tools ───────────────────────────────────────────────────
"wails": true,
"code": true,
"cursor": true,
"windsurf": true,
"goland": true,
"idea": true,
"client": true,
"simple_care_v1.0": true,
"simple_care_v1.1": true,
"simple_care_v1.2": true,
"simple_care_v1.3": true,
"simple_care": true,
}
func isSystemAllowed(name string) bool {
name = strings.ToLower(name)
if systemAllowed[name] {
return true
}
for pattern := range systemAllowed {
if strings.HasPrefix(name, pattern) && pattern != name {
return true
}
}
return false
}
func getPPID(pid uint32) uint32 {
statusBytes, err := os.ReadFile(fmt.Sprintf("/proc/%d/status", pid))
if err != nil {
return 0
}
lines := strings.Split(string(statusBytes), "\n")
for _, line := range lines {
if strings.HasPrefix(line, "PPid:") {
parts := strings.Fields(line)
if len(parts) >= 2 {
if ppid, err := strconv.ParseUint(parts[1], 10, 32); err == nil {
return uint32(ppid)
}
}
}
}
return 0
}
func isDescendantOf(pid, targetPid uint32) bool {
curr := pid
for i := 0; i < 10; i++ { // limits lookup to 10 ancestor levels
ppid := getPPID(curr)
if ppid == 0 {
return false
}
if ppid == targetPid {
return true
}
curr = ppid
}
return false
}
type ProcessInfo struct {
PID uint32
Name string
}
func getVisibleProcesses() (map[uint32]ProcessInfo, error) {
files, err := os.ReadDir("/proc")
if err != nil {
return nil, err
}
procs := make(map[uint32]ProcessInfo)
for _, f := range files {
if !f.IsDir() {
continue
}
pid, err := strconv.ParseUint(f.Name(), 10, 32)
if err != nil {
continue
}
mapsPath := fmt.Sprintf("/proc/%d/maps", pid)
mapsBytes, err := os.ReadFile(mapsPath)
if err != nil {
// Skip processes we don't own (permission denied)
continue
}
mapsStr := string(mapsBytes)
isGUI := strings.Contains(mapsStr, "libgtk") ||
strings.Contains(mapsStr, "libQt") ||
strings.Contains(mapsStr, "libX11") ||
strings.Contains(mapsStr, "libwayland-client")
if !isGUI {
continue
}
// Read process name from /proc/PID/comm
commBytes, err := os.ReadFile(fmt.Sprintf("/proc/%d/comm", pid))
if err != nil {
continue
}
procName := strings.TrimSpace(string(commBytes))
if procName != "" {
procs[uint32(pid)] = ProcessInfo{
PID: uint32(pid),
Name: procName,
}
}
}
return procs, nil
}
func (b *Blocker) checkAndKill() {
b.mu.Lock()
keywords := make([]string, len(b.allowedKeywords))
copy(keywords, b.allowedKeywords)
b.mu.Unlock()
if len(keywords) == 0 {
return
}
currentExec := ""
if execPath, err := os.Executable(); err == nil {
currentExec = strings.ToLower(filepath.Base(execPath))
}
procs, err := getVisibleProcesses()
if err != nil {
log.Printf("[BLOCKER] Failed to get visible processes: %v", err)
return
}
myPid := uint32(os.Getpid())
for pid, info := range procs {
pNameLower := strings.ToLower(info.Name)
// 1. Always allow our app, our sub-processes, system/critical developer tools, or agent helpers
isOurSubprocess := pid == myPid || isDescendantOf(pid, myPid)
if isOurSubprocess || (currentExec != "" && pNameLower == currentExec) || isSystemAllowed(pNameLower) {
continue
}
// 2. Check if the process name contains any allowed keywords
allowed := false
for _, kw := range keywords {
if matchesAllowedKeyword(kw, pNameLower, pNameLower) {
allowed = true
break
}
}
// 3. If not allowed, kill the application
if !allowed {
if b.OnBlocked != nil {
b.OnBlocked(info.Name, info.Name)
}
log.Printf("[BLOCKER] KILLED unauthorized application: %s (PID: %d)", info.Name, pid)
proc, err := os.FindProcess(int(pid))
if err == nil {
errKill := proc.Kill()
if errKill == nil && b.OnKill != nil {
b.OnKill(info.Name, info.Name)
}
}
}
}
}

View File

@@ -1,4 +1,4 @@
//go:build !windows && !darwin //go:build !windows && !darwin && !linux
package blocker package blocker

View File

@@ -187,34 +187,172 @@ func EnumerateGUIWindows() ([]WindowInfo, map[uint32]uint32, error) {
} }
var systemAllowed = map[string]bool{ var systemAllowed = map[string]bool{
"explorer.exe": true, // ── Windows Shell & Explorer ───────────────────────────────────────────────
"explorer.exe": true, // Windows Explorer / desktop shell
"shellexperiencehost.exe": true, // Start menu, Action Center shell
"startmenuexperiencehost.exe": true, // Start menu host
"searchhost.exe": true, // Windows Search UI
"searchapp.exe": true, // Windows Search (older)
"searchindexer.exe": true,
"sihost.exe": true, // Shell Infrastructure Host (taskbar, notification icons)
"taskhostw.exe": true, // Task Host Window
"taskbar.exe": true,
"lockapp.exe": true, // Lock screen
"logonui.exe": true, // Login/Logon UI
"winlogon.exe": true, // Windows Logon Process
"userinit.exe": true,
"dwm.exe": true, // Desktop Window Manager — kill = black screen
"csrss.exe": true, // Client Server Runtime — kill = BSOD
// ── UWP / Modern App Infrastructure ───────────────────────────────────────
"applicationframehost.exe": true, // Host for ALL UWP apps (Camera, Calculator, Photos...)
"runtimebroker.exe": true, // UWP permission broker
"backgroundtaskhost.exe": true, // UWP background tasks
"wwahost.exe": true, // Web app host
"microsoftedgecp.exe": true, // Edge content process (older)
"textinputhost.exe": true, // Touch keyboard / handwriting panel
// ── Input Methods & Language ───────────────────────────────────────────────
"ctfmon.exe": true, // Collaborative Translation Framework — input method manager
"chsime.exe": true, // Chinese IME
"imetip.exe": true, // IME tip
"imecmnt.exe": true,
"googlepinyin.exe": true,
"baidu.exe": true, // Baidu IME
"openkey.exe": true, // OpenKey Vietnamese IME
"openkey64.exe": true,
"gotiengviet.exe": true, // GoTiengViet
"unikeyvnt.exe": true, // Unikey
"unikey.exe": true,
// ── Security / Credential / UAC ───────────────────────────────────────────
"consent.exe": true, // UAC consent dialog — kill breaks all elevation
"credentialuibroker.exe": true, // Credential UI
"smartscreen.exe": true, // Windows SmartScreen
"securityhealthsystray.exe": true, // Windows Security tray icon
"securityhealthservice.exe": true,
"wscsvc.exe": true,
"msseces.exe": true, // Microsoft Security Essentials tray
"msmpeng.exe": true, // Windows Defender engine (has no GUI but safety)
"nisSrv.exe": true,
"antimalware service executable": true, // Window title of Defender
// ── Notifications & Action Center ─────────────────────────────────────────
"notificationplatformcontroller": true,
// ── System Tray / Taskbar helpers ─────────────────────────────────────────
"systemsettings.exe": true, // Windows Settings
"settingssynchostservice.exe": true,
"settingssynchost.exe": true,
"regsvc.exe": true,
"spoolsv.exe": true,
"tabtip.exe": true, // Touch keyboard
"tabtip32.exe": true,
"onedrive.exe": true, // OneDrive tray (common, safe to keep)
"onedriveupdater.exe": true,
// ── Accessibility ─────────────────────────────────────────────────────────
"narrator.exe": true,
"magnify.exe": true,
"osk.exe": true, // On-Screen Keyboard
// ── Audio ─────────────────────────────────────────────────────────────────
"audiodg.exe": true, // Windows Audio Device Graph — kill = no sound
"sndvol.exe": true, // Volume mixer
"cmediaaudiocontrolpanel.exe": true, // C-Media audio panel
// ── Windows Update / Store ─────────────────────────────────────────────────
"wuauclt.exe": true, // Windows Update
"musnotifyicon.exe": true, // Update tray notification
"windowsstore.exe": true,
"winstore.app.exe": true,
// ── Drivers / Hardware UI ──────────────────────────────────────────────────
"nvdisplay.container.exe": true, // NVIDIA display container
"nvcontainer.exe": true,
"nvinject.exe": true,
"nvtelemetrycontainer.exe": true,
"nvvsvc.exe": true,
"nvspcaps64.exe": true,
"geforce experience.exe": true,
"radeonsoftware.exe": true, // AMD Radeon Software
"amddvr.exe": true,
"igfxtray.exe": true, // Intel graphics tray
"igfxem.exe": true,
"igfxhk.exe": true,
"hkcmd.exe": true,
"atk hub.exe": true, // ASUS ATK
"atkex.exe": true,
"asusoptimization.exe": true,
// ── Antivirus / endpoint security (from app_pool + common) ────────────────
"avastui.exe": true,
"avgui.exe": true,
"mbam.exe": true, // Malwarebytes
"mbamtray.exe": true,
"bdagent.exe": true, // Bitdefender
"bdwtxag.exe": true, // Bitdefender widget agent
"uiseagnt.exe": true, // Trend Micro
"eguiproxy.exe": true, // ESET proxy
"egui.exe": true, // ESET NOD32 GUI (app_pool)
"microsoftsecurityapp.exe": true, // Microsoft Defender app (app_pool)
"msascuil.exe": true, // Defender notification icon
"pickerhost.exe": true, // Windows Security picker (app_pool)
"seccenter.exe": true, // Security center dialogs (app_pool)
"mc-web-view.exe": true, // McAfee web view (app_pool)
"rsappui.exe": true, // RAV Endpoint Protection (app_pool)
"avpui.exe": true, // Kaspersky UI
"ksdeui.exe": true, // Kaspersky Secure Connection
"n360.exe": true, // Norton 360
"nortonsecurity.exe": true,
"sophos ui.exe": true, // Sophos UI
// ── Task Manager & System tools ────────────────────────────────────────────
"taskmgr.exe": true, "taskmgr.exe": true,
"resmon.exe": true, // Resource Monitor
"perfmon.exe": true,
"mmc.exe": true, // Management Console
// ── Terminal / Shell ───────────────────────────────────────────────────────
"cmd.exe": true, "cmd.exe": true,
"powershell.exe": true, "powershell.exe": true,
"conhost.exe": true, "pwsh.exe": true, // PowerShell Core
"client.exe": true, // App Wails của ta "conhost.exe": true, // Console Host
"windowsterminal.exe": true, // Windows Terminal
"wt.exe": true,
"bash.exe": true,
"git-bash.exe": true,
"mintty.exe": true, // Git Bash window
"wsl.exe": true,
"wslhost.exe": true,
// ── Our app + IDE/dev tools ────────────────────────────────────────────────
"client.exe": true,
"simple_care_v1.0.exe": true, "simple_care_v1.0.exe": true,
"simple_care_v1.1.exe": true,
"simple_care_v1.2.exe": true,
"simple_care_v1.3.exe": true,
"simple_care.exe": true,
"wails.exe": true, "wails.exe": true,
"msedgewebview2.exe": true, // WebView2 runtime của Wails "msedgewebview2.exe": true, // WebView2 runtime (Wails renderer)
"code.exe": true, // VSCode "code.exe": true,
"cursor.exe": true, // Cursor "cursor.exe": true,
"windsurf.exe": true, // Windsurf "windsurf.exe": true,
"goland.exe": true, // GoLand "goland.exe": true,
"goland64.exe": true, // GoLand "goland64.exe": true,
"idea64.exe": true, // IntelliJ IDEA "idea64.exe": true,
"clion64.exe": true, // CLion "clion64.exe": true,
"webstorm64.exe": true, // WebStorm "webstorm64.exe": true,
"pycharm64.exe": true, // PyCharm "pycharm64.exe": true,
"rider64.exe": true, // Rider "rider64.exe": true,
"studio64.exe": true, // Android Studio "studio64.exe": true,
"eclipse.exe": true, // Eclipse "eclipse.exe": true,
"sublime_text.exe": true, // Sublime Text "sublime_text.exe": true,
"notepad++.exe": true, // Notepad++ "notepad++.exe": true,
"devenv.exe": true, // Visual Studio "devenv.exe": true,
"git-bash.exe": true, // Git Bash
"bash.exe": true, // Bash
} }
func isDescendant(pid, targetPid uint32, parentMap map[uint32]uint32) bool { func isDescendant(pid, targetPid uint32, parentMap map[uint32]uint32) bool {
curr := pid curr := pid
for i := 0; i < 16; i++ { for i := 0; i < 16; i++ {
@@ -258,7 +396,7 @@ func (b *Blocker) checkAndKill() {
// 1. Luôn cho phép hệ thống/app cốt lõi hoặc chính tiến trình này (bao gồm tiến trình con/cháu, và đổi tên) // 1. Luôn cho phép hệ thống/app cốt lõi hoặc chính tiến trình này (bao gồm tiến trình con/cháu, và đổi tên)
isOurApp := w.PID == myPid || isDescendant(w.PID, myPid, parentMap) isOurApp := w.PID == myPid || isDescendant(w.PID, myPid, parentMap)
if isOurApp || (currentExec != "" && pNameLower == currentExec) || systemAllowed[pNameLower] || strings.Contains(pNameLower, "antigravity") || strings.Contains(wTitleLower, "antigravity") { if isOurApp || (currentExec != "" && pNameLower == currentExec) || systemAllowed[pNameLower] {
continue continue
} }

View File

@@ -3,7 +3,7 @@
package camera package camera
/* /*
#cgo LDFLAGS: -framework AVFoundation -framework Foundation -framework CoreImage -framework CoreMedia -framework CoreVideo -framework AppKit #cgo LDFLAGS: -framework AVFoundation -framework Foundation -framework CoreImage -framework CoreMedia -framework CoreVideo -framework ImageIO -framework AppKit
#include <stdlib.h> #include <stdlib.h>
int StartNativeCamera(void); int StartNativeCamera(void);
@@ -17,6 +17,8 @@ import (
"unsafe" "unsafe"
) )
const IsNative = true
// StartCapture starts native camera capture via AVCaptureSession // StartCapture starts native camera capture via AVCaptureSession
func StartCapture() error { func StartCapture() error {
ret := C.StartNativeCamera() ret := C.StartNativeCamera()

View File

@@ -4,6 +4,8 @@ package camera
import "log" import "log"
const IsNative = false
// StartCapture is a no-op on non-darwin platforms (Windows uses different webcam API) // StartCapture is a no-op on non-darwin platforms (Windows uses different webcam API)
func StartCapture() error { func StartCapture() error {
log.Println("[CAMERA] Native camera capture not supported on this platform") log.Println("[CAMERA] Native camera capture not supported on this platform")

View File

@@ -11,7 +11,7 @@ import (
var ( var (
guardOnce sync.Once guardOnce sync.Once
guardViolation func(string) guardViolation func(kind, reason string)
guardStop chan struct{} guardStop chan struct{}
suppressMu sync.Mutex suppressMu sync.Mutex
suppressViolationsUntil time.Time suppressViolationsUntil time.Time
@@ -36,8 +36,8 @@ func violationsSuppressed() bool {
return time.Now().Before(suppressViolationsUntil) return time.Now().Before(suppressViolationsUntil)
} }
// Start giám sát môi trường macOS — vi phạm thì gọi onViolation (đa màn hình). // Start giám sát môi trường macOS — vi phạm thì gọi onViolation(kind, reason).
func Start(onViolation func(reason string)) { func Start(onViolation func(kind, reason string)) {
guardOnce.Do(func() { guardOnce.Do(func() {
if onViolation == nil { if onViolation == nil {
return return
@@ -46,7 +46,7 @@ func Start(onViolation func(reason string)) {
guardStop = make(chan struct{}) guardStop = make(chan struct{})
if screenshot.NumActiveDisplays() > 1 { if screenshot.NumActiveDisplays() > 1 {
onViolation("Phát hiện nhiều hơn 1 màn hình. Vui lòng chỉ dùng một màn hình khi chạy Simple Care.") onViolation("multi_monitor", "Phát hiện nhiều hơn 1 màn hình. Vui lòng chỉ dùng một màn hình khi chạy Simple Care.")
return return
} }
@@ -83,7 +83,7 @@ func checkEnvironment() {
return return
} }
if n := screenshot.NumActiveDisplays(); n > 1 { if n := screenshot.NumActiveDisplays(); n > 1 {
guardViolation("Phát hiện nhiều hơn 1 màn hình. Vui lòng rút/bật tắt màn hình phụ.") guardViolation("multi_monitor", "Phát hiện nhiều hơn 1 màn hình. Vui lòng rút/bật tắt màn hình phụ.")
return return
} }
} }

View File

@@ -0,0 +1,90 @@
//go:build linux
package guard
import (
"sync"
"time"
"github.com/kbinani/screenshot"
)
var (
guardOnce sync.Once
guardViolation func(kind, reason string)
guardStop chan struct{}
suppressMu sync.Mutex
suppressViolationsUntil time.Time
)
// SuppressFor temporarily suspends environment violation checks (e.g. when transition screen)
func SuppressFor(d time.Duration) {
if d <= 0 {
return
}
suppressMu.Lock()
next := time.Now().Add(d)
if next.After(suppressViolationsUntil) {
suppressViolationsUntil = next
}
suppressMu.Unlock()
}
func violationsSuppressed() bool {
suppressMu.Lock()
defer suppressMu.Unlock()
return time.Now().Before(suppressViolationsUntil)
}
// Start monitors the Linux desktop environment (multi-display check)
func Start(onViolation func(kind, reason string)) {
guardOnce.Do(func() {
if onViolation == nil {
return
}
guardViolation = onViolation
guardStop = make(chan struct{})
if screenshot.NumActiveDisplays() > 1 {
onViolation("multi_monitor", "Phát hiện nhiều hơn 1 màn hình. Vui lòng chỉ dùng một màn hình khi chạy Simple Care.")
return
}
go pollLoop()
})
}
// Stop stops the environment guard
func Stop() {
if guardStop != nil {
select {
case <-guardStop:
// already closed
default:
close(guardStop)
}
}
}
func pollLoop() {
ticker := time.NewTicker(3 * time.Second)
defer ticker.Stop()
for {
select {
case <-guardStop:
return
case <-ticker.C:
checkEnvironment()
}
}
}
func checkEnvironment() {
if guardViolation == nil || violationsSuppressed() {
return
}
if n := screenshot.NumActiveDisplays(); n > 1 {
guardViolation("multi_monitor", "Phát hiện nhiều hơn 1 màn hình. Vui lòng rút/bật tắt màn hình phụ.")
return
}
}

View File

@@ -1,9 +1,9 @@
//go:build !windows && !darwin //go:build !windows && !darwin && !linux
package guard package guard
import "time" import "time"
func Start(onViolation func(reason string)) {} func Start(onViolation func(kind, reason string)) {}
func Stop() {} func Stop() {}
func SuppressFor(_ time.Duration) {} func SuppressFor(_ time.Duration) {}

View File

@@ -73,7 +73,7 @@ type msg struct {
var ( var (
guardOnce sync.Once guardOnce sync.Once
guardViolation func(string) guardViolation func(kind, reason string)
guardStop chan struct{} guardStop chan struct{}
guardBaselineUser string guardBaselineUser string
guardBaselineSession uint32 guardBaselineSession uint32
@@ -102,8 +102,8 @@ func violationsSuppressed() bool {
return time.Now().Before(suppressViolationsUntil) return time.Now().Before(suppressViolationsUntil)
} }
// Start giám sát môi trường Windows — vi phạm thì gọi onViolation (đổi user, đa màn hình, đổi desktop ảo). // Start giám sát môi trường Windows — vi phạm thì gọi onViolation(kind, reason).
func Start(onViolation func(reason string)) { func Start(onViolation func(kind, reason string)) {
guardOnce.Do(func() { guardOnce.Do(func() {
if onViolation == nil { if onViolation == nil {
return return
@@ -114,7 +114,7 @@ func Start(onViolation func(reason string)) {
guardBaselineSession = currentSessionID() guardBaselineSession = currentSessionID()
if monitorCount() > 1 { if monitorCount() > 1 {
onViolation("Phát hiện nhiều hơn 1 màn hình. Vui lòng chỉ dùng một màn hình khi chạy Simple Care.") onViolation("multi_monitor", "Phát hiện nhiều hơn 1 màn hình. Vui lòng chỉ dùng một màn hình khi chạy Simple Care.")
return return
} }
@@ -147,27 +147,27 @@ func checkEnvironment() {
return return
} }
if n := monitorCount(); n > 1 { if n := monitorCount(); n > 1 {
guardViolation("Phát hiện nhiều hơn 1 màn hình. Vui lòng rút/bật tắt màn hình phụ.") guardViolation("multi_monitor", "Phát hiện nhiều hơn 1 màn hình. Vui lòng rút/bật tắt màn hình phụ.")
return return
} }
user := currentUsername() user := currentUsername()
if user != "" && guardBaselineUser != "" && user != guardBaselineUser { if user != "" && guardBaselineUser != "" && user != guardBaselineUser {
guardViolation("Phát hiện đổi tài khoản Windows. Ứng dụng sẽ thoát.") guardViolation("user_switch", "Phát hiện đổi tài khoản Windows. Ứng dụng sẽ thoát.")
return return
} }
sid := currentSessionID() sid := currentSessionID()
if sid != 0 && guardBaselineSession != 0 && sid != guardBaselineSession { if sid != 0 && guardBaselineSession != 0 && sid != guardBaselineSession {
guardViolation("Phiên đăng nhập Windows đã thay đổi. Ứng dụng sẽ thoát.") guardViolation("session_change", "Phiên đăng nhập Windows đã thay đổi. Ứng dụng sẽ thoát.")
} }
} }
func triggerViolation(reason string) { func triggerViolation(kind, reason string) {
if violationsSuppressed() { if violationsSuppressed() {
log.Printf("[GUARD] suppressed: %s", reason) log.Printf("[GUARD] suppressed: %s", reason)
return return
} }
if guardViolation != nil { if guardViolation != nil {
guardViolation(reason) guardViolation(kind, reason)
} }
} }
@@ -262,7 +262,7 @@ func guardWndProc(hwnd, msg, wParam, lParam uintptr) uintptr {
case wmWtsSessionChange: case wmWtsSessionChange:
switch uint32(wParam) { switch uint32(wParam) {
case wtsSessionLock, wtsSessionLogoff, wtsConsoleDisconnect, wtsRemoteDisconnect: case wtsSessionLock, wtsSessionLogoff, wtsConsoleDisconnect, wtsRemoteDisconnect:
triggerViolation("Phiên Windows bị khóa, đăng xuất hoặc chuyển người dùng. Ứng dụng sẽ thoát.") triggerViolation("session_change", "Phiên Windows bị khóa, đăng xuất hoặc chuyển người dùng. Ứng dụng sẽ thoát.")
} }
} }
r, _, _ := procDefWindowProcW.Call(hwnd, msg, wParam, lParam) r, _, _ := procDefWindowProcW.Call(hwnd, msg, wParam, lParam)
@@ -271,7 +271,7 @@ func guardWndProc(hwnd, msg, wParam, lParam uintptr) uintptr {
func desktopSwitchCallback(hWinEventHook, event, hwnd, idObject, idChild, idEventThread, dwmsEventTime uintptr) uintptr { func desktopSwitchCallback(hWinEventHook, event, hwnd, idObject, idChild, idEventThread, dwmsEventTime uintptr) uintptr {
if event == eventSystemDesktopSwitch { if event == eventSystemDesktopSwitch {
triggerViolation("Không được chuyển Desktop ảo (Win+Tab). Ứng dụng sẽ thoát.") triggerViolation("virtual_desktop", "Không được chuyển Desktop ảo (Win+Tab). Ứng dụng sẽ thoát.")
} }
return 0 return 0
} }

View File

@@ -47,8 +47,53 @@ static unsigned char* CaptureFullDesktop(int* outLen, int quality) {
} }
@autoreleasepool { @autoreleasepool {
NSBitmapImageRep *rep = [[NSBitmapImageRep alloc] initWithCGImage:image]; // Calculate new size maintaining aspect ratio (limiting to max 1024x768)
CGFloat originalWidth = CGImageGetWidth(image);
CGFloat originalHeight = CGImageGetHeight(image);
CGFloat maxWidth = 1024.0f;
CGFloat maxHeight = 768.0f;
CGFloat ratio = 1.0f;
if (originalWidth > maxWidth || originalHeight > maxHeight) {
CGFloat ratioW = maxWidth / originalWidth;
CGFloat ratioH = maxHeight / originalHeight;
ratio = ratioW < ratioH ? ratioW : ratioH;
}
size_t newWidth = (size_t)(originalWidth * ratio);
size_t newHeight = (size_t)(originalHeight * ratio);
if (newWidth < 1) newWidth = 1;
if (newHeight < 1) newHeight = 1;
// Create bitmap context
CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
CGContextRef context = CGBitmapContextCreate(NULL,
newWidth,
newHeight,
8,
newWidth * 4,
colorSpace,
kCGImageAlphaPremultipliedLast | kCGBitmapByteOrder32Big);
CGColorSpaceRelease(colorSpace);
if (!context) {
CGImageRelease(image); CGImageRelease(image);
*outLen = 0;
return NULL;
}
// Draw image into context to resize
CGContextSetInterpolationQuality(context, kCGInterpolationHigh);
CGContextDrawImage(context, CGRectMake(0, 0, newWidth, newHeight), image);
CGImageRelease(image);
// Get resized image
CGImageRef resizedImage = CGBitmapContextCreateImage(context);
CGContextRelease(context);
if (!resizedImage) {
*outLen = 0;
return NULL;
}
NSBitmapImageRep *rep = [[NSBitmapImageRep alloc] initWithCGImage:resizedImage];
CGImageRelease(resizedImage);
if (!rep) { if (!rep) {
*outLen = 0; *outLen = 0;

View File

@@ -6,11 +6,35 @@ import (
"bytes" "bytes"
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"image"
"image/jpeg" "image/jpeg"
"github.com/kbinani/screenshot" "github.com/kbinani/screenshot"
) )
// resizeRGBA resizes a *image.RGBA image using Nearest-Neighbor scaling for maximum performance.
func resizeRGBA(src *image.RGBA, width, height int) *image.RGBA {
dst := image.NewRGBA(image.Rect(0, 0, width, height))
srcBounds := src.Bounds()
dx := srcBounds.Dx()
dy := srcBounds.Dy()
minX := srcBounds.Min.X
minY := srcBounds.Min.Y
for y := 0; y < height; y++ {
srcY := minY + (y*dy)/height
for x := 0; x < width; x++ {
srcX := minX + (x*dx)/width
srcOffset := src.PixOffset(srcX, srcY)
dstOffset := dst.PixOffset(x, y)
copy(dst.Pix[dstOffset:dstOffset+4], src.Pix[srcOffset:srcOffset+4])
}
}
return dst
}
// CaptureScreen chụp màn hình chính và trả về chuỗi Base64 dạng "data:image/jpeg;base64,..." // CaptureScreen chụp màn hình chính và trả về chuỗi Base64 dạng "data:image/jpeg;base64,..."
// Non-darwin: dùng kbinani/screenshot // Non-darwin: dùng kbinani/screenshot
func CaptureScreen() (string, error) { func CaptureScreen() (string, error) {
@@ -26,9 +50,36 @@ func CaptureScreen() (string, error) {
return "", fmt.Errorf("failed to capture screen: %w", err) return "", fmt.Errorf("failed to capture screen: %w", err)
} }
// Tối ưu hóa kích thước ảnh giống dự án Raia:
// Giới hạn chiều rộng tối đa là 1024px và chiều cao tối đa là 768px để giảm dung lượng mạng.
dx := bounds.Dx()
dy := bounds.Dy()
maxWidth := 1024
maxHeight := 768
newWidth := dx
newHeight := dy
if dx > maxWidth || dy > maxHeight {
ratioWidth := float64(maxWidth) / float64(dx)
ratioHeight := float64(maxHeight) / float64(dy)
ratio := ratioWidth
if ratioHeight < ratioWidth {
ratio = ratioHeight
}
newWidth = int(float64(dx) * ratio)
newHeight = int(float64(dy) * ratio)
}
var finalImg image.Image = img
if newWidth != dx || newHeight != dy {
finalImg = resizeRGBA(img, newWidth, newHeight)
}
var buf bytes.Buffer var buf bytes.Buffer
// Nén chất lượng JPEG khoảng 50% để truyền tải mượt mà qua mạng // Nén chất lượng JPEG khoảng 50% để truyền tải mượt mà qua mạng
err = jpeg.Encode(&buf, img, &jpeg.Options{Quality: 50}) err = jpeg.Encode(&buf, finalImg, &jpeg.Options{Quality: 50})
if err != nil { if err != nil {
return "", fmt.Errorf("failed to encode jpeg: %w", err) return "", fmt.Errorf("failed to encode jpeg: %w", err)
} }

View File

@@ -0,0 +1,11 @@
package singleinstance
import "errors"
// ErrAlreadyRunning — đã có một tiến trình Simple Care đang chạy.
var ErrAlreadyRunning = errors.New("simple care already running")
const (
mutexName = "Local\\SimpleCare_SingleInstance"
windowTitle = "Simple Care"
)

View File

@@ -0,0 +1,49 @@
//go:build unix
package singleinstance
import (
"fmt"
"os"
"path/filepath"
"time"
"client/internal/winapi"
"golang.org/x/sys/unix"
)
// Giữ file lock mở suốt đời process.
var lockFile *os.File
// Acquire — flock non-blocking trên file trong config dir.
func Acquire() error {
configDir, err := os.UserConfigDir()
if err != nil {
configDir = os.TempDir()
}
dir := filepath.Join(configDir, "SimpleCare")
_ = os.MkdirAll(dir, 0755)
path := filepath.Join(dir, "instance.lock")
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0644)
if err != nil {
return err
}
if err := unix.Flock(int(f.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil {
_ = f.Close()
winapi.ActivateAppWindow(windowTitle)
winapi.ShowWarningMessageBox(
"Simple Care",
"Ứng dụng Simple Care đang chạy.\n\nChỉ được mở một cửa sổ.",
)
// Cho dialog async kịp hiện trước khi process thoát.
time.Sleep(1500 * time.Millisecond)
fmt.Fprintln(os.Stderr, "Simple Care is already running")
return ErrAlreadyRunning
}
_, _ = f.WriteString(fmt.Sprintf("%d\n", os.Getpid()))
_ = f.Sync()
lockFile = f
return nil
}

View File

@@ -0,0 +1,47 @@
//go:build windows
package singleinstance
import (
"syscall"
"unsafe"
"client/internal/winapi"
)
var (
kernel32 = syscall.NewLazyDLL("kernel32.dll")
procCreateMutexW = kernel32.NewProc("CreateMutexW")
user32 = syscall.NewLazyDLL("user32.dll")
procMessageBoxW = user32.NewProc("MessageBoxW")
// Giữ handle mutex suốt đời process — đóng = nhả lock.
mutexHandle uintptr
)
const errorAlreadyExists = 183
// Acquire — chỉ cho phép 1 instance. Instance thứ 2: đưa cửa sổ cũ lên rồi báo lỗi.
func Acquire() error {
namePtr, err := syscall.UTF16PtrFromString(mutexName)
if err != nil {
return err
}
r1, _, lastErr := procCreateMutexW.Call(0, 0, uintptr(unsafe.Pointer(namePtr)))
if r1 == 0 {
return lastErr
}
mutexHandle = r1
if errno, ok := lastErr.(syscall.Errno); ok && errno == errorAlreadyExists {
winapi.ActivateAppWindow(windowTitle)
showAlreadyRunningDialog()
return ErrAlreadyRunning
}
return nil
}
func showAlreadyRunningDialog() {
title, _ := syscall.UTF16PtrFromString("Simple Care")
msg, _ := syscall.UTF16PtrFromString("Ứng dụng Simple Care đang chạy.\n\nChỉ được mở một cửa sổ. Cửa sổ hiện có đã được đưa lên phía trước.")
_, _, _ = procMessageBoxW.Call(0, uintptr(unsafe.Pointer(msg)), uintptr(unsafe.Pointer(title)), 0x00000040) // MB_ICONINFORMATION
}

View File

@@ -4,14 +4,23 @@ package winapi
import ( import (
"fmt" "fmt"
"os"
"os/exec" "os/exec"
) )
// ActivateAppWindow — đưa cửa sổ app lên trước // ActivateAppWindow — đưa cửa sổ Simple Care đang chạy lên trước (theo tên process / title).
func ActivateAppWindow(titleHint string) { func ActivateAppWindow(titleHint string) {
pid := os.Getpid() hint := titleHint
script := fmt.Sprintf(`tell application "System Events" to set frontmost of first process whose unix id is %d to true`, pid) if hint == "" {
hint = "Simple Care"
}
script := fmt.Sprintf(`
tell application "System Events"
set candidates to every process whose name contains %q or name contains "simple_care" or name contains "SimpleCare"
if (count of candidates) > 0 then
set frontmost of item 1 of candidates to true
end if
end tell
`, hint)
cmd := exec.Command("osascript", "-e", script) cmd := exec.Command("osascript", "-e", script)
_ = cmd.Run() _ = cmd.Run()
} }

View File

@@ -0,0 +1,40 @@
//go:build linux
package winapi
import (
"log"
"os/exec"
)
// ActivateAppWindow attempts to focus the application window.
// On Linux standard X11/Wayland desktop, focus is managed by the WM.
func ActivateAppWindow(titleHint string) {
log.Printf("[WINDOW] ActivateAppWindow requested for: %s", titleHint)
}
// PlayNotifySound plays a system notification sound using canberra-gtk-play, falling back to pw-play or aplay.
func PlayNotifySound() {
log.Println("[WINDOW] Playing notification sound...")
go func() {
// Try canberra-gtk-play first
cmd := exec.Command("canberra-gtk-play", "-i", "bell")
if err := cmd.Run(); err != nil {
// Fallback to pw-play (Pipewire)
cmd = exec.Command("pw-play", "/usr/share/sounds/freedesktop/stereo/bell.oga")
if err := cmd.Run(); err != nil {
// Fallback to aplay (ALSA)
_ = exec.Command("aplay", "/usr/share/sounds/alsa/Front_Center.wav").Run()
}
}
}()
}
// ShowWarningMessageBox displays an asynchronous GUI warning dialog using zenity.
func ShowWarningMessageBox(title, message string) {
log.Printf("[WINDOW] Warning Message Box: %s - %s", title, message)
go func() {
cmd := exec.Command("zenity", "--warning", "--title="+title, "--text="+message, "--no-wrap")
_ = cmd.Run()
}()
}

View File

@@ -1,4 +1,4 @@
//go:build !windows && !darwin //go:build !windows && !darwin && !linux
package winapi package winapi

View File

@@ -1,11 +1,14 @@
package main package main
import ( import (
"context"
"embed" "embed"
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
"client/internal/singleinstance"
"github.com/wailsapp/wails/v2" "github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/menu" "github.com/wailsapp/wails/v2/pkg/menu"
"github.com/wailsapp/wails/v2/pkg/menu/keys" "github.com/wailsapp/wails/v2/pkg/menu/keys"
@@ -33,6 +36,10 @@ func initLogging() *os.File {
} }
func main() { func main() {
if err := singleinstance.Acquire(); err != nil {
os.Exit(0)
}
lf := initLogging() lf := initLogging()
if lf != nil { if lf != nil {
defer lf.Close() defer lf.Close()
@@ -46,10 +53,16 @@ func main() {
fileMenu.AddText("Về trang chính", keys.CmdOrCtrl("h"), func(_ *menu.CallbackData) { fileMenu.AddText("Về trang chính", keys.CmdOrCtrl("h"), func(_ *menu.CallbackData) {
app.ReturnToDashboard() app.ReturnToDashboard()
}) })
fileMenu.AddText("Làm mới trang (F5)", keys.Key("f5"), func(_ *menu.CallbackData) {
app.ReloadExamPage()
})
fileMenu.AddText("Xóa cache trình duyệt", keys.CmdOrCtrl("Delete"), func(_ *menu.CallbackData) {
app.ClearExamBrowserCache()
})
// Create application with options // Create application with options
err := wails.Run(&options.App{ err := wails.Run(&options.App{
Title: "Simple Care — Rikkei Education", Title: "Simple Care v1.3 — Rikkei Education",
Width: 1024, Width: 1024,
Height: 768, Height: 768,
Menu: appMenu, Menu: appMenu,
@@ -58,6 +71,9 @@ func main() {
}, },
BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1}, BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1},
OnStartup: app.startup, OnStartup: app.startup,
OnBeforeClose: func(ctx context.Context) (prevent bool) {
return app.HandleBeforeClose()
},
Windows: &windows.Options{ Windows: &windows.Options{
WebviewUserDataPath: app.webviewDataPath, WebviewUserDataPath: app.webviewDataPath,
}, },

View File

@@ -1,7 +1,7 @@
{ {
"$schema": "https://wails.io/schemas/config.v2.json", "$schema": "https://wails.io/schemas/config.v2.json",
"name": "Simple Care by Rikkei Edu", "name": "Simple Care by Rikkei Edu",
"outputfilename": "simple_care_v1.0", "outputfilename": "simple_care_v1.3",
"frontend:install": "npm install", "frontend:install": "npm install",
"frontend:build": "npm run build", "frontend:build": "npm run build",
"frontend:dev:watcher": "npm run dev", "frontend:dev:watcher": "npm run dev",

405
landing_page/index.html Normal file
View File

@@ -0,0 +1,405 @@
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Simple Care - Tải Ứng Dụng & Hướng Dẫn Hỗ Trợ Sinh Viên</title>
<style>
:root {
--primary: #bb2126;
--primary-hover: #9c1b1f;
--bg: #f8fafc;
--card-bg: #ffffff;
--text-main: #1e293b;
--text-sub: #475569;
--border: #e2e8f0;
--radius: 8px;
--transition: all 0.2s ease;
--shadow: 0 1px 3px rgba(0,0,0,0.05), 0 1px 2px rgba(0,0,0,0.03);
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: var(--font);
background-color: var(--bg);
color: var(--text-main);
line-height: 1.5;
padding: 2rem 1rem;
}
.container {
max-width: 800px;
margin: 0 auto;
}
header {
text-align: center;
margin-bottom: 2.5rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--border);
}
h1 {
font-size: 2rem;
color: var(--primary);
margin-bottom: 0.5rem;
font-weight: 800;
}
.subtitle {
color: var(--text-sub);
font-size: 1rem;
}
section {
margin-bottom: 2.5rem;
}
h2 {
font-size: 1.25rem;
margin-bottom: 1.25rem;
color: var(--text-main);
display: flex;
align-items: center;
gap: 8px;
}
.grid {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
}
@media (min-width: 600px) {
.grid {
grid-template-columns: 1fr 1fr;
}
}
.card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.25rem;
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
justify-content: space-between;
transition: var(--transition);
}
.card:hover {
border-color: var(--primary);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
}
.card-title {
font-weight: 700;
font-size: 1.05rem;
margin-bottom: 0.25rem;
display: flex;
align-items: center;
gap: 8px;
}
.card-desc {
color: var(--text-sub);
font-size: 0.85rem;
margin-bottom: 1.25rem;
flex-grow: 1;
}
.platform-badge {
display: inline-flex;
align-items: center;
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
background: #f1f5f9;
color: var(--text-sub);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
background-color: var(--primary);
color: #fff;
text-decoration: none;
padding: 0.65rem 1rem;
border-radius: var(--radius);
font-weight: 600;
font-size: 0.9rem;
transition: var(--transition);
text-align: center;
border: none;
cursor: pointer;
}
.btn:hover {
background-color: var(--primary-hover);
}
/* Guides section styles */
.guide-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.guide-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.85rem 1.25rem;
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
transition: var(--transition);
}
.guide-item:hover {
border-color: var(--primary);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
}
.guide-link {
display: flex;
align-items: center;
gap: 10px;
color: var(--text-main);
text-decoration: none;
font-weight: 600;
font-size: 0.95rem;
flex-grow: 1;
}
.guide-link:hover {
color: var(--primary);
}
.guide-link-btn {
font-size: 0.85rem;
color: var(--primary);
font-weight: 600;
text-decoration: none;
}
.guide-link-btn:hover {
text-decoration: underline;
}
.loading-container, .error-container {
text-align: center;
padding: 3rem 1.5rem;
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text-sub);
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid #e2e8f0;
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 0 auto 1rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
footer {
text-align: center;
margin-top: 4rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border);
color: var(--text-sub);
font-size: 0.8rem;
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>Simple Care</h1>
<p class="subtitle">Cổng tải ứng dụng và tài liệu hướng dẫn học tập, thi cử dành cho sinh viên</p>
</header>
<main>
<!-- Applications section -->
<section>
<h2>
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style="color: var(--primary)">
<rect x="4" y="4" width="16" height="16" rx="2" ry="2" />
<path d="M9 22V12h6v10M2 17l10 5 10-5M2 12l10 5 10-5" />
</svg>
Ứng dụng hỗ trợ
</h2>
<div id="apps-loading" class="loading-container">
<div class="spinner"></div>
<p>Đang tải danh sách ứng dụng...</p>
</div>
<div id="apps-error" class="error-container" style="display: none;"></div>
<div id="apps-list" class="grid" style="display: none;"></div>
</section>
<!-- Guides section -->
<section>
<h2>
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style="color: var(--primary)">
<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" />
</svg>
Hướng dẫn & Tài liệu
</h2>
<div id="guides-loading" class="loading-container">
<div class="spinner"></div>
<p>Đang tải danh sách tài liệu...</p>
</div>
<div id="guides-error" class="error-container" style="display: none;"></div>
<div id="guides-list" class="guide-list" style="display: none;"></div>
</section>
</main>
<footer>
<p>&copy; 2026 Simple Care. Bản quyền thuộc Rikkei Academy.</p>
</footer>
</div>
<script>
// Platforms coloring/badges
function getPlatformIcon(platform) {
const p = platform.toLowerCase();
if (p.includes('win')) {
return `<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" style="color: #0078d7"><path d="M0 3.449L9.75 2.1v9.45H0V3.449zM0 12.45h9.75v9.45L0 20.551v-8.1zM10.8 1.95L24 0v11.55H10.8V1.95zM10.8 12.45H24v11.55l-13.2-1.95v-9.6z"/></svg>`;
}
if (p.includes('mac') || p.includes('apple') || p.includes('ios')) {
return `<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" style="color: #555"><path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M15.97 4.17c.66-.81 1.11-1.93.99-3.05-1 .04-2.22.67-2.94 1.51-.62.73-1.16 1.87-1.01 2.97 1.12.09 2.27-.58 2.96-1.43z"/></svg>`;
}
if (p.includes('linux')) {
return `<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" style="color: #FCC624"><path d="M12 2c-3.3 0-6 2.7-6 6 0 2.2.8 4.2 2.2 5.6C6.5 15.6 5 18.5 5 22h14c0-3.5-1.5-6.4-3.2-8.4 1.4-1.4 2.2-3.4 2.2-5.6 0-3.3-2.7-6-6-6zm0 2c2.2 0 4 1.8 4 4 0 .9-.3 1.7-.8 2.3-.3.4-.7.7-1.2.9-.6.2-1.3.3-2 .3s-1.4-.1-2-.3c-.5-.2-.9-.5-1.2-.9-.5-.6-.8-1.4-.8-2.3 0-2.2 1.8-4 4-4z"/></svg>`;
}
return `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style="color: var(--primary)"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>`;
}
const host = 'https://sv.rikkeiraia.org';
async function loadApps() {
const loader = document.getElementById('apps-loading');
const errorDiv = document.getElementById('apps-error');
const listDiv = document.getElementById('apps-list');
try {
const response = await fetch(`${host}/api/public/app-downloads`);
if (!response.ok) throw new Error('Không thể lấy danh sách ứng dụng');
const res = await response.json();
const data = res.data || [];
loader.style.display = 'none';
if (data.length === 0) {
errorDiv.innerText = 'Không có ứng dụng nào khả dụng để tải về.';
errorDiv.style.display = 'block';
return;
}
listDiv.innerHTML = data.map(app => `
<div class="card">
<div>
<div class="card-title">
${getPlatformIcon(app.platform)}
${app.name}
</div>
<div class="card-desc">${app.description || 'Hỗ trợ quá trình kiểm tra và học tập.'}</div>
</div>
<div style="display:flex; justify-content:space-between; align-items:center;">
<span class="platform-badge">${app.platform === 'macOS' ? 'MacOS' : app.platform}</span>
<a href="${app.downloadUrl}" target="_blank" rel="noopener noreferrer" class="btn">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
Tải về
</a>
</div>
</div>
`).join('');
listDiv.style.display = 'grid';
} catch (err) {
loader.style.display = 'none';
errorDiv.innerText = 'Lỗi kết nối máy chủ. Vui lòng thử lại sau.';
errorDiv.style.display = 'block';
}
}
async function loadGuides() {
const loader = document.getElementById('guides-loading');
const errorDiv = document.getElementById('guides-error');
const listDiv = document.getElementById('guides-list');
try {
const response = await fetch(`${host}/api/public/app-guides`);
if (!response.ok) throw new Error('Không thể lấy danh sách hướng dẫn');
const res = await response.json();
const data = res.data || [];
loader.style.display = 'none';
if (data.length === 0) {
errorDiv.innerText = 'Không có tài liệu hoặc video hướng dẫn nào.';
errorDiv.style.display = 'block';
return;
}
listDiv.innerHTML = data.map(guide => {
const isVideo = guide.url.toLowerCase().includes('youtube.com') || guide.url.toLowerCase().includes('youtu.be') || guide.url.toLowerCase().includes('drive.google.com/file');
const icon = isVideo
? `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style="color: #ea4335"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></svg>`
: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style="color: #4285f4"><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"/></svg>`;
return `
<div class="guide-item">
<a href="${guide.url}" target="_blank" rel="noopener noreferrer" class="guide-link">
${icon}
${guide.title}
</a>
<a href="${guide.url}" target="_blank" rel="noopener noreferrer" class="guide-link-btn">Xem &rarr;</a>
</div>
`;
}).join('');
listDiv.style.display = 'flex';
} catch (err) {
loader.style.display = 'none';
errorDiv.innerText = 'Lỗi kết nối máy chủ. Vui lòng thử lại sau.';
errorDiv.style.display = 'block';
}
}
// Run
loadApps();
loadGuides();
</script>
</body>
</html>

View File

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

1
management/src/.env Normal file
View File

@@ -0,0 +1 @@
VITE_API_BASE=https://sv.rikkeiraia.org/api

View File

@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useCallback, useState } from 'react';
import { apiMyClasses } from './api';
import { DashboardTab } from './components/DashboardTab'; import { DashboardTab } from './components/DashboardTab';
import { ClassesTab } from './components/ClassesTab'; import { ClassesTab } from './components/ClassesTab';
import { StudentsTab } from './components/StudentsTab'; import { StudentsTab } from './components/StudentsTab';
@@ -7,6 +8,8 @@ import { ExamsTab } from './components/ExamsTab';
import { ExamRoomWorkspace } from './components/ExamRoomWorkspace'; import { ExamRoomWorkspace } from './components/ExamRoomWorkspace';
import { SystemTab } from './components/SystemTab'; import { SystemTab } from './components/SystemTab';
import { MyAccountTab } from './components/AccountsTab'; import { MyAccountTab } from './components/AccountsTab';
import { StudentAffairsTab } from './components/StudentAffairsTab';
import { ApplicationsTab } from './components/ApplicationsTab';
import { ChatWidget } from './components/ChatWidget'; import { ChatWidget } from './components/ChatWidget';
import { StaffChatSocket } from './hooks/useStaffChatSocket'; import { StaffChatSocket } from './hooks/useStaffChatSocket';
import { ClassWorkspace } from './components/ClassWorkspace'; import { ClassWorkspace } from './components/ClassWorkspace';
@@ -25,52 +28,87 @@ import {
import { useAuth } from './auth/AuthContext'; import { useAuth } from './auth/AuthContext';
const IconDashboard = () => ( const IconDashboard = () => (
<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>
<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="3" width="7" height="7" rx="1.5" />
<rect x="3" y="14" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" /> <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> </svg>
); );
const IconClass = () => ( 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" /> <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" /> <polyline points="9 22 9 12 15 12 15 22" />
</svg> </svg>
); );
const IconStudent = () => ( const IconStudent = () => (
<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="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" /> <path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" /> <circle cx="9" cy="7" r="3.5" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" /><path d="M16 3.13a4 4 0 0 1 0 7.75" /> <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> </svg>
); );
const IconLearning = () => ( 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="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" /> <path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
</svg> </svg>
); );
const IconExam = () => ( 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" /> <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" /> <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> </svg>
); );
const IconSystem = () => ( 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" /> <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="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="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 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> </svg>
); );
const IconMenu = () => ( const IconMenu = () => (
<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>
<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" /> <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> </svg>
); );
@@ -78,12 +116,44 @@ const SYSTEM_NAV: { section: SystemSection }[] = [
{ section: 'organization' }, { section: 'organization' },
{ section: 'network' }, { section: 'network' },
{ section: 'templates' }, { section: 'templates' },
{ section: 'seating' },
]; ];
function App() { function App() {
const route = useRoute(); const route = useRoute();
const { staff, logout } = useAuth(); const { staff, logout } = useAuth();
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
return localStorage.getItem('sc_sidebar_collapsed') === 'true';
});
const toggleSidebarCollapse = () => {
// On mobile (<=900px), just close the sidebar overlay
if (window.innerWidth <= 900) {
setSidebarOpen(false);
return;
}
// On desktop, toggle collapsed state
const nextVal = !sidebarCollapsed;
setSidebarCollapsed(nextVal);
localStorage.setItem('sc_sidebar_collapsed', String(nextVal));
};
const [myClasses, setMyClasses] = useState<{ id: number; name: string; code: string }[]>([]);
const refreshMyClasses = useCallback(() => {
apiMyClasses.list().then((items) => {
setMyClasses(items.map((c) => ({ id: c.rkId, name: c.name, code: c.classCode })));
}).catch(() => {});
}, []);
useEffect(() => {
refreshMyClasses();
}, [refreshMyClasses]);
useEffect(() => {
window.addEventListener('my-classes-updated', refreshMyClasses);
return () => window.removeEventListener('my-classes-updated', refreshMyClasses);
}, [refreshMyClasses]);
useEffect(() => { useEffect(() => {
const initial = parseRoute(); const initial = parseRoute();
@@ -131,6 +201,7 @@ function App() {
return ( return (
<> <>
{!sidebarOpen && (
<button <button
type="button" type="button"
className="mobile-menu-btn" className="mobile-menu-btn"
@@ -139,6 +210,7 @@ function App() {
> >
<IconMenu /> <IconMenu />
</button> </button>
)}
{sidebarOpen && ( {sidebarOpen && (
<button <button
@@ -149,7 +221,7 @@ function App() {
/> />
)} )}
<aside className={`sidebar${sidebarOpen ? ' sidebar--open' : ''}`}> <aside className={`sidebar${sidebarOpen ? ' sidebar--open' : ''}${sidebarCollapsed ? ' sidebar--collapsed' : ''}`}>
<div className="brand"> <div className="brand">
<img src="/logo.jpeg" alt="Simple Care" className="brand-logo-img" /> <img src="/logo.jpeg" alt="Simple Care" className="brand-logo-img" />
<div className="brand-text"> <div className="brand-text">
@@ -158,72 +230,134 @@ function App() {
</div> </div>
</div> </div>
<nav> <nav className="sidebar-nav" aria-label="Menu chính">
<ul className="nav-links"> <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"> <li className="nav-item">
<button <button
type="button"
className={navBtn(route.tab === 'dashboard' && !inWorkspace)} className={navBtn(route.tab === 'dashboard' && !inWorkspace)}
onClick={() => setActiveTab('dashboard')} onClick={() => setActiveTab('dashboard')}
> >
<span className="nav-icon"><IconDashboard /></span> <span className="nav-icon"><IconDashboard /></span>
Tổng quan <span className="nav-label">Tổng quan & Tài liệu</span>
</button> </button>
</li> </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"> <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> <span className="nav-icon"><IconClass /></span>
Lớp học <span className="nav-label">Lớp học</span>
</button> </button>
</li> </li>
<li className="nav-item"> <li className="nav-item">
<button <button
type="button"
className={navBtn(route.tab === 'students' && !inWorkspace)} className={navBtn(route.tab === 'students' && !inWorkspace)}
onClick={() => setActiveTab('students')} onClick={() => setActiveTab('students')}
> >
<span className="nav-icon"><IconStudent /></span> <span className="nav-icon"><IconStudent /></span>
Sinh viên <span className="nav-label">Sinh viên</span>
</button> </button>
</li> </li>
<div className="nav-header">Quản học tập</div> <li className="nav-section" aria-hidden="true">
<span className="nav-header">Quản học tập</span>
</li>
<li className="nav-item"> <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> <span className="nav-icon"><IconLearning /></span>
Phòng Học <span className="nav-label">Phòng học</span>
</button> </button>
</li> </li>
<li className="nav-item"> <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> <span className="nav-icon"><IconExam /></span>
Phòng thi <span className="nav-label">Phòng thi</span>
</button> </button>
</li> </li>
<div className="nav-header">Hệ thống</div> {myClasses.length > 0 && (
<>
<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
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}
>
<span className="nav-icon"><IconMyClass /></span>
<span className="nav-label">{c.code || c.name}</span>
</button>
</li>
))}
</>
)}
<li className="nav-section" aria-hidden="true">
<span className="nav-header">Công tác sinh viên</span>
</li>
<li className="nav-item"> <li className="nav-item">
<button <button
type="button"
className={navBtn(route.tab === 'student-affairs' && !inWorkspace)}
onClick={() => setActiveTab('student-affairs')}
>
<span className="nav-icon"><IconStudentAffairs /></span>
<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>
<span className="nav-label">ng dụng</span>
</button>
</li>
<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)} className={navBtn(systemActive)}
onClick={() => goSystem('organization')} onClick={() => goSystem('organization')}
> >
<span className="nav-icon"><IconSystem /></span> <span className="nav-icon"><IconSystem /></span>
Quản hệ thống <span className="nav-label">Quản hệ thống</span>
</button> </button>
</li> </li>
<li className="nav-item nav-item--sub">
<ul className="nav-sub"> <ul className="nav-sub">
{SYSTEM_NAV.map(({ section }) => ( {SYSTEM_NAV.map(({ section }) => (
<li key={section} className="nav-item"> <li key={section}>
<button <button
type="button"
className={navBtn(systemActive && route.systemSection === section)} className={navBtn(systemActive && route.systemSection === section)}
onClick={() => goSystem(section)} onClick={() => goSystem(section)}
> >
{SYSTEM_SECTION_LABELS[section]} <span className="nav-label">{SYSTEM_SECTION_LABELS[section]}</span>
</button> </button>
</li> </li>
))} ))}
</ul> </ul>
</li>
</ul> </ul>
</nav> </nav>
@@ -234,18 +368,44 @@ function App() {
onClick={() => setActiveTab('profile')} onClick={() => setActiveTab('profile')}
title="Tài khoản của tôi" 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"> <span className="sidebar-user-name">
{staff?.fullName?.trim() || staff?.email?.split('@')[0] || 'Tài khoản'} {staff?.fullName?.trim() || staff?.email?.split('@')[0] || 'Tài khoản'}
</span> </span>
<span className="sidebar-user-email">{staff?.email}</span> <span className="sidebar-user-email">{staff?.email}</span>
</span>
</button> </button>
<button type="button" className="link-btn" onClick={logout}> <button type="button" className="sidebar-logout" onClick={logout}>
<IconLogout />
Đăng xuất Đăng xuất
</button> </button>
</div> </div>
<button
type="button"
className="sidebar-edge-toggle"
onClick={toggleSidebarCollapse}
title={sidebarCollapsed ? 'Mở rộng menu' : 'Thu gọn menu'}
>
<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" />
}
</svg>
</button>
</aside> </aside>
<main className="main-content"> <main className={`main-content${sidebarCollapsed ? ' main-content--full' : ''}`}>
<div className="page-viewport"> <div className="page-viewport">
{route.classId ? ( {route.classId ? (
<ClassWorkspace <ClassWorkspace
@@ -267,6 +427,8 @@ function App() {
{route.tab === 'exams' && <ExamsTab />} {route.tab === 'exams' && <ExamsTab />}
{route.tab === 'system' && <SystemTab />} {route.tab === 'system' && <SystemTab />}
{route.tab === 'profile' && <MyAccountTab />} {route.tab === 'profile' && <MyAccountTab />}
{route.tab === 'student-affairs' && <StudentAffairsTab />}
{route.tab === 'applications' && <ApplicationsTab />}
</> </>
)} )}
</div> </div>

View File

@@ -1,4 +1,4 @@
export const API_BASE = import.meta.env.VITE_API_BASE || 'http://127.0.0.1:8080/api'; export const API_BASE = import.meta.env.VITE_API_BASE || 'https://sv.rikkeiraia.org/api';
export function getWsUrl(path: string): string { export function getWsUrl(path: string): string {
try { try {
@@ -269,6 +269,32 @@ export interface StatsResponse {
totalStudents: number; totalStudents: number;
} }
export interface GuideLinkItem {
id: number;
createdAt: string;
updatedAt: string;
title: string;
url: string;
}
export interface AppDownloadItem {
id: number;
createdAt: string;
updatedAt: string;
name: string;
description: string;
downloadUrl: string;
platform: string;
}
export interface AppGuideItem {
id: number;
createdAt: string;
updatedAt: string;
title: string;
url: string;
}
export interface PaginatedResponse<T> { export interface PaginatedResponse<T> {
data: T[]; data: T[];
total: number; total: number;
@@ -355,6 +381,32 @@ export interface StudentSessionLogItem {
lastActiveAt?: string; lastActiveAt?: string;
} }
export interface StudentViolationItem {
id: number;
studentRkId: number;
studentCode: string;
fullName: string;
classRkId: number;
examRoomId?: number;
kind: string;
reason: string;
monitorMode: string;
clientAt?: string;
createdAt: string;
}
export const VIOLATION_KIND_OPTIONS = [
{ value: '', label: 'Tất cả loại' },
{ value: 'app_closed', label: 'Tắt ứng dụng' },
{ value: 'unclean_shutdown', label: 'Tắt đột ngột' },
{ value: 'multi_monitor', label: 'Nhiều màn hình' },
{ value: 'user_switch', label: 'Đổi user' },
{ value: 'session_change', label: 'Khóa / đổi phiên' },
{ value: 'virtual_desktop', label: 'Desktop ảo' },
{ value: 'wifi', label: 'WiFi trái phép' },
{ value: 'guard', label: 'Vi phạm môi trường (cũ)' },
] as const;
export const api = { export const api = {
getStats: async (): Promise<StatsResponse> => { getStats: async (): Promise<StatsResponse> => {
const res = await staffFetch('/stats'); const res = await staffFetch('/stats');
@@ -362,6 +414,87 @@ export const api = {
return res.json(); return res.json();
}, },
listGuideLinks: async (): Promise<{ data: GuideLinkItem[] }> => {
const res = await staffFetch('/guide-links');
if (!res.ok) throw new Error('Không thể tải danh sách tài liệu');
return res.json();
},
createGuideLink: async (title: string, url: string): Promise<{ data: GuideLinkItem }> => {
const res = await staffFetch('/guide-links', {
method: 'POST',
body: JSON.stringify({ title, url }),
});
if (!res.ok) await parseError(res, 'Thêm tài liệu thất bại');
return res.json();
},
updateGuideLink: async (id: number, title: string, url: string): Promise<{ data: GuideLinkItem }> => {
const res = await staffFetch(`/guide-links/${id}`, {
method: 'PUT',
body: JSON.stringify({ title, url }),
});
if (!res.ok) await parseError(res, 'Cập nhật tài liệu thất bại');
return res.json();
},
deleteGuideLink: async (id: number): Promise<{ ok: boolean }> => {
const res = await staffFetch(`/guide-links/${id}`, { method: 'DELETE' });
if (!res.ok) await parseError(res, 'Xóa tài liệu thất bại');
return res.json();
},
listAppDownloads: async (): Promise<{ data: AppDownloadItem[] }> => {
const res = await staffFetch('/app-downloads');
if (!res.ok) throw new Error('Không thể tải danh sách ứng dụng');
return res.json();
},
createAppDownload: async (name: string, description: string, downloadUrl: string, platform: string): Promise<{ data: AppDownloadItem }> => {
const res = await staffFetch('/app-downloads', {
method: 'POST',
body: JSON.stringify({ name, description, downloadUrl, platform }),
});
if (!res.ok) await parseError(res, 'Thêm ứng dụng thất bại');
return res.json();
},
updateAppDownload: async (id: number, name: string, description: string, downloadUrl: string, platform: string): Promise<{ data: AppDownloadItem }> => {
const res = await staffFetch(`/app-downloads/${id}`, {
method: 'PUT',
body: JSON.stringify({ name, description, downloadUrl, platform }),
});
if (!res.ok) await parseError(res, 'Cập nhật ứng dụng thất bại');
return res.json();
},
deleteAppDownload: async (id: number): Promise<{ ok: boolean }> => {
const res = await staffFetch(`/app-downloads/${id}`, { method: 'DELETE' });
if (!res.ok) await parseError(res, 'Xóa ứng dụng thất bại');
return res.json();
},
listAppGuides: async (): Promise<{ data: AppGuideItem[] }> => {
const res = await staffFetch('/app-guides');
if (!res.ok) throw new Error('Không thể tải danh sách tài liệu hướng dẫn');
return res.json();
},
createAppGuide: async (title: string, url: string): Promise<{ data: AppGuideItem }> => {
const res = await staffFetch('/app-guides', {
method: 'POST',
body: JSON.stringify({ title, url }),
});
if (!res.ok) await parseError(res, 'Thêm tài liệu hướng dẫn thất bại');
return res.json();
},
updateAppGuide: async (id: number, title: string, url: string): Promise<{ data: AppGuideItem }> => {
const res = await staffFetch(`/app-guides/${id}`, {
method: 'PUT',
body: JSON.stringify({ title, url }),
});
if (!res.ok) await parseError(res, 'Cập nhật tài liệu hướng dẫn thất bại');
return res.json();
},
deleteAppGuide: async (id: number): Promise<{ ok: boolean }> => {
const res = await staffFetch(`/app-guides/${id}`, { method: 'DELETE' });
if (!res.ok) await parseError(res, 'Xóa tài liệu hướng dẫn thất bại');
return res.json();
},
getClasses: async (params: { getClasses: async (params: {
page: number; page: number;
pageSize: number; pageSize: number;
@@ -609,12 +742,51 @@ export const apiFetchClassSessionLogs = async (
return res.json(); return res.json();
}; };
export const apiFetchClassViolations = async (
rkId: number,
date: string,
kind = ''
): Promise<{ data: StudentViolationItem[]; date: string }> => {
const params = new URLSearchParams({ date });
if (kind) params.set('kind', kind);
const res = await staffFetch(`/classes/${rkId}/violations?${params}`);
if (!res.ok) throw new Error('Failed to fetch class violations');
return res.json();
};
export const apiFetchExamViolations = async (
examId: number,
date: string,
kind = ''
): Promise<{ data: StudentViolationItem[]; date: string }> => {
const params = new URLSearchParams({ date });
if (kind) params.set('kind', kind);
const res = await staffFetch(`/exam-rooms/${examId}/violations?${params}`);
if (!res.ok) throw new Error('Failed to fetch exam violations');
return res.json();
};
export const apiFetchOnlineStudents = async (rkId: number): Promise<{ onlineStudentIds: number[] }> => { export const apiFetchOnlineStudents = async (rkId: number): Promise<{ onlineStudentIds: number[] }> => {
const res = await staffFetch(`/classes/${rkId}/online-students`); const res = await staffFetch(`/classes/${rkId}/online-students`);
if (!res.ok) throw new Error('Failed to fetch online students list'); if (!res.ok) throw new Error('Failed to fetch online students list');
return res.json(); return res.json();
}; };
export interface ScheduleConflictItem {
studentRkId: number;
fullName: string;
studentCode: string;
conflictClassRkId: number;
conflictClassName: string;
conflictClassCode: string;
}
export const apiFetchScheduleConflicts = async (rkId: number): Promise<{ conflicts: ScheduleConflictItem[]; ok: boolean }> => {
const res = await staffFetch(`/classes/${rkId}/schedule-conflicts`);
if (!res.ok) throw new Error('Failed to check schedule conflicts');
return res.json();
};
export const apiFetchClassCourses = async (rkId: number): Promise<{ export const apiFetchClassCourses = async (rkId: number): Promise<{
data: ClassCourseItem[]; data: ClassCourseItem[];
source?: string; source?: string;
@@ -691,6 +863,55 @@ export const apiPushAttendanceQLDT = async (rkId: number, date: string, period:
return res.json(); return res.json();
}; };
export interface LeaveRequestItem {
id: number;
date: string;
note: string;
reasonImage?: string;
rejectReason?: string | null;
period: number;
status: string; // 'Đang chờ', 'Phê duyệt', 'Từ chối'
approverId?: number | null;
createdAt: string;
student: {
id: number;
studentCode: string;
fullName: string;
phone?: string;
email: string;
avatar?: string;
};
}
export const apiFetchLeaveRequests = async (
classId: number,
courseId: number,
date: string
): Promise<LeaveRequestItem[]> => {
const res = await staffFetch(`/classes/${classId}/leave-requests?courseId=${courseId}&date=${date}`);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || 'Failed to fetch leave requests');
}
return res.json();
};
export const apiUpdateLeaveStatus = async (
classId: number,
leaveId: number,
payload: { status: string; studentRkId: number; date: string; period: number }
): Promise<{ ok: boolean; message: string }> => {
const res = await staffFetch(`/classes/${classId}/leave-requests/${leaveId}/status`, {
method: 'POST',
body: JSON.stringify(payload),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || 'Failed to update leave status');
}
return res.json();
};
async function staffUpload(path: string, form: FormData): Promise<Response> { async function staffUpload(path: string, form: FormData): Promise<Response> {
const headers = new Headers(); const headers = new Headers();
const token = getToken(); const token = getToken();
@@ -712,6 +933,7 @@ export interface ExamRoomItem {
studentCount: number; studentCount: number;
paperCount: number; paperCount: number;
displayStatus: 'draft' | 'ready' | 'active' | 'ended' | 'cancelled'; displayStatus: 'draft' | 'ready' | 'active' | 'ended' | 'cancelled';
createdByStaffId?: number;
} }
export interface ExamPaperResource { export interface ExamPaperResource {
@@ -764,8 +986,9 @@ function decodeBase64ToArrayBuffer(b64: string): ArrayBuffer {
} }
export const apiExam = { export const apiExam = {
list: async (): Promise<{ data: ExamRoomItem[] }> => { list: async (opts?: { mine?: boolean }): Promise<{ data: ExamRoomItem[] }> => {
const res = await staffFetch('/exam-rooms'); const q = opts?.mine ? '?mine=1' : '';
const res = await staffFetch(`/exam-rooms${q}`);
if (!res.ok) await parseError(res, 'Failed'); if (!res.ok) await parseError(res, 'Failed');
return res.json(); return res.json();
}, },
@@ -787,6 +1010,7 @@ export const apiExam = {
canPublish: boolean; canPublish: boolean;
canUnpublish: boolean; canUnpublish: boolean;
canCancel: boolean; canCancel: boolean;
canExtend?: boolean;
}>; }>;
}, },
publish: async (id: number) => { publish: async (id: number) => {
@@ -810,6 +1034,14 @@ export const apiExam = {
if (!res.ok) await parseError(res, 'Hủy phòng thi thất bại'); if (!res.ok) await parseError(res, 'Hủy phòng thi thất bại');
return res.json(); return res.json();
}, },
extend: async (id: number, minutes: number) => {
const res = await staffFetch(`/exam-rooms/${id}/extend`, {
method: 'POST',
body: JSON.stringify({ minutes }),
});
if (!res.ok) await parseError(res, 'Gia hạn thất bại');
return res.json() as Promise<{ ok: boolean; endTime: string; addedMinutes: number; displayStatus: string }>;
},
update: async (id: number, payload: Partial<{ name: string; startTime: string; endTime: string; allowedApps: string; quizUrl: string }>) => { update: async (id: number, payload: Partial<{ name: string; startTime: string; endTime: string; allowedApps: string; quizUrl: string }>) => {
const res = await staffFetch(`/exam-rooms/${id}`, { method: 'PATCH', body: JSON.stringify(payload) }); const res = await staffFetch(`/exam-rooms/${id}`, { method: 'PATCH', body: JSON.stringify(payload) });
if (!res.ok) await parseError(res, 'Cập nhật thất bại'); if (!res.ok) await parseError(res, 'Cập nhật thất bại');
@@ -904,6 +1136,14 @@ export const apiExam = {
if (!res.ok) await parseError(res, 'Chia gói đề thất bại'); if (!res.ok) await parseError(res, 'Chia gói đề thất bại');
return res.json() as Promise<{ assigned: number; packages: number }>; return res.json() as Promise<{ assigned: number; packages: number }>;
}, },
assignPapersBatch: async (id: number, assignments: { studentRkId: number; paperId: number }[]) => {
const res = await staffFetch(`/exam-rooms/${id}/assign-papers-batch`, {
method: 'POST',
body: JSON.stringify({ assignments }),
});
if (!res.ok) await parseError(res, 'Chia gói đề thất bại');
return res.json() as Promise<{ assigned: number }>;
},
sendPapers: async (id: number, payload?: { scheduledAt?: string; studentRkIds?: number[] }) => { sendPapers: async (id: number, payload?: { scheduledAt?: string; studentRkIds?: number[] }) => {
const res = await staffFetch(`/exam-rooms/${id}/send-papers`, { method: 'POST', body: JSON.stringify(payload || {}) }); const res = await staffFetch(`/exam-rooms/${id}/send-papers`, { method: 'POST', body: JSON.stringify(payload || {}) });
if (!res.ok) await parseError(res, 'Gửi gói đề thất bại'); if (!res.ok) await parseError(res, 'Gửi gói đề thất bại');
@@ -966,6 +1206,64 @@ export const apiExam = {
}, },
}; };
export interface MyClassItem {
rkId: number;
name: string;
classCode: string;
}
export const apiMyClasses = {
list: async (): Promise<MyClassItem[]> => {
const res = await staffFetch('/staff/my-classes');
if (!res.ok) return [];
const json = await res.json() as { data: MyClassItem[] };
return json.data ?? [];
},
add: async (classRkId: number): Promise<void> => {
await staffFetch(`/staff/my-classes/${classRkId}`, { method: 'POST' });
},
remove: async (classRkId: number): Promise<void> => {
await staffFetch(`/staff/my-classes/${classRkId}`, { method: 'DELETE' });
},
};
export const apiSeatingLayout = {
getClass: async (classRkId: number): Promise<string | null> => {
const res = await staffFetch(`/classes/${classRkId}/seating-layout`);
if (!res.ok) return null;
const json = await res.json() as { layoutJson: string | null };
return json.layoutJson ?? null;
},
saveClass: async (classRkId: number, layoutJson: string): Promise<void> => {
const res = await staffFetch(`/classes/${classRkId}/seating-layout`, {
method: 'PUT',
body: JSON.stringify({ layoutJson }),
});
if (!res.ok) await parseError(res, 'Lưu sơ đồ lớp thất bại');
},
deleteClass: async (classRkId: number): Promise<void> => {
const res = await staffFetch(`/classes/${classRkId}/seating-layout`, { method: 'DELETE' });
if (!res.ok) await parseError(res, 'Xóa sơ đồ lớp thất bại');
},
getExam: async (examRoomId: number): Promise<string | null> => {
const res = await staffFetch(`/exam-rooms/${examRoomId}/seating-layout`);
if (!res.ok) return null;
const json = await res.json() as { layoutJson: string | null };
return json.layoutJson ?? null;
},
saveExam: async (examRoomId: number, layoutJson: string): Promise<void> => {
const res = await staffFetch(`/exam-rooms/${examRoomId}/seating-layout`, {
method: 'PUT',
body: JSON.stringify({ layoutJson }),
});
if (!res.ok) await parseError(res, 'Lưu sơ đồ phòng thi thất bại');
},
deleteExam: async (examRoomId: number): Promise<void> => {
const res = await staffFetch(`/exam-rooms/${examRoomId}/seating-layout`, { method: 'DELETE' });
if (!res.ok) await parseError(res, 'Xóa sơ đồ phòng thi thất bại');
},
};
export const apiQldt = { export const apiQldt = {
getToken: async (): Promise<{ token: string }> => { getToken: async (): Promise<{ token: string }> => {
const res = await staffFetch('/qldt-token'); const res = await staffFetch('/qldt-token');

View File

@@ -9,6 +9,48 @@ interface AppPoolModalProps {
allowedApps: string; 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) => { const formatWhen = (iso: string) => {
if (!iso) return '—'; if (!iso) return '—';
const d = new Date(iso); const d = new Date(iso);
@@ -80,18 +122,29 @@ export const AppPoolModal: React.FC<AppPoolModalProps> = ({
return ( return (
<div className="modal-overlay app-pool-overlay" onClick={onClose}> <div className="modal-overlay app-pool-overlay" onClick={onClose}>
<div className="modal-container app-pool-modal" onClick={e => e.stopPropagation()}> <div className="modal-container app-pool-modal app-picker-modal" onClick={e => e.stopPropagation()}>
<div className="modal-header"> <div className="modal-header app-picker-header">
<div> <div className="app-picker-header-text">
<h2 className="modal-title" style={{ margin: 0 }}>Kho ng dụng</h2> <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"> <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> </p>
</div> </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>
<div className="app-pool-toolbar"> <div className="app-pool-toolbar">
<div className="app-picker-search-wrap">
<span className="app-picker-search-icon" aria-hidden>
<IconSearch />
</span>
<input <input
type="search" type="search"
className="app-pool-search" className="app-pool-search"
@@ -100,24 +153,32 @@ export const AppPoolModal: React.FC<AppPoolModalProps> = ({
onChange={e => setSearch(e.target.value)} onChange={e => setSearch(e.target.value)}
autoFocus autoFocus
/> />
</div>
<button <button
type="button" type="button"
className="btn btn-secondary" className="btn btn-secondary app-picker-refresh"
onClick={() => loadPool(debouncedQ)} onClick={() => loadPool(debouncedQ)}
disabled={loading} disabled={loading}
> >
<IconRefresh />
{loading ? 'Đang tải...' : 'Làm mới'} {loading ? 'Đang tải...' : 'Làm mới'}
</button> </button>
</div> </div>
<div className="app-pool-body"> <div className="app-pool-body">
{error ? ( {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 ? ( ) : 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 ? ( ) : items.length === 0 ? (
<div className="app-pool-status"> <div className="app-picker-state">
{debouncedQ ? `Không tìm thấy "${debouncedQ}"` : 'Chưa có app nào trong kho.'} <IconInbox />
<p>
{debouncedQ ? `Không tìm thấy “${debouncedQ}` : 'Chưa có app nào trong kho.'}
</p>
</div> </div>
) : ( ) : (
<table className="data-table app-pool-table"> <table className="data-table app-pool-table">
@@ -128,7 +189,7 @@ export const AppPoolModal: React.FC<AppPoolModalProps> = ({
<th>Tiêu đ</th> <th>Tiêu đ</th>
<th>Lần chặn</th> <th>Lần chặn</th>
<th>Gần nhất</th> <th>Gần nhất</th>
<th></th> <th style={{ textAlign: 'right' }}>Thao tác</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -136,17 +197,33 @@ export const AppPoolModal: React.FC<AppPoolModalProps> = ({
const added = allowedSet.has(app.keyword.toLowerCase()); const added = allowedSet.has(app.keyword.toLowerCase());
return ( return (
<tr key={app.id} className={added ? 'app-pool-row--added' : ''}> <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> <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 ? ( {added ? (
<span className="app-pool-added-tag">Đã </span> <span className="app-pool-added-tag">
<IconCheck />
Đã
</span>
) : ( ) : (
<button type="button" className="btn btn-primary app-pool-add-btn" onClick={() => handleSelect(app.keyword)}> <button
+ Thêm type="button"
className="btn btn-primary app-pool-add-btn"
onClick={() => handleSelect(app.keyword)}
>
<IconPlus />
Thêm
</button> </button>
)} )}
</td> </td>
@@ -159,7 +236,9 @@ export const AppPoolModal: React.FC<AppPoolModalProps> = ({
</div> </div>
<div className="app-pool-footer"> <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> </div>
</div> </div>

View File

@@ -9,6 +9,47 @@ interface AppTemplatePickerModalProps {
allowedApps: string; 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> = ({ export const AppTemplatePickerModal: React.FC<AppTemplatePickerModalProps> = ({
open, open,
onClose, onClose,
@@ -60,20 +101,29 @@ export const AppTemplatePickerModal: React.FC<AppTemplatePickerModalProps> = ({
return ( return (
<div className="modal-overlay app-pool-overlay" onClick={onClose}> <div className="modal-overlay app-pool-overlay" onClick={onClose}>
<div className="modal-container app-pool-modal" onClick={(e) => e.stopPropagation()}> <div className="modal-container app-pool-modal app-picker-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header"> <div className="modal-header app-picker-header">
<div> <div className="app-picker-header-text">
<h2 className="modal-title" style={{ margin: 0 }}>Chọn khung ng dụng</h2> <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"> <p className="app-pool-modal-sub">
Ghép bộ keyword đã lưu vào danh sách ng dụng đưc phép. Quản khung tại Hệ thống Khung ng dụng. Ghép bộ keyword đã lưu vào whitelist. Quản khung tại Hệ thống Khung ng dụng.
</p> </p>
</div> </div>
<button type="button" className="btn btn-secondary" onClick={onClose}> <button type="button" className="btn btn-secondary app-picker-close" onClick={onClose}>
Đóng Đóng
</button> </button>
</div> </div>
<div className="app-pool-toolbar"> <div className="app-pool-toolbar">
<div className="app-picker-search-wrap">
<span className="app-picker-search-icon" aria-hidden>
<IconSearch />
</span>
<input <input
type="search" type="search"
className="app-pool-search" className="app-pool-search"
@@ -82,30 +132,58 @@ export const AppTemplatePickerModal: React.FC<AppTemplatePickerModalProps> = ({
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
autoFocus 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'} {loading ? 'Đang tải...' : 'Làm mới'}
</button> </button>
</div> </div>
<div className="app-pool-body"> <div className="app-pool-body">
{error ? ( {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 ? ( ) : 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 ? ( ) : filtered.length === 0 ? (
<div className="app-pool-status"> <div className="app-picker-state">
{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.'} <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>
) : ( ) : (
<div className="template-picker-list"> <div className="template-picker-list">
{filtered.map((tpl) => ( {filtered.map((tpl) => {
<div key={tpl.id} className="template-picker-card"> 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"> <div className="template-picker-card-head">
<strong>{tpl.name}</strong> <strong className="template-picker-name">{tpl.name}</strong>
<span className="template-picker-count">{countKeywords(tpl.keywords)} keyword</span> <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> </div>
{tpl.description && <p className="template-picker-desc">{tpl.description}</p>}
<code className="template-picker-kw">{tpl.keywords}</code>
<button <button
type="button" type="button"
className="btn btn-primary btn-sm template-picker-apply" className="btn btn-primary btn-sm template-picker-apply"
@@ -114,16 +192,19 @@ export const AppTemplatePickerModal: React.FC<AppTemplatePickerModalProps> = ({
onClose(); onClose();
}} }}
> >
Áp dụng khung <IconCheck size={13} />
Áp dụng
</button> </button>
</div> </article>
))} );
})}
</div> </div>
)} )}
</div> </div>
<div className="app-pool-footer"> <div className="app-pool-footer">
Đang {countKeywords(allowedApps)} keyword trong whitelist hiện tại Whitelist hiện tại: <strong>{countKeywords(allowedApps)}</strong> keyword
{filtered.length > 0 ? ` · ${filtered.length} khung` : ''}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,653 @@
import React, { useEffect, useState } from 'react';
import { api, type AppDownloadItem, type AppGuideItem } from '../api';
import { useAuth } from '../auth/AuthContext';
// Icons
const IconDownload = () => (
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
);
const PlatformIcon = ({ platform }: { platform: string }) => {
const p = platform.toLowerCase();
if (p.includes('win')) {
return (
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" style={{ color: '#0078d7' }}>
<path d="M0 3.449L9.75 2.1v9.45H0V3.449zM0 12.45h9.75v9.45L0 20.551v-8.1zM10.8 1.95L24 0v11.55H10.8V1.95zM10.8 12.45H24v11.55l-13.2-1.95v-9.6z" />
</svg>
);
}
if (p.includes('mac') || p.includes('apple') || p.includes('ios')) {
return (
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" style={{ color: '#555555' }}>
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M15.97 4.17c.66-.81 1.11-1.93.99-3.05-1 .04-2.22.67-2.94 1.51-.62.73-1.16 1.87-1.01 2.97 1.12.09 2.27-.58 2.96-1.43z" />
</svg>
);
}
if (p.includes('linux')) {
return (
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" style={{ color: '#FCC624' }}>
<path d="M12 2c-3.3 0-6 2.7-6 6 0 2.2.8 4.2 2.2 5.6C6.5 15.6 5 18.5 5 22h14c0-3.5-1.5-6.4-3.2-8.4 1.4-1.4 2.2-3.4 2.2-5.6 0-3.3-2.7-6-6-6zm0 2c2.2 0 4 1.8 4 4 0 .9-.3 1.7-.8 2.3-.3.4-.7.7-1.2.9-.6.2-1.3.3-2 .3s-1.4-.1-2-.3c-.5-.2-.9-.5-1.2-.9-.5-.6-.8-1.4-.8-2.3 0-2.2 1.8-4 4-4z" />
</svg>
);
}
return (
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ color: 'var(--accent)' }}>
<circle cx="12" cy="12" r="10" />
<line x1="2" y1="12" x2="22" y2="12" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</svg>
);
};
export const ApplicationsTab: React.FC = () => {
const { staff } = useAuth();
const isSuperAdmin = staff?.email === 'phuocntb@rikkeiacademy.com';
const [activeSubTab, setActiveSubTab] = useState<'apps' | 'guides'>('apps');
// Apps state
const [apps, setApps] = useState<AppDownloadItem[]>([]);
const [appsLoading, setAppsLoading] = useState(true);
// Guides state
const [guides, setGuides] = useState<AppGuideItem[]>([]);
const [guidesLoading, setGuidesLoading] = useState(true);
// App Modal states
const [showAppModal, setShowAppModal] = useState(false);
const [editingApp, setEditingApp] = useState<AppDownloadItem | null>(null);
const [appName, setAppName] = useState('');
const [appDescription, setAppDescription] = useState('');
const [appDownloadUrl, setAppDownloadUrl] = useState('');
const [appPlatform, setAppPlatform] = useState('Windows');
const [appSaving, setAppSaving] = useState(false);
// Guide Modal states
const [showGuideModal, setShowGuideModal] = useState(false);
const [editingGuide, setEditingGuide] = useState<AppGuideItem | null>(null);
const [guideTitle, setGuideTitle] = useState('');
const [guideUrl, setGuideUrl] = useState('');
const [guideSaving, setGuideSaving] = useState(false);
const fetchApps = async () => {
try {
setAppsLoading(true);
const res = await api.listAppDownloads();
setApps(res.data || []);
} catch (err) {
console.error('Không thể tải danh sách ứng dụng:', err);
} finally {
setAppsLoading(false);
}
};
const fetchGuides = async () => {
try {
setGuidesLoading(true);
const res = await api.listAppGuides();
setGuides(res.data || []);
} catch (err) {
console.error('Không thể tải danh sách hướng dẫn sinh viên:', err);
} finally {
setGuidesLoading(false);
}
};
useEffect(() => {
fetchApps();
fetchGuides();
}, []);
// App Modal Handlers
const handleOpenAddApp = () => {
setEditingApp(null);
setAppName('');
setAppDescription('');
setAppDownloadUrl('');
setAppPlatform('Windows');
setShowAppModal(true);
};
const handleOpenEditApp = (app: AppDownloadItem) => {
setEditingApp(app);
setAppName(app.name);
setAppDescription(app.description);
setAppDownloadUrl(app.downloadUrl);
setAppPlatform(app.platform === 'macOS' ? 'MacOS' : app.platform);
setShowAppModal(true);
};
const handleSaveApp = async () => {
if (!appName.trim() || !appDownloadUrl.trim()) {
alert('Vui lòng nhập tên ứng dụng và đường dẫn tải.');
return;
}
let finalPlatform = appPlatform;
if (finalPlatform.toLowerCase() === 'macos') {
finalPlatform = 'MacOS';
}
try {
setAppSaving(true);
if (editingApp) {
await api.updateAppDownload(editingApp.id, appName.trim(), appDescription.trim(), appDownloadUrl.trim(), finalPlatform);
} else {
await api.createAppDownload(appName.trim(), appDescription.trim(), appDownloadUrl.trim(), finalPlatform);
}
setShowAppModal(false);
await fetchApps();
} catch (err: any) {
alert(err.message || 'Lưu ứng dụng thất bại');
} finally {
setAppSaving(false);
}
};
const handleDeleteApp = async (id: number, name: string) => {
if (window.confirm(`Bạn có chắc chắn muốn xóa liên kết tải cho ứng dụng "${name}" không?`)) {
try {
await api.deleteAppDownload(id);
await fetchApps();
} catch (err: any) {
alert(err.message || 'Xóa ứng dụng thất bại');
}
}
};
// Guide Modal Handlers
const handleOpenAddGuide = () => {
setEditingGuide(null);
setGuideTitle('');
setGuideUrl('');
setShowGuideModal(true);
};
const handleOpenEditGuide = (guide: AppGuideItem) => {
setEditingGuide(guide);
setGuideTitle(guide.title);
setGuideUrl(guide.url);
setShowGuideModal(true);
};
const handleSaveGuide = async () => {
if (!guideTitle.trim() || !guideUrl.trim()) {
alert('Vui lòng điền đầy đủ tiêu đề và đường dẫn hướng dẫn');
return;
}
try {
setGuideSaving(true);
if (editingGuide) {
await api.updateAppGuide(editingGuide.id, guideTitle.trim(), guideUrl.trim());
} else {
await api.createAppGuide(guideTitle.trim(), guideUrl.trim());
}
setShowGuideModal(false);
await fetchGuides();
} catch (err: any) {
alert(err.message || 'Lưu hướng dẫn thất bại');
} finally {
setGuideSaving(false);
}
};
const handleDeleteGuide = async (id: number, title: string) => {
if (window.confirm(`Bạn có chắc chắn muốn xóa tài liệu hướng dẫn sinh viên "${title}"?`)) {
try {
await api.deleteAppGuide(id);
await fetchGuides();
} catch (err: any) {
alert(err.message || 'Xóa hướng dẫn thất bại');
}
}
};
return (
<div className="tab-page">
<header className="page-header page-header--row">
<div>
<h1 className="page-title">ng Dụng & Hướng Dẫn</h1>
<p className="page-desc">Quản các công cụ, link tải ng dụng các tài liệu hướng dẫn cho sinh viên.</p>
</div>
{isSuperAdmin && (
<div>
{activeSubTab === 'apps' ? (
<button className="btn btn-primary" onClick={handleOpenAddApp}>
+ Thêm ng dụng
</button>
) : (
<button className="btn btn-primary" onClick={handleOpenAddGuide}>
+ Thêm tài liệu hướng dẫn
</button>
)}
</div>
)}
</header>
<div style={{ padding: '0 2rem' }}>
<div style={{
display: 'flex',
gap: '12px',
borderBottom: '1px solid var(--border-color)',
paddingBottom: '12px',
marginBottom: '1.5rem'
}}>
<button
type="button"
className={`btn ${activeSubTab === 'apps' ? 'btn-primary' : 'btn-ghost'}`}
onClick={() => setActiveSubTab('apps')}
style={{
padding: '0.6rem 1.5rem',
borderRadius: 'var(--radius-sm)',
fontWeight: 600,
fontSize: '0.9rem',
transition: 'var(--transition)'
}}
>
ng dụng tải về
</button>
<button
type="button"
className={`btn ${activeSubTab === 'guides' ? 'btn-primary' : 'btn-ghost'}`}
onClick={() => setActiveSubTab('guides')}
style={{
padding: '0.6rem 1.5rem',
borderRadius: 'var(--radius-sm)',
fontWeight: 600,
fontSize: '0.9rem',
transition: 'var(--transition)'
}}
>
Tài liệu & Video Hướng dẫn
</button>
</div>
</div>
<div className="tab-page-body tab-page-scroll" style={{ padding: '0 2rem 2rem 2rem' }}>
{activeSubTab === 'apps' && (
<div className="content-card" style={{ padding: '1.5rem', borderRadius: 'var(--radius-md)', boxShadow: 'var(--shadow-sm)' }}>
{appsLoading && (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.75rem', padding: '4rem 1.5rem' }}>
<div className="sync-spinner" style={{ width: '36px', height: '36px', borderWidth: '3px' }}></div>
<p style={{ color: 'var(--text-secondary)', fontSize: '0.9rem' }}>Đang tải danh sách ng dụng...</p>
</div>
)}
{!appsLoading && apps.length === 0 && (
<div style={{ textAlign: 'center', padding: '4rem 1.5rem', 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" style={{ marginBottom: '0.75rem' }}>
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M21 12H3M12 3v18" />
</svg>
<p style={{ fontSize: '0.95rem' }}>Chưa ng dụng nào đưc cấu hình tải.</p>
{isSuperAdmin && (
<button className="btn btn-secondary" onClick={handleOpenAddApp} style={{ marginTop: '1rem' }}>
Thêm ng dụng đu tiên
</button>
)}
</div>
)}
{!appsLoading && apps.length > 0 && (
<div className="table-responsive">
<table className="table" style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr>
<th style={{ padding: '1.15rem 1.5rem', width: '15%', fontWeight: 700, color: 'var(--text-secondary)', textAlign: 'left' }}>Nền tảng</th>
<th style={{ padding: '1.15rem 1.5rem', width: '25%', fontWeight: 700, color: 'var(--text-secondary)', textAlign: 'left' }}>ng dụng</th>
<th style={{ padding: '1.15rem 1.5rem', width: '35%', fontWeight: 700, color: 'var(--text-secondary)', textAlign: 'left' }}> tả</th>
<th style={{ padding: '1.15rem 1.5rem', width: '15%', fontWeight: 700, color: 'var(--text-secondary)', textAlign: 'left' }}>Đưng dẫn tải</th>
{isSuperAdmin && <th style={{ padding: '1.15rem 1.5rem', width: '10%', textAlign: 'right', fontWeight: 700, color: 'var(--text-secondary)' }}>Thao tác</th>}
</tr>
</thead>
<tbody>
{apps.map((app) => (
<tr key={app.id} style={{ borderBottom: '1px solid var(--border-color)' }}>
<td style={{ padding: '1.15rem 1.5rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<PlatformIcon platform={app.platform} />
<span style={{ fontWeight: 600, fontSize: '0.9rem', color: 'var(--text-primary)' }}>
{app.platform === 'macOS' ? 'MacOS' : app.platform}
</span>
</div>
</td>
<td style={{ padding: '1.15rem 1.5rem', fontWeight: 700, color: 'var(--text-primary)', fontSize: '0.95rem' }}>
{app.name}
</td>
<td style={{ padding: '1.15rem 1.5rem', color: 'var(--text-secondary)', fontSize: '0.9rem', lineHeight: '1.5' }}>
{app.description || '—'}
</td>
<td style={{ padding: '1.15rem 1.5rem' }}>
<a
href={app.downloadUrl}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
color: 'var(--accent)',
fontSize: '0.9rem',
textDecoration: 'none',
fontWeight: 600
}}
onMouseEnter={(e) => e.currentTarget.style.textDecoration = 'underline'}
onMouseLeave={(e) => e.currentTarget.style.textDecoration = 'none'}
>
<IconDownload />
Tải ng dụng
</a>
</td>
{isSuperAdmin && (
<td style={{ padding: '1.15rem 1.5rem', textAlign: 'right' }}>
<div style={{ display: 'inline-flex', gap: '6px' }}>
<button
className="btn btn-secondary btn-sm"
onClick={() => handleOpenEditApp(app)}
style={{ padding: '0.35rem 0.75rem', fontSize: '0.85rem' }}
>
Sửa
</button>
<button
className="btn btn-secondary btn-sm"
onClick={() => handleDeleteApp(app.id, app.name)}
style={{ padding: '0.35rem 0.75rem', fontSize: '0.85rem', color: 'var(--danger)', borderColor: 'rgba(214, 48, 49, 0.2)' }}
>
Xóa
</button>
</div>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{activeSubTab === 'guides' && (
<div className="content-card" style={{ padding: '1.5rem', borderRadius: 'var(--radius-md)', boxShadow: 'var(--shadow-sm)' }}>
{guidesLoading && (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.75rem', padding: '4rem 1.5rem' }}>
<div className="sync-spinner" style={{ width: '36px', height: '36px', borderWidth: '3px' }}></div>
<p style={{ color: 'var(--text-secondary)', fontSize: '0.9rem' }}>Đang tải danh sách tài liệu hướng dẫn...</p>
</div>
)}
{!guidesLoading && guides.length === 0 && (
<div style={{ textAlign: 'center', padding: '4rem 1.5rem', 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" style={{ marginBottom: '0.75rem' }}>
<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>
<p style={{ fontSize: '0.95rem' }}>Chưa tài liệu hướng dẫn sinh viên nào.</p>
{isSuperAdmin && (
<button className="btn btn-secondary" onClick={handleOpenAddGuide} style={{ marginTop: '1rem' }}>
Thêm hướng dẫn sinh viên đu tiên
</button>
)}
</div>
)}
{!guidesLoading && guides.length > 0 && (
<div className="table-responsive">
<table className="table" style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr>
<th style={{ padding: '1.15rem 1.5rem', width: '15%', fontWeight: 700, color: 'var(--text-secondary)', textAlign: 'left' }}>Loại tài liệu</th>
<th style={{ padding: '1.15rem 1.5rem', width: '50%', fontWeight: 700, color: 'var(--text-secondary)', textAlign: 'left' }}>Tiêu đ hướng dẫn sinh viên</th>
<th style={{ padding: '1.15rem 1.5rem', width: '25%', fontWeight: 700, color: 'var(--text-secondary)', textAlign: 'left' }}>Đưng dẫn liên kết</th>
{isSuperAdmin && <th style={{ padding: '1.15rem 1.5rem', width: '10%', textAlign: 'right', fontWeight: 700, color: 'var(--text-secondary)' }}>Thao tác</th>}
</tr>
</thead>
<tbody>
{guides.map((guide) => {
const isVideo = guide.url.toLowerCase().includes('youtube.com') || guide.url.toLowerCase().includes('youtu.be') || guide.url.toLowerCase().includes('drive.google.com/file');
return (
<tr key={guide.id} style={{ borderBottom: '1px solid var(--border-color)' }}>
<td style={{ padding: '1.15rem 1.5rem' }}>
<span className="platform-badge" style={{ backgroundColor: isVideo ? '#ffebee' : '#e3f2fd', color: isVideo ? '#d32f2f' : '#1976d2', padding: '0.3rem 0.6rem', fontSize: '0.75rem', borderRadius: '4px', fontWeight: 700 }}>
{isVideo ? 'VIDEO' : 'TÀI LIỆU'}
</span>
</td>
<td style={{ padding: '1.15rem 1.5rem', fontWeight: 700, color: 'var(--text-primary)', fontSize: '0.95rem' }}>
{guide.title}
</td>
<td style={{ padding: '1.15rem 1.5rem' }}>
<a
href={guide.url}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
color: 'var(--accent)',
fontSize: '0.9rem',
textDecoration: 'none',
fontWeight: 600
}}
onMouseEnter={(e) => e.currentTarget.style.textDecoration = 'underline'}
onMouseLeave={(e) => e.currentTarget.style.textDecoration = 'none'}
>
Xem liên kết &rarr;
</a>
</td>
{isSuperAdmin && (
<td style={{ padding: '1.15rem 1.5rem', textAlign: 'right' }}>
<div style={{ display: 'inline-flex', gap: '6px' }}>
<button
className="btn btn-secondary btn-sm"
onClick={() => handleOpenEditGuide(guide)}
style={{ padding: '0.35rem 0.75rem', fontSize: '0.85rem' }}
>
Sửa
</button>
<button
className="btn btn-secondary btn-sm"
onClick={() => handleDeleteGuide(guide.id, guide.title)}
style={{ padding: '0.35rem 0.75rem', fontSize: '0.85rem', color: 'var(--danger)', borderColor: 'rgba(214, 48, 49, 0.2)' }}
>
Xóa
</button>
</div>
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
{/* App Modal */}
{showAppModal && (
<div className="modal-overlay" onClick={() => setShowAppModal(false)}>
<div className="modal-container" style={{ maxWidth: '480px' }} onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2 className="modal-title">{editingApp ? 'Cập nhật ứng dụng' : 'Thêm ứng dụng tải'}</h2>
<button className="modal-close-btn" onClick={() => setShowAppModal(false)} aria-label="Đóng">&times;</button>
</div>
<div className="modal-body" style={{ padding: '1.5rem' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
<label className="login-field" style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
<span style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--text-secondary)' }}>Tên ng dụng</span>
<input
type="text"
value={appName}
onChange={e => setAppName(e.target.value)}
placeholder="VD: Simple Care Client v1.2"
style={{
width: '100%',
padding: '0.6rem 0.85rem',
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--border-color)',
fontSize: '0.9rem',
outline: 'none',
transition: 'var(--transition)'
}}
onFocus={e => e.target.style.borderColor = 'var(--accent)'}
onBlur={e => e.target.style.borderColor = 'var(--border-color)'}
/>
</label>
<label className="login-field" style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
<span style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--text-secondary)' }}>Nền tảng</span>
<select
value={appPlatform}
onChange={e => setAppPlatform(e.target.value)}
style={{
width: '100%',
padding: '0.6rem 0.85rem',
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--border-color)',
fontSize: '0.9rem',
outline: 'none',
backgroundColor: '#fff',
transition: 'var(--transition)'
}}
onFocus={e => e.target.style.borderColor = 'var(--accent)'}
onBlur={e => e.target.style.borderColor = 'var(--border-color)'}
>
<option value="Windows">Windows</option>
<option value="MacOS">MacOS</option>
<option value="Linux">Linux</option>
<option value="Khác">Khác</option>
</select>
</label>
<label className="login-field" style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
<span style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--text-secondary)' }}> tả ngắn</span>
<input
type="text"
value={appDescription}
onChange={e => setAppDescription(e.target.value)}
placeholder="VD: Dành cho máy Windows 64-bit"
style={{
width: '100%',
padding: '0.6rem 0.85rem',
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--border-color)',
fontSize: '0.9rem',
outline: 'none',
transition: 'var(--transition)'
}}
onFocus={e => e.target.style.borderColor = 'var(--accent)'}
onBlur={e => e.target.style.borderColor = 'var(--border-color)'}
/>
</label>
<label className="login-field" style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
<span style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--text-secondary)' }}>Đưng dẫn tải</span>
<input
type="url"
value={appDownloadUrl}
onChange={e => setAppDownloadUrl(e.target.value)}
placeholder="https://..."
style={{
width: '100%',
padding: '0.6rem 0.85rem',
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--border-color)',
fontSize: '0.9rem',
outline: 'none',
transition: 'var(--transition)'
}}
onFocus={e => e.target.style.borderColor = 'var(--accent)'}
onBlur={e => e.target.style.borderColor = 'var(--border-color)'}
/>
</label>
</div>
</div>
<div className="modal-footer" style={{ padding: '1rem 1.5rem' }}>
<button className="btn btn-secondary" onClick={() => setShowAppModal(false)}>Hủy</button>
<button
className="btn btn-primary"
onClick={handleSaveApp}
disabled={appSaving}
>
{appSaving ? 'Đang lưu...' : 'Lưu lại'}
</button>
</div>
</div>
</div>
)}
{/* Guide Modal */}
{showGuideModal && (
<div className="modal-overlay" onClick={() => setShowGuideModal(false)}>
<div className="modal-container" style={{ maxWidth: '480px' }} onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2 className="modal-title">{editingGuide ? 'Cập nhật hướng dẫn sinh viên' : 'Thêm tài liệu hướng dẫn sinh viên'}</h2>
<button className="modal-close-btn" onClick={() => setShowGuideModal(false)} aria-label="Đóng">&times;</button>
</div>
<div className="modal-body" style={{ padding: '1.5rem' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
<label className="login-field" style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
<span style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--text-secondary)' }}>Tiêu đ hướng dẫn</span>
<input
type="text"
value={guideTitle}
onChange={e => setGuideTitle(e.target.value)}
placeholder="VD: Hướng dẫn cài đặt và chạy ứng dụng cho sinh viên"
style={{
width: '100%',
padding: '0.6rem 0.85rem',
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--border-color)',
fontSize: '0.9rem',
outline: 'none',
transition: 'var(--transition)'
}}
onFocus={e => e.target.style.borderColor = 'var(--accent)'}
onBlur={e => e.target.style.borderColor = 'var(--border-color)'}
/>
</label>
<label className="login-field" style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
<span style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--text-secondary)' }}>Đưng dẫn hướng dẫn (Google Docs / Video Youtube)</span>
<input
type="url"
value={guideUrl}
onChange={e => setGuideUrl(e.target.value)}
placeholder="https://docs.google.com/document/d/... hoặc https://youtube.com/..."
style={{
width: '100%',
padding: '0.6rem 0.85rem',
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--border-color)',
fontSize: '0.9rem',
outline: 'none',
transition: 'var(--transition)'
}}
onFocus={e => e.target.style.borderColor = 'var(--accent)'}
onBlur={e => e.target.style.borderColor = 'var(--border-color)'}
/>
</label>
</div>
</div>
<div className="modal-footer" style={{ padding: '1rem 1.5rem' }}>
<button className="btn btn-secondary" onClick={() => setShowGuideModal(false)}>Hủy</button>
<button
className="btn btn-primary"
onClick={handleSaveGuide}
disabled={guideSaving}
>
{guideSaving ? 'Đang lưu...' : 'Lưu lại'}
</button>
</div>
</div>
</div>
)}
</div>
);
};

View File

@@ -9,10 +9,106 @@ import {
attendanceStatusClass, attendanceStatusClass,
type AttendanceRow, type AttendanceRow,
type AttendanceShiftInfo, type AttendanceShiftInfo,
apiFetchLeaveRequests,
apiUpdateLeaveStatus,
type LeaveRequestItem,
} from '../api'; } 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 { interface AttendancePanelProps {
classId: number; classId: number;
onClose?: () => void;
} }
function formatQldtTime(iso?: string): string { function formatQldtTime(iso?: string): string {
@@ -25,7 +121,9 @@ function formatQldtTime(iso?: string): string {
}); });
} }
export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) => { /* ─── Component ─────────────────────────────────────────────────────────── */
export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId, onClose }) => {
const today = new Date().toISOString().slice(0, 10); const today = new Date().toISOString().slice(0, 10);
const [date, setDate] = useState(today); const [date, setDate] = useState(today);
const [period, setPeriod] = useState(1); const [period, setPeriod] = useState(1);
@@ -36,6 +134,15 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
const [pushing, setPushing] = useState(false); const [pushing, setPushing] = useState(false);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [selectedStudentRkIds, setSelectedStudentRkIds] = useState<number[]>([]); const [selectedStudentRkIds, setSelectedStudentRkIds] = useState<number[]>([]);
const [isCollapsed, setIsCollapsed] = useState(false);
const [leaveRequests, setLeaveRequests] = useState<LeaveRequestItem[]>([]);
const [loadingLeave, setLoadingLeave] = useState(false);
const [showLeaveModal, setShowLeaveModal] = useState(false);
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
const scrollTop = e.currentTarget.scrollTop;
setIsCollapsed(scrollTop > 20);
}, []);
const loadShifts = useCallback(async () => { const loadShifts = useCallback(async () => {
try { try {
@@ -44,79 +151,109 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
if (res.data?.length && !res.data.find((s: any) => s.period === period)) { if (res.data?.length && !res.data.find((s: any) => s.period === period)) {
setPeriod(res.data[0].period || 1); setPeriod(res.data[0].period || 1);
} }
} catch { } catch { setShifts([]); }
setShifts([]);
}
}, [classId, date, period]); }, [classId, date, period]);
const loadAttendance = useCallback(async () => { const loadAttendance = useCallback(async (isBackground: boolean | any = false) => {
const isBg = isBackground === true;
try { try {
setLoading(true); if (!isBg) setLoading(true);
const res = await apiFetchAttendance(classId, date, period); const res = await apiFetchAttendance(classId, date, period);
setRows(res.data || []); setRows(res.data || []);
setShiftInfo(res.shift || null); setShiftInfo(res.shift || null);
setSelectedStudentRkIds([]); // Clear selection when data changes if (!isBg) setSelectedStudentRkIds([]);
} catch (e: any) { } catch (e: any) {
alert(e.message || 'Không tải được điểm danh'); alert(e.message || 'Không tải được điểm danh');
} finally { } finally {
setLoading(false); if (!isBg) setLoading(false);
} }
}, [classId, date, period]); }, [classId, date, period]);
const currentShift = useMemo(() => shifts.find(s => s.period === period), [shifts, period]);
const courseId = currentShift?.courseId;
const loadLeaveRequests = useCallback(async () => {
if (!courseId) { setLeaveRequests([]); return; }
try {
setLoadingLeave(true);
const res = await apiFetchLeaveRequests(classId, courseId, date);
setLeaveRequests(res || []);
} catch { setLeaveRequests([]); } finally { setLoadingLeave(false); }
}, [classId, courseId, date]);
useEffect(() => { loadShifts(); }, [loadShifts]); useEffect(() => { loadShifts(); }, [loadShifts]);
useEffect(() => { loadAttendance(); }, [loadAttendance]); useEffect(() => { loadAttendance(); }, [loadAttendance]);
useEffect(() => { loadLeaveRequests(); }, [loadLeaveRequests]);
const handleUpdateLeaveStatus = async (leaveId: number, status: string, studentRkId: number) => {
try {
await apiUpdateLeaveStatus(classId, leaveId, { status, studentRkId, date, period });
await loadAttendance();
await loadLeaveRequests();
} catch (e: any) {
alert(e.message || 'Cập nhật đơn xin nghỉ thất bại');
}
};
const filteredRows = useMemo(() => { const filteredRows = useMemo(() => {
const q = searchQuery.trim().toLowerCase(); const q = searchQuery.trim().toLowerCase();
if (!q) return rows; if (!q) return rows;
return rows.filter(row => { return rows.filter(row => {
const haystack = [row.fullName, row.studentCode, row.email, row.statusLabel] const haystack = [row.fullName, row.studentCode, row.email, row.statusLabel].filter(Boolean).join(' ').toLowerCase();
.filter(Boolean)
.join(' ')
.toLowerCase();
return haystack.includes(q); return haystack.includes(q);
}); });
}, [rows, searchQuery]); }, [rows, searchQuery]);
const statusCounts = useMemo(() => { const statusCounts = useMemo(() => {
const counts: Record<number, number> = { 0: 0, 1: 0, 2: 0, 3: 0, 4: 0 }; const counts: Record<number, number> = { 0: 0, 1: 0, 2: 0, 3: 0, 4: 0 };
for (const row of rows) { for (const row of rows) { if (counts[row.status] !== undefined) counts[row.status]++; }
if (counts[row.status] !== undefined) counts[row.status]++;
}
return counts; return counts;
}, [rows]); }, [rows]);
const toggleStudentSelection = useCallback((studentRkId: number) => { const toggleStudentSelection = (studentRkId: number) => {
setSelectedStudentRkIds(prev => setSelectedStudentRkIds(prev =>
prev.includes(studentRkId) ? prev.filter(id => id !== studentRkId) : [...prev, studentRkId] prev.includes(studentRkId) ? prev.filter(id => id !== studentRkId) : [...prev, studentRkId]
); );
}, []); };
const handleStatusChange = async (studentRkId: number, status: number) => { const handleStatusChange = async (studentRkId: number, status: number) => {
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;
})
);
try { try {
await apiUpdateAttendanceStatus(classId, { date, period, studentRkId, status }); await apiUpdateAttendanceStatus(classId, { date, period, studentRkId, status });
await loadAttendance(); await loadAttendance(true);
} catch (e: any) { } catch (e: any) {
alert(e.message || 'Cập nhật trạng thái thất bại'); alert(e.message || 'Cập nhật trạng thái thất bại');
await loadAttendance();
} }
}; };
const handleBulkStatusChange = async (status: number) => { const handleBulkStatusChange = async (status: number) => {
if (selectedStudentRkIds.length === 0) return; if (selectedStudentRkIds.length === 0) return;
try { setRows(prevRows =>
setLoading(true); prevRows.map(row => {
await apiUpdateAttendanceBulkStatus(classId, { if (selectedStudentRkIds.includes(row.studentRkId)) {
date, const matchedOpt = ATTENDANCE_STATUS_OPTIONS.find(o => o.value === status);
period, return { ...row, status, statusLabel: matchedOpt ? matchedOpt.label : row.statusLabel, statusEditedByTeacher: true };
studentRkIds: selectedStudentRkIds, }
status, return row;
}); })
);
const idsToUpdate = [...selectedStudentRkIds];
setSelectedStudentRkIds([]); setSelectedStudentRkIds([]);
await loadAttendance(); try {
await apiUpdateAttendanceBulkStatus(classId, { date, period, studentRkIds: idsToUpdate, status });
await loadAttendance(true);
} catch (e: any) { } catch (e: any) {
alert(e.message || 'Cập nhật hàng loạt thất bại'); alert(e.message || 'Cập nhật hàng loạt thất bại');
} finally { await loadAttendance();
setLoading(false);
} }
}; };
@@ -133,151 +270,343 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
await loadAttendance(); await loadAttendance();
} catch (e: any) { } catch (e: any) {
alert(e.message || 'Đẩy QLĐT thất bại'); alert(e.message || 'Đẩy QLĐT thất bại');
} finally { } finally { setPushing(false); }
setPushing(false);
}
}; };
const currentShift = shifts.find(s => s.period === period);
const qldtSynced = Boolean(shiftInfo?.pushedToQldtAt); const qldtSynced = Boolean(shiftInfo?.pushedToQldtAt);
const qldtDirty = Boolean(shiftInfo?.qldtDirty); const qldtDirty = Boolean(shiftInfo?.qldtDirty);
const hasPendingLeave = leaveRequests.some(r => r.status === 'Đang chờ');
return ( return (
<div className="attendance-panel"> <div className="ap-panel">
<div className="attendance-toolbar"> <style>{`
<label className="attendance-field"> .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="ap-btn"
onClick={() => setIsCollapsed(!isCollapsed)}
>
{isCollapsed ? <><IconChevronDown size={14} /> Chi tiết</> : <><IconChevronUp size={14} /> Thu gọn</>}
</button>
<button
type="button"
className="ap-btn"
onClick={() => loadAttendance(false)}
disabled={loading}
>
<IconRefresh size={14} />
Tải lại
</button>
{courseId && (
<button
type="button"
className="ap-btn"
onClick={() => setShowLeaveModal(true)}
>
<IconMail size={14} />
Đơn phép
{leaveRequests.length > 0 && (
<span className={`ap-btn--leave-badge${hasPendingLeave ? '' : ' muted'}`}>
{leaveRequests.length}
</span>
)}
</button>
)}
<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>
{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> <span>Ngày</span>
<input <input type="date" className="ap-input search-input" value={date} onChange={e => setDate(e.target.value)} />
type="date"
className="search-input"
style={{ padding: '0.5rem 0.75rem' }}
value={date}
onChange={e => setDate(e.target.value)}
/>
</label> </label>
<label className="attendance-field"> <label className="ap-field">
<span>Ca học</span> <span>Ca học</span>
<select className="select-filter" value={period} onChange={e => setPeriod(Number(e.target.value))}> <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) => ( {(shifts.length ? shifts : [{ period: 1 }, { period: 2 }, { period: 3 }, { period: 4 }]).map((s: any) => (
<option key={s.period} value={s.period}> <option key={s.period} value={s.period}>Ca {s.period}{s.startTime ? ` (${s.startTime}${s.endTime})` : ''}</option>
Ca {s.period}{s.startTime ? ` (${s.startTime}${s.endTime})` : ''}
</option>
))} ))}
</select> </select>
</label> </label>
<label className="attendance-field attendance-field--grow"> <label className="ap-field ap-field--grow">
<span>Tìm sinh viên</span> <span>Tìm sinh viên</span>
<input <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)} />
type="search"
className="app-pool-search attendance-search"
placeholder="Mã SV, tên, email..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
/>
</label> </label>
<button type="button" className="btn btn-secondary" onClick={loadAttendance} disabled={loading}>
Tải lại
</button>
<button type="button" className="btn btn-primary" onClick={handlePushQLDT} disabled={pushing || rows.length === 0}>
{pushing ? 'Đang đẩy...' : 'Đẩy QLĐT'}
</button>
</div> </div>
<div {/* QLĐT sync status banner */}
className={`attendance-qldt-banner${ <div className={`ap-qldt-banner${qldtSynced ? (qldtDirty ? ' ap-qldt-banner--stale' : ' ap-qldt-banner--synced') : ''}`}>
qldtSynced ? (qldtDirty ? ' attendance-qldt-banner--stale' : ' attendance-qldt-banner--synced') : '' {qldtSynced
}`} ? qldtDirty
> ? <><strong>Đã lưu QLĐT thay đi mới</strong><span>Lần đy: {formatQldtTime(shiftInfo?.pushedToQldtAt)} · Cần đy lại</span></>
{qldtSynced ? ( : <><strong>Đã lưu QLĐT</strong><span>Lần đy: {formatQldtTime(shiftInfo?.pushedToQldtAt)}</span></>
qldtDirty ? ( : <><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></>
<> }
<strong>Đã lưu QLĐT thay đi mới</strong>
<span>
Lần đy: {formatQldtTime(shiftInfo?.pushedToQldtAt)} · Cần đy lại sau khi chỉnh sửa
</span>
</>
) : (
<>
<strong>Đã lưu QLĐT</strong>
<span>Lần đy gần nhất: {formatQldtTime(shiftInfo?.pushedToQldtAt)}</span>
</>
)
) : (
<>
<strong>Chưa đy lên QLĐT</strong>
<span>Ca {period} ngày {date} bấm &quot;Đy QLĐT&quot; sau khi kiểm tra trạng thái</span>
</>
)}
</div> </div>
{/* Shift info strip */}
{currentShift && ( {currentShift && (
<div className="attendance-shift-info"> <div className="ap-shift-info">
<span>{currentShift.startTime}{currentShift.endTime}</span> <span>{currentShift.startTime}{currentShift.endTime}</span>
<span>{currentShift.courseName || 'Chưa chọn môn'}</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>
)} )}
<div className="attendance-status-summary"> {/* Status summary chips */}
<div className="ap-chips">
{ATTENDANCE_STATUS_OPTIONS.map(opt => ( {ATTENDANCE_STATUS_OPTIONS.map(opt => (
<button <button key={opt.value} type="button" className={`ap-chip attendance-summary-chip ${attendanceStatusClass(opt.value)}`} onClick={() => setSearchQuery('')}>
key={opt.value} <span>{opt.short}</span>
type="button" <span className="ap-chip-count">{statusCounts[opt.value] ?? 0}</span>
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> </button>
))} ))}
<span className="attendance-summary-total"> <span className="ap-chips-total">{filteredRows.length}/{rows.length} SV{searchQuery.trim() ? ' (đã lọc)' : ''}</span>
{filteredRows.length}/{rows.length} SV
{searchQuery.trim() ? ' (đã lọc)' : ''}
</span>
</div> </div>
<p className="schedule-hint" style={{ margin: 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 đè. Sửa trạng thái thủ công sẽ đưc khóa hệ thống tự tính sẽ không ghi đè.
</p> </p>
{selectedStudentRkIds.length > 0 && ( {/* Notice banner — replaces yellow emoji block */}
<div className="attendance-bulk-actions"> <div className="ap-notice ap-notice--amber">
<span className="attendance-bulk-title"> <IconInfo size={15} />
Đang chọn {selectedStudentRkIds.length} sinh viên: <span>
<strong>Lưu ý:</strong> Hệ thống hiện tại chỉ ghi nhận điểm danh học tập nội bộ {' '}
<strong>KHÔNG tự đng lưu lên QLĐT</strong>. Thầy vui lòng kiểm tra kỹ trạng thái của sinh viên,
sau đó bấm nút <strong>Đy QLĐT</strong> góc phải bên trên đ đng bộ điểm danh chính thức.
</span> </span>
<div className="attendance-bulk-btns"> </div>
{ATTENDANCE_STATUS_OPTIONS.map(opt => ( </div>
{/* ── 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, 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="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' }}>
{/* 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 }} />
</div>
) : leaveRequests.length === 0 ? (
<div style={{ textAlign: 'center', padding: '2rem 0', color: 'var(--text-muted)', fontSize: '0.85rem', fontStyle: 'italic' }}>
Không đơn xin nghỉ phép nào cho ca học này trong ngày hôm nay.
</div>
) : (
leaveRequests.map((req) => (
<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={`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 <button
key={opt.value}
type="button" type="button"
className={`btn btn-secondary ${attendanceStatusClass(opt.value)}`} className="ap-btn ap-btn--primary"
style={{ padding: '0.25rem 0.75rem', fontSize: '0.875rem', border: '1px solid var(--att-border)' }} style={{ background: 'var(--success)', borderColor: 'var(--success)' }}
onClick={() => handleBulkStatusChange(opt.value)} 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); }}
> >
Gắn &quot;{opt.label}&quot; <IconCheck size={13} /> Phê duyệt
</button>
<button
type="button"
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); }}
>
<IconXMark size={13} /> Từ chối
</button>
</div>
)}
</div>
<div className="ap-leave-card-reason">
<strong> do nghỉ:</strong> {req.note || 'Không có ghi chú'}
</div>
{req.reasonImage && (
<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>
)}
</div>
))
)}
</div>
</div>
</div>
)}
{/* ── Bulk status actions bar ── */}
{selectedStudentRkIds.length > 0 && (
<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={`ap-btn ${attendanceStatusClass(opt.value)}`} style={{ border: '1px solid var(--att-border)' }} onClick={() => handleBulkStatusChange(opt.value)}>
Gắn "{opt.label}"
</button> </button>
))} ))}
</div> </div>
<button <button type="button" className="ap-btn" style={{ marginLeft: 'auto' }} onClick={() => setSelectedStudentRkIds([])}>
type="button"
className="btn btn-muted attendance-bulk-btn-close"
style={{ padding: '0.25rem 0.75rem', fontSize: '0.875rem' }}
onClick={() => setSelectedStudentRkIds([])}
>
Hủy chọn Hủy chọn
</button> </button>
</div> </div>
)} )}
<div className="attendance-table-scroll table-wrapper"> {/* ── Attendance table ── */}
<div className="ap-table-scroll attendance-table-scroll table-wrapper" onScroll={handleScroll}>
{loading ? ( {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> <thead>
<tr> <tr>
<th style={{ width: '45px', textAlign: 'center' }}> <th>
<input <input
type="checkbox" type="checkbox"
checked={filteredRows.length > 0 && filteredRows.every(r => selectedStudentRkIds.includes(r.studentRkId))} checked={filteredRows.length > 0 && filteredRows.every(r => selectedStudentRkIds.includes(r.studentRkId))}
@@ -302,7 +631,7 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
<tbody> <tbody>
{filteredRows.length === 0 ? ( {filteredRows.length === 0 ? (
<tr> <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'} {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> </td>
</tr> </tr>
@@ -310,60 +639,38 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
filteredRows.map(row => { filteredRows.map(row => {
const isSelected = selectedStudentRkIds.includes(row.studentRkId); const isSelected = selectedStudentRkIds.includes(row.studentRkId);
return ( return (
<tr key={row.studentRkId} className={attendanceStatusClass(row.status)}> <tr key={row.studentRkId} className={attendanceStatusClass(row.status)} style={{ background: isSelected ? 'rgba(var(--accent-rgb, 99 102 241) / 0.05)' : undefined }}>
<td <td>
style={{ textAlign: 'center', cursor: 'pointer' }} <input type="checkbox" checked={isSelected} onChange={() => toggleStudentSelection(row.studentRkId)} />
onClick={() => toggleStudentSelection(row.studentRkId)}
>
<input
type="checkbox"
checked={isSelected}
onChange={() => {}}
onClick={(e) => {
e.stopPropagation();
toggleStudentSelection(row.studentRkId);
}}
/>
</td> </td>
<td <td style={{ cursor: 'pointer' }} onClick={() => toggleStudentSelection(row.studentRkId)}>
style={{ cursor: 'pointer' }} <div className="attendance-student-name" style={{ fontWeight: 700, fontSize: '0.85rem' }}>{row.fullName}</div>
onClick={() => toggleStudentSelection(row.studentRkId)} <div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem 0.5rem', fontSize: '0.7rem', color: 'var(--text-muted)', marginTop: '1px' }}>
>
<div className="attendance-student-name">{row.fullName}</div>
<div className="attendance-student-meta">
<code>{row.studentCode}</code> <code>{row.studentCode}</code>
{row.email && <span>{row.email}</span>} {row.email && <span>{row.email}</span>}
</div> </div>
</td> </td>
<td> <td>
<span className="attendance-online-mins">{row.onlineMinutes}</span> <span style={{ fontFamily: 'monospace', fontWeight: 800, fontSize: '0.95rem' }}>{row.onlineMinutes}</span>
<span className="attendance-online-unit">phút</span> <span style={{ fontSize: '0.65rem', color: 'var(--text-muted)', marginLeft: '2px' }}>phút</span>
</td> </td>
<td> <td>
<select <select
className={`attendance-status-select ${attendanceStatusClass(row.status)}`} 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} value={row.status}
onChange={e => handleStatusChange(row.studentRkId, Number(e.target.value))} onChange={e => handleStatusChange(row.studentRkId, Number(e.target.value))}
> >
{ATTENDANCE_STATUS_OPTIONS.map(opt => ( {ATTENDANCE_STATUS_OPTIONS.map(opt => (<option key={opt.value} value={opt.value}>{opt.label}</option>))}
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select> </select>
</td> </td>
<td> <td>
<div className="attendance-notes"> <div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.2rem' }}>
{row.pushedToQldtAt ? ( {row.pushedToQldtAt
<span className="attendance-qldt-tag attendance-qldt-tag--ok" title={row.pushedToQldtAt}> ? <span className="ap-tag ap-tag--ok">QLĐT <IconSync size={10} /></span>
QLĐT : <span className="ap-tag ap-tag--pending">Chưa QLĐT</span>
</span> }
) : ( {row.statusEditedByTeacher && <span className="ap-tag ap-tag--locked">Đã khóa</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> </div>
</td> </td>
</tr> </tr>

View File

@@ -3,7 +3,7 @@ import { createPortal } from 'react-dom';
import { apiChat, type ChatConversation, type ChatMessage, type ChatStudent } from '../api'; import { apiChat, type ChatConversation, type ChatMessage, type ChatStudent } from '../api';
import { type StaffChatOpenDetail } from '../chatEvents'; import { type StaffChatOpenDetail } from '../chatEvents';
import { useAuth } from '../auth/AuthContext'; import { useAuth } from '../auth/AuthContext';
import { onStaffChatMessage, playChatSound } from '../hooks/useStaffChatSocket'; import { onStaffChatMessage, onStudentViolation, playChatSound, kindLabel } from '../hooks/useStaffChatSocket';
export function ChatWidget() { export function ChatWidget() {
const { staff } = useAuth(); const { staff } = useAuth();
@@ -83,6 +83,13 @@ export function ChatWidget() {
return () => { off(); }; return () => { off(); };
}, [handleIncoming]); }, [handleIncoming]);
useEffect(() => {
return onStudentViolation((v) => {
const name = v.studentName || v.studentCode || `SV #${v.studentId}`;
showToast(`${kindLabel(v.kind)}`, `${name}: ${v.reason || 'Vi phạm giám sát'}`);
});
}, []);
useEffect(() => { useEffect(() => {
const onOpen = (e: Event) => { const onOpen = (e: Event) => {
const detail = (e as CustomEvent<StaffChatOpenDetail>).detail; const detail = (e as CustomEvent<StaffChatOpenDetail>).detail;
@@ -152,7 +159,7 @@ export function ChatWidget() {
const dock = ( const dock = (
<div className="chat-dock"> <div className="chat-dock">
{toast && ( {toast && (
<div className="chat-toast" role="status"> <div className={`chat-toast ${toast.title.startsWith('⚠') ? 'chat-toast-alert' : ''}`} role="status">
<strong>{toast.title}</strong> <strong>{toast.title}</strong>
<span>{toast.body}</span> <span>{toast.body}</span>
</div> </div>

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,85 @@ import { api } from '../api';
import type { ClassItem, SyncStatus } from '../api'; import type { ClassItem, SyncStatus } from '../api';
import { openClass } from './NavHistoryBar'; 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 = () => { export const ClassesTab: React.FC = () => {
const [classes, setClasses] = useState<ClassItem[]>([]); const [classes, setClasses] = useState<ClassItem[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
@@ -12,7 +91,6 @@ export const ClassesTab: React.FC = () => {
const [systemFilter, setSystemFilter] = useState<number | undefined>(undefined); const [systemFilter, setSystemFilter] = useState<number | undefined>(undefined);
const [studyingOnly, setStudyingOnly] = useState(false); const [studyingOnly, setStudyingOnly] = useState(false);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
// Sync state
const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null); const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null);
const fetchClasses = async () => { const fetchClasses = async () => {
@@ -49,12 +127,10 @@ export const ClassesTab: React.FC = () => {
fetchClasses(); fetchClasses();
}, [page, search, systemFilter, studyingOnly]); }, [page, search, systemFilter, studyingOnly]);
// Check sync status on load and start polling if running
useEffect(() => { useEffect(() => {
fetchSyncStatus(); fetchSyncStatus();
}, []); }, []);
// Sync polling logic
useEffect(() => { useEffect(() => {
let timer: any; let timer: any;
if (syncStatus?.running) { if (syncStatus?.running) {
@@ -62,7 +138,7 @@ export const ClassesTab: React.FC = () => {
const isRunning = await fetchSyncStatus(); const isRunning = await fetchSyncStatus();
if (!isRunning) { if (!isRunning) {
clearInterval(timer); clearInterval(timer);
fetchClasses(); // Reload data when sync completes fetchClasses();
} }
}, 2000); }, 2000);
} }
@@ -74,13 +150,12 @@ export const ClassesTab: React.FC = () => {
const handleStartSync = async () => { const handleStartSync = async () => {
try { try {
await api.startClassesSync(); await api.startClassesSync();
// Set local state to running to trigger useEffect poller
setSyncStatus({ setSyncStatus({
running: true, running: true,
done: false, done: false,
total: 0, total: 0,
synced: 0, synced: 0,
updatedAt: Date.now() / 1000 updatedAt: Date.now() / 1000,
}); });
} catch (err: any) { } catch (err: any) {
alert(err.message || 'Không thể bắt đầu đồng bộ lớp học'); alert(err.message || 'Không thể bắt đầu đồng bộ lớp học');
@@ -90,56 +165,85 @@ export const ClassesTab: React.FC = () => {
const handleToggleStudying = async (cItem: ClassItem) => { const handleToggleStudying = async (cItem: ClassItem) => {
const nextVal = !cItem.isStudying; const nextVal = !cItem.isStudying;
try { try {
// Optimistic update setClasses(prev =>
setClasses(prev => prev.map(c => c.rkId === cItem.rkId ? { ...c, isStudying: nextVal } : c)); prev.map(c => (c.rkId === cItem.rkId ? { ...c, isStudying: nextVal } : c))
);
await api.updateClassStudying(cItem.rkId, nextVal); await api.updateClassStudying(cItem.rkId, nextVal);
} catch (err: any) { } catch (err: any) {
// Revert if error setClasses(prev =>
setClasses(prev => prev.map(c => c.rkId === cItem.rkId ? { ...c, isStudying: !nextVal } : c)); 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'); 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 =
const syncPercent = syncStatus && syncStatus.total > 0 syncStatus && syncStatus.total > 0
? Math.round((syncStatus.synced / syncStatus.total) * 100) ? Math.round((syncStatus.synced / syncStatus.total) * 100)
: 0; : 0;
const totalPages = Math.ceil(total / pageSize) || 1;
return ( return (
<div className="tab-page"> <div className="tab-page">
<div className="tab-page-toolbar"> <div className="tab-page-toolbar">
<div className="page-header"> <div className="page-header">
<div className="page-title"> <div className="page-title">
<h1>Quản Lớp Học</h1> <h1 className="page-title-heading">
<span className="page-title-icon" aria-hidden>
<IconGraduation />
</span>
Quản lớp học
</h1>
<p>Đng bộ dữ liệu lớp học sinh viên từ hệ thống chính</p> <p>Đng bộ dữ liệu lớp học sinh viên từ hệ thống chính</p>
</div> </div>
<button <button
className="btn btn-primary" className="btn btn-primary"
onClick={handleStartSync} onClick={handleStartSync}
disabled={syncStatus?.running} disabled={syncStatus?.running}
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 ? ( {syncStatus?.running ? (
<> <>
<div className="sync-spinner"></div> Đng bộ... <div className="sync-spinner" style={{ width: 18, height: 18 }} />
Đng bộ... {syncPercent}%
</> </>
) : ( ) : (
'🔄 Đồng bộ Lớp học' <>
<IconRefresh />
Đng bộ Lớp học
</>
)} )}
</button> </button>
</div> </div>
{/* Sync Status Banner */}
{syncStatus && (syncStatus.running || syncStatus.done || syncStatus.error) && ( {syncStatus && (syncStatus.running || syncStatus.done || syncStatus.error) && (
<div className="sync-progress-banner"> <div className="sync-progress-banner">
<div className="sync-header"> <div className="sync-header">
<div className="sync-title"> <div className="sync-title">
{syncStatus.running && <div className="sync-spinner"></div>} {syncStatus.running && <div className="sync-spinner" />}
<span> <span>
{syncStatus.running && `Đang đồng bộ lớp học... (${syncPercent}%)`} {syncStatus.running && `Đang đồng bộ... ${syncPercent}%`}
{syncStatus.done && '🎉 Đồng bộ lớp học hoàn tất!'} {syncStatus.done && (
{syncStatus.error && `❌ Đồng bộ thất bại: ${syncStatus.error}`} <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: 6, color: 'var(--danger)' }}>
<IconAlert />
{syncStatus.error}
</span>
)}
</span> </span>
</div> </div>
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}> <div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>
@@ -148,7 +252,7 @@ export const ClassesTab: React.FC = () => {
</div> </div>
{syncStatus.running && ( {syncStatus.running && (
<div className="sync-bar-container"> <div className="sync-bar-container">
<div className="sync-bar" style={{ width: `${syncPercent}%` }}></div> <div className="sync-bar" style={{ width: `${syncPercent}%` }} />
</div> </div>
)} )}
<div className="sync-meta"> <div className="sync-meta">
@@ -158,7 +262,6 @@ export const ClassesTab: React.FC = () => {
</div> </div>
)} )}
{/* Control bar filters */}
<div className="control-bar"> <div className="control-bar">
<div className="search-input-wrapper"> <div className="search-input-wrapper">
<input <input
@@ -171,7 +274,9 @@ export const ClassesTab: React.FC = () => {
setPage(1); setPage(1);
}} }}
/> />
<span className="search-icon">🔍</span> <span className="search-icon">
<IconSearch />
</span>
</div> </div>
<div className="filters-wrapper"> <div className="filters-wrapper">
@@ -185,10 +290,10 @@ export const ClassesTab: React.FC = () => {
}} }}
> >
<option value="">Tất cả phân hệ</option> <option value="">Tất cả phân hệ</option>
<option value="1"> Nội (System 1)</option> <option value="1"> Nội</option>
<option value="2">Hồ Chí Minh (System 2)</option> <option value="2">Hồ Chí Minh</option>
<option value="3">Đà Nẵng (System 3)</option> <option value="3">Đà Nẵng</option>
<option value="4">Cần Thơ (System 4)</option> <option value="4">Cần Thơ</option>
</select> </select>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', fontSize: '0.9rem' }}> <label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', fontSize: '0.9rem' }}>
@@ -199,7 +304,7 @@ export const ClassesTab: React.FC = () => {
setStudyingOnly(e.target.checked); setStudyingOnly(e.target.checked);
setPage(1); 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 Chỉ xem lớp đang học
</label> </label>
@@ -207,143 +312,613 @@ export const ClassesTab: React.FC = () => {
</div> </div>
</div> </div>
{/* Classes list table */}
<div className="tab-page-body"> <div className="tab-page-body">
<div className="table-wrapper table-fill"> <div className="table-wrapper table-fill">
{loading ? ( {loading ? (
<div className="empty-state"> <div className="empty-state">
<div className="sync-spinner" style={{ width: '32px', height: '32px' }}></div> <div className="sync-spinner" style={{ width: '40px', height: '40px' }} />
<p style={{ marginTop: '0.5rem' }}>Đang tải danh sách lớp học...</p> <p style={{ marginTop: '1rem', fontWeight: 600 }}>Đang tải danh sách lớp học...</p>
</div> </div>
) : classes.length === 0 ? ( ) : classes.length === 0 ? (
<div className="empty-state"> <div className="empty-state">
<div className="empty-state-icon">🏫</div> <div className="empty-state-icon" style={{ color: 'var(--text-muted)' }}>
<h2>Không tìm thấy lớp học nào</h2> <IconInbox />
<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> </div>
<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> </div>
) : ( ) : (
<table className="data-table"> <div className="table-scroll-container">
<table className="data-table class-table">
<thead> <thead>
<tr> <tr>
<th> lớp / ID</th> <th className="col-code"> lớp / ID</th>
<th>Tên Lớp học</th> <th className="col-name">Tên lớp học</th>
<th>Phân hệ</th> <th className="col-system">Phân hệ</th>
<th>Môn học của lớp</th> <th className="col-courses">Môn học</th>
<th>Sinh viên</th> <th className="col-students">Sinh viên</th>
<th>Đang dạy</th> <th className="col-status">Trạng thái</th>
<th style={{ textAlign: 'right' }}>Hành đng</th> <th className="col-action">Hành đng</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{classes.map(cl => ( {classes.map(cl => {
<tr key={cl.rkId} style={cl.isStudying ? { background: 'rgba(16, 185, 129, 0.02)' } : {}}> const isActive = cl.isStudying;
<td> return (
<div style={{ fontWeight: 700, color: 'var(--text-primary)' }}>{cl.classCode}</div> <tr key={cl.rkId} className={isActive ? 'row-active' : ''}>
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>ID: {cl.rkId}</div> <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> <td className="col-name" data-label="Tên lớp học">
<div <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)} 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} {cl.name}
</div> </div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', marginTop: '0.15rem' }}> <div className="class-specialize">
💼 {cl.specializeName || 'Chưa có chuyên ngành'} <IconSpecialize />
<span className="class-specialize-text">
{cl.specializeName || 'Chưa có chuyên ngành'}
</span>
</div> </div>
</td> </td>
<td> <td className="col-system" data-label="Phân hệ">
<span className="badge badge-muted" style={{ fontWeight: 600 }}> <span className="badge badge-system">
📍 {cl.systemName || `Hệ thống ${cl.systemRkId || 'Khác'}`} {cl.systemName || `Hệ thống ${cl.systemRkId || 'Khác'}`}
</span> </span>
</td> </td>
<td> <td className="col-courses" data-label="Môn học">
<div className="courses-tag-list"> <div className="course-tags">
{cl.courses && cl.courses.length > 0 ? ( {cl.courses && cl.courses.length > 0 ? (
cl.courses.map((co, i) => ( cl.courses.map((co, i) => (
<span key={i} className="course-tag"> <span key={i} className="course-tag" title={co.courseName}>
{co.courseName} {co.courseName}
</span> </span>
)) ))
) : ( ) : (
<span style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>Chưa môn học</span> <span className="text-muted-small">Chưa môn học</span>
)} )}
</div> </div>
</td> </td>
<td> <td className="col-students" data-label="Sinh viên">
<span className="badge badge-info" style={{ padding: '0.3rem 0.65rem', borderRadius: '6px', fontWeight: 600 }}> <span className="badge badge-student">
👤 {cl.studentCount} SV <IconUsers />
<span className="student-count">{cl.studentCount}</span>
<span>SV</span>
</span> </span>
</td> </td>
<td> <td className="col-status" data-label="Trạng thái">
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}> <div className="status-toggle-wrap">
<div className="switch-container"> <div className="switch-container">
<label className="switch"> <label className="switch">
<input <input
type="checkbox" type="checkbox"
checked={cl.isStudying} checked={isActive}
onChange={() => handleToggleStudying(cl)} onChange={() => handleToggleStudying(cl)}
/> />
<span className="slider"></span> <span className="slider" />
</label> </label>
</div> </div>
{cl.isStudying ? ( <span className={`status-label ${isActive ? 'active' : 'inactive'}`}>
<span style={{ color: 'var(--success)', fontSize: '0.72rem', display: 'inline-flex', alignItems: 'center', gap: '0.25rem', fontWeight: 700, textTransform: 'uppercase' }}> {isActive ? 'Đang học' : 'Tạm dừng'}
<span className="pulse-dot-active" style={{ display: 'inline-block', width: '6px', height: '6px', background: '#10b981', borderRadius: '50%', boxShadow: '0 0 6px #10b981' }}></span>
Đang học
</span> </span>
) : (
<span style={{ color: 'var(--text-muted)', fontSize: '0.72rem', textTransform: 'uppercase', fontWeight: 600 }}>Tạm dừng</span>
)}
</div> </div>
</td> </td>
<td style={{ textAlign: 'right' }}> <td className="col-action" data-label="Hành động">
<button <button
className="btn btn-secondary" className="btn btn-outline"
style={{ padding: '0.5rem 0.95rem', fontSize: '0.8rem', fontWeight: 600, display: 'inline-flex', alignItems: 'center', gap: '0.35rem' }}
onClick={() => openClass('classes', cl.rkId, cl.name)} onClick={() => openClass('classes', cl.rkId, cl.name)}
> >
👥 Xem lớp <span className="btn-outline-label">Xem lớp</span>
<IconOpen />
</button> </button>
</td> </td>
</tr> </tr>
))} );
})}
</tbody> </tbody>
</table> </table>
</div>
)} )}
</div> </div>
</div> </div>
{/* Pagination control bar */}
{!loading && classes.length > 0 && ( {!loading && classes.length > 0 && (
<div className="tab-page-footer"> <div className="tab-page-footer">
<div className="pagination-row"> <div className="pagination-row">
<div> <div className="pagination-info">
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 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>
<div className="pagination-btn-group"> <div className="pagination-btn-group">
<button <button
className="pagination-btn" className="pagination-btn"
onClick={() => setPage(p => Math.max(1, p - 1))} onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1} disabled={page === 1}
aria-label="Trang trước"
> >
<IconChevronLeft />
</button> </button>
<span style={{ display: 'flex', alignItems: 'center', padding: '0 1rem', fontWeight: 600, color: 'var(--text-primary)' }}> <span className="pagination-current">
Trang {page} / {Math.ceil(total / pageSize) || 1} Trang {page} / {totalPages}
</span> </span>
<button <button
className="pagination-btn" className="pagination-btn"
onClick={() => setPage(p => p + 1)} onClick={() => setPage(p => p + 1)}
disabled={page >= Math.ceil(total / pageSize)} disabled={page >= totalPages}
aria-label="Trang sau"
> >
<IconChevronRight />
</button> </button>
</div> </div>
</div> </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> </div>
); );
}; };

View File

@@ -1,16 +1,73 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { api } from '../api'; import { api, type GuideLinkItem } from '../api';
import type { StatsResponse } from '../api'; import type { StatsResponse } from '../api';
import { useAuth } from '../auth/AuthContext';
interface DashboardTabProps { interface DashboardTabProps {
onNavigate: (tab: string) => void; onNavigate: (tab: string) => void;
} }
const IconClasses = () => (
<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" strokeWidth="2" 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>
);
const IconActiveClasses = () => (
<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
</svg>
);
const IconStudents = () => (
<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
);
const IconAlert = () => (
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
);
export const DashboardTab: React.FC<DashboardTabProps> = ({ onNavigate }) => { export const DashboardTab: React.FC<DashboardTabProps> = ({ onNavigate }) => {
const { staff } = useAuth();
const isSuperAdmin = staff?.email === 'phuocntb@rikkeiacademy.com';
const [stats, setStats] = useState<StatsResponse | null>(null); const [stats, setStats] = useState<StatsResponse | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Guide links (teacher guides) state
const [links, setLinks] = useState<GuideLinkItem[]>([]);
const [linksLoading, setLinksLoading] = useState(true);
// Modal states
const [showModal, setShowModal] = useState(false);
const [editingLink, setEditingLink] = useState<GuideLinkItem | null>(null);
const [modalTitle, setModalTitle] = useState('');
const [modalUrl, setModalUrl] = useState('');
const [modalSaving, setModalSaving] = useState(false);
const fetchLinks = async () => {
try {
setLinksLoading(true);
const res = await api.listGuideLinks();
setLinks(res.data || []);
} catch (err) {
console.error('Không thể tải tài liệu hướng dẫn giáo viên:', err);
} finally {
setLinksLoading(false);
}
};
useEffect(() => { useEffect(() => {
const fetchStats = async () => { const fetchStats = async () => {
try { try {
@@ -24,8 +81,55 @@ export const DashboardTab: React.FC<DashboardTabProps> = ({ onNavigate }) => {
} }
}; };
fetchStats(); fetchStats();
fetchLinks();
}, []); }, []);
const handleOpenAddModal = () => {
setEditingLink(null);
setModalTitle('');
setModalUrl('');
setShowModal(true);
};
const handleOpenEditModal = (link: GuideLinkItem) => {
setEditingLink(link);
setModalTitle(link.title);
setModalUrl(link.url);
setShowModal(true);
};
const handleSaveLink = async () => {
if (!modalTitle.trim() || !modalUrl.trim()) {
alert('Vui lòng điền đầy đủ tiêu đề và đường dẫn');
return;
}
try {
setModalSaving(true);
if (editingLink) {
await api.updateGuideLink(editingLink.id, modalTitle.trim(), modalUrl.trim());
} else {
await api.createGuideLink(modalTitle.trim(), modalUrl.trim());
}
setShowModal(false);
await fetchLinks();
} catch (err: any) {
alert(err.message || 'Lưu tài liệu thất bại');
} finally {
setModalSaving(false);
}
};
const handleDeleteLink = async (id: number, title: string) => {
if (window.confirm(`Bạn có chắc chắn muốn xóa tài liệu giáo viên "${title}"?`)) {
try {
await api.deleteGuideLink(id);
await fetchLinks();
} catch (err: any) {
alert(err.message || 'Xóa tài liệu thất bại');
}
}
};
return ( return (
<div className="tab-page"> <div className="tab-page">
<div className="tab-page-toolbar"> <div className="tab-page-toolbar">
@@ -46,8 +150,10 @@ export const DashboardTab: React.FC<DashboardTabProps> = ({ onNavigate }) => {
)} )}
{error && ( {error && (
<div className="empty-state" style={{ border: '1px solid rgba(239, 68, 68, 0.2)', backgroundColor: 'rgba(239, 68, 68, 0.05)' }}> <div className="empty-state" style={{ border: '1px solid rgba(214, 48, 49, 0.2)', backgroundColor: 'rgba(214, 48, 49, 0.05)', borderRadius: '12px' }}>
<div className="empty-state-icon" style={{ color: 'var(--danger)' }}></div> <div className="empty-state-icon" style={{ color: 'var(--danger)' }}>
<IconAlert />
</div>
<p style={{ color: 'var(--danger)', fontWeight: 600 }}>Lỗi: {error}</p> <p style={{ color: 'var(--danger)', fontWeight: 600 }}>Lỗi: {error}</p>
</div> </div>
)} )}
@@ -60,7 +166,9 @@ export const DashboardTab: React.FC<DashboardTabProps> = ({ onNavigate }) => {
<div className="stat-label">Tổng lớp học đã đng bộ</div> <div className="stat-label">Tổng lớp học đã đng bộ</div>
<div className="stat-value">{stats.totalClasses}</div> <div className="stat-value">{stats.totalClasses}</div>
</div> </div>
<div className="stat-icon">🏫</div> <div className="stat-icon" style={{ color: 'var(--accent)' }}>
<IconClasses />
</div>
</div> </div>
<div className="stat-card" onClick={() => onNavigate('classes')} style={{ cursor: 'pointer' }}> <div className="stat-card" onClick={() => onNavigate('classes')} style={{ cursor: 'pointer' }}>
@@ -68,7 +176,9 @@ export const DashboardTab: React.FC<DashboardTabProps> = ({ onNavigate }) => {
<div className="stat-label">Lớp đang giảng dạy</div> <div className="stat-label">Lớp đang giảng dạy</div>
<div className="stat-value" style={{ color: 'var(--success)' }}>{stats.activeClasses}</div> <div className="stat-value" style={{ color: 'var(--success)' }}>{stats.activeClasses}</div>
</div> </div>
<div className="stat-icon success"></div> <div className="stat-icon success" style={{ color: 'var(--success)', backgroundColor: 'var(--success-light)' }}>
<IconActiveClasses />
</div>
</div> </div>
<div className="stat-card" onClick={() => onNavigate('students')} style={{ cursor: 'pointer' }}> <div className="stat-card" onClick={() => onNavigate('students')} style={{ cursor: 'pointer' }}>
@@ -76,48 +186,205 @@ export const DashboardTab: React.FC<DashboardTabProps> = ({ onNavigate }) => {
<div className="stat-label">Tổng sinh viên đã đng bộ</div> <div className="stat-label">Tổng sinh viên đã đng bộ</div>
<div className="stat-value" style={{ color: 'var(--accent-hover)' }}>{stats.totalStudents}</div> <div className="stat-value" style={{ color: 'var(--accent-hover)' }}>{stats.totalStudents}</div>
</div> </div>
<div className="stat-icon">🎓</div> <div className="stat-icon" style={{ color: 'var(--accent-hover)' }}>
<IconStudents />
</div>
</div> </div>
</div> </div>
<div className="content-grid-2"> <div style={{ marginTop: '1.5rem' }}>
<div className="content-card"> <div className="content-card">
<h2>Khởi đng nhanh</h2> <h2>Khởi đng nhanh</h2>
<p> <p style={{ lineHeight: '1.6', color: 'var(--text-secondary)' }}>
Chào mừng bạn đến với trang quản trị <strong>Simple Care</strong>. Chào mừng bạn đến với trang quản trị <strong>Simple Care</strong>.
Hệ thống hỗ trợ đng bộ hóa thông tin tự đng từ cổng đào tạo chính (QLĐT), giúp lưu trữ, cập nhật trạng thái lớp học danh sách sinh viên nội bộ. Hệ thống hỗ trợ quản lớp học, thi cử chăm sóc sinh viên.
</p> </p>
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', marginTop: '1rem' }}>
<button className="btn btn-primary" onClick={() => onNavigate('classes')}> <button className="btn btn-primary" onClick={() => onNavigate('classes')}>
Quản lớp học Quản lớp học
</button> </button>
<button className="btn btn-secondary" onClick={() => onNavigate('students')}> <button className="btn btn-secondary" onClick={() => onNavigate('learning')}>
Danh sách sinh viên Lịch học & Giám sát
</button>
<button className="btn btn-secondary" onClick={() => onNavigate('exams')}>
Quản phòng thi
</button> </button>
</div> </div>
</div> </div>
<div className="content-card">
<h2>Quy trình hoạt đng</h2>
<ul style={{
color: 'var(--text-secondary)',
fontSize: '0.875rem',
lineHeight: 1.75,
paddingLeft: '1.15rem',
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
margin: 0
}}>
<li><strong>Đng bộ lớp học:</strong> Tải danh sách lớp môn học, kéo thông tin sinh viên từng lớp qua bảng kết quả.</li>
<li><strong>Quản lớp:</strong> Lọc theo phân hệ, tìm kiếm tên/ lớp bật/tắt cờ <strong>Đang học</strong>.</li>
<li><strong>Đng bộ sinh viên:</strong> Kéo toàn bộ sinh viên từ hệ thống chính (kèm thông tin liên hệ, hệ học) vào database nội bộ.</li>
</ul>
</div> </div>
<div className="content-card" style={{ marginTop: '1.5rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem', borderBottom: '1px solid var(--border-color)', paddingBottom: '0.75rem' }}>
<h2 style={{ margin: 0, display: 'flex', alignItems: 'center', gap: '8px' }}>
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" style={{ color: 'var(--accent)' }}>
<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" />
</svg>
Tài liệu nội bộ (Giáo viên)
</h2>
{isSuperAdmin && (
<button
className="btn btn-primary btn-sm"
onClick={handleOpenAddModal}
style={{ display: 'inline-flex', alignItems: 'center', gap: '4px' }}
>
+ Thêm tài liệu
</button>
)}
</div>
{linksLoading && (
<div style={{ display: 'flex', justifyContent: 'center', padding: '1rem' }}>
<div className="sync-spinner" style={{ width: '24px', height: '24px' }}></div>
</div>
)}
{!linksLoading && links.length === 0 && (
<p style={{ color: 'var(--text-muted)', fontStyle: 'italic', fontSize: '0.9rem', margin: '0.5rem 0' }}>Chưa tài liệu hướng dẫn nào đưc cấu hình.</p>
)}
{!linksLoading && links.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{links.map((link) => (
<div
key={link.id}
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '0.75rem 1rem',
backgroundColor: 'var(--bg-app)',
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--border-color)',
transition: 'var(--transition)'
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'rgba(187, 33, 38, 0.25)';
e.currentTarget.style.backgroundColor = '#fff';
e.currentTarget.style.boxShadow = 'var(--shadow-sm)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--border-color)';
e.currentTarget.style.backgroundColor = 'var(--bg-app)';
e.currentTarget.style.boxShadow = 'none';
}}
>
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
color: 'var(--text-primary)',
textDecoration: 'none',
fontWeight: 500,
fontSize: '0.92rem',
flex: 1
}}
onMouseEnter={(e) => e.currentTarget.style.color = 'var(--accent)'}
onMouseLeave={(e) => e.currentTarget.style.color = 'var(--text-primary)'}
>
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ color: '#4285F4' }}>
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
<polyline points="14 2 14 8 20 8" />
</svg>
{link.title}
</a>
{isSuperAdmin && (
<div style={{ display: 'flex', gap: '6px' }}>
<button
className="btn btn-secondary btn-sm"
onClick={() => handleOpenEditModal(link)}
style={{ padding: '0.25rem 0.5rem', fontSize: '0.75rem' }}
>
Sửa
</button>
<button
className="btn btn-secondary btn-sm"
onClick={() => handleDeleteLink(link.id, link.title)}
style={{ padding: '0.25rem 0.5rem', fontSize: '0.75rem', color: 'var(--danger)', borderColor: 'rgba(214, 48, 49, 0.2)' }}
>
Xóa
</button>
</div>
)}
</div>
))}
</div>
)}
</div> </div>
</> </>
)} )}
</div> </div>
{showModal && (
<div className="modal-overlay" onClick={() => setShowModal(false)}>
<div className="modal-container" style={{ maxWidth: '450px' }} onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2 className="modal-title">{editingLink ? 'Cập nhật tài liệu' : 'Thêm tài liệu hướng dẫn'}</h2>
<button className="modal-close-btn" onClick={() => setShowModal(false)} aria-label="Đóng">&times;</button>
</div>
<div className="modal-body" style={{ padding: '1.5rem' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<label className="login-field" style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
<span style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--text-secondary)' }}>Tiêu đ tài liệu</span>
<input
type="text"
value={modalTitle}
onChange={e => setModalTitle(e.target.value)}
placeholder="VD: Hướng dẫn coi thi cuối kỳ"
style={{
width: '100%',
padding: '0.55rem 0.75rem',
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--border-color)',
fontSize: '0.9rem',
outline: 'none',
transition: 'var(--transition)'
}}
onFocus={e => e.target.style.borderColor = 'var(--accent)'}
onBlur={e => e.target.style.borderColor = 'var(--border-color)'}
/>
</label>
<label className="login-field" style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
<span style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--text-secondary)' }}>Đưng dẫn Google Docs</span>
<input
type="url"
value={modalUrl}
onChange={e => setModalUrl(e.target.value)}
placeholder="https://docs.google.com/document/d/..."
style={{
width: '100%',
padding: '0.55rem 0.75rem',
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--border-color)',
fontSize: '0.9rem',
outline: 'none',
transition: 'var(--transition)'
}}
onFocus={e => e.target.style.borderColor = 'var(--accent)'}
onBlur={e => e.target.style.borderColor = 'var(--border-color)'}
/>
</label>
</div>
</div>
<div className="modal-footer" style={{ padding: '1rem 1.5rem' }}>
<button className="btn btn-secondary" onClick={() => setShowModal(false)}>Hủy</button>
<button
className="btn btn-primary"
onClick={handleSaveLink}
disabled={modalSaving}
>
{modalSaving ? 'Đang lưu...' : 'Lưu lại'}
</button>
</div>
</div>
</div>
)}
</div> </div>
); );
}; };

View File

@@ -0,0 +1,392 @@
import React, { useEffect, useState, useRef, useMemo } from 'react';
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[];
onSelectStudent: (student: ExamRoomStudent) => void;
}
export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
students,
onlineIds,
onSelectStudent,
}) => {
const [gridCols, setGridCols] = useState<number>(3);
const [showWebcamOverlay, setShowWebcamOverlay] = useState<boolean>(true);
const [searchQuery, setSearchQuery] = useState<string>('');
const [onlyOnline, setOnlyOnline] = useState<boolean>(false);
const [isFullscreen, setIsFullscreen] = useState<boolean>(false);
const [zoomedStudent, setZoomedStudent] = useState<ExamRoomStudent | null>(null);
const [pageSize, setPageSize] = useState<number | 'all'>(12);
const [currentPage, setCurrentPage] = useState<number>(1);
const gridContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setZoomedStudent(null);
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
const filteredStudents = useMemo(() => {
return students.filter((s) => {
const matchesSearch =
s.fullName.toLowerCase().includes(searchQuery.toLowerCase()) ||
s.studentCode.toLowerCase().includes(searchQuery.toLowerCase());
const isOnline = onlineIds.includes(s.studentRkId);
return matchesSearch && (!onlyOnline || isOnline);
});
}, [students, onlineIds, searchQuery, onlyOnline]);
const pagedStudents = useMemo(() => {
if (pageSize === 'all') return filteredStudents;
const start = (currentPage - 1) * pageSize;
return filteredStudents.slice(start, start + pageSize);
}, [filteredStudents, currentPage, pageSize]);
const totalPages = useMemo(() => {
if (pageSize === 'all') return 1;
return Math.ceil(filteredStudents.length / pageSize) || 1;
}, [filteredStudents, pageSize]);
useEffect(() => {
if (currentPage > totalPages) setCurrentPage(totalPages);
}, [totalPages, currentPage]);
const handleOpenChat = (s: ExamRoomStudent, e: React.MouseEvent) => {
e.stopPropagation();
openStaffChat({
studentRkId: s.studentRkId,
fullName: s.fullName,
studentCode: s.studentCode,
email: s.email,
});
};
useEffect(() => {
const handleFsChange = () => {
setIsFullscreen(document.fullscreenElement === gridContainerRef.current);
};
document.addEventListener('fullscreenchange', handleFsChange);
return () => document.removeEventListener('fullscreenchange', handleFsChange);
}, []);
const toggleFullscreen = () => {
const el = gridContainerRef.current;
if (!el) return;
if (document.fullscreenElement === el) {
document.exitFullscreen().catch(console.error);
} else {
el.requestFullscreen().catch(console.error);
}
};
return (
<div className={`grid-proctor-container ${isFullscreen ? 'fullscreen-active' : ''}`} ref={gridContainerRef}>
<div className="grid-proctor-toolbar">
<div className="grid-proctor-toolbar-left">
<div className="gp-search">
<span className="gp-search-icon"><IconSearch size={14} /></span>
<input
type="search"
className="search-input gp-search-input"
placeholder="Lọc sinh viên..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<label className="gp-check">
<input type="checkbox" checked={onlyOnline} onChange={(e) => setOnlyOnline(e.target.checked)} />
<span>Chỉ hiện Online</span>
</label>
<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">
<label className="gp-page-size">
<span>Xem tối đa</span>
<select
value={pageSize}
onChange={(e) => {
const val = e.target.value;
setPageSize(val === 'all' ? 'all' : Number(val));
setCurrentPage(1);
}}
>
<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>
<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={{ gridTemplateColumns: `repeat(${gridCols}, minmax(0, 1fr))` }}
>
{pagedStudents.map((s) => {
const isOnline = onlineIds.includes(s.studentRkId);
return (
<div
key={s.id}
className={`grid-proctor-card ${isOnline ? 'online' : 'offline'} ${s.submitted ? 'submitted' : ''}`}
onClick={() => onSelectStudent(s)}
>
<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>
</div>
<span className="student-code">{s.studentCode}</span>
</div>
<div className="grid-proctor-card-body">
{isOnline ? (
<div
className="proctor-frame-container cursor-zoom-in"
onClick={(e) => {
e.stopPropagation();
setZoomedStudent(s);
}}
>
<StudentStreamImage
studentId={s.studentRkId}
kind="screen"
className="proctor-screen-image"
/>
{showWebcamOverlay && (
<div className="proctor-webcam-overlay">
<StudentStreamImage
studentId={s.studentRkId}
kind="webcam"
className="proctor-webcam-image"
/>
</div>
)}
</div>
) : (
<div className="proctor-placeholder offline">
<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 đề'}>
<IconFile size={12} />
{s.paperTitle ? s.paperTitle : 'Chưa gán đề'}
</span>
<div className="footer-actions">
<button
type="button"
className="gp-action-btn"
onClick={(e) => handleOpenChat(s, e)}
title="Nhắn tin cho sinh viên"
>
<IconMessage size={13} />
Nhắn tin
</button>
<button
type="button"
className="gp-action-btn gp-action-btn--primary"
onClick={() => onSelectStudent(s)}
title="Xem chi tiết giám sát"
>
<IconEye size={13} />
Giám sát
</button>
</div>
</div>
</div>
);
})}
{filteredStudents.length === 0 && (
<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>
{totalPages > 1 && (
<div className="grid-proctor-pagination">
<button
type="button"
className="btn btn-secondary btn-sm gp-page-btn"
disabled={currentPage === 1}
onClick={() => setCurrentPage((p) => Math.max(p - 1, 1))}
>
<IconChevronLeft size={14} />
Trang trước
</button>
<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 gp-page-btn"
disabled={currentPage === totalPages}
onClick={() => setCurrentPage((p) => Math.min(p + 1, totalPages))}
>
Trang sau
<IconChevronRight size={14} />
</button>
</div>
)}
</div>
{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>
<div className="zoomed-proctor-body">
<div className="zoomed-proctor-frame">
<StudentStreamImage
studentId={zoomedStudent.studentRkId}
kind="screen"
className="zoomed-proctor-screen"
/>
{showWebcamOverlay && (
<div className="zoomed-proctor-webcam">
<StudentStreamImage
studentId={zoomedStudent.studentRkId}
kind="webcam"
className="proctor-webcam-image"
/>
</div>
)}
</div>
</div>
</div>
</div>
)}
</div>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { apiExam, type ExamRoomItem } from '../api'; import { apiExam, type ExamRoomItem } from '../api';
import { openExam } from './NavHistoryBar'; import { openExam } from './NavHistoryBar';
import { useAuth } from '../auth/AuthContext';
function fmtTime(iso: string) { function fmtTime(iso: string) {
try { try {
@@ -10,13 +11,13 @@ function fmtTime(iso: string) {
} }
} }
function statusLabel(st: string) { function statusMeta(st: string) {
if (st === 'draft') return { text: 'Tạm thời', cls: 'badge-muted' }; if (st === 'draft') return { text: 'Tạm thời', tone: 'muted' };
if (st === 'ready') return { text: 'Sẵn sàng', cls: 'badge-info' }; if (st === 'ready') return { text: 'Sẵn sàng', tone: 'info' };
if (st === 'active') return { text: 'Đang thi', cls: 'badge-success' }; if (st === 'active') return { text: 'Đang thi', tone: 'active' };
if (st === 'cancelled') return { text: 'Đã hủy', cls: 'badge-warning' }; if (st === 'cancelled') return { text: 'Đã hủy', tone: 'warn' };
if (st === 'ended') return { text: 'Đã kết thúc', cls: 'badge-muted' }; if (st === 'ended') return { text: 'Đã kết thúc', tone: 'muted' };
return { text: st, cls: 'badge-muted' }; return { text: st, tone: 'muted' };
} }
function toLocalInput(iso?: string) { function toLocalInput(iso?: string) {
@@ -31,7 +32,71 @@ function localInputToISO(v: string) {
return new Date(v).toISOString(); return new Date(v).toISOString();
} }
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 = () => { export const ExamsTab: React.FC = () => {
const { staff } = useAuth();
const isSuperAdmin = staff?.email === 'phuocntb@rikkeiacademy.com';
const myStaffId = staff?.id ?? 0;
const [rooms, setRooms] = useState<ExamRoomItem[]>([]); const [rooms, setRooms] = useState<ExamRoomItem[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false); const [showCreate, setShowCreate] = useState(false);
@@ -40,6 +105,7 @@ export const ExamsTab: React.FC = () => {
const [end, setEnd] = useState(''); const [end, setEnd] = useState('');
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [roomScope, setRoomScope] = useState<RoomScope>('mine');
const load = async () => { const load = async () => {
setLoading(true); setLoading(true);
@@ -57,20 +123,28 @@ export const ExamsTab: React.FC = () => {
load(); load();
}, []); }, []);
const scopedRooms = useMemo(() => {
if (roomScope === 'all' || !myStaffId) return rooms;
return rooms.filter((r) => Number(r.createdByStaffId) === myStaffId);
}, [rooms, roomScope, myStaffId]);
const stats = useMemo(() => { const stats = useMemo(() => {
const active = rooms.filter((r) => (r.displayStatus || r.status) === 'active').length; const active = scopedRooms.filter((r) => (r.displayStatus || r.status) === 'active').length;
const upcoming = rooms.filter((r) => { const upcoming = scopedRooms.filter((r) => {
const st = r.displayStatus || r.status; const st = r.displayStatus || r.status;
return st === 'ready' || st === 'draft'; return st === 'ready' || st === 'draft';
}).length; }).length;
return { total: rooms.length, active, upcoming }; return { total: scopedRooms.length, active, upcoming };
}, [rooms]); }, [scopedRooms]);
const filtered = useMemo(() => { const filtered = useMemo(() => {
const q = search.trim().toLowerCase(); const q = search.trim().toLowerCase();
if (!q) return rooms; if (!q) return scopedRooms;
return rooms.filter((r) => r.name.toLowerCase().includes(q)); return scopedRooms.filter((r) => r.name.toLowerCase().includes(q));
}, [rooms, search]); }, [scopedRooms, search]);
const canDeleteRoom = (r: ExamRoomItem) =>
isSuperAdmin || (myStaffId > 0 && Number(r.createdByStaffId) === myStaffId);
const create = async () => { const create = async () => {
if (!name.trim() || !start || !end) return; if (!name.trim() || !start || !end) return;
@@ -94,22 +168,55 @@ 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.`
)
) {
try {
await apiExam.remove(roomId);
alert('Đã xóa phòng thi thành công!');
await load();
} catch (err: any) {
alert(err.message || 'Xóa phòng thi thất bại');
}
}
};
return ( return (
<div className="tab-page exams-page"> <div className="tab-page exams-page">
<header className="page-header page-header--row"> <div className="tab-page-toolbar">
<div> <div className="page-header">
<h1 className="page-title">Danh sách phòng thi</h1> <div className="page-title">
<p className="page-desc">Tạo phòng, chia đ ngẫu nhiên, gửi đ thu bài từ sinh viên.</p> <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>
<div className="exams-header-actions">
<button type="button" className="btn btn-primary" onClick={() => setShowCreate(true)}> <button type="button" className="btn btn-primary" onClick={() => setShowCreate(true)}>
+ Tạo phòng thi <IconPlus />
Tạo phòng thi
</button> </button>
</header> </div>
</div>
</div>
<div className="tab-page-body" style={{ gap: '0.85rem' }}>
<div className="exams-stats-row"> <div className="exams-stats-row">
<div className="learning-stat"> <div className="learning-stat">
<span className="learning-stat-value">{stats.total}</span> <span className="learning-stat-value">{stats.total}</span>
<span className="learning-stat-label">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>
<div className="learning-stat learning-stat--accent"> <div className="learning-stat learning-stat--accent">
<span className="learning-stat-value">{stats.active}</span> <span className="learning-stat-value">{stats.active}</span>
@@ -121,40 +228,190 @@ export const ExamsTab: React.FC = () => {
</div> </div>
</div> </div>
<div className="exams-toolbar"> <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-seg-btn${roomScope === 'mine' ? ' learning-seg-btn--active' : ''}`}
onClick={() => setRoomScope('mine')}
>
<IconStar size={13} filled={roomScope === 'mine'} />
<span>Phòng của tôi</span>
</button>
<button
type="button"
className={`learning-seg-btn${roomScope === 'all' ? ' learning-seg-btn--active' : ''}`}
onClick={() => setRoomScope('all')}
>
<span>Xem tất cả</span>
</button>
</div>
<input <input
type="search" 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..." placeholder="Tìm theo tên phòng thi..."
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
/> />
</div> </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 && ( {showCreate && (
<div className="system-form-card exams-create-card"> <div className="modal-overlay" onClick={() => setShowCreate(false)}>
<h2 className="system-form-title">Phòng thi mới</h2> <div className="modal-container class-picker-modal exam-create-modal" onClick={(e) => e.stopPropagation()}>
<div className="system-form-grid"> <div className="modal-header class-picker-header">
<label className="login-field system-form-full"> <div>
<span>Tên phòng thi</span> <h2 className="modal-title">Tạo phòng thi mới</h2>
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="VD: Thi cuối kỳ Java" /> <p className="class-picker-subtitle">
Đt tên khung giờ thi. Sau khi tạo sẽ mở workspace đ cấu hình đ sinh viên.
</p>
</div>
<button className="modal-close-btn" onClick={() => setShowCreate(false)} aria-label="Đóng">
&times;
</button>
</div>
<div className="modal-body class-picker-body exam-create-body">
<label className="exam-field">
<span className="exam-field-label">Tên phòng thi</span>
<input
className="search-input"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="VD: Thi cuối kỳ Java"
autoFocus
/>
</label> </label>
<label className="login-field"> <div className="exam-field-grid">
<span>Bắt đu</span> <label className="exam-field">
<input type="datetime-local" value={start} onChange={(e) => setStart(e.target.value)} /> <span className="exam-field-label">Bắt đu</span>
<input
type="datetime-local"
className="search-input exam-datetime"
value={start}
onChange={(e) => setStart(e.target.value)}
/>
</label> </label>
<label className="login-field"> <label className="exam-field">
<span>Kết thúc</span> <span className="exam-field-label">Kết thúc</span>
<input type="datetime-local" value={end} onChange={(e) => setEnd(e.target.value)} /> <input
type="datetime-local"
className="search-input exam-datetime"
value={end}
onChange={(e) => setEnd(e.target.value)}
/>
</label> </label>
</div> </div>
<div className="system-form-actions"> </div>
<div /> <div className="modal-footer" style={{ gap: '0.65rem' }}>
<div className="system-form-actions-right"> <button type="button" className="btn btn-secondary" onClick={() => setShowCreate(false)}>
<button type="button" className="btn btn-ghost" onClick={() => setShowCreate(false)}>
Hủy Hủy
</button> </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'} {busy ? 'Đang tạo...' : 'Tạo & mở phòng'}
</button> </button>
</div> </div>
@@ -162,42 +419,327 @@ export const ExamsTab: React.FC = () => {
</div> </div>
)} )}
{loading ? ( <style>{`
<div className="system-empty">Đang tải...</div> .page-title-heading {
) : filtered.length === 0 ? ( display: flex;
<div className="system-empty"> align-items: center;
{search.trim() ? 'Không tìm thấy phòng thi phù hợp.' : 'Chưa có phòng thi — bấm Tạo phòng thi để bắt đầu.'} gap: 0.55rem;
</div> }
) : ( .page-title-icon {
<div className="exams-card-grid"> display: inline-flex;
{filtered.map((r) => { align-items: center;
const st = statusLabel(r.displayStatus || r.status); justify-content: center;
return ( color: var(--accent);
<article key={r.id} className="exam-list-card"> flex-shrink: 0;
<div className="exam-list-card-head"> }
<h3>{r.name}</h3>
<span className={`badge ${st.cls}`}>{st.text}</span> .exams-header-actions {
</div> display: flex;
<div className="exam-list-card-meta"> gap: 0.5rem;
<div> flex-wrap: wrap;
<span className="exam-list-card-label">Thời gian</span> justify-content: flex-end;
<span>{fmtTime(r.startTime)}</span> }
<span className="exam-list-card-sep"></span> .exams-header-actions .btn {
<span>{fmtTime(r.endTime)}</span> display: inline-flex;
</div> align-items: center;
<div> gap: 0.4rem;
<span className="exam-list-card-label">Quy </span> }
<span>{r.studentCount} sinh viên · {r.paperCount} đ</span>
</div> .exams-toolbar-card {
</div> margin-bottom: 0 !important;
<button type="button" className="btn btn-primary btn-sm exam-list-card-open" onClick={() => openExam(r.id, r.name)}> }
Mở phòng thi .exams-toolbar-inner {
</button> margin: 0;
</article> }
); .exams-search {
})} max-width: none !important;
</div> }
)}
.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> </div>
); );
}; };

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -111,6 +111,40 @@ export const ScheduleEditor: React.FC<ScheduleEditorProps> = ({ classId, schedul
} }
}; };
const handleQuickCreateLocal = () => {
const nextLocal = [...local];
const standardTimes = [
{ period: 1, start: '07:00', end: '09:00' },
{ period: 2, start: '09:10', end: '11:10' },
{ period: 3, start: '12:10', end: '14:10' },
{ period: 4, start: '14:20', end: '16:20' },
];
let added = 0;
for (let day = 0; day <= 4; day++) {
for (const slot of standardTimes) {
const exists = nextLocal.some(s => s.dayOfWeek === day && s.period === slot.period);
if (!exists) {
nextLocal.push({
dayOfWeek: day,
period: slot.period,
startTime: slot.start,
endTime: slot.end,
courseId: 0,
courseName: '',
isActive: false, // status is tắt (false)
});
added++;
}
}
}
if (added === 0) {
alert('Tất cả các ca học từ T2 đến T6 đã tồn tại.');
return;
}
setLocal(nextLocal);
alert(`Đã tạo nhanh ${added} ca học nháp (trạng thái Tắt) cho Thứ 26. Vui lòng chọn môn học và bấm "Lưu lịch học".`);
};
const handleSave = async () => { const handleSave = async () => {
const payload = local.filter(s => s.startTime?.trim() && s.endTime?.trim()); const payload = local.filter(s => s.startTime?.trim() && s.endTime?.trim());
try { try {
@@ -127,7 +161,10 @@ export const ScheduleEditor: React.FC<ScheduleEditorProps> = ({ classId, schedul
return ( return (
<div className="schedule-editor"> <div className="schedule-editor">
<div className="schedule-editor-toolbar"> <div className="schedule-editor-toolbar" style={{ display: 'flex', gap: '8px', marginBottom: '10px' }}>
<button type="button" className="btn btn-secondary" onClick={handleQuickCreateLocal}>
Tạo nhanh 4 ca T2T6 (Tắt)
</button>
<button type="button" className="btn btn-secondary" onClick={handleApplyTemplate} disabled={applying}> <button type="button" className="btn btn-secondary" onClick={handleApplyTemplate} disabled={applying}>
{applying ? 'Đang áp dụng...' : 'Template 4 ca (T2T6)'} {applying ? 'Đang áp dụng...' : 'Template 4 ca (T2T6)'}
</button> </button>

View File

@@ -0,0 +1,355 @@
import React, { useEffect, useState } from 'react';
export interface SeatingTemplate {
id: string;
name: string;
rows: number;
cols: number;
boardPosition: 'top' | 'bottom' | 'left' | 'right';
lockedSeats: string[]; // e.g. ["0,0", "1,2"]
}
const DEFAULT_TEMPLATES: SeatingTemplate[] = [
{
id: 'tpl-standard-30',
name: 'Phòng Lab Standard (30 máy)',
rows: 5,
cols: 6,
boardPosition: 'top',
lockedSeats: [],
},
{
id: 'tpl-aisle-large',
name: 'Phòng Máy A1 (Có lối đi giữa)',
rows: 6,
cols: 8,
boardPosition: 'top',
lockedSeats: ['0,3', '1,3', '2,3', '3,3', '4,3', '5,3'],
},
];
export const SeatingTemplatesSection: React.FC = () => {
const [templates, setTemplates] = useState<SeatingTemplate[]>([]);
const [editing, setEditing] = useState<SeatingTemplate | null>(null);
const [creating, setCreating] = useState(false);
// Form states
const [name, setName] = useState('');
const [rows, setRows] = useState(5);
const [cols, setCols] = useState(6);
const [boardPosition, setBoardPosition] = useState<'top' | 'bottom' | 'left' | 'right'>('top');
const [lockedSeats, setLockedSeats] = useState<string[]>([]);
const [error, setError] = useState('');
useEffect(() => {
const stored = localStorage.getItem('sc_seating_templates');
if (stored) {
try {
setTemplates(JSON.parse(stored));
} catch {
setTemplates(DEFAULT_TEMPLATES);
}
} else {
localStorage.setItem('sc_seating_templates', JSON.stringify(DEFAULT_TEMPLATES));
setTemplates(DEFAULT_TEMPLATES);
}
}, []);
const saveTemplates = (newTemplates: SeatingTemplate[]) => {
localStorage.setItem('sc_seating_templates', JSON.stringify(newTemplates));
setTemplates(newTemplates);
};
const handleOpenCreate = () => {
setCreating(true);
setEditing(null);
setName('');
setRows(5);
setCols(6);
setBoardPosition('top');
setLockedSeats([]);
setError('');
};
const handleOpenEdit = (tpl: SeatingTemplate) => {
setEditing(tpl);
setCreating(false);
setName(tpl.name);
setRows(tpl.rows);
setCols(tpl.cols);
setBoardPosition(tpl.boardPosition);
setLockedSeats(tpl.lockedSeats || []);
setError('');
};
const handleCloseForm = () => {
setCreating(false);
setEditing(null);
setError('');
};
const handleToggleLock = (r: number, c: number) => {
const key = `${r},${c}`;
if (lockedSeats.includes(key)) {
setLockedSeats(lockedSeats.filter((k) => k !== key));
} else {
setLockedSeats([...lockedSeats, key]);
}
};
const handleSave = () => {
if (!name.trim()) {
setError('Vui lòng nhập tên template.');
return;
}
// Clean up locked seats that fall outside the current rows/cols boundary
const validLockedSeats = lockedSeats.filter((key) => {
const [r, c] = key.split(',').map(Number);
return r < rows && c < cols;
});
const newTemplate: SeatingTemplate = {
id: editing ? editing.id : `tpl-${Date.now()}`,
name: name.trim(),
rows,
cols,
boardPosition,
lockedSeats: validLockedSeats,
};
let updatedList: SeatingTemplate[];
if (editing) {
updatedList = templates.map((t) => (t.id === editing.id ? newTemplate : t));
} else {
updatedList = [...templates, newTemplate];
}
saveTemplates(updatedList);
handleCloseForm();
};
const handleDelete = (tpl: SeatingTemplate) => {
if (confirm(`Bạn có chắc chắn muốn xóa template "${tpl.name}"?`)) {
const updatedList = templates.filter((t) => t.id !== tpl.id);
saveTemplates(updatedList);
if (editing?.id === tpl.id) {
handleCloseForm();
}
}
};
// Helper to render preview grid in edit/create form
const renderDesignerGrid = () => {
const gridItems = [];
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const key = `${r},${c}`;
const isLocked = lockedSeats.includes(key);
gridItems.push(
<div
key={key}
onClick={() => handleToggleLock(r, c)}
className={`seating-designer-cell ${isLocked ? 'locked' : 'available'}`}
style={{
padding: '0.5rem',
border: '1px solid var(--border-color)',
borderRadius: '6px',
textAlign: 'center',
cursor: 'pointer',
fontSize: '0.78rem',
fontWeight: 600,
userSelect: 'none',
backgroundColor: isLocked ? '#e5e7eb' : '#eff6ff',
color: isLocked ? '#9ca3af' : '#1e40af',
borderColor: isLocked ? '#d1d5db' : '#bfdbfe',
transition: 'var(--transition)',
}}
>
{isLocked ? '🔒 Khóa' : `H${r + 1}-C${c + 1}`}
</div>
);
}
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', margin: '1rem 0' }}>
<div style={{ display: 'flex', justifyContent: 'center' }}>
{boardPosition === 'top' && (
<div className="seating-board-label" style={{ width: '60%', textAlign: 'center', background: '#374151', color: '#fff', padding: '0.25rem 0.5rem', borderRadius: '4px', fontSize: '0.8rem', fontWeight: 700 }}>
📢 BẢNG / GIẢNG ĐƯNG (TOP)
</div>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', justifyContent: 'center' }}>
{boardPosition === 'left' && (
<div className="seating-board-label" style={{ writingMode: 'vertical-lr', textOrientation: 'mixed', textAlign: 'center', background: '#374151', color: '#fff', padding: '0.5rem 0.25rem', borderRadius: '4px', fontSize: '0.8rem', fontWeight: 700, minHeight: '100px' }}>
📢 BẢNG (LEFT)
</div>
)}
<div
style={{
display: 'grid',
gridTemplateColumns: `repeat(${cols}, minmax(72px, 1fr))`,
gap: '6px',
flex: 1,
maxHeight: '320px',
overflow: 'auto',
padding: '4px',
}}
>
{gridItems}
</div>
{boardPosition === 'right' && (
<div className="seating-board-label" style={{ writingMode: 'vertical-lr', textOrientation: 'mixed', textAlign: 'center', background: '#374151', color: '#fff', padding: '0.5rem 0.25rem', borderRadius: '4px', fontSize: '0.8rem', fontWeight: 700, minHeight: '100px' }}>
📢 BẢNG (RIGHT)
</div>
)}
</div>
<div style={{ display: 'flex', justifyContent: 'center' }}>
{boardPosition === 'bottom' && (
<div className="seating-board-label" style={{ width: '60%', textAlign: 'center', background: '#374151', color: '#fff', padding: '0.25rem 0.5rem', borderRadius: '4px', fontSize: '0.8rem', fontWeight: 700 }}>
📢 BẢNG / GIẢNG ĐƯNG (BOTTOM)
</div>
)}
</div>
</div>
);
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<h3 style={{ margin: 0, fontSize: '1.1rem', fontWeight: 700 }}>Danh sách mẫu đ chỗ ngồi</h3>
<p style={{ margin: '4px 0 0 0', color: 'var(--text-secondary)', fontSize: '0.82rem' }}>
Thiết lập sẵn cấu trúc hàng, cột, bảng các vị trí máy hỏng hoặc lối đi đ áp dụng nhanh cho lớp hoặc phòng thi.
</p>
</div>
{!creating && !editing && (
<button className="btn btn-primary" onClick={handleOpenCreate}>
+ Thêm đ mẫu
</button>
)}
</div>
{(creating || editing) ? (
<div className="content-card" style={{ padding: '1.5rem', display: 'grid', gridTemplateColumns: '1fr 1.5fr', gap: '2rem' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem', borderRight: '1px solid var(--border-color)', paddingRight: '2rem' }}>
<h4 style={{ margin: 0, fontSize: '0.95rem' }}>{creating ? 'Thêm sơ đồ mẫu mới' : 'Chỉnh sửa sơ đồ mẫu'}</h4>
{error && <div style={{ color: 'var(--danger)', fontSize: '0.82rem', fontWeight: 600 }}>{error}</div>}
<label className="checkbox-label" style={{ display: 'flex', flexDirection: 'column', gap: '4px', alignItems: 'flex-start' }}>
<span style={{ fontSize: '0.82rem', fontWeight: 600 }}>Tên đ</span>
<input
type="text"
className="search-input"
style={{ width: '100%' }}
placeholder="Ví dụ: Phòng Lab 302, Phòng A2"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</label>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
<label className="checkbox-label" style={{ display: 'flex', flexDirection: 'column', gap: '4px', alignItems: 'flex-start' }}>
<span style={{ fontSize: '0.82rem', fontWeight: 600 }}>Số hàng (dọc)</span>
<input
type="number"
min={1}
max={10}
className="search-input"
style={{ width: '100%' }}
value={rows}
onChange={(e) => setRows(Math.max(1, Math.min(10, Number(e.target.value))))}
/>
</label>
<label className="checkbox-label" style={{ display: 'flex', flexDirection: 'column', gap: '4px', alignItems: 'flex-start' }}>
<span style={{ fontSize: '0.82rem', fontWeight: 600 }}>Số cột (ngang)</span>
<input
type="number"
min={1}
max={15}
className="search-input"
style={{ width: '100%' }}
value={cols}
onChange={(e) => setCols(Math.max(1, Math.min(15, Number(e.target.value))))}
/>
</label>
</div>
<label className="checkbox-label" style={{ display: 'flex', flexDirection: 'column', gap: '4px', alignItems: 'flex-start' }}>
<span style={{ fontSize: '0.82rem', fontWeight: 600 }}>Vị trí bảng viết</span>
<select
className="select-filter"
style={{ width: '100%', padding: '0.5rem' }}
value={boardPosition}
onChange={(e) => setBoardPosition(e.target.value as any)}
>
<option value="top">Phía trước (Top)</option>
<option value="bottom">Phía sau (Bottom)</option>
<option value="left">Bên trái (Left)</option>
<option value="right">Bên phải (Right)</option>
</select>
</label>
<div style={{ display: 'flex', gap: '10px', marginTop: '1rem' }}>
<button className="btn btn-primary" style={{ flex: 1 }} onClick={handleSave}>
Lưu lại
</button>
<button className="btn btn-secondary" style={{ flex: 1 }} onClick={handleCloseForm}>
Hủy bỏ
</button>
</div>
</div>
<div>
<h4 style={{ margin: '0 0 4px 0', fontSize: '0.95rem' }}>Thiết kế vị trí khóa</h4>
<p style={{ margin: '0 0 1rem 0', color: 'var(--text-secondary)', fontSize: '0.78rem' }}>
Click chuột vào các ô bên dưới đ chuyển đi trạng thái giữa <strong>Chỗ trống khả dụng</strong> <strong>Vị trí khóa (Không thể xếp sinh viên)</strong>.
</p>
{renderDesignerGrid()}
</div>
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: '1rem' }}>
{templates.map((tpl) => (
<div key={tpl.id} className="content-card" style={{ padding: '1.25rem', display: 'flex', flexDirection: 'column', gap: '0.75rem', position: 'relative' }}>
<div>
<h4 style={{ margin: 0, fontSize: '0.95rem', fontWeight: 700 }}>{tpl.name}</h4>
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', marginTop: '4px' }}>
Kích thước: <strong>{tpl.rows} hàng &times; {tpl.cols} cột</strong> ({tpl.rows * tpl.cols} vị trí)
</div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)' }}>
Bảng : <strong>{tpl.boardPosition.toUpperCase()}</strong>
</div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)' }}>
Vị trí khóa: <strong>{tpl.lockedSeats ? tpl.lockedSeats.length : 0} ô</strong>
</div>
</div>
<div style={{ display: 'flex', gap: '8px', marginTop: 'auto' }}>
<button className="btn btn-secondary btn-sm" style={{ flex: 1 }} onClick={() => handleOpenEdit(tpl)}>
Chỉnh sửa
</button>
<button
className="btn btn-secondary btn-sm text-danger"
style={{ flex: 1, borderColor: '#fca5a5', color: 'var(--danger)' }}
onClick={() => handleDelete(tpl)}
>
Xóa
</button>
</div>
</div>
))}
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,117 @@
import React, { useState } from 'react';
export const StudentAffairsTab: React.FC = () => {
const [showUnderDevModal, setShowUnderDevModal] = useState(false);
return (
<div className="tab-page">
<div className="tab-page-toolbar">
<div className="page-header">
<div className="page-title">
<h1>Công Tác Sinh Viên</h1>
<p>Quản các hoạt đng hỗ trợ, vấn chăm sóc sinh viên tại Rikkei Education</p>
</div>
</div>
</div>
<div className="tab-page-body tab-page-scroll">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: '1.5rem', marginTop: '0.5rem' }}>
<div
className="content-card"
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
cursor: 'pointer',
transition: 'var(--transition)',
position: 'relative',
overflow: 'hidden',
padding: '1.75rem',
}}
onClick={() => setShowUnderDevModal(true)}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-4px)';
e.currentTarget.style.boxShadow = 'var(--shadow-lg)';
e.currentTarget.style.borderColor = 'rgba(187, 33, 38, 0.3)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'none';
e.currentTarget.style.boxShadow = 'none';
e.currentTarget.style.borderColor = 'var(--border-color)';
}}
>
<div>
<div
style={{
width: '48px',
height: '48px',
borderRadius: '12px',
backgroundColor: 'var(--accent-light)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--accent)',
marginBottom: '1.25rem'
}}
>
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<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>
</div>
<h3 style={{ fontSize: '1.1rem', fontWeight: 700, marginBottom: '0.5rem', color: 'var(--text-primary)' }}>Chăm Sóc Sinh Viên</h3>
<p style={{ fontSize: '0.875rem', color: 'var(--text-secondary)', lineHeight: '1.5' }}>
Hỗ trợ vấn, giải quyết các khó khăn, theo sát quá trình học tập đi sống tinh thần của học viên.
</p>
</div>
<div style={{ marginTop: '1.5rem', display: 'flex', alignItems: 'center', color: 'var(--accent)', fontWeight: 600, fontSize: '0.875rem' }}>
Truy cập chức năng
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ marginLeft: '4px' }}>
<path d="M5 12h14M12 5l7 7-7 7" />
</svg>
</div>
</div>
</div>
</div>
{showUnderDevModal && (
<div className="modal-overlay" style={{ zIndex: 1100 }} onClick={() => setShowUnderDevModal(false)}>
<div className="modal-container" style={{ maxWidth: '400px' }} onClick={e => e.stopPropagation()}>
<div className="modal-body" style={{ textAlign: 'center', padding: '2.5rem 2rem' }}>
<div
style={{
width: '64px',
height: '64px',
borderRadius: '50%',
backgroundColor: 'rgba(230, 168, 23, 0.1)',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--warning)',
marginBottom: '1.5rem'
}}
>
<svg viewBox="0 0 24 24" width="32" height="32" fill="none" stroke="currentColor" strokeWidth="2" 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>
</div>
<h2 style={{ fontSize: '1.3rem', fontWeight: 700, marginBottom: '0.75rem', color: 'var(--text-primary)' }}>Đang phát triển</h2>
<p style={{ fontSize: '0.95rem', color: 'var(--text-secondary)', lineHeight: '1.6', marginBottom: '2rem' }}>
Tính năng <strong>Chăm Sóc Sinh Viên</strong> hiện đang đưc phát triển hoàn thiện. Vui lòng quay lại sau!
</p>
<button
type="button"
className="btn btn-primary"
onClick={() => setShowUnderDevModal(false)}
style={{ width: '100%', justifyContent: 'center' }}
>
Đng ý
</button>
</div>
</div>
</div>
)}
</div>
);
};

View File

@@ -12,13 +12,25 @@ interface StudentAvatarProps {
avatar?: string | null; avatar?: string | null;
isOnline?: boolean; isOnline?: boolean;
size?: number; size?: number;
showStatusBadge?: boolean;
} }
export const StudentOnlineBadge: React.FC<{
isOnline: boolean;
size?: 'sm' | 'md';
}> = ({ isOnline, size = 'sm' }) => (
<span className={`student-online-badge ${isOnline ? 'online' : 'offline'} student-online-badge--${size}`}>
<span className="student-online-badge-dot" aria-hidden />
{isOnline ? 'Online' : 'Offline'}
</span>
);
export const StudentAvatar: React.FC<StudentAvatarProps> = ({ export const StudentAvatar: React.FC<StudentAvatarProps> = ({
fullName, fullName,
avatar, avatar,
isOnline = false, isOnline = false,
size = 48, size = 48,
showStatusBadge = true,
}) => { }) => {
const [imgFailed, setImgFailed] = useState(false); const [imgFailed, setImgFailed] = useState(false);
const avatarUrl = normalizeAvatarUrl(avatar); const avatarUrl = normalizeAvatarUrl(avatar);
@@ -30,8 +42,9 @@ export const StudentAvatar: React.FC<StudentAvatarProps> = ({
}, [avatarUrl]); }, [avatarUrl]);
return ( return (
<div className="student-avatar-wrap" style={{ width: size, height: size }}>
<div <div
className={`student-workspace-avatar ${isOnline ? 'online' : ''} ${showImage ? 'has-image' : ''}`} className={`student-workspace-avatar ${isOnline ? 'online' : 'offline'} ${showImage ? 'has-image' : ''}`}
style={{ width: size, height: size, fontSize: size * 0.38 }} style={{ width: size, height: size, fontSize: size * 0.38 }}
> >
{showImage ? ( {showImage ? (
@@ -46,5 +59,12 @@ export const StudentAvatar: React.FC<StudentAvatarProps> = ({
initial initial
)} )}
</div> </div>
{showStatusBadge && (
<span
className={`student-avatar-status-ring ${isOnline ? 'online' : 'offline'}`}
title={isOnline ? 'Đang online' : 'Đang offline'}
/>
)}
</div>
); );
}; };

View File

@@ -1,6 +1,6 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import type { StudentItem, StudentSessionLogItem } from '../api'; import type { StudentItem, StudentSessionLogItem } from '../api';
import { StudentAvatar } from './StudentAvatar'; import { StudentAvatar, StudentOnlineBadge } from './StudentAvatar';
import { ProctorStreamPanels } from './ProctorStreamPanels'; import { ProctorStreamPanels } from './ProctorStreamPanels';
interface StudentDetailModalProps { interface StudentDetailModalProps {
@@ -8,8 +8,27 @@ interface StudentDetailModalProps {
isOnline: boolean; isOnline: boolean;
sessionLog?: StudentSessionLogItem | null; sessionLog?: StudentSessionLogItem | null;
onClose: () => void; 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 formatDuration = (totalSeconds: number) => {
const hrs = Math.floor(totalSeconds / 3600); const hrs = Math.floor(totalSeconds / 3600);
const mins = Math.floor((totalSeconds % 3600) / 60); const mins = Math.floor((totalSeconds % 3600) / 60);
@@ -23,24 +42,34 @@ export const StudentDetailModal: React.FC<StudentDetailModalProps> = ({
isOnline, isOnline,
sessionLog, sessionLog,
onClose, onClose,
initialShowProctor = false,
}) => { }) => {
const [showProctor, setShowProctor] = useState(false); const [showProctor, setShowProctor] = useState(initialShowProctor);
return ( return (
<div className="modal-overlay student-detail-overlay" onClick={onClose}> <div
<div className={`modal-container student-detail-modal ${showProctor ? 'student-detail-modal--proctor' : ''}`} onClick={e => e.stopPropagation()}> className={`modal-overlay student-detail-overlay${showProctor ? ' student-detail-overlay--proctor' : ''}`}
<div className="modal-header"> 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"> <div className="student-detail-header">
<StudentAvatar fullName={student.fullName} avatar={student.avatar} isOnline={isOnline} size={56} /> <StudentAvatar fullName={student.fullName} avatar={student.avatar} isOnline={isOnline} size={showProctor ? 40 : 52} />
<div> <div className="student-detail-title-block">
<h2 className="modal-title" style={{ margin: 0 }}>{student.fullName}</h2> <h2 className="modal-title student-detail-name">{student.fullName}</h2>
<p style={{ margin: '4px 0 0', color: 'var(--text-muted)', fontSize: '0.85rem' }}> <p className="student-detail-sub">
<span style={{ fontFamily: 'monospace', fontWeight: 700, color: 'var(--accent)' }}>{student.studentCode}</span> <span className="student-detail-code">{student.studentCode}</span>
{student.email && <> · {student.email}</>} {student.email && <span className="student-detail-email">{student.email}</span>}
</p> </p>
</div> </div>
</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>
<div className="student-detail-body"> <div className="student-detail-body">
@@ -48,8 +77,8 @@ export const StudentDetailModal: React.FC<StudentDetailModalProps> = ({
<div className="student-detail-meta"> <div className="student-detail-meta">
<div className="student-meta-item"> <div className="student-meta-item">
<span className="student-meta-label">Trạng thái</span> <span className="student-meta-label">Trạng thái</span>
<span className={`student-meta-value ${isOnline ? 'online' : ''}`}> <span className="student-meta-value">
{isOnline ? '● Online' : '○ Offline'} <StudentOnlineBadge isOnline={isOnline} size="md" />
</span> </span>
</div> </div>
{student.phone && ( {student.phone && (
@@ -62,20 +91,20 @@ export const StudentDetailModal: React.FC<StudentDetailModalProps> = ({
<> <>
<div className="student-meta-item"> <div className="student-meta-item">
<span className="student-meta-label">Online (ca)</span> <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)} {formatDuration(sessionLog.onlineSeconds)}
</span> </span>
</div> </div>
<div className="student-meta-item"> <div className="student-meta-item">
<span className="student-meta-label">Offline (ca)</span> <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)} {formatDuration(sessionLog.offlineSeconds)}
</span> </span>
</div> </div>
{sessionLog.wifiSsids && sessionLog.wifiSsids !== '—' && ( {sessionLog.wifiSsids && sessionLog.wifiSsids !== '—' && (
<div className="student-meta-item"> <div className="student-meta-item">
<span className="student-meta-label">WiFi</span> <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} {sessionLog.wifiSsids}
</span> </span>
</div> </div>
@@ -86,14 +115,14 @@ export const StudentDetailModal: React.FC<StudentDetailModalProps> = ({
<button <button
type="button" type="button"
className="btn btn-primary" className={`btn student-detail-proctor-toggle ${showProctor ? 'btn-secondary' : 'btn-primary'}`}
style={{ width: '100%', justifyContent: 'center' }} onClick={() => setShowProctor((v) => !v)}
onClick={() => setShowProctor(v => !v)}
> >
<IconMonitor size={14} />
{showProctor ? 'Ẩn giám sát' : 'Xem webcam & màn hình'} {showProctor ? 'Ẩn giám sát' : 'Xem webcam & màn hình'}
</button> </button>
{!isOnline && !showProctor && ( {!isOnline && !showProctor && (
<p className="schedule-hint" style={{ margin: 0 }}> <p className="student-detail-hint">
Sinh viên offline stream chỉ khi app Simple Care đang chạy. Sinh viên offline stream chỉ khi app Simple Care đang chạy.
</p> </p>
)} )}

View File

@@ -0,0 +1,55 @@
import React, { useState, useEffect } from 'react';
import { API_BASE } from '../api';
interface StudentStreamImageProps {
studentId: number;
kind: 'screen' | 'webcam';
className?: string;
style?: React.CSSProperties;
}
export const StudentStreamImage: React.FC<StudentStreamImageProps> = ({
studentId,
kind,
className,
style,
}) => {
const [url, setUrl] = useState('');
const [error, setError] = useState(false);
useEffect(() => {
const token = localStorage.getItem('sc_staff_token') || '';
const tokenParam = token ? `?token=${encodeURIComponent(token)}` : '';
setUrl(`${API_BASE}/students/${studentId}/stream/${kind}${tokenParam}`);
setError(false);
}, [studentId, kind]);
const handleError = () => {
setError(true);
setTimeout(() => {
const token = localStorage.getItem('sc_staff_token') || '';
const tokenParam = token ? `?token=${encodeURIComponent(token)}` : '';
setUrl(`${API_BASE}/students/${studentId}/stream/${kind}${tokenParam}&t=${Date.now()}`);
setError(false);
}, 2000);
};
if (error || !url) {
return (
<div className="no-stream-placeholder">
<p>Đang chờ {kind === 'screen' ? 'màn hình' : 'webcam'}...</p>
</div>
);
}
return (
<img
src={url}
alt={`${kind === 'screen' ? 'Màn hình' : 'Webcam'} sinh viên`}
className={className}
style={style}
onError={handleError}
draggable={false}
/>
);
};

View File

@@ -2,6 +2,90 @@ import React, { useEffect, useState } from 'react';
import { api } from '../api'; import { api } from '../api';
import type { StudentItem, SyncStatus } 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 = () => { export const StudentsTab: React.FC = () => {
const [students, setStudents] = useState<StudentItem[]>([]); const [students, setStudents] = useState<StudentItem[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
@@ -9,8 +93,6 @@ export const StudentsTab: React.FC = () => {
const [pageSize] = useState(15); const [pageSize] = useState(15);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
// Sync state
const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null); const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null);
const fetchStudents = async () => { const fetchStudents = async () => {
@@ -49,7 +131,6 @@ export const StudentsTab: React.FC = () => {
fetchSyncStatus(); fetchSyncStatus();
}, []); }, []);
// Sync polling logic
useEffect(() => { useEffect(() => {
let timer: any; let timer: any;
if (syncStatus?.running) { if (syncStatus?.running) {
@@ -57,7 +138,7 @@ export const StudentsTab: React.FC = () => {
const isRunning = await fetchSyncStatus(); const isRunning = await fetchSyncStatus();
if (!isRunning) { if (!isRunning) {
clearInterval(timer); clearInterval(timer);
fetchStudents(); // Reload data when sync completes fetchStudents();
} }
}, 2000); }, 2000);
} }
@@ -69,57 +150,85 @@ export const StudentsTab: React.FC = () => {
const handleStartSync = async () => { const handleStartSync = async () => {
try { try {
await api.startStudentsSync(); await api.startStudentsSync();
// Set local state to running to trigger useEffect poller
setSyncStatus({ setSyncStatus({
running: true, running: true,
done: false, done: false,
total: 0, total: 0,
synced: 0, synced: 0,
updatedAt: Date.now() / 1000 updatedAt: Date.now() / 1000,
}); });
} catch (err: any) { } catch (err: any) {
alert(err.message || 'Không thể bắt đầu đồng bộ sinh viên'); 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 =
const syncPercent = syncStatus && syncStatus.total > 0 syncStatus && syncStatus.total > 0
? Math.round((syncStatus.synced / syncStatus.total) * 100) ? Math.round((syncStatus.synced / syncStatus.total) * 100)
: 0; : 0;
const totalPages = Math.ceil(total / pageSize) || 1;
return ( return (
<div className="tab-page"> <div className="tab-page">
<div className="tab-page-toolbar"> <div className="tab-page-toolbar">
<div className="page-header"> <div className="page-header">
<div className="page-title"> <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> <p>Danh sách toàn bộ sinh viên trong hệ thống đưc đng bộ</p>
</div> </div>
<button <button
className="btn btn-primary" className="btn btn-primary"
onClick={handleStartSync} onClick={handleStartSync}
disabled={syncStatus?.running} disabled={syncStatus?.running}
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 ? ( {syncStatus?.running ? (
<> <>
<div className="sync-spinner"></div> Đng bộ... <div className="sync-spinner" style={{ width: 18, height: 18 }} />
Đng bộ... {syncPercent}%
</> </>
) : ( ) : (
'🔄 Đồng bộ toàn bộ SV' <>
<IconRefresh />
Đng bộ toàn bộ SV
</>
)} )}
</button> </button>
</div> </div>
{/* Sync Status Banner */}
{syncStatus && (syncStatus.running || syncStatus.done || syncStatus.error) && ( {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-header">
<div className="sync-title"> <div className="sync-title">
{syncStatus.running && <div className="sync-spinner"></div>} {syncStatus.running && <div className="sync-spinner" />}
<span> <span>
{syncStatus.running && `Đang tải sinh viên... Trang ${syncStatus.page || 0} (${syncPercent}%)`} {syncStatus.running && `Đang tải sinh viên... Trang ${syncStatus.page || 0} (${syncPercent}%)`}
{syncStatus.done && '🎉 Đồng bộ toàn bộ sinh viên hoàn tất!'} {syncStatus.done && (
{syncStatus.error && `❌ Đồng bộ thất bại: ${syncStatus.error}`} <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: 6, color: 'var(--danger)' }}>
<IconAlert />
{syncStatus.error}
</span>
)}
</span> </span>
</div> </div>
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}> <div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>
@@ -128,19 +237,22 @@ export const StudentsTab: React.FC = () => {
</div> </div>
{syncStatus.running && ( {syncStatus.running && (
<div className="sync-bar-container"> <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>
)} )}
<div className="sync-meta"> <div className="sync-meta">
<span>Cập nhật lần cuối: {new Date(syncStatus.updatedAt * 1000).toLocaleString()}</span> <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>
</div> </div>
)} )}
{/* Control filters */}
<div className="control-bar"> <div className="control-bar">
<div className="search-input-wrapper" style={{ maxWidth: '400px' }}> <div className="search-input-wrapper">
<input <input
type="text" type="text"
className="search-input" className="search-input"
@@ -151,104 +263,452 @@ export const StudentsTab: React.FC = () => {
setPage(1); setPage(1);
}} }}
/> />
<span className="search-icon">🔍</span> <span className="search-icon">
<IconSearch />
</span>
</div> </div>
</div> </div>
</div> </div>
{/* Students Table */}
<div className="tab-page-body"> <div className="tab-page-body">
<div className="table-wrapper table-fill"> <div className="table-wrapper table-fill">
{loading ? ( {loading ? (
<div className="empty-state"> <div className="empty-state">
<div className="sync-spinner" style={{ width: '32px', height: '32px' }}></div> <div className="sync-spinner" style={{ width: 40, height: 40 }} />
<p style={{ marginTop: '0.5rem' }}>Đang tải danh sách sinh viên...</p> <p style={{ marginTop: '1rem', fontWeight: 600 }}>Đang tải danh sách sinh viên...</p>
</div> </div>
) : students.length === 0 ? ( ) : students.length === 0 ? (
<div className="empty-state"> <div className="empty-state">
<div className="empty-state-icon">👥</div> <div className="empty-state-icon" style={{ color: 'var(--text-muted)' }}>
<IconInbox />
</div>
<h2>Không sinh viên nào</h2> <h2>Không sinh viên nào</h2>
<p>Hãy thử thay đi bộ lọc hoặc bấm nút "Đồng bộ toàn bộ SV" đ tải dữ liệu.</p> <p>Hãy thử thay đi bộ lọc hoặc bấm "Đồng bộ toàn bộ SV" đ tải dữ liệu.</p>
</div> </div>
) : ( ) : (
<table className="data-table"> <div className="table-scroll-container">
<table className="data-table student-table">
<thead> <thead>
<tr> <tr>
<th> SV</th> <th className="col-code"> SV</th>
<th>Họ Tên</th> <th className="col-name">Họ tên</th>
<th>Thông Tin Liên H</th> <th className="col-contact">Thông tin liên h</th>
<th>Ngày sinh / Giới tính</th> <th className="col-birth">Ngày sinh / Giới tính</th>
<th>Phân hệ</th> <th className="col-system">Phân hệ</th>
<th>Đa điểm</th> <th className="col-location">Đa điểm</th>
<th>Trạng thái</th> <th className="col-status">Trạng thái</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{students.map(st => ( {students.map(st => {
const isActive = st.status === 'Đang học' || st.status === 'active';
return (
<tr key={st.id}> <tr key={st.id}>
<td style={{ fontWeight: 600 }}>{st.studentCode}</td> <td className="col-code" data-label="Mã SV">
<td style={{ color: 'var(--text-primary)', fontWeight: 500 }}> <div className="student-code" title={st.studentCode || undefined}>
{st.fullName} {st.studentCode || '—'}
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>ID: {st.rkId}</div>
</td>
<td>
<div>📧 {st.email}</div>
{st.phone && <div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>📞 {st.phone}</div>}
</td>
<td>
<div>🎂 {st.dateOfBirth ? new Date(st.dateOfBirth).toLocaleDateString('vi-VN') : '—'}</div>
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>
Giới tính: {st.gender === 1 ? 'Nam' : st.gender === 0 ? 'Nữ' : 'Khác'}
</div> </div>
</td> </td>
<td> <td className="col-name" data-label="Họ tên">
<span className="badge badge-muted"> <div className="student-name" title={st.fullName}>
{st.fullName}
</div>
<div className="student-id">ID: {st.rkId}</div>
</td>
<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 className="meta-row meta-row--secondary">
<IconPhone />
<span className="meta-text">{st.phone}</span>
</div>
) : null}
</td>
<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 className="col-system" data-label="Phân hệ">
<span className="badge badge-system">
{st.systemName || 'Chung'} {st.systemName || 'Chung'}
</span> </span>
</td> </td>
<td>{st.location || '—'}</td> <td className="col-location" data-label="Địa điểm">
<td> <span className="location-text">{st.location || '—'}</span>
<span className={`badge ${st.status === 'Đang học' || st.status === 'active' ? 'badge-success' : 'badge-muted'}`}> </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'} {st.status || 'Đang học'}
</span> </span>
</td> </td>
</tr> </tr>
))} );
})}
</tbody> </tbody>
</table> </table>
</div>
)} )}
</div> </div>
</div> </div>
{/* Pagination controls */}
{!loading && students.length > 0 && ( {!loading && students.length > 0 && (
<div className="tab-page-footer"> <div className="tab-page-footer">
<div className="pagination-row"> <div className="pagination-row">
<div> <div className="pagination-info">
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 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>
<div className="pagination-btn-group"> <div className="pagination-btn-group">
<button <button
className="pagination-btn" className="pagination-btn"
onClick={() => setPage(p => Math.max(1, p - 1))} onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1} disabled={page === 1}
aria-label="Trang trước"
> >
<IconChevronLeft />
</button> </button>
<span style={{ display: 'flex', alignItems: 'center', padding: '0 1rem', fontWeight: 600, color: 'var(--text-primary)' }}> <span className="pagination-current">
Trang {page} / {Math.ceil(total / pageSize) || 1} Trang {page} / {totalPages}
</span> </span>
<button <button
className="pagination-btn" className="pagination-btn"
onClick={() => setPage(p => p + 1)} onClick={() => setPage(p => p + 1)}
disabled={page >= Math.ceil(total / pageSize)} disabled={page >= totalPages}
aria-label="Trang sau"
> >
<IconChevronRight />
</button> </button>
</div> </div>
</div> </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> </div>
); );
}; };

View File

@@ -8,6 +8,7 @@ import {
import { OrganizationSection } from './OrganizationSection'; import { OrganizationSection } from './OrganizationSection';
import { NetworkSection } from './NetworkSection'; import { NetworkSection } from './NetworkSection';
import { AppTemplatesSection } from './AppTemplatesSection'; import { AppTemplatesSection } from './AppTemplatesSection';
import { SeatingTemplatesSection } from './SeatingTemplatesSection';
const SECTIONS: { id: SystemSection; icon: React.ReactNode; hint: string }[] = [ const SECTIONS: { id: SystemSection; icon: React.ReactNode; hint: string }[] = [
{ {
@@ -40,6 +41,19 @@ const SECTIONS: { id: SystemSection; icon: React.ReactNode; hint: string }[] = [
</svg> </svg>
), ),
}, },
{
id: 'seating',
hint: 'Sơ đồ chỗ ngồi',
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" />
<line x1="9" y1="3" x2="9" y2="21" />
<line x1="15" y1="3" x2="15" y2="21" />
<line x1="3" y1="9" x2="21" y2="9" />
<line x1="3" y1="15" x2="21" y2="15" />
</svg>
),
},
]; ];
export const SystemTab: React.FC = () => { export const SystemTab: React.FC = () => {
@@ -86,6 +100,7 @@ export const SystemTab: React.FC = () => {
{section === 'organization' && <OrganizationSection />} {section === 'organization' && <OrganizationSection />}
{section === 'network' && <NetworkSection />} {section === 'network' && <NetworkSection />}
{section === 'templates' && <AppTemplatesSection />} {section === 'templates' && <AppTemplatesSection />}
{section === 'seating' && <SeatingTemplatesSection />}
</div> </div>
</div> </div>
); );

View File

@@ -0,0 +1,255 @@
import { useCallback, useEffect, useState } from 'react';
import {
apiFetchClassViolations,
apiFetchExamViolations,
VIOLATION_KIND_OPTIONS,
type StudentViolationItem,
} 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 };
function todayLocal(): string {
const d = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
function formatTime(iso?: string): string {
if (!iso) return '—';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleString('vi-VN');
}
function modeLabel(mode?: string): string {
switch (mode) {
case 'exam': return 'Phòng thi';
case 'learning': return 'Lớp học';
default: return mode || '—';
}
}
/* ─── Component ─────────────────────────────────────────────────────────── */
export const ViolationsPanel = (props: Props) => {
const [date, setDate] = useState(todayLocal);
const [kind, setKind] = useState('');
const [rows, setRows] = useState<StudentViolationItem[]>([]);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState('');
const load = useCallback(async () => {
setLoading(true);
setErr('');
try {
const res =
props.mode === 'class'
? await apiFetchClassViolations(props.classId, date, kind)
: await apiFetchExamViolations(props.examId, date, kind);
setRows(res.data || []);
} catch (e: any) {
setErr(e?.message || 'Không tải được danh sách vi phạm');
setRows([]);
} finally {
setLoading(false);
}
}, [props, date, kind]);
useEffect(() => { void load(); }, [load]);
useEffect(() => {
return onStudentViolation((v) => {
const matches =
props.mode === 'class'
? Number(v.classId) === props.classId
: Number(v.examRoomId) === props.examId ||
(!v.examRoomId && v.monitorMode === 'exam');
if (!matches) return;
if (date !== todayLocal()) return;
void load();
});
}, [props, date, load]);
const isExam = props.mode === 'exam';
return (
<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>
{/* ── Toolbar ── */}
<div className="vp-toolbar attendance-toolbar">
<label className="vp-field attendance-field">
<span>Ngày</span>
<input
type="date"
className="vp-input search-input"
value={date}
onChange={(e) => setDate(e.target.value)}
/>
</label>
<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="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 className="vp-count">{rows.length} vi phạm</span>
</div>
{/* ── Error message ── */}
{err && <div className="vp-error form-error">{err}</div>}
{/* ── Table ── */}
<div className="vp-table-scroll attendance-table-scroll table-wrapper">
{loading && rows.length === 0 ? (
<div className="vp-empty">
<div className="sync-spinner" style={{ width: 28, height: 28 }} />
</div>
) : rows.length === 0 ? (
<div className="vp-empty">
<IconShieldOff size={36} />
<span>Không vi phạm trong ngày đã chọn.</span>
</div>
) : (
<table className="vp-table data-table">
<thead>
<tr>
<th>Thời gian</th>
<th>Sinh viên</th>
<th> SV</th>
<th>Loại</th>
<th>Chi tiết</th>
<th>Ngữ cảnh</th>
</tr>
</thead>
<tbody>
{rows.map((r) => {
const isClose = r.kind === 'app_closed' || r.kind === 'unclean_shutdown';
return (
<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 style={{ fontSize: '0.78rem' }}>{r.studentCode || r.studentRkId}</code></td>
<td>
<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: 320, fontSize: '0.8rem' }} title={r.reason}>{r.reason}</td>
<td style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>{modeLabel(r.monitorMode)}</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
</div>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +1,60 @@
import { useCallback, useEffect, useRef } from 'react'; import { useCallback, useEffect, useRef } from 'react';
import { type ChatMessage, getWsUrl } from '../api'; import { type ChatMessage, getWsUrl } from '../api';
import { useAuth } from '../auth/AuthContext'; import { useAuth } from '../auth/AuthContext';
import { playChatSound } from '../utils/notifySound'; import { playChatSound, playAlertSound } from '../utils/notifySound';
type ChatIncomingHandler = (msg: ChatMessage) => void; type ChatIncomingHandler = (msg: ChatMessage) => void;
export type PresenceUpdate = { studentId: number; online: boolean; classId?: number };
export type StudentViolationEvent = {
studentId: number;
studentName?: string;
studentCode?: string;
kind: string;
reason: string;
monitorMode?: string;
classId?: number;
examRoomId?: number;
};
const handlers = new Set<ChatIncomingHandler>(); const chatHandlers = new Set<ChatIncomingHandler>();
const presenceHandlers = new Set<(p: PresenceUpdate) => void>();
const violationHandlers = new Set<(v: StudentViolationEvent) => void>();
export function onStaffChatMessage(handler: ChatIncomingHandler): () => void { export function onStaffChatMessage(handler: ChatIncomingHandler): () => void {
handlers.add(handler); chatHandlers.add(handler);
return () => { handlers.delete(handler); }; return () => { chatHandlers.delete(handler); };
} }
/** WebSocket luôn bật khi đã login — nhận tin sinh viên realtime */ export function onStudentPresence(handler: (p: PresenceUpdate) => void): () => void {
presenceHandlers.add(handler);
return () => { presenceHandlers.delete(handler); };
}
export function onStudentViolation(handler: (v: StudentViolationEvent) => void): () => void {
violationHandlers.add(handler);
return () => { violationHandlers.delete(handler); };
}
function scheduleBackoff(attempt: number): number {
const base = Math.min(1000 * 2 ** Math.min(attempt, 4), 10000);
return base + Math.floor(Math.random() * 250);
}
export function kindLabel(kind: string): string {
switch (kind) {
case 'app_closed': return 'Tắt ứng dụng';
case 'unclean_shutdown': return 'Tắt đột ngột';
case 'multi_monitor': return 'Nhiều màn hình';
case 'user_switch': return 'Đổi user';
case 'session_change': return 'Khóa / đổi phiên';
case 'virtual_desktop': return 'Desktop ảo';
case 'wifi': return 'WiFi trái phép';
case 'guard': return 'Vi phạm môi trường';
default: return kind || 'Vi phạm';
}
}
/** WebSocket luôn bật khi đã login — chat + presence + violation + keepalive. */
export function StaffChatSocket() { export function StaffChatSocket() {
const { staff, token } = useAuth(); const { staff, token } = useAuth();
const staffIdRef = useRef(0); const staffIdRef = useRef(0);
@@ -21,8 +63,8 @@ export function StaffChatSocket() {
staffIdRef.current = staff?.id ?? 0; staffIdRef.current = staff?.id ?? 0;
}, [staff?.id]); }, [staff?.id]);
const dispatch = useCallback((msg: ChatMessage) => { const dispatchChat = useCallback((msg: ChatMessage) => {
handlers.forEach((h) => h(msg)); chatHandlers.forEach((h) => h(msg));
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -31,26 +73,75 @@ export function StaffChatSocket() {
const wsUrl = getWsUrl(`/ws?role=teacher&staffId=${staff.id}`); const wsUrl = getWsUrl(`/ws?role=teacher&staffId=${staff.id}`);
let ws: WebSocket | null = null; let ws: WebSocket | null = null;
let retryTimer: ReturnType<typeof setTimeout> | null = null; let retryTimer: ReturnType<typeof setTimeout> | null = null;
let pingTimer: ReturnType<typeof setInterval> | null = null;
let closed = false; let closed = false;
let attempt = 0;
const clearPing = () => {
if (pingTimer) {
clearInterval(pingTimer);
pingTimer = null;
}
};
const connect = () => { const connect = () => {
if (closed) return; if (closed) return;
ws = new WebSocket(wsUrl); ws = new WebSocket(wsUrl);
ws.onopen = () => {
attempt = 0;
clearPing();
pingTimer = setInterval(() => {
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ event: 'client:ping', data: {} }));
}
}, 15000);
};
ws.onmessage = (ev) => { ws.onmessage = (ev) => {
try { try {
const payload = JSON.parse(ev.data); const payload = JSON.parse(ev.data);
if (payload.event === 'client:pong') return;
if (payload.event === 'presence:update') {
const studentId = Number(payload.data?.studentId ?? 0);
if (!studentId) return;
presenceHandlers.forEach((h) => h({
studentId,
online: !!payload.data?.online,
classId: Number(payload.data?.classId ?? 0) || undefined,
}));
return;
}
if (payload.event === 'teacher:student-violation') {
const studentId = Number(payload.data?.studentId ?? 0);
if (!studentId) return;
playAlertSound();
violationHandlers.forEach((h) => h({
studentId,
studentName: payload.data?.studentName || '',
studentCode: payload.data?.studentCode || '',
kind: String(payload.data?.kind || ''),
reason: String(payload.data?.reason || ''),
monitorMode: payload.data?.monitorMode || '',
classId: Number(payload.data?.classId ?? 0) || undefined,
examRoomId: Number(payload.data?.examRoomId ?? 0) || undefined,
}));
return;
}
if (payload.event !== 'chat:message') return; if (payload.event !== 'chat:message') return;
const msg = payload.data as ChatMessage; const msg = payload.data as ChatMessage;
const targetStaff = Number(msg.targetStaffId ?? msg.staffId ?? 0); const targetStaff = Number(msg.targetStaffId ?? msg.staffId ?? 0);
if (targetStaff > 0 && targetStaff !== staffIdRef.current) return; if (targetStaff > 0 && targetStaff !== staffIdRef.current) return;
dispatch(msg); dispatchChat(msg);
} catch { } catch {
/* ignore */ /* ignore */
} }
}; };
ws.onclose = () => { ws.onclose = () => {
clearPing();
if (!closed) { if (!closed) {
retryTimer = setTimeout(connect, 3000); retryTimer = setTimeout(connect, scheduleBackoff(attempt++));
} }
}; };
}; };
@@ -59,12 +150,13 @@ export function StaffChatSocket() {
return () => { return () => {
closed = true; closed = true;
clearPing();
if (retryTimer) clearTimeout(retryTimer); if (retryTimer) clearTimeout(retryTimer);
ws?.close(); ws?.close();
}; };
}, [token, staff?.id, dispatch]); }, [token, staff?.id, dispatchChat]);
return null; return null;
} }
export { playChatSound }; export { playChatSound, playAlertSound };

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
export type SystemSection = 'organization' | 'network' | 'templates'; export type SystemSection = 'organization' | 'network' | 'templates' | 'seating';
export type TabId = 'dashboard' | 'classes' | 'students' | 'learning' | 'exams' | 'system' | 'profile'; export type TabId = 'dashboard' | 'classes' | 'students' | 'learning' | 'exams' | 'system' | 'profile' | 'student-affairs' | 'applications';
export interface NavEntry { export interface NavEntry {
id: string; id: string;
@@ -17,6 +17,7 @@ export const SYSTEM_SECTION_LABELS: Record<SystemSection, string> = {
organization: 'Tổ chức', organization: 'Tổ chức',
network: 'Gói mạng', network: 'Gói mạng',
templates: 'Khung ứng dụng', templates: 'Khung ứng dụng',
seating: 'Sơ đồ chỗ ngồi',
}; };
export const TAB_LABELS: Record<TabId, string> = { export const TAB_LABELS: Record<TabId, string> = {
@@ -27,6 +28,8 @@ export const TAB_LABELS: Record<TabId, string> = {
exams: 'Phòng thi', exams: 'Phòng thi',
system: 'Hệ thống', system: 'Hệ thống',
profile: 'Tài khoản của tôi', profile: 'Tài khoản của tôi',
'student-affairs': 'Công Tác Sinh Viên',
applications: 'Ứng Dụng',
}; };
const HISTORY_KEY = 'sc_nav_history'; const HISTORY_KEY = 'sc_nav_history';
@@ -40,12 +43,14 @@ function isTabId(value: string | null): value is TabId {
value === 'learning' || value === 'learning' ||
value === 'exams' || value === 'exams' ||
value === 'system' || value === 'system' ||
value === 'profile' value === 'profile' ||
value === 'student-affairs' ||
value === 'applications'
); );
} }
function isSystemSection(value: string | null): value is SystemSection { function isSystemSection(value: string | null): value is SystemSection {
return value === 'organization' || value === 'network' || value === 'templates'; return value === 'organization' || value === 'network' || value === 'templates' || value === 'seating';
} }
function resolveTabAndSection(tabParam: string | null): { tab: TabId; systemSection: SystemSection } { function resolveTabAndSection(tabParam: string | null): { tab: TabId; systemSection: SystemSection } {

View File

@@ -35,3 +35,29 @@ export function playChatSound() {
playTone(880, t, 0.12); playTone(880, t, 0.12);
playTone(1174, t + 0.14, 0.14); playTone(1174, t + 0.14, 0.14);
} }
/** Louder alert for student violations (quit / kill app) */
export function playAlertSound() {
const ctx = getAudioCtx();
if (!ctx) return;
if (ctx.state === 'suspended') {
void ctx.resume();
}
const playTone = (freq: number, start: number, duration: number) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'square';
osc.frequency.value = freq;
gain.gain.setValueAtTime(0.0001, start);
gain.gain.exponentialRampToValueAtTime(0.1, start + 0.02);
gain.gain.exponentialRampToValueAtTime(0.0001, start + duration);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(start);
osc.stop(start + duration + 0.02);
};
const t = ctx.currentTime;
playTone(520, t, 0.16);
playTone(390, t + 0.18, 0.2);
playTone(520, t + 0.4, 0.18);
}

View File

@@ -45,6 +45,7 @@ func FindActiveClassAndPeriodForStudent(db *gorm.DB, studentRkID int64) (int64,
var schedules []models.ClassSchedule var schedules []models.ClassSchedule
if err := db.Where("class_rk_id IN ? AND is_active = ?", classRkIDs, true).Find(&schedules).Error; err != nil || len(schedules) == 0 { if err := db.Where("class_rk_id IN ? AND is_active = ?", classRkIDs, true).Find(&schedules).Error; err != nil || len(schedules) == 0 {
// Không có lớp nào có lịch học → trả về lớp đầu tiên
return classRkIDs[0], 1 return classRkIDs[0], 1
} }
@@ -52,6 +53,7 @@ func FindActiveClassAndPeriodForStudent(db *gorm.DB, studentRkID int64) (int64,
day := scheduleDayIndex(now) day := scheduleDayIndex(now)
currMin := now.Hour()*60 + now.Minute() currMin := now.Hour()*60 + now.Minute()
// Ưu tiên 1: Lớp có ca đang diễn ra ngay lúc này
for _, s := range schedules { for _, s := range schedules {
if s.DayOfWeek != day { if s.DayOfWeek != day {
continue continue
@@ -67,6 +69,18 @@ func FindActiveClassAndPeriodForStudent(db *gorm.DB, studentRkID int64) (int64,
} }
} }
// Ưu tiên 2: Fallback về lớp đầu tiên CÓ lịch học (ưu tiên hơn lớp chỉ có allowed_apps)
// Lấy tập hợp các class có schedules
scheduledClassSet := map[int64]bool{}
for _, s := range schedules {
scheduledClassSet[s.ClassRkID] = true
}
for _, id := range classRkIDs {
if scheduledClassSet[id] {
return id, 1
}
}
return classRkIDs[0], 1 return classRkIDs[0], 1
} }

View File

@@ -48,11 +48,15 @@ func AutoMigrate(db *gorm.DB) error {
&models.ClassSchedule{}, &models.ClassSchedule{},
&models.ClassCourse{}, &models.ClassCourse{},
&models.ClassAllowedApp{}, &models.ClassAllowedApp{},
&models.ClassSeatingLayout{},
&models.ExamSeatingLayout{},
&models.StaffMyClass{},
&models.AppPoolEntry{}, &models.AppPoolEntry{},
&models.AppTemplate{}, &models.AppTemplate{},
&models.WifiPoolEntry{}, &models.WifiPoolEntry{},
&models.AcceptedWifi{}, &models.AcceptedWifi{},
&models.StudentSession{}, &models.StudentSession{},
&models.StudentViolation{},
&models.AttendanceResult{}, &models.AttendanceResult{},
&models.StaffAccount{}, &models.StaffAccount{},
&models.EmailDomain{}, &models.EmailDomain{},
@@ -66,6 +70,10 @@ func AutoMigrate(db *gorm.DB) error {
&models.StaffGitHubAuth{}, &models.StaffGitHubAuth{},
&models.StaffGitHubRepo{}, &models.StaffGitHubRepo{},
&models.SystemSetting{}, &models.SystemSetting{},
&models.GuideLink{},
&models.AppDownload{},
&models.AppGuide{},
&models.LocalLeaveRequest{},
); err != nil { ); err != nil {
return err return err
} }

View File

@@ -54,6 +54,11 @@ func ExamRoomCanCancel(room models.ExamRoom, now time.Time) bool {
return ExamRoomIsLive(room, now) return ExamRoomIsLive(room, now)
} }
// ExamRoomCanExtend — gia hạn giờ kết thúc khi phòng đang thi.
func ExamRoomCanExtend(room models.ExamRoom, now time.Time) bool {
return ExamRoomIsLive(room, now)
}
// ProcessExamRoomLifecycle đánh dấu phòng ready đã quá giờ kết thúc. // ProcessExamRoomLifecycle đánh dấu phòng ready đã quá giờ kết thúc.
func ProcessExamRoomLifecycle(db *gorm.DB) { func ProcessExamRoomLifecycle(db *gorm.DB) {
now := time.Now() now := time.Now()

View File

@@ -0,0 +1,148 @@
package handlers
import (
"strconv"
"server/internal/models"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
)
// GET /api/public/app-downloads
func ListAppDownloadsPublicHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
var apps []models.AppDownload
if err := db.Order("created_at desc").Find(&apps).Error; err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Không thể tải danh sách ứng dụng"})
}
return c.JSON(fiber.Map{"data": apps})
}
}
// GET /api/app-downloads
func ListAppDownloadsHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
var apps []models.AppDownload
if err := db.Order("created_at desc").Find(&apps).Error; err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Không thể tải danh sách ứng dụng"})
}
return c.JSON(fiber.Map{"data": apps})
}
}
// POST /api/app-downloads
func CreateAppDownloadHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
if !isSuperAdmin(c) {
return c.Status(403).JSON(fiber.Map{"error": "Bạn không có quyền thực hiện chức năng này"})
}
type Request struct {
Name string `json:"name"`
Description string `json:"description"`
DownloadURL string `json:"downloadUrl"`
Platform string `json:"platform"`
}
var req Request
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid request body"})
}
if req.Name == "" || req.DownloadURL == "" {
return c.Status(400).JSON(fiber.Map{"error": "Tên ứng dụng và đường dẫn tải không được để trống"})
}
app := models.AppDownload{
Name: req.Name,
Description: req.Description,
DownloadURL: req.DownloadURL,
Platform: req.Platform,
}
if err := db.Create(&app).Error; err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Lưu ứng dụng thất bại"})
}
return c.JSON(fiber.Map{"ok": true, "data": app})
}
}
// PUT /api/app-downloads/:id
func UpdateAppDownloadHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
if !isSuperAdmin(c) {
return c.Status(403).JSON(fiber.Map{"error": "Bạn không có quyền thực hiện chức năng này"})
}
idParam := c.Params("id")
id, err := strconv.ParseUint(idParam, 10, 32)
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid ID"})
}
var app models.AppDownload
if err := db.First(&app, uint(id)).Error; err != nil {
return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy ứng dụng"})
}
type Request struct {
Name string `json:"name"`
Description string `json:"description"`
DownloadURL string `json:"downloadUrl"`
Platform string `json:"platform"`
}
var req Request
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid request body"})
}
if req.Name == "" || req.DownloadURL == "" {
return c.Status(400).JSON(fiber.Map{"error": "Tên ứng dụng và đường dẫn tải không được để trống"})
}
app.Name = req.Name
app.Description = req.Description
app.DownloadURL = req.DownloadURL
app.Platform = req.Platform
if err := db.Save(&app).Error; err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Cập nhật ứng dụng thất bại"})
}
return c.JSON(fiber.Map{"ok": true, "data": app})
}
}
// DELETE /api/app-downloads/:id
func DeleteAppDownloadHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
if !isSuperAdmin(c) {
return c.Status(403).JSON(fiber.Map{"error": "Bạn không có quyền thực hiện chức năng này"})
}
idParam := c.Params("id")
id, err := strconv.ParseUint(idParam, 10, 32)
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid ID"})
}
var app models.AppDownload
if err := db.First(&app, uint(id)).Error; err != nil {
return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy ứng dụng"})
}
if err := db.Delete(&app).Error; err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Xóa ứng dụng thất bại"})
}
return c.JSON(fiber.Map{"ok": true})
}
}
// GET /api/public/guide-links
func ListGuideLinksPublicHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
var links []models.GuideLink
if err := db.Order("created_at desc").Find(&links).Error; err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Không thể tải danh sách tài liệu"})
}
return c.JSON(fiber.Map{"data": links})
}
}

View File

@@ -0,0 +1,129 @@
package handlers
import (
"strconv"
"server/internal/models"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
)
// GET /api/public/app-guides
func ListAppGuidesPublicHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
var guides []models.AppGuide
if err := db.Order("created_at desc").Find(&guides).Error; err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Không thể tải danh sách tài liệu hướng dẫn"})
}
return c.JSON(fiber.Map{"data": guides})
}
}
// GET /api/app-guides
func ListAppGuidesHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
var guides []models.AppGuide
if err := db.Order("created_at desc").Find(&guides).Error; err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Không thể tải danh sách tài liệu hướng dẫn"})
}
return c.JSON(fiber.Map{"data": guides})
}
}
// POST /api/app-guides
func CreateAppGuideHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
if !isSuperAdmin(c) {
return c.Status(403).JSON(fiber.Map{"error": "Bạn không có quyền thực hiện chức năng này"})
}
type Request struct {
Title string `json:"title"`
URL string `json:"url"`
}
var req Request
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid request body"})
}
if req.Title == "" || req.URL == "" {
return c.Status(400).JSON(fiber.Map{"error": "Tiêu đề và đường dẫn không được để trống"})
}
guide := models.AppGuide{
Title: req.Title,
URL: req.URL,
}
if err := db.Create(&guide).Error; err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Lưu tài liệu hướng dẫn thất bại"})
}
return c.JSON(fiber.Map{"ok": true, "data": guide})
}
}
// PUT /api/app-guides/:id
func UpdateAppGuideHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
if !isSuperAdmin(c) {
return c.Status(403).JSON(fiber.Map{"error": "Bạn không có quyền thực hiện chức năng này"})
}
idParam := c.Params("id")
id, err := strconv.ParseUint(idParam, 10, 32)
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid ID"})
}
var guide models.AppGuide
if err := db.First(&guide, uint(id)).Error; err != nil {
return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy tài liệu hướng dẫn"})
}
type Request struct {
Title string `json:"title"`
URL string `json:"url"`
}
var req Request
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid request body"})
}
if req.Title == "" || req.URL == "" {
return c.Status(400).JSON(fiber.Map{"error": "Tiêu đề và đường dẫn không được để trống"})
}
guide.Title = req.Title
guide.URL = req.URL
if err := db.Save(&guide).Error; err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Cập nhật tài liệu hướng dẫn thất bại"})
}
return c.JSON(fiber.Map{"ok": true, "data": guide})
}
}
// DELETE /api/app-guides/:id
func DeleteAppGuideHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
if !isSuperAdmin(c) {
return c.Status(403).JSON(fiber.Map{"error": "Bạn không có quyền thực hiện chức năng này"})
}
idParam := c.Params("id")
id, err := strconv.ParseUint(idParam, 10, 32)
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid ID"})
}
var guide models.AppGuide
if err := db.First(&guide, uint(id)).Error; err != nil {
return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy tài liệu hướng dẫn"})
}
if err := db.Delete(&guide).Error; err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Xóa tài liệu hướng dẫn thất bại"})
}
return c.JSON(fiber.Map{"ok": true})
}
}

View File

@@ -2,6 +2,7 @@ package handlers
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"strconv" "strconv"
"time" "time"
@@ -542,3 +543,154 @@ func ListAttendanceShiftsHandler(db *gorm.DB) fiber.Handler {
return c.JSON(fiber.Map{"data": out, "date": date, "dayOfWeek": dayOfWeek}) return c.JSON(fiber.Map{"data": out, "date": date, "dayOfWeek": dayOfWeek})
} }
} }
// GET /api/classes/:rkId/leave-requests
func GetLeaveRequestsHandler(db *gorm.DB, qldtClient *qldt.Client) fiber.Handler {
return func(c *fiber.Ctx) error {
classRkID, _ := strconv.ParseInt(c.Params("rkId"), 10, 64)
courseID, _ := strconv.ParseInt(c.Query("courseId"), 10, 64)
date := c.Query("date", qldt.NowDateVN())
if courseID == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Missing courseId"})
}
token := GetQldtToken(db)
if token == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "QLDT_TOKEN chưa cấu hình"})
}
body, err := qldtClient.GetLeaveRequests(context.Background(), token, classRkID, courseID, date)
if err != nil {
return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{"error": err.Error()})
}
var data []map[string]interface{}
if err := json.Unmarshal(body, &data); err != nil {
var fallback any
if errFallback := json.Unmarshal(body, &fallback); errFallback == nil {
return c.JSON(fallback)
}
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to parse QLDT response"})
}
// Ghi đè trạng thái duyệt local nếu có
var localReqs []models.LocalLeaveRequest
if err := db.Find(&localReqs).Error; err == nil && len(localReqs) > 0 {
localMap := make(map[int64]string)
for _, lr := range localReqs {
localMap[lr.LeaveID] = lr.Status
}
for i, item := range data {
if idVal, ok := item["id"]; ok {
var idInt int64
switch v := idVal.(type) {
case float64:
idInt = int64(v)
case int64:
idInt = v
case int:
idInt = int64(v)
}
if localStatus, found := localMap[idInt]; found {
data[i]["status"] = localStatus
}
}
}
}
return c.JSON(data)
}
}
// POST /api/classes/:rkId/leave-requests/:leaveId/status
func UpdateLeaveStatusHandler(db *gorm.DB, qldtClient *qldt.Client) fiber.Handler {
return func(c *fiber.Ctx) error {
classRkID, _ := strconv.ParseInt(c.Params("rkId"), 10, 64)
leaveID, _ := strconv.ParseInt(c.Params("leaveId"), 10, 64)
var req struct {
Status string `json:"status"` // "Phê duyệt" or "Từ chối"
StudentRkID int64 `json:"studentRkId"`
Date string `json:"date"`
Period int `json:"period"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid body payload"})
}
if req.Status != "Phê duyệt" && req.Status != "Từ chối" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Trạng thái phê duyệt không hợp lệ"})
}
// 1. Lưu trạng thái đơn phép vào database local thay vì gọi QLDT Portal API
var localLeave models.LocalLeaveRequest
errLocal := db.Where("leave_id = ?", leaveID).First(&localLeave).Error
if errLocal == gorm.ErrRecordNotFound {
localLeave = models.LocalLeaveRequest{
LeaveID: leaveID,
Status: req.Status,
}
if err := db.Create(&localLeave).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Lỗi lưu đơn phép local: " + err.Error()})
}
} else if errLocal == nil {
localLeave.Status = req.Status
if err := db.Save(&localLeave).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Lỗi cập nhật đơn phép local: " + err.Error()})
}
} else {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": errLocal.Error()})
}
// 2. Nếu status là "Phê duyệt", tự động cập nhật điểm danh trong hệ thống của chúng ta thành "Nghỉ có phép"
if req.Status == "Phê duyệt" {
if req.StudentRkID == 0 || req.Date == "" || req.Period == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Thiếu thông tin cập nhật điểm danh (studentRkId, date, period)"})
}
dayOfWeek, err := scheduleDayFromDate(req.Date)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Ngày không hợp lệ"})
}
sched, err := resolveScheduleForPeriod(db, classRkID, dayOfWeek, req.Period)
if err != nil || sched == nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Không tìm thấy lịch học cho ca này"})
}
// Cập nhật hoặc Tạo mới AttendanceResult
var row models.AttendanceResult
errFind := db.Where("class_rk_id = ? AND session_date = ? AND period = ? AND student_rk_id = ?",
classRkID, req.Date, req.Period, req.StudentRkID).First(&row).Error
if errFind == nil {
// Cập nhật hàng có sẵn
row.Status = 1 // Nghỉ có phép
row.StatusLabel = "Nghỉ có phép"
row.StatusEditedByTeacher = true
if err := db.Save(&row).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Lỗi lưu điểm danh: " + err.Error()})
}
} else if errFind == gorm.ErrRecordNotFound {
// Tạo mới hàng
row = models.AttendanceResult{
ClassRkID: classRkID,
SessionDate: req.Date,
Period: req.Period,
StudentRkID: req.StudentRkID,
Status: 1,
StatusLabel: "Nghỉ có phép",
StatusEditedByTeacher: true,
}
if err := db.Create(&row).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Lỗi tạo điểm danh: " + err.Error()})
}
} else {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": errFind.Error()})
}
}
return c.JSON(fiber.Map{"ok": true, "message": "Cập nhật đơn xin nghỉ thành công!"})
}
}

View File

@@ -14,6 +14,7 @@ import (
"time" "time"
internalDb "server/internal/db" internalDb "server/internal/db"
"server/internal/middleware"
"server/internal/models" "server/internal/models"
internalWs "server/internal/websocket" internalWs "server/internal/websocket"
@@ -21,7 +22,10 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
const defaultExamAllowedApps = "chrome,msedge,edge,acrobat,acrord32,foxit,wails,simple_care,client"
const systemAllowedApps = "explorer.exe,taskmgr.exe,cmd.exe,powershell.exe,conhost.exe,client.exe,simple_care_v1.0.exe,simple_care.exe,wails.exe,msedgewebview2.exe,code.exe,cursor.exe,windsurf.exe,goland.exe,goland64.exe,idea64.exe,clion64.exe,webstorm64.exe,pycharm64.exe,rider64.exe,studio64.exe,eclipse.exe,sublime_text.exe,notepad++.exe,devenv.exe,git-bash.exe,bash.exe,chsime.exe,ctfmon.exe,unikey.exe,unikeyvnt.exe,gotiengviet.exe,openkey.exe,openkey64.exe,applicationframehost.exe,dwm.exe,csrss.exe,consent.exe,finder,dock,windowserver,loginwindow,systemuiserver,controlcenter,notificationcenter,spotlight,vietnamese,pboard,securityagent,keychain,coreaudiod,terminal,iterm,iterm2,zsh,sh,osascript,system events,gnome-shell,mutter,xwayland,xorg,plasmashell,hyprland,sway,waybar,rofi,gdm3,sddm,lightdm,gnome-terminal,ptyxis,konsole,fcitx5,fcitx,ibus-daemon,ibus-x11,ibus-,dbus-daemon,systemd,nm-applet,blueman-applet,kdeconnectd,shellexperiencehost.exe,startmenuexperiencehost.exe,searchhost.exe,searchapp.exe,searchindexer.exe,sihost.exe,taskhostw.exe,taskbar.exe,lockapp.exe,logonui.exe,winlogon.exe,userinit.exe,runtimebroker.exe,backgroundtaskhost.exe,wwahost.exe,microsoftedgecp.exe,textinputhost.exe,chsime.exe,imetip.exe,imecmnt.exe,googlepinyin.exe,baidu.exe,credentialuibroker.exe,smartscreen.exe,securityhealthsystray.exe,securityhealthservice.exe,wscsvc.exe,msseces.exe,msmpeng.exe,nisSrv.exe,notificationplatformcontroller,systemsettings.exe,settingssynchostservice.exe,settingssynchost.exe,regsvc.exe,spoolsv.exe,tabtip.exe,tabtip32.exe,onedrive.exe,onedriveupdater.exe,narrator.exe,magnify.exe,osk.exe,audiodg.exe,sndvol.exe,cmediaaudiocontrolpanel.exe,wuauclt.exe,musnotifyicon.exe,windowsstore.exe,winstore.app.exe,nvdisplay.container.exe,nvcontainer.exe,nvinject.exe,nvtelemetrycontainer.exe,nvvsvc.exe,nvspcaps64.exe,geforce experience.exe,radeonsoftware.exe,amddvr.exe,igfxtray.exe,igfxem.exe,igfxhk.exe,hkcmd.exe,atk hub.exe,atkex.exe,asusoptimization.exe,avastui.exe,avgui.exe,mbam.exe,mbamtray.exe,bdagent.exe,uiseagnt.exe,eguiproxy.exe,resmon.exe,perfmon.exe,mmc.exe,pwsh.exe,windowsterminal.exe,wt.exe,mintty.exe,wsl.exe,wslhost.exe,launchpad,mission control,exposé,universalaccessd,accessibilityuiagent,inputmethodkit,ibus,hiragana kakomi input,kinput2,squirrel,scim,kotoeri,pinyin - simplified,zhuyin - traditional,abc,keychainservicesagent,trustd,opendirectoryd,authorizationhost,securityd,biometricd,touchidd,audioundockhelper,audio midi setup,noiseremoval,networkd,nesessionmanager,scutil,configd,mDNSResponder,mds,mds_stores,mdworker,mdworker_shared,bird,cloudd,com.apple.icloud,cloudphotod,nsurlsessiond,system preferences,system settings,software update,app store,screensaver engine,screensharingd,com.apple.screensaver,colorsyncd,colorsync utility,nightshift,display menu,printingproxy,cupsd,crashreporter,diagnosticsd,spindump,reportmemoryexception,webkit,com.apple.webkit,com.apple.webkit.networking,applescriptkit,applescript runner,rustdesk,anydesk,teamviewer,screen sharing,wezterm,kitty,alacritty,hyper,git,git-credential-manager,git-credential-osxkeychain,github desktop,sourcetree,fork,docker,docker desktop,com.docker,mutter-x11-fram,xorg,Xorg,x11,kwin_wayland,kwin_x11,wayfire,river,labwc,openbox,i3,i3bar,awesome,bspwm,xfwm4,marco,compiz,picom,compton,wl-paste,wl-copy,wlr-randr,kanshi,wlsunset,gammastep,swaylock,swayidle,swaybg,swaync,eww,ags,wofi,fuzzel,tofi,dmenu,bemenu,gdm,lxdm,slim,greetd,gnome-session-bi,lxsession,startx,xinit,gnome-terminal-,foot,xfce4-terminal,tilix,terminator,urxvt,rxvt,sakura,st,fish,dash,ksh,fcitx-,uim,sogou-qimpanel,gcin,kimpanel,gjs,gsd-,goa-identity-ser,evolution-,gnome-keyring-d,gnome-keyring,polkit-gnome-au,polkitd,gnome-settings-d,gnome-initial-se,gnome-control-ce,gvfsd,gvfsd-,tracker-miner-,tracker3,zeitgeist,zeitgeist-,accounts-daemon,colord,power-profiles-,fprintd,fwupd,udisksd,upowerd,packagekitd,nm-dispatcher,at-spi,at-spi-bus-laun,at-spi2-registr,dbus-launch,snapd-,snapd,nm-tray,network-manager-,blueman-tray,blueman-manager,kdeconnect-indi,dunst,mako,notify-osd,xfce4-notifyd,fnott,lxqt-policykit-,xfce-polkit,mate-polkit,pkttyagent,copyq,clipit,xclip,xsel,clipman,greenclip,glycin,zenity,yad,kdialog,webkit2gtk,WebKitWebProcess,WebKitNetworkPro,rustdesk-bin,remmina,clion,webstorm,pycharm,rider,studio,simple_care_v1.0,egui.exe,microsoftsecurityapp.exe,msascuil.exe,pickerhost.exe,seccenter.exe,mc-web-view.exe,rsappui.exe,avpui.exe,ksdeui.exe,n360.exe,nortonsecurity.exe,sophos ui.exe,bdwtxag.exe,activity monitor,passwords,xprotectservice,xprotect,malware removal tool,mrt,avast,bitdefender,norton,sophos,malwarebytes,eset,little snitch,lulu,update-notifier,apport-gtk,kgpg,ksecretd,clamtk,clamav,seahorse,kwalletd,gufw,firewalld,pkexec"
func examUploadRoot() string { func examUploadRoot() string {
if v := os.Getenv("UPLOAD_DIR"); v != "" { if v := os.Getenv("UPLOAD_DIR"); v != "" {
@@ -49,28 +53,23 @@ func validateExamRoomForPublish(db *gorm.DB, room *models.ExamRoom) []string {
if strings.TrimSpace(room.AllowedApps) == "" { if strings.TrimSpace(room.AllowedApps) == "" {
issues = append(issues, "Chưa cấu hình ứng dụng được phép khi thi") issues = append(issues, "Chưa cấu hình ứng dụng được phép khi thi")
} }
var paperCount int64
_ = db.Model(&models.ExamPaper{}).Where("exam_room_id = ?", room.ID).Count(&paperCount).Error
if paperCount == 0 {
issues = append(issues, "Chưa có gói đề (PDF + tài nguyên)")
}
var students []models.ExamRoomStudent var students []models.ExamRoomStudent
_ = db.Where("exam_room_id = ?", room.ID).Find(&students).Error _ = db.Where("exam_room_id = ?", room.ID).Find(&students).Error
if len(students) == 0 { if len(students) == 0 {
issues = append(issues, "Chưa có sinh viên trong phòng thi") issues = append(issues, "Chưa có sinh viên trong phòng thi")
} }
unassigned := 0
for _, s := range students {
if s.AssignedPaperID == nil {
unassigned++
}
}
if unassigned > 0 {
issues = append(issues, fmt.Sprintf("Còn %d sinh viên chưa được chia gói đề", unassigned))
}
return issues return issues
} }
func canModifyExamPapers(db *gorm.DB, room models.ExamRoom) bool {
if room.Status != models.ExamStatusDraft && room.Status != models.ExamStatusReady {
return false
}
var sentCount int64
_ = db.Model(&models.ExamRoomStudent{}).Where("exam_room_id = ? AND paper_sent_at IS NOT NULL", room.ID).Count(&sentCount).Error
return sentCount == 0
}
// ProcessScheduledExamSends gửi đề đã hẹn giờ — gọi định kỳ từ main. // ProcessScheduledExamSends gửi đề đã hẹn giờ — gọi định kỳ từ main.
func ProcessScheduledExamSends(db *gorm.DB) { func ProcessScheduledExamSends(db *gorm.DB) {
internalDb.ProcessExamRoomLifecycle(db) internalDb.ProcessExamRoomLifecycle(db)
@@ -122,8 +121,15 @@ func deliverExamPaper(db *gorm.DB, enrollmentID uint) {
// GET /api/exam-rooms // GET /api/exam-rooms
func ListExamRoomsHandler(db *gorm.DB) fiber.Handler { func ListExamRoomsHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error { return func(c *fiber.Ctx) error {
staffID := middleware.StaffIDFromCtx(c)
mineOnly := c.Query("mine") == "1" || c.Query("mine") == "true"
q := db.Order("start_time desc")
if mineOnly && staffID > 0 {
q = q.Where("created_by_staff_id = ?", staffID)
}
var rooms []models.ExamRoom var rooms []models.ExamRoom
if err := db.Order("start_time desc").Find(&rooms).Error; err != nil { if err := q.Find(&rooms).Error; err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()}) return c.Status(500).JSON(fiber.Map{"error": err.Error()})
} }
type row struct { type row struct {
@@ -131,6 +137,7 @@ func ListExamRoomsHandler(db *gorm.DB) fiber.Handler {
StudentCount int `json:"studentCount"` StudentCount int `json:"studentCount"`
PaperCount int `json:"paperCount"` PaperCount int `json:"paperCount"`
DisplayStatus string `json:"displayStatus"` DisplayStatus string `json:"displayStatus"`
CreatedByStaffID uint `json:"createdByStaffId"`
} }
out := make([]row, 0, len(rooms)) out := make([]row, 0, len(rooms))
now := time.Now() now := time.Now()
@@ -146,6 +153,7 @@ func ListExamRoomsHandler(db *gorm.DB) fiber.Handler {
StudentCount: int(sc), StudentCount: int(sc),
PaperCount: int(pc), PaperCount: int(pc),
DisplayStatus: internalDb.ExamDisplayStatus(r, now), DisplayStatus: internalDb.ExamDisplayStatus(r, now),
CreatedByStaffID: r.CreatedByStaffID,
}) })
} }
return c.JSON(fiber.Map{"data": out}) return c.JSON(fiber.Map{"data": out})
@@ -178,9 +186,6 @@ func CreateExamRoomHandler(db *gorm.DB) fiber.Handler {
return c.Status(400).JSON(fiber.Map{"error": "Giờ kết thúc phải sau giờ bắt đầu"}) return c.Status(400).JSON(fiber.Map{"error": "Giờ kết thúc phải sau giờ bắt đầu"})
} }
apps := strings.TrimSpace(req.AllowedApps) apps := strings.TrimSpace(req.AllowedApps)
if apps == "" {
apps = defaultExamAllowedApps
}
room := models.ExamRoom{ room := models.ExamRoom{
Name: req.Name, Name: req.Name,
StartTime: start, StartTime: start,
@@ -188,6 +193,7 @@ func CreateExamRoomHandler(db *gorm.DB) fiber.Handler {
AllowedApps: apps, AllowedApps: apps,
QuizURL: strings.TrimSpace(req.QuizURL), QuizURL: strings.TrimSpace(req.QuizURL),
Status: models.ExamStatusDraft, Status: models.ExamStatusDraft,
CreatedByStaffID: middleware.StaffIDFromCtx(c),
} }
if err := db.Create(&room).Error; err != nil { if err := db.Create(&room).Error; err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()}) return c.Status(500).JSON(fiber.Map{"error": err.Error()})
@@ -264,8 +270,9 @@ func GetExamRoomHandler(db *gorm.DB) fiber.Handler {
"editable": internalDb.ExamRoomEditable(room), "editable": internalDb.ExamRoomEditable(room),
"prepEditable": internalDb.ExamRoomPrepEditable(room, now), "prepEditable": internalDb.ExamRoomPrepEditable(room, now),
"canPublish": room.Status == models.ExamStatusDraft, "canPublish": room.Status == models.ExamStatusDraft,
"canUnpublish": internalDb.ExamRoomCanUnpublish(room, time.Now()), "canUnpublish": internalDb.ExamRoomCanUnpublish(room, now),
"canCancel": internalDb.ExamRoomCanCancel(room, time.Now()), "canCancel": internalDb.ExamRoomCanCancel(room, now),
"canExtend": internalDb.ExamRoomCanExtend(room, now),
}) })
} }
} }
@@ -302,9 +309,6 @@ func UpdateExamRoomHandler(db *gorm.DB) fiber.Handler {
if err := db.First(&room, id).Error; err != nil { if err := db.First(&room, id).Error; err != nil {
return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy"}) return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy"})
} }
if !internalDb.ExamRoomEditable(room) {
return c.Status(403).JSON(fiber.Map{"error": "Chỉ sửa được phòng thi ở trạng thái tạm thời"})
}
var req struct { var req struct {
Name *string `json:"name"` Name *string `json:"name"`
StartTime *string `json:"startTime"` StartTime *string `json:"startTime"`
@@ -315,6 +319,22 @@ func UpdateExamRoomHandler(db *gorm.DB) fiber.Handler {
if err := c.BodyParser(&req); err != nil { if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid body"}) return c.Status(400).JSON(fiber.Map{"error": "Invalid body"})
} }
now := time.Now()
ds := internalDb.ExamDisplayStatus(room, now)
if !internalDb.ExamRoomEditable(room) {
if ds == "active" {
// Đang thi: chỉ cho sửa app được phép + giờ kết thúc (gia hạn)
if req.Name != nil || req.StartTime != nil || req.QuizURL != nil {
return c.Status(403).JSON(fiber.Map{"error": "Khi đang thi chỉ được sửa ứng dụng được phép hoặc gia hạn giờ kết thúc"})
}
} else if room.Status == models.ExamStatusReady {
if req.Name != nil || req.StartTime != nil || req.EndTime != nil || req.QuizURL != nil {
return c.Status(403).JSON(fiber.Map{"error": "Chỉ sửa được cấu hình ứng dụng được phép khi phòng thi đã đẩy hoặc đang thi"})
}
} else {
return c.Status(403).JSON(fiber.Map{"error": "Chỉ sửa được phòng thi ở trạng thái tạm thời hoặc đang sẵn sàng/đang thi"})
}
}
if req.Name != nil { if req.Name != nil {
room.Name = strings.TrimSpace(*req.Name) room.Name = strings.TrimSpace(*req.Name)
} }
@@ -330,6 +350,14 @@ func UpdateExamRoomHandler(db *gorm.DB) fiber.Handler {
if err != nil { if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "endTime không hợp lệ"}) return c.Status(400).JSON(fiber.Map{"error": "endTime không hợp lệ"})
} }
if ds == "active" {
if !t.After(now) {
return c.Status(400).JSON(fiber.Map{"error": "Giờ kết thúc mới phải sau thời điểm hiện tại"})
}
if !t.After(room.EndTime) {
return c.Status(400).JSON(fiber.Map{"error": "Gia hạn phải chọn giờ kết thúc muộn hơn giờ hiện tại của phòng"})
}
}
room.EndTime = t room.EndTime = t
} }
if !room.EndTime.After(room.StartTime) { if !room.EndTime.After(room.StartTime) {
@@ -419,9 +447,55 @@ func CancelExamRoomHandler(db *gorm.DB) fiber.Handler {
} }
} }
// POST /api/exam-rooms/:id/extend — gia hạn thêm phút cho phòng đang thi
func ExtendExamRoomHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
id, err := parseUintParam(c, "id")
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid id"})
}
var req struct {
Minutes int `json:"minutes"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid body"})
}
if req.Minutes < 1 || req.Minutes > 480 {
return c.Status(400).JSON(fiber.Map{"error": "Số phút gia hạn phải từ 1 đến 480"})
}
var room models.ExamRoom
if err := db.First(&room, id).Error; err != nil {
return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy"})
}
now := time.Now()
if !internalDb.ExamRoomCanExtend(room, now) {
return c.Status(403).JSON(fiber.Map{"error": "Chỉ gia hạn được phòng thi đang diễn ra"})
}
base := room.EndTime
if !base.After(now) {
base = now
}
room.EndTime = base.Add(time.Duration(req.Minutes) * time.Minute)
if err := db.Save(&room).Error; err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{
"ok": true,
"room": room,
"displayStatus": internalDb.ExamDisplayStatus(room, time.Now()),
"addedMinutes": req.Minutes,
"endTime": room.EndTime,
})
}
}
// DELETE /api/exam-rooms/:id // DELETE /api/exam-rooms/:id
func DeleteExamRoomHandler(db *gorm.DB) fiber.Handler { func DeleteExamRoomHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error { return func(c *fiber.Ctx) error {
staffID := middleware.StaffIDFromCtx(c)
email, _ := c.Locals("staffEmail").(string)
isSuperAdmin := email == "phuocntb@rikkeiacademy.com"
id, err := parseUintParam(c, "id") id, err := parseUintParam(c, "id")
if err != nil { if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid id"}) return c.Status(400).JSON(fiber.Map{"error": "Invalid id"})
@@ -430,6 +504,9 @@ func DeleteExamRoomHandler(db *gorm.DB) fiber.Handler {
if err := db.First(&room, id).Error; err != nil { if err := db.First(&room, id).Error; err != nil {
return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy"}) return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy"})
} }
if !isSuperAdmin && (room.CreatedByStaffID == 0 || room.CreatedByStaffID != staffID) {
return c.Status(403).JSON(fiber.Map{"error": "Chỉ người tạo phòng hoặc admin mới được xóa"})
}
if internalDb.ExamRoomIsLive(room, time.Now()) { if internalDb.ExamRoomIsLive(room, time.Now()) {
return c.Status(403).JSON(fiber.Map{"error": "Không thể xóa phòng thi đang diễn ra — hãy hủy phòng trước"}) return c.Status(403).JSON(fiber.Map{"error": "Không thể xóa phòng thi đang diễn ra — hãy hủy phòng trước"})
} }
@@ -445,6 +522,7 @@ func DeleteExamRoomHandler(db *gorm.DB) fiber.Handler {
_ = db.Where("exam_paper_id = ?", p.ID).Delete(&models.ExamPaperResource{}).Error _ = db.Where("exam_paper_id = ?", p.ID).Delete(&models.ExamPaperResource{}).Error
} }
_ = db.Where("exam_room_id = ?", id).Delete(&models.ExamPaper{}).Error _ = db.Where("exam_room_id = ?", id).Delete(&models.ExamPaper{}).Error
_ = db.Where("exam_room_id = ?", id).Delete(&models.ExamSeatingLayout{}).Error
_ = db.Delete(&room).Error _ = db.Delete(&room).Error
return c.JSON(fiber.Map{"ok": true}) return c.JSON(fiber.Map{"ok": true})
} }
@@ -457,12 +535,33 @@ func SearchExamStudentsHandler(db *gorm.DB) fiber.Handler {
if q == "" { if q == "" {
return c.JSON(fiber.Map{"data": []any{}}) return c.JSON(fiber.Map{"data": []any{}})
} }
pat := "%" + q + "%" // Split search query by newline, comma, or semicolon
var terms []string
for _, line := range strings.FieldsFunc(q, func(r rune) bool {
return r == '\n' || r == '\r' || r == ';' || r == ','
}) {
t := strings.TrimSpace(line)
if t != "" {
terms = append(terms, t)
}
}
if len(terms) == 0 {
return c.JSON(fiber.Map{"data": []any{}})
}
if len(terms) > 100 {
terms = terms[:100]
}
var ids []int64 var ids []int64
for _, term := range terms {
pat := "%" + term + "%"
var termIDs []int64
_ = db.Model(&models.Student{}). _ = db.Model(&models.Student{}).
Where("full_name LIKE ? OR student_code LIKE ? OR email LIKE ?", pat, pat, pat). Where("full_name LIKE ? OR student_code LIKE ? OR email LIKE ?", pat, pat, pat).
Limit(30). Limit(30).
Pluck("rk_id", &ids).Error Pluck("rk_id", &termIDs).Error
ids = append(ids, termIDs...)
var classIDs []int64 var classIDs []int64
_ = db.Model(&models.Class{}).Where("class_code LIKE ? OR name LIKE ?", pat, pat).Pluck("rk_id", &classIDs).Error _ = db.Model(&models.Class{}).Where("class_code LIKE ? OR name LIKE ?", pat, pat).Pluck("rk_id", &classIDs).Error
if len(classIDs) > 0 { if len(classIDs) > 0 {
@@ -470,6 +569,8 @@ func SearchExamStudentsHandler(db *gorm.DB) fiber.Handler {
_ = db.Model(&models.ClassStudent{}).Where("class_rk_id IN ?", classIDs).Distinct().Pluck("student_rk_id", &more).Error _ = db.Model(&models.ClassStudent{}).Where("class_rk_id IN ?", classIDs).Distinct().Pluck("student_rk_id", &more).Error
ids = append(ids, more...) ids = append(ids, more...)
} }
}
seen := map[int64]bool{} seen := map[int64]bool{}
uniq := make([]int64, 0) uniq := make([]int64, 0)
for _, id := range ids { for _, id := range ids {
@@ -478,12 +579,12 @@ func SearchExamStudentsHandler(db *gorm.DB) fiber.Handler {
uniq = append(uniq, id) uniq = append(uniq, id)
} }
} }
if len(uniq) > 40 { if len(uniq) > 100 {
uniq = uniq[:40] uniq = uniq[:100]
} }
var students []models.Student var students []models.Student
if len(uniq) > 0 { if len(uniq) > 0 {
_ = db.Where("rk_id IN ?", uniq).Limit(40).Find(&students).Error _ = db.Where("rk_id IN ?", uniq).Limit(100).Find(&students).Error
} }
type row struct { type row struct {
StudentRkID int64 `json:"studentRkId"` StudentRkID int64 `json:"studentRkId"`
@@ -564,8 +665,9 @@ func RemoveExamRoomStudentHandler(db *gorm.DB) fiber.Handler {
if err := db.First(&room, roomID).Error; err != nil { if err := db.First(&room, roomID).Error; err != nil {
return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy"}) return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy"})
} }
if !internalDb.ExamRoomPrepEditable(room, time.Now()) { ds := internalDb.ExamDisplayStatus(room, time.Now())
return c.Status(403).JSON(fiber.Map{"error": "Phòng thi đã bắt đầu — không thể xóa sinh viên"}) if ds == models.ExamStatusEnded {
return c.Status(403).JSON(fiber.Map{"error": "Phòng thi đã kết thúc — không thể xóa sinh viên"})
} }
_ = db.Where("exam_room_id = ? AND student_rk_id = ?", roomID, studentRkID).Delete(&models.ExamRoomStudent{}).Error _ = db.Where("exam_room_id = ? AND student_rk_id = ?", roomID, studentRkID).Delete(&models.ExamRoomStudent{}).Error
return c.JSON(fiber.Map{"ok": true}) return c.JSON(fiber.Map{"ok": true})
@@ -583,9 +685,8 @@ func UploadExamPaperHandler(db *gorm.DB) fiber.Handler {
if err := db.First(&room, roomID).Error; err != nil { if err := db.First(&room, roomID).Error; err != nil {
return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy"}) return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy"})
} }
now := time.Now() if !canModifyExamPapers(db, room) {
if !internalDb.ExamRoomPrepEditable(room, now) { return c.Status(403).JSON(fiber.Map{"error": "Không thể thêm gói đề khi đề đã được gửi cho sinh viên hoặc phòng thi đã kết thúc"})
return c.Status(403).JSON(fiber.Map{"error": "Chỉ thêm gói đề khi phòng tạm thời hoặc sẵn sàng (trước giờ thi)"})
} }
form, err := c.MultipartForm() form, err := c.MultipartForm()
if err != nil { if err != nil {
@@ -690,8 +791,8 @@ func UploadExamPaperResourceHandler(db *gorm.DB) fiber.Handler {
if err := db.First(&room, roomID).Error; err != nil { if err := db.First(&room, roomID).Error; err != nil {
return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy phòng"}) return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy phòng"})
} }
if !internalDb.ExamRoomPrepEditable(room, time.Now()) { if !canModifyExamPapers(db, room) {
return c.Status(403).JSON(fiber.Map{"error": "Phòng thi đã bắt đầu — không thể thêm tài nguyên"}) return c.Status(403).JSON(fiber.Map{"error": "Không thể thêm tài nguyên khi đề đã được gửi cho sinh viên hoặc phòng thi đã kết thúc"})
} }
var paper models.ExamPaper var paper models.ExamPaper
if err := db.Where("id = ? AND exam_room_id = ?", paperID, roomID).First(&paper).Error; err != nil { if err := db.Where("id = ? AND exam_room_id = ?", paperID, roomID).First(&paper).Error; err != nil {
@@ -737,14 +838,12 @@ func DeleteExamPaperHandler(db *gorm.DB) fiber.Handler {
if err := db.First(&room, roomID).Error; err != nil { if err := db.First(&room, roomID).Error; err != nil {
return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy"}) return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy"})
} }
if !internalDb.ExamRoomPrepEditable(room, time.Now()) { if !canModifyExamPapers(db, room) {
return c.Status(403).JSON(fiber.Map{"error": "Phòng thi đã bắt đầu — không thể xóa gói đề"}) return c.Status(403).JSON(fiber.Map{"error": "Không thể xóa gói đề khi đề đã được gửi cho sinh viên hoặc phòng thi đã kết thúc"})
}
var assigned int64
_ = db.Model(&models.ExamRoomStudent{}).Where("exam_room_id = ? AND assigned_paper_id = ?", roomID, paperID).Count(&assigned).Error
if assigned > 0 {
return c.Status(403).JSON(fiber.Map{"error": "Gói đề đã được gán cho sinh viên — không thể xóa"})
} }
// Clear student assignments for this paper first
_ = db.Model(&models.ExamRoomStudent{}).Where("exam_room_id = ? AND assigned_paper_id = ?", roomID, paperID).Update("assigned_paper_id", nil).Error
_ = os.RemoveAll(filepath.Join(examRoomDir(roomID), "papers", fmt.Sprintf("%d", paperID))) _ = os.RemoveAll(filepath.Join(examRoomDir(roomID), "papers", fmt.Sprintf("%d", paperID)))
_ = db.Where("exam_paper_id = ?", paperID).Delete(&models.ExamPaperResource{}).Error _ = db.Where("exam_paper_id = ?", paperID).Delete(&models.ExamPaperResource{}).Error
_ = db.Where("id = ? AND exam_room_id = ?", paperID, roomID).Delete(&models.ExamPaper{}).Error _ = db.Where("id = ? AND exam_room_id = ?", paperID, roomID).Delete(&models.ExamPaper{}).Error
@@ -763,8 +862,8 @@ func RandomAssignExamPapersHandler(db *gorm.DB) fiber.Handler {
if err := db.First(&room, roomID).Error; err != nil { if err := db.First(&room, roomID).Error; err != nil {
return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy"}) return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy"})
} }
if !internalDb.ExamRoomPrepEditable(room, time.Now()) { if !canModifyExamPapers(db, room) {
return c.Status(403).JSON(fiber.Map{"error": "Chỉ chia gói đề khi phòng tạm thời hoặc sẵn sàng (trước giờ thi)"}) return c.Status(403).JSON(fiber.Map{"error": "Không thể chia gói đề khi đề đã được gửi cho sinh viên hoặc phòng thi đã kết thúc"})
} }
var papers []models.ExamPaper var papers []models.ExamPaper
if err := db.Where("exam_room_id = ?", roomID).Order("sort_order asc, id asc").Find(&papers).Error; err != nil || len(papers) == 0 { if err := db.Where("exam_room_id = ?", roomID).Order("sort_order asc, id asc").Find(&papers).Error; err != nil || len(papers) == 0 {
@@ -785,6 +884,43 @@ func RandomAssignExamPapersHandler(db *gorm.DB) fiber.Handler {
} }
} }
// POST /api/exam-rooms/:id/assign-papers-batch — chia đề theo sơ đồ chỗ ngồi (frontend tính toán)
func AssignExamPapersBatchHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
roomID, err := parseUintParam(c, "id")
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid id"})
}
var room models.ExamRoom
if err := db.First(&room, roomID).Error; err != nil {
return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy phòng"})
}
if !canModifyExamPapers(db, room) {
return c.Status(403).JSON(fiber.Map{"error": "Không thể chia gói đề khi đề đã gửi hoặc phòng thi đã kết thúc"})
}
var req struct {
Assignments []struct {
StudentRkID int64 `json:"studentRkId"`
PaperID uint `json:"paperId"`
} `json:"assignments"`
}
if err := c.BodyParser(&req); err != nil || len(req.Assignments) == 0 {
return c.Status(400).JSON(fiber.Map{"error": "assignments rỗng hoặc không hợp lệ"})
}
count := 0
for _, a := range req.Assignments {
pid := a.PaperID
res := db.Model(&models.ExamRoomStudent{}).
Where("exam_room_id = ? AND student_rk_id = ?", roomID, a.StudentRkID).
Update("assigned_paper_id", pid)
if res.RowsAffected > 0 {
count++
}
}
return c.JSON(fiber.Map{"assigned": count})
}
}
// POST /api/exam-rooms/:id/send-papers // POST /api/exam-rooms/:id/send-papers
func SendExamPapersHandler(db *gorm.DB) fiber.Handler { func SendExamPapersHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error { return func(c *fiber.Ctx) error {

View File

@@ -0,0 +1,124 @@
package handlers
import (
"strconv"
"server/internal/models"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
)
// Helper check email
func isSuperAdmin(c *fiber.Ctx) bool {
email, ok := c.Locals("staffEmail").(string)
return ok && email == "phuocntb@rikkeiacademy.com"
}
// GET /api/guide-links
func ListGuideLinksHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
var links []models.GuideLink
if err := db.Order("created_at desc").Find(&links).Error; err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Không thể tải danh sách tài liệu"})
}
return c.JSON(fiber.Map{"data": links})
}
}
// POST /api/guide-links
func CreateGuideLinkHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
if !isSuperAdmin(c) {
return c.Status(403).JSON(fiber.Map{"error": "Bạn không có quyền thực hiện chức năng này"})
}
type Request struct {
Title string `json:"title"`
URL string `json:"url"`
}
var req Request
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid request body"})
}
if req.Title == "" || req.URL == "" {
return c.Status(400).JSON(fiber.Map{"error": "Tiêu đề và đường dẫn không được để trống"})
}
link := models.GuideLink{
Title: req.Title,
URL: req.URL,
}
if err := db.Create(&link).Error; err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Lưu tài liệu thất bại"})
}
return c.JSON(fiber.Map{"ok": true, "data": link})
}
}
// PUT /api/guide-links/:id
func UpdateGuideLinkHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
if !isSuperAdmin(c) {
return c.Status(403).JSON(fiber.Map{"error": "Bạn không có quyền thực hiện chức năng này"})
}
idParam := c.Params("id")
id, err := strconv.ParseUint(idParam, 10, 32)
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid ID"})
}
var link models.GuideLink
if err := db.First(&link, uint(id)).Error; err != nil {
return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy tài liệu"})
}
type Request struct {
Title string `json:"title"`
URL string `json:"url"`
}
var req Request
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid request body"})
}
if req.Title == "" || req.URL == "" {
return c.Status(400).JSON(fiber.Map{"error": "Tiêu đề và đường dẫn không được để trống"})
}
link.Title = req.Title
link.URL = req.URL
if err := db.Save(&link).Error; err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Cập nhật tài liệu thất bại"})
}
return c.JSON(fiber.Map{"ok": true, "data": link})
}
}
// DELETE /api/guide-links/:id
func DeleteGuideLinkHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
if !isSuperAdmin(c) {
return c.Status(403).JSON(fiber.Map{"error": "Bạn không có quyền thực hiện chức năng này"})
}
idParam := c.Params("id")
id, err := strconv.ParseUint(idParam, 10, 32)
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid ID"})
}
var link models.GuideLink
if err := db.First(&link, uint(id)).Error; err != nil {
return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy tài liệu"})
}
if err := db.Delete(&link).Error; err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Xóa tài liệu thất bại"})
}
return c.JSON(fiber.Map{"ok": true})
}
}

View File

@@ -6,6 +6,7 @@ import (
"time" "time"
internalDb "server/internal/db" internalDb "server/internal/db"
"server/internal/auth"
"server/internal/models" "server/internal/models"
"server/internal/util" "server/internal/util"
internalWs "server/internal/websocket" internalWs "server/internal/websocket"
@@ -205,6 +206,92 @@ func DeleteClassScheduleHandler(db *gorm.DB) fiber.Handler {
} }
} }
// GET /api/classes/:rkId/schedule-conflicts - Kiểm tra sinh viên trùng với lớp đang giám sát có lịch học
func GetClassScheduleConflictsHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
rkID, err := strconv.ParseInt(c.Params("rkId"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid class rkId"})
}
// Lấy danh sách sinh viên của lớp muốn kích hoạt
var newClassStudents []int64
if err := db.Model(&models.ClassStudent{}).Where("class_rk_id = ?", rkID).Pluck("student_rk_id", &newClassStudents).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
if len(newClassStudents) == 0 {
return c.JSON(fiber.Map{"conflicts": []any{}, "ok": true})
}
// Tìm các lớp đang giám sát CÓ lịch học (không bao gồm lớp đang được kiểm tra)
var activeClassIDs []int64
if err := db.Model(&models.ClassSchedule{}).Distinct("class_rk_id").Pluck("class_rk_id", &activeClassIDs).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
// Lọc bỏ chính lớp đang kiểm tra
var otherActiveIDs []int64
for _, id := range activeClassIDs {
if id != rkID {
otherActiveIDs = append(otherActiveIDs, id)
}
}
if len(otherActiveIDs) == 0 {
return c.JSON(fiber.Map{"conflicts": []any{}, "ok": true})
}
// Tìm sinh viên trùng
var overlappingStudentIDs []int64
if err := db.Model(&models.ClassStudent{}).
Where("class_rk_id IN ? AND student_rk_id IN ?", otherActiveIDs, newClassStudents).
Pluck("student_rk_id", &overlappingStudentIDs).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
if len(overlappingStudentIDs) == 0 {
return c.JSON(fiber.Map{"conflicts": []any{}, "ok": true})
}
// Lấy thông tin sinh viên và lớp bị trùng
type conflictItem struct {
StudentRkID int64 `json:"studentRkId"`
FullName string `json:"fullName"`
StudentCode string `json:"studentCode"`
ConflictClassRkID int64 `json:"conflictClassRkId"`
ConflictClassName string `json:"conflictClassName"`
ConflictClassCode string `json:"conflictClassCode"`
}
var conflicts []conflictItem
for _, sid := range overlappingStudentIDs {
var st models.Student
if err := db.Where("rk_id = ?", sid).First(&st).Error; err != nil {
continue
}
var classIDs []int64
_ = db.Model(&models.ClassStudent{}).Where("class_rk_id IN ? AND student_rk_id = ?", otherActiveIDs, sid).Pluck("class_rk_id", &classIDs)
for _, cid := range classIDs {
var cls models.Class
if err := db.Where("rk_id = ?", cid).First(&cls).Error; err != nil {
continue
}
conflicts = append(conflicts, conflictItem{
StudentRkID: sid,
FullName: st.FullName,
StudentCode: st.StudentCode,
ConflictClassRkID: cid,
ConflictClassName: cls.Name,
ConflictClassCode: cls.ClassCode,
})
}
}
if conflicts == nil {
conflicts = []conflictItem{}
}
return c.JSON(fiber.Map{"conflicts": conflicts, "ok": len(conflicts) == 0})
}
}
func parseTimeMinutes(tStr string) int { func parseTimeMinutes(tStr string) int {
parts := strings.Split(tStr, ":") parts := strings.Split(tStr, ":")
if len(parts) != 2 { if len(parts) != 2 {
@@ -231,7 +318,9 @@ func GetAllowedAppsHandler(db *gorm.DB) fiber.Handler {
if examInfo := internalDb.FindActiveExamForStudent(db, studentID); examInfo != nil { if examInfo := internalDb.FindActiveExamForStudent(db, studentID); examInfo != nil {
keywords := strings.TrimSpace(examInfo.Room.AllowedApps) keywords := strings.TrimSpace(examInfo.Room.AllowedApps)
if keywords == "" { if keywords == "" {
keywords = defaultExamAllowedApps keywords = systemAllowedApps
} else {
keywords = keywords + "," + systemAllowedApps
} }
return c.JSON(fiber.Map{ return c.JSON(fiber.Map{
"classRkId": rkID, "classRkId": rkID,
@@ -255,6 +344,22 @@ func GetAllowedAppsHandler(db *gorm.DB) fiber.Handler {
errApp := db.Where("class_rk_id = ?", rkID).First(&appConfig).Error errApp := db.Where("class_rk_id = ?", rkID).First(&appConfig).Error
hasAppsConfigured := (errApp == nil && strings.TrimSpace(appConfig.Keywords) != "") hasAppsConfigured := (errApp == nil && strings.TrimSpace(appConfig.Keywords) != "")
// Nếu là request từ Staff (có Authorization Bearer token hợp lệ), trả về trực tiếp danh sách keywords đã lưu
authHeader := c.Get("Authorization")
if authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") {
token := strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer "))
if _, err := auth.ParseToken(token); err == nil {
keywords := ""
if errApp == nil {
keywords = appConfig.Keywords
}
return c.JSON(fiber.Map{
"classRkId": rkID,
"keywords": keywords,
})
}
}
// 2. Kiểm tra xem có trong giờ học không // 2. Kiểm tra xem có trong giờ học không
var schedules []models.ClassSchedule var schedules []models.ClassSchedule
errSched := db.Where("class_rk_id = ?", rkID).Find(&schedules).Error errSched := db.Where("class_rk_id = ?", rkID).Find(&schedules).Error
@@ -307,7 +412,17 @@ func GetAllowedAppsHandler(db *gorm.DB) fiber.Handler {
}) })
} }
return c.JSON(appConfig) keywords := strings.TrimSpace(appConfig.Keywords)
if keywords != "" {
keywords = keywords + "," + systemAllowedApps
} else {
keywords = systemAllowedApps
}
return c.JSON(fiber.Map{
"classRkId": appConfig.ClassRkID,
"keywords": keywords,
"exit": false,
})
} }
} }
@@ -620,6 +735,263 @@ func ReportBlockedAppHandler(db *gorm.DB) fiber.Handler {
} }
} }
// POST /api/student/report-violation — SV tắt app / vi phạm khi đang giám sát
func ReportViolationHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
var req struct {
StudentRkID int64 `json:"studentRkId"`
Kind string `json:"kind"`
Reason string `json:"reason"`
MonitorMode string `json:"monitorMode"`
ClientAt string `json:"clientAt"` // RFC3339 optional
ClassRkID int64 `json:"classRkId"`
ExamRoomID uint `json:"examRoomId"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payload format"})
}
if req.StudentRkID <= 0 || strings.TrimSpace(req.Kind) == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "studentRkId and kind are required"})
}
monitorMode := strings.TrimSpace(req.MonitorMode)
// Chỉ lưu khi đang thi hoặc đang học — ngoài giờ / chưa cấu hình bỏ qua
if monitorMode != "exam" && monitorMode != "learning" {
return c.JSON(fiber.Map{"ok": true, "skipped": true, "reason": "not_in_exam_or_learning"})
}
kind := strings.ToLower(strings.TrimSpace(req.Kind))
reason := strings.TrimSpace(req.Reason)
if reason == "" {
reason = kind
}
classID := req.ClassRkID
if classID <= 0 {
classID = internalDb.FindActiveClassForStudent(db, req.StudentRkID)
}
examRoomID := req.ExamRoomID
if examRoomID == 0 && monitorMode == "exam" {
if examInfo := internalDb.FindActiveExamForStudent(db, req.StudentRkID); examInfo != nil {
examRoomID = examInfo.Room.ID
}
}
clientAt := time.Now()
if t, err := time.Parse(time.RFC3339, strings.TrimSpace(req.ClientAt)); err == nil {
clientAt = t
}
row := models.StudentViolation{
StudentRkID: req.StudentRkID,
ClassRkID: classID,
ExamRoomID: examRoomID,
Kind: kind,
Reason: reason,
MonitorMode: monitorMode,
ClientAt: clientAt,
}
if err := db.Create(&row).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
studentName := ""
studentCode := ""
var st models.Student
if err := db.Where("rk_id = ?", req.StudentRkID).First(&st).Error; err == nil {
studentName = st.FullName
studentCode = st.StudentCode
}
// Cố ý tắt / crash / môi trường → offline ngay, không dùng grace 8s
switch kind {
case "app_closed", "unclean_shutdown", "wifi",
"guard", "multi_monitor", "user_switch", "session_change", "virtual_desktop":
internalWs.Hub.ForceStudentOffline(req.StudentRkID)
}
internalWs.Hub.BroadcastStudentViolation(map[string]any{
"id": row.ID,
"studentId": req.StudentRkID,
"studentName": studentName,
"studentCode": studentCode,
"classId": classID,
"examRoomId": examRoomID,
"kind": kind,
"reason": reason,
"monitorMode": row.MonitorMode,
"clientAt": clientAt.Format(time.RFC3339),
"createdAt": row.CreatedAt.Format(time.RFC3339),
})
return c.JSON(fiber.Map{"ok": true, "id": row.ID, "classRkId": classID, "examRoomId": examRoomID})
}
}
type studentViolationItem struct {
ID uint `json:"id"`
StudentRkID int64 `json:"studentRkId"`
StudentCode string `json:"studentCode"`
FullName string `json:"fullName"`
ClassRkID int64 `json:"classRkId"`
ExamRoomID uint `json:"examRoomId"`
Kind string `json:"kind"`
Reason string `json:"reason"`
MonitorMode string `json:"monitorMode"`
ClientAt time.Time `json:"clientAt"`
CreatedAt time.Time `json:"createdAt"`
}
// GET /api/classes/:rkId/violations?date=YYYY-MM-DD&kind=
func ListClassViolationsHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
classRkID, err := strconv.ParseInt(c.Params("rkId"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid class rkId"})
}
date := c.Query("date")
if date == "" {
date = time.Now().Format("2006-01-02")
}
kindFilter := strings.TrimSpace(c.Query("kind", ""))
var mappings []models.ClassStudent
if err := db.Where("class_rk_id = ?", classRkID).Find(&mappings).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
if len(mappings) == 0 {
return c.JSON(fiber.Map{"data": []any{}, "date": date})
}
studentIDs := make([]int64, 0, len(mappings))
for _, m := range mappings {
studentIDs = append(studentIDs, m.StudentRkID)
}
var students []models.Student
if err := db.Where("rk_id IN ?", studentIDs).Find(&students).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
nameByID := map[int64]models.Student{}
for _, st := range students {
nameByID[st.RkID] = st
}
dayStart, err := time.ParseInLocation("2006-01-02", date, time.Local)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid date"})
}
dayEnd := dayStart.Add(24 * time.Hour)
// Ưu tiên vi phạm gắn đúng lớp; fallback bản ghi cũ (class_rk_id=0) của SV trong lớp khi đang học
q := db.Where(
"created_at >= ? AND created_at < ? AND ((class_rk_id = ?) OR (class_rk_id = 0 AND monitor_mode = ? AND student_rk_id IN ?))",
dayStart, dayEnd, classRkID, "learning", studentIDs,
)
if kindFilter != "" {
q = q.Where("kind = ?", kindFilter)
}
var rows []models.StudentViolation
if err := q.Order("created_at desc").Limit(500).Find(&rows).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
out := make([]studentViolationItem, 0, len(rows))
for _, r := range rows {
st := nameByID[r.StudentRkID]
if st.RkID == 0 {
_ = db.Where("rk_id = ?", r.StudentRkID).First(&st).Error
}
out = append(out, studentViolationItem{
ID: r.ID,
StudentRkID: r.StudentRkID,
StudentCode: st.StudentCode,
FullName: st.FullName,
ClassRkID: r.ClassRkID,
ExamRoomID: r.ExamRoomID,
Kind: r.Kind,
Reason: r.Reason,
MonitorMode: r.MonitorMode,
ClientAt: r.ClientAt,
CreatedAt: r.CreatedAt,
})
}
return c.JSON(fiber.Map{"data": out, "date": date})
}
}
// GET /api/exam-rooms/:id/violations?date=YYYY-MM-DD
func ListExamRoomViolationsHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid exam room id"})
}
date := c.Query("date")
if date == "" {
date = time.Now().Format("2006-01-02")
}
kindFilter := strings.TrimSpace(c.Query("kind", ""))
var roster []models.ExamRoomStudent
if err := db.Where("exam_room_id = ?", id).Find(&roster).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
if len(roster) == 0 {
return c.JSON(fiber.Map{"data": []any{}, "date": date})
}
studentIDs := make([]int64, 0, len(roster))
for _, s := range roster {
studentIDs = append(studentIDs, s.StudentRkID)
}
var students []models.Student
_ = db.Where("rk_id IN ?", studentIDs).Find(&students)
nameByID := map[int64]models.Student{}
for _, st := range students {
nameByID[st.RkID] = st
}
dayStart, err := time.ParseInLocation("2006-01-02", date, time.Local)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid date"})
}
dayEnd := dayStart.Add(24 * time.Hour)
// Ưu tiên exam_room_id; fallback bản ghi cũ (exam_room_id=0, mode=exam) của SV trong phòng
q := db.Where(
"created_at >= ? AND created_at < ? AND ((exam_room_id = ?) OR (exam_room_id = 0 AND monitor_mode = ? AND student_rk_id IN ?))",
dayStart, dayEnd, id, "exam", studentIDs,
)
if kindFilter != "" {
q = q.Where("kind = ?", kindFilter)
}
var rows []models.StudentViolation
if err := q.Order("created_at desc").Limit(500).Find(&rows).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
out := make([]studentViolationItem, 0, len(rows))
for _, r := range rows {
st := nameByID[r.StudentRkID]
out = append(out, studentViolationItem{
ID: r.ID,
StudentRkID: r.StudentRkID,
StudentCode: st.StudentCode,
FullName: st.FullName,
ClassRkID: r.ClassRkID,
ExamRoomID: r.ExamRoomID,
Kind: r.Kind,
Reason: r.Reason,
MonitorMode: r.MonitorMode,
ClientAt: r.ClientAt,
CreatedAt: r.CreatedAt,
})
}
return c.JSON(fiber.Map{"data": out, "date": date})
}
}
// GET /api/app-pool — kho app bị chặn toàn hệ thống (?q= tìm kiếm, limit= mặc định 50) // GET /api/app-pool — kho app bị chặn toàn hệ thống (?q= tìm kiếm, limit= mặc định 50)
func ListAppPoolHandler(db *gorm.DB) fiber.Handler { func ListAppPoolHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error { return func(c *fiber.Ctx) error {

View File

@@ -0,0 +1,184 @@
package handlers
import (
"errors"
"strconv"
"time"
"server/internal/middleware"
"server/internal/models"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// GET /classes/:rkId/seating-layout
func GetClassSeatingLayoutHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
classRkID, err := strconv.ParseInt(c.Params("rkId"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid class ID"})
}
var layout models.ClassSeatingLayout
if err := db.Where("class_rk_id = ?", classRkID).First(&layout).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return c.JSON(fiber.Map{"layoutJson": nil})
}
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"layoutJson": layout.LayoutJSON})
}
}
// PUT /classes/:rkId/seating-layout
func PutClassSeatingLayoutHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
classRkID, err := strconv.ParseInt(c.Params("rkId"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid class ID"})
}
var body struct {
LayoutJSON string `json:"layoutJson"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid body"})
}
if body.LayoutJSON == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "layoutJson is required"})
}
layout := models.ClassSeatingLayout{
ClassRkID: classRkID,
LayoutJSON: body.LayoutJSON,
UpdatedAt: time.Now(),
}
result := db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "class_rk_id"}},
DoUpdates: clause.AssignmentColumns([]string{"layout_json", "updated_at"}),
}).Create(&layout)
if result.Error != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": result.Error.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
}
// DELETE /classes/:rkId/seating-layout
func DeleteClassSeatingLayoutHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
classRkID, err := strconv.ParseInt(c.Params("rkId"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid class ID"})
}
db.Where("class_rk_id = ?", classRkID).Delete(&models.ClassSeatingLayout{})
return c.JSON(fiber.Map{"ok": true})
}
}
// GET /exam-rooms/:id/seating-layout
func GetExamSeatingLayoutHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
examID, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid exam room ID"})
}
var layout models.ExamSeatingLayout
if err := db.Where("exam_room_id = ?", examID).First(&layout).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return c.JSON(fiber.Map{"layoutJson": nil})
}
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"layoutJson": layout.LayoutJSON})
}
}
// PUT /exam-rooms/:id/seating-layout
func PutExamSeatingLayoutHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
examID, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid exam room ID"})
}
var body struct {
LayoutJSON string `json:"layoutJson"`
}
if err := c.BodyParser(&body); err != nil || body.LayoutJSON == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "layoutJson is required"})
}
layout := models.ExamSeatingLayout{
ExamRoomID: uint(examID),
LayoutJSON: body.LayoutJSON,
UpdatedAt: time.Now(),
}
result := db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "exam_room_id"}},
DoUpdates: clause.AssignmentColumns([]string{"layout_json", "updated_at"}),
}).Create(&layout)
if result.Error != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": result.Error.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
}
// DELETE /exam-rooms/:id/seating-layout
func DeleteExamSeatingLayoutHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
examID, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid exam room ID"})
}
db.Where("exam_room_id = ?", examID).Delete(&models.ExamSeatingLayout{})
return c.JSON(fiber.Map{"ok": true})
}
}
// GET /staff/my-classes
func GetMyClassesHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
staffID := middleware.StaffIDFromCtx(c)
type classRow struct {
RkID int64 `json:"rkId"`
Name string `json:"name"`
ClassCode string `json:"classCode"`
}
var rows []classRow
db.Table("staff_my_classes").
Select("classes.rk_id, classes.name, classes.class_code").
Joins("JOIN classes ON classes.rk_id = staff_my_classes.class_rk_id").
Where("staff_my_classes.staff_id = ?", staffID).
Scan(&rows)
if rows == nil {
rows = []classRow{}
}
return c.JSON(fiber.Map{"data": rows})
}
}
// POST /staff/my-classes/:classRkId
func AddMyClassHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
staffID := middleware.StaffIDFromCtx(c)
classRkID, err := strconv.ParseInt(c.Params("classRkId"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid class ID"})
}
rec := models.StaffMyClass{StaffID: staffID, ClassRkID: classRkID}
db.Clauses(clause.OnConflict{DoNothing: true}).Create(&rec)
return c.JSON(fiber.Map{"ok": true})
}
}
// DELETE /staff/my-classes/:classRkId
func RemoveMyClassHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
staffID := middleware.StaffIDFromCtx(c)
classRkID, err := strconv.ParseInt(c.Params("classRkId"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid class ID"})
}
db.Where("staff_id = ? AND class_rk_id = ?", staffID, classRkID).Delete(&models.StaffMyClass{})
return c.JSON(fiber.Map{"ok": true})
}
}

View File

@@ -90,6 +90,9 @@ func GetStudentStatusHandler(db *gorm.DB) fiber.Handler {
if err := db.Where("class_rk_id = ?", classID).First(&appConfig).Error; err == nil { if err := db.Where("class_rk_id = ?", classID).First(&appConfig).Error; err == nil {
keywords = strings.TrimSpace(appConfig.Keywords) keywords = strings.TrimSpace(appConfig.Keywords)
hasApps = keywords != "" hasApps = keywords != ""
if hasApps {
keywords = keywords + "," + systemAllowedApps
}
} }
} }
@@ -173,7 +176,9 @@ func GetStudentStatusHandler(db *gorm.DB) fiber.Handler {
if examInfo := internalDb.FindActiveExamForStudent(db, studentID); examInfo != nil { if examInfo := internalDb.FindActiveExamForStudent(db, studentID); examInfo != nil {
examKeywords := strings.TrimSpace(examInfo.Room.AllowedApps) examKeywords := strings.TrimSpace(examInfo.Room.AllowedApps)
if examKeywords == "" { if examKeywords == "" {
examKeywords = defaultExamAllowedApps examKeywords = systemAllowedApps
} else {
examKeywords = examKeywords + "," + systemAllowedApps
} }
paperSent := examInfo.Enrollment.PaperSentAt != nil paperSent := examInfo.Enrollment.PaperSentAt != nil
var subCount int64 var subCount int64

View File

@@ -11,10 +11,16 @@ import (
func RequireStaff() fiber.Handler { func RequireStaff() fiber.Handler {
return func(c *fiber.Ctx) error { return func(c *fiber.Ctx) error {
header := c.Get("Authorization") header := c.Get("Authorization")
if header == "" || !strings.HasPrefix(header, "Bearer ") { var token string
if header != "" && strings.HasPrefix(header, "Bearer ") {
token = strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
} else {
token = c.Query("token")
}
if token == "" {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"}) return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"})
} }
token := strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
claims, err := auth.ParseToken(token) claims, err := auth.ParseToken(token)
if err != nil { if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid or expired token"}) return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid or expired token"})

View File

@@ -23,6 +23,7 @@ type ExamRoom struct {
GitBranch string `gorm:"column:git_branch;size:64;default:main" json:"gitBranch"` GitBranch string `gorm:"column:git_branch;size:64;default:main" json:"gitBranch"`
GitPublishURL string `gorm:"column:git_publish_url;size:1024" json:"gitPublishUrl,omitempty"` GitPublishURL string `gorm:"column:git_publish_url;size:1024" json:"gitPublishUrl,omitempty"`
Status string `gorm:"column:status;size:16;not null;default:draft;index" json:"status"` Status string `gorm:"column:status;size:16;not null;default:draft;index" json:"status"`
CreatedByStaffID uint `gorm:"column:created_by_staff_id;index;default:0" json:"createdByStaffId"`
} }
func (ExamRoom) TableName() string { return "exam_rooms" } func (ExamRoom) TableName() string { return "exam_rooms" }

View File

@@ -97,6 +97,24 @@ type AttendanceResult struct {
func (AttendanceResult) TableName() string { return "attendance_results" } func (AttendanceResult) TableName() string { return "attendance_results" }
// ClassSeatingLayout lưu sơ đồ chỗ ngồi theo lớp
type ClassSeatingLayout struct {
ClassRkID int64 `gorm:"column:class_rk_id;primaryKey;not null" json:"classRkId"`
LayoutJSON string `gorm:"column:layout_json;type:longtext;not null" json:"layoutJson"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (ClassSeatingLayout) TableName() string { return "class_seating_layouts" }
// ExamSeatingLayout lưu sơ đồ chỗ ngồi theo phòng thi
type ExamSeatingLayout struct {
ExamRoomID uint `gorm:"column:exam_room_id;primaryKey;not null" json:"examRoomId"`
LayoutJSON string `gorm:"column:layout_json;type:longtext;not null" json:"layoutJson"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (ExamSeatingLayout) TableName() string { return "exam_seating_layouts" }
// ClassAllowedApp lưu từ khóa app được mở của lớp // ClassAllowedApp lưu từ khóa app được mở của lớp
type ClassAllowedApp struct { type ClassAllowedApp struct {
ClassRkID int64 `gorm:"column:class_rk_id;primaryKey;not null" json:"classRkId"` ClassRkID int64 `gorm:"column:class_rk_id;primaryKey;not null" json:"classRkId"`
@@ -179,6 +197,21 @@ type StudentSession struct {
func (StudentSession) TableName() string { return "student_sessions" } func (StudentSession) TableName() string { return "student_sessions" }
// StudentViolation — ghi nhận SV tắt app / vi phạm môi trường khi đang giám sát
type StudentViolation struct {
ID uint `gorm:"primaryKey" json:"id"`
CreatedAt time.Time `json:"createdAt"`
StudentRkID int64 `gorm:"column:student_rk_id;not null;index" json:"studentRkId"`
ClassRkID int64 `gorm:"column:class_rk_id;index" json:"classRkId"`
ExamRoomID uint `gorm:"column:exam_room_id;index;default:0" json:"examRoomId"`
Kind string `gorm:"column:kind;size:64;not null;index" json:"kind"` // app_closed | unclean_shutdown | multi_monitor | user_switch | session_change | virtual_desktop | wifi | guard
Reason string `gorm:"column:reason;type:text" json:"reason"`
MonitorMode string `gorm:"column:monitor_mode;size:32" json:"monitorMode"`
ClientAt time.Time `gorm:"column:client_at" json:"clientAt"`
}
func (StudentViolation) TableName() string { return "student_violations" }
// SystemSetting lưu các cấu hình hệ thống động // SystemSetting lưu các cấu hình hệ thống động
type SystemSetting struct { type SystemSetting struct {
SettingKey string `gorm:"primaryKey;column:setting_key;size:128" json:"settingKey"` SettingKey string `gorm:"primaryKey;column:setting_key;size:128" json:"settingKey"`
@@ -188,3 +221,51 @@ type SystemSetting struct {
func (SystemSetting) TableName() string { return "system_settings" } func (SystemSetting) TableName() string { return "system_settings" }
// GuideLink đại diện cho tài liệu hướng dẫn Google Docs public
type GuideLink struct {
ID uint `gorm:"primaryKey" json:"id"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
Title string `gorm:"column:title;size:512;not null" json:"title"`
URL string `gorm:"column:url;size:1024;not null" json:"url"`
}
func (GuideLink) TableName() string { return "guide_links" }
// AppDownload đại diện cho ứng dụng và link tải
type AppDownload struct {
ID uint `gorm:"primaryKey" json:"id"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
Name string `gorm:"column:name;size:255;not null" json:"name"`
Description string `gorm:"column:description;size:512" json:"description"`
DownloadURL string `gorm:"column:download_url;size:1024;not null" json:"downloadUrl"`
Platform string `gorm:"column:platform;size:64" json:"platform"` // "Windows", "macOS", "Linux", "Khác"
}
func (AppDownload) TableName() string { return "app_downloads" }
// AppGuide đại diện cho tài liệu/video hướng dẫn ứng dụng dành cho sinh viên
type AppGuide struct {
ID uint `gorm:"primaryKey" json:"id"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
Title string `gorm:"column:title;size:512;not null" json:"title"`
URL string `gorm:"column:url;size:1024;not null" json:"url"`
}
func (AppGuide) TableName() string { return "app_guides" }
// LocalLeaveRequest lưu trạng thái phê duyệt đơn xin nghỉ phép trên hệ thống nội bộ thay vì call QLĐT
type LocalLeaveRequest struct {
ID uint `gorm:"primaryKey" json:"id"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
LeaveID int64 `gorm:"column:leave_id;uniqueIndex;not null" json:"leaveId"`
Status string `gorm:"column:status;size:32;not null" json:"status"` // "Phê duyệt" hoặc "Từ chối"
}
func (LocalLeaveRequest) TableName() string { return "local_leave_requests" }

View File

@@ -38,6 +38,14 @@ type PasswordResetToken struct {
func (PasswordResetToken) TableName() string { return "password_reset_tokens" } func (PasswordResetToken) TableName() string { return "password_reset_tokens" }
// StaffMyClass lưu danh sách lớp học cá nhân của giáo viên
type StaffMyClass struct {
StaffID uint `gorm:"column:staff_id;primaryKey;not null;index" json:"staffId"`
ClassRkID int64 `gorm:"column:class_rk_id;primaryKey;not null" json:"classRkId"`
}
func (StaffMyClass) TableName() string { return "staff_my_classes" }
// ChatMessage — tin nhắn thầy cô ↔ sinh viên // ChatMessage — tin nhắn thầy cô ↔ sinh viên
type ChatMessage struct { type ChatMessage struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`

View File

@@ -1,6 +1,7 @@
package qldt package qldt
import ( import (
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
@@ -198,3 +199,70 @@ func (c *Client) GetClassDashboard(ctx context.Context, token string, classID, c
} }
return body, nil return body, nil
} }
func (c *Client) GetLeaveRequests(ctx context.Context, token string, classID, courseID int64, date string) ([]byte, error) {
u := fmt.Sprintf("%s/request-leave?classId=%d&courseId=%d&date=%s", c.baseURL, classID, courseID, date)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, err
}
c.setAuthHeaders(req, token)
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("qldt request-leave http %d: %s", resp.StatusCode, string(body))
}
return body, nil
}
func (c *Client) updateLeaveStatusAttempt(ctx context.Context, token string, leaveID int64, status string, method string) ([]byte, int, error) {
payload := map[string]string{"status": status}
jsonData, _ := json.Marshal(payload)
u := fmt.Sprintf("%s/request-leave/%d/status", c.baseURL, leaveID)
req, err := http.NewRequestWithContext(ctx, method, u, bytes.NewBuffer(jsonData))
if err != nil {
return nil, 0, err
}
req.Header.Set("Content-Type", "application/json")
c.setAuthHeaders(req, token)
resp, err := c.http.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
return body, resp.StatusCode, nil
}
func (c *Client) UpdateLeaveStatus(ctx context.Context, token string, leaveID int64, status string) ([]byte, error) {
// Try PUT first, then POST, then PATCH
methods := []string{http.MethodPut, http.MethodPost, http.MethodPatch}
var lastErr error
var lastBody []byte
var lastCode int
for _, method := range methods {
body, code, err := c.updateLeaveStatusAttempt(ctx, token, leaveID, status, method)
if err == nil && code >= 200 && code < 300 {
return body, nil
}
if err != nil {
lastErr = err
} else {
lastErr = fmt.Errorf("HTTP %d: %s", code, string(body))
}
lastBody = body
lastCode = code
}
return lastBody, fmt.Errorf("all methods (PUT, POST, PATCH) failed. Last error: %w (code %d)", lastErr, lastCode)
}

View File

@@ -1,14 +1,29 @@
package websocket package websocket
import ( import (
"bufio"
"encoding/base64"
"encoding/json" "encoding/json"
"fmt"
"log" "log"
"strconv" "strconv"
"strings"
"sync" "sync"
"time"
internalDb "server/internal/db"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/websocket/v2" "github.com/gofiber/websocket/v2"
"gorm.io/gorm" "gorm.io/gorm"
internalDb "server/internal/db" )
const (
// Heartbeat kiểu game: phát hiện mất kết nối trong ~3045s.
wsPingInterval = 15 * time.Second
wsPongWait = 45 * time.Second
// Grace khi SV reconnect — tránh nhấp nháy online/offline.
studentOfflineGrace = 8 * time.Second
) )
type SocketMsg struct { type SocketMsg struct {
@@ -23,6 +38,24 @@ type SocketClient struct {
StaffID uint StaffID uint
Role string // "student" | "teacher" Role string // "student" | "teacher"
Addr string Addr string
writeMu sync.Mutex
}
func (c *SocketClient) WriteJSON(v any) error {
c.writeMu.Lock()
defer c.writeMu.Unlock()
return c.Conn.WriteJSON(v)
}
func (c *SocketClient) WriteRaw(msg []byte) error {
c.writeMu.Lock()
defer c.writeMu.Unlock()
return c.Conn.WriteMessage(websocket.TextMessage, msg)
}
type offlineGrace struct {
until time.Time
classID int64
} }
type WsHub struct { type WsHub struct {
@@ -31,6 +64,12 @@ type WsHub struct {
teachers map[string]*SocketClient teachers map[string]*SocketClient
teachersByStaff map[uint][]string // staffId -> teacher connection addresses teachersByStaff map[uint][]string // staffId -> teacher connection addresses
subscribers map[int64][]string // studentId -> list of teacher connection addresses subscribers map[int64][]string // studentId -> list of teacher connection addresses
grace map[int64]offlineGrace
graceTimers map[int64]*time.Timer
subscriberModes map[string]string // "teacherAddr_studentId" -> "grid"|"focus"
lastRelayed map[string]time.Time // "teacherAddr_studentId_event" -> time
httpScreenSubscribers map[int64][]chan []byte // studentId -> list of channels for screen MJPEG
httpWebcamSubscribers map[int64][]chan []byte // studentId -> list of channels for webcam MJPEG
} }
var Hub = &WsHub{ var Hub = &WsHub{
@@ -38,13 +77,22 @@ var Hub = &WsHub{
teachers: make(map[string]*SocketClient), teachers: make(map[string]*SocketClient),
teachersByStaff: make(map[uint][]string), teachersByStaff: make(map[uint][]string),
subscribers: make(map[int64][]string), subscribers: make(map[int64][]string),
grace: make(map[int64]offlineGrace),
graceTimers: make(map[int64]*time.Timer),
subscriberModes: make(map[string]string),
lastRelayed: make(map[string]time.Time),
httpScreenSubscribers: make(map[int64][]chan []byte),
httpWebcamSubscribers: make(map[int64][]chan []byte),
} }
func (h *WsHub) IsStudentOnline(studentRkID int64) bool { func (h *WsHub) IsStudentOnline(studentRkID int64) bool {
h.mu.RLock() h.mu.RLock()
defer h.mu.RUnlock() defer h.mu.RUnlock()
_, ok := h.students[studentRkID] if _, ok := h.students[studentRkID]; ok {
return ok return true
}
g, ok := h.grace[studentRkID]
return ok && time.Now().Before(g.until)
} }
func (h *WsHub) PushExamToStudent(studentRkID int64, data map[string]any) { func (h *WsHub) PushExamToStudent(studentRkID int64, data map[string]any) {
@@ -54,7 +102,7 @@ func (h *WsHub) PushExamToStudent(studentRkID int64, data map[string]any) {
if !ok || client == nil { if !ok || client == nil {
return return
} }
_ = client.Conn.WriteJSON(SocketMsg{Event: "exam:paper-sent", Data: data}) _ = client.WriteJSON(SocketMsg{Event: "exam:paper-sent", Data: data})
} }
func (h *WsHub) PushChatToStudent(studentRkID int64, data map[string]any) { func (h *WsHub) PushChatToStudent(studentRkID int64, data map[string]any) {
@@ -64,7 +112,7 @@ func (h *WsHub) PushChatToStudent(studentRkID int64, data map[string]any) {
if !ok || client == nil { if !ok || client == nil {
return return
} }
_ = client.Conn.WriteJSON(SocketMsg{Event: "chat:message", Data: data}) _ = client.WriteJSON(SocketMsg{Event: "chat:message", Data: data})
} }
func (h *WsHub) PushChatToStaff(staffID uint, data map[string]any) { func (h *WsHub) PushChatToStaff(staffID uint, data map[string]any) {
@@ -77,7 +125,109 @@ func (h *WsHub) PushChatToStaff(staffID uint, data map[string]any) {
defer h.mu.RUnlock() defer h.mu.RUnlock()
for _, t := range h.teachers { for _, t := range h.teachers {
if t != nil && t.Role == "teacher" { if t != nil && t.Role == "teacher" {
_ = t.Conn.WriteJSON(msg) _ = t.WriteJSON(msg)
}
}
}
func (h *WsHub) BroadcastStudentViolation(data map[string]any) {
msg := SocketMsg{Event: "teacher:student-violation", Data: data}
msgBytes, err := json.Marshal(msg)
if err != nil {
return
}
h.mu.RLock()
defer h.mu.RUnlock()
for _, t := range h.teachers {
if t != nil {
_ = t.WriteRaw(msgBytes)
}
}
}
func (h *WsHub) broadcastPresence(studentID int64, online bool, classID int64) {
msg := SocketMsg{
Event: "presence:update",
Data: map[string]any{
"studentId": studentID,
"online": online,
"classId": classID,
},
}
msgBytes, err := json.Marshal(msg)
if err != nil {
return
}
for _, t := range h.teachers {
if t != nil {
_ = t.WriteRaw(msgBytes)
}
}
}
// ForceStudentOffline — tắt ngay (không chờ grace), dùng khi SV cố ý đóng app / vi phạm.
func (h *WsHub) ForceStudentOffline(studentID int64) {
h.mu.Lock()
defer h.mu.Unlock()
classID := int64(0)
if client, ok := h.students[studentID]; ok && client != nil {
classID = client.ClassID
delete(h.students, studentID)
}
if g, ok := h.grace[studentID]; ok {
if classID == 0 {
classID = g.classID
}
}
h.cancelGraceLocked(studentID)
log.Printf("[WS] Student %d force offline (intentional quit/violation)", studentID)
h.broadcastPresence(studentID, false, classID)
if teachers, exists := h.subscribers[studentID]; exists {
for _, tAddr := range teachers {
if t, found := h.teachers[tAddr]; found {
_ = t.WriteJSON(SocketMsg{
Event: "teacher:stream-stopped",
Data: map[string]any{"studentId": studentID},
})
}
}
}
}
func (h *WsHub) cancelGraceLocked(studentID int64) {
if t, ok := h.graceTimers[studentID]; ok {
t.Stop()
delete(h.graceTimers, studentID)
}
delete(h.grace, studentID)
}
func (h *WsHub) finalizeStudentOffline(studentID int64, classID int64) {
h.mu.Lock()
defer h.mu.Unlock()
if _, online := h.students[studentID]; online {
return
}
if _, ok := h.grace[studentID]; !ok {
return
}
delete(h.grace, studentID)
delete(h.graceTimers, studentID)
log.Printf("[WS] Student %d offline after grace", studentID)
h.broadcastPresence(studentID, false, classID)
if teachers, exists := h.subscribers[studentID]; exists {
for _, tAddr := range teachers {
if t, found := h.teachers[tAddr]; found {
_ = t.WriteJSON(SocketMsg{
Event: "teacher:stream-stopped",
Data: map[string]any{"studentId": studentID},
})
}
} }
} }
} }
@@ -86,12 +236,20 @@ func (h *WsHub) GetOnlineStudentIDs(classID int64) []int64 {
h.mu.RLock() h.mu.RLock()
defer h.mu.RUnlock() defer h.mu.RUnlock()
seen := map[int64]bool{}
var ids []int64 var ids []int64
now := time.Now()
for _, client := range h.students { for _, client := range h.students {
if client.ClassID == classID { if client.ClassID == classID {
seen[client.StudentID] = true
ids = append(ids, client.StudentID) ids = append(ids, client.StudentID)
} }
} }
for sid, g := range h.grace {
if now.Before(g.until) && g.classID == classID && !seen[sid] {
ids = append(ids, sid)
}
}
if ids == nil { if ids == nil {
return []int64{} return []int64{}
} }
@@ -104,8 +262,19 @@ func (h *WsHub) Register(c *SocketClient) {
c.Addr = c.Conn.RemoteAddr().String() c.Addr = c.Conn.RemoteAddr().String()
if c.Role == "student" { if c.Role == "student" {
wasInGrace := false
if _, ok := h.grace[c.StudentID]; ok {
wasInGrace = true
}
h.cancelGraceLocked(c.StudentID)
h.students[c.StudentID] = c h.students[c.StudentID] = c
log.Printf("[WS] Student %d registered (Address: %s, Class: %d)", c.StudentID, c.Addr, c.ClassID) log.Printf("[WS] Student %d registered (Address: %s, Class: %d, reconnectGrace=%v)", c.StudentID, c.Addr, c.ClassID, wasInGrace)
h.broadcastPresence(c.StudentID, true, c.ClassID)
if subs, exists := h.subscribers[c.StudentID]; exists && len(subs) > 0 {
_ = c.WriteJSON(SocketMsg{Event: "start_screenshot_stream"})
_ = c.WriteJSON(SocketMsg{Event: "start_webcam_stream"})
log.Printf("[WS] Student %d has active subscribers. Sent start stream commands.", c.StudentID)
}
} else if c.Role == "teacher" { } else if c.Role == "teacher" {
h.teachers[c.Addr] = c h.teachers[c.Addr] = c
if c.StaffID > 0 { if c.StaffID > 0 {
@@ -120,21 +289,23 @@ func (h *WsHub) Unregister(c *SocketClient) {
defer h.mu.Unlock() defer h.mu.Unlock()
if c.Role == "student" { if c.Role == "student" {
current, exists := h.students[c.StudentID]
if !exists || current != c {
log.Printf("[WS] Student %d stale disconnect ignored (replaced by newer connection)", c.StudentID)
return
}
delete(h.students, c.StudentID) delete(h.students, c.StudentID)
log.Printf("[WS] Student %d disconnected", c.StudentID) classID := c.ClassID
studentID := c.StudentID
// Báo cho các giáo viên đang xem là stream của học sinh đã dừng h.cancelGraceLocked(studentID)
if teachers, exists := h.subscribers[c.StudentID]; exists { deadline := time.Now().Add(studentOfflineGrace)
for _, tAddr := range teachers { h.grace[studentID] = offlineGrace{until: deadline, classID: classID}
if t, found := h.teachers[tAddr]; found { h.graceTimers[studentID] = time.AfterFunc(studentOfflineGrace, func() {
_ = t.Conn.WriteJSON(SocketMsg{ h.finalizeStudentOffline(studentID, classID)
Event: "teacher:stream-stopped",
Data: map[string]any{"studentId": c.StudentID},
}) })
} log.Printf("[WS] Student %d disconnected — grace %v before offline", studentID, studentOfflineGrace)
} // Chưa broadcast offline / stream-stopped — chờ grace (reconnect nhanh như game).
delete(h.subscribers, c.StudentID)
}
} else if c.Role == "teacher" { } else if c.Role == "teacher" {
delete(h.teachers, c.Addr) delete(h.teachers, c.Addr)
if c.StaffID > 0 { if c.StaffID > 0 {
@@ -153,7 +324,6 @@ func (h *WsHub) Unregister(c *SocketClient) {
} }
log.Printf("[WS] Teacher %s disconnected", c.Addr) log.Printf("[WS] Teacher %s disconnected", c.Addr)
// Dọn dẹp subscriptions của giáo viên này
for sID, teachersList := range h.subscribers { for sID, teachersList := range h.subscribers {
newList := []string{} newList := []string{}
for _, addr := range teachersList { for _, addr := range teachersList {
@@ -161,12 +331,17 @@ func (h *WsHub) Unregister(c *SocketClient) {
newList = append(newList, addr) newList = append(newList, addr)
} }
} }
// Clean up mode map for this student and teacher
key := c.Addr + "_" + strconv.FormatInt(sID, 10)
delete(h.subscriberModes, key)
delete(h.lastRelayed, key+"_screenshot_stream_frame")
delete(h.lastRelayed, key+"_webcam_stream_frame")
if len(newList) == 0 { if len(newList) == 0 {
delete(h.subscribers, sID) delete(h.subscribers, sID)
// Nếu không còn ai xem học sinh này, gửi lệnh tắt camera/screen cho client học sinh
if student, exists := h.students[sID]; exists { if student, exists := h.students[sID]; exists {
_ = student.Conn.WriteJSON(SocketMsg{Event: "stop_screenshot_stream"}) _ = student.WriteJSON(SocketMsg{Event: "stop_screenshot_stream"})
_ = student.Conn.WriteJSON(SocketMsg{Event: "stop_webcam_stream"}) _ = student.WriteJSON(SocketMsg{Event: "stop_webcam_stream"})
} }
} else { } else {
h.subscribers[sID] = newList h.subscribers[sID] = newList
@@ -175,12 +350,10 @@ func (h *WsHub) Unregister(c *SocketClient) {
} }
} }
// Teacher bắt đầu xem stream của Student func (h *WsHub) Subscribe(teacherAddr string, studentID int64, mode string) {
func (h *WsHub) Subscribe(teacherAddr string, studentID int64) {
h.mu.Lock() h.mu.Lock()
defer h.mu.Unlock() defer h.mu.Unlock()
// Thêm giáo viên vào list người xem của học sinh
teachersList := h.subscribers[studentID] teachersList := h.subscribers[studentID]
alreadySubscribed := false alreadySubscribed := false
for _, addr := range teachersList { for _, addr := range teachersList {
@@ -191,21 +364,27 @@ func (h *WsHub) Subscribe(teacherAddr string, studentID int64) {
} }
if !alreadySubscribed { if !alreadySubscribed {
h.subscribers[studentID] = append(teachersList, teacherAddr) h.subscribers[studentID] = append(teachersList, teacherAddr)
log.Printf("[WS] Teacher %s subscribed to student %d stream", teacherAddr, studentID) log.Printf("[WS] Teacher %s subscribed to student %d stream in %s mode", teacherAddr, studentID, mode)
} }
// Phát lệnh cho máy học sinh bật stream (nếu học sinh đang online) key := teacherAddr + "_" + strconv.FormatInt(studentID, 10)
h.subscriberModes[key] = mode
if student, exists := h.students[studentID]; exists { if student, exists := h.students[studentID]; exists {
_ = student.Conn.WriteJSON(SocketMsg{Event: "start_screenshot_stream"}) _ = student.WriteJSON(SocketMsg{Event: "start_screenshot_stream"})
_ = student.Conn.WriteJSON(SocketMsg{Event: "start_webcam_stream"}) _ = student.WriteJSON(SocketMsg{Event: "start_webcam_stream"})
} }
} }
// Teacher dừng xem stream của Student
func (h *WsHub) Unsubscribe(teacherAddr string, studentID int64) { func (h *WsHub) Unsubscribe(teacherAddr string, studentID int64) {
h.mu.Lock() h.mu.Lock()
defer h.mu.Unlock() defer h.mu.Unlock()
key := teacherAddr + "_" + strconv.FormatInt(studentID, 10)
delete(h.subscriberModes, key)
delete(h.lastRelayed, key+"_screenshot_stream_frame")
delete(h.lastRelayed, key+"_webcam_stream_frame")
teachersList, exists := h.subscribers[studentID] teachersList, exists := h.subscribers[studentID]
if !exists { if !exists {
return return
@@ -221,21 +400,57 @@ func (h *WsHub) Unsubscribe(teacherAddr string, studentID int64) {
if len(newList) == 0 { if len(newList) == 0 {
delete(h.subscribers, studentID) delete(h.subscribers, studentID)
log.Printf("[WS] Student %d has no more proctor subscribers. Stopping streams.", studentID) log.Printf("[WS] Student %d has no more proctor subscribers. Stopping streams.", studentID)
// Báo học sinh tắt camera & screen stream để tiết kiệm mạng và CPU
if student, exists := h.students[studentID]; exists { if student, exists := h.students[studentID]; exists {
_ = student.Conn.WriteJSON(SocketMsg{Event: "stop_screenshot_stream"}) _ = student.WriteJSON(SocketMsg{Event: "stop_screenshot_stream"})
_ = student.Conn.WriteJSON(SocketMsg{Event: "stop_webcam_stream"}) _ = student.WriteJSON(SocketMsg{Event: "stop_webcam_stream"})
} }
} else { } else {
h.subscribers[studentID] = newList h.subscribers[studentID] = newList
} }
} }
// Chuyển tiếp frame ảnh từ Student đến các Teacher đã subscribe func (h *WsHub) RelayFrameRaw(studentID int64, event string, rawImageBuffer json.RawMessage) {
func (h *WsHub) RelayFrame(studentID int64, event string, data map[string]any) { h.mu.Lock()
h.mu.RLock() defer h.mu.Unlock()
defer h.mu.RUnlock()
// Broadcast to HTTP subscribers if any
if event == "screenshot_stream_frame" {
subs, exists := h.httpScreenSubscribers[studentID]
if exists && len(subs) > 0 {
var b64Str string
if err := json.Unmarshal(rawImageBuffer, &b64Str); err == nil && len(b64Str) > 0 {
if idx := strings.Index(b64Str, ","); idx != -1 {
b64Str = b64Str[idx+1:]
}
if rawBytes, err := base64.StdEncoding.DecodeString(b64Str); err == nil {
for _, ch := range subs {
select {
case ch <- rawBytes:
default:
}
}
}
}
}
} else if event == "webcam_stream_frame" {
subs, exists := h.httpWebcamSubscribers[studentID]
if exists && len(subs) > 0 {
var b64Str string
if err := json.Unmarshal(rawImageBuffer, &b64Str); err == nil && len(b64Str) > 0 {
if idx := strings.Index(b64Str, ","); idx != -1 {
b64Str = b64Str[idx+1:]
}
if rawBytes, err := base64.StdEncoding.DecodeString(b64Str); err == nil {
for _, ch := range subs {
select {
case ch <- rawBytes:
default:
}
}
}
}
}
}
teachersList, exists := h.subscribers[studentID] teachersList, exists := h.subscribers[studentID]
if !exists || len(teachersList) == 0 { if !exists || len(teachersList) == 0 {
@@ -247,22 +462,55 @@ func (h *WsHub) RelayFrame(studentID int64, event string, data map[string]any) {
relayEvent = "teacher:webcam-stream-frame" relayEvent = "teacher:webcam-stream-frame"
} }
msg := SocketMsg{ type RelayFrameRawMsg struct {
Event: relayEvent, Event string `json:"event"`
Data: map[string]any{ Data struct {
"studentId": studentID, StudentID int64 `json:"studentId"`
"imageBuffer": data["imageBuffer"], ImageBuffer json.RawMessage `json:"imageBuffer"`
}, } `json:"data"`
} }
for _, addr := range teachersList { msg := RelayFrameRawMsg{
if t, found := h.teachers[addr]; found { Event: relayEvent,
_ = t.Conn.WriteJSON(msg)
} }
msg.Data.StudentID = studentID
msg.Data.ImageBuffer = rawImageBuffer
msgBytes, err := json.Marshal(msg)
if err != nil {
return
}
now := time.Now()
for _, addr := range teachersList {
t, found := h.teachers[addr]
if !found || t == nil {
continue
}
subKey := addr + "_" + strconv.FormatInt(studentID, 10)
mode := h.subscriberModes[subKey]
if mode == "grid" {
// In grid mode, rate limit to max 1 frame per 3 seconds per stream type
relayKey := subKey + "_" + event
lastTime, ok := h.lastRelayed[relayKey]
if ok && now.Sub(lastTime) < 3*time.Second {
continue
}
h.lastRelayed[relayKey] = now
}
// Send asynchronously to avoid head-of-line blocking on slower clients
go func(client *SocketClient, data []byte) {
if err := client.WriteRaw(data); err != nil {
log.Printf("[WS] Relay to teacher %s failed: %v", client.Addr, err)
_ = client.Conn.Close()
}
}(t, msgBytes)
} }
} }
// WebSocket handler cho Fiber route
func WebSocketHandler(db *gorm.DB) func(*websocket.Conn) { func WebSocketHandler(db *gorm.DB) func(*websocket.Conn) {
return func(c *websocket.Conn) { return func(c *websocket.Conn) {
role := c.Query("role", "student") role := c.Query("role", "student")
@@ -295,25 +543,71 @@ func WebSocketHandler(db *gorm.DB) func(*websocket.Conn) {
c.Close() c.Close()
}() }()
c.SetReadDeadline(time.Now().Add(wsPongWait))
c.SetPongHandler(func(string) error {
return c.SetReadDeadline(time.Now().Add(wsPongWait))
})
pingDone := make(chan struct{})
defer close(pingDone)
go func() {
ticker := time.NewTicker(wsPingInterval)
defer ticker.Stop()
for {
select {
case <-pingDone:
return
case <-ticker.C:
client.writeMu.Lock()
err := c.WriteControl(websocket.PingMessage, []byte("ping"), time.Now().Add(5*time.Second))
client.writeMu.Unlock()
if err != nil {
return
}
}
}
}()
for { for {
_, msgBytes, err := c.ReadMessage() _, msgBytes, err := c.ReadMessage()
if err != nil { if err != nil {
break break
} }
_ = c.SetReadDeadline(time.Now().Add(wsPongWait))
type EventOnlyMsg struct {
Event string `json:"event"`
}
var eventMsg EventOnlyMsg
if err := json.Unmarshal(msgBytes, &eventMsg); err != nil {
continue
}
if eventMsg.Event == "screenshot_stream_frame" || eventMsg.Event == "webcam_stream_frame" {
var frameMsg struct {
Data struct {
ImageBuffer json.RawMessage `json:"imageBuffer"`
} `json:"data"`
}
if err := json.Unmarshal(msgBytes, &frameMsg); err == nil && len(frameMsg.Data.ImageBuffer) > 0 {
// Copy slice to avoid memory race / corruption since WebSocket read buffer gets recycled
bufCopy := make([]byte, len(frameMsg.Data.ImageBuffer))
copy(bufCopy, frameMsg.Data.ImageBuffer)
Hub.RelayFrameRaw(client.StudentID, eventMsg.Event, json.RawMessage(bufCopy))
}
continue
}
var msg SocketMsg var msg SocketMsg
if err := json.Unmarshal(msgBytes, &msg); err != nil { if err := json.Unmarshal(msgBytes, &msg); err != nil {
continue continue
} }
// Xử lý các sự kiện
switch msg.Event { switch msg.Event {
case "screenshot_stream_frame", "webcam_stream_frame": case "client:ping":
// Nhận frame từ học sinh, chuyển tiếp về các thầy cô _ = client.WriteJSON(SocketMsg{Event: "client:pong", Data: map[string]any{"t": time.Now().UnixMilli()}})
Hub.RelayFrame(client.StudentID, msg.Event, msg.Data)
case "teacher:subscribe": case "teacher:subscribe":
// Giáo viên đăng ký xem học sinh cụ thể
if client.Role == "teacher" { if client.Role == "teacher" {
if sIDVal, ok := msg.Data["studentId"]; ok { if sIDVal, ok := msg.Data["studentId"]; ok {
var sID int64 var sID int64
@@ -323,14 +617,20 @@ func WebSocketHandler(db *gorm.DB) func(*websocket.Conn) {
case string: case string:
sID, _ = strconv.ParseInt(v, 10, 64) sID, _ = strconv.ParseInt(v, 10, 64)
} }
// Nhận chế độ subscription (mặc định là "focus")
mode := "focus"
if mVal, ok := msg.Data["mode"].(string); ok && mVal != "" {
mode = mVal
}
if sID > 0 { if sID > 0 {
Hub.Subscribe(client.Addr, sID) Hub.Subscribe(client.Addr, sID, mode)
} }
} }
} }
case "teacher:unsubscribe": case "teacher:unsubscribe":
// Giáo viên hủy đăng ký
if client.Role == "teacher" { if client.Role == "teacher" {
if sIDVal, ok := msg.Data["studentId"]; ok { if sIDVal, ok := msg.Data["studentId"]; ok {
var sID int64 var sID int64
@@ -349,3 +649,134 @@ func WebSocketHandler(db *gorm.DB) func(*websocket.Conn) {
} }
} }
} }
func (h *WsHub) RegisterHttpSubscriber(studentID int64, kind string) chan []byte {
h.mu.Lock()
defer h.mu.Unlock()
ch := make(chan []byte, 16)
if kind == "screen" {
h.httpScreenSubscribers[studentID] = append(h.httpScreenSubscribers[studentID], ch)
} else if kind == "webcam" {
h.httpWebcamSubscribers[studentID] = append(h.httpWebcamSubscribers[studentID], ch)
}
// Always trigger streams start if there's any HTTP subscriber
if student, exists := h.students[studentID]; exists {
_ = student.WriteJSON(SocketMsg{Event: "start_screenshot_stream"})
_ = student.WriteJSON(SocketMsg{Event: "start_webcam_stream"})
}
return ch
}
func (h *WsHub) UnregisterHttpSubscriber(studentID int64, kind string, ch chan []byte) {
h.mu.Lock()
defer h.mu.Unlock()
if kind == "screen" {
subs := h.httpScreenSubscribers[studentID]
var next []chan []byte
for _, c := range subs {
if c != ch {
next = append(next, c)
}
}
if len(next) == 0 {
delete(h.httpScreenSubscribers, studentID)
} else {
h.httpScreenSubscribers[studentID] = next
}
} else if kind == "webcam" {
subs := h.httpWebcamSubscribers[studentID]
var next []chan []byte
for _, c := range subs {
if c != ch {
next = append(next, c)
}
}
if len(next) == 0 {
delete(h.httpWebcamSubscribers, studentID)
} else {
h.httpWebcamSubscribers[studentID] = next
}
}
// If no subscribers left (WS or HTTP), stop student's stream
wsSubs := h.subscribers[studentID]
httpScSubs := h.httpScreenSubscribers[studentID]
httpCamSubs := h.httpWebcamSubscribers[studentID]
if len(wsSubs) == 0 && len(httpScSubs) == 0 && len(httpCamSubs) == 0 {
if student, exists := h.students[studentID]; exists {
_ = student.WriteJSON(SocketMsg{Event: "stop_screenshot_stream"})
_ = student.WriteJSON(SocketMsg{Event: "stop_webcam_stream"})
}
}
close(ch)
}
func GetStudentScreenStreamHandler(c *fiber.Ctx) error {
studentIDVal := c.Params("studentId")
studentID, err := strconv.ParseInt(studentIDVal, 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).SendString("Invalid student ID")
}
ch := Hub.RegisterHttpSubscriber(studentID, "screen")
c.Set("Content-Type", "multipart/x-mixed-replace; boundary=frame")
c.Set("Cache-Control", "no-cache")
c.Set("Connection", "keep-alive")
c.Set("Pragma", "no-cache")
c.Status(fiber.StatusOK)
c.Context().SetBodyStreamWriter(func(w *bufio.Writer) {
defer Hub.UnregisterHttpSubscriber(studentID, "screen", ch)
for frame := range ch {
_, _ = fmt.Fprintf(w, "--frame\r\n")
_, _ = fmt.Fprintf(w, "Content-Type: image/jpeg\r\n")
_, _ = fmt.Fprintf(w, "Content-Length: %d\r\n\r\n", len(frame))
_, _ = w.Write(frame)
_, _ = fmt.Fprintf(w, "\r\n")
if err := w.Flush(); err != nil {
return
}
}
})
return nil
}
func GetStudentWebcamStreamHandler(c *fiber.Ctx) error {
studentIDVal := c.Params("studentId")
studentID, err := strconv.ParseInt(studentIDVal, 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).SendString("Invalid student ID")
}
ch := Hub.RegisterHttpSubscriber(studentID, "webcam")
c.Set("Content-Type", "multipart/x-mixed-replace; boundary=frame")
c.Set("Cache-Control", "no-cache")
c.Set("Connection", "keep-alive")
c.Set("Pragma", "no-cache")
c.Status(fiber.StatusOK)
c.Context().SetBodyStreamWriter(func(w *bufio.Writer) {
defer Hub.UnregisterHttpSubscriber(studentID, "webcam", ch)
for frame := range ch {
_, _ = fmt.Fprintf(w, "--frame\r\n")
_, _ = fmt.Fprintf(w, "Content-Type: image/jpeg\r\n")
_, _ = fmt.Fprintf(w, "Content-Length: %d\r\n\r\n", len(frame))
_, _ = w.Write(frame)
_, _ = fmt.Fprintf(w, "\r\n")
if err := w.Flush(); err != nil {
return
}
}
})
return nil
}

View File

@@ -93,6 +93,7 @@ func main() {
api.Get("/student/wifi-policy", handlers.GetStudentWifiPolicyHandler(gormDB)) api.Get("/student/wifi-policy", handlers.GetStudentWifiPolicyHandler(gormDB))
api.Post("/student/report-wifi", handlers.ReportWifiHandler(gormDB)) api.Post("/student/report-wifi", handlers.ReportWifiHandler(gormDB))
api.Post("/student/report-blocked-app", handlers.ReportBlockedAppHandler(gormDB)) api.Post("/student/report-blocked-app", handlers.ReportBlockedAppHandler(gormDB))
api.Post("/student/report-violation", handlers.ReportViolationHandler(gormDB))
api.Get("/student/chat/messages", handlers.StudentListChatHandler(gormDB)) api.Get("/student/chat/messages", handlers.StudentListChatHandler(gormDB))
api.Post("/student/chat/messages", handlers.StudentSendChatHandler(gormDB)) api.Post("/student/chat/messages", handlers.StudentSendChatHandler(gormDB))
api.Get("/student/chat/conversations", handlers.StudentListChatConversationsHandler(gormDB)) api.Get("/student/chat/conversations", handlers.StudentListChatConversationsHandler(gormDB))
@@ -107,6 +108,10 @@ func main() {
return c.JSON(fiber.Map{"ok": true}) return c.JSON(fiber.Map{"ok": true})
}) })
api.Get("/public/app-downloads", handlers.ListAppDownloadsPublicHandler(gormDB))
api.Get("/public/guide-links", handlers.ListGuideLinksPublicHandler(gormDB))
api.Get("/public/app-guides", handlers.ListAppGuidesPublicHandler(gormDB))
// Management — yêu cầu đăng nhập staff // Management — yêu cầu đăng nhập staff
staff := api.Group("", middleware.RequireStaff()) staff := api.Group("", middleware.RequireStaff())
staff.Get("/auth/me", handlers.MeStaffHandler(gormDB)) staff.Get("/auth/me", handlers.MeStaffHandler(gormDB))
@@ -125,9 +130,19 @@ func main() {
staff.Get("/classes/:rkId", handlers.GetClassHandler(gormDB)) staff.Get("/classes/:rkId", handlers.GetClassHandler(gormDB))
staff.Patch("/classes/:rkId/studying", handlers.PatchClassStudyingHandler(gormDB)) staff.Patch("/classes/:rkId/studying", handlers.PatchClassStudyingHandler(gormDB))
staff.Get("/classes/:rkId/students", handlers.ListClassStudentsHandler(gormDB)) staff.Get("/classes/:rkId/students", handlers.ListClassStudentsHandler(gormDB))
staff.Get("/classes/:rkId/seating-layout", handlers.GetClassSeatingLayoutHandler(gormDB))
staff.Put("/classes/:rkId/seating-layout", handlers.PutClassSeatingLayoutHandler(gormDB))
staff.Delete("/classes/:rkId/seating-layout", handlers.DeleteClassSeatingLayoutHandler(gormDB))
// My classes (lớp cá nhân theo tài khoản)
staff.Get("/staff/my-classes", handlers.GetMyClassesHandler(gormDB))
staff.Post("/staff/my-classes/:classRkId", handlers.AddMyClassHandler(gormDB))
staff.Delete("/staff/my-classes/:classRkId", handlers.RemoveMyClassHandler(gormDB))
// Students endpoints // Students endpoints
staff.Get("/students", handlers.ListAllStudentsHandler(gormDB)) staff.Get("/students", handlers.ListAllStudentsHandler(gormDB))
staff.Get("/students/:studentId/stream/screen", internalWs.GetStudentScreenStreamHandler)
staff.Get("/students/:studentId/stream/webcam", internalWs.GetStudentWebcamStreamHandler)
// Sync endpoints // Sync endpoints
staff.Post("/sync/classes/start", handlers.StartClassesSyncHandler(gormDB, qldtClient, classesJob)) staff.Post("/sync/classes/start", handlers.StartClassesSyncHandler(gormDB, qldtClient, classesJob))
@@ -152,7 +167,9 @@ func main() {
staff.Post("/network/accepted-wifis/add", handlers.AddAcceptedWifiHandler(gormDB)) staff.Post("/network/accepted-wifis/add", handlers.AddAcceptedWifiHandler(gormDB))
staff.Delete("/network/accepted-wifis/:id", handlers.DeleteAcceptedWifiHandler(gormDB)) staff.Delete("/network/accepted-wifis/:id", handlers.DeleteAcceptedWifiHandler(gormDB))
staff.Get("/classes/:rkId/session-logs", handlers.ListClassSessionLogsHandler(gormDB)) staff.Get("/classes/:rkId/session-logs", handlers.ListClassSessionLogsHandler(gormDB))
staff.Get("/classes/:rkId/violations", handlers.ListClassViolationsHandler(gormDB))
staff.Get("/classes/:rkId/online-students", handlers.GetOnlineStudentsHandler(gormDB)) staff.Get("/classes/:rkId/online-students", handlers.GetOnlineStudentsHandler(gormDB))
staff.Get("/classes/:rkId/schedule-conflicts", handlers.GetClassScheduleConflictsHandler(gormDB))
staff.Get("/classes/:rkId/courses", handlers.GetClassCoursesHandler(gormDB, qldtClient)) staff.Get("/classes/:rkId/courses", handlers.GetClassCoursesHandler(gormDB, qldtClient))
staff.Post("/classes/:rkId/schedule/apply-template", handlers.ApplyScheduleTemplateHandler(gormDB)) staff.Post("/classes/:rkId/schedule/apply-template", handlers.ApplyScheduleTemplateHandler(gormDB))
@@ -161,6 +178,8 @@ func main() {
staff.Put("/classes/:rkId/attendance/status", handlers.UpdateAttendanceStatusHandler(gormDB)) staff.Put("/classes/:rkId/attendance/status", handlers.UpdateAttendanceStatusHandler(gormDB))
staff.Put("/classes/:rkId/attendance/bulk-status", handlers.UpdateAttendanceBulkStatusHandler(gormDB)) staff.Put("/classes/:rkId/attendance/bulk-status", handlers.UpdateAttendanceBulkStatusHandler(gormDB))
staff.Post("/classes/:rkId/attendance/push-qldt", handlers.PushAttendanceToQLDTHandler(gormDB, qldtClient)) staff.Post("/classes/:rkId/attendance/push-qldt", handlers.PushAttendanceToQLDTHandler(gormDB, qldtClient))
staff.Get("/classes/:rkId/leave-requests", handlers.GetLeaveRequestsHandler(gormDB, qldtClient))
staff.Post("/classes/:rkId/leave-requests/:leaveId/status", handlers.UpdateLeaveStatusHandler(gormDB, qldtClient))
// Chat & quản lý tài khoản // Chat & quản lý tài khoản
staff.Get("/chat/students", handlers.SearchChatStudentsHandler(gormDB)) staff.Get("/chat/students", handlers.SearchChatStudentsHandler(gormDB))
@@ -180,6 +199,7 @@ func main() {
staff.Get("/exam-rooms/search-students", handlers.SearchExamStudentsHandler(gormDB)) staff.Get("/exam-rooms/search-students", handlers.SearchExamStudentsHandler(gormDB))
staff.Get("/exam-rooms/:id", handlers.GetExamRoomHandler(gormDB)) staff.Get("/exam-rooms/:id", handlers.GetExamRoomHandler(gormDB))
staff.Get("/exam-rooms/:id/online-students", handlers.GetExamRoomOnlineStudentsHandler(gormDB)) staff.Get("/exam-rooms/:id/online-students", handlers.GetExamRoomOnlineStudentsHandler(gormDB))
staff.Get("/exam-rooms/:id/violations", handlers.ListExamRoomViolationsHandler(gormDB))
staff.Patch("/exam-rooms/:id", handlers.UpdateExamRoomHandler(gormDB)) staff.Patch("/exam-rooms/:id", handlers.UpdateExamRoomHandler(gormDB))
staff.Delete("/exam-rooms/:id", handlers.DeleteExamRoomHandler(gormDB)) staff.Delete("/exam-rooms/:id", handlers.DeleteExamRoomHandler(gormDB))
staff.Post("/exam-rooms/:id/students", handlers.AddExamRoomStudentsHandler(gormDB)) staff.Post("/exam-rooms/:id/students", handlers.AddExamRoomStudentsHandler(gormDB))
@@ -193,7 +213,12 @@ func main() {
staff.Post("/exam-rooms/:id/publish", handlers.PublishExamRoomHandler(gormDB)) staff.Post("/exam-rooms/:id/publish", handlers.PublishExamRoomHandler(gormDB))
staff.Post("/exam-rooms/:id/unpublish", handlers.UnpublishExamRoomHandler(gormDB)) staff.Post("/exam-rooms/:id/unpublish", handlers.UnpublishExamRoomHandler(gormDB))
staff.Post("/exam-rooms/:id/cancel", handlers.CancelExamRoomHandler(gormDB)) staff.Post("/exam-rooms/:id/cancel", handlers.CancelExamRoomHandler(gormDB))
staff.Post("/exam-rooms/:id/extend", handlers.ExtendExamRoomHandler(gormDB))
staff.Post("/exam-rooms/:id/assign-random", handlers.RandomAssignExamPapersHandler(gormDB)) staff.Post("/exam-rooms/:id/assign-random", handlers.RandomAssignExamPapersHandler(gormDB))
staff.Post("/exam-rooms/:id/assign-papers-batch", handlers.AssignExamPapersBatchHandler(gormDB))
staff.Get("/exam-rooms/:id/seating-layout", handlers.GetExamSeatingLayoutHandler(gormDB))
staff.Put("/exam-rooms/:id/seating-layout", handlers.PutExamSeatingLayoutHandler(gormDB))
staff.Delete("/exam-rooms/:id/seating-layout", handlers.DeleteExamSeatingLayoutHandler(gormDB))
staff.Post("/exam-rooms/:id/send-papers", handlers.SendExamPapersHandler(gormDB)) staff.Post("/exam-rooms/:id/send-papers", handlers.SendExamPapersHandler(gormDB))
staff.Get("/exam-rooms/:id/submissions", handlers.ListExamSubmissionsHandler(gormDB)) staff.Get("/exam-rooms/:id/submissions", handlers.ListExamSubmissionsHandler(gormDB))
staff.Get("/exam-rooms/:id/submissions/download-all", handlers.DownloadAllExamSubmissionsHandler(gormDB)) staff.Get("/exam-rooms/:id/submissions/download-all", handlers.DownloadAllExamSubmissionsHandler(gormDB))
@@ -202,6 +227,21 @@ func main() {
staff.Patch("/exam-rooms/:id/git-settings", handlers.UpdateExamRoomGitSettingsHandler(gormDB)) staff.Patch("/exam-rooms/:id/git-settings", handlers.UpdateExamRoomGitSettingsHandler(gormDB))
staff.Get("/exam-rooms/:id/submissions/:subId/download", handlers.DownloadExamSubmissionHandler(gormDB)) staff.Get("/exam-rooms/:id/submissions/:subId/download", handlers.DownloadExamSubmissionHandler(gormDB))
staff.Get("/guide-links", handlers.ListGuideLinksHandler(gormDB))
staff.Post("/guide-links", handlers.CreateGuideLinkHandler(gormDB))
staff.Put("/guide-links/:id", handlers.UpdateGuideLinkHandler(gormDB))
staff.Delete("/guide-links/:id", handlers.DeleteGuideLinkHandler(gormDB))
staff.Get("/app-downloads", handlers.ListAppDownloadsHandler(gormDB))
staff.Post("/app-downloads", handlers.CreateAppDownloadHandler(gormDB))
staff.Put("/app-downloads/:id", handlers.UpdateAppDownloadHandler(gormDB))
staff.Delete("/app-downloads/:id", handlers.DeleteAppDownloadHandler(gormDB))
staff.Get("/app-guides", handlers.ListAppGuidesHandler(gormDB))
staff.Post("/app-guides", handlers.CreateAppGuideHandler(gormDB))
staff.Put("/app-guides/:id", handlers.UpdateAppGuideHandler(gormDB))
staff.Delete("/app-guides/:id", handlers.DeleteAppGuideHandler(gormDB))
go func() { go func() {
ticker := time.NewTicker(30 * time.Second) ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop() defer ticker.Stop()
@@ -210,6 +250,8 @@ func main() {
} }
}() }()
app.Static("/", "../landing_page")
port := getEnv("PORT", "8080") port := getEnv("PORT", "8080")
log.Printf("Server starting on port %s...", port) log.Printf("Server starting on port %s...", port)
if err := app.Listen(":" + port); err != nil { if err := app.Listen(":" + port); err != nil {

Binary file not shown.

View File

@@ -1,28 +0,0 @@
{
"version": 1,
"exportedAt": "2026-06-29T08:03:38.6066807Z",
"profiles": [
{
"id": "4e08fbd2-15ff-4f37-ba79-44d9f633a248",
"name": "Gà Chill",
"createdAt": "2026-06-27T03:50:45.9698419Z",
"updatedAt": "2026-06-29T01:46:39.7112252Z",
"sshHost": "103.245.236.191",
"sshPort": 22,
"sshUser": "Administrator",
"sshPassword": "pass@123123aA@",
"remoteDbHost": "127.0.0.1",
"remoteDbPort": 1433,
"dbUser": "sa",
"dbPassword": "123123aA@",
"dbTank41": "Db_Tank41",
"dbTank": "Db_Tank",
"dbMember": "Db_Member",
"remoteRequestHost": "127.0.0.1",
"remoteRequestPort": 81,
"resourceUrl": "https://resource.gunnychill.net",
"deployRoot": "C:\\Gunny",
"devRootXml": "E:\\gunny3"
}
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -1,28 +0,0 @@
{
"version": 1,
"exportedAt": "2026-06-29T08:03:38.6066807Z",
"profiles": [
{
"id": "4e08fbd2-15ff-4f37-ba79-44d9f633a248",
"name": "Gà Chill",
"createdAt": "2026-06-27T03:50:45.9698419Z",
"updatedAt": "2026-06-29T01:46:39.7112252Z",
"sshHost": "103.245.236.191",
"sshPort": 22,
"sshUser": "Administrator",
"sshPassword": "pass@123123aA@",
"remoteDbHost": "127.0.0.1",
"remoteDbPort": 1433,
"dbUser": "sa",
"dbPassword": "123123aA@",
"dbTank41": "Db_Tank41",
"dbTank": "Db_Tank",
"dbMember": "Db_Member",
"remoteRequestHost": "127.0.0.1",
"remoteRequestPort": 81,
"resourceUrl": "https://resource.gunnychill.net",
"deployRoot": "C:\\Gunny",
"devRootXml": "E:\\gunny3"
}
]
}

Some files were not shown because too many files have changed in this diff Show More