Compare commits

...

22 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
56 changed files with 10580 additions and 13042 deletions

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;

View File

@@ -46,13 +46,13 @@ cd client
chmod +x build.sh chmod +x build.sh
./build.sh ./build.sh
``` ```
Sau khi build xong, file thực thi sẽ nằm tại: `client/build/bin/simple_care_v1.0` 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) ## 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.0` và cài đặt các thư viện đồ họa cơ bản của hệ thố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:** * **Ubuntu / Debian / Linux Mint:**
```bash ```bash
@@ -73,8 +73,8 @@ Người dùng cuối **không cần** cài đặt Go, Node hay trình biên d
### Lệnh chạy ứng dụng: ### Lệnh chạy ứng dụng:
Cấp quyền chạy cho file và khởi chạy: Cấp quyền chạy cho file và khởi chạy:
```bash ```bash
chmod +x simple_care_v1.0 chmod +x simple_care_v1.3
./simple_care_v1.0 ./simple_care_v1.3
``` ```
--- ---

View File

@@ -8,14 +8,15 @@ import (
"crypto/cipher" "crypto/cipher"
"crypto/rand" "crypto/rand"
"crypto/sha256" "crypto/sha256"
"encoding/json"
"encoding/base64" "encoding/base64"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"html" "html"
"io" "io"
"log" "log"
"mime/multipart" "mime/multipart"
"net"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
@@ -58,7 +59,7 @@ func getWsUrl(apiBase string) string {
return "ws://" + apiBase return "ws://" + apiBase
} }
var wifiReportLast sync.Map // ssidKey -> time.Time var wifiReportLast sync.Map // ssidKey -> time.Time
func getDynamicEncryptionKey() []byte { func getDynamicEncryptionKey() []byte {
base := "simple-care-proctor-secretkey32!" base := "simple-care-proctor-secretkey32!"
@@ -119,44 +120,45 @@ type StudentData struct {
} }
type App struct { type App struct {
ctx context.Context ctx context.Context
student *StudentData student *StudentData
mu sync.Mutex mu sync.Mutex
sessionPath string sessionPath string
statsPath string statsPath string
webviewDataPath string webviewDataPath string
pendingViolPath string pendingViolPath string
runLockPath string runLockPath string
wsConn *websocket.Conn wsConn *websocket.Conn
wsConnected bool wsConnected bool
wsConnecting bool wsConnecting bool
wsBackoff time.Duration wsBackoff time.Duration
wsWriteMu sync.Mutex wsWriteMu sync.Mutex
wifiSSID string wifiSSID string
wifiBSSID string wifiBSSID string
allowedApps string allowedApps string
onlineSecs int onlineSecs int
offlineSecs int offlineSecs int
unsyncedOn int unsyncedOn int
unsyncedOff int unsyncedOff int
lastSyncTime time.Time lastSyncTime time.Time
isStreamingSc bool isStreamingSc bool
streamScStop chan struct{} streamScStop chan struct{}
isStreamingCam bool isStreamingCam bool
streamCamStop chan struct{} streamCamStop chan struct{}
statusMsg string statusMsg string
expectingLogin bool expectingLogin bool
needsClearPortalStorage bool needsClearPortalStorage bool
backendOnline bool backendOnline bool
dashboard StudentDashboardSnapshot dashboard StudentDashboardSnapshot
wifiEnforce bool wifiEnforce bool
acceptedWifi map[string]bool acceptedWifi map[string]bool
wifiRejected bool wifiRejected bool
quitDialogShown bool quitDialogShown bool
monitoringTornDown bool monitoringTornDown bool
chatUnread int localBrowserActive bool // trình duyệt local — chỉ cho phép localhost
replyStaffID uint chatUnread int
fetchAppsMu sync.Mutex replyStaffID uint
fetchAppsMu sync.Mutex
} }
type LocalStats struct { type LocalStats struct {
@@ -217,13 +219,13 @@ func NewApp() *App {
_ = os.MkdirAll(webviewDir, 0755) _ = os.MkdirAll(webviewDir, 0755)
return &App{ return &App{
sessionPath: filepath.Join(appDir, "student_session.json"), sessionPath: filepath.Join(appDir, "student_session.json"),
statsPath: filepath.Join(appDir, "student_stats.json"), statsPath: filepath.Join(appDir, "student_stats.json"),
webviewDataPath: webviewDir, webviewDataPath: webviewDir,
pendingViolPath: filepath.Join(appDir, "pending_violations.json"), pendingViolPath: filepath.Join(appDir, "pending_violations.json"),
runLockPath: filepath.Join(appDir, "monitoring.lock"), runLockPath: filepath.Join(appDir, "monitoring.lock"),
lastSyncTime: time.Now(), lastSyncTime: time.Now(),
expectingLogin: false, expectingLogin: false,
} }
} }
@@ -276,6 +278,9 @@ func (a *App) startup(ctx context.Context) {
// Khởi chạy vòng lặp đọc local storage của trang Rikkei Portal khi chưa đăng nhập // Khởi chạy vòng lặp đọc local storage của trang Rikkei Portal khi chưa đăng nhập
go a.authStorageScanner() go a.authStorageScanner()
// Chặn thoát localhost khi đang dùng trình duyệt local
go a.localBrowserGuardLoop()
} }
func (a *App) handleGuardViolation(kind, reason string) { func (a *App) handleGuardViolation(kind, reason string) {
@@ -286,27 +291,56 @@ func (a *App) handleGuardViolation(kind, reason string) {
a.showQuitDialog("Vi phạm giám sát", reason) a.showQuitDialog("Vi phạm giám sát", reason)
} }
// HandleBeforeClose — SV bấm X / Alt+F4 khi đang giám sát = vi phạm. // HandleBeforeClose — SV bấm X / Alt+F4: luôn hỏi xác nhận.
// Trả về false để cho phép đóng sau khi đã báo cáo. // Có → thoát + lưu vết vi phạm (nếu đang giám sát exam/learning). Không → hủy đóng.
func (a *App) HandleBeforeClose() (prevent bool) { func (a *App) HandleBeforeClose() (prevent bool) {
if !a.CheckLoginStatus() || !a.isMonitoringActive() {
a.clearRunLock()
return false
}
a.mu.Lock() a.mu.Lock()
mode := a.dashboard.MonitorMode mode := a.dashboard.MonitorMode
loggedIn := a.student != nil
a.mu.Unlock() a.mu.Unlock()
if mode == "" || mode == "not_configured" {
a.clearRunLock() message := "Bạn có chắc muốn thoát Simple Care không?"
return false if loggedIn && a.isMonitoringActive() && shouldRecordViolation(mode) {
message = "Bạn có chắc chắn muốn thoát ứng dụng không?\n\nLưu ý: Thoát khi đang trong giờ thi/học sẽ được ghi nhận là VI PHẠM."
} }
a.reportViolation("app_closed", "Sinh viên tự đóng ứng dụng khi đang giám sát ("+mode+")") selection, err := runtime.MessageDialog(a.ctx, runtime.MessageDialogOptions{
a.tearDownBeforeQuit() Type: runtime.QuestionDialog,
Title: "Xác nhận thoát",
Message: message,
Buttons: []string{"Có, thoát ứng dụng", "Không, tiếp tục"},
DefaultButton: "Không, tiếp tục",
CancelButton: "Không, tiếp tục",
})
if err != nil {
log.Printf("[CLOSE] MessageDialog error: %v — hủy thoát để an toàn", err)
return true
}
if !isConfirmQuitSelection(selection) {
return true // Không / Cancel → ở lại
}
// Có → gửi vi phạm lên server rồi mới thoát
if loggedIn && a.isMonitoringActive() && shouldRecordViolation(mode) {
a.reportViolation("app_closed", "Sinh viên xác nhận tắt ứng dụng khi đang giám sát ("+mode+")")
a.tearDownBeforeQuit()
}
a.clearRunLock() a.clearRunLock()
log.Printf("[CLOSE] User confirmed quit (mode=%s, loggedIn=%v)", mode, loggedIn)
return false return false
} }
func isConfirmQuitSelection(selection string) bool {
s := strings.TrimSpace(strings.ToLower(selection))
switch s {
case "có, thoát ứng dụng", "co, thoat ung dung", "yes", "ok", "có", "co":
return true
}
// Windows đôi khi trả về đúng nhãn nút đã truyền
return strings.Contains(s, "thoát") || strings.Contains(s, "thoat") || s == "yes"
}
// tearDownBeforeQuit ngắt mọi kênh giám sát ngay — trước khi hiện dialog (tránh treo OK để duy trì kết nối). // tearDownBeforeQuit ngắt mọi kênh giám sát ngay — trước khi hiện dialog (tránh treo OK để duy trì kết nối).
func (a *App) tearDownBeforeQuit() { func (a *App) tearDownBeforeQuit() {
a.mu.Lock() a.mu.Lock()
@@ -357,22 +391,41 @@ type pendingViolation struct {
MonitorMode string `json:"monitorMode"` MonitorMode string `json:"monitorMode"`
ClientAt string `json:"clientAt"` ClientAt string `json:"clientAt"`
StudentRkID int64 `json:"studentRkId"` StudentRkID int64 `json:"studentRkId"`
ClassRkID int64 `json:"classRkId"`
ExamRoomID uint `json:"examRoomId"`
}
// shouldRecordViolation — chỉ lưu vi phạm khi đang thi hoặc đang học (trong giờ).
func shouldRecordViolation(monitorMode string) bool {
switch strings.TrimSpace(monitorMode) {
case "exam", "learning":
return true
default:
return false
}
} }
func (a *App) markRunLock() { func (a *App) markRunLock() {
a.mu.Lock() a.mu.Lock()
studentID := int64(0) studentID := int64(0)
mode := a.dashboard.MonitorMode mode := a.dashboard.MonitorMode
classID := a.dashboard.ClassRkID
examRoomID := uint(0)
if a.dashboard.Exam != nil {
examRoomID = a.dashboard.Exam.ExamRoomID
}
if a.student != nil { if a.student != nil {
studentID = a.student.StudentID studentID = a.student.StudentID
} }
a.mu.Unlock() a.mu.Unlock()
if studentID <= 0 || mode == "" || mode == "not_configured" { if studentID <= 0 || !shouldRecordViolation(mode) {
return return
} }
payload, _ := json.Marshal(map[string]any{ payload, _ := json.Marshal(map[string]any{
"studentRkId": studentID, "studentRkId": studentID,
"monitorMode": mode, "monitorMode": mode,
"classRkId": classID,
"examRoomId": examRoomID,
"startedAt": time.Now().Format(time.RFC3339), "startedAt": time.Now().Format(time.RFC3339),
}) })
_ = os.WriteFile(a.runLockPath, payload, 0644) _ = os.WriteFile(a.runLockPath, payload, 0644)
@@ -392,6 +445,8 @@ func (a *App) detectUncleanShutdown() {
var meta struct { var meta struct {
StudentRkID int64 `json:"studentRkId"` StudentRkID int64 `json:"studentRkId"`
MonitorMode string `json:"monitorMode"` MonitorMode string `json:"monitorMode"`
ClassRkID int64 `json:"classRkId"`
ExamRoomID uint `json:"examRoomId"`
StartedAt string `json:"startedAt"` StartedAt string `json:"startedAt"`
} }
_ = json.Unmarshal(data, &meta) _ = json.Unmarshal(data, &meta)
@@ -402,7 +457,7 @@ func (a *App) detectUncleanShutdown() {
} }
a.mu.Unlock() a.mu.Unlock()
} }
if meta.StudentRkID <= 0 { if meta.StudentRkID <= 0 || !shouldRecordViolation(meta.MonitorMode) {
return return
} }
reason := "Ứng dụng bị tắt đột ngột (Task Manager / kill process) khi đang giám sát" reason := "Ứng dụng bị tắt đột ngột (Task Manager / kill process) khi đang giám sát"
@@ -415,6 +470,8 @@ func (a *App) detectUncleanShutdown() {
MonitorMode: meta.MonitorMode, MonitorMode: meta.MonitorMode,
ClientAt: time.Now().Format(time.RFC3339), ClientAt: time.Now().Format(time.RFC3339),
StudentRkID: meta.StudentRkID, StudentRkID: meta.StudentRkID,
ClassRkID: meta.ClassRkID,
ExamRoomID: meta.ExamRoomID,
}) })
} }
@@ -457,8 +514,8 @@ func (a *App) flushPendingViolations() {
} }
func (a *App) postViolation(v pendingViolation) bool { func (a *App) postViolation(v pendingViolation) bool {
if v.StudentRkID <= 0 { if v.StudentRkID <= 0 || !shouldRecordViolation(v.MonitorMode) {
return true return true // bỏ qua — không thi / không học thì không lưu
} }
payload := map[string]any{ payload := map[string]any{
"studentRkId": v.StudentRkID, "studentRkId": v.StudentRkID,
@@ -466,9 +523,11 @@ func (a *App) postViolation(v pendingViolation) bool {
"reason": v.Reason, "reason": v.Reason,
"monitorMode": v.MonitorMode, "monitorMode": v.MonitorMode,
"clientAt": v.ClientAt, "clientAt": v.ClientAt,
"classRkId": v.ClassRkID,
"examRoomId": v.ExamRoomID,
} }
bodyBytes, _ := json.Marshal(payload) bodyBytes, _ := json.Marshal(payload)
client := http.Client{Timeout: 5 * time.Second} client := http.Client{Timeout: 8 * time.Second}
resp, err := client.Post(API_BASE+"/api/student/report-violation", "application/json", bytes.NewBuffer(bodyBytes)) resp, err := client.Post(API_BASE+"/api/student/report-violation", "application/json", bytes.NewBuffer(bodyBytes))
if err != nil { if err != nil {
log.Printf("[VIOLATION] report failed: %v", err) log.Printf("[VIOLATION] report failed: %v", err)
@@ -480,7 +539,7 @@ func (a *App) postViolation(v pendingViolation) bool {
log.Printf("[VIOLATION] report status %d: %s", resp.StatusCode, string(body)) log.Printf("[VIOLATION] report status %d: %s", resp.StatusCode, string(body))
return false return false
} }
log.Printf("[VIOLATION] reported kind=%s student=%d", v.Kind, v.StudentRkID) log.Printf("[VIOLATION] reported kind=%s student=%d class=%d exam=%d", v.Kind, v.StudentRkID, v.ClassRkID, v.ExamRoomID)
return true return true
} }
@@ -488,11 +547,16 @@ func (a *App) reportViolation(kind, reason string) {
a.mu.Lock() a.mu.Lock()
studentID := int64(0) studentID := int64(0)
mode := a.dashboard.MonitorMode mode := a.dashboard.MonitorMode
classID := a.dashboard.ClassRkID
examRoomID := uint(0)
if a.dashboard.Exam != nil {
examRoomID = a.dashboard.Exam.ExamRoomID
}
if a.student != nil { if a.student != nil {
studentID = a.student.StudentID studentID = a.student.StudentID
} }
a.mu.Unlock() a.mu.Unlock()
if studentID <= 0 { if studentID <= 0 || !shouldRecordViolation(mode) {
return return
} }
v := pendingViolation{ v := pendingViolation{
@@ -501,6 +565,8 @@ func (a *App) reportViolation(kind, reason string) {
MonitorMode: mode, MonitorMode: mode,
ClientAt: time.Now().Format(time.RFC3339), ClientAt: time.Now().Format(time.RFC3339),
StudentRkID: studentID, StudentRkID: studentID,
ClassRkID: classID,
ExamRoomID: examRoomID,
} }
if !a.postViolation(v) { if !a.postViolation(v) {
a.enqueuePendingViolation(v) a.enqueuePendingViolation(v)
@@ -578,6 +644,7 @@ func (a *App) startLocalServer() {
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
return return
} }
a.setLocalBrowserActive(false)
go func() { go func() {
guard.SuppressFor(5 * time.Second) guard.SuppressFor(5 * time.Second)
runtime.WindowReloadApp(a.ctx) runtime.WindowReloadApp(a.ctx)
@@ -590,10 +657,50 @@ func (a *App) startLocalServer() {
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
return return
} }
// Chỉ purge disk — JS trên trang exam-view tự clear storage + reload iframe. // Chỉ xóa HTTP disk cache — không đụng cookie / localStorage (giữ phiên đăng nhập trang thi).
go a.purgeWebViewDiskCache() go a.purgeWebViewDiskCache()
w.Write([]byte("ok")) w.Write([]byte("ok"))
}) })
mux.HandleFunc("/local-browser", func(w http.ResponseWriter, r *http.Request) {
blocked := strings.TrimSpace(r.URL.Query().Get("blocked"))
from := strings.TrimSpace(r.URL.Query().Get("from"))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
notice := ""
if blocked == "1" {
notice = `<div class="notice">Đã chặn liên kết ngoài mạng. Trình duyệt này chỉ mở <strong>localhost</strong> / <strong>127.0.0.1</strong>.</div>`
if from != "" {
notice += `<div class="notice-sub">Đã chặn: ` + html.EscapeString(from) + `</div>`
}
}
_, _ = fmt.Fprintf(w, localBrowserHTML, notice)
})
mux.HandleFunc("/local-browser-go", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
if r.Method == "OPTIONS" {
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
return
}
raw := strings.TrimSpace(r.URL.Query().Get("url"))
if raw == "" {
http.Error(w, "missing url", http.StatusBadRequest)
return
}
if !strings.Contains(raw, "://") {
raw = "http://" + raw
}
if !isLoopbackURL(raw) {
http.Error(w, "only localhost allowed", http.StatusForbidden)
return
}
a.mu.Lock()
a.localBrowserActive = true
a.mu.Unlock()
go func() {
guard.SuppressFor(3 * time.Second)
runtime.WindowExecJS(a.ctx, fmt.Sprintf("window.location.href = %q", raw))
}()
w.Write([]byte("ok"))
})
go func() { go func() {
_ = server.ListenAndServe() _ = server.ListenAndServe()
@@ -609,18 +716,18 @@ const examViewHTML = `<!DOCTYPE html>
<title>%s</title> <title>%s</title>
<style> <style>
*{box-sizing:border-box;margin:0;padding:0} *{box-sizing:border-box;margin:0;padding:0}
body{font-family:Segoe UI,system-ui,sans-serif;background:#1e293b;color:#f8fafc;height:100vh;display:flex;flex-direction:column} body{font-family:Segoe UI,system-ui,sans-serif;background:#f5f7fa;color:#1a2332;height:100vh;display:flex;flex-direction:column}
.bar{display:flex;align-items:center;gap:8px;padding:10px 14px;background:#0f172a;border-bottom:1px solid #334155;flex-shrink:0} .bar{display:flex;align-items:center;gap:8px;padding:10px 14px;background:#fff;border-bottom:1px solid #e4e9f0;flex-shrink:0}
.bar-title{font-size:14px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-right:auto;min-width:0} .bar-title{font-size:14px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-right:auto;min-width:0;color:#1a2332}
.bar button{border:none;border-radius:8px;padding:8px 12px;font-size:13px;cursor:pointer;font-weight:600;white-space:nowrap} .bar button{border-radius:8px;padding:8px 12px;font-size:13px;cursor:pointer;font-weight:600;white-space:nowrap}
.btn-back{background:#7c3aed;color:#fff} .btn-back{background:#fff;color:#bb2126;border:1px solid #bb2126}
.btn-back:hover{background:#6d28d9} .btn-back:hover{background:#fde8e9}
.btn-refresh{background:#0ea5e9;color:#fff} .btn-refresh{background:#bb2126;color:#fff;border:1px solid #bb2126}
.btn-refresh:hover{background:#0284c7} .btn-refresh:hover{background:#9e1c22;border-color:#9e1c22}
.btn-cache{background:#f59e0b;color:#111} .btn-cache{background:#fff;color:#bb2126;border:1px solid #bb2126}
.btn-cache:hover{background:#d97706} .btn-cache:hover{background:#fde8e9}
.btn-cache:disabled,.btn-refresh:disabled{opacity:.65;cursor:wait} .btn-cache:disabled,.btn-refresh:disabled{opacity:.65;cursor:wait}
.hint{font-size:11px;color:#94a3b8;flex-shrink:0} .hint{font-size:11px;color:#8b99a8;flex-shrink:0}
.frame-wrap{flex:1;min-height:0;background:#fff} .frame-wrap{flex:1;min-height:0;background:#fff}
iframe{width:100%%;height:100%%;border:0;display:block} iframe{width:100%%;height:100%%;border:0;display:block}
</style> </style>
@@ -629,7 +736,7 @@ iframe{width:100%%;height:100%%;border:0;display:block}
<div class="bar"> <div class="bar">
<button type="button" class="btn-back" onclick="goBack()">← Quay lại</button> <button type="button" class="btn-back" onclick="goBack()">← Quay lại</button>
<span class="bar-title">%s</span> <span class="bar-title">%s</span>
<span class="hint">Nếu trang lỗi / trắng: F5 hoặc Xóa cache</span> <span class="hint">Trang trắng/lỗi: F5 hoặc Xóa cache (giữ đăng nhập)</span>
<button type="button" class="btn-refresh" id="btn-refresh" onclick="refreshExam()">⟳ F5 Làm mới</button> <button type="button" class="btn-refresh" id="btn-refresh" onclick="refreshExam()">⟳ F5 Làm mới</button>
<button type="button" class="btn-cache" id="btn-cache" onclick="clearExamCache()">🗑 Xóa cache</button> <button type="button" class="btn-cache" id="btn-cache" onclick="clearExamCache()">🗑 Xóa cache</button>
</div> </div>
@@ -640,58 +747,35 @@ iframe{width:100%%;height:100%%;border:0;display:block}
function goBack(){ function goBack(){
fetch('http://127.0.0.1:34115/exam-close').catch(function(){}); fetch('http://127.0.0.1:34115/exam-close').catch(function(){});
} }
function refreshExam(){ function reloadFrameSoft(){
var f = document.getElementById('exam-frame'); var f = document.getElementById('exam-frame');
if (!f) return; if (!f) return;
var btn = document.getElementById('btn-refresh');
if (btn) btn.disabled = true;
try { try {
f.contentWindow.location.reload(); f.contentWindow.location.reload();
} catch (e) { } catch (e) {
var src = f.src; var src = f.getAttribute('src') || f.src;
f.src = 'about:blank'; f.src = 'about:blank';
setTimeout(function(){ f.src = src; }, 50); setTimeout(function(){ f.src = src; }, 50);
} }
}
function refreshExam(){
var btn = document.getElementById('btn-refresh');
if (btn) btn.disabled = true;
reloadFrameSoft();
setTimeout(function(){ if (btn) btn.disabled = false; }, 800); setTimeout(function(){ if (btn) btn.disabled = false; }, 800);
} }
async function clearExamCache(){ async function clearExamCache(){
var btn = document.getElementById('btn-cache'); var btn = document.getElementById('btn-cache');
if (btn) btn.disabled = true; if (btn) btn.disabled = true;
try { // Không xóa localStorage/sessionStorage/cookie — tránh văng đăng nhập trang thi.
if (window.caches) {
var keys = await caches.keys();
await Promise.all(keys.map(function(k){ return caches.delete(k); }));
}
} catch (e) {}
try { localStorage.clear(); } catch (e) {}
try { sessionStorage.clear(); } catch (e) {}
try {
var f = document.getElementById('exam-frame');
if (f && f.contentWindow) {
try { f.contentWindow.localStorage.clear(); } catch (e) {}
try { f.contentWindow.sessionStorage.clear(); } catch (e) {}
try {
if (f.contentWindow.caches) {
var ikeys = await f.contentWindow.caches.keys();
await Promise.all(ikeys.map(function(k){ return f.contentWindow.caches.delete(k); }));
}
} catch (e) {}
}
} catch (e) {}
try { try {
await fetch('http://127.0.0.1:34115/exam-clear-cache'); await fetch('http://127.0.0.1:34115/exam-clear-cache');
} catch (e) {} } catch (e) {}
var f = document.getElementById('exam-frame'); // Chờ một nhịp để disk cache kịp xóa rồi reload cùng URL (không thêm query bust).
if (f) { setTimeout(function(){
try { reloadFrameSoft();
var u = new URL(f.src, window.location.href); if (btn) btn.disabled = false;
u.searchParams.set('_sc_cb', String(Date.now())); }, 400);
f.src = u.toString();
} catch (e) {
refreshExam();
}
}
setTimeout(function(){ if (btn) btn.disabled = false; }, 1200);
} }
document.addEventListener('keydown', function(e){ document.addEventListener('keydown', function(e){
if (e.key === 'F5') { if (e.key === 'F5') {
@@ -703,6 +787,94 @@ document.addEventListener('keydown', function(e){
</body> </body>
</html>` </html>`
const localBrowserHTML = `<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Trình duyệt Local — Simple Care</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:Segoe UI,system-ui,sans-serif;background:#f5f7fa;color:#1a2332;min-height:100vh;display:flex;flex-direction:column}
.bar{display:flex;align-items:center;gap:8px;padding:10px 14px;background:#fff;border-bottom:1px solid #e4e9f0;flex-wrap:wrap}
.bar button{border-radius:8px;padding:8px 12px;font-size:13px;cursor:pointer;font-weight:600;white-space:nowrap}
.btn-back{background:#fff;color:#bb2126;border:1px solid #bb2126}
.btn-back:hover{background:#fde8e9}
.btn-go{background:#bb2126;color:#fff;border:1px solid #bb2126}
.btn-go:hover{background:#9e1c22}
.addr{flex:1;min-width:220px;padding:8px 12px;border:1px solid #e4e9f0;border-radius:8px;font-size:13px}
.addr:focus{outline:2px solid rgba(187,33,38,.25);border-color:#bb2126}
.main{flex:1;padding:28px 20px;max-width:720px;margin:0 auto;width:100%%}
h1{font-size:1.35rem;margin-bottom:8px;color:#bb2126}
.lead{color:#5a6a7e;font-size:0.95rem;margin-bottom:18px;line-height:1.5}
.notice{background:#fde8e9;color:#9e1c22;border:1px solid #f5c2c4;border-radius:10px;padding:12px 14px;margin-bottom:10px;font-size:0.9rem}
.notice-sub{font-size:0.8rem;color:#8b99a8;margin-bottom:14px;word-break:break-all}
.chips{display:flex;flex-wrap:wrap;gap:8px;margin-top:16px}
.chip{background:#fff;border:1px solid #e4e9f0;border-radius:999px;padding:6px 12px;font-size:12px;cursor:pointer;color:#bb2126;font-weight:600}
.chip:hover{background:#fde8e9;border-color:#bb2126}
.hint{margin-top:22px;font-size:12px;color:#8b99a8;line-height:1.5}
</style>
</head>
<body>
<div class="bar">
<button type="button" class="btn-back" onclick="goHome()">← Về trang chính</button>
<input class="addr" id="addr" type="url" placeholder="http://localhost:3000" spellcheck="false" />
<button type="button" class="btn-go" onclick="goLocal()">Mở</button>
</div>
<div class="main">
%s
<h1>Trình duyệt Local</h1>
<p class="lead">Chỉ dùng để xem bài làm chạy trên máy bạn (<strong>localhost</strong> / <strong>127.0.0.1</strong>). Mọi link online (GitHub, Google, …) đều bị chặn tại đây.</p>
<div class="chips">
<button type="button" class="chip" onclick="fill('http://localhost:3000')">localhost:3000</button>
<button type="button" class="chip" onclick="fill('http://localhost:5173')">localhost:5173</button>
<button type="button" class="chip" onclick="fill('http://127.0.0.1:8080')">127.0.0.1:8080</button>
<button type="button" class="chip" onclick="fill('http://localhost:5500')">localhost:5500</button>
</div>
<p class="hint">Mẹo: chạy dự án (npm run dev / Live Server), dán URL vào ô trên rồi bấm Mở. Về trang chính: nút ← hoặc menu Simple Care → Ctrl+H.</p>
</div>
<script>
function fill(u){ document.getElementById('addr').value = u; }
function goHome(){
fetch('http://127.0.0.1:34115/exam-close').catch(function(){});
}
function isLoopback(raw){
try {
if (!raw) return false;
if (raw.indexOf('://') < 0) raw = 'http://' + raw;
var u = new URL(raw);
if (u.protocol !== 'http:' && u.protocol !== 'https:') return false;
var h = (u.hostname || '').toLowerCase();
if (h === 'localhost' || h === '127.0.0.1' || h === '::1' || h === '[::1]') return true;
if (/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(h)) return true;
return false;
} catch (e) { return false; }
}
async function goLocal(){
var raw = (document.getElementById('addr').value || '').trim();
if (!raw) { alert('Nhập URL localhost'); return; }
if (!isLoopback(raw)) {
alert('Chỉ được mở localhost / 127.0.0.1');
return;
}
if (raw.indexOf('://') < 0) raw = 'http://' + raw;
try {
var res = await fetch('http://127.0.0.1:34115/local-browser-go?url=' + encodeURIComponent(raw));
if (!res.ok) {
alert('Không mở được — chỉ localhost');
return;
}
} catch (e) {
location.href = raw;
}
}
document.getElementById('addr').addEventListener('keydown', function(e){
if (e.key === 'Enter') { e.preventDefault(); goLocal(); }
});
</script>
</body>
</html>`
// loadSession nạp session từ file cục bộ // loadSession nạp session từ file cục bộ
func (a *App) loadSession() { func (a *App) loadSession() {
a.mu.Lock() a.mu.Lock()
@@ -821,6 +993,7 @@ func (a *App) GetStudentInfo() map[string]any {
// NavigateToLogin chuyển WebView đến trang đăng nhập Rikkei Portal // NavigateToLogin chuyển WebView đến trang đăng nhập Rikkei Portal
func (a *App) NavigateToLogin() { func (a *App) NavigateToLogin() {
a.setLocalBrowserActive(false)
a.mu.Lock() a.mu.Lock()
a.expectingLogin = true a.expectingLogin = true
a.mu.Unlock() a.mu.Unlock()
@@ -1857,10 +2030,88 @@ func (a *App) examStudentContext() (int64, *StudentExamSnapshot, error) {
// ReturnToDashboard quay lại giao diện chính của app (giữ cookie WebView2). // ReturnToDashboard quay lại giao diện chính của app (giữ cookie WebView2).
func (a *App) ReturnToDashboard() { func (a *App) ReturnToDashboard() {
a.setLocalBrowserActive(false)
guard.SuppressFor(5 * time.Second) guard.SuppressFor(5 * time.Second)
runtime.WindowReloadApp(a.ctx) runtime.WindowReloadApp(a.ctx)
} }
func (a *App) setLocalBrowserActive(on bool) {
a.mu.Lock()
a.localBrowserActive = on
a.mu.Unlock()
}
func isLoopbackURL(raw string) bool {
raw = strings.TrimSpace(raw)
if raw == "" {
return false
}
if !strings.Contains(raw, "://") {
raw = "http://" + raw
}
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
return false
}
scheme := strings.ToLower(u.Scheme)
if scheme != "http" && scheme != "https" {
return false
}
host := strings.ToLower(u.Hostname())
if host == "localhost" {
return true
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}
func (a *App) localBrowserGuardLoop() {
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
for range ticker.C {
a.mu.Lock()
active := a.localBrowserActive && a.ctx != nil && !a.monitoringTornDown
ctx := a.ctx
a.mu.Unlock()
if !active {
continue
}
// Nếu hostname không phải loopback → đá về trang local-browser
runtime.WindowExecJS(ctx, `
(function(){
try {
var h = (location.hostname || '').toLowerCase();
var ok = h === 'localhost' || h === '127.0.0.1' || h === '::1' || h === '[::1]' || /^127\.\d+\.\d+\.\d+$/.test(h);
if (!ok) {
location.href = 'http://127.0.0.1:34115/local-browser?blocked=1&from=' + encodeURIComponent(location.href);
}
} catch (e) {}
})();
`)
}
}
// OpenGitHub mở github.com trong WebView (về trang chính: Ctrl+H).
func (a *App) OpenGitHub() {
a.setLocalBrowserActive(false)
guard.SuppressFor(5 * time.Second)
runtime.WindowExecJS(a.ctx, `window.location.href = 'https://github.com'`)
}
// OpenGoogleTranslate mở Google Dịch trong WebView.
func (a *App) OpenGoogleTranslate() {
a.setLocalBrowserActive(false)
guard.SuppressFor(5 * time.Second)
runtime.WindowExecJS(a.ctx, `window.location.href = 'https://translate.google.com/?sl=auto&tl=vi'`)
}
// OpenLocalBrowser mở trình duyệt chỉ cho phép localhost (test bài làm local).
func (a *App) OpenLocalBrowser() {
a.setLocalBrowserActive(true)
guard.SuppressFor(5 * time.Second)
runtime.WindowExecJS(a.ctx, `window.location.href = 'http://127.0.0.1:34115/local-browser'`)
}
// ReloadExamPage — F5: làm mới trang / iframe đang mở (trắc nghiệm). // ReloadExamPage — F5: làm mới trang / iframe đang mở (trắc nghiệm).
func (a *App) ReloadExamPage() { func (a *App) ReloadExamPage() {
guard.SuppressFor(3 * time.Second) guard.SuppressFor(3 * time.Second)
@@ -1880,36 +2131,26 @@ func (a *App) ReloadExamPage() {
`) `)
} }
// ClearExamBrowserCache — xóa cache WebView (storage + disk cache) rồi làm mới trang thi. // ClearExamBrowserCache — chỉ xóa HTTP disk cache, giữ cookie/localStorage (không văng đăng nhập trang thi).
func (a *App) ClearExamBrowserCache() { func (a *App) ClearExamBrowserCache() {
guard.SuppressFor(8 * time.Second) guard.SuppressFor(8 * time.Second)
a.purgeWebViewDiskCache() a.purgeWebViewDiskCache()
runtime.WindowExecJS(a.ctx, ` runtime.WindowExecJS(a.ctx, `
(async function(){ (function(){
try { try {
if (window.caches) { if (typeof window.reloadFrameSoft === 'function') {
var keys = await caches.keys(); window.reloadFrameSoft();
await Promise.all(keys.map(function(k){ return caches.delete(k); })); return;
} }
} catch (e) {}
try { localStorage.clear(); } catch (e) {}
try { sessionStorage.clear(); } catch (e) {}
try {
var f = document.getElementById('exam-frame'); var f = document.getElementById('exam-frame');
if (f) { if (f) {
try { f.contentWindow.localStorage.clear(); } catch (e) {} try { f.contentWindow.location.reload(); }
try { f.contentWindow.sessionStorage.clear(); } catch (e) {} catch (e) {
try { var s = f.getAttribute('src') || f.src;
var u = new URL(f.src, window.location.href); f.src = 'about:blank';
u.searchParams.set('_sc_cb', String(Date.now())); setTimeout(function(){ f.src = s; }, 50);
f.src = u.toString();
return;
} catch (e) {
try { f.contentWindow.location.reload(); } catch (e2) {
var s = f.src; f.src = 'about:blank'; setTimeout(function(){ f.src = s; }, 50);
}
return;
} }
return;
} }
} catch (e) {} } catch (e) {}
location.reload(); location.reload();
@@ -1921,12 +2162,11 @@ func (a *App) purgeWebViewDiskCache() {
if a.webviewDataPath == "" { if a.webviewDataPath == "" {
return return
} }
// Chỉ Cache tải tài nguyên — KHÔNG xóa Cookies / Local Storage / Service Worker (giữ phiên đăng nhập).
subs := []string{ subs := []string{
filepath.Join("EBWebView", "Default", "Cache"), filepath.Join("EBWebView", "Default", "Cache"),
filepath.Join("EBWebView", "Default", "Code Cache"), filepath.Join("EBWebView", "Default", "Code Cache"),
filepath.Join("EBWebView", "Default", "GPUCache"), filepath.Join("EBWebView", "Default", "GPUCache"),
filepath.Join("EBWebView", "Default", "Service Worker", "CacheStorage"),
filepath.Join("EBWebView", "Default", "Service Worker", "ScriptCache"),
} }
for _, sub := range subs { for _, sub := range subs {
path := filepath.Join(a.webviewDataPath, sub) path := filepath.Join(a.webviewDataPath, sub)
@@ -1948,17 +2188,13 @@ func isLocalExamURL(raw string) bool {
} }
func (a *App) openExamWebView(targetURL, title string) error { func (a *App) openExamWebView(targetURL, title string) error {
return a.openExamWebViewOpts(targetURL, title, false)
}
// openExamWebViewOpts mở nội dung trong WebView.
// forceWrap=true luôn dùng khung exam-view (có nút F5 / xóa cache) — dùng cho trắc nghiệm.
func (a *App) openExamWebViewOpts(targetURL, title string, forceWrap bool) error {
targetURL = strings.TrimSpace(targetURL) targetURL = strings.TrimSpace(targetURL)
if targetURL == "" { if targetURL == "" {
return errors.New("không có nội dung để mở") return errors.New("không có nội dung để mở")
} }
if forceWrap || isLocalExamURL(targetURL) { a.setLocalBrowserActive(false)
// URL local (PDF/resource): khung exam-view. URL ngoài (quiz): mở top-level giữ first-party cookies/session.
if isLocalExamURL(targetURL) {
wrapper := fmt.Sprintf( wrapper := fmt.Sprintf(
"http://127.0.0.1:34115/exam-view?url=%s&title=%s", "http://127.0.0.1:34115/exam-view?url=%s&title=%s",
url.QueryEscape(targetURL), url.QueryEscape(targetURL),
@@ -1968,7 +2204,6 @@ func (a *App) openExamWebViewOpts(targetURL, title string, forceWrap bool) error
runtime.WindowExecJS(a.ctx, fmt.Sprintf("window.location.href = %q", wrapper)) runtime.WindowExecJS(a.ctx, fmt.Sprintf("window.location.href = %q", wrapper))
return nil return nil
} }
// Trang thi bên ngoài (đề/tài nguyên): mở trực tiếp để giữ first-party cookies.
guard.SuppressFor(5 * time.Second) guard.SuppressFor(5 * time.Second)
runtime.WindowExecJS(a.ctx, fmt.Sprintf("window.location.href = %q", targetURL)) runtime.WindowExecJS(a.ctx, fmt.Sprintf("window.location.href = %q", targetURL))
return nil return nil
@@ -2060,8 +2295,9 @@ func (a *App) OpenExamQuiz() error {
if err != nil { if err != nil {
return err return err
} }
// Luôn mở trong khung có nút F5 / Xóa cache để SV tự xử lý khi trang lỗi. // Mở trực tiếp (không iframe) để cookie/localStorage đăng nhập web quiz là first-party.
return a.openExamWebViewOpts(viewURL, "Làm trắc nghiệm", true) // F5 / Xóa cache / Về trang chính: menu Simple Care.
return a.openExamWebView(viewURL, "Làm trắc nghiệm")
} }
func (a *App) OpenExamResource(fileID uint) error { func (a *App) OpenExamResource(fileID uint) error {
@@ -2198,7 +2434,7 @@ func (a *App) DownloadExamResource(fileID uint) (string, error) {
// LoadExamViewFile tải file đề/tài nguyên qua Go (không mở URL trực tiếp trong WebView). // LoadExamViewFile tải file đề/tài nguyên qua Go (không mở URL trực tiếp trong WebView).
func (a *App) LoadExamViewFile(fileURL string) (map[string]any, error) { func (a *App) LoadExamViewFile(fileURL string) (map[string]any, error) {
fileURL = strings.TrimSpace(fileURL) fileURL = strings.TrimSpace(fileURL)
if !strings.HasPrefix(fileURL, API_BASE + "/api/student/exam/download") { if !strings.HasPrefix(fileURL, API_BASE+"/api/student/exam/download") {
return nil, errors.New("URL không hợp lệ") return nil, errors.New("URL không hợp lệ")
} }
if _, _, err := a.examStudentContext(); err != nil { if _, _, err := a.examStudentContext(); err != nil {
@@ -2274,7 +2510,7 @@ func (a *App) SubmitExamWork() (string, error) {
} }
_ = w.Close() _ = w.Close()
req, err := http.NewRequest("POST", API_BASE + "/api/student/exam/submit", &body) req, err := http.NewRequest("POST", API_BASE+"/api/student/exam/submit", &body)
if err != nil { if err != nil {
return "", err return "", err
} }

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

@@ -19,6 +19,7 @@ if (typeof window.go.main.App === 'undefined') {
} }
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;
@@ -126,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>
@@ -413,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 trong app. Trắc nghiệm có nút <strong>F5</strong> / <strong>Xóa cache</strong> trên thanh công cụ. Xong: 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}
@@ -498,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>
@@ -544,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>
@@ -592,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

@@ -10,82 +10,106 @@ export function ClearChatUnread() {
return ObfuscatedCall(1, []); return ObfuscatedCall(1, []);
} }
export function ClearExamBrowserCache() {
return ObfuscatedCall(2, []);
}
export function DownloadExamResource(arg1) { export function DownloadExamResource(arg1) {
return ObfuscatedCall(2, [arg1]); return ObfuscatedCall(3, [arg1]);
} }
export function GetChatConversations() { export function GetChatConversations() {
return ObfuscatedCall(3, []); return ObfuscatedCall(4, []);
} }
export function GetChatMessages(arg1) { export function GetChatMessages(arg1) {
return ObfuscatedCall(4, [arg1]); return ObfuscatedCall(5, [arg1]);
} }
export function GetChatUnread() { export function GetChatUnread() {
return ObfuscatedCall(5, []);
}
export function GetExamPaperFiles() {
return ObfuscatedCall(6, []); return ObfuscatedCall(6, []);
} }
export function GetExamPaperViewURL() { export function GetExamPaperFiles() {
return ObfuscatedCall(7, []); return ObfuscatedCall(7, []);
} }
export function GetExamQuizViewURL() { export function GetExamPaperViewURL() {
return ObfuscatedCall(8, []); return ObfuscatedCall(8, []);
} }
export function GetStats() { export function GetExamQuizViewURL() {
return ObfuscatedCall(9, []); return ObfuscatedCall(9, []);
} }
export function GetStudentInfo() { export function GetStats() {
return ObfuscatedCall(10, []); return ObfuscatedCall(10, []);
} }
export function LoadExamViewFile(arg1) { export function GetStudentInfo() {
return ObfuscatedCall(11, [arg1]); return ObfuscatedCall(11, []);
} }
export function Logout() { export function HandleBeforeClose() {
return ObfuscatedCall(12, []); return ObfuscatedCall(12, []);
} }
export function NavigateToLogin() { export function LoadExamViewFile(arg1) {
return ObfuscatedCall(13, []); return ObfuscatedCall(13, [arg1]);
} }
export function OpenExamPaper() { export function Logout() {
return ObfuscatedCall(14, []); return ObfuscatedCall(14, []);
} }
export function OpenExamQuiz() { export function NavigateToLogin() {
return ObfuscatedCall(15, []); return ObfuscatedCall(15, []);
} }
export function OpenExamResource(arg1) { export function OpenExamPaper() {
return ObfuscatedCall(16, [arg1]); return ObfuscatedCall(16, []);
} }
export function ReturnToDashboard() { export function OpenExamQuiz() {
return ObfuscatedCall(17, []); return ObfuscatedCall(17, []);
} }
export function SendChatMessage(arg1) { export function OpenExamResource(arg1) {
return ObfuscatedCall(18, [arg1]); return ObfuscatedCall(18, [arg1]);
} }
export function SendWebcamFrame(arg1) { export function OpenGitHub() {
return ObfuscatedCall(19, [arg1]); return ObfuscatedCall(19, []);
} }
export function SubmitExamWork() { export function OpenGoogleTranslate() {
return ObfuscatedCall(20, []); return ObfuscatedCall(20, []);
} }
export function UnlockChatAudio() { export function OpenLocalBrowser() {
return ObfuscatedCall(21, []); return ObfuscatedCall(21, []);
} }
export function ReloadExamPage() {
return ObfuscatedCall(22, []);
}
export function ReturnToDashboard() {
return ObfuscatedCall(23, []);
}
export function SendChatMessage(arg1) {
return ObfuscatedCall(24, [arg1]);
}
export function SendWebcamFrame(arg1) {
return ObfuscatedCall(25, [arg1]);
}
export function SubmitExamWork() {
return ObfuscatedCall(26, []);
}
export function UnlockChatAudio() {
return ObfuscatedCall(27, []);
}

View File

@@ -52,6 +52,23 @@ var systemAllowed = map[string]bool{
"biometricd": true, "biometricd": true,
"touchidd": 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 ───────────────────────────────────────────────────────── // ── Audio / Media ─────────────────────────────────────────────────────────
"coreaudiod": true, // Core Audio daemon — kill = no sound "coreaudiod": true, // Core Audio daemon — kill = no sound
"audioundockhelper": true, "audioundockhelper": true,
@@ -154,6 +171,9 @@ var systemAllowed = map[string]bool{
// ── Our app + IDE/dev tools ──────────────────────────────────────────────── // ── Our app + IDE/dev tools ────────────────────────────────────────────────
"client": true, "client": true,
"simple_care_v1.0": true, "simple_care_v1.0": true,
"simple_care_v1.1": true,
"simple_care_v1.2": true,
"simple_care_v1.3": true,
"simple_care": true, "simple_care": true,
"wails": true, "wails": true,
"code": true, // VSCode "code": true, // VSCode

View File

@@ -205,6 +205,21 @@ var systemAllowed = map[string]bool{
"WebKitWebProcess": true, "WebKitWebProcess": true,
"WebKitNetworkPro": true, // prefix "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) ──────────────────────────────────────── // ── Remote support tools (safety) ────────────────────────────────────────
"rustdesk": true, "rustdesk": true,
"rustdesk-bin": true, "rustdesk-bin": true,
@@ -221,6 +236,9 @@ var systemAllowed = map[string]bool{
"idea": true, "idea": true,
"client": true, "client": true,
"simple_care_v1.0": true, "simple_care_v1.0": true,
"simple_care_v1.1": true,
"simple_care_v1.2": true,
"simple_care_v1.3": true,
"simple_care": true, "simple_care": true,
} }

View File

@@ -285,14 +285,27 @@ var systemAllowed = map[string]bool{
"atkex.exe": true, "atkex.exe": true,
"asusoptimization.exe": true, "asusoptimization.exe": true,
// ── Antivirus trays (common, prevent false-kill) ─────────────────────────── // ── Antivirus / endpoint security (from app_pool + common) ────────────────
"avastui.exe": true, "avastui.exe": true,
"avgui.exe": true, "avgui.exe": true,
"mbam.exe": true, // Malwarebytes "mbam.exe": true, // Malwarebytes
"mbamtray.exe": true, "mbamtray.exe": true,
"bdagent.exe": true, // Bitdefender "bdagent.exe": true, // Bitdefender
"bdwtxag.exe": true, // Bitdefender widget agent
"uiseagnt.exe": true, // Trend Micro "uiseagnt.exe": true, // Trend Micro
"eguiproxy.exe": true, // ESET "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 ──────────────────────────────────────────── // ── Task Manager & System tools ────────────────────────────────────────────
"taskmgr.exe": true, "taskmgr.exe": true,
@@ -316,6 +329,9 @@ var systemAllowed = map[string]bool{
// ── Our app + IDE/dev tools ──────────────────────────────────────────────── // ── Our app + IDE/dev tools ────────────────────────────────────────────────
"client.exe": true, "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, "simple_care.exe": true,
"wails.exe": true, "wails.exe": true,
"msedgewebview2.exe": true, // WebView2 runtime (Wails renderer) "msedgewebview2.exe": true, // WebView2 runtime (Wails renderer)

View File

@@ -47,9 +47,54 @@ 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);
*outLen = 0;
return NULL;
}
// Draw image into context to resize
CGContextSetInterpolationQuality(context, kCGInterpolationHigh);
CGContextDrawImage(context, CGRectMake(0, 0, newWidth, newHeight), image);
CGImageRelease(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;
return NULL; return NULL;

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

@@ -7,6 +7,8 @@ import (
"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"
@@ -34,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()
@@ -56,7 +62,7 @@ func main() {
// 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,

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",

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",

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';
@@ -27,67 +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> </svg>
); );
const IconStudentAffairs = () => ( const IconStudentAffairs = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z" /> <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> </svg>
); );
const IconApplications = () => ( const IconApplications = () => (
<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="2" y="3" width="20" height="14" rx="2" />
<rect x="14" y="3" width="7" height="7" rx="1" /> <path d="M8 21h8M12 17v4" />
<rect x="14" y="14" width="7" height="7" rx="1" /> <path d="M7 8h.01M12 8h.01M17 8h.01M7 12h10" />
<path d="M3 14h7v7H3z" /> </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>
); );
@@ -117,25 +138,22 @@ function App() {
setSidebarCollapsed(nextVal); setSidebarCollapsed(nextVal);
localStorage.setItem('sc_sidebar_collapsed', String(nextVal)); localStorage.setItem('sc_sidebar_collapsed', String(nextVal));
}; };
const [myClasses, setMyClasses] = useState<{ id: number; name: string; code: string }[]>(() => { const [myClasses, setMyClasses] = useState<{ id: number; name: string; code: string }[]>([]);
try {
const stored = localStorage.getItem('sc_my_classes_details'); const refreshMyClasses = useCallback(() => {
return stored ? JSON.parse(stored) : []; apiMyClasses.list().then((items) => {
} catch { setMyClasses(items.map((c) => ({ id: c.rkId, name: c.name, code: c.classCode })));
return []; }).catch(() => {});
} }, []);
});
useEffect(() => { useEffect(() => {
const handleUpdate = () => { refreshMyClasses();
try { }, [refreshMyClasses]);
const stored = localStorage.getItem('sc_my_classes_details');
setMyClasses(stored ? JSON.parse(stored) : []); useEffect(() => {
} catch (e) { } window.addEventListener('my-classes-updated', refreshMyClasses);
}; return () => window.removeEventListener('my-classes-updated', refreshMyClasses);
window.addEventListener('my-classes-updated', handleUpdate); }, [refreshMyClasses]);
return () => window.removeEventListener('my-classes-updated', handleUpdate);
}, []);
useEffect(() => { useEffect(() => {
const initial = parseRoute(); const initial = parseRoute();
@@ -212,122 +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 & Tài Liệu <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>
{myClasses.length > 0 && ( {myClasses.length > 0 && (
<> <>
<div className="nav-header">Lớp của tôi</div> <li className="nav-section" aria-hidden="true">
<span className="nav-header">Lớp của tôi</span>
</li>
{myClasses.map((c) => ( {myClasses.map((c) => (
<li key={c.id} className="nav-item"> <li key={c.id} className="nav-item">
<button <button
className={navBtn(route.classId === c.id && route.tab === 'learning')} type="button"
className={`${navBtn(route.classId === c.id && route.tab === 'learning')} nav-btn--class`}
onClick={() => { onClick={() => {
navigate('learning', c.id, c.name, 'class'); navigate('learning', c.id, c.name, 'class');
setSidebarOpen(false); setSidebarOpen(false);
}} }}
title={c.name} title={c.name}
style={{
paddingLeft: '1.75rem',
fontSize: '0.82rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
display: 'block',
width: '100%',
textAlign: 'left'
}}
> >
🏫 {c.code || c.name} <span className="nav-icon"><IconMyClass /></span>
<span className="nav-label">{c.code || c.name}</span>
</button> </button>
</li> </li>
))} ))}
</> </>
)} )}
<div className="nav-header">Công tác sinh viên</div> <li className="nav-section" aria-hidden="true">
<span className="nav-header">Công tác sinh viên</span>
</li>
<li className="nav-item"> <li className="nav-item">
<button <button
type="button"
className={navBtn(route.tab === 'student-affairs' && !inWorkspace)} className={navBtn(route.tab === 'student-affairs' && !inWorkspace)}
onClick={() => setActiveTab('student-affairs')} onClick={() => setActiveTab('student-affairs')}
> >
<span className="nav-icon"><IconStudentAffairs /></span> <span className="nav-icon"><IconStudentAffairs /></span>
Công Tác Sinh Viên <span className="nav-label">Công tác sinh viên</span>
</button> </button>
</li> </li>
<li className="nav-item"> <li className="nav-item">
<button <button
type="button"
className={navBtn(route.tab === 'applications' && !inWorkspace)} className={navBtn(route.tab === 'applications' && !inWorkspace)}
onClick={() => setActiveTab('applications')} onClick={() => setActiveTab('applications')}
> >
<span className="nav-icon"><IconApplications /></span> <span className="nav-icon"><IconApplications /></span>
ng Dụng <span className="nav-label">ng dụng</span>
</button> </button>
</li> </li>
<div className="nav-header">Hệ thống</div> <li className="nav-section" aria-hidden="true">
<span className="nav-header">Hệ thống</span>
</li>
<li className="nav-item"> <li className="nav-item">
<button <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>
<ul className="nav-sub"> <li className="nav-item nav-item--sub">
{SYSTEM_NAV.map(({ section }) => ( <ul className="nav-sub">
<li key={section} className="nav-item"> {SYSTEM_NAV.map(({ section }) => (
<button <li key={section}>
className={navBtn(systemActive && route.systemSection === section)} <button
onClick={() => goSystem(section)} type="button"
> className={navBtn(systemActive && route.systemSection === section)}
{SYSTEM_SECTION_LABELS[section]} onClick={() => goSystem(section)}
</button> >
</li> <span className="nav-label">{SYSTEM_SECTION_LABELS[section]}</span>
))} </button>
</ul> </li>
))}
</ul>
</li>
</ul> </ul>
</nav> </nav>
@@ -338,12 +368,24 @@ 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-name"> <span className="sidebar-user-avatar" aria-hidden>
{staff?.fullName?.trim() || staff?.email?.split('@')[0] || 'Tài khoản'} {(staff?.fullName?.trim() || staff?.email || '?')
.split(/\s+/)
.filter(Boolean)
.slice(-2)
.map((w) => w[0]?.toUpperCase() || '')
.join('')
.slice(0, 2) || '?'}
</span>
<span className="sidebar-user-meta">
<span className="sidebar-user-name">
{staff?.fullName?.trim() || staff?.email?.split('@')[0] || 'Tài khoản'}
</span>
<span className="sidebar-user-email">{staff?.email}</span>
</span> </span>
<span className="sidebar-user-email">{staff?.email}</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>
@@ -354,7 +396,7 @@ function App() {
onClick={toggleSidebarCollapse} onClick={toggleSidebarCollapse}
title={sidebarCollapsed ? 'Mở rộng menu' : 'Thu gọn menu'} title={sidebarCollapsed ? 'Mở rộng menu' : 'Thu gọn menu'}
> >
<svg viewBox="0 0 10 18" width="10" height="18" fill="currentColor"> <svg viewBox="0 0 10 18" width="10" height="18" fill="currentColor" aria-hidden>
{sidebarCollapsed {sidebarCollapsed
? <path d="M1 1 L9 9 L1 17" /> ? <path d="M1 1 L9 9 L1 17" />
: <path d="M9 1 L1 9 L9 17" /> : <path d="M9 1 L1 9 L9 17" />

View File

@@ -387,6 +387,7 @@ export interface StudentViolationItem {
studentCode: string; studentCode: string;
fullName: string; fullName: string;
classRkId: number; classRkId: number;
examRoomId?: number;
kind: string; kind: string;
reason: string; reason: string;
monitorMode: string; monitorMode: string;
@@ -396,7 +397,7 @@ export interface StudentViolationItem {
export const VIOLATION_KIND_OPTIONS = [ export const VIOLATION_KIND_OPTIONS = [
{ value: '', label: 'Tất cả loại' }, { value: '', label: 'Tất cả loại' },
{ value: 'app_closed', label: 'Tự đóng app' }, { value: 'app_closed', label: 'Tắt ứng dụng' },
{ value: 'unclean_shutdown', label: 'Tắt đột ngột' }, { value: 'unclean_shutdown', label: 'Tắt đột ngột' },
{ value: 'multi_monitor', label: 'Nhiều màn hình' }, { value: 'multi_monitor', label: 'Nhiều màn hình' },
{ value: 'user_switch', label: 'Đổi user' }, { value: 'user_switch', label: 'Đổi user' },
@@ -932,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 {
@@ -984,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();
}, },
@@ -1007,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) => {
@@ -1030,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');
@@ -1124,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');
@@ -1186,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,44 +122,63 @@ 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">
<input <div className="app-picker-search-wrap">
type="search" <span className="app-picker-search-icon" aria-hidden>
className="app-pool-search" <IconSearch />
placeholder="Tìm theo tên app, keyword, tiêu đề cửa sổ..." </span>
value={search} <input
onChange={e => setSearch(e.target.value)} type="search"
autoFocus className="app-pool-search"
/> placeholder="Tìm theo tên app, keyword, tiêu đề cửa sổ..."
value={search}
onChange={e => setSearch(e.target.value)}
autoFocus
/>
</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,70 +101,110 @@ 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">
<input <div className="app-picker-search-wrap">
type="search" <span className="app-picker-search-icon" aria-hidden>
className="app-pool-search" <IconSearch />
placeholder="Tìm theo tên, mô tả, keyword..." </span>
value={search} <input
onChange={(e) => setSearch(e.target.value)} type="search"
autoFocus className="app-pool-search"
/> placeholder="Tìm theo tên, mô tả, keyword..."
<button type="button" className="btn btn-secondary" onClick={load} disabled={loading}> value={search}
onChange={(e) => setSearch(e.target.value)}
autoFocus
/>
</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);
<div className="template-picker-card-head"> const count = countKeywords(tpl.keywords);
<strong>{tpl.name}</strong> return (
<span className="template-picker-count">{countKeywords(tpl.keywords)} keyword</span> <article key={tpl.id} className="template-picker-card">
</div> <div className="template-picker-card-main">
{tpl.description && <p className="template-picker-desc">{tpl.description}</p>} <div className="template-picker-card-head">
<code className="template-picker-kw">{tpl.keywords}</code> <strong className="template-picker-name">{tpl.name}</strong>
<button <span className="template-picker-count">{count} keyword</span>
type="button" </div>
className="btn btn-primary btn-sm template-picker-apply" {tpl.description ? (
onClick={() => { <p className="template-picker-desc">{tpl.description}</p>
onSelect(tpl.keywords); ) : null}
onClose(); <div className="template-picker-chips" title={tpl.keywords}>
}} {kws.map((kw) => (
> <span key={kw} className="template-picker-chip">
Áp dụng khung {kw}
</button> </span>
</div> ))}
))} </div>
</div>
<button
type="button"
className="btn btn-primary btn-sm template-picker-apply"
onClick={() => {
onSelect(tpl.keywords);
onClose();
}}
>
<IconCheck size={13} />
Áp dụng
</button>
</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

@@ -14,8 +14,101 @@ import {
type LeaveRequestItem, 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 {
@@ -28,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);
@@ -46,11 +141,7 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => { const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
const scrollTop = e.currentTarget.scrollTop; const scrollTop = e.currentTarget.scrollTop;
if (scrollTop > 20) { setIsCollapsed(scrollTop > 20);
setIsCollapsed(true);
} else if (scrollTop <= 5) {
setIsCollapsed(false);
}
}, []); }, []);
const loadShifts = useCallback(async () => { const loadShifts = useCallback(async () => {
@@ -60,9 +151,7 @@ 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 (isBackground: boolean | any = false) => { const loadAttendance = useCallback(async (isBackground: boolean | any = false) => {
@@ -72,7 +161,7 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
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);
if (!isBg) 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 {
@@ -80,26 +169,16 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
} }
}, [classId, date, period]); }, [classId, date, period]);
const currentShift = useMemo(() => { const currentShift = useMemo(() => shifts.find(s => s.period === period), [shifts, period]);
return shifts.find(s => s.period === period);
}, [shifts, period]);
const courseId = currentShift?.courseId; const courseId = currentShift?.courseId;
const loadLeaveRequests = useCallback(async () => { const loadLeaveRequests = useCallback(async () => {
if (!courseId) { if (!courseId) { setLeaveRequests([]); return; }
setLeaveRequests([]);
return;
}
try { try {
setLoadingLeave(true); setLoadingLeave(true);
const res = await apiFetchLeaveRequests(classId, courseId, date); const res = await apiFetchLeaveRequests(classId, courseId, date);
setLeaveRequests(res || []); setLeaveRequests(res || []);
} catch { } catch { setLeaveRequests([]); } finally { setLoadingLeave(false); }
setLeaveRequests([]);
} finally {
setLoadingLeave(false);
}
}, [classId, courseId, date]); }, [classId, courseId, date]);
useEffect(() => { loadShifts(); }, [loadShifts]); useEffect(() => { loadShifts(); }, [loadShifts]);
@@ -108,12 +187,7 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
const handleUpdateLeaveStatus = async (leaveId: number, status: string, studentRkId: number) => { const handleUpdateLeaveStatus = async (leaveId: number, status: string, studentRkId: number) => {
try { try {
await apiUpdateLeaveStatus(classId, leaveId, { await apiUpdateLeaveStatus(classId, leaveId, { status, studentRkId, date, period });
status,
studentRkId,
date,
period,
});
await loadAttendance(); await loadAttendance();
await loadLeaveRequests(); await loadLeaveRequests();
} catch (e: any) { } catch (e: any) {
@@ -125,87 +199,61 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
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) => {
// Optimistic state update so the UI reacts instantly
setRows(prevRows => setRows(prevRows =>
prevRows.map(row => { prevRows.map(row => {
if (row.studentRkId === studentRkId) { if (row.studentRkId === studentRkId) {
const matchedOpt = ATTENDANCE_STATUS_OPTIONS.find(o => o.value === status); const matchedOpt = ATTENDANCE_STATUS_OPTIONS.find(o => o.value === status);
return { return { ...row, status, statusLabel: matchedOpt ? matchedOpt.label : row.statusLabel, statusEditedByTeacher: true };
...row,
status,
statusLabel: matchedOpt ? matchedOpt.label : row.statusLabel,
statusEditedByTeacher: true,
};
} }
return row; return row;
}) })
); );
try { try {
await apiUpdateAttendanceStatus(classId, { date, period, studentRkId, status }); await apiUpdateAttendanceStatus(classId, { date, period, studentRkId, status });
await loadAttendance(true); 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(); // Rollback on failure await loadAttendance();
} }
}; };
const handleBulkStatusChange = async (status: number) => { const handleBulkStatusChange = async (status: number) => {
if (selectedStudentRkIds.length === 0) return; if (selectedStudentRkIds.length === 0) return;
// Optimistic state update for selected students
setRows(prevRows => setRows(prevRows =>
prevRows.map(row => { prevRows.map(row => {
if (selectedStudentRkIds.includes(row.studentRkId)) { if (selectedStudentRkIds.includes(row.studentRkId)) {
const matchedOpt = ATTENDANCE_STATUS_OPTIONS.find(o => o.value === status); const matchedOpt = ATTENDANCE_STATUS_OPTIONS.find(o => o.value === status);
return { return { ...row, status, statusLabel: matchedOpt ? matchedOpt.label : row.statusLabel, statusEditedByTeacher: true };
...row,
status,
statusLabel: matchedOpt ? matchedOpt.label : row.statusLabel,
statusEditedByTeacher: true,
};
} }
return row; return row;
}) })
); );
const idsToUpdate = [...selectedStudentRkIds]; const idsToUpdate = [...selectedStudentRkIds];
setSelectedStudentRkIds([]); setSelectedStudentRkIds([]);
try { try {
await apiUpdateAttendanceBulkStatus(classId, { await apiUpdateAttendanceBulkStatus(classId, { date, period, studentRkIds: idsToUpdate, status });
date,
period,
studentRkIds: idsToUpdate,
status,
});
await loadAttendance(true); 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');
await loadAttendance(); // Rollback await loadAttendance();
} }
}; };
@@ -222,179 +270,257 @@ 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 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; }
<span>Ngày</span>
<input /* Header */
type="date" .ap-header { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem; }
className="search-input" .ap-header-title { display: flex; align-items: center; gap: 0.5rem; margin: 0; font-size: 0.95rem; font-weight: 700; color: var(--text-primary); }
style={{ padding: '0.5rem 0.75rem' }} .ap-header-title svg { color: var(--accent); flex-shrink: 0; }
value={date} .ap-header-actions { display: flex; gap: 0.3rem; align-items: center; flex-wrap: wrap; }
onChange={e => setDate(e.target.value)}
/> /* Buttons */
</label> .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; }
<label className="attendance-field"> .ap-btn:hover:not(:disabled) { background: var(--bg-hover); border-color: var(--border-hover); }
<span>Ca học</span> .ap-btn:disabled { opacity: 0.55; cursor: not-allowed; }
<select className="select-filter" value={period} onChange={e => setPeriod(Number(e.target.value))}> .ap-btn--primary { background: var(--accent); border-color: var(--accent); color: #fff; }
{(shifts.length ? shifts : [{ period: 1 }, { period: 2 }, { period: 3 }, { period: 4 }]).map((s: any) => ( .ap-btn--primary:hover:not(:disabled) { filter: brightness(1.08); background: var(--accent); }
<option key={s.period} value={s.period}> .ap-btn--close { padding: 0.22rem 0.45rem; }
Ca {s.period}{s.startTime ? ` (${s.startTime}${s.endTime})` : ''} .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; }
</option> .ap-btn--leave-badge.muted { background: var(--text-secondary); }
))}
</select> /* Collapsible toolbar area */
</label> .ap-collapsible { display: flex; flex-direction: column; gap: 0.5rem; overflow: hidden; transition: max-height 0.3s ease; }
<label className="attendance-field attendance-field--grow"> .ap-collapsible.collapsed { max-height: 0 !important; }
<span>Tìm sinh viên</span>
<input /* Toolbar */
type="search" .ap-toolbar { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: flex-end; }
className="app-pool-search attendance-search" .ap-field { display: flex; flex-direction: column; gap: 0.18rem; font-size: 0.71rem; font-weight: 600; color: var(--text-muted); }
placeholder="Mã SV, tên, email..." .ap-field--grow { flex: 1; }
value={searchQuery} .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); }
onChange={e => setSearchQuery(e.target.value)} .ap-input:focus { outline: none; border-color: var(--accent); }
/>
</label> /* QLĐT sync banner */
<button .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; }
type="button" .ap-qldt-banner--synced { background: rgba(46,125,50,0.06); border-color: rgba(46,125,50,0.3); color: #2e7d32; }
className="btn btn-secondary" .ap-qldt-banner--stale { background: rgba(180,83,9,0.06); border-color: rgba(180,83,9,0.35); color: #92400e; }
onClick={() => setIsCollapsed(!isCollapsed)}
title={isCollapsed ? 'Hiện đầy đủ thông tin chi tiết' : 'Ẩn bớt thông tin chi tiết để xem bảng to hơn'} /* 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); }
{isCollapsed ? '📂 Chi tiết' : '📁 Thu gọn'}
</button> /* Status chips */
<button type="button" className="btn btn-secondary" onClick={() => loadAttendance(false)} disabled={loading}> .ap-chips { display: flex; flex-wrap: wrap; gap: 0.22rem; align-items: center; }
Tải lại .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; }
</button> .ap-chip-count { min-width: 1rem; text-align: center; padding: 0.04rem 0.18rem; border-radius: 99px; background: rgba(0,0,0,0.12); }
{courseId && ( .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 <button
type="button" type="button"
className="btn btn-secondary" className="ap-btn"
onClick={() => setShowLeaveModal(true)} onClick={() => setIsCollapsed(!isCollapsed)}
style={{ display: 'flex', alignItems: 'center', gap: '6px' }}
> >
Đơn phép {isCollapsed ? <><IconChevronDown size={14} /> Chi tiết</> : <><IconChevronUp size={14} /> Thu gọn</>}
{leaveRequests.length > 0 && (
<span
className="badge"
style={{
background: leaveRequests.some(r => r.status === 'Đang chờ') ? 'var(--accent)' : 'var(--text-secondary)',
color: '#fff',
fontSize: '0.72rem',
padding: '0.1rem 0.35rem',
borderRadius: '10px',
fontWeight: 700
}}
>
{leaveRequests.length}
</span>
)}
</button> </button>
)} <button
<button type="button" className="btn btn-primary" onClick={handlePushQLDT} disabled={pushing || rows.length === 0}> type="button"
{pushing ? 'Đang tải...' : 'Đẩy QLĐT'} className="ap-btn"
</button> onClick={() => loadAttendance(false)}
</div> disabled={loading}
>
<div className={`attendance-collapsible-wrapper ${isCollapsed ? 'collapsed' : ''}`}> <IconRefresh size={14} />
<div Tải lại
className={`attendance-qldt-banner${ </button>
qldtSynced ? (qldtDirty ? ' attendance-qldt-banner--stale' : ' attendance-qldt-banner--synced') : '' {courseId && (
}`} <button
> type="button"
{qldtSynced ? ( className="ap-btn"
qldtDirty ? ( onClick={() => setShowLeaveModal(true)}
<> >
<strong>Đã lưu QLĐT thay đi mới</strong> <IconMail size={14} />
<span> Đơn phép
Lần đy: {formatQldtTime(shiftInfo?.pushedToQldtAt)} · Cần đy lại sau khi chỉnh sửa {leaveRequests.length > 0 && (
<span className={`ap-btn--leave-badge${hasPendingLeave ? '' : ' muted'}`}>
{leaveRequests.length}
</span> </span>
</> )}
) : ( </button>
<> )}
<strong>Đã lưu QLĐT</strong> <button
<span>Lần đy gần nhất: {formatQldtTime(shiftInfo?.pushedToQldtAt)}</span> type="button"
</> className="ap-btn ap-btn--primary"
) onClick={handlePushQLDT}
) : ( disabled={pushing || rows.length === 0}
<> >
<strong>Chưa đy lên QLĐT</strong> <IconUpload size={14} />
<span>Ca {period} ngày {date} bấm &quot;Đy QLĐT&quot; sau khi kiểm tra trạng thái</span> {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>
</div>
{/* ── Collapsible toolbar area ── */}
<div className={`ap-collapsible${isCollapsed ? ' collapsed' : ''}`}>
{/* Toolbar: date / shift / search */}
<div className="ap-toolbar">
<label className="ap-field">
<span>Ngày</span>
<input type="date" className="ap-input search-input" value={date} onChange={e => setDate(e.target.value)} />
</label>
<label className="ap-field">
<span>Ca học</span>
<select className="ap-input select-filter" value={period} onChange={e => setPeriod(Number(e.target.value))}>
{(shifts.length ? shifts : [{ period: 1 }, { period: 2 }, { period: 3 }, { period: 4 }]).map((s: any) => (
<option key={s.period} value={s.period}>Ca {s.period}{s.startTime ? ` (${s.startTime}${s.endTime})` : ''}</option>
))}
</select>
</label>
<label className="ap-field ap-field--grow">
<span>Tìm sinh viên</span>
<input type="search" className="ap-input app-pool-search attendance-search" placeholder="Mã SV, tên, email..." value={searchQuery} onChange={e => setSearchQuery(e.target.value)} />
</label>
</div>
{/* QLĐT sync status banner */}
<div className={`ap-qldt-banner${qldtSynced ? (qldtDirty ? ' ap-qldt-banner--stale' : ' ap-qldt-banner--synced') : ''}`}>
{qldtSynced
? qldtDirty
? <><strong>Đã lưu QLĐT thay đi mới</strong><span>Lần đy: {formatQldtTime(shiftInfo?.pushedToQldtAt)} · Cần đy lại</span></>
: <><strong>Đã lưu QLĐT</strong><span>Lần đy: {formatQldtTime(shiftInfo?.pushedToQldtAt)}</span></>
: <><strong>Chưa đy lên QLĐT</strong><span>Ca {period} ngày {date} bấm "Đẩy QLĐT" sau khi kiểm tra</span></>
}
</div>
{/* Shift info strip */}
{currentShift && ( {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 0 4px 0' }}> <p className="schedule-hint" style={{ margin: '0 0 2px 0', fontSize: '0.71rem', color: 'var(--text-muted)' }}>
Sửa trạng thái thủ công sẽ đưc khóa hệ thống tự tính sẽ không ghi đè. 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>
<div style={{ padding: '0.65rem 0.85rem', background: '#fef3c7', border: '1px solid #fcd34d', borderRadius: '4px', color: '#92400e', fontSize: '0.82rem', display: 'flex', alignItems: 'center', gap: '0.35rem', margin: '4px 0 4px 0', lineHeight: '1.4' }}> {/* Notice banner — replaces yellow emoji block */}
💡 <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. <div className="ap-notice ap-notice--amber">
<IconInfo size={15} />
<span>
<strong>Lưu ý:</strong> Hệ thống hiện tại chỉ ghi nhận điểm danh học tập nội bộ {' '}
<strong>KHÔNG tự đng lưu lên QLĐT</strong>. Thầy vui lòng kiểm tra kỹ trạng thái của sinh viên,
sau đó bấm nút <strong>Đy QLĐT</strong> góc phải bên trên đ đng bộ điểm danh chính thức.
</span>
</div> </div>
</div> </div>
{/* Leave Requests Approval Modal */} {/* ── Leave Requests Modal ── */}
{showLeaveModal && courseId && ( {showLeaveModal && courseId && (
<div className="modal-overlay" onClick={() => setShowLeaveModal(false)}> <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-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 className="modal-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div> <div>
<h3 className="modal-title" style={{ margin: 0 }}> Đơn xin nghỉ phép trong ca</h3> <h3 className="modal-title" style={{ margin: 0, display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
<p style={{ margin: '4px 0 0 0', color: 'var(--text-secondary)', fontSize: '0.8rem' }}> <IconMail size={17} />
Ca {period} ngày {date} - Giao diện duyệt đơn xin nghỉ từ cổng QLĐT portal. Đơn xin nghỉ phép
</p> </h3>
<p style={{ margin: '4px 0 0 0', color: 'var(--text-secondary)', fontSize: '0.8rem' }}>Ca {period} ngày {date}</p>
</div> </div>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}> <div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<button <button type="button" className="ap-btn" onClick={loadLeaveRequests} disabled={loadingLeave}>
type="button" <IconRefresh size={14} />
className="btn btn-secondary btn-sm" {loadingLeave ? 'Đang tải...' : 'Làm mới'}
onClick={loadLeaveRequests}
disabled={loadingLeave}
style={{ padding: '0.3rem 0.6rem', fontSize: '0.78rem' }}
>
{loadingLeave ? 'Đang tải...' : '🔄 Làm mới'}
</button>
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setShowLeaveModal(false)}>
Đóng
</button> </button>
<button type="button" className="ap-btn" onClick={() => setShowLeaveModal(false)}>Đóng</button>
</div> </div>
</div> </div>
<div className="modal-body" style={{ padding: '1rem', overflowY: 'auto', maxHeight: '60vh', display: 'flex', flexDirection: 'column', gap: '10px' }}> <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 ? ( {loadingLeave ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '2rem 0' }}> <div style={{ display: 'flex', justifyContent: 'center', padding: '2rem 0' }}>
<div className="sync-spinner" style={{ width: 28, height: 28 }} /> <div className="sync-spinner" style={{ width: 28, height: 28 }} />
@@ -405,81 +531,43 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
</div> </div>
) : ( ) : (
leaveRequests.map((req) => ( leaveRequests.map((req) => (
<div <div key={req.id} className="ap-leave-card">
key={req.id}
style={{
display: 'flex',
flexDirection: 'column',
gap: '8px',
padding: '0.85rem',
border: '1px solid var(--border-color)',
borderRadius: '8px',
backgroundColor: '#ffffff',
boxShadow: 'var(--shadow-sm)'
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: '8px' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: '8px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<div style={{ fontWeight: 600, fontSize: '0.9rem' }}>{req.student.fullName}</div> <div style={{ fontWeight: 600, fontSize: '0.9rem' }}>{req.student.fullName}</div>
<code style={{ fontSize: '0.8rem', color: 'var(--text-secondary)' }}>{req.student.studentCode}</code> <code style={{ fontSize: '0.8rem', color: 'var(--text-secondary)' }}>{req.student.studentCode}</code>
<span <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'}`}>
className="badge"
style={{
fontSize: '0.72rem',
fontWeight: 600,
backgroundColor: req.status === 'Đang chờ' ? '#fef3c7' : req.status === 'Phê duyệt' ? '#d1fae5' : '#fee2e2',
color: req.status === 'Đang chờ' ? '#b45309' : req.status === 'Phê duyệt' ? '#065f46' : '#991b1b',
padding: '0.15rem 0.45rem',
borderRadius: '4px'
}}
>
{req.status} {req.status}
</span> </span>
</div> </div>
{req.status === 'Đang chờ' && ( {req.status === 'Đang chờ' && (
<div style={{ display: 'flex', gap: '6px' }}> <div style={{ display: 'flex', gap: '6px' }}>
<button <button
type="button" type="button"
className="btn btn-primary btn-sm" className="ap-btn ap-btn--primary"
style={{ padding: '0.25rem 0.65rem', fontSize: '0.78rem', backgroundColor: 'var(--success)', borderColor: 'var(--success)' }} style={{ background: 'var(--success)', borderColor: 'var(--success)' }}
onClick={() => { 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); }}
if (confirm(`Phê duyệt đơn xin nghỉ phép của ${req.student.fullName}? Trạng thái điểm danh ca sẽ chuyển thành Nghỉ có phép.`)) {
handleUpdateLeaveStatus(req.id, 'Phê duyệt', req.student.id);
}
}}
> >
Phê duyệt <IconCheck size={13} /> Phê duyệt
</button> </button>
<button <button
type="button" type="button"
className="btn btn-secondary btn-sm text-danger" className="ap-btn"
style={{ padding: '0.25rem 0.65rem', fontSize: '0.78rem', borderColor: '#fca5a5' }} style={{ borderColor: '#fca5a5' }}
onClick={() => { 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); }}
if (confirm(`Từ chối đơn xin nghỉ phép của ${req.student.fullName}?`)) {
handleUpdateLeaveStatus(req.id, 'Từ chối', req.student.id);
}
}}
> >
Từ chối <IconXMark size={13} /> Từ chối
</button> </button>
</div> </div>
)} )}
</div> </div>
<div className="ap-leave-card-reason">
<div style={{ fontSize: '0.82rem', color: 'var(--text-primary)', background: '#f9fafb', padding: '8px 10px', borderRadius: '4px', borderLeft: '4px solid #cbd5e1', lineHeight: '1.4' }}>
<strong> do nghỉ:</strong> {req.note || 'Không có ghi chú'} <strong> do nghỉ:</strong> {req.note || 'Không có ghi chú'}
</div> </div>
{req.reasonImage && ( {req.reasonImage && (
<div style={{ marginTop: '4px' }}> <div>
<a <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' }}>
href={req.reasonImage} <IconImage size={13} /> Xem nh minh chứng
target="_blank"
rel="noopener noreferrer"
style={{ fontSize: '0.75rem', color: 'var(--accent)', textDecoration: 'underline', display: 'inline-flex', alignItems: 'center', gap: '4px', fontWeight: 500 }}
>
🖼 Xem nh minh chứng phép
</a> </a>
</div> </div>
)} )}
@@ -491,43 +579,34 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
</div> </div>
)} )}
{/* ── Bulk status actions bar ── */}
{selectedStudentRkIds.length > 0 && ( {selectedStudentRkIds.length > 0 && (
<div className="attendance-bulk-actions"> <div className="ap-bulk">
<span className="attendance-bulk-title"> <span className="ap-bulk-title">Đang chọn {selectedStudentRkIds.length} SV:</span>
Đang chọn {selectedStudentRkIds.length} sinh viên: <div className="ap-bulk-btns">
</span>
<div className="attendance-bulk-btns">
{ATTENDANCE_STATUS_OPTIONS.map(opt => ( {ATTENDANCE_STATUS_OPTIONS.map(opt => (
<button <button key={opt.value} type="button" className={`ap-btn ${attendanceStatusClass(opt.value)}`} style={{ border: '1px solid var(--att-border)' }} onClick={() => handleBulkStatusChange(opt.value)}>
key={opt.value} Gắn "{opt.label}"
type="button"
className={`btn btn-secondary ${attendanceStatusClass(opt.value)}`}
style={{ padding: '0.25rem 0.75rem', fontSize: '0.875rem', border: '1px solid var(--att-border)' }}
onClick={() => handleBulkStatusChange(opt.value)}
>
Gắn &quot;{opt.label}&quot;
</button> </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" onScroll={handleScroll}> {/* ── 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))}
@@ -552,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>
@@ -560,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>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,86 @@
import React, { useEffect, useState, useRef, useMemo } from 'react'; import React, { useEffect, useState, useRef, useMemo } from 'react';
import { getWsUrl, type ExamRoomStudent } from '../api'; import { type ExamRoomStudent } from '../api';
import { openStaffChat } from '../chatEvents'; 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 { interface ExamGridProctorProps {
students: ExamRoomStudent[]; students: ExamRoomStudent[];
@@ -13,165 +93,24 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
onlineIds, onlineIds,
onSelectStudent, onSelectStudent,
}) => { }) => {
const [screenFrames, setScreenFrames] = useState<Record<number, string>>({});
const [webcamFrames, setWebcamFrames] = useState<Record<number, string>>({});
const [gridCols, setGridCols] = useState<number>(3); const [gridCols, setGridCols] = useState<number>(3);
const [showWebcamOverlay, setShowWebcamOverlay] = useState<boolean>(true); const [showWebcamOverlay, setShowWebcamOverlay] = useState<boolean>(true);
const [searchQuery, setSearchQuery] = useState<string>(''); const [searchQuery, setSearchQuery] = useState<string>('');
const [onlyOnline, setOnlyOnline] = useState<boolean>(false); const [onlyOnline, setOnlyOnline] = useState<boolean>(false);
const [isFullscreen, setIsFullscreen] = 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 wsRef = useRef<WebSocket | null>(null);
const subscribedRef = useRef<Set<number>>(new Set());
const gridContainerRef = useRef<HTMLDivElement>(null); const gridContainerRef = useRef<HTMLDivElement>(null);
const studentIdsString = useMemo(
() => students.map((s) => s.studentRkId).join(','),
[students]
);
const onlineKey = useMemo(() => onlineIds.join(','), [onlineIds]);
const syncSubscriptions = (ws: WebSocket) => {
if (ws.readyState !== WebSocket.OPEN) return;
const target = new Set(onlineIds);
const prev = subscribedRef.current;
prev.forEach((id) => {
if (!target.has(id)) {
ws.send(JSON.stringify({ event: 'teacher:unsubscribe', data: { studentId: id } }));
prev.delete(id);
}
});
onlineIds.forEach((id) => {
if (!prev.has(id)) {
ws.send(JSON.stringify({ event: 'teacher:subscribe', data: { studentId: id } }));
prev.add(id);
}
});
};
useEffect(() => { useEffect(() => {
if (students.length === 0) return; const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setZoomedStudent(null);
const wsUrl = getWsUrl('/ws?role=teacher');
let closed = false;
let retryTimer: ReturnType<typeof setTimeout> | null = null;
let pingTimer: ReturnType<typeof setInterval> | null = null;
let attempt = 0;
const clearPing = () => {
if (pingTimer) {
clearInterval(pingTimer);
pingTimer = null;
}
}; };
window.addEventListener('keydown', handleKeyDown);
const connect = () => { return () => window.removeEventListener('keydown', handleKeyDown);
if (closed) return; }, []);
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
subscribedRef.current = new Set();
ws.onopen = () => {
attempt = 0;
syncSubscriptions(ws);
clearPing();
pingTimer = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ event: 'client:ping', data: {} }));
}
}, 15000);
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.event === 'client:pong') return;
if (msg.event === 'teacher:screenshot-stream-frame') {
const { studentId, imageBuffer } = msg.data;
setScreenFrames((prev) => ({ ...prev, [studentId]: imageBuffer }));
} else if (msg.event === 'teacher:webcam-stream-frame') {
const { studentId, imageBuffer } = msg.data;
setWebcamFrames((prev) => ({ ...prev, [studentId]: imageBuffer }));
} else if (msg.event === 'teacher:stream-stopped') {
const { studentId } = msg.data;
setScreenFrames((prev) => {
const next = { ...prev };
delete next[studentId];
return next;
});
setWebcamFrames((prev) => {
const next = { ...prev };
delete next[studentId];
return next;
});
}
} catch (err) {
console.error('Error parsing WS message in grid view:', err);
}
};
ws.onclose = () => {
clearPing();
subscribedRef.current = new Set();
if (!closed) {
const delay = Math.min(1000 * 2 ** Math.min(attempt++, 4), 10000);
retryTimer = setTimeout(connect, delay);
}
};
};
connect();
return () => {
closed = true;
clearPing();
if (retryTimer) clearTimeout(retryTimer);
const ws = wsRef.current;
if (ws?.readyState === WebSocket.OPEN) {
subscribedRef.current.forEach((id) => {
ws.send(JSON.stringify({ event: 'teacher:unsubscribe', data: { studentId: id } }));
});
}
ws?.close();
wsRef.current = null;
subscribedRef.current = new Set();
};
}, [studentIdsString]);
useEffect(() => {
const ws = wsRef.current;
if (ws) syncSubscriptions(ws);
}, [onlineKey]);
// Clean up frames when students are removed or go offline
useEffect(() => {
setScreenFrames((prev) => {
const next = { ...prev };
let changed = false;
Object.keys(next).forEach((idStr) => {
const id = Number(idStr);
if (!onlineIds.includes(id)) {
delete next[id];
changed = true;
}
});
return changed ? next : prev;
});
setWebcamFrames((prev) => {
const next = { ...prev };
let changed = false;
Object.keys(next).forEach((idStr) => {
const id = Number(idStr);
if (!onlineIds.includes(id)) {
delete next[id];
changed = true;
}
});
return changed ? next : prev;
});
}, [onlineIds]);
const filteredStudents = useMemo(() => { const filteredStudents = useMemo(() => {
return students.filter((s) => { return students.filter((s) => {
@@ -179,11 +118,25 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
s.fullName.toLowerCase().includes(searchQuery.toLowerCase()) || s.fullName.toLowerCase().includes(searchQuery.toLowerCase()) ||
s.studentCode.toLowerCase().includes(searchQuery.toLowerCase()); s.studentCode.toLowerCase().includes(searchQuery.toLowerCase());
const isOnline = onlineIds.includes(s.studentRkId); const isOnline = onlineIds.includes(s.studentRkId);
const matchesOnlineFilter = !onlyOnline || isOnline; return matchesSearch && (!onlyOnline || isOnline);
return matchesSearch && matchesOnlineFilter;
}); });
}, [students, onlineIds, searchQuery, onlyOnline]); }, [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) => { const handleOpenChat = (s: ExamRoomStudent, e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
openStaffChat({ openStaffChat({
@@ -213,95 +166,77 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
}; };
return ( return (
<div className="grid-proctor-container" ref={gridContainerRef}> <div className={`grid-proctor-container ${isFullscreen ? 'fullscreen-active' : ''}`} ref={gridContainerRef}>
<div className="grid-proctor-toolbar"> <div className="grid-proctor-toolbar">
<div className="grid-proctor-toolbar-left"> <div className="grid-proctor-toolbar-left">
<div className="search-input-wrapper"> <div className="gp-search">
<span className="gp-search-icon"><IconSearch size={14} /></span>
<input <input
type="text" type="search"
className="search-input" className="search-input gp-search-input"
placeholder="Lọc sinh viên..." placeholder="Lọc sinh viên..."
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
/> />
<span className="search-icon">🔍</span>
</div> </div>
<label className="checkbox-label" style={{ display: 'flex', alignItems: 'center', gap: '0.35rem', cursor: 'pointer', fontSize: '0.85rem' }}> <label className="gp-check">
<input <input type="checkbox" checked={onlyOnline} onChange={(e) => setOnlyOnline(e.target.checked)} />
type="checkbox"
checked={onlyOnline}
onChange={(e) => setOnlyOnline(e.target.checked)}
/>
<span>Chỉ hiện Online</span> <span>Chỉ hiện Online</span>
</label> </label>
<label className="checkbox-label" style={{ display: 'flex', alignItems: 'center', gap: '0.35rem', cursor: 'pointer', fontSize: '0.85rem' }}> <label className="gp-check">
<input <input type="checkbox" checked={showWebcamOverlay} onChange={(e) => setShowWebcamOverlay(e.target.checked)} />
type="checkbox"
checked={showWebcamOverlay}
onChange={(e) => setShowWebcamOverlay(e.target.checked)}
/>
<span>Đè webcam góc màn hình</span> <span>Đè webcam góc màn hình</span>
</label> </label>
</div> </div>
<div className="grid-proctor-toolbar-right"> <div className="grid-proctor-toolbar-right">
<div className="grid-cols-selector"> <label className="gp-page-size">
<span style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>Cột:</span> <span>Xem tối đa</span>
<button <select
type="button" value={pageSize}
className={`btn btn-secondary btn-xs ${gridCols === 2 ? 'active' : ''}`} onChange={(e) => {
onClick={() => setGridCols(2)} const val = e.target.value;
setPageSize(val === 'all' ? 'all' : Number(val));
setCurrentPage(1);
}}
> >
2 <option value={12}>12 bạn</option>
</button> <option value={24}>24 bạn</option>
<button <option value={48}>48 bạn</option>
type="button" <option value="all">Tất cả</option>
className={`btn btn-secondary btn-xs ${gridCols === 3 ? 'active' : ''}`} </select>
onClick={() => setGridCols(3)} </label>
>
3 <div className="grid-cols-selector" role="group" aria-label="Số cột">
</button> <span className="gp-cols-label">Cột</span>
<button {[2, 3, 4, 6].map((n) => (
type="button" <button
className={`btn btn-secondary btn-xs ${gridCols === 4 ? 'active' : ''}`} key={n}
onClick={() => setGridCols(4)} type="button"
> className={gridCols === n ? 'active' : ''}
4 onClick={() => setGridCols(n)}
</button> >
<button {n}
type="button" </button>
className={`btn btn-secondary btn-xs ${gridCols === 6 ? 'active' : ''}`} ))}
onClick={() => setGridCols(6)}
>
6
</button>
</div> </div>
<button <button type="button" className="btn btn-secondary btn-sm gp-fs-btn" onClick={toggleFullscreen}>
type="button" {isFullscreen ? <IconMinimize size={13} /> : <IconMaximize size={13} />}
className="btn btn-secondary btn-sm" {isFullscreen ? 'Thu nhỏ' : 'Toàn màn hình'}
onClick={toggleFullscreen}
>
{isFullscreen ? '🖥️ Thu nhỏ' : '🖥️ Toàn màn hình'}
</button> </button>
</div> </div>
</div> </div>
<div className="grid-proctor-scroll">
<div <div
className={`grid-proctor-layout`} className="grid-proctor-layout"
style={{ style={{ gridTemplateColumns: `repeat(${gridCols}, minmax(0, 1fr))` }}
display: 'grid',
gridTemplateColumns: `repeat(${gridCols}, minmax(0, 1fr))`,
gap: '1rem',
padding: '1rem 0',
}}
> >
{filteredStudents.map((s) => { {pagedStudents.map((s) => {
const isOnline = onlineIds.includes(s.studentRkId); const isOnline = onlineIds.includes(s.studentRkId);
const screenFrame = screenFrames[s.studentRkId];
const webcamFrame = webcamFrames[s.studentRkId];
return ( return (
<div <div
@@ -312,69 +247,67 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
<div className="grid-proctor-card-header"> <div className="grid-proctor-card-header">
<div className="student-info-left"> <div className="student-info-left">
<span className={`status-dot ${isOnline ? 'online' : 'offline'}`} /> <span className={`status-dot ${isOnline ? 'online' : 'offline'}`} />
<span className="student-name" title={s.fullName}> <span className="student-name" title={s.fullName}>{s.fullName}</span>
{s.fullName}
</span>
</div> </div>
<span className="student-code">{s.studentCode}</span> <span className="student-code">{s.studentCode}</span>
</div> </div>
<div className="grid-proctor-card-body"> <div className="grid-proctor-card-body">
{isOnline ? ( {isOnline ? (
<div className="proctor-frame-container"> <div
{screenFrame ? ( className="proctor-frame-container cursor-zoom-in"
<img onClick={(e) => {
src={screenFrame} e.stopPropagation();
alt={`Màn hình ${s.fullName}`} setZoomedStudent(s);
className="proctor-screen-image" }}
draggable={false} >
/> <StudentStreamImage
) : ( studentId={s.studentRkId}
<div className="proctor-placeholder streaming"> kind="screen"
<div className="sync-spinner" style={{ width: '20px', height: '20px', borderWidth: '2px', marginBottom: '0.5rem' }} /> className="proctor-screen-image"
<span>Đang kết nối màn hình...</span> />
</div> {showWebcamOverlay && (
)}
{showWebcamOverlay && webcamFrame && (
<div className="proctor-webcam-overlay"> <div className="proctor-webcam-overlay">
<img <StudentStreamImage
src={webcamFrame} studentId={s.studentRkId}
alt={`Webcam ${s.fullName}`} kind="webcam"
className="proctor-webcam-image" className="proctor-webcam-image"
draggable={false}
/> />
</div> </div>
)} )}
</div> </div>
) : ( ) : (
<div className="proctor-placeholder offline"> <div className="proctor-placeholder offline">
<span className="icon">📴</span> <span className="gp-offline-icon"><IconOffline size={26} /></span>
<span>Ngoại tuyến (Offline)</span> <span className="gp-offline-label">Ngoại tuyến</span>
<span className="gp-offline-sub">Offline</span>
</div> </div>
)} )}
</div> </div>
<div className="grid-proctor-card-footer" onClick={(e) => e.stopPropagation()}> <div className="grid-proctor-card-footer" onClick={(e) => e.stopPropagation()}>
<span className="assigned-paper" title={s.paperTitle || 'Chưa gán đề'}> <span className="assigned-paper" title={s.paperTitle || 'Chưa gán đề'}>
{s.paperTitle ? `Đề: ${s.paperTitle}` : 'Chưa gán đề'} <IconFile size={12} />
{s.paperTitle ? s.paperTitle : 'Chưa gán đề'}
</span> </span>
<div className="footer-actions"> <div className="footer-actions">
<button <button
type="button" type="button"
className="btn btn-ghost btn-xs text-primary" className="gp-action-btn"
onClick={(e) => handleOpenChat(s, e)} onClick={(e) => handleOpenChat(s, e)}
title="Nhắn tin cho sinh viên" title="Nhắn tin cho sinh viên"
> >
💬 Nhắn tin <IconMessage size={13} />
Nhắn tin
</button> </button>
<button <button
type="button" type="button"
className="btn btn-ghost btn-xs" className="gp-action-btn gp-action-btn--primary"
onClick={() => onSelectStudent(s)} onClick={() => onSelectStudent(s)}
title="Xem chi tiết giám sát" title="Xem chi tiết giám sát"
> >
🔍 Giám sát <IconEye size={13} />
Giám sát
</button> </button>
</div> </div>
</div> </div>
@@ -383,12 +316,77 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
})} })}
{filteredStudents.length === 0 && ( {filteredStudents.length === 0 && (
<div className="grid-proctor-empty" style={{ gridColumn: '1 / -1' }}> <div className="grid-proctor-empty">
<span>🔍</span> <span className="gp-empty-icon"><IconSearch size={28} /></span>
<p>Không tìm thấy sinh viên nào.</p> <p>Không tìm thấy sinh viên nào.</p>
</div> </div>
)} )}
</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> </div>
); );
}; };

View File

@@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { import {
apiExam, apiExam,
apiSeatingLayout,
staffFetch, staffFetch,
type ExamPaper, type ExamPaper,
type ExamRoomStudent, type ExamRoomStudent,
@@ -25,6 +26,101 @@ import { ViolationsPanel } from './ViolationsPanel';
const EXAM_APP_SUGGESTIONS = [...new Set([...BASE_APP_SUGGESTIONS, 'msedge', 'edge', 'acrobat', 'foxit'])]; const EXAM_APP_SUGGESTIONS = [...new Set([...BASE_APP_SUGGESTIONS, 'msedge', 'edge', 'acrobat', 'foxit'])];
/* ─── Icons (match ClassWorkspace) ───────────────────────────────────────── */
type IconProps = { size?: number };
const IconExam = ({ size = 20 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="9" y1="13" x2="15" y2="13" />
<line x1="9" y1="17" x2="13" y2="17" />
</svg>
);
const IconSettings = ({ size = 14 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z" />
</svg>
);
const IconClose = ({ size = 16 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M18 6 6 18M6 6l12 12" />
</svg>
);
const IconMap = ({ size = 14 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<polygon points="3 6 9 3 15 6 21 3 21 18 15 21 9 18 3 21" />
<line x1="9" y1="3" x2="9" y2="18" />
<line x1="15" y1="6" x2="15" y2="21" />
</svg>
);
const IconMonitor = ({ size = 14 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<rect x="2" y="3" width="20" height="14" rx="2" />
<path d="M8 21h8M12 17v4" />
</svg>
);
const IconList = ({ size = 14 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<line x1="8" y1="6" x2="21" y2="6" />
<line x1="8" y1="12" x2="21" y2="12" />
<line x1="8" y1="18" x2="21" y2="18" />
<line x1="3" y1="6" x2="3.01" y2="6" />
<line x1="3" y1="12" x2="3.01" y2="12" />
<line x1="3" y1="18" x2="3.01" y2="18" />
</svg>
);
const IconPackage = ({ size = 14 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
<line x1="12" y1="22.08" x2="12" y2="12" />
</svg>
);
const IconShield = ({ size = 14 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg>
);
const IconSearch = ({ size = 14 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<circle cx="11" cy="11" r="7" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
);
const IconUsers = ({ size = 13 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="3.5" />
<path d="M22 21v-2a3.5 3.5 0 0 0-2.5-3.35" />
<path d="M16 3.5a3.5 3.5 0 0 1 0 7" />
</svg>
);
const IconPlus = ({ size = 14 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
);
const IconInbox = ({ size = 36 }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<polyline points="22 12 16 12 14 15 10 15 8 12 2 12" />
<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
</svg>
);
interface Props { interface Props {
examId: number; examId: number;
onBack: () => void; onBack: () => void;
@@ -69,6 +165,8 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
const [canPublish, setCanPublish] = useState(false); const [canPublish, setCanPublish] = useState(false);
const [canUnpublish, setCanUnpublish] = useState(false); const [canUnpublish, setCanUnpublish] = useState(false);
const [canCancel, setCanCancel] = useState(false); const [canCancel, setCanCancel] = useState(false);
const [canExtend, setCanExtend] = useState(false);
const [extending, setExtending] = useState(false);
const [papers, setPapers] = useState<ExamPaper[]>([]); const [papers, setPapers] = useState<ExamPaper[]>([]);
const [students, setStudents] = useState<ExamRoomStudent[]>([]); const [students, setStudents] = useState<ExamRoomStudent[]>([]);
const [submissions, setSubmissions] = useState<ExamSubmission[]>([]); const [submissions, setSubmissions] = useState<ExamSubmission[]>([]);
@@ -76,8 +174,7 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
const [searchHits, setSearchHits] = useState<{ studentRkId: number; fullName: string; studentCode: string; email: string; classCodes: string }[]>([]); const [searchHits, setSearchHits] = useState<{ studentRkId: number; fullName: string; studentCode: string; email: string; classCodes: string }[]>([]);
const [selectedPickerRkIds, setSelectedPickerRkIds] = useState<number[]>([]); const [selectedPickerRkIds, setSelectedPickerRkIds] = useState<number[]>([]);
const [paperTitle, setPaperTitle] = useState(''); const [paperTitle, setPaperTitle] = useState('');
const [packagePdf, setPackagePdf] = useState<File | null>(null); const [packagePdfs, setPackagePdfs] = useState<File[]>([]);
const [packageResources, setPackageResources] = useState<File[]>([]);
const [creatingPackage, setCreatingPackage] = useState(false); const [creatingPackage, setCreatingPackage] = useState(false);
const [sendSchedule, setSendSchedule] = useState(''); const [sendSchedule, setSendSchedule] = useState('');
const [msg, setMsg] = useState(''); const [msg, setMsg] = useState('');
@@ -101,6 +198,7 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
const canEditApps = displayStatus === 'draft' || displayStatus === 'ready' || displayStatus === 'active'; const canEditApps = displayStatus === 'draft' || displayStatus === 'ready' || displayStatus === 'active';
const anyPaperSent = students.some((s) => s.paperSentAt); const anyPaperSent = students.some((s) => s.paperSentAt);
const canModifyPapers = prepEditable || (!anyPaperSent && (displayStatus === 'draft' || displayStatus === 'ready' || displayStatus === 'active')); const canModifyPapers = prepEditable || (!anyPaperSent && (displayStatus === 'draft' || displayStatus === 'ready' || displayStatus === 'active'));
const canRemoveStudents = displayStatus !== 'ended' && displayStatus !== 'cancelled';
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -118,6 +216,7 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
setCanPublish(data.canPublish); setCanPublish(data.canPublish);
setCanUnpublish(data.canUnpublish); setCanUnpublish(data.canUnpublish);
setCanCancel(data.canCancel); setCanCancel(data.canCancel);
setCanExtend(!!data.canExtend || data.displayStatus === 'active');
setPapers(data.papers); setPapers(data.papers);
setStudents(data.students); setStudents(data.students);
pushNav({ pushNav({
@@ -231,6 +330,20 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
} }
}; };
const saveExtendedEndTime = async () => {
setErr(''); setMsg('');
setSaving(true);
try {
await apiExam.update(examId, { endTime: localInputToISO(end) });
setMsg('Đã gia hạn giờ kết thúc phòng thi');
await load();
} catch (e: any) {
setErr(e?.message || 'Lỗi');
} finally {
setSaving(false);
}
};
const addStudent = async (studentRkId: number) => { const addStudent = async (studentRkId: number) => {
await apiExam.addStudents(examId, [studentRkId]); await apiExam.addStudents(examId, [studentRkId]);
await load(); await load();
@@ -293,21 +406,35 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
} }
}; };
const createPackage = async () => { const createPackagesBulk = async () => {
if (!packagePdf) { if (!packagePdfs.length) {
setErr('Chọn file PDF cho gói đề'); setErr('Chọn ít nhất một file PDF đề chính');
return; return;
} }
setErr(''); setErr('');
setCreatingPackage(true); setCreatingPackage(true);
try { try {
const title = paperTitle.trim() || packagePdf.name.replace(/\.pdf$/i, '') || 'Gói đề'; const prefix = paperTitle.trim();
const resCount = packageResources.length; const createdTitle = packagePdfs.length === 1
await apiExam.uploadPackage(examId, title, packagePdf, packageResources); ? (prefix || packagePdfs[0].name.replace(/\.pdf$/i, '') || 'Gói đề')
: '';
let created = 0;
for (let i = 0; i < packagePdfs.length; i++) {
const pdf = packagePdfs[i];
const baseName = pdf.name.replace(/\.pdf$/i, '') || 'Gói đề';
const title = packagePdfs.length === 1
? (prefix || baseName)
: baseName;
await apiExam.uploadPackage(examId, title, pdf, []);
created++;
}
setPaperTitle(''); setPaperTitle('');
setPackagePdf(null); setPackagePdfs([]);
setPackageResources([]); setMsg(
setMsg(`Đã tạo gói đề "${title}" (1 PDF${resCount ? ` + ${resCount} tài nguyên` : ''})`); created === 1
? `Đã tạo gói đề "${createdTitle}". Bổ sung tài nguyên bên dưới nếu cần.`
: `Đã tạo ${created} gói đề. Bổ sung tài nguyên cho từng gói bên dưới nếu cần.`,
);
await load(); await load();
} catch (e: any) { } catch (e: any) {
setErr(e?.message || 'Tạo gói đề thất bại'); setErr(e?.message || 'Tạo gói đề thất bại');
@@ -316,6 +443,10 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
} }
}; };
const removePackagePdf = (index: number) => {
setPackagePdfs((prev) => prev.filter((_, i) => i !== index));
};
const uploadResource = async (paperId: number, e: React.ChangeEvent<HTMLInputElement>) => { const uploadResource = async (paperId: number, e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []); const files = Array.from(e.target.files || []);
if (!files.length) return; if (!files.length) return;
@@ -325,8 +456,62 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
}; };
const assignRandomPackages = async () => { const assignRandomPackages = async () => {
const res = await apiExam.assignRandom(examId); if (papers.length === 0) return;
setMsg(`Đã chia ${res.assigned} sinh viên vào ${res.packages} gói đề`);
// Đọc sơ đồ chỗ ngồi từ server
let seatingSeats: Record<string, number> | null = null;
try {
const json = await apiSeatingLayout.getExam(examId);
if (json) {
const parsed = JSON.parse(json) as { seats?: Record<string, number> };
if (parsed.seats && Object.keys(parsed.seats).length > 0) {
seatingSeats = parsed.seats;
}
}
} catch { /* ignore */ }
if (!seatingSeats) {
// Không có sơ đồ → fallback random server-side
const res = await apiExam.assignRandom(examId);
setMsg(`Đã chia ${res.assigned} sinh viên vào ${res.packages} gói đề`);
await load();
return;
}
// Nhóm sinh viên theo hàng: row → [(col, studentRkId)] tăng dần theo cột
const rowMap = new Map<number, { col: number; rkId: number }[]>();
Object.entries(seatingSeats).forEach(([key, rkId]) => {
const [r, c] = key.split(',').map(Number);
if (!rowMap.has(r)) rowMap.set(r, []);
rowMap.get(r)!.push({ col: c, rkId });
});
rowMap.forEach((arr) => arr.sort((a, b) => a.col - b.col));
const numPapers = papers.length;
const assignments: { studentRkId: number; paperId: number }[] = [];
const assignedIds = new Set<number>();
// Chia đề theo hàng ngang: mỗi hàng có random offset → cạnh nhau không trùng
[...rowMap.keys()].sort((a, b) => a - b).forEach((row) => {
const seatsInRow = rowMap.get(row)!;
const offset = Math.floor(Math.random() * numPapers);
seatsInRow.forEach(({ rkId }, relIdx) => {
const paper = papers[(offset + relIdx) % numPapers];
assignments.push({ studentRkId: rkId, paperId: paper.id });
assignedIds.add(rkId);
});
});
// Sinh viên chưa có chỗ ngồi → chia tuần tự ngẫu nhiên
const unseated = students
.filter((s) => !assignedIds.has(s.studentRkId))
.sort(() => Math.random() - 0.5);
unseated.forEach((s, i) => {
assignments.push({ studentRkId: s.studentRkId, paperId: papers[i % numPapers].id });
});
const res = await apiExam.assignPapersBatch(examId, assignments);
setMsg(`Đã chia ${res.assigned} sinh viên vào ${numPapers} gói đề (theo sơ đồ chỗ ngồi)`);
await load(); await load();
}; };
@@ -503,6 +688,21 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
} }
}; };
const handleExtend = async (minutes: number) => {
if (!confirm(`Gia hạn thêm ${minutes} phút cho phòng thi đang diễn ra?`)) return;
setErr(''); setMsg('');
setExtending(true);
try {
const res = await apiExam.extend(examId, minutes);
setMsg(`Đã gia hạn +${minutes} phút — kết thúc lúc ${fmtTime(res.endTime)}`);
await load();
} catch (e: any) {
setErr(e?.message || 'Gia hạn thất bại');
} finally {
setExtending(false);
}
};
const filteredStudents = students.filter((s) => { const filteredStudents = students.filter((s) => {
const term = searchQuery.toLowerCase().trim(); const term = searchQuery.toLowerCase().trim();
if (!term) return true; if (!term) return true;
@@ -555,37 +755,35 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
<div className={`alert-banner ${err ? 'alert-error' : 'alert-success'}`}>{err || msg}</div> <div className={`alert-banner ${err ? 'alert-error' : 'alert-success'}`}>{err || msg}</div>
)} )}
<div className="workspace-header"> <div className="workspace-header ew-header">
<div> <div className="ew-header-left">
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}> <div className="ew-brand-icon">
<div className="brand-logo" style={{ width: 44, height: 44, fontSize: '1.1rem' }}>📝</div> <IconExam size={20} />
<div> </div>
<h1 className="workspace-class-title">{roomName || 'Phòng thi'}</h1> <div className="ew-title-block">
<div className="class-badge-container"> <h1 className="workspace-class-title">{roomName || 'Phòng thi'}</h1>
<span className={`badge ${badge.cls}`}>{badge.text}</span> <div className="class-badge-container">
{start && end && ( <span className={`badge ${badge.cls}`}>{badge.text}</span>
<span className="badge badge-muted"> {start && end && (
{fmtTime(localInputToISO(start))} {fmtTime(localInputToISO(end))} <span className="badge badge-muted">
</span> {fmtTime(localInputToISO(start))} {fmtTime(localInputToISO(end))}
)} </span>
<span className="badge badge-info">ID: {examId}</span> )}
<span className="badge badge-muted">{papers.length} gói đ · {submissions.length} bài nộp</span> <span className="badge badge-info">ID: {examId}</span>
</div> <span className="badge badge-muted">{papers.length} gói đ · {submissions.length} bài nộp</span>
</div> </div>
</div> </div>
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: '1.25rem', flexWrap: 'wrap' }}> <div className="ew-header-right">
<div className="status-panel"> <div className="status-panel ew-status-panel">
<div style={{ display: 'flex', flexDirection: 'column' }}> <div className="ew-status-label-group">
<span style={{ fontSize: '0.72rem', color: 'var(--text-muted)', fontWeight: 600, textTransform: 'uppercase' }}> <span className="ew-meta-label">Trạng thái thi</span>
Trạng thái thi <span className="ew-status-value" style={{ color: statusColor(displayStatus) }}>
</span>
<span style={{ fontSize: '0.875rem', fontWeight: 700, color: statusColor(displayStatus) }}>
{badge.text} {badge.text}
</span> </span>
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}> <div className="ew-status-actions">
{canPublish && ( {canPublish && (
<button type="button" className="btn btn-primary btn-sm" onClick={handlePublish}>Đy phòng thi</button> <button type="button" className="btn btn-primary btn-sm" onClick={handlePublish}>Đy phòng thi</button>
)} )}
@@ -595,21 +793,38 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
{canCancel && ( {canCancel && (
<button type="button" className="btn btn-secondary btn-sm learning-btn-danger" onClick={handleCancel}>Hủy</button> <button type="button" className="btn btn-secondary btn-sm learning-btn-danger" onClick={handleCancel}>Hủy</button>
)} )}
{canExtend && (
<div className="ew-extend-row">
<span className="ew-meta-label">Gia hạn</span>
{[15, 30, 60].map((m) => (
<button
key={m}
type="button"
className="btn btn-primary btn-sm"
disabled={extending}
onClick={() => handleExtend(m)}
title={`Thêm ${m} phút vào giờ kết thúc`}
>
+{m}p
</button>
))}
</div>
)}
{papers.length > 0 && ( {papers.length > 0 && (
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setPapersModalOpen(true)}> <button type="button" className="btn btn-secondary btn-sm ew-icon-btn" onClick={() => setPapersModalOpen(true)}>
📄 Xem đ <IconExam size={13} />
Xem đ
</button> </button>
)} )}
</div> </div>
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end' }}> <div className="ew-online-stat">
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)', fontWeight: 600, textTransform: 'uppercase' }}> <span className="ew-meta-label">Online / số</span>
Online / số <div className="workspace-stat-value ew-online-value">
</div> <IconUsers size={13} />
<div className="workspace-stat-value">
<span style={{ color: 'var(--success)' }}>{onlineCount}</span> <span style={{ color: 'var(--success)' }}>{onlineCount}</span>
<span style={{ color: 'var(--text-muted)', fontSize: '0.9rem', fontWeight: 500 }}> / {students.length} SV</span> <span className="ew-online-total">/ {students.length} SV</span>
</div> </div>
</div> </div>
</div> </div>
@@ -622,8 +837,13 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
<aside className={`workspace-config-drawer ${configOpen ? 'open' : ''}`}> <aside className={`workspace-config-drawer ${configOpen ? 'open' : ''}`}>
<div className="workspace-drawer-header"> <div className="workspace-drawer-header">
<strong>Cấu hình phòng thi</strong> <span className="ew-drawer-title">
<button type="button" className="btn btn-secondary workspace-drawer-close" onClick={() => setConfigOpen(false)} aria-label="Đóng">×</button> <IconSettings size={16} />
Cấu hình phòng thi
</span>
<button type="button" className="ew-drawer-close" onClick={() => setConfigOpen(false)} aria-label="Đóng">
<IconClose size={16} />
</button>
</div> </div>
<div className="workspace-drawer-tabs"> <div className="workspace-drawer-tabs">
<button type="button" className={`workspace-drawer-tab ${configTab === 'info' ? 'active' : ''}`} onClick={() => setConfigTab('info')}> <button type="button" className={`workspace-drawer-tab ${configTab === 'info' ? 'active' : ''}`} onClick={() => setConfigTab('info')}>
@@ -641,7 +861,11 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
<div className="config-card config-card-flat"> <div className="config-card config-card-flat">
<div className="card-header-title">Thông tin phòng thi</div> <div className="card-header-title">Thông tin phòng thi</div>
<p className="config-card-desc"> <p className="config-card-desc">
{editable ? 'Chỉnh sửa được khi phòng ở trạng thái tạm thời.' : 'Phòng đã khóa chỉnh sửa.'} {editable
? 'Chỉnh sửa được khi phòng ở trạng thái tạm thời.'
: canExtend
? 'Phòng đang thi — có thể gia hạn giờ kết thúc bên dưới hoặc dùng nút +15p / +30p / +60p trên thanh trạng thái.'
: 'Phòng đã khóa chỉnh sửa.'}
</p> </p>
<label className="login-field"> <label className="login-field">
<span>Tên phòng thi</span> <span>Tên phòng thi</span>
@@ -682,13 +906,39 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
</label> </label>
<label className="login-field"> <label className="login-field">
<span>Kết thúc</span> <span>Kết thúc</span>
<input type="datetime-local" value={end} onChange={(e) => setEnd(e.target.value)} disabled={!editable} /> <input
type="datetime-local"
value={end}
onChange={(e) => setEnd(e.target.value)}
disabled={!editable && !canExtend}
/>
</label> </label>
{editable && ( {(editable || canExtend) && (
<button type="button" className="btn btn-primary" style={{ width: '100%', justifyContent: 'center' }} disabled={saving} onClick={saveRoom}> <button
{saving ? 'Đang lưu...' : 'Lưu thông tin'} type="button"
className="btn btn-primary"
style={{ width: '100%', justifyContent: 'center' }}
disabled={saving}
onClick={editable ? saveRoom : saveExtendedEndTime}
>
{saving ? 'Đang lưu...' : canExtend && !editable ? 'Lưu giờ kết thúc (gia hạn)' : 'Lưu thông tin'}
</button> </button>
)} )}
{canExtend && (
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginTop: '0.75rem' }}>
{[15, 30, 60].map((m) => (
<button
key={m}
type="button"
className="btn btn-secondary btn-sm"
disabled={extending}
onClick={() => handleExtend(m)}
>
+{m} phút
</button>
))}
</div>
)}
</div> </div>
)} )}
@@ -750,7 +1000,7 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
<div className="config-card config-card-flat"> <div className="config-card config-card-flat">
<div className="card-header-title">Gói đ & gửi cho sinh viên</div> <div className="card-header-title">Gói đ & gửi cho sinh viên</div>
<p className="config-card-desc"> <p className="config-card-desc">
Mỗi gói đ gồm <strong>1 file PDF</strong> (đ chính) <strong>tài nguyên kèm</strong> (nh, zip, doc). Chia ngẫu nhiên sẽ gán cả gói cho từng sinh viên. Chọn <strong>nhiều PDF đ chính</strong> cùng lúc đ tạo gói hàng loạt. Sau đó bổ sung <strong>tài nguyên kèm</strong> (nh, zip, doc) cho từng gói bên dưới.
</p> </p>
{!canModifyPapers && ( {!canModifyPapers && (
<p className="config-card-desc" style={{ color: 'var(--danger)' }}> <p className="config-card-desc" style={{ color: 'var(--danger)' }}>
@@ -761,44 +1011,61 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.65rem', marginBottom: '0.75rem' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '0.65rem', marginBottom: '0.75rem' }}>
<input <input
className="search-input" className="search-input"
placeholder="Tên gói đề (VD: Gói A)" placeholder="Tên gói đề (tùy chọn, chỉ khi tạo 1 PDF)"
value={paperTitle} value={paperTitle}
onChange={(e) => setPaperTitle(e.target.value)} onChange={(e) => setPaperTitle(e.target.value)}
/> />
<label className="login-field"> <label className="login-field">
<span>PDF đ chính (bắt buộc)</span> <span>PDF đ chính (chọn nhiều file)</span>
<input <input
type="file" type="file"
accept=".pdf" accept=".pdf"
onChange={(e) => setPackagePdf(e.target.files?.[0] || null)}
/>
</label>
<label className="login-field">
<span>Tài nguyên kèm (tùy chọn, many files)</span>
<input
type="file"
multiple multiple
onChange={(e) => setPackageResources(Array.from(e.target.files || []))} onChange={(e) => {
const files = Array.from(e.target.files || []).filter((f) => /\.pdf$/i.test(f.name));
if (!files.length) {
setPackagePdfs([]);
return;
}
setPackagePdfs(files);
e.target.value = '';
}}
/> />
</label> </label>
{packageResources.length > 0 && ( {packagePdfs.length > 0 && (
<p className="config-card-desc" style={{ margin: 0 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
{packageResources.length} tài nguyên: {packageResources.map((f) => f.name).join(', ')} <p className="config-card-desc" style={{ margin: 0 }}>
</p> Đã chọn {packagePdfs.length} PDF mỗi file sẽ thành 1 gói đ:
</p>
<ul className="config-card-desc" style={{ margin: 0, paddingLeft: '1.1rem' }}>
{packagePdfs.map((f, i) => (
<li key={`${f.name}-${i}`} style={{ display: 'flex', justifyContent: 'space-between', gap: '0.5rem', alignItems: 'center' }}>
<span>📄 {paperTitle.trim() && packagePdfs.length === 1 ? paperTitle.trim() : f.name.replace(/\.pdf$/i, '')}</span>
<button type="button" className="btn btn-ghost btn-sm" onClick={() => removePackagePdf(i)}>
Bỏ
</button>
</li>
))}
</ul>
</div>
)} )}
<button <button
type="button" type="button"
className="btn btn-primary" className="btn btn-primary"
style={{ width: '100%' }} style={{ width: '100%' }}
disabled={creatingPackage || !packagePdf} disabled={creatingPackage || packagePdfs.length === 0}
onClick={createPackage} onClick={createPackagesBulk}
> >
{creatingPackage ? 'Đang tạo...' : 'Tạo gói đề'} {creatingPackage
? 'Đang tạo...'
: packagePdfs.length > 1
? `Tạo ${packagePdfs.length} gói đề`
: 'Tạo gói đề'}
</button> </button>
</div> </div>
)} )}
{papers.length === 0 ? ( {papers.length === 0 ? (
<p className="config-card-desc">Chưa gói đ. Tạo gói gồm PDF + tài nguyên.</p> <p className="config-card-desc">Chưa gói đ. Chọn nhiều PDF đ tạo hàng loạt.</p>
) : ( ) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.65rem' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '0.65rem' }}>
{papers.map((p) => ( {papers.map((p) => (
@@ -861,87 +1128,103 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
</div> </div>
</aside> </aside>
<div className="right-panel workspace-content-panel"> <div className={`right-panel workspace-content-panel${activeSubTab === 'grid' ? ' workspace-content-panel--grid' : ''}`}>
<div className="workspace-panel-toolbar"> <div className="workspace-panel-toolbar ew-toolbar">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '1rem' }}> <div className="ew-toolbar-left">
<div style={{ display: 'flex', alignItems: 'center', gap: '0.65rem', flexWrap: 'wrap' }}> <button
type="button"
className={`btn btn-secondary workspace-config-toggle ew-config-toggle ${configOpen ? 'active' : ''}`}
onClick={() => setConfigOpen((v) => !v)}
>
<IconSettings size={14} />
{configOpen ? 'Ẩn cấu hình' : 'Cấu hình phòng thi'}
</button>
<div className="ew-seg" role="tablist">
<button <button
type="button" type="button"
className={`btn btn-secondary workspace-config-toggle ${configOpen ? 'active' : ''}`} role="tab"
onClick={() => setConfigOpen((v) => !v)} aria-selected={activeSubTab === 'roster'}
className={`ew-seg-btn ${activeSubTab === 'roster' ? 'ew-seg-btn--active' : ''}`}
onClick={() => setActiveSubTab('roster')}
> >
{configOpen ? 'Ẩn cấu hình' : 'Cấu hình phòng thi'} <IconMap size={14} />
đ
</button>
<button
type="button"
role="tab"
aria-selected={activeSubTab === 'grid'}
className={`ew-seg-btn ${activeSubTab === 'grid' ? 'ew-seg-btn--active' : ''}`}
onClick={() => setActiveSubTab('grid')}
>
<IconMonitor size={14} />
Giám sát
</button>
<button
type="button"
role="tab"
aria-selected={activeSubTab === 'detail'}
className={`ew-seg-btn ${activeSubTab === 'detail' ? 'ew-seg-btn--active' : ''}`}
onClick={() => setActiveSubTab('detail')}
>
<IconList size={14} />
Chi tiết thi
</button>
<button
type="button"
role="tab"
aria-selected={activeSubTab === 'submissions'}
className={`ew-seg-btn ${activeSubTab === 'submissions' ? 'ew-seg-btn--active' : ''}`}
onClick={() => setActiveSubTab('submissions')}
>
<IconPackage size={14} />
Bài nộp ({submissions.length})
</button>
<button
type="button"
role="tab"
aria-selected={activeSubTab === 'violations'}
className={`ew-seg-btn ${activeSubTab === 'violations' ? 'ew-seg-btn--active' : ''}`}
onClick={() => setActiveSubTab('violations')}
>
<IconShield size={14} />
Vi phạm
</button> </button>
<div className="tab-btn-group">
<button
type="button"
className={`tab-sub-btn ${activeSubTab === 'roster' ? 'active' : ''}`}
onClick={() => setActiveSubTab('roster')}
>
đ sinh viên
</button>
<button
type="button"
className={`tab-sub-btn ${activeSubTab === 'grid' ? 'active' : ''}`}
onClick={() => setActiveSubTab('grid')}
>
Giám sát camera 🖥
</button>
<button
type="button"
className={`tab-sub-btn ${activeSubTab === 'detail' ? 'active' : ''}`}
onClick={() => setActiveSubTab('detail')}
>
Chi tiết thi
</button>
<button
type="button"
className={`tab-sub-btn ${activeSubTab === 'submissions' ? 'active' : ''}`}
onClick={() => setActiveSubTab('submissions')}
>
Bài nộp ({submissions.length})
</button>
<button
type="button"
className={`tab-sub-btn ${activeSubTab === 'violations' ? 'active' : ''}`}
onClick={() => setActiveSubTab('violations')}
>
Vi phạm
</button>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
{activeSubTab !== 'submissions' && activeSubTab !== 'grid' && activeSubTab !== 'violations' && (
<div className="search-input-wrapper">
<input
type="text"
className="search-input"
placeholder="Tìm sinh viên..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<span className="search-icon">🔍</span>
</div>
)}
{prepEditable && (
<button
type="button"
className="btn btn-primary btn-sm"
onClick={() => { setStudentPickerOpen(true); setSearchQ(''); setSearchHits([]); setSelectedPickerRkIds([]); }}
>
+ Thêm sinh viên
</button>
)}
{papers.length > 0 && (
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setPapersModalOpen(true)}>
📄 Xem đ ({papers.length})
</button>
)}
</div> </div>
</div> </div>
<div className="divider" style={{ opacity: 0.3, margin: '0.25rem 0' }} />
<div className="ew-toolbar-right">
{activeSubTab !== 'submissions' && activeSubTab !== 'grid' && activeSubTab !== 'violations' && (
<div className="ew-search-wrapper">
<span className="ew-search-icon"><IconSearch size={14} /></span>
<input
type="search"
className="search-input ew-search-input"
placeholder="Tìm sinh viên..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
)}
{prepEditable && (
<button
type="button"
className="btn btn-primary btn-sm ew-icon-btn"
onClick={() => { setStudentPickerOpen(true); setSearchQ(''); setSearchHits([]); setSelectedPickerRkIds([]); }}
>
<IconPlus size={14} />
Thêm sinh viên
</button>
)}
{papers.length > 0 && (
<button type="button" className="btn btn-secondary btn-sm ew-icon-btn" onClick={() => setPapersModalOpen(true)}>
<IconExam size={13} />
Xem đ ({papers.length})
</button>
)}
</div>
</div> </div>
<div className="ew-toolbar-divider" />
<div className="workspace-panel-body workspace-panel-fill"> <div className="workspace-panel-body workspace-panel-fill">
{activeSubTab === 'roster' ? ( {activeSubTab === 'roster' ? (
@@ -970,8 +1253,8 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
<div className="session-logs-panel"> <div className="session-logs-panel">
<div className="attendance-table-scroll table-wrapper" style={{ border: 'none' }}> <div className="attendance-table-scroll table-wrapper" style={{ border: 'none' }}>
{students.length === 0 ? ( {students.length === 0 ? (
<div className="empty-state" style={{ minHeight: '300px' }}> <div className="empty-state ew-empty" style={{ minHeight: '300px' }}>
<div className="empty-state-icon">📋</div> <div className="ew-empty-icon"><IconInbox size={36} /></div>
<h2>Chưa sinh viên</h2> <h2>Chưa sinh viên</h2>
<p>Thêm sinh viên đ xem chi tiết đ gán trạng thái nộp bài.</p> <p>Thêm sinh viên đ xem chi tiết đ gán trạng thái nộp bài.</p>
</div> </div>
@@ -1023,14 +1306,22 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
<button type="button" className="btn btn-secondary btn-sm" style={{ marginLeft: '0.35rem' }} onClick={() => openStudentChat(s)}> <button type="button" className="btn btn-secondary btn-sm" style={{ marginLeft: '0.35rem' }} onClick={() => openStudentChat(s)}>
Nhắn tin Nhắn tin
</button> </button>
{prepEditable && ( {canRemoveStudents && (
<button <button
type="button" type="button"
className="btn btn-secondary btn-sm learning-btn-danger" className="btn btn-secondary btn-sm learning-btn-danger"
style={{ marginLeft: '0.35rem' }} style={{ marginLeft: '0.35rem' }}
onClick={() => apiExam.removeStudent(examId, s.studentRkId).then(load)} onClick={async () => {
if (!window.confirm(`Xóa sinh viên "${s.fullName}" khỏi phòng thi?`)) return;
try {
await apiExam.removeStudent(examId, s.studentRkId);
await load();
} catch (e: any) {
alert(e?.message || 'Xóa sinh viên thất bại');
}
}}
> >
Xóa Xóa khỏi phòng
</button> </button>
)} )}
</td> </td>
@@ -1051,10 +1342,11 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
{papers.length > 0 && ( {papers.length > 0 && (
<button <button
type="button" type="button"
className="btn btn-secondary btn-sm" className="btn btn-secondary btn-sm ew-icon-btn"
onClick={() => setPapersModalOpen(true)} onClick={() => setPapersModalOpen(true)}
> >
📄 Xem đ phòng thi <IconExam size={13} />
Xem đ phòng thi
</button> </button>
)} )}
<button <button
@@ -1063,7 +1355,7 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
disabled={bundlingSubs} disabled={bundlingSubs}
onClick={downloadAllSubs} onClick={downloadAllSubs}
> >
{bundlingSubs ? 'Đang gói ZIP...' : '📦 Tải tất cả (ZIP gộp)'} {bundlingSubs ? 'Đang gói ZIP...' : 'Tải tất cả (ZIP gộp)'}
</button> </button>
<button <button
type="button" type="button"
@@ -1098,8 +1390,8 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
)} )}
<div className="attendance-table-scroll table-wrapper" style={{ border: 'none' }}> <div className="attendance-table-scroll table-wrapper" style={{ border: 'none' }}>
{submissions.length === 0 ? ( {submissions.length === 0 ? (
<div className="empty-state" style={{ minHeight: '300px' }}> <div className="empty-state ew-empty" style={{ minHeight: '300px' }}>
<div className="empty-state-icon">📦</div> <div className="ew-empty-icon"><IconPackage size={36} /></div>
<h2>Chưa bài nộp</h2> <h2>Chưa bài nộp</h2>
<p>Sinh viên nộp bài qua app Simple Care sẽ hiện tại đây.</p> <p>Sinh viên nộp bài qua app Simple Care sẽ hiện tại đây.</p>
</div> </div>
@@ -1223,6 +1515,7 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
student={selectedStudent} student={selectedStudent}
isOnline={onlineIds.includes(selectedStudent.rkId)} isOnline={onlineIds.includes(selectedStudent.rkId)}
onClose={() => setSelectedStudent(null)} onClose={() => setSelectedStudent(null)}
initialShowProctor={activeSubTab === 'grid' || activeSubTab === 'roster'}
/> />
)} )}
@@ -1333,17 +1626,17 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
<div className="exam-student-hits"> <div className="exam-student-hits">
{searchMode === 'single' && searchQ.trim() === '' ? ( {searchMode === 'single' && searchQ.trim() === '' ? (
<div className="exam-student-hits-empty"> <div className="exam-student-hits-empty">
<span>🔍</span> <span className="ew-empty-icon"><IconSearch size={28} /></span>
<p> ít nhất vài tự đ tìm sinh viên.</p> <p> ít nhất vài tự đ tìm sinh viên.</p>
</div> </div>
) : searchMode === 'bulk' && searchHits.length === 0 ? ( ) : searchMode === 'bulk' && searchHits.length === 0 ? (
<div className="exam-student-hits-empty"> <div className="exam-student-hits-empty">
<span>📋</span> <span className="ew-empty-icon"><IconList size={28} /></span>
<p>Paste danh sách bấm "Tìm hàng loạt" đ hiển thị kết quả.</p> <p>Paste danh sách bấm "Tìm hàng loạt" đ hiển thị kết quả.</p>
</div> </div>
) : searchHits.length === 0 ? ( ) : searchHits.length === 0 ? (
<div className="exam-student-hits-empty"> <div className="exam-student-hits-empty">
<span>📭</span> <span className="ew-empty-icon"><IconInbox size={28} /></span>
<p>Không tìm thấy sinh viên phù hợp.</p> <p>Không tìm thấy sinh viên phù hợp.</p>
</div> </div>
) : ( ) : (
@@ -1445,6 +1738,103 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
onSelect={applyTemplate} onSelect={applyTemplate}
allowedApps={allowedApps} allowedApps={allowedApps}
/> />
<style>{`
.ew-header { padding: 0.7rem 1.1rem; gap: 0.65rem; }
.ew-header-left { display: flex; align-items: center; gap: 0.75rem; min-width: 0; }
.ew-brand-icon {
width: 40px; height: 40px; border-radius: var(--radius-sm);
background: var(--accent-light); color: var(--accent);
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.ew-title-block { display: flex; flex-direction: column; gap: 0.15rem; min-width: 0; }
.ew-header .workspace-class-title { font-size: 1.2rem; font-weight: 700; margin: 0; line-height: 1.25; }
.ew-header .class-badge-container { margin-top: 0.15rem; gap: 0.35rem; }
.ew-header-right { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; }
.ew-status-panel { gap: 0.75rem; padding: 0.4rem 0.85rem; }
.ew-status-label-group { display: flex; flex-direction: column; }
.ew-meta-label {
font-size: 0.68rem; color: var(--text-muted); font-weight: 600;
text-transform: uppercase; letter-spacing: 0.04em;
}
.ew-status-value { font-size: 0.88rem; font-weight: 700; }
.ew-status-actions { display: flex; flex-direction: column; gap: 0.3rem; }
.ew-extend-row { display: flex; gap: 0.3rem; flex-wrap: wrap; align-items: center; }
.ew-online-stat { display: flex; flex-direction: column; align-items: flex-end; gap: 0.1rem; }
.ew-online-value {
display: flex; align-items: center; gap: 0.3rem; font-size: 1.1rem !important;
}
.ew-online-total { color: var(--text-muted); font-size: 0.82rem; font-weight: 500; }
.ew-drawer-title {
display: inline-flex; align-items: center; gap: 0.45rem;
font-weight: 700; font-size: 0.92rem; color: var(--text-primary);
}
.ew-drawer-close {
display: flex; align-items: center; justify-content: center;
width: 28px; height: 28px; border: none; background: var(--bg-subtle);
border-radius: var(--radius-sm); color: var(--text-secondary); cursor: pointer;
}
.ew-drawer-close:hover { background: var(--border-color); color: var(--text-primary); }
.workspace-content-panel.right-panel {
padding: 0.65rem 0.85rem 0.85rem; gap: 0;
}
.workspace-content-panel--grid { padding-top: 0.55rem; }
.ew-toolbar {
display: flex; justify-content: space-between; align-items: center;
flex-wrap: wrap; gap: 0.55rem 0.75rem; flex-shrink: 0;
}
.ew-toolbar-left, .ew-toolbar-right {
display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;
}
.ew-config-toggle {
display: inline-flex !important; align-items: center; gap: 0.35rem;
padding: 0.38rem 0.85rem !important; font-size: 0.8rem !important; font-weight: 600 !important;
}
.ew-toolbar-divider {
margin: 0.5rem 0 0.35rem; border-top: 1px solid var(--border-light);
opacity: 0.45; flex-shrink: 0;
}
.workspace-content-panel--grid .ew-toolbar-divider { margin: 0.4rem 0 0; opacity: 0.35; }
.ew-seg {
display: flex; background: var(--bg-subtle); border: 1px solid var(--border-color);
border-radius: var(--radius-sm); padding: 0.18rem; gap: 0.15rem; flex-wrap: wrap;
}
.ew-seg-btn {
display: flex; align-items: center; gap: 0.3rem; padding: 0.3rem 0.65rem;
font-size: 0.78rem; font-weight: 600; background: transparent; color: var(--text-secondary);
border: none; border-radius: 4px; cursor: pointer; white-space: nowrap;
font-family: var(--font-sans); transition: background 0.14s, color 0.14s;
}
.ew-seg-btn:hover:not(.ew-seg-btn--active) {
background: var(--border-light); color: var(--text-primary);
}
.ew-seg-btn--active {
background: #fff; color: var(--accent); box-shadow: 0 1px 3px rgba(0,0,0,0.08);
}
.ew-search-wrapper { position: relative; max-width: 220px; width: 100%; }
.ew-search-icon {
position: absolute; left: 0.6rem; top: 50%; transform: translateY(-50%);
color: var(--text-muted); display: flex; pointer-events: none;
}
.ew-search-input { padding: 0.38rem 0.8rem 0.38rem 2rem !important; font-size: 0.82rem !important; width: 100%; }
.ew-icon-btn {
display: inline-flex !important; align-items: center; gap: 0.3rem;
}
.ew-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 0.45rem; }
.ew-empty-icon { display: flex; color: var(--text-muted); opacity: 0.55; }
.exam-student-hits-empty .ew-empty-icon { margin-bottom: 0.25rem; }
@media (max-width: 960px) {
.ew-header { padding: 0.65rem 0.85rem; }
.ew-header .workspace-class-title { font-size: 1.05rem; }
.ew-seg-btn { padding: 0.28rem 0.5rem; font-size: 0.74rem; }
}
`}</style>
</div> </div>
); );
}; };

View File

@@ -11,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) {
@@ -32,9 +32,70 @@ 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 { staff } = useAuth();
const isSuperAdmin = staff?.email === 'phuocntb@rikkeiacademy.com'; 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);
@@ -44,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);
@@ -61,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;
@@ -99,7 +169,11 @@ export const ExamsTab: React.FC = () => {
}; };
const handleDeleteRoom = async (roomId: number, roomName: string) => { const handleDeleteRoom = async (roomId: number, roomName: string) => {
if (window.confirm(`Bạn có chắc chắn muốn xóa hoàn toàn phòng thi "${roomName}" không? Hành động này sẽ xóa sạch dữ liệu phòng thi, các bài nộp, và không thể khôi phục.`)) { if (
window.confirm(
`Bạn có chắc chắn muốn xóa hoàn toàn phòng thi "${roomName}" không? Hành động này sẽ xóa sạch dữ liệu phòng thi, các bài nộp, và không thể khôi phục.`
)
) {
try { try {
await apiExam.remove(roomId); await apiExam.remove(roomId);
alert('Đã xóa phòng thi thành công!'); alert('Đã xóa phòng thi thành công!');
@@ -112,76 +186,216 @@ export const ExamsTab: React.FC = () => {
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">
</div> <span className="page-title-icon" aria-hidden>
<button type="button" className="btn btn-primary" onClick={() => setShowCreate(true)}> <IconClipboard />
+ Tạo phòng thi </span>
</button> Danh sách phòng thi
</header> </h1>
<p>
<div className="exams-stats-row"> {roomScope === 'mine'
<div className="learning-stat"> ? 'Mặc định chỉ hiện phòng thi do bạn tạo — chuyển sang xem tất cả khi cần'
<span className="learning-stat-value">{stats.total}</span> : 'Đang xem toàn bộ phòng thi trong hệ thống'}
<span className="learning-stat-label">Tổng phòng</span> </p>
</div> </div>
<div className="learning-stat learning-stat--accent"> <div className="exams-header-actions">
<span className="learning-stat-value">{stats.active}</span> <button type="button" className="btn btn-primary" onClick={() => setShowCreate(true)}>
<span className="learning-stat-label">Đang thi</span> <IconPlus />
</div> Tạo phòng thi
<div className="learning-stat"> </button>
<span className="learning-stat-value">{stats.upcoming}</span> </div>
<span className="learning-stat-label">Sắp / tạm</span>
</div> </div>
</div> </div>
<div className="exams-toolbar"> <div className="tab-page-body" style={{ gap: '0.85rem' }}>
<input <div className="exams-stats-row">
type="search" <div className="learning-stat">
className="app-pool-search exams-search" <span className="learning-stat-value">{stats.total}</span>
placeholder="Tìm theo tên phòng thi..." <span className="learning-stat-label">
value={search} {roomScope === 'mine' ? 'Phòng của tôi' : 'Tổng phòng'}
onChange={(e) => setSearch(e.target.value)} </span>
/> </div>
<div className="learning-stat learning-stat--accent">
<span className="learning-stat-value">{stats.active}</span>
<span className="learning-stat-label">Đang thi</span>
</div>
<div className="learning-stat">
<span className="learning-stat-value">{stats.upcoming}</span>
<span className="learning-stat-label">Sắp / tạm</span>
</div>
</div>
<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
type="search"
className="app-pool-search learning-search exams-search"
placeholder="Tìm theo tên phòng thi..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</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> </div>
{showCreate && ( {showCreate && (
<div className="modal-overlay" onClick={() => setShowCreate(false)}> <div className="modal-overlay" onClick={() => setShowCreate(false)}>
<div className="modal-container" style={{ maxWidth: '500px' }} onClick={e => e.stopPropagation()}> <div className="modal-container class-picker-modal exam-create-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header"> <div className="modal-header class-picker-header">
<h2 className="modal-title">Tạo phòng thi mới</h2> <div>
<button className="modal-close-btn" onClick={() => setShowCreate(false)} aria-label="Đóng">&times;</button> <h2 className="modal-title">Tạo phòng thi mới</h2>
<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>
<div className="modal-body" style={{ padding: '1.5rem', display: 'flex', flexDirection: 'column', gap: '1.25rem' }}> <div className="modal-body class-picker-body exam-create-body">
<label className="login-field" style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}> <label className="exam-field">
<span style={{ fontWeight: 600, fontSize: '0.85rem', color: 'var(--text-secondary)' }}>Tên phòng thi</span> <span className="exam-field-label">Tên phòng thi</span>
<input <input
className="search-input" className="search-input"
style={{ width: '100%', boxSizing: 'border-box' }}
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
placeholder="VD: Thi cuối kỳ Java" placeholder="VD: Thi cuối kỳ Java"
autoFocus
/> />
</label> </label>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}> <div className="exam-field-grid">
<label className="login-field" style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}> <label className="exam-field">
<span style={{ fontWeight: 600, fontSize: '0.85rem', color: 'var(--text-secondary)' }}>Bắt đu</span> <span className="exam-field-label">Bắt đu</span>
<input <input
type="datetime-local" type="datetime-local"
className="search-input" className="search-input exam-datetime"
style={{ width: '100%', boxSizing: 'border-box', padding: '0.5rem 0.75rem' }}
value={start} value={start}
onChange={(e) => setStart(e.target.value)} onChange={(e) => setStart(e.target.value)}
/> />
</label> </label>
<label className="login-field" style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}> <label className="exam-field">
<span style={{ fontWeight: 600, fontSize: '0.85rem', color: 'var(--text-secondary)' }}>Kết thúc</span> <span className="exam-field-label">Kết thúc</span>
<input <input
type="datetime-local" type="datetime-local"
className="search-input" className="search-input exam-datetime"
style={{ width: '100%', boxSizing: 'border-box', padding: '0.5rem 0.75rem' }}
value={end} value={end}
onChange={(e) => setEnd(e.target.value)} onChange={(e) => setEnd(e.target.value)}
/> />
@@ -192,7 +406,12 @@ export const ExamsTab: React.FC = () => {
<button type="button" className="btn btn-secondary" onClick={() => setShowCreate(false)}> <button type="button" className="btn btn-secondary" 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>
@@ -200,73 +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;
<div style={{ display: 'flex', gap: '8px', marginTop: '1.25rem' }}> }
<button type="button" className="btn btn-primary btn-sm exam-list-card-open" onClick={() => openExam(r.id, r.name)} style={{ flex: 1, margin: 0 }}> .exams-toolbar-inner {
Mở phòng thi margin: 0;
</button> }
{isSuperAdmin && ( .exams-search {
<button max-width: none !important;
type="button" }
className="btn btn-secondary btn-sm"
onClick={(e) => { .exams-empty-panel {
e.stopPropagation(); display: flex;
handleDeleteRoom(r.id, r.name); flex-direction: column;
}} align-items: center;
style={{ justify-content: center;
borderColor: 'var(--danger)', gap: 0.65rem;
color: 'var(--danger)', padding: 2.75rem 1.25rem;
backgroundColor: 'transparent', text-align: center;
padding: '0.5rem 0.75rem', }
fontWeight: 600, .exams-empty-panel p {
transition: 'var(--transition)', margin: 0;
margin: 0 color: var(--text-secondary);
}} font-size: 0.9rem;
onMouseEnter={(e) => { font-weight: 500;
e.currentTarget.style.backgroundColor = 'var(--danger)'; max-width: 36ch;
e.currentTarget.style.color = '#fff'; line-height: 1.45;
}} }
onMouseLeave={(e) => { .exams-empty-icon {
e.currentTarget.style.backgroundColor = 'transparent'; color: var(--text-muted);
e.currentTarget.style.color = 'var(--danger)'; display: flex;
}} }
> .exams-empty-actions {
Xóa display: flex;
</button> gap: 0.5rem;
)} flex-wrap: wrap;
</div> justify-content: center;
</article> margin-top: 0.25rem;
); }
})} .exams-empty-actions .btn {
</div> 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,135 +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');
let retryTimer: ReturnType<typeof setTimeout> | null = null;
let pingTimer: ReturnType<typeof setInterval> | null = null;
let attempt = 0;
intentionalClose.current = false;
hasOpened.current = false;
hasFrames.current = false;
setStreaming(false);
setErrorMessage(null);
setScreenFrame(null);
setWebcamFrame(null);
const markStreaming = () => {
if (!hasFrames.current) {
hasFrames.current = true;
setStreaming(true);
setErrorMessage(null);
}
};
const clearPing = () => {
if (pingTimer) {
clearInterval(pingTimer);
pingTimer = null;
}
};
const connect = () => {
if (intentionalClose.current) return;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
attempt = 0;
hasOpened.current = true;
setStreaming(true);
setErrorMessage(null);
ws.send(JSON.stringify({ event: 'teacher:subscribe', data: { studentId } }));
clearPing();
pingTimer = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ event: 'client:ping', data: {} }));
}
}, 15000);
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.event === 'client:pong') return;
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 = () => {
clearPing();
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');
} else {
setErrorMessage('Mất kết nối giám sát — đang thử lại...');
}
const delay = Math.min(1000 * 2 ** Math.min(attempt++, 4), 10000);
retryTimer = setTimeout(connect, delay);
};
};
connect();
return () => {
intentionalClose.current = true;
clearPing();
if (retryTimer) clearTimeout(retryTimer);
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ event: 'teacher:unsubscribe', data: { studentId } }));
}
wsRef.current?.close();
wsRef.current = null;
};
}, [studentId]);
useEffect(() => { useEffect(() => {
const onFsChange = () => { const onFsChange = () => {
setIsFullscreen(document.fullscreenElement === screenPanelRef.current); setIsFullscreen(document.fullscreenElement === screenPanelRef.current);
@@ -154,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') => {
@@ -169,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>
)} )}
@@ -184,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' : ''}`}
@@ -213,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>
@@ -236,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

@@ -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">
@@ -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,247 +150,565 @@ 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">
<p>Danh sách toàn bộ sinh viên trong hệ thống đưc đng bộ</p> <span className="page-title-icon" aria-hidden>
</div> <IconUsers />
<button
className="btn btn-primary"
onClick={handleStartSync}
disabled={syncStatus?.running}
style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}
>
{syncStatus?.running ? (
<>
<div className="sync-spinner"></div> Đng bộ...
</>
) : (
<>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ display: 'inline-block' }}>
<path d="M23 4v6h-6M1 20v-6h6M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
</svg>
Đng bộ toàn bộ SV
</>
)}
</button>
</div>
{/* Sync Status Banner */}
{syncStatus && (syncStatus.running || syncStatus.done || syncStatus.error) && (
<div className="sync-progress-banner" style={{ borderStyle: 'solid', borderColor: 'var(--success)' }}>
<div className="sync-header">
<div className="sync-title">
{syncStatus.running && <div className="sync-spinner"></div>}
<span>
{syncStatus.running && `Đang tải sinh viên... Trang ${syncStatus.page || 0} (${syncPercent}%)`}
{syncStatus.done && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="var(--success)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
Đng bộ toàn bộ sinh viên hoàn tất!
</span>
)}
{syncStatus.error && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '6px', color: 'var(--danger)' }}>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="var(--danger)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
Đng bộ thất bại: {syncStatus.error}
</span>
)}
</span> </span>
Danh sách sinh viên
</h1>
<p>Danh sách toàn bộ sinh viên trong hệ thống đưc đng bộ</p>
</div>
<button
className="btn btn-primary"
onClick={handleStartSync}
disabled={syncStatus?.running}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '8px',
padding: '0.75rem 1.5rem',
borderRadius: '12px',
fontWeight: 700,
boxShadow: '0 4px 14px rgba(187,33,38,0.25)',
}}
>
{syncStatus?.running ? (
<>
<div className="sync-spinner" style={{ width: 18, height: 18 }} />
Đng bộ... {syncPercent}%
</>
) : (
<>
<IconRefresh />
Đng bộ toàn bộ SV
</>
)}
</button>
</div>
{syncStatus && (syncStatus.running || syncStatus.done || syncStatus.error) && (
<div className="sync-progress-banner">
<div className="sync-header">
<div className="sync-title">
{syncStatus.running && <div className="sync-spinner" />}
<span>
{syncStatus.running && `Đang tải sinh viên... Trang ${syncStatus.page || 0} (${syncPercent}%)`}
{syncStatus.done && (
<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>
</div>
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>
Đã tải: {syncStatus.synced} / {syncStatus.total} sinh viên
</div>
</div> </div>
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}> {syncStatus.running && (
Đã tải: {syncStatus.synced} / {syncStatus.total} sinh viên <div className="sync-bar-container">
<div className="sync-bar" style={{ width: `${syncPercent}%` }} />
</div>
)}
<div className="sync-meta">
<span>Cập nhật lần cuối: {new Date(syncStatus.updatedAt * 1000).toLocaleString()}</span>
{syncStatus.running && (
<span style={{ color: 'var(--accent-hover)' }}>
Hệ thống đang kéo dữ liệu trang {syncStatus.page}...
</span>
)}
</div> </div>
</div> </div>
{syncStatus.running && ( )}
<div className="sync-bar-container">
<div className="sync-bar" style={{ width: `${syncPercent}%`, background: 'linear-gradient(to right, var(--success), #a7f3d0)' }}></div> <div className="control-bar">
<div className="search-input-wrapper">
<input
type="text"
className="search-input"
placeholder="Tìm theo tên, mã SV, email, số điện thoại..."
value={search}
onChange={e => {
setSearch(e.target.value);
setPage(1);
}}
/>
<span className="search-icon">
<IconSearch />
</span>
</div>
</div>
</div>
<div className="tab-page-body">
<div className="table-wrapper table-fill">
{loading ? (
<div className="empty-state">
<div className="sync-spinner" style={{ width: 40, height: 40 }} />
<p style={{ marginTop: '1rem', fontWeight: 600 }}>Đang tải danh sách sinh viên...</p>
</div>
) : students.length === 0 ? (
<div className="empty-state">
<div className="empty-state-icon" style={{ color: 'var(--text-muted)' }}>
<IconInbox />
</div>
<h2>Không sinh viên nào</h2>
<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 className="table-scroll-container">
<table className="data-table student-table">
<thead>
<tr>
<th className="col-code"> SV</th>
<th className="col-name">Họ tên</th>
<th className="col-contact">Thông tin liên hệ</th>
<th className="col-birth">Ngày sinh / Giới tính</th>
<th className="col-system">Phân hệ</th>
<th className="col-location">Đa điểm</th>
<th className="col-status">Trạng thái</th>
</tr>
</thead>
<tbody>
{students.map(st => {
const isActive = st.status === 'Đang học' || st.status === 'active';
return (
<tr key={st.id}>
<td className="col-code" data-label="Mã SV">
<div className="student-code" title={st.studentCode || undefined}>
{st.studentCode || '—'}
</div>
</td>
<td className="col-name" data-label="Họ tên">
<div className="student-name" title={st.fullName}>
{st.fullName}
</div>
<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'}
</span>
</td>
<td className="col-location" data-label="Địa điểm">
<span className="location-text">{st.location || '—'}</span>
</td>
<td className="col-status" data-label="Trạng thái">
<span className={`badge badge-status ${isActive ? 'is-active' : 'is-muted'}`}>
{st.status || 'Đang học'}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div> </div>
)} )}
<div className="sync-meta">
<span>Cập nhật lần cuối: {new Date(syncStatus.updatedAt * 1000).toLocaleString()}</span>
{syncStatus.running && <span style={{ color: 'var(--success)' }}>Hệ thống đang kéo dữ liệu trang {syncStatus.page}...</span>}
</div>
</div>
)}
{/* Control filters */}
<div className="control-bar">
<div className="search-input-wrapper" style={{ maxWidth: '400px' }}>
<input
type="text"
className="search-input"
placeholder="Tìm theo tên, mã SV, email, số điện thoại..."
value={search}
onChange={e => {
setSearch(e.target.value);
setPage(1);
}}
/>
<span className="search-icon">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
</span>
</div> </div>
</div> </div>
</div>
{/* Students Table */}
<div className="tab-page-body">
<div className="table-wrapper table-fill">
{loading ? (
<div className="empty-state">
<div className="sync-spinner" style={{ width: '32px', height: '32px' }}></div>
<p style={{ marginTop: '0.5rem' }}>Đang tải danh sách sinh viên...</p>
</div>
) : students.length === 0 ? (
<div className="empty-state">
<div className="empty-state-icon" style={{ color: 'var(--text-muted)' }}>
<svg viewBox="0 0 24 24" width="48" height="48" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
</div>
<h2>Không sinh viên nào</h2>
<p>Hãy thử thay đi bộ lọc hoặc bấm nút "Đồng bộ toàn bộ SV" đ tải dữ liệu.</p>
</div>
) : (
<table className="data-table">
<thead>
<tr>
<th> SV</th>
<th>Họ Tên</th>
<th>Thông Tin Liên Hệ</th>
<th>Ngày sinh / Giới tính</th>
<th>Phân hệ</th>
<th>Đa điểm</th>
<th>Trạng thái</th>
</tr>
</thead>
<tbody>
{students.map(st => (
<tr key={st.id}>
<td style={{ fontWeight: 600 }}>{st.studentCode}</td>
<td style={{ color: 'var(--text-primary)', fontWeight: 500 }}>
{st.fullName}
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>ID: {st.rkId}</div>
</td>
<td>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
<polyline points="22,6 12,13 2,6" />
</svg>
{st.email}
</div>
{st.phone && (
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)', display: 'flex', alignItems: 'center', gap: '6px', marginTop: '2px' }}>
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z" />
</svg>
{st.phone}
</div>
)}
</td>
<td>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
{st.dateOfBirth ? new Date(st.dateOfBirth).toLocaleDateString('vi-VN') : '—'}
</div>
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)', marginTop: '2px' }}>
Giới tính: {st.gender === 1 ? 'Nam' : st.gender === 0 ? 'Nữ' : 'Khác'}
</div>
</td>
<td>
<span className="badge badge-muted">
{st.systemName || 'Chung'}
</span>
</td>
<td>{st.location || '—'}</td>
<td>
<span className={`badge ${st.status === 'Đang học' || st.status === 'active' ? 'badge-success' : 'badge-muted'}`}>
{st.status || 'Đang học'}
</span>
</td>
</tr>
))}
</tbody>
</table>
)}
</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 className="pagination-btn-group">
<button
className="pagination-btn"
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
aria-label="Trang trước"
>
<IconChevronLeft />
</button>
<span className="pagination-current">
Trang {page} / {totalPages}
</span>
<button
className="pagination-btn"
onClick={() => setPage(p => p + 1)}
disabled={page >= totalPages}
aria-label="Trang sau"
>
<IconChevronRight />
</button>
</div>
</div> </div>
<div className="pagination-btn-group">
<button
className="pagination-btn"
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}
>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
<span style={{ display: 'flex', alignItems: 'center', padding: '0 1rem', fontWeight: 600, color: 'var(--text-primary)' }}>
Trang {page} / {Math.ceil(total / pageSize) || 1}
</span>
<button
className="pagination-btn"
onClick={() => setPage(p => p + 1)}
disabled={page >= Math.ceil(total / pageSize)}
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}
>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
</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

@@ -1,11 +1,47 @@
import React, { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { import {
apiFetchClassViolations, apiFetchClassViolations,
apiFetchExamViolations, apiFetchExamViolations,
VIOLATION_KIND_OPTIONS, VIOLATION_KIND_OPTIONS,
type StudentViolationItem, type StudentViolationItem,
} from '../api'; } from '../api';
import { kindLabel } from '../hooks/useStaffChatSocket'; 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 = type Props =
| { mode: 'class'; classId: number } | { mode: 'class'; classId: number }
@@ -24,7 +60,17 @@ function formatTime(iso?: string): string {
return d.toLocaleString('vi-VN'); return d.toLocaleString('vi-VN');
} }
export const ViolationsPanel: React.FC<Props> = (props) => { 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 [date, setDate] = useState(todayLocal);
const [kind, setKind] = useState(''); const [kind, setKind] = useState('');
const [rows, setRows] = useState<StudentViolationItem[]>([]); const [rows, setRows] = useState<StudentViolationItem[]>([]);
@@ -48,47 +94,128 @@ export const ViolationsPanel: React.FC<Props> = (props) => {
} }
}, [props, date, kind]); }, [props, date, kind]);
useEffect(() => { void load(); }, [load]);
useEffect(() => { useEffect(() => {
void load(); return onStudentViolation((v) => {
}, [load]); 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 ( return (
<div className="session-logs-panel" style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', height: '100%' }}> <div className="vp-panel">
<div className="attendance-toolbar" style={{ marginBottom: 0, flexWrap: 'wrap' }}> <style>{`
<label className="attendance-field"> .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> <span>Ngày</span>
<input type="date" className="search-input" style={{ padding: '0.5rem 0.75rem' }} value={date} onChange={(e) => setDate(e.target.value)} /> <input
type="date"
className="vp-input search-input"
value={date}
onChange={(e) => setDate(e.target.value)}
/>
</label> </label>
<label className="attendance-field"> <label className="vp-field attendance-field">
<span>Loại</span> <span>Loại vi phạm</span>
<select className="select-filter" value={kind} onChange={(e) => setKind(e.target.value)}> <select
className="vp-input select-filter"
value={kind}
onChange={(e) => setKind(e.target.value)}
>
{VIOLATION_KIND_OPTIONS.map((o) => ( {VIOLATION_KIND_OPTIONS.map((o) => (
<option key={o.value || 'all'} value={o.value}>{o.label}</option> <option key={o.value || 'all'} value={o.value}>{o.label}</option>
))} ))}
</select> </select>
</label> </label>
<button type="button" className="btn btn-secondary btn-sm" onClick={() => void load()} disabled={loading}> <button
type="button"
className="vp-refresh btn btn-secondary btn-sm"
onClick={() => void load()}
disabled={loading}
style={{ alignSelf: 'flex-end' }}
>
<IconRefresh size={14} />
{loading ? 'Đang tải...' : 'Làm mới'} {loading ? 'Đang tải...' : 'Làm mới'}
</button> </button>
<span style={{ marginLeft: 'auto', fontSize: '0.82rem', color: 'var(--text-muted)', fontWeight: 600 }}> <span className="vp-count">{rows.length} vi phạm</span>
{rows.length} vi phạm
</span>
</div> </div>
{err && <div className="form-error" style={{ margin: 0 }}>{err}</div>} {/* ── Error message ── */}
{err && <div className="vp-error form-error">{err}</div>}
<div className="attendance-table-scroll table-wrapper" style={{ border: 'none', flex: 1, overflowY: 'auto' }}> {/* ── Table ── */}
<div className="vp-table-scroll attendance-table-scroll table-wrapper">
{loading && rows.length === 0 ? ( {loading && rows.length === 0 ? (
<div className="empty-state" style={{ minHeight: '220px' }}> <div className="vp-empty">
<div className="sync-spinner" style={{ width: '32px', height: '32px' }} /> <div className="sync-spinner" style={{ width: 28, height: 28 }} />
<p style={{ marginTop: '0.5rem' }}>Đang tải vi phạm...</p>
</div> </div>
) : rows.length === 0 ? ( ) : rows.length === 0 ? (
<div className="empty-state" style={{ minHeight: '220px' }}> <div className="vp-empty">
<p>Không vi phạm trong ngày đã chọn.</p> <IconShieldOff size={36} />
<span>Không vi phạm trong ngày đã chọn.</span>
</div> </div>
) : ( ) : (
<table className="data-table"> <table className="vp-table data-table">
<thead> <thead>
<tr> <tr>
<th>Thời gian</th> <th>Thời gian</th>
@@ -96,28 +223,29 @@ export const ViolationsPanel: React.FC<Props> = (props) => {
<th> SV</th> <th> SV</th>
<th>Loại</th> <th>Loại</th>
<th>Chi tiết</th> <th>Chi tiết</th>
<th>Chế đ</th> <th>Ngữ cảnh</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{rows.map((r) => ( {rows.map((r) => {
<tr key={r.id}> const isClose = r.kind === 'app_closed' || r.kind === 'unclean_shutdown';
<td style={{ whiteSpace: 'nowrap', fontFamily: 'monospace', fontSize: '0.8rem' }}> return (
{formatTime(r.createdAt || r.clientAt)} <tr key={r.id} className={isClose ? 'vp-row--close' : ''}>
</td> <td style={{ whiteSpace: 'nowrap', fontFamily: 'monospace', fontSize: '0.75rem' }}>
<td style={{ fontWeight: 600 }}>{r.fullName || '—'}</td> {formatTime(r.createdAt || r.clientAt)}
<td><code>{r.studentCode || r.studentRkId}</code></td> </td>
<td> <td style={{ fontWeight: 600 }}>{r.fullName || '—'}</td>
<span className="badge badge-warning" style={{ fontSize: '0.72rem' }}> <td><code style={{ fontSize: '0.78rem' }}>{r.studentCode || r.studentRkId}</code></td>
{kindLabel(r.kind)} <td>
</span> <span className={`badge ${isClose ? 'badge-danger' : 'badge-warning'}`} style={{ fontSize: '0.68rem', fontWeight: 600, padding: '0.15rem 0.4rem' }}>
</td> {kindLabel(r.kind)}
<td style={{ maxWidth: 360, fontSize: '0.85rem' }} title={r.reason}> </span>
{r.reason} </td>
</td> <td style={{ maxWidth: 320, fontSize: '0.8rem' }} title={r.reason}>{r.reason}</td>
<td style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>{r.monitorMode || '—'}</td> <td style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>{modeLabel(r.monitorMode)}</td>
</tr> </tr>
))} );
})}
</tbody> </tbody>
</table> </table>
)} )}

File diff suppressed because it is too large Load Diff

View File

@@ -13,6 +13,7 @@ export type StudentViolationEvent = {
reason: string; reason: string;
monitorMode?: string; monitorMode?: string;
classId?: number; classId?: number;
examRoomId?: number;
}; };
const chatHandlers = new Set<ChatIncomingHandler>(); const chatHandlers = new Set<ChatIncomingHandler>();
@@ -41,7 +42,7 @@ function scheduleBackoff(attempt: number): number {
export function kindLabel(kind: string): string { export function kindLabel(kind: string): string {
switch (kind) { switch (kind) {
case 'app_closed': return 'Tự đóng app'; case 'app_closed': return 'Tắt ứng dụng';
case 'unclean_shutdown': return 'Tắt đột ngột'; case 'unclean_shutdown': return 'Tắt đột ngột';
case 'multi_monitor': return 'Nhiều màn hình'; case 'multi_monitor': return 'Nhiều màn hình';
case 'user_switch': return 'Đổi user'; case 'user_switch': return 'Đổi user';
@@ -123,6 +124,7 @@ export function StaffChatSocket() {
reason: String(payload.data?.reason || ''), reason: String(payload.data?.reason || ''),
monitorMode: payload.data?.monitorMode || '', monitorMode: payload.data?.monitorMode || '',
classId: Number(payload.data?.classId ?? 0) || undefined, classId: Number(payload.data?.classId ?? 0) || undefined,
examRoomId: Number(payload.data?.examRoomId ?? 0) || undefined,
})); }));
return; return;
} }

File diff suppressed because it is too large Load Diff

View File

@@ -48,6 +48,9 @@ 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{},
@@ -70,6 +73,7 @@ func AutoMigrate(db *gorm.DB) error {
&models.GuideLink{}, &models.GuideLink{},
&models.AppDownload{}, &models.AppDownload{},
&models.AppGuide{}, &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

@@ -565,11 +565,40 @@ func GetLeaveRequestsHandler(db *gorm.DB, qldtClient *qldt.Client) fiber.Handler
return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{"error": err.Error()}) return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{"error": err.Error()})
} }
var data any var data []map[string]interface{}
if err := json.Unmarshal(body, &data); err != nil { 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"}) 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) return c.JSON(data)
} }
} }
@@ -594,15 +623,24 @@ func UpdateLeaveStatusHandler(db *gorm.DB, qldtClient *qldt.Client) fiber.Handle
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Trạng thái phê duyệt không hợp lệ"}) return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Trạng thái phê duyệt không hợp lệ"})
} }
token := GetQldtToken(db) // 1. Lưu trạng thái đơn phép vào database local thay vì gọi QLDT Portal API
if token == "" { var localLeave models.LocalLeaveRequest
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "QLDT_TOKEN chưa cấu hình"}) errLocal := db.Where("leave_id = ?", leaveID).First(&localLeave).Error
} if errLocal == gorm.ErrRecordNotFound {
localLeave = models.LocalLeaveRequest{
// 1. Gửi lệnh cập nhật status qua QLDT Portal API LeaveID: leaveID,
body, err := qldtClient.UpdateLeaveStatus(context.Background(), token, leaveID, req.Status) Status: req.Status,
if err != nil { }
return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{"error": err.Error(), "body": string(body)}) 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" // 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"

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"
@@ -22,7 +23,7 @@ import (
) )
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" 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"
@@ -120,15 +121,23 @@ 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 {
models.ExamRoom models.ExamRoom
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()
@@ -140,10 +149,11 @@ func ListExamRoomsHandler(db *gorm.DB) fiber.Handler {
_ = db.Model(&models.ExamRoomStudent{}).Where("exam_room_id = ?", r.ID).Count(&sc).Error _ = db.Model(&models.ExamRoomStudent{}).Where("exam_room_id = ?", r.ID).Count(&sc).Error
_ = db.Model(&models.ExamPaper{}).Where("exam_room_id = ?", r.ID).Count(&pc).Error _ = db.Model(&models.ExamPaper{}).Where("exam_room_id = ?", r.ID).Count(&pc).Error
out = append(out, row{ out = append(out, row{
ExamRoom: r, ExamRoom: r,
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})
@@ -177,12 +187,13 @@ func CreateExamRoomHandler(db *gorm.DB) fiber.Handler {
} }
apps := strings.TrimSpace(req.AllowedApps) apps := strings.TrimSpace(req.AllowedApps)
room := models.ExamRoom{ room := models.ExamRoom{
Name: req.Name, Name: req.Name,
StartTime: start, StartTime: start,
EndTime: end, EndTime: end,
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()})
@@ -259,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),
}) })
} }
} }
@@ -307,8 +319,15 @@ 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 !internalDb.ExamRoomEditable(room) {
if room.Status == models.ExamStatusReady { 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 { 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"}) 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"})
} }
@@ -331,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) {
@@ -420,13 +447,54 @@ 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) email, _ := c.Locals("staffEmail").(string)
if email != "phuocntb@rikkeiacademy.com" { isSuperAdmin := email == "phuocntb@rikkeiacademy.com"
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"})
}
id, err := parseUintParam(c, "id") id, err := parseUintParam(c, "id")
if err != nil { if err != nil {
@@ -436,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"})
} }
@@ -451,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})
} }
@@ -593,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})
@@ -811,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

@@ -744,6 +744,8 @@ func ReportViolationHandler(db *gorm.DB) fiber.Handler {
Reason string `json:"reason"` Reason string `json:"reason"`
MonitorMode string `json:"monitorMode"` MonitorMode string `json:"monitorMode"`
ClientAt string `json:"clientAt"` // RFC3339 optional ClientAt string `json:"clientAt"` // RFC3339 optional
ClassRkID int64 `json:"classRkId"`
ExamRoomID uint `json:"examRoomId"`
} }
if err := c.BodyParser(&req); err != nil { if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payload format"}) return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payload format"})
@@ -752,12 +754,30 @@ func ReportViolationHandler(db *gorm.DB) fiber.Handler {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "studentRkId and kind are required"}) 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)) kind := strings.ToLower(strings.TrimSpace(req.Kind))
reason := strings.TrimSpace(req.Reason) reason := strings.TrimSpace(req.Reason)
if reason == "" { if reason == "" {
reason = kind reason = kind
} }
classID := internalDb.FindActiveClassForStudent(db, req.StudentRkID)
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() clientAt := time.Now()
if t, err := time.Parse(time.RFC3339, strings.TrimSpace(req.ClientAt)); err == nil { if t, err := time.Parse(time.RFC3339, strings.TrimSpace(req.ClientAt)); err == nil {
clientAt = t clientAt = t
@@ -766,9 +786,10 @@ func ReportViolationHandler(db *gorm.DB) fiber.Handler {
row := models.StudentViolation{ row := models.StudentViolation{
StudentRkID: req.StudentRkID, StudentRkID: req.StudentRkID,
ClassRkID: classID, ClassRkID: classID,
ExamRoomID: examRoomID,
Kind: kind, Kind: kind,
Reason: reason, Reason: reason,
MonitorMode: strings.TrimSpace(req.MonitorMode), MonitorMode: monitorMode,
ClientAt: clientAt, ClientAt: clientAt,
} }
if err := db.Create(&row).Error; err != nil { if err := db.Create(&row).Error; err != nil {
@@ -796,6 +817,7 @@ func ReportViolationHandler(db *gorm.DB) fiber.Handler {
"studentName": studentName, "studentName": studentName,
"studentCode": studentCode, "studentCode": studentCode,
"classId": classID, "classId": classID,
"examRoomId": examRoomID,
"kind": kind, "kind": kind,
"reason": reason, "reason": reason,
"monitorMode": row.MonitorMode, "monitorMode": row.MonitorMode,
@@ -803,7 +825,7 @@ func ReportViolationHandler(db *gorm.DB) fiber.Handler {
"createdAt": row.CreatedAt.Format(time.RFC3339), "createdAt": row.CreatedAt.Format(time.RFC3339),
}) })
return c.JSON(fiber.Map{"ok": true, "id": row.ID}) return c.JSON(fiber.Map{"ok": true, "id": row.ID, "classRkId": classID, "examRoomId": examRoomID})
} }
} }
@@ -813,6 +835,7 @@ type studentViolationItem struct {
StudentCode string `json:"studentCode"` StudentCode string `json:"studentCode"`
FullName string `json:"fullName"` FullName string `json:"fullName"`
ClassRkID int64 `json:"classRkId"` ClassRkID int64 `json:"classRkId"`
ExamRoomID uint `json:"examRoomId"`
Kind string `json:"kind"` Kind string `json:"kind"`
Reason string `json:"reason"` Reason string `json:"reason"`
MonitorMode string `json:"monitorMode"` MonitorMode string `json:"monitorMode"`
@@ -838,7 +861,7 @@ func ListClassViolationsHandler(db *gorm.DB) fiber.Handler {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
} }
if len(mappings) == 0 { if len(mappings) == 0 {
return c.JSON(fiber.Map{"data": []any{}}) return c.JSON(fiber.Map{"data": []any{}, "date": date})
} }
studentIDs := make([]int64, 0, len(mappings)) studentIDs := make([]int64, 0, len(mappings))
for _, m := range mappings { for _, m := range mappings {
@@ -860,7 +883,11 @@ func ListClassViolationsHandler(db *gorm.DB) fiber.Handler {
} }
dayEnd := dayStart.Add(24 * time.Hour) dayEnd := dayStart.Add(24 * time.Hour)
q := db.Where("student_rk_id IN ? AND created_at >= ? AND created_at < ?", studentIDs, dayStart, dayEnd) // Ư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 != "" { if kindFilter != "" {
q = q.Where("kind = ?", kindFilter) q = q.Where("kind = ?", kindFilter)
} }
@@ -872,12 +899,16 @@ func ListClassViolationsHandler(db *gorm.DB) fiber.Handler {
out := make([]studentViolationItem, 0, len(rows)) out := make([]studentViolationItem, 0, len(rows))
for _, r := range rows { for _, r := range rows {
st := nameByID[r.StudentRkID] st := nameByID[r.StudentRkID]
if st.RkID == 0 {
_ = db.Where("rk_id = ?", r.StudentRkID).First(&st).Error
}
out = append(out, studentViolationItem{ out = append(out, studentViolationItem{
ID: r.ID, ID: r.ID,
StudentRkID: r.StudentRkID, StudentRkID: r.StudentRkID,
StudentCode: st.StudentCode, StudentCode: st.StudentCode,
FullName: st.FullName, FullName: st.FullName,
ClassRkID: r.ClassRkID, ClassRkID: r.ClassRkID,
ExamRoomID: r.ExamRoomID,
Kind: r.Kind, Kind: r.Kind,
Reason: r.Reason, Reason: r.Reason,
MonitorMode: r.MonitorMode, MonitorMode: r.MonitorMode,
@@ -907,7 +938,7 @@ func ListExamRoomViolationsHandler(db *gorm.DB) fiber.Handler {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
} }
if len(roster) == 0 { if len(roster) == 0 {
return c.JSON(fiber.Map{"data": []any{}}) return c.JSON(fiber.Map{"data": []any{}, "date": date})
} }
studentIDs := make([]int64, 0, len(roster)) studentIDs := make([]int64, 0, len(roster))
for _, s := range roster { for _, s := range roster {
@@ -927,7 +958,11 @@ func ListExamRoomViolationsHandler(db *gorm.DB) fiber.Handler {
} }
dayEnd := dayStart.Add(24 * time.Hour) dayEnd := dayStart.Add(24 * time.Hour)
q := db.Where("student_rk_id IN ? AND created_at >= ? AND created_at < ?", studentIDs, dayStart, dayEnd) // Ư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 != "" { if kindFilter != "" {
q = q.Where("kind = ?", kindFilter) q = q.Where("kind = ?", kindFilter)
} }
@@ -945,6 +980,7 @@ func ListExamRoomViolationsHandler(db *gorm.DB) fiber.Handler {
StudentCode: st.StudentCode, StudentCode: st.StudentCode,
FullName: st.FullName, FullName: st.FullName,
ClassRkID: r.ClassRkID, ClassRkID: r.ClassRkID,
ExamRoomID: r.ExamRoomID,
Kind: r.Kind, Kind: r.Kind,
Reason: r.Reason, Reason: r.Reason,
MonitorMode: r.MonitorMode, MonitorMode: r.MonitorMode,

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

@@ -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

@@ -11,18 +11,19 @@ const (
// ExamRoom — phòng thi độc lập với lớp học // ExamRoom — phòng thi độc lập với lớp học
type ExamRoom struct { type ExamRoom struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"` UpdatedAt time.Time `json:"updatedAt"`
Name string `gorm:"column:name;size:255;not null" json:"name"` Name string `gorm:"column:name;size:255;not null" json:"name"`
StartTime time.Time `gorm:"column:start_time;not null;index" json:"startTime"` StartTime time.Time `gorm:"column:start_time;not null;index" json:"startTime"`
EndTime time.Time `gorm:"column:end_time;not null;index" json:"endTime"` EndTime time.Time `gorm:"column:end_time;not null;index" json:"endTime"`
AllowedApps string `gorm:"column:allowed_apps;type:text" json:"allowedApps"` AllowedApps string `gorm:"column:allowed_apps;type:text" json:"allowedApps"`
QuizURL string `gorm:"column:quiz_url;size:1024" json:"quizUrl"` QuizURL string `gorm:"column:quiz_url;size:1024" json:"quizUrl"`
GitRepoURL string `gorm:"column:git_repo_url;size:512" json:"gitRepoUrl"` GitRepoURL string `gorm:"column:git_repo_url;size:512" json:"gitRepoUrl"`
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"`
@@ -185,6 +203,7 @@ type StudentViolation struct {
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
StudentRkID int64 `gorm:"column:student_rk_id;not null;index" json:"studentRkId"` StudentRkID int64 `gorm:"column:student_rk_id;not null;index" json:"studentRkId"`
ClassRkID int64 `gorm:"column:class_rk_id;index" json:"classRkId"` 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 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"` Reason string `gorm:"column:reason;type:text" json:"reason"`
MonitorMode string `gorm:"column:monitor_mode;size:32" json:"monitorMode"` MonitorMode string `gorm:"column:monitor_mode;size:32" json:"monitorMode"`
@@ -237,4 +256,16 @@ type AppGuide struct {
func (AppGuide) TableName() string { return "app_guides" } 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,15 +1,21 @@
package websocket package websocket
import ( import (
"bufio"
"encoding/base64"
"encoding/json" "encoding/json"
"fmt"
"log" "log"
"strconv" "strconv"
"strings"
"sync" "sync"
"time" "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 ( const (
@@ -53,22 +59,30 @@ type offlineGrace struct {
} }
type WsHub struct { type WsHub struct {
mu sync.RWMutex mu sync.RWMutex
students map[int64]*SocketClient students map[int64]*SocketClient
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 grace map[int64]offlineGrace
graceTimers map[int64]*time.Timer 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{
students: make(map[int64]*SocketClient), students: make(map[int64]*SocketClient),
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), grace: make(map[int64]offlineGrace),
graceTimers: make(map[int64]*time.Timer), 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 {
@@ -317,6 +331,12 @@ 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)
if student, exists := h.students[sID]; exists { if student, exists := h.students[sID]; exists {
@@ -330,7 +350,7 @@ func (h *WsHub) Unregister(c *SocketClient) {
} }
} }
func (h *WsHub) Subscribe(teacherAddr string, studentID int64) { func (h *WsHub) Subscribe(teacherAddr string, studentID int64, mode string) {
h.mu.Lock() h.mu.Lock()
defer h.mu.Unlock() defer h.mu.Unlock()
@@ -344,9 +364,12 @@ 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)
} }
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.WriteJSON(SocketMsg{Event: "start_screenshot_stream"}) _ = student.WriteJSON(SocketMsg{Event: "start_screenshot_stream"})
_ = student.WriteJSON(SocketMsg{Event: "start_webcam_stream"}) _ = student.WriteJSON(SocketMsg{Event: "start_webcam_stream"})
@@ -357,6 +380,11 @@ 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
@@ -381,55 +409,105 @@ func (h *WsHub) Unsubscribe(teacherAddr string, studentID int64) {
} }
} }
func (h *WsHub) RelayFrame(studentID int64, event string, data map[string]any) { func (h *WsHub) RelayFrameRaw(studentID int64, event string, rawImageBuffer json.RawMessage) {
h.mu.RLock() h.mu.Lock()
defer h.mu.Unlock()
// 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 {
h.mu.RUnlock()
return return
} }
addrs := make([]string, len(teachersList))
copy(addrs, teachersList)
h.mu.RUnlock()
relayEvent := "teacher:screenshot-stream-frame" relayEvent := "teacher:screenshot-stream-frame"
if event == "webcam_stream_frame" { if event == "webcam_stream_frame" {
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"`
} }
msg := RelayFrameRawMsg{
Event: relayEvent,
}
msg.Data.StudentID = studentID
msg.Data.ImageBuffer = rawImageBuffer
msgBytes, err := json.Marshal(msg) msgBytes, err := json.Marshal(msg)
if err != nil { if err != nil {
return return
} }
var dead []string now := time.Now()
h.mu.RLock() for _, addr := range teachersList {
for _, addr := range addrs { t, found := h.teachers[addr]
if t, found := h.teachers[addr]; found { if !found || t == nil {
if err := t.WriteRaw(msgBytes); err != nil { continue
log.Printf("[WS] Relay to teacher %s failed: %v", addr, err)
dead = append(dead, addr)
}
} }
}
h.mu.RUnlock()
for _, addr := range dead { subKey := addr + "_" + strconv.FormatInt(studentID, 10)
if t, ok := func() (*SocketClient, bool) { mode := h.subscriberModes[subKey]
h.mu.RLock()
defer h.mu.RUnlock() if mode == "grid" {
t, ok := h.teachers[addr] // In grid mode, rate limit to max 1 frame per 3 seconds per stream type
return t, ok relayKey := subKey + "_" + event
}(); ok && t != nil { lastTime, ok := h.lastRelayed[relayKey]
_ = t.Conn.Close() 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)
} }
} }
@@ -497,6 +575,29 @@ func WebSocketHandler(db *gorm.DB) func(*websocket.Conn) {
} }
_ = c.SetReadDeadline(time.Now().Add(wsPongWait)) _ = 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
@@ -506,9 +607,6 @@ func WebSocketHandler(db *gorm.DB) func(*websocket.Conn) {
case "client:ping": case "client:ping":
_ = client.WriteJSON(SocketMsg{Event: "client:pong", Data: map[string]any{"t": time.Now().UnixMilli()}}) _ = client.WriteJSON(SocketMsg{Event: "client:pong", Data: map[string]any{"t": time.Now().UnixMilli()}})
case "screenshot_stream_frame", "webcam_stream_frame":
Hub.RelayFrame(client.StudentID, msg.Event, msg.Data)
case "teacher:subscribe": case "teacher:subscribe":
if client.Role == "teacher" { if client.Role == "teacher" {
if sIDVal, ok := msg.Data["studentId"]; ok { if sIDVal, ok := msg.Data["studentId"]; ok {
@@ -519,8 +617,15 @@ 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)
} }
} }
} }
@@ -544,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

@@ -130,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))
@@ -203,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))

File diff suppressed because it is too large Load Diff

73
walkthrough.md Normal file
View File

@@ -0,0 +1,73 @@
# Walkthrough - Attendance Local Approval, Modal Redesign & Native HTTP MJPEG Streaming
We have successfully updated the leave requests approval logic to operate locally, redesigned the attendance panel into a spacious full-height modal, and implemented a high-performance native HTTP MJPEG streaming architecture based on `raia_v3`.
## Changes Made
### 1. Backend Implementation
#### Leave Approvals & Redesign
- **Model Addition** ([models.go](file:///c:/Users/PhuocNTB/Desktop/simple_care_project/server/internal/models/models.go)):
- Defined `LocalLeaveRequest` to keep track of approvals and rejections locally inside Simple Care database.
- **Database Migration** ([db.go](file:///c:/Users/PhuocNTB/Desktop/simple_care_project/server/internal/db/db.go)):
- Registered `LocalLeaveRequest` model for Gorm AutoMigrate.
- **Approval Logic** ([handlers_attendance.go](file:///c:/Users/PhuocNTB/Desktop/simple_care_project/server/internal/handlers/handlers_attendance.go)):
- Modified `UpdateLeaveStatusHandler` to store status overrides directly to the local database, completely bypassing the external `qldtClient.UpdateLeaveStatus` API call.
- **Leave Query status overriding** ([handlers_attendance.go](file:///c:/Users/PhuocNTB/Desktop/simple_care_project/server/internal/handlers/handlers_attendance.go)):
- Modified `GetLeaveRequestsHandler` to intercept the QLDT API list response and override the status field of leave requests with the stored local database values before returning it to the frontend.
#### High-Performance HTTP MJPEG Streaming
- **HTTP MJPEG Endpoint Support** ([websocket.go](file:///c:/Users/PhuocNTB/Desktop/simple_care_project/server/internal/websocket/websocket.go)):
- Added `RegisterHttpSubscriber` and `UnregisterHttpSubscriber` methods to `WsHub` to manage active HTTP streaming connections dynamically.
- Implemented `GetStudentScreenStreamHandler` and `GetStudentWebcamStreamHandler` Fiber controllers.
- Upon receiving streaming frames over the WebSocket connection from a student, the server decodes the base64 JPEGs to raw binary JPEGs and feeds them directly to the corresponding HTTP MJPEG channels.
- Automatically triggers the student's screenshot/webcam capture stream when an HTTP connection is established, and stops it when all subscribers disconnect, saving huge amounts of client CPU and network bandwidth.
- **Unified Query Parameter Auth** ([staff.go](file:///c:/Users/PhuocNTB/Desktop/simple_care_project/server/internal/middleware/staff.go)):
- Updated `RequireStaff()` middleware to accept the JWT token from the query parameter `?token=...` if no `Authorization` header is present. This allows standard `<img>` tags to securely request stream data.
- **Stream Routing** ([main.go](file:///c:/Users/PhuocNTB/Desktop/simple_care_project/server/main.go)):
- Registered routes `/api/students/:studentId/stream/screen` and `/api/students/:studentId/stream/webcam` under the authenticated `staff` router.
---
### 2. Frontend UI / UX Redesign
#### Modal wrapper for Attendance
- **Modal layout** ([AttendancePanel.tsx](file:///c:/Users/PhuocNTB/Desktop/simple_care_project/management/src/components/AttendancePanel.tsx)):
- Redesigned the main container of `<AttendancePanel>` to be a full-screen backdrop modal (`.attendance-modal-overlay` and `.attendance-modal-container`) with a top header containing a close button (`&times;`).
- **Modal Dismiss callback** ([ClassWorkspace.tsx](file:///c:/Users/PhuocNTB/Desktop/simple_care_project/management/src/components/ClassWorkspace.tsx)):
- Configured `onClose` callback on the `<AttendancePanel>` to automatically navigate the teacher back to the roster tab ('roster') when they dismiss the modal.
- **Leave Modal Notices** ([AttendancePanel.tsx](file:///c:/Users/PhuocNTB/Desktop/simple_care_project/management/src/components/AttendancePanel.tsx)):
- Added a clear alert notice informing teachers that approving or rejecting a leave request records the action locally inside Simple Care only and does not sync back to the QLDT portal. Approving will mark the student's status to "Nghỉ có phép".
- **Responsive CSS Styles** ([index.css](file:///c:/Users/PhuocNTB/Desktop/simple_care_project/management/src/index.css)):
- Implemented `.attendance-modal-overlay`, `.attendance-modal-container`, and `.attendance-modal-body` CSS styles. The container utilizes `width: 98vw !important` and `height: 96vh !important` to provide a spacious UI layout with full scroll support, avoiding squeezed tables on small screens and low-height devices.
- **Exam Violations Notice** ([ViolationsPanel.tsx](file:///c:/Users/PhuocNTB/Desktop/simple_care_project/management/src/components/ViolationsPanel.tsx)):
- Added an amber warning notice label in the violations list tab specifically during exam mode (`props.mode === 'exam'`) stating: *"Lưu ý: Chức năng theo dõi vi phạm đang được theo dõi đánh giá tính chuẩn xác, hiện tại kết quả chạy thử chỉ mang tính chất tham khảo."*
#### Native Stream Rendering (MJPEG)
- **StudentStreamImage Component** ([StudentStreamImage.tsx](file:///c:/Users/PhuocNTB/Desktop/simple_care_project/management/src/components/StudentStreamImage.tsx)):
- Created a reusable component that binds directly to the HTTP stream.
- Automatically manages connection establishment, listens to error states (e.g. when a student is offline or stream is dropped), and retries the connection after a small delay.
- **Stream Panel Integration** ([ProctorStreamPanels.tsx](file:///c:/Users/PhuocNTB/Desktop/simple_care_project/management/src/components/ProctorStreamPanels.tsx)):
- Rewrote the panel component to render screens and webcams using `<StudentStreamImage>`.
- Completely removed all complex WebSocket subscription state management, subscriptions, and connection setups from the component, eliminating lag, thread blocks, and frontend JSON parsing overhead.
- **Grid View Integration** ([ExamGridProctor.tsx](file:///c:/Users/PhuocNTB/Desktop/simple_care_project/management/src/components/ExamGridProctor.tsx)):
- Simplified the grid view by using `<StudentStreamImage>` for both card frames and the zoomed modal.
- Bypassed WebSocket connections completely for grid monitoring, reducing frontend CPU load and rendering overhead to zero.
---
## Verification Results
### Backend Compile Check
- Built Go code successfully:
```powershell
go build -o server_test.exe main.go
```
Backend compiles cleanly without errors.
### Frontend Build Check
- Ran Vite production compilation:
```powershell
npx tsc --noEmit
```
Frontend builds and type-checks successfully with all TS checks passing.