Compare commits
14 Commits
83821b6b82
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| b6dfe705f5 | |||
| 42172db9f4 | |||
| 3b80550c76 | |||
| 2bdec7d7e7 | |||
| 232f9873a0 | |||
| 6ee9773f7f | |||
| aed692cffe | |||
| 1ed246eec8 | |||
| a55b0a4567 | |||
| f4b9d99051 | |||
| 487e9eb8ea | |||
| 7d3fdefa3f | |||
| 0ff025fb15 | |||
| b56c6d722c |
@@ -46,13 +46,13 @@ cd client
|
||||
chmod +x build.sh
|
||||
./build.sh
|
||||
```
|
||||
Sau khi build xong, file thực thi sẽ nằm tại: `client/build/bin/simple_care_v1.1`
|
||||
Sau khi build xong, file thực thi sẽ nằm tại: `client/build/bin/simple_care_v1.3`
|
||||
|
||||
---
|
||||
|
||||
## 2. Cho Người dùng cuối (End-user - Chỉ chạy ứng dụng)
|
||||
|
||||
Người dùng cuối **không cần** cài đặt Go, Node hay trình biên dịch C. Họ chỉ cần tệp thực thi `simple_care_v1.1` 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:**
|
||||
```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:
|
||||
Cấp quyền chạy cho file và khởi chạy:
|
||||
```bash
|
||||
chmod +x simple_care_v1.1
|
||||
./simple_care_v1.1
|
||||
chmod +x simple_care_v1.3
|
||||
./simple_care_v1.3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
175
client/app.go
175
client/app.go
@@ -8,8 +8,8 @@ import (
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
@@ -59,7 +59,7 @@ func getWsUrl(apiBase string) string {
|
||||
return "ws://" + apiBase
|
||||
}
|
||||
|
||||
var wifiReportLast sync.Map // ssidKey -> time.Time
|
||||
var wifiReportLast sync.Map // ssidKey -> time.Time
|
||||
|
||||
func getDynamicEncryptionKey() []byte {
|
||||
base := "simple-care-proctor-secretkey32!"
|
||||
@@ -120,45 +120,45 @@ type StudentData struct {
|
||||
}
|
||||
|
||||
type App struct {
|
||||
ctx context.Context
|
||||
student *StudentData
|
||||
mu sync.Mutex
|
||||
sessionPath string
|
||||
statsPath string
|
||||
webviewDataPath string
|
||||
pendingViolPath string
|
||||
runLockPath string
|
||||
wsConn *websocket.Conn
|
||||
wsConnected bool
|
||||
wsConnecting bool
|
||||
wsBackoff time.Duration
|
||||
wsWriteMu sync.Mutex
|
||||
wifiSSID string
|
||||
wifiBSSID string
|
||||
allowedApps string
|
||||
onlineSecs int
|
||||
offlineSecs int
|
||||
unsyncedOn int
|
||||
unsyncedOff int
|
||||
lastSyncTime time.Time
|
||||
isStreamingSc bool
|
||||
streamScStop chan struct{}
|
||||
isStreamingCam bool
|
||||
streamCamStop chan struct{}
|
||||
ctx context.Context
|
||||
student *StudentData
|
||||
mu sync.Mutex
|
||||
sessionPath string
|
||||
statsPath string
|
||||
webviewDataPath string
|
||||
pendingViolPath string
|
||||
runLockPath string
|
||||
wsConn *websocket.Conn
|
||||
wsConnected bool
|
||||
wsConnecting bool
|
||||
wsBackoff time.Duration
|
||||
wsWriteMu sync.Mutex
|
||||
wifiSSID string
|
||||
wifiBSSID string
|
||||
allowedApps string
|
||||
onlineSecs int
|
||||
offlineSecs int
|
||||
unsyncedOn int
|
||||
unsyncedOff int
|
||||
lastSyncTime time.Time
|
||||
isStreamingSc bool
|
||||
streamScStop chan struct{}
|
||||
isStreamingCam bool
|
||||
streamCamStop chan struct{}
|
||||
statusMsg string
|
||||
expectingLogin bool
|
||||
needsClearPortalStorage bool
|
||||
backendOnline bool
|
||||
dashboard StudentDashboardSnapshot
|
||||
wifiEnforce bool
|
||||
acceptedWifi map[string]bool
|
||||
wifiRejected bool
|
||||
quitDialogShown bool
|
||||
monitoringTornDown bool
|
||||
localBrowserActive bool // trình duyệt local — chỉ cho phép localhost
|
||||
chatUnread int
|
||||
replyStaffID uint
|
||||
fetchAppsMu sync.Mutex
|
||||
dashboard StudentDashboardSnapshot
|
||||
wifiEnforce bool
|
||||
acceptedWifi map[string]bool
|
||||
wifiRejected bool
|
||||
quitDialogShown bool
|
||||
monitoringTornDown bool
|
||||
localBrowserActive bool // trình duyệt local — chỉ cho phép localhost
|
||||
chatUnread int
|
||||
replyStaffID uint
|
||||
fetchAppsMu sync.Mutex
|
||||
}
|
||||
|
||||
type LocalStats struct {
|
||||
@@ -219,13 +219,13 @@ func NewApp() *App {
|
||||
_ = os.MkdirAll(webviewDir, 0755)
|
||||
|
||||
return &App{
|
||||
sessionPath: filepath.Join(appDir, "student_session.json"),
|
||||
statsPath: filepath.Join(appDir, "student_stats.json"),
|
||||
webviewDataPath: webviewDir,
|
||||
pendingViolPath: filepath.Join(appDir, "pending_violations.json"),
|
||||
runLockPath: filepath.Join(appDir, "monitoring.lock"),
|
||||
lastSyncTime: time.Now(),
|
||||
expectingLogin: false,
|
||||
sessionPath: filepath.Join(appDir, "student_session.json"),
|
||||
statsPath: filepath.Join(appDir, "student_stats.json"),
|
||||
webviewDataPath: webviewDir,
|
||||
pendingViolPath: filepath.Join(appDir, "pending_violations.json"),
|
||||
runLockPath: filepath.Join(appDir, "monitoring.lock"),
|
||||
lastSyncTime: time.Now(),
|
||||
expectingLogin: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,27 +291,56 @@ func (a *App) handleGuardViolation(kind, reason string) {
|
||||
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.
|
||||
// Trả về false để cho phép đóng sau khi đã báo cáo.
|
||||
// HandleBeforeClose — SV bấm X / Alt+F4: luôn hỏi xác nhận.
|
||||
// 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) {
|
||||
if !a.CheckLoginStatus() || !a.isMonitoringActive() {
|
||||
a.clearRunLock()
|
||||
return false
|
||||
}
|
||||
a.mu.Lock()
|
||||
mode := a.dashboard.MonitorMode
|
||||
loggedIn := a.student != nil
|
||||
a.mu.Unlock()
|
||||
if !shouldRecordViolation(mode) {
|
||||
a.clearRunLock()
|
||||
return false
|
||||
|
||||
message := "Bạn có chắc muốn thoát Simple Care không?"
|
||||
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+")")
|
||||
a.tearDownBeforeQuit()
|
||||
selection, err := runtime.MessageDialog(a.ctx, runtime.MessageDialogOptions{
|
||||
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()
|
||||
log.Printf("[CLOSE] User confirmed quit (mode=%s, loggedIn=%v)", mode, loggedIn)
|
||||
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).
|
||||
func (a *App) tearDownBeforeQuit() {
|
||||
a.mu.Lock()
|
||||
@@ -362,6 +391,8 @@ type pendingViolation struct {
|
||||
MonitorMode string `json:"monitorMode"`
|
||||
ClientAt string `json:"clientAt"`
|
||||
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ờ).
|
||||
@@ -378,6 +409,11 @@ func (a *App) markRunLock() {
|
||||
a.mu.Lock()
|
||||
studentID := int64(0)
|
||||
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 {
|
||||
studentID = a.student.StudentID
|
||||
}
|
||||
@@ -388,6 +424,8 @@ func (a *App) markRunLock() {
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"studentRkId": studentID,
|
||||
"monitorMode": mode,
|
||||
"classRkId": classID,
|
||||
"examRoomId": examRoomID,
|
||||
"startedAt": time.Now().Format(time.RFC3339),
|
||||
})
|
||||
_ = os.WriteFile(a.runLockPath, payload, 0644)
|
||||
@@ -407,6 +445,8 @@ func (a *App) detectUncleanShutdown() {
|
||||
var meta struct {
|
||||
StudentRkID int64 `json:"studentRkId"`
|
||||
MonitorMode string `json:"monitorMode"`
|
||||
ClassRkID int64 `json:"classRkId"`
|
||||
ExamRoomID uint `json:"examRoomId"`
|
||||
StartedAt string `json:"startedAt"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &meta)
|
||||
@@ -430,6 +470,8 @@ func (a *App) detectUncleanShutdown() {
|
||||
MonitorMode: meta.MonitorMode,
|
||||
ClientAt: time.Now().Format(time.RFC3339),
|
||||
StudentRkID: meta.StudentRkID,
|
||||
ClassRkID: meta.ClassRkID,
|
||||
ExamRoomID: meta.ExamRoomID,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -481,9 +523,11 @@ func (a *App) postViolation(v pendingViolation) bool {
|
||||
"reason": v.Reason,
|
||||
"monitorMode": v.MonitorMode,
|
||||
"clientAt": v.ClientAt,
|
||||
"classRkId": v.ClassRkID,
|
||||
"examRoomId": v.ExamRoomID,
|
||||
}
|
||||
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))
|
||||
if err != nil {
|
||||
log.Printf("[VIOLATION] report failed: %v", err)
|
||||
@@ -495,7 +539,7 @@ func (a *App) postViolation(v pendingViolation) bool {
|
||||
log.Printf("[VIOLATION] report status %d: %s", resp.StatusCode, string(body))
|
||||
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
|
||||
}
|
||||
|
||||
@@ -503,6 +547,11 @@ func (a *App) reportViolation(kind, reason string) {
|
||||
a.mu.Lock()
|
||||
studentID := int64(0)
|
||||
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 {
|
||||
studentID = a.student.StudentID
|
||||
}
|
||||
@@ -516,6 +565,8 @@ func (a *App) reportViolation(kind, reason string) {
|
||||
MonitorMode: mode,
|
||||
ClientAt: time.Now().Format(time.RFC3339),
|
||||
StudentRkID: studentID,
|
||||
ClassRkID: classID,
|
||||
ExamRoomID: examRoomID,
|
||||
}
|
||||
if !a.postViolation(v) {
|
||||
a.enqueuePendingViolation(v)
|
||||
@@ -1455,12 +1506,12 @@ func (a *App) fetchAllowedApps(classId int64) {
|
||||
reason = "Không đáp ứng điều kiện học tập (chưa cấu hình ứng dụng được phép hoặc ngoài giờ học)."
|
||||
}
|
||||
log.Printf("[CLIENT] Conditions not met: %s. Blocker suspended.", reason)
|
||||
|
||||
|
||||
a.mu.Lock()
|
||||
a.allowedApps = ""
|
||||
a.statusMsg = reason
|
||||
a.mu.Unlock()
|
||||
|
||||
|
||||
blocker.Instance.Stop()
|
||||
return
|
||||
}
|
||||
@@ -1472,7 +1523,7 @@ func (a *App) fetchAllowedApps(classId int64) {
|
||||
a.statusMsg = "Đang trong giờ học"
|
||||
}
|
||||
a.mu.Unlock()
|
||||
|
||||
|
||||
log.Printf("[CLIENT] Allowed apps fetched from server: %s", res.Keywords)
|
||||
blocker.Instance.SetKeywords(res.Keywords)
|
||||
blocker.Instance.Start()
|
||||
@@ -2383,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).
|
||||
func (a *App) LoadExamViewFile(fileURL string) (map[string]any, error) {
|
||||
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ệ")
|
||||
}
|
||||
if _, _, err := a.examStudentContext(); err != nil {
|
||||
@@ -2459,7 +2510,7 @@ func (a *App) SubmitExamWork() (string, error) {
|
||||
}
|
||||
_ = 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 {
|
||||
return "", err
|
||||
}
|
||||
|
||||
4
client/frontend/package-lock.json
generated
4
client/frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "1.1.0",
|
||||
"version": "1.3.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"version": "1.1.0",
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"pdfjs-dist": "^3.11.174"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.1.0",
|
||||
"version": "1.3.0",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
|
||||
@@ -1 +1 @@
|
||||
39a3c87a78e46d8e7ef5f5042d044ee3
|
||||
792441f7861d7badf1335f3778fcdebd
|
||||
@@ -19,7 +19,7 @@ if (typeof window.go.main.App === 'undefined') {
|
||||
}
|
||||
|
||||
const brandLogoHtml = `<img src="${logoUrl}" alt="Simple Care" class="brand-logo-img" />`;
|
||||
const APP_VERSION = '1.1';
|
||||
const APP_VERSION = '1.3';
|
||||
|
||||
// Trạng thái cục bộ
|
||||
let loggedIn = false;
|
||||
|
||||
@@ -172,6 +172,8 @@ var systemAllowed = map[string]bool{
|
||||
"client": true,
|
||||
"simple_care_v1.0": true,
|
||||
"simple_care_v1.1": true,
|
||||
"simple_care_v1.2": true,
|
||||
"simple_care_v1.3": true,
|
||||
"simple_care": true,
|
||||
"wails": true,
|
||||
"code": true, // VSCode
|
||||
|
||||
@@ -237,6 +237,8 @@ var systemAllowed = map[string]bool{
|
||||
"client": true,
|
||||
"simple_care_v1.0": true,
|
||||
"simple_care_v1.1": true,
|
||||
"simple_care_v1.2": true,
|
||||
"simple_care_v1.3": true,
|
||||
"simple_care": true,
|
||||
}
|
||||
|
||||
|
||||
@@ -330,6 +330,8 @@ var systemAllowed = map[string]bool{
|
||||
"client.exe": true,
|
||||
"simple_care_v1.0.exe": true,
|
||||
"simple_care_v1.1.exe": true,
|
||||
"simple_care_v1.2.exe": true,
|
||||
"simple_care_v1.3.exe": true,
|
||||
"simple_care.exe": true,
|
||||
"wails.exe": true,
|
||||
"msedgewebview2.exe": true, // WebView2 runtime (Wails renderer)
|
||||
|
||||
@@ -47,9 +47,54 @@ static unsigned char* CaptureFullDesktop(int* outLen, int quality) {
|
||||
}
|
||||
|
||||
@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);
|
||||
|
||||
// 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) {
|
||||
*outLen = 0;
|
||||
return NULL;
|
||||
|
||||
@@ -6,11 +6,35 @@ import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
|
||||
"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,..."
|
||||
// Non-darwin: dùng kbinani/screenshot
|
||||
func CaptureScreen() (string, error) {
|
||||
@@ -26,9 +50,36 @@ func CaptureScreen() (string, error) {
|
||||
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
|
||||
// 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 {
|
||||
return "", fmt.Errorf("failed to encode jpeg: %w", err)
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ func main() {
|
||||
|
||||
// Create application with options
|
||||
err := wails.Run(&options.App{
|
||||
Title: "Simple Care v1.1 — Rikkei Education",
|
||||
Title: "Simple Care v1.3 — Rikkei Education",
|
||||
Width: 1024,
|
||||
Height: 768,
|
||||
Menu: appMenu,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://wails.io/schemas/config.v2.json",
|
||||
"name": "Simple Care by Rikkei Edu",
|
||||
"outputfilename": "simple_care_v1.1",
|
||||
"outputfilename": "simple_care_v1.3",
|
||||
"frontend:install": "npm install",
|
||||
"frontend:build": "npm run build",
|
||||
"frontend:dev:watcher": "npm run dev",
|
||||
|
||||
86
management/package-lock.json
generated
86
management/package-lock.json
generated
@@ -57,6 +57,7 @@
|
||||
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/generator": "^7.29.7",
|
||||
@@ -266,29 +267,6 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
|
||||
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.2",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
|
||||
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
|
||||
@@ -540,9 +518,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -560,9 +535,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -580,9 +552,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -600,9 +569,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -620,9 +586,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -640,9 +603,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -660,9 +620,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -680,9 +637,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -853,9 +807,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -873,9 +824,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -893,9 +841,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -913,9 +858,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -933,9 +875,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -953,9 +892,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1041,6 +977,7 @@
|
||||
"integrity": "sha512-+zEk16yGlz1F9STiRr6uG9hmIXb6nprjLczV/htGptYuLoCuxb+itZ03RKCEeOhBpDDd1NU7qF6x1VLMUp62bw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"picomatch": "^4.0.4"
|
||||
},
|
||||
@@ -1135,6 +1072,7 @@
|
||||
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
@@ -1145,6 +1083,7 @@
|
||||
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -1243,6 +1182,7 @@
|
||||
"integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.26.0"
|
||||
}
|
||||
@@ -1298,6 +1238,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.10.38",
|
||||
"caniuse-lite": "^1.0.30001799",
|
||||
@@ -1822,9 +1763,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1846,9 +1784,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1870,9 +1805,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1894,9 +1826,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2311,6 +2240,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
|
||||
"integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -2365,6 +2295,7 @@
|
||||
"integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.137.0",
|
||||
"@rolldown/pluginutils": "^1.0.0"
|
||||
@@ -2648,6 +2579,7 @@
|
||||
"integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.4",
|
||||
|
||||
@@ -10,6 +10,8 @@ import { SystemTab } from './components/SystemTab';
|
||||
import { MyAccountTab } from './components/AccountsTab';
|
||||
import { StudentAffairsTab } from './components/StudentAffairsTab';
|
||||
import { ApplicationsTab } from './components/ApplicationsTab';
|
||||
import { RescideTab } from './components/RescideTab';
|
||||
import { RescideAnnouncePopup } from './components/RescideAnnouncePopup';
|
||||
import { ChatWidget } from './components/ChatWidget';
|
||||
import { StaffChatSocket } from './hooks/useStaffChatSocket';
|
||||
import { ClassWorkspace } from './components/ClassWorkspace';
|
||||
@@ -28,67 +30,95 @@ import {
|
||||
import { useAuth } from './auth/AuthContext';
|
||||
|
||||
const IconDashboard = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" />
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<rect x="3" y="3" width="7" height="7" rx="1.5" />
|
||||
<rect x="14" y="3" width="7" height="7" rx="1.5" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="1.5" />
|
||||
<rect x="14" y="14" width="7" height="7" rx="1.5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconClass = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||
<polyline points="9 22 9 12 15 12 15 22" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconStudent = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" /><path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="3.5" />
|
||||
<path d="M22 21v-2a3.5 3.5 0 0 0-2.5-3.35" />
|
||||
<path d="M16 3.5a3.5 3.5 0 0 1 0 7" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconLearning = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" />
|
||||
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconExam = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" /><line x1="16" y1="17" x2="8" y2="17" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" />
|
||||
<line x1="16" y1="17" x2="8" y2="17" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconSystem = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconStudentAffairs = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconApplications = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="14" width="7" height="7" rx="1" />
|
||||
<path d="M3 14h7v7H3z" />
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" />
|
||||
<path d="M8 21h8M12 17v4" />
|
||||
<path d="M7 8h.01M12 8h.01M17 8h.01M7 12h10" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconRescide = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<polyline points="16 18 22 12 16 6" />
|
||||
<polyline points="8 6 2 12 8 18" />
|
||||
<line x1="14" y1="4" x2="10" y2="20" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconMyClass = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M12 3 2 9l10 6 10-6-10-6Z" />
|
||||
<path d="M6 12v5c0 1.7 2.7 3 6 3s6-1.3 6-3v-5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconLogout = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||
<polyline points="16 17 21 12 16 7" />
|
||||
<line x1="21" y1="12" x2="9" y2="12" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconMenu = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="3" y1="12" x2="21" y2="12" /><line x1="3" y1="6" x2="21" y2="6" /><line x1="3" y1="18" x2="21" y2="18" />
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<line x1="3" y1="12" x2="21" y2="12" />
|
||||
<line x1="3" y1="6" x2="21" y2="6" />
|
||||
<line x1="3" y1="18" x2="21" y2="18" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -210,122 +240,144 @@ function App() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
<nav className="sidebar-nav" aria-label="Menu chính">
|
||||
<ul className="nav-links">
|
||||
<div className="nav-header">Tổng quan</div>
|
||||
<li className="nav-section" aria-hidden="true">
|
||||
<span className="nav-header">Tổng quan</span>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<button
|
||||
type="button"
|
||||
className={navBtn(route.tab === 'dashboard' && !inWorkspace)}
|
||||
onClick={() => setActiveTab('dashboard')}
|
||||
>
|
||||
<span className="nav-icon"><IconDashboard /></span>
|
||||
Tổng quan & Tài Liệu
|
||||
<span className="nav-label">Tổng quan & Tài liệu</span>
|
||||
</button>
|
||||
</li>
|
||||
|
||||
<div className="nav-header">CSDL liên kết</div>
|
||||
<li className="nav-section" aria-hidden="true">
|
||||
<span className="nav-header">CSDL liên kết</span>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<button className={navBtn(classesActive)} onClick={() => setActiveTab('classes')}>
|
||||
<button type="button" className={navBtn(classesActive)} onClick={() => setActiveTab('classes')}>
|
||||
<span className="nav-icon"><IconClass /></span>
|
||||
Lớp học
|
||||
<span className="nav-label">Lớp học</span>
|
||||
</button>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<button
|
||||
type="button"
|
||||
className={navBtn(route.tab === 'students' && !inWorkspace)}
|
||||
onClick={() => setActiveTab('students')}
|
||||
>
|
||||
<span className="nav-icon"><IconStudent /></span>
|
||||
Sinh viên
|
||||
<span className="nav-label">Sinh viên</span>
|
||||
</button>
|
||||
</li>
|
||||
|
||||
<div className="nav-header">Quản lý học tập</div>
|
||||
<li className="nav-section" aria-hidden="true">
|
||||
<span className="nav-header">Quản lý học tập</span>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<button className={navBtn(learningActive)} onClick={() => setActiveTab('learning')}>
|
||||
<button type="button" className={navBtn(learningActive)} onClick={() => setActiveTab('learning')}>
|
||||
<span className="nav-icon"><IconLearning /></span>
|
||||
Phòng Học
|
||||
<span className="nav-label">Phòng học</span>
|
||||
</button>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<button className={navBtn(examsActive)} onClick={() => setActiveTab('exams')}>
|
||||
<button type="button" className={navBtn(examsActive)} onClick={() => setActiveTab('exams')}>
|
||||
<span className="nav-icon"><IconExam /></span>
|
||||
Phòng thi
|
||||
<span className="nav-label">Phòng thi</span>
|
||||
</button>
|
||||
</li>
|
||||
|
||||
{myClasses.length > 0 && (
|
||||
<>
|
||||
<div className="nav-header">Lớp của tôi</div>
|
||||
<li className="nav-section" aria-hidden="true">
|
||||
<span className="nav-header">Lớp của tôi</span>
|
||||
</li>
|
||||
{myClasses.map((c) => (
|
||||
<li key={c.id} className="nav-item">
|
||||
<button
|
||||
className={navBtn(route.classId === c.id && route.tab === 'learning')}
|
||||
type="button"
|
||||
className={`${navBtn(route.classId === c.id && route.tab === 'learning')} nav-btn--class`}
|
||||
onClick={() => {
|
||||
navigate('learning', c.id, c.name, 'class');
|
||||
setSidebarOpen(false);
|
||||
}}
|
||||
title={c.name}
|
||||
style={{
|
||||
paddingLeft: '1.75rem',
|
||||
fontSize: '0.82rem',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
textAlign: 'left'
|
||||
}}
|
||||
>
|
||||
🏫 {c.code || c.name}
|
||||
<span className="nav-icon"><IconMyClass /></span>
|
||||
<span className="nav-label">{c.code || c.name}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="nav-header">Công tác sinh viên</div>
|
||||
<li className="nav-section" aria-hidden="true">
|
||||
<span className="nav-header">Công tác sinh viên</span>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<button
|
||||
type="button"
|
||||
className={navBtn(route.tab === 'student-affairs' && !inWorkspace)}
|
||||
onClick={() => setActiveTab('student-affairs')}
|
||||
>
|
||||
<span className="nav-icon"><IconStudentAffairs /></span>
|
||||
Công Tác Sinh Viên
|
||||
<span className="nav-label">Công tác sinh viên</span>
|
||||
</button>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<button
|
||||
type="button"
|
||||
className={navBtn(route.tab === 'applications' && !inWorkspace)}
|
||||
onClick={() => setActiveTab('applications')}
|
||||
>
|
||||
<span className="nav-icon"><IconApplications /></span>
|
||||
Ứng Dụng
|
||||
<span className="nav-label">Ứng dụng</span>
|
||||
</button>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<button
|
||||
type="button"
|
||||
className={navBtn(route.tab === 'rescide' && !inWorkspace)}
|
||||
onClick={() => setActiveTab('rescide')}
|
||||
>
|
||||
<span className="nav-icon"><IconRescide /></span>
|
||||
<span className="nav-label">RESCIDE IDE</span>
|
||||
</button>
|
||||
</li>
|
||||
|
||||
<div className="nav-header">Hệ thống</div>
|
||||
<li className="nav-section" aria-hidden="true">
|
||||
<span className="nav-header">Hệ thống</span>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<button
|
||||
type="button"
|
||||
className={navBtn(systemActive)}
|
||||
onClick={() => goSystem('organization')}
|
||||
>
|
||||
<span className="nav-icon"><IconSystem /></span>
|
||||
Quản lý hệ thống
|
||||
<span className="nav-label">Quản lý hệ thống</span>
|
||||
</button>
|
||||
</li>
|
||||
<ul className="nav-sub">
|
||||
{SYSTEM_NAV.map(({ section }) => (
|
||||
<li key={section} className="nav-item">
|
||||
<button
|
||||
className={navBtn(systemActive && route.systemSection === section)}
|
||||
onClick={() => goSystem(section)}
|
||||
>
|
||||
{SYSTEM_SECTION_LABELS[section]}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<li className="nav-item nav-item--sub">
|
||||
<ul className="nav-sub">
|
||||
{SYSTEM_NAV.map(({ section }) => (
|
||||
<li key={section}>
|
||||
<button
|
||||
type="button"
|
||||
className={navBtn(systemActive && route.systemSection === section)}
|
||||
onClick={() => goSystem(section)}
|
||||
>
|
||||
<span className="nav-label">{SYSTEM_SECTION_LABELS[section]}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
@@ -336,12 +388,24 @@ function App() {
|
||||
onClick={() => setActiveTab('profile')}
|
||||
title="Tài khoản của tôi"
|
||||
>
|
||||
<span className="sidebar-user-name">
|
||||
{staff?.fullName?.trim() || staff?.email?.split('@')[0] || 'Tài khoản'}
|
||||
<span className="sidebar-user-avatar" aria-hidden>
|
||||
{(staff?.fullName?.trim() || staff?.email || '?')
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(-2)
|
||||
.map((w) => w[0]?.toUpperCase() || '')
|
||||
.join('')
|
||||
.slice(0, 2) || '?'}
|
||||
</span>
|
||||
<span className="sidebar-user-meta">
|
||||
<span className="sidebar-user-name">
|
||||
{staff?.fullName?.trim() || staff?.email?.split('@')[0] || 'Tài khoản'}
|
||||
</span>
|
||||
<span className="sidebar-user-email">{staff?.email}</span>
|
||||
</span>
|
||||
<span className="sidebar-user-email">{staff?.email}</span>
|
||||
</button>
|
||||
<button type="button" className="link-btn" onClick={logout}>
|
||||
<button type="button" className="sidebar-logout" onClick={logout}>
|
||||
<IconLogout />
|
||||
Đăng xuất
|
||||
</button>
|
||||
</div>
|
||||
@@ -352,7 +416,7 @@ function App() {
|
||||
onClick={toggleSidebarCollapse}
|
||||
title={sidebarCollapsed ? 'Mở rộng menu' : 'Thu gọn menu'}
|
||||
>
|
||||
<svg viewBox="0 0 10 18" width="10" height="18" fill="currentColor">
|
||||
<svg viewBox="0 0 10 18" width="10" height="18" fill="currentColor" aria-hidden>
|
||||
{sidebarCollapsed
|
||||
? <path d="M1 1 L9 9 L1 17" />
|
||||
: <path d="M9 1 L1 9 L9 17" />
|
||||
@@ -385,10 +449,12 @@ function App() {
|
||||
{route.tab === 'profile' && <MyAccountTab />}
|
||||
{route.tab === 'student-affairs' && <StudentAffairsTab />}
|
||||
{route.tab === 'applications' && <ApplicationsTab />}
|
||||
{route.tab === 'rescide' && <RescideTab />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
<RescideAnnouncePopup disabled={inWorkspace} />
|
||||
<StaffChatSocket />
|
||||
<ChatWidget />
|
||||
</>
|
||||
|
||||
@@ -387,6 +387,7 @@ export interface StudentViolationItem {
|
||||
studentCode: string;
|
||||
fullName: string;
|
||||
classRkId: number;
|
||||
examRoomId?: number;
|
||||
kind: string;
|
||||
reason: string;
|
||||
monitorMode: string;
|
||||
@@ -396,7 +397,7 @@ export interface StudentViolationItem {
|
||||
|
||||
export const VIOLATION_KIND_OPTIONS = [
|
||||
{ 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: 'multi_monitor', label: 'Nhiều màn hình' },
|
||||
{ value: 'user_switch', label: 'Đổi user' },
|
||||
@@ -932,6 +933,7 @@ export interface ExamRoomItem {
|
||||
studentCount: number;
|
||||
paperCount: number;
|
||||
displayStatus: 'draft' | 'ready' | 'active' | 'ended' | 'cancelled';
|
||||
createdByStaffId?: number;
|
||||
}
|
||||
|
||||
export interface ExamPaperResource {
|
||||
@@ -984,8 +986,9 @@ function decodeBase64ToArrayBuffer(b64: string): ArrayBuffer {
|
||||
}
|
||||
|
||||
export const apiExam = {
|
||||
list: async (): Promise<{ data: ExamRoomItem[] }> => {
|
||||
const res = await staffFetch('/exam-rooms');
|
||||
list: async (opts?: { mine?: boolean }): Promise<{ data: ExamRoomItem[] }> => {
|
||||
const q = opts?.mine ? '?mine=1' : '';
|
||||
const res = await staffFetch(`/exam-rooms${q}`);
|
||||
if (!res.ok) await parseError(res, 'Failed');
|
||||
return res.json();
|
||||
},
|
||||
@@ -1007,6 +1010,7 @@ export const apiExam = {
|
||||
canPublish: boolean;
|
||||
canUnpublish: boolean;
|
||||
canCancel: boolean;
|
||||
canExtend?: boolean;
|
||||
}>;
|
||||
},
|
||||
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');
|
||||
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 }>) => {
|
||||
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');
|
||||
|
||||
@@ -9,6 +9,48 @@ interface AppPoolModalProps {
|
||||
allowedApps: string;
|
||||
}
|
||||
|
||||
type IconProps = { size?: number };
|
||||
|
||||
const IconBox = ({ size = 20 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
|
||||
<path d="M3.27 6.96 12 12.01l8.73-5.05M12 22.08V12" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconSearch = ({ size = 15 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconRefresh = ({ size = 15 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M21 12a9 9 0 1 1-2.6-6.3" />
|
||||
<polyline points="21 3 21 9 15 9" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconPlus = ({ size = 13 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconCheck = ({ size = 12 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconInbox = ({ size = 32 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<polyline points="22 12 16 12 14 15 10 15 8 12 2 12" />
|
||||
<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const formatWhen = (iso: string) => {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
@@ -80,44 +122,63 @@ export const AppPoolModal: React.FC<AppPoolModalProps> = ({
|
||||
|
||||
return (
|
||||
<div className="modal-overlay app-pool-overlay" onClick={onClose}>
|
||||
<div className="modal-container app-pool-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<div>
|
||||
<h2 className="modal-title" style={{ margin: 0 }}>Kho ứng dụng</h2>
|
||||
<div className="modal-container app-pool-modal app-picker-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header app-picker-header">
|
||||
<div className="app-picker-header-text">
|
||||
<h2 className="modal-title app-picker-title">
|
||||
<span className="app-picker-title-icon" aria-hidden>
|
||||
<IconBox />
|
||||
</span>
|
||||
Kho ứng dụng
|
||||
</h2>
|
||||
<p className="app-pool-modal-sub">
|
||||
Toàn hệ thống — app bị chặn từ mọi lớp. Chọn để thêm vào whitelist lớp hiện tại.
|
||||
Toàn hệ thống — app bị chặn từ mọi lớp. Chọn để thêm vào whitelist hiện tại.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" className="btn btn-secondary" onClick={onClose}>Đóng</button>
|
||||
<button type="button" className="btn btn-secondary app-picker-close" onClick={onClose}>
|
||||
Đóng
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="app-pool-toolbar">
|
||||
<input
|
||||
type="search"
|
||||
className="app-pool-search"
|
||||
placeholder="Tìm theo tên app, keyword, tiêu đề cửa sổ..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="app-picker-search-wrap">
|
||||
<span className="app-picker-search-icon" aria-hidden>
|
||||
<IconSearch />
|
||||
</span>
|
||||
<input
|
||||
type="search"
|
||||
className="app-pool-search"
|
||||
placeholder="Tìm theo tên app, keyword, tiêu đề cửa sổ..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
className="btn btn-secondary app-picker-refresh"
|
||||
onClick={() => loadPool(debouncedQ)}
|
||||
disabled={loading}
|
||||
>
|
||||
<IconRefresh />
|
||||
{loading ? 'Đang tải...' : 'Làm mới'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="app-pool-body">
|
||||
{error ? (
|
||||
<div className="discovered-apps-empty discovered-apps-error">{error}</div>
|
||||
<div className="app-picker-state app-picker-state--error">{error}</div>
|
||||
) : loading && items.length === 0 ? (
|
||||
<div className="app-pool-status">Đang tải...</div>
|
||||
<div className="app-picker-state">
|
||||
<div className="sync-spinner" style={{ width: 28, height: 28 }} />
|
||||
<p>Đang tải kho ứng dụng...</p>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="app-pool-status">
|
||||
{debouncedQ ? `Không tìm thấy "${debouncedQ}"` : 'Chưa có app nào trong kho.'}
|
||||
<div className="app-picker-state">
|
||||
<IconInbox />
|
||||
<p>
|
||||
{debouncedQ ? `Không tìm thấy “${debouncedQ}”` : 'Chưa có app nào trong kho.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="data-table app-pool-table">
|
||||
@@ -128,7 +189,7 @@ export const AppPoolModal: React.FC<AppPoolModalProps> = ({
|
||||
<th>Tiêu đề</th>
|
||||
<th>Lần chặn</th>
|
||||
<th>Gần nhất</th>
|
||||
<th></th>
|
||||
<th style={{ textAlign: 'right' }}>Thao tác</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -136,17 +197,33 @@ export const AppPoolModal: React.FC<AppPoolModalProps> = ({
|
||||
const added = allowedSet.has(app.keyword.toLowerCase());
|
||||
return (
|
||||
<tr key={app.id} className={added ? 'app-pool-row--added' : ''}>
|
||||
<td><code className="app-pool-kw">{app.keyword}</code></td>
|
||||
<td className="app-pool-muted">{app.processName}</td>
|
||||
<td className="app-pool-muted app-pool-title" title={app.windowTitle}>{app.windowTitle || '—'}</td>
|
||||
<td className="app-pool-hit">{app.hitCount}×</td>
|
||||
<td className="app-pool-muted">{formatWhen(app.lastSeenAt)}</td>
|
||||
<td>
|
||||
<code className="app-pool-kw">{app.keyword}</code>
|
||||
</td>
|
||||
<td className="app-pool-muted" title={app.processName}>
|
||||
{app.processName || '—'}
|
||||
</td>
|
||||
<td className="app-pool-muted app-pool-title" title={app.windowTitle}>
|
||||
{app.windowTitle || '—'}
|
||||
</td>
|
||||
<td>
|
||||
<span className="app-pool-hit">{app.hitCount}×</span>
|
||||
</td>
|
||||
<td className="app-pool-muted app-pool-when">{formatWhen(app.lastSeenAt)}</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
{added ? (
|
||||
<span className="app-pool-added-tag">Đã có</span>
|
||||
<span className="app-pool-added-tag">
|
||||
<IconCheck />
|
||||
Đã có
|
||||
</span>
|
||||
) : (
|
||||
<button type="button" className="btn btn-primary app-pool-add-btn" onClick={() => handleSelect(app.keyword)}>
|
||||
+ Thêm
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary app-pool-add-btn"
|
||||
onClick={() => handleSelect(app.keyword)}
|
||||
>
|
||||
<IconPlus />
|
||||
Thêm
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
@@ -159,7 +236,9 @@ export const AppPoolModal: React.FC<AppPoolModalProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="app-pool-footer">
|
||||
Hiển thị {items.length} kết quả{debouncedQ ? ` cho "${debouncedQ}"` : ''} (tối đa 80 mỗi lần tải)
|
||||
Hiển thị <strong>{items.length}</strong> kết quả
|
||||
{debouncedQ ? ` cho “${debouncedQ}”` : ''}
|
||||
{' '}(tối đa 80 mỗi lần tải)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,47 @@ interface AppTemplatePickerModalProps {
|
||||
allowedApps: string;
|
||||
}
|
||||
|
||||
type IconProps = { size?: number };
|
||||
|
||||
const IconLayers = ({ size = 20 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="m12 2 9 4.5-9 4.5L3 6.5 12 2z" />
|
||||
<path d="m3 12 9 4.5 9-4.5" />
|
||||
<path d="m3 17.5 9 4.5 9-4.5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconSearch = ({ size = 15 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconRefresh = ({ size = 15 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M21 12a9 9 0 1 1-2.6-6.3" />
|
||||
<polyline points="21 3 21 9 15 9" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconCheck = ({ size = 14 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconInbox = ({ size = 32 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<polyline points="22 12 16 12 14 15 10 15 8 12 2 12" />
|
||||
<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
function keywordList(csv: string): string[] {
|
||||
return csv.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
export const AppTemplatePickerModal: React.FC<AppTemplatePickerModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
@@ -60,70 +101,110 @@ export const AppTemplatePickerModal: React.FC<AppTemplatePickerModalProps> = ({
|
||||
|
||||
return (
|
||||
<div className="modal-overlay app-pool-overlay" onClick={onClose}>
|
||||
<div className="modal-container app-pool-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<div>
|
||||
<h2 className="modal-title" style={{ margin: 0 }}>Chọn khung ứng dụng</h2>
|
||||
<div className="modal-container app-pool-modal app-picker-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header app-picker-header">
|
||||
<div className="app-picker-header-text">
|
||||
<h2 className="modal-title app-picker-title">
|
||||
<span className="app-picker-title-icon" aria-hidden>
|
||||
<IconLayers />
|
||||
</span>
|
||||
Chọn khung ứng dụng
|
||||
</h2>
|
||||
<p className="app-pool-modal-sub">
|
||||
Ghép bộ keyword đã lưu vào danh sách ứng dụng được phép. Quản lý khung tại Hệ thống → Khung ứng dụng.
|
||||
Ghép bộ keyword đã lưu vào whitelist. Quản lý khung tại Hệ thống → Khung ứng dụng.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" className="btn btn-secondary" onClick={onClose}>
|
||||
<button type="button" className="btn btn-secondary app-picker-close" onClick={onClose}>
|
||||
Đóng
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="app-pool-toolbar">
|
||||
<input
|
||||
type="search"
|
||||
className="app-pool-search"
|
||||
placeholder="Tìm theo tên, mô tả, keyword..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<button type="button" className="btn btn-secondary" onClick={load} disabled={loading}>
|
||||
<div className="app-picker-search-wrap">
|
||||
<span className="app-picker-search-icon" aria-hidden>
|
||||
<IconSearch />
|
||||
</span>
|
||||
<input
|
||||
type="search"
|
||||
className="app-pool-search"
|
||||
placeholder="Tìm theo tên, mô tả, keyword..."
|
||||
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'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="app-pool-body">
|
||||
{error ? (
|
||||
<div className="discovered-apps-empty discovered-apps-error">{error}</div>
|
||||
<div className="app-picker-state app-picker-state--error">{error}</div>
|
||||
) : loading && items.length === 0 ? (
|
||||
<div className="app-pool-status">Đang tải...</div>
|
||||
<div className="app-picker-state">
|
||||
<div className="sync-spinner" style={{ width: 28, height: 28 }} />
|
||||
<p>Đang tải khung ứng dụng...</p>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="app-pool-status">
|
||||
{q ? `Không tìm thấy "${search}"` : 'Chưa có khung nào. Tạo tại Hệ thống → Khung ứng dụng.'}
|
||||
<div className="app-picker-state">
|
||||
<IconInbox />
|
||||
<p>
|
||||
{q
|
||||
? `Không tìm thấy “${search}”`
|
||||
: 'Chưa có khung nào. Tạo tại Hệ thống → Khung ứng dụng.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="template-picker-list">
|
||||
{filtered.map((tpl) => (
|
||||
<div key={tpl.id} className="template-picker-card">
|
||||
<div className="template-picker-card-head">
|
||||
<strong>{tpl.name}</strong>
|
||||
<span className="template-picker-count">{countKeywords(tpl.keywords)} keyword</span>
|
||||
</div>
|
||||
{tpl.description && <p className="template-picker-desc">{tpl.description}</p>}
|
||||
<code className="template-picker-kw">{tpl.keywords}</code>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm template-picker-apply"
|
||||
onClick={() => {
|
||||
onSelect(tpl.keywords);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
Áp dụng khung
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{filtered.map((tpl) => {
|
||||
const kws = keywordList(tpl.keywords);
|
||||
const count = countKeywords(tpl.keywords);
|
||||
return (
|
||||
<article key={tpl.id} className="template-picker-card">
|
||||
<div className="template-picker-card-main">
|
||||
<div className="template-picker-card-head">
|
||||
<strong className="template-picker-name">{tpl.name}</strong>
|
||||
<span className="template-picker-count">{count} keyword</span>
|
||||
</div>
|
||||
{tpl.description ? (
|
||||
<p className="template-picker-desc">{tpl.description}</p>
|
||||
) : null}
|
||||
<div className="template-picker-chips" title={tpl.keywords}>
|
||||
{kws.map((kw) => (
|
||||
<span key={kw} className="template-picker-chip">
|
||||
{kw}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<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 className="app-pool-footer">
|
||||
Đang có {countKeywords(allowedApps)} keyword trong whitelist hiện tại
|
||||
Whitelist hiện tại: <strong>{countKeywords(allowedApps)}</strong> keyword
|
||||
{filtered.length > 0 ? ` · ${filtered.length} khung` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,8 +14,101 @@ import {
|
||||
type LeaveRequestItem,
|
||||
} from '../api';
|
||||
|
||||
/* ─── Icon components ─────────────────────────────────────────────────────── */
|
||||
|
||||
type IconProps = { size?: number; className?: string };
|
||||
|
||||
const IconClipboard = ({ size = 18 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<rect x="8" y="2" width="8" height="4" rx="1" />
|
||||
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconRefresh = ({ size = 15 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M21 12a9 9 0 1 1-2.6-6.3" />
|
||||
<polyline points="21 3 21 9 15 9" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconChevronDown = ({ size = 15 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconChevronUp = ({ size = 15 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="m18 15-6-6-6 6" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconMail = ({ size = 15 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<rect x="2" y="4" width="20" height="16" rx="2" />
|
||||
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconUpload = ({ size = 15 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="17 8 12 3 7 8" />
|
||||
<line x1="12" y1="3" x2="12" y2="15" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconClose = ({ size = 14 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconInfo = ({ size = 15 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconCheck = ({ size = 14 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconXMark = ({ size = 14 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconImage = ({ size = 13 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
<polyline points="21 15 16 10 5 21" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconSync = ({ size = 15 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M4 12c0-4.42 3.58-8 8-8 2.21 0 4.21.9 5.66 2.34" />
|
||||
<polyline points="14 8 20 8 20 2" />
|
||||
<path d="M20 12c0 4.42-3.58 8-8 8-2.21 0-4.21-.9-5.66-2.34" />
|
||||
<polyline points="10 16 4 16 4 22" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/* ─── Helpers ────────────────────────────────────────────────────────────── */
|
||||
|
||||
interface AttendancePanelProps {
|
||||
classId: number;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
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 [date, setDate] = useState(today);
|
||||
const [period, setPeriod] = useState(1);
|
||||
@@ -46,11 +141,7 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
|
||||
|
||||
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||
const scrollTop = e.currentTarget.scrollTop;
|
||||
if (scrollTop > 20) {
|
||||
setIsCollapsed(true);
|
||||
} else if (scrollTop <= 5) {
|
||||
setIsCollapsed(false);
|
||||
}
|
||||
setIsCollapsed(scrollTop > 20);
|
||||
}, []);
|
||||
|
||||
const loadShifts = useCallback(async () => {
|
||||
@@ -60,9 +151,7 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
|
||||
if (res.data?.length && !res.data.find((s: any) => s.period === period)) {
|
||||
setPeriod(res.data[0].period || 1);
|
||||
}
|
||||
} catch {
|
||||
setShifts([]);
|
||||
}
|
||||
} catch { setShifts([]); }
|
||||
}, [classId, date, period]);
|
||||
|
||||
const loadAttendance = useCallback(async (isBackground: boolean | any = false) => {
|
||||
@@ -72,7 +161,7 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
|
||||
const res = await apiFetchAttendance(classId, date, period);
|
||||
setRows(res.data || []);
|
||||
setShiftInfo(res.shift || null);
|
||||
if (!isBg) setSelectedStudentRkIds([]); // Clear selection when data changes
|
||||
if (!isBg) setSelectedStudentRkIds([]);
|
||||
} catch (e: any) {
|
||||
alert(e.message || 'Không tải được điểm danh');
|
||||
} finally {
|
||||
@@ -80,26 +169,16 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
|
||||
}
|
||||
}, [classId, date, period]);
|
||||
|
||||
const currentShift = useMemo(() => {
|
||||
return shifts.find(s => s.period === period);
|
||||
}, [shifts, period]);
|
||||
|
||||
const currentShift = useMemo(() => shifts.find(s => s.period === period), [shifts, period]);
|
||||
const courseId = currentShift?.courseId;
|
||||
|
||||
const loadLeaveRequests = useCallback(async () => {
|
||||
if (!courseId) {
|
||||
setLeaveRequests([]);
|
||||
return;
|
||||
}
|
||||
if (!courseId) { setLeaveRequests([]); return; }
|
||||
try {
|
||||
setLoadingLeave(true);
|
||||
const res = await apiFetchLeaveRequests(classId, courseId, date);
|
||||
setLeaveRequests(res || []);
|
||||
} catch {
|
||||
setLeaveRequests([]);
|
||||
} finally {
|
||||
setLoadingLeave(false);
|
||||
}
|
||||
} catch { setLeaveRequests([]); } finally { setLoadingLeave(false); }
|
||||
}, [classId, courseId, date]);
|
||||
|
||||
useEffect(() => { loadShifts(); }, [loadShifts]);
|
||||
@@ -108,12 +187,7 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
|
||||
|
||||
const handleUpdateLeaveStatus = async (leaveId: number, status: string, studentRkId: number) => {
|
||||
try {
|
||||
await apiUpdateLeaveStatus(classId, leaveId, {
|
||||
status,
|
||||
studentRkId,
|
||||
date,
|
||||
period,
|
||||
});
|
||||
await apiUpdateLeaveStatus(classId, leaveId, { status, studentRkId, date, period });
|
||||
await loadAttendance();
|
||||
await loadLeaveRequests();
|
||||
} catch (e: any) {
|
||||
@@ -125,87 +199,61 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
if (!q) return rows;
|
||||
return rows.filter(row => {
|
||||
const haystack = [row.fullName, row.studentCode, row.email, row.statusLabel]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
const haystack = [row.fullName, row.studentCode, row.email, row.statusLabel].filter(Boolean).join(' ').toLowerCase();
|
||||
return haystack.includes(q);
|
||||
});
|
||||
}, [rows, searchQuery]);
|
||||
|
||||
const statusCounts = useMemo(() => {
|
||||
const counts: Record<number, number> = { 0: 0, 1: 0, 2: 0, 3: 0, 4: 0 };
|
||||
for (const row of rows) {
|
||||
if (counts[row.status] !== undefined) counts[row.status]++;
|
||||
}
|
||||
for (const row of rows) { if (counts[row.status] !== undefined) counts[row.status]++; }
|
||||
return counts;
|
||||
}, [rows]);
|
||||
|
||||
const toggleStudentSelection = useCallback((studentRkId: number) => {
|
||||
const toggleStudentSelection = (studentRkId: number) => {
|
||||
setSelectedStudentRkIds(prev =>
|
||||
prev.includes(studentRkId) ? prev.filter(id => id !== studentRkId) : [...prev, studentRkId]
|
||||
);
|
||||
}, []);
|
||||
};
|
||||
|
||||
const handleStatusChange = async (studentRkId: number, status: number) => {
|
||||
// Optimistic state update so the UI reacts instantly
|
||||
setRows(prevRows =>
|
||||
prevRows.map(row => {
|
||||
if (row.studentRkId === studentRkId) {
|
||||
const matchedOpt = ATTENDANCE_STATUS_OPTIONS.find(o => o.value === status);
|
||||
return {
|
||||
...row,
|
||||
status,
|
||||
statusLabel: matchedOpt ? matchedOpt.label : row.statusLabel,
|
||||
statusEditedByTeacher: true,
|
||||
};
|
||||
return { ...row, status, statusLabel: matchedOpt ? matchedOpt.label : row.statusLabel, statusEditedByTeacher: true };
|
||||
}
|
||||
return row;
|
||||
})
|
||||
);
|
||||
|
||||
try {
|
||||
await apiUpdateAttendanceStatus(classId, { date, period, studentRkId, status });
|
||||
await loadAttendance(true);
|
||||
} catch (e: any) {
|
||||
alert(e.message || 'Cập nhật trạng thái thất bại');
|
||||
await loadAttendance(); // Rollback on failure
|
||||
await loadAttendance();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkStatusChange = async (status: number) => {
|
||||
if (selectedStudentRkIds.length === 0) return;
|
||||
|
||||
// Optimistic state update for selected students
|
||||
setRows(prevRows =>
|
||||
prevRows.map(row => {
|
||||
if (selectedStudentRkIds.includes(row.studentRkId)) {
|
||||
const matchedOpt = ATTENDANCE_STATUS_OPTIONS.find(o => o.value === status);
|
||||
return {
|
||||
...row,
|
||||
status,
|
||||
statusLabel: matchedOpt ? matchedOpt.label : row.statusLabel,
|
||||
statusEditedByTeacher: true,
|
||||
};
|
||||
return { ...row, status, statusLabel: matchedOpt ? matchedOpt.label : row.statusLabel, statusEditedByTeacher: true };
|
||||
}
|
||||
return row;
|
||||
})
|
||||
);
|
||||
|
||||
const idsToUpdate = [...selectedStudentRkIds];
|
||||
setSelectedStudentRkIds([]);
|
||||
|
||||
try {
|
||||
await apiUpdateAttendanceBulkStatus(classId, {
|
||||
date,
|
||||
period,
|
||||
studentRkIds: idsToUpdate,
|
||||
status,
|
||||
});
|
||||
await apiUpdateAttendanceBulkStatus(classId, { date, period, studentRkIds: idsToUpdate, status });
|
||||
await loadAttendance(true);
|
||||
} catch (e: any) {
|
||||
alert(e.message || 'Cập nhật hàng loạt thất bại');
|
||||
await loadAttendance(); // Rollback
|
||||
await loadAttendance();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -222,179 +270,257 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
|
||||
await loadAttendance();
|
||||
} catch (e: any) {
|
||||
alert(e.message || 'Đẩy QLĐT thất bại');
|
||||
} finally {
|
||||
setPushing(false);
|
||||
}
|
||||
} finally { setPushing(false); }
|
||||
};
|
||||
|
||||
const qldtSynced = Boolean(shiftInfo?.pushedToQldtAt);
|
||||
const qldtDirty = Boolean(shiftInfo?.qldtDirty);
|
||||
|
||||
const hasPendingLeave = leaveRequests.some(r => r.status === 'Đang chờ');
|
||||
|
||||
return (
|
||||
<div className="attendance-panel">
|
||||
<div className="attendance-toolbar">
|
||||
<label className="attendance-field">
|
||||
<span>Ngày</span>
|
||||
<input
|
||||
type="date"
|
||||
className="search-input"
|
||||
style={{ padding: '0.5rem 0.75rem' }}
|
||||
value={date}
|
||||
onChange={e => setDate(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="attendance-field">
|
||||
<span>Ca học</span>
|
||||
<select className="select-filter" value={period} onChange={e => setPeriod(Number(e.target.value))}>
|
||||
{(shifts.length ? shifts : [{ period: 1 }, { period: 2 }, { period: 3 }, { period: 4 }]).map((s: any) => (
|
||||
<option key={s.period} value={s.period}>
|
||||
Ca {s.period}{s.startTime ? ` (${s.startTime}–${s.endTime})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="attendance-field attendance-field--grow">
|
||||
<span>Tìm sinh viên</span>
|
||||
<input
|
||||
type="search"
|
||||
className="app-pool-search attendance-search"
|
||||
placeholder="Mã SV, tên, email..."
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
title={isCollapsed ? 'Hiện đầy đủ thông tin chi tiết' : 'Ẩn bớt thông tin chi tiết để xem bảng to hơn'}
|
||||
>
|
||||
{isCollapsed ? '📂 Chi tiết' : '📁 Thu gọn'}
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => loadAttendance(false)} disabled={loading}>
|
||||
Tải lại
|
||||
</button>
|
||||
{courseId && (
|
||||
<div className="ap-panel">
|
||||
<style>{`
|
||||
.ap-panel { display: flex; flex-direction: column; height: 100%; gap: 0.65rem; }
|
||||
|
||||
/* Header */
|
||||
.ap-header { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem; }
|
||||
.ap-header-title { display: flex; align-items: center; gap: 0.5rem; margin: 0; font-size: 0.95rem; font-weight: 700; color: var(--text-primary); }
|
||||
.ap-header-title svg { color: var(--accent); flex-shrink: 0; }
|
||||
.ap-header-actions { display: flex; gap: 0.3rem; align-items: center; flex-wrap: wrap; }
|
||||
|
||||
/* Buttons */
|
||||
.ap-btn { display: inline-flex; align-items: center; gap: 0.3rem; padding: 0.25rem 0.6rem; font-size: 0.75rem; font-weight: 500; border-radius: var(--radius-sm); border: 1px solid var(--border-color); background: var(--bg-card); color: var(--text-primary); cursor: pointer; transition: background 0.15s, border-color 0.15s; line-height: 1.4; white-space: nowrap; }
|
||||
.ap-btn:hover:not(:disabled) { background: var(--bg-hover); border-color: var(--border-hover); }
|
||||
.ap-btn:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
.ap-btn--primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
.ap-btn--primary:hover:not(:disabled) { filter: brightness(1.08); background: var(--accent); }
|
||||
.ap-btn--close { padding: 0.22rem 0.45rem; }
|
||||
.ap-btn--leave-badge { background: var(--accent); color: #fff; font-size: 0.62rem; font-weight: 700; padding: 0.05rem 0.3rem; border-radius: 10px; line-height: 1.4; }
|
||||
.ap-btn--leave-badge.muted { background: var(--text-secondary); }
|
||||
|
||||
/* Collapsible toolbar area */
|
||||
.ap-collapsible { display: flex; flex-direction: column; gap: 0.5rem; overflow: hidden; transition: max-height 0.3s ease; }
|
||||
.ap-collapsible.collapsed { max-height: 0 !important; }
|
||||
|
||||
/* Toolbar */
|
||||
.ap-toolbar { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: flex-end; }
|
||||
.ap-field { display: flex; flex-direction: column; gap: 0.18rem; font-size: 0.71rem; font-weight: 600; color: var(--text-muted); }
|
||||
.ap-field--grow { flex: 1; }
|
||||
.ap-input { padding: 0.3rem 0.5rem; font-size: 0.78rem; border: 1px solid var(--border-color); border-radius: var(--radius-sm); background: var(--bg-card); color: var(--text-primary); }
|
||||
.ap-input:focus { outline: none; border-color: var(--accent); }
|
||||
|
||||
/* QLĐT sync banner */
|
||||
.ap-qldt-banner { padding: 0.45rem 0.7rem; border-radius: var(--radius-sm); background: var(--bg-subtle); border: 1px solid var(--border-color); display: flex; flex-direction: column; gap: 0.1rem; font-size: 0.77rem; }
|
||||
.ap-qldt-banner--synced { background: rgba(46,125,50,0.06); border-color: rgba(46,125,50,0.3); color: #2e7d32; }
|
||||
.ap-qldt-banner--stale { background: rgba(180,83,9,0.06); border-color: rgba(180,83,9,0.35); color: #92400e; }
|
||||
|
||||
/* Shift info strip */
|
||||
.ap-shift-info { display: flex; flex-wrap: wrap; gap: 0.5rem; font-size: 0.77rem; color: var(--text-secondary); padding: 0.3rem 0.6rem; background: var(--bg-subtle); border-radius: var(--radius-sm); border: 1px solid var(--border-color); }
|
||||
|
||||
/* Status chips */
|
||||
.ap-chips { display: flex; flex-wrap: wrap; gap: 0.22rem; align-items: center; }
|
||||
.ap-chip { border: 1px solid transparent; border-radius: 99px; padding: 0.14rem 0.4rem; font-size: 0.68rem; font-weight: 700; cursor: default; background: transparent; display: inline-flex; align-items: center; gap: 0.18rem; }
|
||||
.ap-chip-count { min-width: 1rem; text-align: center; padding: 0.04rem 0.18rem; border-radius: 99px; background: rgba(0,0,0,0.12); }
|
||||
.ap-chips-total { margin-left: auto; font-size: 0.71rem; color: var(--text-muted); font-weight: 600; }
|
||||
|
||||
/* Notice banner */
|
||||
.ap-notice { padding: 0.55rem 0.75rem; border: 1px solid var(--border-color); border-left: 3px solid var(--accent); border-radius: var(--radius-sm); background: var(--bg-subtle); font-size: 0.75rem; color: var(--text-secondary); line-height: 1.5; display: flex; align-items: flex-start; gap: 0.45rem; }
|
||||
.ap-notice svg { color: var(--accent); flex-shrink: 0; margin-top: 0.05rem; }
|
||||
.ap-notice--amber { border-left-color: #d97706; background: rgba(253,230,138,0.18); color: #78350f; }
|
||||
.ap-notice--amber svg { color: #d97706; }
|
||||
|
||||
/* Bulk actions bar */
|
||||
.ap-bulk { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0.75rem; background: var(--bg-card); border: 1px solid var(--border-color); border-radius: var(--radius-sm); flex-wrap: wrap; }
|
||||
.ap-bulk-title { font-weight: 600; font-size: 0.82rem; }
|
||||
.ap-bulk-btns { display: flex; gap: 0.2rem; flex-wrap: wrap; }
|
||||
|
||||
/* Table */
|
||||
.ap-table-scroll { flex: 1; overflow: auto; border: 1px solid var(--border-color); border-radius: var(--radius-sm); }
|
||||
.ap-table { width: 100%; border-collapse: collapse; }
|
||||
.ap-table thead th { padding: 0.5rem 0.8rem; background: var(--bg-subtle); font-weight: 700; font-size: 0.68rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.04em; border-bottom: 1px solid var(--border-color); white-space: nowrap; }
|
||||
.ap-table thead th:first-child { width: 40px; text-align: center; padding: 0.5rem 0.5rem; }
|
||||
.ap-table tbody tr { border-bottom: 1px solid var(--border-light); transition: background 0.1s; }
|
||||
.ap-table tbody tr:last-child { border-bottom: none; }
|
||||
.ap-table tbody td { padding: 0.42rem 0.8rem; font-size: 0.82rem; vertical-align: middle; }
|
||||
.ap-table tbody td:first-child { text-align: center; padding: 0.42rem 0.5rem; }
|
||||
|
||||
/* QLĐT inline tags */
|
||||
.ap-tag { font-size: 0.62rem; font-weight: 700; padding: 0.1rem 0.3rem; border-radius: 3px; }
|
||||
.ap-tag--ok { background: rgba(46,125,50,0.1); color: #2e7d32; border: 1px solid rgba(46,125,50,0.3); }
|
||||
.ap-tag--pending { background: var(--bg-subtle); color: var(--text-muted); border: 1px solid var(--border-color); }
|
||||
.ap-tag--locked { background: rgba(21,101,192,0.08); color: #1565c0; border: 1px solid rgba(21,101,192,0.25); }
|
||||
|
||||
/* Leave modal notice */
|
||||
.ap-leave-notice { padding: 0.55rem 0.75rem; border: 1px solid #bae6fd; border-left: 3px solid #0ea5e9; border-radius: var(--radius-sm); background: rgba(224,242,254,0.5); font-size: 0.76rem; color: #0369a1; line-height: 1.5; display: flex; align-items: flex-start; gap: 0.45rem; }
|
||||
.ap-leave-notice svg { color: #0ea5e9; flex-shrink: 0; margin-top: 0.05rem; }
|
||||
.ap-leave-card { display: flex; flex-direction: column; gap: 8px; padding: 0.75rem; border: 1px solid var(--border-color); border-radius: var(--radius-sm); background: var(--bg-card); }
|
||||
.ap-leave-card-reason { font-size: 0.77rem; color: var(--text-primary); background: var(--bg-subtle); padding: 0.4rem 0.65rem; border-radius: var(--radius-sm); border-left: 3px solid #cbd5e1; line-height: 1.45; }
|
||||
.ap-leave-status { font-size: 0.69rem; font-weight: 600; padding: 0.1rem 0.4rem; border-radius: 4px; }
|
||||
.ap-leave-status--pending { background: #fef3c7; color: #b45309; }
|
||||
.ap-leave-status--approved { background: #d1fae5; color: #065f46; }
|
||||
.ap-leave-status--rejected { background: #fee2e2; color: #991b1b; }
|
||||
|
||||
/* Empty & loading states */
|
||||
.ap-empty { min-height: 200px; display: flex; align-items: center; justify-content: center; color: var(--text-muted); font-size: 0.85rem; }
|
||||
`}</style>
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div className="ap-header">
|
||||
<h3 className="ap-header-title">
|
||||
<IconClipboard size={18} />
|
||||
Điểm danh ca học
|
||||
</h3>
|
||||
<div className="ap-header-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => setShowLeaveModal(true)}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '6px' }}
|
||||
className="ap-btn"
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
>
|
||||
✉️ Đơn phép
|
||||
{leaveRequests.length > 0 && (
|
||||
<span
|
||||
className="badge"
|
||||
style={{
|
||||
background: leaveRequests.some(r => r.status === 'Đang chờ') ? 'var(--accent)' : 'var(--text-secondary)',
|
||||
color: '#fff',
|
||||
fontSize: '0.72rem',
|
||||
padding: '0.1rem 0.35rem',
|
||||
borderRadius: '10px',
|
||||
fontWeight: 700
|
||||
}}
|
||||
>
|
||||
{leaveRequests.length}
|
||||
</span>
|
||||
)}
|
||||
{isCollapsed ? <><IconChevronDown size={14} /> Chi tiết</> : <><IconChevronUp size={14} /> Thu gọn</>}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="btn btn-primary" onClick={handlePushQLDT} disabled={pushing || rows.length === 0}>
|
||||
{pushing ? 'Đang tải...' : 'Đẩy QLĐT'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={`attendance-collapsible-wrapper ${isCollapsed ? 'collapsed' : ''}`}>
|
||||
<div
|
||||
className={`attendance-qldt-banner${
|
||||
qldtSynced ? (qldtDirty ? ' attendance-qldt-banner--stale' : ' attendance-qldt-banner--synced') : ''
|
||||
}`}
|
||||
>
|
||||
{qldtSynced ? (
|
||||
qldtDirty ? (
|
||||
<>
|
||||
<strong>Đã lưu QLĐT — có thay đổi mới</strong>
|
||||
<span>
|
||||
Lần đẩy: {formatQldtTime(shiftInfo?.pushedToQldtAt)} · Cần đẩy lại sau khi chỉnh sửa
|
||||
<button
|
||||
type="button"
|
||||
className="ap-btn"
|
||||
onClick={() => loadAttendance(false)}
|
||||
disabled={loading}
|
||||
>
|
||||
<IconRefresh size={14} />
|
||||
Tải lại
|
||||
</button>
|
||||
{courseId && (
|
||||
<button
|
||||
type="button"
|
||||
className="ap-btn"
|
||||
onClick={() => setShowLeaveModal(true)}
|
||||
>
|
||||
<IconMail size={14} />
|
||||
Đơn phép
|
||||
{leaveRequests.length > 0 && (
|
||||
<span className={`ap-btn--leave-badge${hasPendingLeave ? '' : ' muted'}`}>
|
||||
{leaveRequests.length}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<strong>Đã lưu QLĐT</strong>
|
||||
<span>Lần đẩy gần nhất: {formatQldtTime(shiftInfo?.pushedToQldtAt)}</span>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<strong>Chưa đẩy lên QLĐT</strong>
|
||||
<span>Ca {period} ngày {date} — bấm "Đẩy QLĐT" sau khi kiểm tra trạng thái</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="ap-btn ap-btn--primary"
|
||||
onClick={handlePushQLDT}
|
||||
disabled={pushing || rows.length === 0}
|
||||
>
|
||||
<IconUpload size={14} />
|
||||
{pushing ? 'Đang tải...' : 'Đẩy QLĐT'}
|
||||
</button>
|
||||
{onClose && (
|
||||
<button type="button" className="ap-btn ap-btn--close" onClick={onClose} title="Đóng">
|
||||
<IconClose size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Collapsible toolbar area ── */}
|
||||
<div className={`ap-collapsible${isCollapsed ? ' collapsed' : ''}`}>
|
||||
{/* Toolbar: date / shift / search */}
|
||||
<div className="ap-toolbar">
|
||||
<label className="ap-field">
|
||||
<span>Ngày</span>
|
||||
<input type="date" className="ap-input search-input" value={date} onChange={e => setDate(e.target.value)} />
|
||||
</label>
|
||||
<label className="ap-field">
|
||||
<span>Ca học</span>
|
||||
<select className="ap-input select-filter" value={period} onChange={e => setPeriod(Number(e.target.value))}>
|
||||
{(shifts.length ? shifts : [{ period: 1 }, { period: 2 }, { period: 3 }, { period: 4 }]).map((s: any) => (
|
||||
<option key={s.period} value={s.period}>Ca {s.period}{s.startTime ? ` (${s.startTime}–${s.endTime})` : ''}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="ap-field ap-field--grow">
|
||||
<span>Tìm sinh viên</span>
|
||||
<input type="search" className="ap-input app-pool-search attendance-search" placeholder="Mã SV, tên, email..." value={searchQuery} onChange={e => setSearchQuery(e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* QLĐT sync status banner */}
|
||||
<div className={`ap-qldt-banner${qldtSynced ? (qldtDirty ? ' ap-qldt-banner--stale' : ' ap-qldt-banner--synced') : ''}`}>
|
||||
{qldtSynced
|
||||
? qldtDirty
|
||||
? <><strong>Đã lưu QLĐT — có thay đổi mới</strong><span>Lần đẩy: {formatQldtTime(shiftInfo?.pushedToQldtAt)} · Cần đẩy lại</span></>
|
||||
: <><strong>Đã lưu QLĐT</strong><span>Lần đẩy: {formatQldtTime(shiftInfo?.pushedToQldtAt)}</span></>
|
||||
: <><strong>Chưa đẩy lên QLĐT</strong><span>Ca {period} ngày {date} — bấm "Đẩy QLĐT" sau khi kiểm tra</span></>
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* Shift info strip */}
|
||||
{currentShift && (
|
||||
<div className="attendance-shift-info">
|
||||
<div className="ap-shift-info">
|
||||
<span>{currentShift.startTime}–{currentShift.endTime}</span>
|
||||
<span>{currentShift.courseName || 'Chưa chọn môn'}</span>
|
||||
{!currentShift.isActive && <span className="badge badge-muted">Ca tắt</span>}
|
||||
{!currentShift.isActive && <span className="badge badge-muted" style={{ fontSize: '0.65rem' }}>Ca tắt</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="attendance-status-summary">
|
||||
{/* Status summary chips */}
|
||||
<div className="ap-chips">
|
||||
{ATTENDANCE_STATUS_OPTIONS.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
className={`attendance-summary-chip ${attendanceStatusClass(opt.value)}`}
|
||||
onClick={() => setSearchQuery('')}
|
||||
title={`${opt.label}: ${statusCounts[opt.value] ?? 0} sinh viên`}
|
||||
>
|
||||
<span className="attendance-summary-chip-label">{opt.short}</span>
|
||||
<span className="attendance-summary-chip-count">{statusCounts[opt.value] ?? 0}</span>
|
||||
<button key={opt.value} type="button" className={`ap-chip attendance-summary-chip ${attendanceStatusClass(opt.value)}`} onClick={() => setSearchQuery('')}>
|
||||
<span>{opt.short}</span>
|
||||
<span className="ap-chip-count">{statusCounts[opt.value] ?? 0}</span>
|
||||
</button>
|
||||
))}
|
||||
<span className="attendance-summary-total">
|
||||
{filteredRows.length}/{rows.length} SV
|
||||
{searchQuery.trim() ? ' (đã lọc)' : ''}
|
||||
</span>
|
||||
<span className="ap-chips-total">{filteredRows.length}/{rows.length} SV{searchQuery.trim() ? ' (đã lọc)' : ''}</span>
|
||||
</div>
|
||||
|
||||
<p className="schedule-hint" style={{ margin: '0 0 4px 0' }}>
|
||||
<p className="schedule-hint" style={{ margin: '0 0 2px 0', fontSize: '0.71rem', color: 'var(--text-muted)' }}>
|
||||
Sửa trạng thái thủ công sẽ được khóa — hệ thống tự tính sẽ không ghi đè.
|
||||
</p>
|
||||
|
||||
<div style={{ padding: '0.65rem 0.85rem', background: '#fef3c7', border: '1px solid #fcd34d', borderRadius: '4px', color: '#92400e', fontSize: '0.82rem', display: 'flex', alignItems: 'center', gap: '0.35rem', margin: '4px 0 4px 0', lineHeight: '1.4' }}>
|
||||
💡 <strong>Lưu ý:</strong> Hệ thống hiện tại chỉ ghi nhận điểm danh học tập nội bộ và <strong>KHÔNG tự động lưu lên QLĐT</strong>. Thầy cô vui lòng kiểm tra kỹ trạng thái của sinh viên, sau đó bấm nút <strong>Đẩy QLĐT</strong> ở góc phải bên trên để đồng bộ điểm danh chính thức.
|
||||
{/* Notice banner — replaces yellow emoji block */}
|
||||
<div className="ap-notice ap-notice--amber">
|
||||
<IconInfo size={15} />
|
||||
<span>
|
||||
<strong>Lưu ý:</strong> Hệ thống hiện tại chỉ ghi nhận điểm danh học tập nội bộ và{' '}
|
||||
<strong>KHÔNG tự động lưu lên QLĐT</strong>. Thầy cô vui lòng kiểm tra kỹ trạng thái của sinh viên,
|
||||
sau đó bấm nút <strong>Đẩy QLĐT</strong> ở góc phải bên trên để đồng bộ điểm danh chính thức.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Leave Requests Approval Modal */}
|
||||
{/* ── Leave Requests Modal ── */}
|
||||
{showLeaveModal && courseId && (
|
||||
<div className="modal-overlay" 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-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<h3 className="modal-title" style={{ margin: 0 }}>✉️ Đơn xin nghỉ phép trong ca</h3>
|
||||
<p style={{ margin: '4px 0 0 0', color: 'var(--text-secondary)', fontSize: '0.8rem' }}>
|
||||
Ca {period} ngày {date} - Giao diện duyệt đơn xin nghỉ từ cổng QLĐT portal.
|
||||
</p>
|
||||
<h3 className="modal-title" style={{ margin: 0, display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
||||
<IconMail size={17} />
|
||||
Đơn xin nghỉ phép
|
||||
</h3>
|
||||
<p style={{ margin: '4px 0 0 0', color: 'var(--text-secondary)', fontSize: '0.8rem' }}>Ca {period} ngày {date}</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={loadLeaveRequests}
|
||||
disabled={loadingLeave}
|
||||
style={{ padding: '0.3rem 0.6rem', fontSize: '0.78rem' }}
|
||||
>
|
||||
{loadingLeave ? 'Đang tải...' : '🔄 Làm mới'}
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setShowLeaveModal(false)}>
|
||||
Đóng
|
||||
<button type="button" className="ap-btn" onClick={loadLeaveRequests} disabled={loadingLeave}>
|
||||
<IconRefresh size={14} />
|
||||
{loadingLeave ? 'Đang tải...' : 'Làm mới'}
|
||||
</button>
|
||||
<button type="button" className="ap-btn" onClick={() => setShowLeaveModal(false)}>Đóng</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-body" style={{ padding: '1rem', overflowY: 'auto', maxHeight: '60vh', display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
{/* Info notice */}
|
||||
<div className="ap-leave-notice">
|
||||
<IconInfo size={15} />
|
||||
<div>
|
||||
<strong>Lưu ý duyệt đơn phép:</strong> Thao tác duyệt/từ chối đơn tại đây chỉ được ghi nhận{' '}
|
||||
<strong>nội bộ trên hệ thống Simple Care</strong> (không đồng bộ ngược lên QLĐT).
|
||||
<div style={{ marginTop: '0.25rem' }}>
|
||||
• Khi duyệt đơn, hệ thống sẽ tự động đổi trạng thái điểm danh thành <strong>"Nghỉ có phép"</strong>.<br />
|
||||
• Trạng thái này sẽ được cập nhật lên QLĐT khi bấm nút <strong>"Đẩy QLĐT"</strong>.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loadingLeave ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '2rem 0' }}>
|
||||
<div className="sync-spinner" style={{ width: 28, height: 28 }} />
|
||||
@@ -405,81 +531,43 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
|
||||
</div>
|
||||
) : (
|
||||
leaveRequests.map((req) => (
|
||||
<div
|
||||
key={req.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
padding: '0.85rem',
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: '#ffffff',
|
||||
boxShadow: 'var(--shadow-sm)'
|
||||
}}
|
||||
>
|
||||
<div key={req.id} className="ap-leave-card">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: '8px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<div style={{ fontWeight: 600, fontSize: '0.9rem' }}>{req.student.fullName}</div>
|
||||
<code style={{ fontSize: '0.8rem', color: 'var(--text-secondary)' }}>{req.student.studentCode}</code>
|
||||
<span
|
||||
className="badge"
|
||||
style={{
|
||||
fontSize: '0.72rem',
|
||||
fontWeight: 600,
|
||||
backgroundColor: req.status === 'Đang chờ' ? '#fef3c7' : req.status === 'Phê duyệt' ? '#d1fae5' : '#fee2e2',
|
||||
color: req.status === 'Đang chờ' ? '#b45309' : req.status === 'Phê duyệt' ? '#065f46' : '#991b1b',
|
||||
padding: '0.15rem 0.45rem',
|
||||
borderRadius: '4px'
|
||||
}}
|
||||
>
|
||||
<span className={`ap-leave-status ${req.status === 'Đang chờ' ? 'ap-leave-status--pending' : req.status === 'Phê duyệt' ? 'ap-leave-status--approved' : 'ap-leave-status--rejected'}`}>
|
||||
{req.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{req.status === 'Đang chờ' && (
|
||||
<div style={{ display: 'flex', gap: '6px' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
style={{ padding: '0.25rem 0.65rem', fontSize: '0.78rem', backgroundColor: 'var(--success)', borderColor: 'var(--success)' }}
|
||||
onClick={() => {
|
||||
if (confirm(`Phê duyệt đơn xin nghỉ phép của ${req.student.fullName}? Trạng thái điểm danh ca sẽ chuyển thành Nghỉ có phép.`)) {
|
||||
handleUpdateLeaveStatus(req.id, 'Phê duyệt', req.student.id);
|
||||
}
|
||||
}}
|
||||
className="ap-btn ap-btn--primary"
|
||||
style={{ background: 'var(--success)', borderColor: 'var(--success)' }}
|
||||
onClick={() => { if (confirm(`Phê duyệt đơn xin nghỉ phép của ${req.student.fullName}?`)) handleUpdateLeaveStatus(req.id, 'Phê duyệt', req.student.id); }}
|
||||
>
|
||||
✓ Phê duyệt
|
||||
<IconCheck size={13} /> Phê duyệt
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm text-danger"
|
||||
style={{ padding: '0.25rem 0.65rem', fontSize: '0.78rem', borderColor: '#fca5a5' }}
|
||||
onClick={() => {
|
||||
if (confirm(`Từ chối đơn xin nghỉ phép của ${req.student.fullName}?`)) {
|
||||
handleUpdateLeaveStatus(req.id, 'Từ chối', req.student.id);
|
||||
}
|
||||
}}
|
||||
className="ap-btn"
|
||||
style={{ borderColor: '#fca5a5' }}
|
||||
onClick={() => { if (confirm(`Từ chối đơn xin nghỉ phép của ${req.student.fullName}?`)) handleUpdateLeaveStatus(req.id, 'Từ chối', req.student.id); }}
|
||||
>
|
||||
✗ Từ chối
|
||||
<IconXMark size={13} /> Từ chối
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: '0.82rem', color: 'var(--text-primary)', background: '#f9fafb', padding: '8px 10px', borderRadius: '4px', borderLeft: '4px solid #cbd5e1', lineHeight: '1.4' }}>
|
||||
<div className="ap-leave-card-reason">
|
||||
<strong>Lý do nghỉ:</strong> {req.note || 'Không có ghi chú'}
|
||||
</div>
|
||||
|
||||
{req.reasonImage && (
|
||||
<div style={{ marginTop: '4px' }}>
|
||||
<a
|
||||
href={req.reasonImage}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ fontSize: '0.75rem', color: 'var(--accent)', textDecoration: 'underline', display: 'inline-flex', alignItems: 'center', gap: '4px', fontWeight: 500 }}
|
||||
>
|
||||
🖼️ Xem ảnh minh chứng phép
|
||||
<div>
|
||||
<a href={req.reasonImage} target="_blank" rel="noopener noreferrer" style={{ fontSize: '0.72rem', color: 'var(--accent)', textDecoration: 'underline', display: 'inline-flex', alignItems: 'center', gap: '4px' }}>
|
||||
<IconImage size={13} /> Xem ảnh minh chứng
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
@@ -491,43 +579,34 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Bulk status actions bar ── */}
|
||||
{selectedStudentRkIds.length > 0 && (
|
||||
<div className="attendance-bulk-actions">
|
||||
<span className="attendance-bulk-title">
|
||||
Đang chọn {selectedStudentRkIds.length} sinh viên:
|
||||
</span>
|
||||
<div className="attendance-bulk-btns">
|
||||
<div className="ap-bulk">
|
||||
<span className="ap-bulk-title">Đang chọn {selectedStudentRkIds.length} SV:</span>
|
||||
<div className="ap-bulk-btns">
|
||||
{ATTENDANCE_STATUS_OPTIONS.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
className={`btn btn-secondary ${attendanceStatusClass(opt.value)}`}
|
||||
style={{ padding: '0.25rem 0.75rem', fontSize: '0.875rem', border: '1px solid var(--att-border)' }}
|
||||
onClick={() => handleBulkStatusChange(opt.value)}
|
||||
>
|
||||
Gắn "{opt.label}"
|
||||
<button key={opt.value} type="button" className={`ap-btn ${attendanceStatusClass(opt.value)}`} style={{ border: '1px solid var(--att-border)' }} onClick={() => handleBulkStatusChange(opt.value)}>
|
||||
Gắn "{opt.label}"
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-muted attendance-bulk-btn-close"
|
||||
style={{ padding: '0.25rem 0.75rem', fontSize: '0.875rem' }}
|
||||
onClick={() => setSelectedStudentRkIds([])}
|
||||
>
|
||||
<button type="button" className="ap-btn" style={{ marginLeft: 'auto' }} onClick={() => setSelectedStudentRkIds([])}>
|
||||
Hủy chọn
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="attendance-table-scroll table-wrapper" onScroll={handleScroll}>
|
||||
{/* ── Attendance table ── */}
|
||||
<div className="ap-table-scroll attendance-table-scroll table-wrapper" onScroll={handleScroll}>
|
||||
{loading ? (
|
||||
<div className="empty-state"><div className="sync-spinner" style={{ width: 28, height: 28 }} /></div>
|
||||
<div className="ap-empty">
|
||||
<div className="sync-spinner" style={{ width: 28, height: 28 }} />
|
||||
</div>
|
||||
) : (
|
||||
<table className="data-table attendance-table">
|
||||
<table className="ap-table data-table attendance-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '45px', textAlign: 'center' }}>
|
||||
<th>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filteredRows.length > 0 && filteredRows.every(r => selectedStudentRkIds.includes(r.studentRkId))}
|
||||
@@ -552,7 +631,7 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
|
||||
<tbody>
|
||||
{filteredRows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} style={{ textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||
<td colSpan={5} style={{ textAlign: 'center', color: 'var(--text-muted)', padding: '1.5rem 0', fontStyle: 'italic', fontSize: '0.83rem' }}>
|
||||
{rows.length === 0 ? 'Chưa có dữ liệu điểm danh' : 'Không tìm thấy sinh viên phù hợp'}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -560,60 +639,38 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
|
||||
filteredRows.map(row => {
|
||||
const isSelected = selectedStudentRkIds.includes(row.studentRkId);
|
||||
return (
|
||||
<tr key={row.studentRkId} className={attendanceStatusClass(row.status)}>
|
||||
<td
|
||||
style={{ textAlign: 'center', cursor: 'pointer' }}
|
||||
onClick={() => toggleStudentSelection(row.studentRkId)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => {}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleStudentSelection(row.studentRkId);
|
||||
}}
|
||||
/>
|
||||
<tr key={row.studentRkId} className={attendanceStatusClass(row.status)} style={{ background: isSelected ? 'rgba(var(--accent-rgb, 99 102 241) / 0.05)' : undefined }}>
|
||||
<td>
|
||||
<input type="checkbox" checked={isSelected} onChange={() => toggleStudentSelection(row.studentRkId)} />
|
||||
</td>
|
||||
<td
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => toggleStudentSelection(row.studentRkId)}
|
||||
>
|
||||
<div className="attendance-student-name">{row.fullName}</div>
|
||||
<div className="attendance-student-meta">
|
||||
<td style={{ cursor: 'pointer' }} onClick={() => toggleStudentSelection(row.studentRkId)}>
|
||||
<div className="attendance-student-name" style={{ fontWeight: 700, fontSize: '0.85rem' }}>{row.fullName}</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem 0.5rem', fontSize: '0.7rem', color: 'var(--text-muted)', marginTop: '1px' }}>
|
||||
<code>{row.studentCode}</code>
|
||||
{row.email && <span>{row.email}</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className="attendance-online-mins">{row.onlineMinutes}</span>
|
||||
<span className="attendance-online-unit">phút</span>
|
||||
<span style={{ fontFamily: 'monospace', fontWeight: 800, fontSize: '0.95rem' }}>{row.onlineMinutes}</span>
|
||||
<span style={{ fontSize: '0.65rem', color: 'var(--text-muted)', marginLeft: '2px' }}>phút</span>
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
className={`attendance-status-select ${attendanceStatusClass(row.status)}`}
|
||||
style={{ padding: '0.2rem 0.4rem', fontSize: '0.72rem', fontWeight: 600, border: '1px solid var(--att-border)', background: 'var(--att-bg)', color: 'var(--att-fg)', borderRadius: '4px', cursor: 'pointer' }}
|
||||
value={row.status}
|
||||
onChange={e => handleStatusChange(row.studentRkId, Number(e.target.value))}
|
||||
>
|
||||
{ATTENDANCE_STATUS_OPTIONS.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
{ATTENDANCE_STATUS_OPTIONS.map(opt => (<option key={opt.value} value={opt.value}>{opt.label}</option>))}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<div className="attendance-notes">
|
||||
{row.pushedToQldtAt ? (
|
||||
<span className="attendance-qldt-tag attendance-qldt-tag--ok" title={row.pushedToQldtAt}>
|
||||
QLĐT ✓
|
||||
</span>
|
||||
) : (
|
||||
<span className="attendance-qldt-tag attendance-qldt-tag--pending">Chưa QLĐT</span>
|
||||
)}
|
||||
{row.statusEditedByTeacher && (
|
||||
<span className="attendance-lock-tag" title="Giáo viên đã sửa — không bị ghi đè">
|
||||
Đã khóa
|
||||
</span>
|
||||
)}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.2rem' }}>
|
||||
{row.pushedToQldtAt
|
||||
? <span className="ap-tag ap-tag--ok">QLĐT <IconSync size={10} /></span>
|
||||
: <span className="ap-tag ap-tag--pending">Chưa QLĐT</span>
|
||||
}
|
||||
{row.statusEditedByTeacher && <span className="ap-tag ap-tag--locked">Đã khóa</span>}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,86 @@
|
||||
import React, { useEffect, useState, useRef, useMemo } from 'react';
|
||||
import { getWsUrl, type ExamRoomStudent } from '../api';
|
||||
import { type ExamRoomStudent } from '../api';
|
||||
import { openStaffChat } from '../chatEvents';
|
||||
import { StudentStreamImage } from './StudentStreamImage';
|
||||
|
||||
/* ─── Icons ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
type IconProps = { size?: number };
|
||||
|
||||
const IconSearch = ({ size = 14 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconMaximize = ({ size = 14 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M8 3H5a2 2 0 0 0-2 2v3" />
|
||||
<path d="M21 8V5a2 2 0 0 0-2-2h-3" />
|
||||
<path d="M3 16v3a2 2 0 0 0 2 2h3" />
|
||||
<path d="M16 21h3a2 2 0 0 0 2-2v-3" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconMinimize = ({ size = 14 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M8 3v3a2 2 0 0 1-2 2H3" />
|
||||
<path d="M21 8h-3a2 2 0 0 1-2-2V3" />
|
||||
<path d="M3 16h3a2 2 0 0 1 2 2v3" />
|
||||
<path d="M16 21v-3a2 2 0 0 1 2-2h3" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconOffline = ({ size = 28 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" />
|
||||
<line x1="8" y1="21" x2="16" y2="21" />
|
||||
<line x1="12" y1="17" x2="12" y2="21" />
|
||||
<line x1="2" y1="3" x2="22" y2="21" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconMessage = ({ size = 13 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconEye = ({ size = 13 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconFile = ({ size = 12 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconClose = ({ size = 18 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconChevronLeft = ({ size = 14 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<polyline points="15 18 9 12 15 6" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconChevronRight = ({ size = 14 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/* ─── Component ──────────────────────────────────────────────────────────── */
|
||||
|
||||
interface ExamGridProctorProps {
|
||||
students: ExamRoomStudent[];
|
||||
@@ -13,8 +93,6 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
|
||||
onlineIds,
|
||||
onSelectStudent,
|
||||
}) => {
|
||||
const [screenFrames, setScreenFrames] = useState<Record<number, string>>({});
|
||||
const [webcamFrames, setWebcamFrames] = useState<Record<number, string>>({});
|
||||
const [gridCols, setGridCols] = useState<number>(3);
|
||||
const [showWebcamOverlay, setShowWebcamOverlay] = useState<boolean>(true);
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
@@ -24,33 +102,23 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setZoomedStudent(null);
|
||||
}
|
||||
if (e.key === 'Escape') setZoomedStudent(null);
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
|
||||
const studentIdsString = useMemo(
|
||||
() => students.map((s) => s.studentRkId).join(','),
|
||||
[students]
|
||||
);
|
||||
|
||||
const filteredStudents = useMemo(() => {
|
||||
return students.filter((s) => {
|
||||
const matchesSearch =
|
||||
s.fullName.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
s.studentCode.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const isOnline = onlineIds.includes(s.studentRkId);
|
||||
const matchesOnlineFilter = !onlyOnline || isOnline;
|
||||
return matchesSearch && matchesOnlineFilter;
|
||||
return matchesSearch && (!onlyOnline || isOnline);
|
||||
});
|
||||
}, [students, onlineIds, searchQuery, onlyOnline]);
|
||||
|
||||
@@ -66,165 +134,9 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
|
||||
}, [filteredStudents, pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentPage > totalPages) {
|
||||
setCurrentPage(totalPages);
|
||||
}
|
||||
if (currentPage > totalPages) setCurrentPage(totalPages);
|
||||
}, [totalPages, currentPage]);
|
||||
|
||||
const visibleOnlineIds = useMemo(() => {
|
||||
return pagedStudents
|
||||
.map((s) => s.studentRkId)
|
||||
.filter((id) => onlineIds.includes(id));
|
||||
}, [pagedStudents, onlineIds]);
|
||||
|
||||
const visibleOnlineIdsRef = useRef<number[]>(visibleOnlineIds);
|
||||
visibleOnlineIdsRef.current = visibleOnlineIds;
|
||||
|
||||
const onlineKey = useMemo(() => visibleOnlineIds.join(','), [visibleOnlineIds]);
|
||||
|
||||
const syncSubscriptions = (ws: WebSocket) => {
|
||||
if (ws.readyState !== WebSocket.OPEN) return;
|
||||
const currentVisibleIds = visibleOnlineIdsRef.current;
|
||||
const target = new Set(currentVisibleIds);
|
||||
const prev = subscribedRef.current;
|
||||
|
||||
prev.forEach((id) => {
|
||||
if (!target.has(id)) {
|
||||
ws.send(JSON.stringify({ event: 'teacher:unsubscribe', data: { studentId: id } }));
|
||||
prev.delete(id);
|
||||
}
|
||||
});
|
||||
currentVisibleIds.forEach((id) => {
|
||||
if (!prev.has(id)) {
|
||||
ws.send(JSON.stringify({ event: 'teacher:subscribe', data: { studentId: id } }));
|
||||
prev.add(id);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const ws = wsRef.current;
|
||||
if (ws) syncSubscriptions(ws);
|
||||
}, [onlineKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (students.length === 0) return;
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
const connect = () => {
|
||||
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]);
|
||||
|
||||
// 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 handleOpenChat = (s: ExamRoomStudent, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
openStaffChat({
|
||||
@@ -254,42 +166,34 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
|
||||
};
|
||||
|
||||
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-left">
|
||||
<div className="search-input-wrapper">
|
||||
<div className="gp-search">
|
||||
<span className="gp-search-icon"><IconSearch size={14} /></span>
|
||||
<input
|
||||
type="text"
|
||||
className="search-input"
|
||||
type="search"
|
||||
className="search-input gp-search-input"
|
||||
placeholder="Lọc sinh viên..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<span className="search-icon">🔍</span>
|
||||
</div>
|
||||
|
||||
<label className="checkbox-label" style={{ display: 'flex', alignItems: 'center', gap: '0.35rem', cursor: 'pointer', fontSize: '0.85rem', color: 'var(--text-primary)' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={onlyOnline}
|
||||
onChange={(e) => setOnlyOnline(e.target.checked)}
|
||||
/>
|
||||
<label className="gp-check">
|
||||
<input type="checkbox" checked={onlyOnline} onChange={(e) => setOnlyOnline(e.target.checked)} />
|
||||
<span>Chỉ hiện Online</span>
|
||||
</label>
|
||||
|
||||
<label className="checkbox-label" style={{ display: 'flex', alignItems: 'center', gap: '0.35rem', cursor: 'pointer', fontSize: '0.85rem', color: 'var(--text-primary)' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showWebcamOverlay}
|
||||
onChange={(e) => setShowWebcamOverlay(e.target.checked)}
|
||||
/>
|
||||
<label className="gp-check">
|
||||
<input type="checkbox" checked={showWebcamOverlay} onChange={(e) => setShowWebcamOverlay(e.target.checked)} />
|
||||
<span>Đè webcam góc màn hình</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid-proctor-toolbar-right">
|
||||
<div className="grid-cols-selector" style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
|
||||
<span style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>Xem tối đa:</span>
|
||||
<label className="gp-page-size">
|
||||
<span>Xem tối đa</span>
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => {
|
||||
@@ -297,79 +201,42 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
|
||||
setPageSize(val === 'all' ? 'all' : Number(val));
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
style={{
|
||||
fontSize: '0.75rem',
|
||||
padding: '0.2rem 0.4rem',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid var(--border-color)',
|
||||
backgroundColor: 'var(--bg-card)',
|
||||
color: 'var(--text-primary)',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<option value={12}>12 bạn</option>
|
||||
<option value={24}>24 bạn</option>
|
||||
<option value={48}>48 bạn</option>
|
||||
<option value="all">Tất cả</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="grid-cols-selector" role="group" aria-label="Số cột">
|
||||
<span className="gp-cols-label">Cột</span>
|
||||
{[2, 3, 4, 6].map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
className={gridCols === n ? 'active' : ''}
|
||||
onClick={() => setGridCols(n)}
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid-cols-selector">
|
||||
<span style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>Cột:</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-secondary btn-xs ${gridCols === 2 ? 'active' : ''}`}
|
||||
onClick={() => setGridCols(2)}
|
||||
>
|
||||
2
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-secondary btn-xs ${gridCols === 3 ? 'active' : ''}`}
|
||||
onClick={() => setGridCols(3)}
|
||||
>
|
||||
3
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-secondary btn-xs ${gridCols === 4 ? 'active' : ''}`}
|
||||
onClick={() => setGridCols(4)}
|
||||
>
|
||||
4
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-secondary btn-xs ${gridCols === 6 ? 'active' : ''}`}
|
||||
onClick={() => setGridCols(6)}
|
||||
>
|
||||
6
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={toggleFullscreen}
|
||||
>
|
||||
{isFullscreen ? '🖥️ Thu nhỏ' : '🖥️ Toàn màn hình'}
|
||||
<button type="button" className="btn btn-secondary btn-sm gp-fs-btn" onClick={toggleFullscreen}>
|
||||
{isFullscreen ? <IconMinimize size={13} /> : <IconMaximize size={13} />}
|
||||
{isFullscreen ? 'Thu nhỏ' : 'Toàn màn hình'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid-proctor-scroll">
|
||||
<div
|
||||
className={`grid-proctor-layout`}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${gridCols}, minmax(0, 1fr))`,
|
||||
gap: '1rem',
|
||||
padding: '1rem 0',
|
||||
}}
|
||||
className="grid-proctor-layout"
|
||||
style={{ gridTemplateColumns: `repeat(${gridCols}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{pagedStudents.map((s) => {
|
||||
const isOnline = onlineIds.includes(s.studentRkId);
|
||||
const screenFrame = screenFrames[s.studentRkId];
|
||||
const webcamFrame = webcamFrames[s.studentRkId];
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -380,9 +247,7 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
|
||||
<div className="grid-proctor-card-header">
|
||||
<div className="student-info-left">
|
||||
<span className={`status-dot ${isOnline ? 'online' : 'offline'}`} />
|
||||
<span className="student-name" title={s.fullName}>
|
||||
{s.fullName}
|
||||
</span>
|
||||
<span className="student-name" title={s.fullName}>{s.fullName}</span>
|
||||
</div>
|
||||
<span className="student-code">{s.studentCode}</span>
|
||||
</div>
|
||||
@@ -395,61 +260,54 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
|
||||
e.stopPropagation();
|
||||
setZoomedStudent(s);
|
||||
}}
|
||||
style={{ cursor: 'zoom-in' }}
|
||||
>
|
||||
{screenFrame ? (
|
||||
<img
|
||||
src={screenFrame}
|
||||
alt={`Màn hình ${s.fullName}`}
|
||||
className="proctor-screen-image"
|
||||
draggable={false}
|
||||
/>
|
||||
) : (
|
||||
<div className="proctor-placeholder streaming">
|
||||
<div className="sync-spinner" style={{ width: '20px', height: '20px', borderWidth: '2px', marginBottom: '0.5rem' }} />
|
||||
<span>Đang kết nối màn hình...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showWebcamOverlay && webcamFrame && (
|
||||
<StudentStreamImage
|
||||
studentId={s.studentRkId}
|
||||
kind="screen"
|
||||
className="proctor-screen-image"
|
||||
/>
|
||||
{showWebcamOverlay && (
|
||||
<div className="proctor-webcam-overlay">
|
||||
<img
|
||||
src={webcamFrame}
|
||||
alt={`Webcam ${s.fullName}`}
|
||||
<StudentStreamImage
|
||||
studentId={s.studentRkId}
|
||||
kind="webcam"
|
||||
className="proctor-webcam-image"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="proctor-placeholder offline">
|
||||
<span className="icon">📴</span>
|
||||
<span>Ngoại tuyến (Offline)</span>
|
||||
<span className="gp-offline-icon"><IconOffline size={26} /></span>
|
||||
<span className="gp-offline-label">Ngoại tuyến</span>
|
||||
<span className="gp-offline-sub">Offline</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid-proctor-card-footer" onClick={(e) => e.stopPropagation()}>
|
||||
<span className="assigned-paper" title={s.paperTitle || 'Chưa gán đề'}>
|
||||
{s.paperTitle ? `Đề: ${s.paperTitle}` : 'Chưa gán đề'}
|
||||
<IconFile size={12} />
|
||||
{s.paperTitle ? s.paperTitle : 'Chưa gán đề'}
|
||||
</span>
|
||||
<div className="footer-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-xs text-primary"
|
||||
className="gp-action-btn"
|
||||
onClick={(e) => handleOpenChat(s, e)}
|
||||
title="Nhắn tin cho sinh viên"
|
||||
>
|
||||
💬 Nhắn tin
|
||||
<IconMessage size={13} />
|
||||
Nhắn tin
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-xs"
|
||||
className="gp-action-btn gp-action-btn--primary"
|
||||
onClick={() => onSelectStudent(s)}
|
||||
title="Xem chi tiết giám sát"
|
||||
>
|
||||
🔍 Giám sát
|
||||
<IconEye size={13} />
|
||||
Giám sát
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -458,214 +316,77 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
|
||||
})}
|
||||
|
||||
{filteredStudents.length === 0 && (
|
||||
<div className="grid-proctor-empty" style={{ gridColumn: '1 / -1' }}>
|
||||
<span>🔍</span>
|
||||
<div className="grid-proctor-empty">
|
||||
<span className="gp-empty-icon"><IconSearch size={28} /></span>
|
||||
<p>Không tìm thấy sinh viên nào.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
{totalPages > 1 && (
|
||||
<div
|
||||
className="grid-proctor-pagination"
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: '1rem',
|
||||
padding: '1rem 0',
|
||||
borderTop: '1px solid var(--border-light)',
|
||||
marginTop: '1.25rem',
|
||||
}}
|
||||
>
|
||||
<div className="grid-proctor-pagination">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
className="btn btn-secondary btn-sm gp-page-btn"
|
||||
disabled={currentPage === 1}
|
||||
onClick={() => setCurrentPage((p) => Math.max(p - 1, 1))}
|
||||
style={{ fontWeight: 600 }}
|
||||
>
|
||||
← Trang trước
|
||||
<IconChevronLeft size={14} />
|
||||
Trang trước
|
||||
</button>
|
||||
<span style={{ fontSize: '0.85rem', color: 'var(--text-secondary)', fontWeight: 600 }}>
|
||||
Trang {currentPage} / {totalPages} (Tổng {filteredStudents.length} bạn)
|
||||
<span className="gp-page-info">
|
||||
Trang {currentPage} / {totalPages}
|
||||
<span className="gp-page-total"> · {filteredStudents.length} bạn</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
className="btn btn-secondary btn-sm gp-page-btn"
|
||||
disabled={currentPage === totalPages}
|
||||
onClick={() => setCurrentPage((p) => Math.min(p + 1, totalPages))}
|
||||
style={{ fontWeight: 600 }}
|
||||
>
|
||||
Trang sau →
|
||||
Trang sau
|
||||
<IconChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Zoom Modal Overlay (Rendered inside the container so it works in fullscreen mode) */}
|
||||
{zoomedStudent && (
|
||||
<div
|
||||
className="zoomed-proctor-overlay"
|
||||
onClick={() => setZoomedStudent(null)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: 'rgba(10, 15, 30, 0.9)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
zIndex: 9999,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '2rem',
|
||||
animation: 'fadeIn 0.2s ease-out',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="zoomed-proctor-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: '1000px',
|
||||
backgroundColor: 'var(--bg-card)',
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: '16px',
|
||||
boxShadow: '0 20px 40px rgba(0, 0, 0, 0.4)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
maxHeight: '90%',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Modal Header */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '1rem 1.5rem',
|
||||
borderBottom: '1px solid var(--border-color)',
|
||||
backgroundColor: 'var(--bg-subtle)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<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
|
||||
style={{
|
||||
width: '10px',
|
||||
height: '10px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: onlineIds.includes(zoomedStudent.studentRkId) ? 'var(--success)' : 'var(--danger)',
|
||||
boxShadow: onlineIds.includes(zoomedStudent.studentRkId) ? '0 0 8px var(--success)' : 'none',
|
||||
}}
|
||||
className={`status-dot ${onlineIds.includes(zoomedStudent.studentRkId) ? 'online' : 'offline'}`}
|
||||
/>
|
||||
<h3 style={{ margin: 0, color: 'var(--text-primary)', fontSize: '1.1rem', fontWeight: 600 }}>
|
||||
{zoomedStudent.fullName}
|
||||
</h3>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.85rem',
|
||||
color: 'var(--text-secondary)',
|
||||
backgroundColor: 'var(--bg-subtle)',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
>
|
||||
{zoomedStudent.studentCode}
|
||||
</span>
|
||||
<h3>{zoomedStudent.fullName}</h3>
|
||||
<span className="student-code">{zoomedStudent.studentCode}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setZoomedStudent(null)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'var(--text-secondary)',
|
||||
fontSize: '1.5rem',
|
||||
cursor: 'pointer',
|
||||
padding: '4px 8px',
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
×
|
||||
<button type="button" className="zoomed-proctor-close" onClick={() => setZoomedStudent(null)} aria-label="Đóng">
|
||||
<IconClose size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal Body */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '1.5rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#000',
|
||||
position: 'relative',
|
||||
minHeight: '400px',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{screenFrames[zoomedStudent.studentRkId] ? (
|
||||
<div style={{ position: 'relative', width: '100%', height: '100%', display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
|
||||
<img
|
||||
src={screenFrames[zoomedStudent.studentRkId]}
|
||||
alt="Zoomed Screen"
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '65vh',
|
||||
objectFit: 'contain',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
/>
|
||||
{showWebcamOverlay && webcamFrames[zoomedStudent.studentRkId] && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '20px',
|
||||
right: '20px',
|
||||
width: '240px',
|
||||
aspectRatio: '4/3',
|
||||
borderRadius: '8px',
|
||||
border: '2px solid #ffffff',
|
||||
boxShadow: '0 8px 16px rgba(0, 0, 0, 0.4)',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#000',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={webcamFrames[zoomedStudent.studentRkId]}
|
||||
alt="Zoomed Webcam"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ color: '#8e8e9e', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '12px' }}>
|
||||
<div className="sync-spinner" style={{ width: '32px', height: '32px', borderWidth: '3px', marginBottom: '0.5rem' }} />
|
||||
<span>Đang tải màn hình...</span>
|
||||
</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>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
.cursor-zoom-in {
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
.cursor-zoom-in:hover {
|
||||
transform: scale(1.015);
|
||||
box-shadow: 0 6px 16px rgba(0,0,0,0.3);
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -26,6 +26,101 @@ import { ViolationsPanel } from './ViolationsPanel';
|
||||
|
||||
const EXAM_APP_SUGGESTIONS = [...new Set([...BASE_APP_SUGGESTIONS, 'msedge', 'edge', 'acrobat', 'foxit'])];
|
||||
|
||||
/* ─── Icons (match ClassWorkspace) ───────────────────────────────────────── */
|
||||
type IconProps = { size?: number };
|
||||
|
||||
const IconExam = ({ size = 20 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="9" y1="13" x2="15" y2="13" />
|
||||
<line x1="9" y1="17" x2="13" y2="17" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconSettings = ({ size = 14 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconClose = ({ size = 16 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M18 6 6 18M6 6l12 12" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconMap = ({ size = 14 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<polygon points="3 6 9 3 15 6 21 3 21 18 15 21 9 18 3 21" />
|
||||
<line x1="9" y1="3" x2="9" y2="18" />
|
||||
<line x1="15" y1="6" x2="15" y2="21" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconMonitor = ({ size = 14 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" />
|
||||
<path d="M8 21h8M12 17v4" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconList = ({ size = 14 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<line x1="8" y1="6" x2="21" y2="6" />
|
||||
<line x1="8" y1="12" x2="21" y2="12" />
|
||||
<line x1="8" y1="18" x2="21" y2="18" />
|
||||
<line x1="3" y1="6" x2="3.01" y2="6" />
|
||||
<line x1="3" y1="12" x2="3.01" y2="12" />
|
||||
<line x1="3" y1="18" x2="3.01" y2="18" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconPackage = ({ size = 14 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
|
||||
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
|
||||
<line x1="12" y1="22.08" x2="12" y2="12" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconShield = ({ size = 14 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconSearch = ({ size = 14 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconUsers = ({ size = 13 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="3.5" />
|
||||
<path d="M22 21v-2a3.5 3.5 0 0 0-2.5-3.35" />
|
||||
<path d="M16 3.5a3.5 3.5 0 0 1 0 7" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconPlus = ({ size = 14 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconInbox = ({ size = 36 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<polyline points="22 12 16 12 14 15 10 15 8 12 2 12" />
|
||||
<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
interface Props {
|
||||
examId: number;
|
||||
onBack: () => void;
|
||||
@@ -70,6 +165,8 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
const [canPublish, setCanPublish] = useState(false);
|
||||
const [canUnpublish, setCanUnpublish] = useState(false);
|
||||
const [canCancel, setCanCancel] = useState(false);
|
||||
const [canExtend, setCanExtend] = useState(false);
|
||||
const [extending, setExtending] = useState(false);
|
||||
const [papers, setPapers] = useState<ExamPaper[]>([]);
|
||||
const [students, setStudents] = useState<ExamRoomStudent[]>([]);
|
||||
const [submissions, setSubmissions] = useState<ExamSubmission[]>([]);
|
||||
@@ -101,6 +198,7 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
const canEditApps = displayStatus === 'draft' || displayStatus === 'ready' || displayStatus === 'active';
|
||||
const anyPaperSent = students.some((s) => s.paperSentAt);
|
||||
const canModifyPapers = prepEditable || (!anyPaperSent && (displayStatus === 'draft' || displayStatus === 'ready' || displayStatus === 'active'));
|
||||
const canRemoveStudents = displayStatus !== 'ended' && displayStatus !== 'cancelled';
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -118,6 +216,7 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
setCanPublish(data.canPublish);
|
||||
setCanUnpublish(data.canUnpublish);
|
||||
setCanCancel(data.canCancel);
|
||||
setCanExtend(!!data.canExtend || data.displayStatus === 'active');
|
||||
setPapers(data.papers);
|
||||
setStudents(data.students);
|
||||
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) => {
|
||||
await apiExam.addStudents(examId, [studentRkId]);
|
||||
await load();
|
||||
@@ -575,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 term = searchQuery.toLowerCase().trim();
|
||||
if (!term) return true;
|
||||
@@ -627,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="workspace-header">
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<div className="brand-logo" style={{ width: 44, height: 44, fontSize: '1.1rem' }}>📝</div>
|
||||
<div>
|
||||
<h1 className="workspace-class-title">{roomName || 'Phòng thi'}</h1>
|
||||
<div className="class-badge-container">
|
||||
<span className={`badge ${badge.cls}`}>{badge.text}</span>
|
||||
{start && end && (
|
||||
<span className="badge badge-muted">
|
||||
{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>
|
||||
</div>
|
||||
<div className="workspace-header ew-header">
|
||||
<div className="ew-header-left">
|
||||
<div className="ew-brand-icon">
|
||||
<IconExam size={20} />
|
||||
</div>
|
||||
<div className="ew-title-block">
|
||||
<h1 className="workspace-class-title">{roomName || 'Phòng thi'}</h1>
|
||||
<div className="class-badge-container">
|
||||
<span className={`badge ${badge.cls}`}>{badge.text}</span>
|
||||
{start && end && (
|
||||
<span className="badge badge-muted">
|
||||
{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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1.25rem', flexWrap: 'wrap' }}>
|
||||
<div className="status-panel">
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<span style={{ fontSize: '0.72rem', color: 'var(--text-muted)', fontWeight: 600, textTransform: 'uppercase' }}>
|
||||
Trạng thái thi
|
||||
</span>
|
||||
<span style={{ fontSize: '0.875rem', fontWeight: 700, color: statusColor(displayStatus) }}>
|
||||
<div className="ew-header-right">
|
||||
<div className="status-panel ew-status-panel">
|
||||
<div className="ew-status-label-group">
|
||||
<span className="ew-meta-label">Trạng thái thi</span>
|
||||
<span className="ew-status-value" style={{ color: statusColor(displayStatus) }}>
|
||||
{badge.text}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
|
||||
<div className="ew-status-actions">
|
||||
{canPublish && (
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={handlePublish}>Đẩy phòng thi</button>
|
||||
)}
|
||||
@@ -667,21 +793,38 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
{canCancel && (
|
||||
<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 && (
|
||||
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setPapersModalOpen(true)}>
|
||||
📄 Xem đề
|
||||
<button type="button" className="btn btn-secondary btn-sm ew-icon-btn" onClick={() => setPapersModalOpen(true)}>
|
||||
<IconExam size={13} />
|
||||
Xem đề
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end' }}>
|
||||
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)', fontWeight: 600, textTransform: 'uppercase' }}>
|
||||
Online / Sĩ số
|
||||
</div>
|
||||
<div className="workspace-stat-value">
|
||||
<div className="ew-online-stat">
|
||||
<span className="ew-meta-label">Online / Sĩ số</span>
|
||||
<div className="workspace-stat-value ew-online-value">
|
||||
<IconUsers size={13} />
|
||||
<span style={{ color: 'var(--success)' }}>{onlineCount}</span>
|
||||
<span style={{ color: 'var(--text-muted)', fontSize: '0.9rem', fontWeight: 500 }}> / {students.length} SV</span>
|
||||
<span className="ew-online-total">/ {students.length} SV</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -694,8 +837,13 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
|
||||
<aside className={`workspace-config-drawer ${configOpen ? 'open' : ''}`}>
|
||||
<div className="workspace-drawer-header">
|
||||
<strong>Cấu hình phòng thi</strong>
|
||||
<button type="button" className="btn btn-secondary workspace-drawer-close" onClick={() => setConfigOpen(false)} aria-label="Đóng">×</button>
|
||||
<span className="ew-drawer-title">
|
||||
<IconSettings size={16} />
|
||||
Cấu hình phòng thi
|
||||
</span>
|
||||
<button type="button" className="ew-drawer-close" onClick={() => setConfigOpen(false)} aria-label="Đóng">
|
||||
<IconClose size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="workspace-drawer-tabs">
|
||||
<button type="button" className={`workspace-drawer-tab ${configTab === 'info' ? 'active' : ''}`} onClick={() => setConfigTab('info')}>
|
||||
@@ -713,7 +861,11 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
<div className="config-card config-card-flat">
|
||||
<div className="card-header-title">Thông tin phòng thi</div>
|
||||
<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>
|
||||
<label className="login-field">
|
||||
<span>Tên phòng thi</span>
|
||||
@@ -754,13 +906,39 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
</label>
|
||||
<label className="login-field">
|
||||
<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>
|
||||
{editable && (
|
||||
<button type="button" className="btn btn-primary" style={{ width: '100%', justifyContent: 'center' }} disabled={saving} onClick={saveRoom}>
|
||||
{saving ? 'Đang lưu...' : 'Lưu thông tin'}
|
||||
{(editable || canExtend) && (
|
||||
<button
|
||||
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>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
|
||||
@@ -950,87 +1128,103 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="right-panel workspace-content-panel">
|
||||
<div className="workspace-panel-toolbar">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '1rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.65rem', flexWrap: 'wrap' }}>
|
||||
<div className={`right-panel workspace-content-panel${activeSubTab === 'grid' ? ' workspace-content-panel--grid' : ''}`}>
|
||||
<div className="workspace-panel-toolbar ew-toolbar">
|
||||
<div className="ew-toolbar-left">
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-secondary workspace-config-toggle 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
|
||||
type="button"
|
||||
className={`btn btn-secondary workspace-config-toggle ${configOpen ? 'active' : ''}`}
|
||||
onClick={() => setConfigOpen((v) => !v)}
|
||||
role="tab"
|
||||
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} />
|
||||
Sơ đồ
|
||||
</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>
|
||||
<div className="tab-btn-group">
|
||||
<button
|
||||
type="button"
|
||||
className={`tab-sub-btn ${activeSubTab === 'roster' ? 'active' : ''}`}
|
||||
onClick={() => setActiveSubTab('roster')}
|
||||
>
|
||||
Sơ đồ 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 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 className="ew-toolbar-divider" />
|
||||
|
||||
<div className="workspace-panel-body workspace-panel-fill">
|
||||
{activeSubTab === 'roster' ? (
|
||||
@@ -1059,8 +1253,8 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
<div className="session-logs-panel">
|
||||
<div className="attendance-table-scroll table-wrapper" style={{ border: 'none' }}>
|
||||
{students.length === 0 ? (
|
||||
<div className="empty-state" style={{ minHeight: '300px' }}>
|
||||
<div className="empty-state-icon">📋</div>
|
||||
<div className="empty-state ew-empty" style={{ minHeight: '300px' }}>
|
||||
<div className="ew-empty-icon"><IconInbox size={36} /></div>
|
||||
<h2>Chưa có sinh viên</h2>
|
||||
<p>Thêm sinh viên để xem chi tiết đề gán và trạng thái nộp bài.</p>
|
||||
</div>
|
||||
@@ -1112,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)}>
|
||||
Nhắn tin
|
||||
</button>
|
||||
{prepEditable && (
|
||||
{canRemoveStudents && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm learning-btn-danger"
|
||||
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>
|
||||
)}
|
||||
</td>
|
||||
@@ -1140,10 +1342,11 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
{papers.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
className="btn btn-secondary btn-sm ew-icon-btn"
|
||||
onClick={() => setPapersModalOpen(true)}
|
||||
>
|
||||
📄 Xem đề phòng thi
|
||||
<IconExam size={13} />
|
||||
Xem đề phòng thi
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
@@ -1152,7 +1355,7 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
disabled={bundlingSubs}
|
||||
onClick={downloadAllSubs}
|
||||
>
|
||||
{bundlingSubs ? 'Đang gói ZIP...' : '📦 Tải tất cả (ZIP gộp)'}
|
||||
{bundlingSubs ? 'Đang gói ZIP...' : 'Tải tất cả (ZIP gộp)'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1187,8 +1390,8 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
)}
|
||||
<div className="attendance-table-scroll table-wrapper" style={{ border: 'none' }}>
|
||||
{submissions.length === 0 ? (
|
||||
<div className="empty-state" style={{ minHeight: '300px' }}>
|
||||
<div className="empty-state-icon">📦</div>
|
||||
<div className="empty-state ew-empty" style={{ minHeight: '300px' }}>
|
||||
<div className="ew-empty-icon"><IconPackage size={36} /></div>
|
||||
<h2>Chưa có bài nộp</h2>
|
||||
<p>Sinh viên nộp bài qua app Simple Care sẽ hiện tại đây.</p>
|
||||
</div>
|
||||
@@ -1312,6 +1515,7 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
student={selectedStudent}
|
||||
isOnline={onlineIds.includes(selectedStudent.rkId)}
|
||||
onClose={() => setSelectedStudent(null)}
|
||||
initialShowProctor={activeSubTab === 'grid' || activeSubTab === 'roster'}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1422,17 +1626,17 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
<div className="exam-student-hits">
|
||||
{searchMode === 'single' && searchQ.trim() === '' ? (
|
||||
<div className="exam-student-hits-empty">
|
||||
<span>🔍</span>
|
||||
<span className="ew-empty-icon"><IconSearch size={28} /></span>
|
||||
<p>Gõ ít nhất vài ký tự để tìm sinh viên.</p>
|
||||
</div>
|
||||
) : searchMode === 'bulk' && searchHits.length === 0 ? (
|
||||
<div className="exam-student-hits-empty">
|
||||
<span>📋</span>
|
||||
<span className="ew-empty-icon"><IconList size={28} /></span>
|
||||
<p>Paste danh sách và bấm "Tìm hàng loạt" để hiển thị kết quả.</p>
|
||||
</div>
|
||||
) : searchHits.length === 0 ? (
|
||||
<div className="exam-student-hits-empty">
|
||||
<span>📭</span>
|
||||
<span className="ew-empty-icon"><IconInbox size={28} /></span>
|
||||
<p>Không tìm thấy sinh viên phù hợp.</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -1534,6 +1738,103 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
onSelect={applyTemplate}
|
||||
allowedApps={allowedApps}
|
||||
/>
|
||||
|
||||
<style>{`
|
||||
.ew-header { padding: 0.7rem 1.1rem; gap: 0.65rem; }
|
||||
.ew-header-left { display: flex; align-items: center; gap: 0.75rem; min-width: 0; }
|
||||
.ew-brand-icon {
|
||||
width: 40px; height: 40px; border-radius: var(--radius-sm);
|
||||
background: var(--accent-light); color: var(--accent);
|
||||
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
||||
}
|
||||
.ew-title-block { display: flex; flex-direction: column; gap: 0.15rem; min-width: 0; }
|
||||
.ew-header .workspace-class-title { font-size: 1.2rem; font-weight: 700; margin: 0; line-height: 1.25; }
|
||||
.ew-header .class-badge-container { margin-top: 0.15rem; gap: 0.35rem; }
|
||||
.ew-header-right { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; }
|
||||
.ew-status-panel { gap: 0.75rem; padding: 0.4rem 0.85rem; }
|
||||
.ew-status-label-group { display: flex; flex-direction: column; }
|
||||
.ew-meta-label {
|
||||
font-size: 0.68rem; color: var(--text-muted); font-weight: 600;
|
||||
text-transform: uppercase; letter-spacing: 0.04em;
|
||||
}
|
||||
.ew-status-value { font-size: 0.88rem; font-weight: 700; }
|
||||
.ew-status-actions { display: flex; flex-direction: column; gap: 0.3rem; }
|
||||
.ew-extend-row { display: flex; gap: 0.3rem; flex-wrap: wrap; align-items: center; }
|
||||
.ew-online-stat { display: flex; flex-direction: column; align-items: flex-end; gap: 0.1rem; }
|
||||
.ew-online-value {
|
||||
display: flex; align-items: center; gap: 0.3rem; font-size: 1.1rem !important;
|
||||
}
|
||||
.ew-online-total { color: var(--text-muted); font-size: 0.82rem; font-weight: 500; }
|
||||
|
||||
.ew-drawer-title {
|
||||
display: inline-flex; align-items: center; gap: 0.45rem;
|
||||
font-weight: 700; font-size: 0.92rem; color: var(--text-primary);
|
||||
}
|
||||
.ew-drawer-close {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 28px; height: 28px; border: none; background: var(--bg-subtle);
|
||||
border-radius: var(--radius-sm); color: var(--text-secondary); cursor: pointer;
|
||||
}
|
||||
.ew-drawer-close:hover { background: var(--border-color); color: var(--text-primary); }
|
||||
|
||||
.workspace-content-panel.right-panel {
|
||||
padding: 0.65rem 0.85rem 0.85rem; gap: 0;
|
||||
}
|
||||
.workspace-content-panel--grid { padding-top: 0.55rem; }
|
||||
.ew-toolbar {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
flex-wrap: wrap; gap: 0.55rem 0.75rem; flex-shrink: 0;
|
||||
}
|
||||
.ew-toolbar-left, .ew-toolbar-right {
|
||||
display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;
|
||||
}
|
||||
.ew-config-toggle {
|
||||
display: inline-flex !important; align-items: center; gap: 0.35rem;
|
||||
padding: 0.38rem 0.85rem !important; font-size: 0.8rem !important; font-weight: 600 !important;
|
||||
}
|
||||
.ew-toolbar-divider {
|
||||
margin: 0.5rem 0 0.35rem; border-top: 1px solid var(--border-light);
|
||||
opacity: 0.45; flex-shrink: 0;
|
||||
}
|
||||
.workspace-content-panel--grid .ew-toolbar-divider { margin: 0.4rem 0 0; opacity: 0.35; }
|
||||
|
||||
.ew-seg {
|
||||
display: flex; background: var(--bg-subtle); border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm); padding: 0.18rem; gap: 0.15rem; flex-wrap: wrap;
|
||||
}
|
||||
.ew-seg-btn {
|
||||
display: flex; align-items: center; gap: 0.3rem; padding: 0.3rem 0.65rem;
|
||||
font-size: 0.78rem; font-weight: 600; background: transparent; color: var(--text-secondary);
|
||||
border: none; border-radius: 4px; cursor: pointer; white-space: nowrap;
|
||||
font-family: var(--font-sans); transition: background 0.14s, color 0.14s;
|
||||
}
|
||||
.ew-seg-btn:hover:not(.ew-seg-btn--active) {
|
||||
background: var(--border-light); color: var(--text-primary);
|
||||
}
|
||||
.ew-seg-btn--active {
|
||||
background: #fff; color: var(--accent); box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
.ew-search-wrapper { position: relative; max-width: 220px; width: 100%; }
|
||||
.ew-search-icon {
|
||||
position: absolute; left: 0.6rem; top: 50%; transform: translateY(-50%);
|
||||
color: var(--text-muted); display: flex; pointer-events: none;
|
||||
}
|
||||
.ew-search-input { padding: 0.38rem 0.8rem 0.38rem 2rem !important; font-size: 0.82rem !important; width: 100%; }
|
||||
.ew-icon-btn {
|
||||
display: inline-flex !important; align-items: center; gap: 0.3rem;
|
||||
}
|
||||
|
||||
.ew-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 0.45rem; }
|
||||
.ew-empty-icon { display: flex; color: var(--text-muted); opacity: 0.55; }
|
||||
.exam-student-hits-empty .ew-empty-icon { margin-bottom: 0.25rem; }
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.ew-header { padding: 0.65rem 0.85rem; }
|
||||
.ew-header .workspace-class-title { font-size: 1.05rem; }
|
||||
.ew-seg-btn { padding: 0.28rem 0.5rem; font-size: 0.74rem; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,13 +11,13 @@ function fmtTime(iso: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabel(st: string) {
|
||||
if (st === 'draft') return { text: 'Tạm thời', cls: 'badge-muted' };
|
||||
if (st === 'ready') return { text: 'Sẵn sàng', cls: 'badge-info' };
|
||||
if (st === 'active') return { text: 'Đang thi', cls: 'badge-success' };
|
||||
if (st === 'cancelled') return { text: 'Đã hủy', cls: 'badge-warning' };
|
||||
if (st === 'ended') return { text: 'Đã kết thúc', cls: 'badge-muted' };
|
||||
return { text: st, cls: 'badge-muted' };
|
||||
function statusMeta(st: string) {
|
||||
if (st === 'draft') return { text: 'Tạm thời', tone: 'muted' };
|
||||
if (st === 'ready') return { text: 'Sẵn sàng', tone: 'info' };
|
||||
if (st === 'active') return { text: 'Đang thi', tone: 'active' };
|
||||
if (st === 'cancelled') return { text: 'Đã hủy', tone: 'warn' };
|
||||
if (st === 'ended') return { text: 'Đã kết thúc', tone: 'muted' };
|
||||
return { text: st, tone: 'muted' };
|
||||
}
|
||||
|
||||
function toLocalInput(iso?: string) {
|
||||
@@ -32,9 +32,70 @@ function localInputToISO(v: string) {
|
||||
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 = () => {
|
||||
const { staff } = useAuth();
|
||||
const isSuperAdmin = staff?.email === 'phuocntb@rikkeiacademy.com';
|
||||
const myStaffId = staff?.id ?? 0;
|
||||
|
||||
const [rooms, setRooms] = useState<ExamRoomItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -44,6 +105,7 @@ export const ExamsTab: React.FC = () => {
|
||||
const [end, setEnd] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [roomScope, setRoomScope] = useState<RoomScope>('mine');
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
@@ -61,20 +123,28 @@ export const ExamsTab: React.FC = () => {
|
||||
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 active = rooms.filter((r) => (r.displayStatus || r.status) === 'active').length;
|
||||
const upcoming = rooms.filter((r) => {
|
||||
const active = scopedRooms.filter((r) => (r.displayStatus || r.status) === 'active').length;
|
||||
const upcoming = scopedRooms.filter((r) => {
|
||||
const st = r.displayStatus || r.status;
|
||||
return st === 'ready' || st === 'draft';
|
||||
}).length;
|
||||
return { total: rooms.length, active, upcoming };
|
||||
}, [rooms]);
|
||||
return { total: scopedRooms.length, active, upcoming };
|
||||
}, [scopedRooms]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return rooms;
|
||||
return rooms.filter((r) => r.name.toLowerCase().includes(q));
|
||||
}, [rooms, search]);
|
||||
if (!q) return scopedRooms;
|
||||
return scopedRooms.filter((r) => r.name.toLowerCase().includes(q));
|
||||
}, [scopedRooms, search]);
|
||||
|
||||
const canDeleteRoom = (r: ExamRoomItem) =>
|
||||
isSuperAdmin || (myStaffId > 0 && Number(r.createdByStaffId) === myStaffId);
|
||||
|
||||
const create = async () => {
|
||||
if (!name.trim() || !start || !end) return;
|
||||
@@ -99,7 +169,11 @@ export const ExamsTab: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleDeleteRoom = async (roomId: number, roomName: string) => {
|
||||
if (window.confirm(`Bạn có chắc chắn muốn xóa hoàn toàn phòng thi "${roomName}" không? Hành động này sẽ xóa sạch dữ liệu phòng thi, các bài nộp, và không thể khôi phục.`)) {
|
||||
if (
|
||||
window.confirm(
|
||||
`Bạn có chắc chắn muốn xóa hoàn toàn phòng thi "${roomName}" không? Hành động này sẽ xóa sạch dữ liệu phòng thi, các bài nộp, và không thể khôi phục.`
|
||||
)
|
||||
) {
|
||||
try {
|
||||
await apiExam.remove(roomId);
|
||||
alert('Đã xóa phòng thi thành công!');
|
||||
@@ -112,76 +186,216 @@ export const ExamsTab: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="tab-page exams-page">
|
||||
<header className="page-header page-header--row">
|
||||
<div>
|
||||
<h1 className="page-title">Danh sách phòng thi</h1>
|
||||
<p className="page-desc">Tạo phòng, chia đề ngẫu nhiên, gửi đề và thu bài từ sinh viên.</p>
|
||||
</div>
|
||||
<button type="button" className="btn btn-primary" onClick={() => setShowCreate(true)}>
|
||||
+ Tạo phòng thi
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="exams-stats-row">
|
||||
<div className="learning-stat">
|
||||
<span className="learning-stat-value">{stats.total}</span>
|
||||
<span className="learning-stat-label">Tổng phòng</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 className="tab-page-toolbar">
|
||||
<div className="page-header">
|
||||
<div className="page-title">
|
||||
<h1 className="page-title-heading">
|
||||
<span className="page-title-icon" aria-hidden>
|
||||
<IconClipboard />
|
||||
</span>
|
||||
Danh sách phòng thi
|
||||
</h1>
|
||||
<p>
|
||||
{roomScope === 'mine'
|
||||
? 'Mặc định chỉ hiện phòng thi do bạn tạo — chuyển sang xem tất cả khi cần'
|
||||
: 'Đang xem toàn bộ phòng thi trong hệ thống'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="exams-header-actions">
|
||||
<button type="button" className="btn btn-primary" onClick={() => setShowCreate(true)}>
|
||||
<IconPlus />
|
||||
Tạo phòng thi
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="exams-toolbar">
|
||||
<input
|
||||
type="search"
|
||||
className="app-pool-search exams-search"
|
||||
placeholder="Tìm theo tên phòng thi..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
<div className="tab-page-body" style={{ gap: '0.85rem' }}>
|
||||
<div className="exams-stats-row">
|
||||
<div className="learning-stat">
|
||||
<span className="learning-stat-value">{stats.total}</span>
|
||||
<span className="learning-stat-label">
|
||||
{roomScope === 'mine' ? 'Phòng của tôi' : 'Tổng phòng'}
|
||||
</span>
|
||||
</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>
|
||||
|
||||
{showCreate && (
|
||||
<div className="modal-overlay" onClick={() => setShowCreate(false)}>
|
||||
<div className="modal-container" style={{ maxWidth: '500px' }} onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2 className="modal-title">Tạo phòng thi mới</h2>
|
||||
<button className="modal-close-btn" onClick={() => setShowCreate(false)} aria-label="Đóng">×</button>
|
||||
<div className="modal-container class-picker-modal exam-create-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header class-picker-header">
|
||||
<div>
|
||||
<h2 className="modal-title">Tạo phòng thi mới</h2>
|
||||
<p className="class-picker-subtitle">
|
||||
Đặt tên và khung giờ thi. Sau khi tạo sẽ mở workspace để cấu hình đề và sinh viên.
|
||||
</p>
|
||||
</div>
|
||||
<button className="modal-close-btn" onClick={() => setShowCreate(false)} aria-label="Đóng">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-body" style={{ padding: '1.5rem', display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
|
||||
<label className="login-field" style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
|
||||
<span style={{ fontWeight: 600, fontSize: '0.85rem', color: 'var(--text-secondary)' }}>Tên phòng thi</span>
|
||||
<div className="modal-body class-picker-body exam-create-body">
|
||||
<label className="exam-field">
|
||||
<span className="exam-field-label">Tên phòng thi</span>
|
||||
<input
|
||||
className="search-input"
|
||||
style={{ width: '100%', boxSizing: 'border-box' }}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="VD: Thi cuối kỳ Java"
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
||||
<label className="login-field" style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
|
||||
<span style={{ fontWeight: 600, fontSize: '0.85rem', color: 'var(--text-secondary)' }}>Bắt đầu</span>
|
||||
<div className="exam-field-grid">
|
||||
<label className="exam-field">
|
||||
<span className="exam-field-label">Bắt đầu</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
className="search-input"
|
||||
style={{ width: '100%', boxSizing: 'border-box', padding: '0.5rem 0.75rem' }}
|
||||
className="search-input exam-datetime"
|
||||
value={start}
|
||||
onChange={(e) => setStart(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="login-field" style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
|
||||
<span style={{ fontWeight: 600, fontSize: '0.85rem', color: 'var(--text-secondary)' }}>Kết thúc</span>
|
||||
<label className="exam-field">
|
||||
<span className="exam-field-label">Kết thúc</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
className="search-input"
|
||||
style={{ width: '100%', boxSizing: 'border-box', padding: '0.5rem 0.75rem' }}
|
||||
className="search-input exam-datetime"
|
||||
value={end}
|
||||
onChange={(e) => setEnd(e.target.value)}
|
||||
/>
|
||||
@@ -192,7 +406,12 @@ export const ExamsTab: React.FC = () => {
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setShowCreate(false)}>
|
||||
Hủy
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" disabled={busy} onClick={create}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={busy || !name.trim() || !start || !end}
|
||||
onClick={create}
|
||||
>
|
||||
{busy ? 'Đang tạo...' : 'Tạo & mở phòng'}
|
||||
</button>
|
||||
</div>
|
||||
@@ -200,73 +419,327 @@ export const ExamsTab: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="system-empty">Đang tải...</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="system-empty">
|
||||
{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.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="exams-card-grid">
|
||||
{filtered.map((r) => {
|
||||
const st = statusLabel(r.displayStatus || r.status);
|
||||
return (
|
||||
<article key={r.id} className="exam-list-card">
|
||||
<div className="exam-list-card-head">
|
||||
<h3>{r.name}</h3>
|
||||
<span className={`badge ${st.cls}`}>{st.text}</span>
|
||||
</div>
|
||||
<div className="exam-list-card-meta">
|
||||
<div>
|
||||
<span className="exam-list-card-label">Thời gian</span>
|
||||
<span>{fmtTime(r.startTime)}</span>
|
||||
<span className="exam-list-card-sep">→</span>
|
||||
<span>{fmtTime(r.endTime)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="exam-list-card-label">Quy mô</span>
|
||||
<span>{r.studentCount} sinh viên · {r.paperCount} đề</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '1.25rem' }}>
|
||||
<button type="button" className="btn btn-primary btn-sm exam-list-card-open" onClick={() => openExam(r.id, r.name)} style={{ flex: 1, margin: 0 }}>
|
||||
Mở phòng thi
|
||||
</button>
|
||||
{isSuperAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteRoom(r.id, r.name);
|
||||
}}
|
||||
style={{
|
||||
borderColor: 'var(--danger)',
|
||||
color: 'var(--danger)',
|
||||
backgroundColor: 'transparent',
|
||||
padding: '0.5rem 0.75rem',
|
||||
fontWeight: 600,
|
||||
transition: 'var(--transition)',
|
||||
margin: 0
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'var(--danger)';
|
||||
e.currentTarget.style.color = '#fff';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = 'var(--danger)';
|
||||
}}
|
||||
>
|
||||
Xóa
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<style>{`
|
||||
.page-title-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
.page-title-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.exams-header-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.exams-header-actions .btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.exams-toolbar-card {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
.exams-toolbar-inner {
|
||||
margin: 0;
|
||||
}
|
||||
.exams-search {
|
||||
max-width: none !important;
|
||||
}
|
||||
|
||||
.exams-empty-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.65rem;
|
||||
padding: 2.75rem 1.25rem;
|
||||
text-align: center;
|
||||
}
|
||||
.exams-empty-panel p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
max-width: 36ch;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.exams-empty-icon {
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
}
|
||||
.exams-empty-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.exams-empty-actions .btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.exams-card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 0.75rem;
|
||||
padding: 0.85rem;
|
||||
padding-bottom: 4.75rem;
|
||||
}
|
||||
|
||||
.exam-list-card {
|
||||
background: #fff;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
padding: 0.85rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
min-height: 0;
|
||||
}
|
||||
.exam-list-card:hover {
|
||||
border-color: #c5cdd8;
|
||||
box-shadow: 0 3px 12px rgba(26, 35, 50, 0.08);
|
||||
}
|
||||
.exam-list-card--active {
|
||||
border-left: 3px solid var(--accent);
|
||||
background: rgba(187, 33, 38, 0.03);
|
||||
}
|
||||
|
||||
.exam-list-card-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
flex: 1;
|
||||
}
|
||||
.exam-list-card-info {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.exam-list-card-title {
|
||||
margin: 0 0 0.4rem;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.35;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.exam-list-card-meta-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
.exam-meta-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.exam-meta-row svg {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
.exam-meta-row--secondary {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
.exam-list-card-sep {
|
||||
color: var(--text-muted);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.exam-list-card-badges {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.exam-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.15rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.exam-tag--mine {
|
||||
background: rgba(234, 179, 8, 0.14);
|
||||
color: #a16207;
|
||||
border-color: rgba(234, 179, 8, 0.35);
|
||||
}
|
||||
.exam-tag--active {
|
||||
background: rgba(13, 159, 110, 0.12);
|
||||
color: #0b7a55;
|
||||
border-color: rgba(13, 159, 110, 0.28);
|
||||
}
|
||||
.exam-tag--info {
|
||||
background: rgba(37, 99, 235, 0.1);
|
||||
color: #1d4ed8;
|
||||
border-color: rgba(37, 99, 235, 0.22);
|
||||
}
|
||||
.exam-tag--warn {
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
color: #b45309;
|
||||
border-color: rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
.exam-tag--muted {
|
||||
background: var(--bg-subtle);
|
||||
color: var(--text-muted);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.exam-list-card-actions {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
margin-top: auto;
|
||||
padding-top: 0.55rem;
|
||||
border-top: 1px solid var(--border-light);
|
||||
}
|
||||
.exam-list-card-actions .btn {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
height: 30px;
|
||||
padding: 0.35rem 0.5rem;
|
||||
font-size: 0.72rem;
|
||||
border-radius: 7px;
|
||||
line-height: 1;
|
||||
}
|
||||
.exam-btn-danger {
|
||||
color: #b91c1c !important;
|
||||
border-color: #fecaca !important;
|
||||
background: #fef2f2 !important;
|
||||
flex: 0 0 auto !important;
|
||||
min-width: 52px;
|
||||
}
|
||||
.exam-btn-danger:hover {
|
||||
background: #fee2e2 !important;
|
||||
border-color: #fca5a5 !important;
|
||||
}
|
||||
|
||||
.exam-create-modal {
|
||||
max-width: 520px !important;
|
||||
}
|
||||
.exam-create-modal .class-picker-header {
|
||||
align-items: flex-start !important;
|
||||
padding: 1.1rem 1.35rem !important;
|
||||
}
|
||||
.exam-create-modal .class-picker-subtitle {
|
||||
margin: 0.3rem 0 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.4;
|
||||
max-width: 42ch;
|
||||
}
|
||||
.exam-create-modal .class-picker-body {
|
||||
padding: 1rem 1.35rem 1.15rem !important;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
.exam-create-body {
|
||||
gap: 0.9rem !important;
|
||||
}
|
||||
.exam-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.exam-field-label {
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.exam-field .search-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.exam-field-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.exam-datetime {
|
||||
padding: 0.5rem 0.75rem !important;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.exams-header-actions {
|
||||
width: 100%;
|
||||
}
|
||||
.exams-header-actions .btn {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.learning-seg {
|
||||
width: 100%;
|
||||
}
|
||||
.learning-seg-btn {
|
||||
flex: 1;
|
||||
}
|
||||
.exams-stats-row {
|
||||
grid-template-columns: repeat(3, 1fr) !important;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.exams-card-grid {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 0.75rem;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
.exam-list-card-actions .btn {
|
||||
height: 32px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.exams-stats-row {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
.exam-field-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.exam-list-card-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
.exam-btn-danger {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,135 +1,75 @@
|
||||
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import { getWsUrl } from '../api';
|
||||
import { StudentStreamImage } from './StudentStreamImage';
|
||||
|
||||
interface ProctorStreamPanelsProps {
|
||||
studentId: number;
|
||||
layout?: 'default' | 'focus';
|
||||
}
|
||||
|
||||
type IconProps = { size?: number };
|
||||
|
||||
const IconMinus = ({ size = 14 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden>
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconPlus = ({ size = 14 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconMaximize = ({ size = 13 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M8 3H5a2 2 0 0 0-2 2v3" />
|
||||
<path d="M21 8V5a2 2 0 0 0-2-2h-3" />
|
||||
<path d="M3 16v3a2 2 0 0 0 2 2h3" />
|
||||
<path d="M16 21h3a2 2 0 0 0 2-2v-3" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconMinimize = ({ size = 13 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M8 3v3a2 2 0 0 1-2 2H3" />
|
||||
<path d="M21 8h-3a2 2 0 0 1-2-2V3" />
|
||||
<path d="M3 16h3a2 2 0 0 1 2 2v3" />
|
||||
<path d="M16 21v-3a2 2 0 0 1 2-2h3" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconCamera = ({ size = 13 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" />
|
||||
<circle cx="12" cy="13" r="4" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconCameraOff = ({ size = 13 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<line x1="1" y1="1" x2="23" y2="23" />
|
||||
<path d="M21 21H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h3m3-3h6l2 3h4a2 2 0 0 1 2 2v7" />
|
||||
<path d="M9.4 9.4A4 4 0 0 0 12 17a4 4 0 0 0 3.6-5.6" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ZOOM_STEPS = [0.5, 0.75, 1, 1.25, 1.5, 2, 2.5, 3];
|
||||
|
||||
export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
|
||||
studentId,
|
||||
layout = 'focus',
|
||||
}) => {
|
||||
const [screenFrame, setScreenFrame] = useState<string | null>(null);
|
||||
const [webcamFrame, setWebcamFrame] = useState<string | null>(null);
|
||||
const [streaming, setStreaming] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [showWebcam, setShowWebcam] = useState(true);
|
||||
const [screenZoomIdx, setScreenZoomIdx] = useState(2); // 1x
|
||||
const [screenZoomIdx, setScreenZoomIdx] = useState(2);
|
||||
const [webcamZoomIdx, setWebcamZoomIdx] = useState(2);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const intentionalClose = useRef(false);
|
||||
const hasOpened = useRef(false);
|
||||
const hasFrames = useRef(false);
|
||||
const screenPanelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const screenZoom = ZOOM_STEPS[screenZoomIdx];
|
||||
const webcamZoom = ZOOM_STEPS[webcamZoomIdx];
|
||||
|
||||
useEffect(() => {
|
||||
const 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(() => {
|
||||
const onFsChange = () => {
|
||||
setIsFullscreen(document.fullscreenElement === screenPanelRef.current);
|
||||
@@ -154,12 +94,12 @@ export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
|
||||
|
||||
const zoomIn = (target: 'screen' | 'webcam') => {
|
||||
const setter = target === 'screen' ? setScreenZoomIdx : setWebcamZoomIdx;
|
||||
setter(i => Math.min(i + 1, ZOOM_STEPS.length - 1));
|
||||
setter((i) => Math.min(i + 1, ZOOM_STEPS.length - 1));
|
||||
};
|
||||
|
||||
const zoomOut = (target: 'screen' | 'webcam') => {
|
||||
const setter = target === 'screen' ? setScreenZoomIdx : setWebcamZoomIdx;
|
||||
setter(i => Math.max(i - 1, 0));
|
||||
setter((i) => Math.max(i - 1, 0));
|
||||
};
|
||||
|
||||
const zoomReset = (target: 'screen' | 'webcam') => {
|
||||
@@ -169,12 +109,19 @@ export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
|
||||
|
||||
const renderZoomToolbar = (target: 'screen' | 'webcam', zoom: number, onFs?: () => void) => (
|
||||
<div className="panel-toolbar">
|
||||
<button type="button" className="proctor-tool-btn" title="Thu nhỏ" onClick={() => zoomOut(target)}>−</button>
|
||||
<button type="button" className="proctor-tool-btn" title="Thu nhỏ" onClick={() => zoomOut(target)}>
|
||||
<IconMinus size={14} />
|
||||
</button>
|
||||
<span className="proctor-zoom-label">{Math.round(zoom * 100)}%</span>
|
||||
<button type="button" className="proctor-tool-btn" title="Phóng to" onClick={() => zoomIn(target)}>+</button>
|
||||
<button type="button" className="proctor-tool-btn" title="Về 100%" onClick={() => zoomReset(target)}>1:1</button>
|
||||
<button type="button" className="proctor-tool-btn" title="Phóng to" onClick={() => zoomIn(target)}>
|
||||
<IconPlus size={14} />
|
||||
</button>
|
||||
<button type="button" className="proctor-tool-btn proctor-tool-btn-label" title="Về 100%" onClick={() => zoomReset(target)}>
|
||||
1:1
|
||||
</button>
|
||||
{onFs && (
|
||||
<button type="button" className="proctor-tool-btn proctor-tool-btn-wide" title="Toàn màn hình" onClick={onFs}>
|
||||
{isFullscreen ? <IconMinimize size={13} /> : <IconMaximize size={13} />}
|
||||
{isFullscreen ? 'Thu nhỏ' : 'Phóng to'}
|
||||
</button>
|
||||
)}
|
||||
@@ -184,24 +131,22 @@ export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
|
||||
return (
|
||||
<div className="proctor-stream-wrap">
|
||||
<div className="proctor-stream-toolbar">
|
||||
<span className={`status-pill ${streaming || screenFrame || webcamFrame ? 'connected' : 'connecting'}`}>
|
||||
{streaming || screenFrame || webcamFrame ? '● Đang phát' : '○ Đang kết nối...'}
|
||||
<span className="status-pill connected">
|
||||
<span className="status-pill-dot" />
|
||||
Đang phát (HTTP)
|
||||
</span>
|
||||
<div className="proctor-stream-actions">
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-secondary proctor-action-btn ${showWebcam ? '' : 'active'}`}
|
||||
onClick={() => setShowWebcam(v => !v)}
|
||||
onClick={() => setShowWebcam((v) => !v)}
|
||||
>
|
||||
{showWebcam ? <IconCameraOff size={13} /> : <IconCamera size={13} />}
|
||||
{showWebcam ? 'Ẩn webcam' : 'Hiện webcam'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{errorMessage && !screenFrame && !webcamFrame && (
|
||||
<div className="alert-error proctor-stream-error">{errorMessage}</div>
|
||||
)}
|
||||
|
||||
<div className={`proctor-grid proctor-grid-${layout} ${!showWebcam ? 'proctor-grid--no-webcam' : ''}`}>
|
||||
<div
|
||||
className={`proctor-panel screen-panel ${isFullscreen ? 'screen-panel--fullscreen' : ''}`}
|
||||
@@ -213,17 +158,12 @@ export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
|
||||
</div>
|
||||
<div className="panel-body screen-body" onDoubleClick={toggleFullscreen} title="Double-click để phóng to">
|
||||
<div className="proctor-zoom-viewport">
|
||||
{screenFrame ? (
|
||||
<img
|
||||
src={screenFrame}
|
||||
alt="Màn hình sinh viên"
|
||||
className="live-frame screen-img"
|
||||
style={{ transform: `scale(${screenZoom})` }}
|
||||
draggable={false}
|
||||
/>
|
||||
) : (
|
||||
<div className="no-stream-placeholder"><p>Đang chờ màn hình...</p></div>
|
||||
)}
|
||||
<StudentStreamImage
|
||||
studentId={studentId}
|
||||
kind="screen"
|
||||
className="live-frame screen-img"
|
||||
style={{ transform: `scale(${screenZoom})` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -236,17 +176,12 @@ export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
|
||||
</div>
|
||||
<div className="panel-body webcam-body">
|
||||
<div className="proctor-zoom-viewport proctor-zoom-viewport--webcam">
|
||||
{webcamFrame ? (
|
||||
<img
|
||||
src={webcamFrame}
|
||||
alt="Webcam sinh viên"
|
||||
className="live-frame webcam-img"
|
||||
style={{ transform: `scale(${webcamZoom})` }}
|
||||
draggable={false}
|
||||
/>
|
||||
) : (
|
||||
<div className="no-stream-placeholder"><p>Đang chờ webcam...</p></div>
|
||||
)}
|
||||
<StudentStreamImage
|
||||
studentId={studentId}
|
||||
kind="webcam"
|
||||
className="live-frame webcam-img"
|
||||
style={{ transform: `scale(${webcamZoom})` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
118
management/src/components/RescideAnnouncePopup.tsx
Normal file
118
management/src/components/RescideAnnouncePopup.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import {
|
||||
isRescidePopupHidden,
|
||||
RESCIDE_DEFAULT_DOWNLOADS,
|
||||
RESCIDE_FEATURES,
|
||||
setRescidePopupHidden,
|
||||
} from '../rescide';
|
||||
import { navigate } from '../navigation';
|
||||
|
||||
type Props = {
|
||||
/** Khi true, không hiện popup (ví dụ đang ở workspace lớp/thi). */
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const RescideAnnouncePopup: React.FC<Props> = ({ disabled }) => {
|
||||
const { staff } = useAuth();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled || !staff?.email) {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
setOpen(!isRescidePopupHidden(staff.email));
|
||||
}, [staff?.email, disabled]);
|
||||
|
||||
if (!open || !staff?.email) return null;
|
||||
|
||||
const dismissOnce = () => setOpen(false);
|
||||
|
||||
const dismissForever = () => {
|
||||
setRescidePopupHidden(staff.email, true);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const goTab = () => {
|
||||
setOpen(false);
|
||||
navigate('rescide');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" style={{ zIndex: 1200 }} onClick={dismissOnce}>
|
||||
<div
|
||||
className="modal-container"
|
||||
style={{ maxWidth: 520, width: '92%' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="modal-header" style={{ borderBottom: '1px solid var(--border-light)' }}>
|
||||
<h2 className="modal-title" style={{ margin: 0 }}>
|
||||
Thông báo: RESCIDE IDE
|
||||
</h2>
|
||||
<button type="button" className="modal-close-btn" aria-label="Đóng" onClick={dismissOnce}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-body" style={{ padding: '1.25rem 1.5rem' }}>
|
||||
<p style={{ fontSize: '0.95rem', color: 'var(--text-secondary)', lineHeight: 1.6, marginBottom: '1rem' }}>
|
||||
<strong style={{ color: 'var(--text-primary)' }}>RESCIDE</strong> — IDE thi thực hành đang mở test thử để
|
||||
đánh giá và cải thiện chức năng. Mục đích: môi trường IDE chuyên cho kỳ thi thực hành, chống gian lận cùng
|
||||
RAIA DESKTOP.
|
||||
</p>
|
||||
<ul
|
||||
style={{
|
||||
margin: '0 0 1.25rem',
|
||||
paddingLeft: '1.15rem',
|
||||
fontSize: '0.875rem',
|
||||
color: 'var(--text-secondary)',
|
||||
lineHeight: 1.65,
|
||||
}}
|
||||
>
|
||||
{RESCIDE_FEATURES.map((f) => (
|
||||
<li key={f} style={{ marginBottom: '0.35rem' }}>
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginBottom: '0.25rem' }}>
|
||||
{RESCIDE_DEFAULT_DOWNLOADS.map((d) => (
|
||||
<a
|
||||
key={d.downloadUrl}
|
||||
href={d.downloadUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn btn-secondary"
|
||||
style={{ justifyContent: 'space-between', textDecoration: 'none', width: '100%' }}
|
||||
>
|
||||
<span>{d.name}</span>
|
||||
<span style={{ fontWeight: 700, color: 'var(--accent)' }}>Tải</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="modal-footer"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '0.5rem',
|
||||
justifyContent: 'flex-end',
|
||||
borderTop: '1px solid var(--border-light)',
|
||||
padding: '1rem 1.5rem',
|
||||
}}
|
||||
>
|
||||
<button type="button" className="btn btn-secondary" onClick={dismissForever}>
|
||||
Ẩn với tài khoản này
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={goTab}>
|
||||
Xem chi tiết
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" onClick={dismissOnce}>
|
||||
Đã hiểu
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
182
management/src/components/RescideTab.tsx
Normal file
182
management/src/components/RescideTab.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { api, type AppDownloadItem } from '../api';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import {
|
||||
isRescideManager,
|
||||
isRescidePopupHidden,
|
||||
RESCIDE_DEFAULT_DOWNLOADS,
|
||||
RESCIDE_FEATURES,
|
||||
setRescidePopupHidden,
|
||||
type RescideDownload,
|
||||
} from '../rescide';
|
||||
import { navigate } from '../navigation';
|
||||
|
||||
function mergeDownloads(fromApi: AppDownloadItem[]): RescideDownload[] {
|
||||
const rescide = fromApi.filter((a) => a.name.toUpperCase().includes('RESCIDE'));
|
||||
if (rescide.length > 0) {
|
||||
return rescide.map((a) => ({
|
||||
name: a.name,
|
||||
description: a.description || '',
|
||||
downloadUrl: a.downloadUrl,
|
||||
platform: a.platform || '',
|
||||
}));
|
||||
}
|
||||
return RESCIDE_DEFAULT_DOWNLOADS;
|
||||
}
|
||||
|
||||
export const RescideTab: React.FC = () => {
|
||||
const { staff } = useAuth();
|
||||
const canManage = isRescideManager(staff?.email);
|
||||
const [downloads, setDownloads] = useState<RescideDownload[]>(RESCIDE_DEFAULT_DOWNLOADS);
|
||||
const [popupHidden, setPopupHidden] = useState(() => isRescidePopupHidden(staff?.email));
|
||||
|
||||
useEffect(() => {
|
||||
setPopupHidden(isRescidePopupHidden(staff?.email));
|
||||
}, [staff?.email]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api
|
||||
.listAppDownloads()
|
||||
.then((res) => {
|
||||
if (!cancelled) setDownloads(mergeDownloads(res.data || []));
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setDownloads(RESCIDE_DEFAULT_DOWNLOADS);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const togglePopupHide = () => {
|
||||
if (!staff?.email) return;
|
||||
const next = !popupHidden;
|
||||
setRescidePopupHidden(staff.email, next);
|
||||
setPopupHidden(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tab-page">
|
||||
<div className="tab-page-toolbar">
|
||||
<div className="page-header">
|
||||
<div className="page-title">
|
||||
<h1>RESCIDE — IDE thi thực hành</h1>
|
||||
<p>
|
||||
Mở test thử để đánh giá và cải thiện chức năng. IDE chuyên dụng cho kỳ thi thực hành, chống gian lận
|
||||
cùng RAIA DESKTOP.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tab-page-body tab-page-scroll">
|
||||
<div
|
||||
className="content-card"
|
||||
style={{
|
||||
marginBottom: '1.25rem',
|
||||
padding: '1.25rem 1.5rem',
|
||||
borderColor: 'rgba(187, 33, 38, 0.25)',
|
||||
background: 'linear-gradient(135deg, #fff 0%, var(--accent-light) 100%)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '1rem', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '0.75rem', fontWeight: 700, color: 'var(--accent)', letterSpacing: '0.04em', textTransform: 'uppercase', marginBottom: '0.35rem' }}>
|
||||
Thông báo thử nghiệm
|
||||
</div>
|
||||
<p style={{ fontSize: '0.95rem', color: 'var(--text-secondary)', lineHeight: 1.55, margin: 0, maxWidth: 640 }}>
|
||||
Vui lòng tải RESCIDE, dùng thử trong môi trường thi thực hành và góp ý để đội ngũ hoàn thiện sản phẩm.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={togglePopupHide}
|
||||
title={popupHidden ? 'Hiện popup RESCIDE mỗi khi vào web' : 'Ẩn popup RESCIDE với tài khoản này'}
|
||||
>
|
||||
{popupHidden ? 'Hiện lại popup khi vào web' : 'Ẩn popup với tài khoản này'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="content-card" style={{ marginBottom: '1.25rem', padding: '1.5rem' }}>
|
||||
<h2 style={{ fontSize: '1.05rem', fontWeight: 700, marginBottom: '0.75rem' }}>Chức năng tiêu biểu</h2>
|
||||
<ul style={{ margin: 0, paddingLeft: '1.2rem', color: 'var(--text-secondary)', lineHeight: 1.7, fontSize: '0.95rem' }}>
|
||||
{RESCIDE_FEATURES.map((f) => (
|
||||
<li key={f} style={{ marginBottom: '0.4rem' }}>
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="content-card" style={{ padding: '1.5rem' }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.75rem', alignItems: 'baseline', justifyContent: 'space-between', marginBottom: '1rem' }}>
|
||||
<h2 style={{ fontSize: '1.05rem', fontWeight: 700, margin: 0 }}>Tải RESCIDE</h2>
|
||||
{canManage && (
|
||||
<button type="button" className="btn btn-secondary" onClick={() => navigate('applications')}>
|
||||
Quản lý link tải (Ứng dụng)
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p style={{ fontSize: '0.875rem', color: 'var(--text-muted)', marginBottom: '1.25rem', lineHeight: 1.5 }}>
|
||||
Chọn bản cài đặt phù hợp hệ điều hành. Link tải được đồng bộ từ khu vực Ứng dụng
|
||||
{canManage ? ' — bạn có thể chỉnh sửa tại tab Ứng dụng.' : '.'}
|
||||
</p>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: '1rem' }}>
|
||||
{downloads.map((d) => (
|
||||
<a
|
||||
key={`${d.name}-${d.downloadUrl}`}
|
||||
href={d.downloadUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="content-card"
|
||||
style={{
|
||||
padding: '1.25rem',
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
border: '1px solid var(--border-color)',
|
||||
transition: 'var(--transition)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.5rem',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = 'rgba(187, 33, 38, 0.35)';
|
||||
e.currentTarget.style.boxShadow = 'var(--shadow-md)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--border-color)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignSelf: 'flex-start',
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 700,
|
||||
color: 'var(--accent)',
|
||||
background: 'var(--accent-light)',
|
||||
padding: '0.2rem 0.55rem',
|
||||
borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
{d.platform || 'Download'}
|
||||
</span>
|
||||
<strong style={{ fontSize: '0.95rem', color: 'var(--text-primary)' }}>{d.name}</strong>
|
||||
{d.description && (
|
||||
<span style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', lineHeight: 1.45 }}>{d.description}</span>
|
||||
)}
|
||||
<span style={{ marginTop: 'auto', paddingTop: '0.75rem', fontSize: '0.85rem', fontWeight: 600, color: 'var(--accent)' }}>
|
||||
Tải về →
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -8,8 +8,27 @@ interface StudentDetailModalProps {
|
||||
isOnline: boolean;
|
||||
sessionLog?: StudentSessionLogItem | null;
|
||||
onClose: () => void;
|
||||
/** Mở sẵn phần giám sát (từ tab Giám sát / sơ đồ) */
|
||||
initialShowProctor?: boolean;
|
||||
}
|
||||
|
||||
type IconProps = { size?: number };
|
||||
|
||||
const IconClose = ({ size = 16 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconMonitor = ({ size = 14 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" />
|
||||
<line x1="8" y1="21" x2="16" y2="21" />
|
||||
<line x1="12" y1="17" x2="12" y2="21" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const formatDuration = (totalSeconds: number) => {
|
||||
const hrs = Math.floor(totalSeconds / 3600);
|
||||
const mins = Math.floor((totalSeconds % 3600) / 60);
|
||||
@@ -23,24 +42,34 @@ export const StudentDetailModal: React.FC<StudentDetailModalProps> = ({
|
||||
isOnline,
|
||||
sessionLog,
|
||||
onClose,
|
||||
initialShowProctor = false,
|
||||
}) => {
|
||||
const [showProctor, setShowProctor] = useState(false);
|
||||
const [showProctor, setShowProctor] = useState(initialShowProctor);
|
||||
|
||||
return (
|
||||
<div className="modal-overlay student-detail-overlay" onClick={onClose}>
|
||||
<div className={`modal-container student-detail-modal ${showProctor ? 'student-detail-modal--proctor' : ''}`} onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<div
|
||||
className={`modal-overlay student-detail-overlay${showProctor ? ' student-detail-overlay--proctor' : ''}`}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className={`modal-container student-detail-modal${showProctor ? ' student-detail-modal--proctor' : ''}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="modal-header student-detail-modal-header">
|
||||
<div className="student-detail-header">
|
||||
<StudentAvatar fullName={student.fullName} avatar={student.avatar} isOnline={isOnline} size={56} />
|
||||
<div>
|
||||
<h2 className="modal-title" style={{ margin: 0 }}>{student.fullName}</h2>
|
||||
<p style={{ margin: '4px 0 0', color: 'var(--text-muted)', fontSize: '0.85rem' }}>
|
||||
<span style={{ fontFamily: 'monospace', fontWeight: 700, color: 'var(--accent)' }}>{student.studentCode}</span>
|
||||
{student.email && <> · {student.email}</>}
|
||||
<StudentAvatar fullName={student.fullName} avatar={student.avatar} isOnline={isOnline} size={showProctor ? 40 : 52} />
|
||||
<div className="student-detail-title-block">
|
||||
<h2 className="modal-title student-detail-name">{student.fullName}</h2>
|
||||
<p className="student-detail-sub">
|
||||
<span className="student-detail-code">{student.studentCode}</span>
|
||||
{student.email && <span className="student-detail-email">{student.email}</span>}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="btn btn-secondary" onClick={onClose}>Đóng</button>
|
||||
<button type="button" className="btn btn-secondary student-detail-close" onClick={onClose}>
|
||||
<IconClose size={15} />
|
||||
Đóng
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="student-detail-body">
|
||||
@@ -62,20 +91,20 @@ export const StudentDetailModal: React.FC<StudentDetailModalProps> = ({
|
||||
<>
|
||||
<div className="student-meta-item">
|
||||
<span className="student-meta-label">Online (ca)</span>
|
||||
<span className="student-meta-value" style={{ color: 'var(--success)', fontFamily: 'monospace' }}>
|
||||
<span className="student-meta-value student-meta-value--online">
|
||||
{formatDuration(sessionLog.onlineSeconds)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="student-meta-item">
|
||||
<span className="student-meta-label">Offline (ca)</span>
|
||||
<span className="student-meta-value" style={{ color: 'var(--danger)', fontFamily: 'monospace' }}>
|
||||
<span className="student-meta-value student-meta-value--offline">
|
||||
{formatDuration(sessionLog.offlineSeconds)}
|
||||
</span>
|
||||
</div>
|
||||
{sessionLog.wifiSsids && sessionLog.wifiSsids !== '—' && (
|
||||
<div className="student-meta-item">
|
||||
<span className="student-meta-label">WiFi</span>
|
||||
<span className="student-meta-value" style={{ fontFamily: 'monospace', fontSize: '0.8rem' }}>
|
||||
<span className="student-meta-value student-meta-value--mono">
|
||||
{sessionLog.wifiSsids}
|
||||
</span>
|
||||
</div>
|
||||
@@ -86,14 +115,14 @@ export const StudentDetailModal: React.FC<StudentDetailModalProps> = ({
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
style={{ width: '100%', justifyContent: 'center' }}
|
||||
onClick={() => setShowProctor(v => !v)}
|
||||
className={`btn student-detail-proctor-toggle ${showProctor ? 'btn-secondary' : 'btn-primary'}`}
|
||||
onClick={() => setShowProctor((v) => !v)}
|
||||
>
|
||||
<IconMonitor size={14} />
|
||||
{showProctor ? 'Ẩn giám sát' : 'Xem webcam & màn hình'}
|
||||
</button>
|
||||
{!isOnline && !showProctor && (
|
||||
<p className="schedule-hint" style={{ margin: 0 }}>
|
||||
<p className="student-detail-hint">
|
||||
Sinh viên offline — stream chỉ có khi app Simple Care đang chạy.
|
||||
</p>
|
||||
)}
|
||||
|
||||
55
management/src/components/StudentStreamImage.tsx
Normal file
55
management/src/components/StudentStreamImage.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -2,6 +2,90 @@ import React, { useEffect, useState } from 'react';
|
||||
import { api } from '../api';
|
||||
import type { StudentItem, SyncStatus } from '../api';
|
||||
|
||||
type IconProps = { size?: number };
|
||||
|
||||
const IconUsers = ({ size = 22 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="3.5" />
|
||||
<path d="M22 21v-2a3.5 3.5 0 0 0-2.5-3.35" />
|
||||
<path d="M16 3.5a3.5 3.5 0 0 1 0 7" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconRefresh = ({ size = 18 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M21 12a9 9 0 1 1-2.6-6.3" />
|
||||
<polyline points="21 3 21 9 15 9" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconCheck = ({ size = 16 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconAlert = ({ size = 16 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconSearch = ({ size = 16 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconMail = ({ size = 13 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<rect x="3" y="5" width="18" height="14" rx="2" />
|
||||
<path d="m3 7 9 6 9-6" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconPhone = ({ size = 12 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.81.36 1.6.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c1.2.34 1.99.57 2.81.7A2 2 0 0 1 22 16.92z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconCalendar = ({ size = 13 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<rect x="3" y="5" width="18" height="16" rx="2" />
|
||||
<path d="M16 3v4M8 3v4M3 11h18" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconInbox = ({ size = 40 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<polyline points="22 12 16 12 14 15 10 15 8 12 2 12" />
|
||||
<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconChevronLeft = ({ size = 16 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconChevronRight = ({ size = 16 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
function genderLabel(gender: number | null | undefined) {
|
||||
if (gender === 1) return 'Nam';
|
||||
if (gender === 0) return 'Nữ';
|
||||
return 'Khác';
|
||||
}
|
||||
|
||||
export const StudentsTab: React.FC = () => {
|
||||
const [students, setStudents] = useState<StudentItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
@@ -9,8 +93,6 @@ export const StudentsTab: React.FC = () => {
|
||||
const [pageSize] = useState(15);
|
||||
const [search, setSearch] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Sync state
|
||||
const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null);
|
||||
|
||||
const fetchStudents = async () => {
|
||||
@@ -49,7 +131,6 @@ export const StudentsTab: React.FC = () => {
|
||||
fetchSyncStatus();
|
||||
}, []);
|
||||
|
||||
// Sync polling logic
|
||||
useEffect(() => {
|
||||
let timer: any;
|
||||
if (syncStatus?.running) {
|
||||
@@ -57,7 +138,7 @@ export const StudentsTab: React.FC = () => {
|
||||
const isRunning = await fetchSyncStatus();
|
||||
if (!isRunning) {
|
||||
clearInterval(timer);
|
||||
fetchStudents(); // Reload data when sync completes
|
||||
fetchStudents();
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
@@ -69,247 +150,565 @@ export const StudentsTab: React.FC = () => {
|
||||
const handleStartSync = async () => {
|
||||
try {
|
||||
await api.startStudentsSync();
|
||||
// Set local state to running to trigger useEffect poller
|
||||
setSyncStatus({
|
||||
running: true,
|
||||
done: false,
|
||||
total: 0,
|
||||
synced: 0,
|
||||
updatedAt: Date.now() / 1000
|
||||
updatedAt: Date.now() / 1000,
|
||||
});
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Không thể bắt đầu đồng bộ sinh viên');
|
||||
}
|
||||
};
|
||||
|
||||
// Tính toán % tiến trình sync
|
||||
const syncPercent = syncStatus && syncStatus.total > 0
|
||||
? Math.round((syncStatus.synced / syncStatus.total) * 100)
|
||||
: 0;
|
||||
const syncPercent =
|
||||
syncStatus && syncStatus.total > 0
|
||||
? Math.round((syncStatus.synced / syncStatus.total) * 100)
|
||||
: 0;
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize) || 1;
|
||||
|
||||
return (
|
||||
<div className="tab-page">
|
||||
<div className="tab-page-toolbar">
|
||||
<div className="page-header">
|
||||
<div className="page-title">
|
||||
<h1>Danh Sách Sinh Viên</h1>
|
||||
<p>Danh sách toàn bộ sinh viên trong hệ thống được đồng bộ</p>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleStartSync}
|
||||
disabled={syncStatus?.running}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
<div className="page-header">
|
||||
<div className="page-title">
|
||||
<h1 className="page-title-heading">
|
||||
<span className="page-title-icon" aria-hidden>
|
||||
<IconUsers />
|
||||
</span>
|
||||
Danh sách sinh viên
|
||||
</h1>
|
||||
<p>Danh sách toàn bộ sinh viên trong hệ thống được đồng bộ</p>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleStartSync}
|
||||
disabled={syncStatus?.running}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '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 style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>
|
||||
Đã tải: {syncStatus.synced} / {syncStatus.total} sinh viên
|
||||
{syncStatus.running && (
|
||||
<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>
|
||||
{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 có 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">Mã SV</th>
|
||||
<th className="col-name">Họ tên</th>
|
||||
<th className="col-contact">Thông tin liên hệ</th>
|
||||
<th className="col-birth">Ngày sinh / Giới tính</th>
|
||||
<th className="col-system">Phân hệ</th>
|
||||
<th className="col-location">Địa điểm</th>
|
||||
<th className="col-status">Trạng thái</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{students.map(st => {
|
||||
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 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>
|
||||
|
||||
{/* 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 có sinh viên nào</h2>
|
||||
<p>Hãy thử thay đổi bộ lọc hoặc bấm nút "Đồng bộ toàn bộ SV" để tải dữ liệu.</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Mã SV</th>
|
||||
<th>Họ Tên</th>
|
||||
<th>Thông Tin Liên Hệ</th>
|
||||
<th>Ngày sinh / Giới tính</th>
|
||||
<th>Phân hệ</th>
|
||||
<th>Địa điểm</th>
|
||||
<th>Trạng thái</th>
|
||||
</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 && (
|
||||
<div className="tab-page-footer">
|
||||
<div className="pagination-row">
|
||||
<div>
|
||||
Hiển thị sinh viên thứ <b>{((page - 1) * pageSize) + 1}</b> đến <b>{Math.min(page * pageSize, total)}</b> trong tổng số <b>{total}</b> sinh viên
|
||||
<div className="pagination-row">
|
||||
<div className="pagination-info">
|
||||
Hiển thị <b>{(page - 1) * pageSize + 1}</b> –{' '}
|
||||
<b>{Math.min(page * pageSize, total)}</b> trong tổng số <b>{total}</b> sinh viên
|
||||
</div>
|
||||
<div className="pagination-btn-group">
|
||||
<button
|
||||
className="pagination-btn"
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
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 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>
|
||||
)}
|
||||
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,11 +1,47 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
apiFetchClassViolations,
|
||||
apiFetchExamViolations,
|
||||
VIOLATION_KIND_OPTIONS,
|
||||
type StudentViolationItem,
|
||||
} 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 =
|
||||
| { mode: 'class'; classId: number }
|
||||
@@ -24,7 +60,17 @@ function formatTime(iso?: string): string {
|
||||
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 [kind, setKind] = useState('');
|
||||
const [rows, setRows] = useState<StudentViolationItem[]>([]);
|
||||
@@ -48,47 +94,128 @@ export const ViolationsPanel: React.FC<Props> = (props) => {
|
||||
}
|
||||
}, [props, date, kind]);
|
||||
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
return onStudentViolation((v) => {
|
||||
const matches =
|
||||
props.mode === 'class'
|
||||
? Number(v.classId) === props.classId
|
||||
: Number(v.examRoomId) === props.examId ||
|
||||
(!v.examRoomId && v.monitorMode === 'exam');
|
||||
if (!matches) return;
|
||||
if (date !== todayLocal()) return;
|
||||
void load();
|
||||
});
|
||||
}, [props, date, load]);
|
||||
|
||||
const isExam = props.mode === 'exam';
|
||||
|
||||
return (
|
||||
<div className="session-logs-panel" style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', height: '100%' }}>
|
||||
<div className="attendance-toolbar" style={{ marginBottom: 0, flexWrap: 'wrap' }}>
|
||||
<label className="attendance-field">
|
||||
<div className="vp-panel">
|
||||
<style>{`
|
||||
.vp-panel { display: flex; flex-direction: column; gap: 0.65rem; height: 100%; }
|
||||
|
||||
/* Notice banner */
|
||||
.vp-notice { padding: 0.55rem 0.75rem; border-radius: var(--radius-sm); border: 1px solid; border-left-width: 3px; font-size: 0.76rem; line-height: 1.5; display: flex; align-items: flex-start; gap: 0.45rem; }
|
||||
.vp-notice--exam { background: rgba(254,243,199,0.6); border-color: #fcd34d; border-left-color: #d97706; color: #78350f; }
|
||||
.vp-notice--exam svg { color: #d97706; }
|
||||
.vp-notice--class { background: rgba(239,246,255,0.7); border-color: #bfdbfe; border-left-color: #3b82f6; color: #1e3a8a; }
|
||||
.vp-notice--class svg { color: #3b82f6; }
|
||||
.vp-notice svg { flex-shrink: 0; margin-top: 0.1rem; }
|
||||
|
||||
/* Toolbar */
|
||||
.vp-toolbar { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: flex-end; }
|
||||
.vp-field { display: flex; flex-direction: column; gap: 0.18rem; font-size: 0.71rem; font-weight: 600; color: var(--text-muted); }
|
||||
.vp-input { padding: 0.3rem 0.5rem; font-size: 0.78rem; border: 1px solid var(--border-color); border-radius: var(--radius-sm); background: var(--bg-card); color: var(--text-primary); }
|
||||
.vp-input:focus { outline: none; border-color: var(--accent); }
|
||||
.vp-refresh { display: inline-flex; align-items: center; gap: 0.3rem; padding: 0.28rem 0.65rem; font-size: 0.75rem; font-weight: 500; border-radius: var(--radius-sm); border: 1px solid var(--border-color); background: var(--bg-card); color: var(--text-primary); cursor: pointer; transition: background 0.15s; white-space: nowrap; }
|
||||
.vp-refresh:hover:not(:disabled) { background: var(--bg-hover); border-color: var(--border-hover); }
|
||||
.vp-refresh:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
.vp-count { margin-left: auto; font-size: 0.77rem; color: var(--text-muted); font-weight: 600; align-self: flex-end; padding-bottom: 2px; }
|
||||
|
||||
/* Table wrapper */
|
||||
.vp-table-scroll { flex: 1; overflow: auto; border: 1px solid var(--border-color); border-radius: var(--radius-sm); }
|
||||
.vp-table { width: 100%; border-collapse: collapse; }
|
||||
.vp-table thead th { padding: 0.5rem 0.8rem; background: var(--bg-subtle); font-weight: 700; font-size: 0.68rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.04em; border-bottom: 1px solid var(--border-color); white-space: nowrap; }
|
||||
.vp-table tbody tr { border-bottom: 1px solid var(--border-light); transition: background 0.1s; }
|
||||
.vp-table tbody tr:last-child { border-bottom: none; }
|
||||
.vp-table tbody tr:hover { background: var(--bg-hover); }
|
||||
.vp-table tbody tr.vp-row--close { background: rgba(239,68,68,0.04); }
|
||||
.vp-table tbody tr.vp-row--close:hover { background: rgba(239,68,68,0.08); }
|
||||
.vp-table tbody td { padding: 0.44rem 0.8rem; font-size: 0.82rem; vertical-align: middle; }
|
||||
|
||||
/* Empty state */
|
||||
.vp-empty { min-height: 180px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 0.5rem; color: var(--text-muted); font-size: 0.85rem; }
|
||||
.vp-empty svg { opacity: 0.35; }
|
||||
.vp-empty span { font-style: italic; }
|
||||
|
||||
/* Error */
|
||||
.vp-error { font-size: 0.78rem; color: var(--danger); }
|
||||
`}</style>
|
||||
|
||||
{/* ── Notice banner ── */}
|
||||
<div className={`vp-notice${isExam ? ' vp-notice--exam' : ' vp-notice--class'}`}>
|
||||
{isExam ? <IconAlert size={15} /> : <IconInfo size={15} />}
|
||||
<span>
|
||||
{isExam
|
||||
? 'Vi phạm trong phòng thi (tắt app, WiFi, môi trường…) được ghi nhận theo thời gian thực.'
|
||||
: 'Vi phạm trong giờ học (tắt app, WiFi, môi trường…) hiển thị tại đây theo từng lớp.'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* ── Toolbar ── */}
|
||||
<div className="vp-toolbar attendance-toolbar">
|
||||
<label className="vp-field attendance-field">
|
||||
<span>Ngày</span>
|
||||
<input type="date" className="search-input" style={{ padding: '0.5rem 0.75rem' }} value={date} onChange={(e) => setDate(e.target.value)} />
|
||||
<input
|
||||
type="date"
|
||||
className="vp-input search-input"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="attendance-field">
|
||||
<span>Loại</span>
|
||||
<select className="select-filter" value={kind} onChange={(e) => setKind(e.target.value)}>
|
||||
<label className="vp-field attendance-field">
|
||||
<span>Loại vi phạm</span>
|
||||
<select
|
||||
className="vp-input select-filter"
|
||||
value={kind}
|
||||
onChange={(e) => setKind(e.target.value)}
|
||||
>
|
||||
{VIOLATION_KIND_OPTIONS.map((o) => (
|
||||
<option key={o.value || 'all'} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" className="btn btn-secondary btn-sm" onClick={() => void load()} disabled={loading}>
|
||||
<button
|
||||
type="button"
|
||||
className="vp-refresh btn btn-secondary btn-sm"
|
||||
onClick={() => void load()}
|
||||
disabled={loading}
|
||||
style={{ alignSelf: 'flex-end' }}
|
||||
>
|
||||
<IconRefresh size={14} />
|
||||
{loading ? 'Đang tải...' : 'Làm mới'}
|
||||
</button>
|
||||
<span style={{ marginLeft: 'auto', fontSize: '0.82rem', color: 'var(--text-muted)', fontWeight: 600 }}>
|
||||
{rows.length} vi phạm
|
||||
</span>
|
||||
<span className="vp-count">{rows.length} vi phạm</span>
|
||||
</div>
|
||||
|
||||
{err && <div className="form-error" style={{ margin: 0 }}>{err}</div>}
|
||||
{/* ── Error message ── */}
|
||||
{err && <div className="vp-error form-error">{err}</div>}
|
||||
|
||||
<div className="attendance-table-scroll table-wrapper" style={{ border: 'none', flex: 1, overflowY: 'auto' }}>
|
||||
{/* ── Table ── */}
|
||||
<div className="vp-table-scroll attendance-table-scroll table-wrapper">
|
||||
{loading && rows.length === 0 ? (
|
||||
<div className="empty-state" style={{ minHeight: '220px' }}>
|
||||
<div className="sync-spinner" style={{ width: '32px', height: '32px' }} />
|
||||
<p style={{ marginTop: '0.5rem' }}>Đang tải vi phạm...</p>
|
||||
<div className="vp-empty">
|
||||
<div className="sync-spinner" style={{ width: 28, height: 28 }} />
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
<div className="empty-state" style={{ minHeight: '220px' }}>
|
||||
<p>Không có vi phạm trong ngày đã chọn.</p>
|
||||
<div className="vp-empty">
|
||||
<IconShieldOff size={36} />
|
||||
<span>Không có vi phạm trong ngày đã chọn.</span>
|
||||
</div>
|
||||
) : (
|
||||
<table className="data-table">
|
||||
<table className="vp-table data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Thời gian</th>
|
||||
@@ -96,28 +223,29 @@ export const ViolationsPanel: React.FC<Props> = (props) => {
|
||||
<th>Mã SV</th>
|
||||
<th>Loại</th>
|
||||
<th>Chi tiết</th>
|
||||
<th>Chế độ</th>
|
||||
<th>Ngữ cảnh</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td style={{ whiteSpace: 'nowrap', fontFamily: 'monospace', fontSize: '0.8rem' }}>
|
||||
{formatTime(r.createdAt || r.clientAt)}
|
||||
</td>
|
||||
<td style={{ fontWeight: 600 }}>{r.fullName || '—'}</td>
|
||||
<td><code>{r.studentCode || r.studentRkId}</code></td>
|
||||
<td>
|
||||
<span className="badge badge-warning" style={{ fontSize: '0.72rem' }}>
|
||||
{kindLabel(r.kind)}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ maxWidth: 360, fontSize: '0.85rem' }} title={r.reason}>
|
||||
{r.reason}
|
||||
</td>
|
||||
<td style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>{r.monitorMode || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
{rows.map((r) => {
|
||||
const isClose = r.kind === 'app_closed' || r.kind === 'unclean_shutdown';
|
||||
return (
|
||||
<tr key={r.id} className={isClose ? 'vp-row--close' : ''}>
|
||||
<td style={{ whiteSpace: 'nowrap', fontFamily: 'monospace', fontSize: '0.75rem' }}>
|
||||
{formatTime(r.createdAt || r.clientAt)}
|
||||
</td>
|
||||
<td style={{ fontWeight: 600 }}>{r.fullName || '—'}</td>
|
||||
<td><code style={{ fontSize: '0.78rem' }}>{r.studentCode || r.studentRkId}</code></td>
|
||||
<td>
|
||||
<span className={`badge ${isClose ? 'badge-danger' : 'badge-warning'}`} style={{ fontSize: '0.68rem', fontWeight: 600, padding: '0.15rem 0.4rem' }}>
|
||||
{kindLabel(r.kind)}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ maxWidth: 320, fontSize: '0.8rem' }} title={r.reason}>{r.reason}</td>
|
||||
<td style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>{modeLabel(r.monitorMode)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@ export type StudentViolationEvent = {
|
||||
reason: string;
|
||||
monitorMode?: string;
|
||||
classId?: number;
|
||||
examRoomId?: number;
|
||||
};
|
||||
|
||||
const chatHandlers = new Set<ChatIncomingHandler>();
|
||||
@@ -41,7 +42,7 @@ function scheduleBackoff(attempt: number): number {
|
||||
|
||||
export function kindLabel(kind: string): string {
|
||||
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 'multi_monitor': return 'Nhiều màn hình';
|
||||
case 'user_switch': return 'Đổi user';
|
||||
@@ -123,6 +124,7 @@ export function StaffChatSocket() {
|
||||
reason: String(payload.data?.reason || ''),
|
||||
monitorMode: payload.data?.monitorMode || '',
|
||||
classId: Number(payload.data?.classId ?? 0) || undefined,
|
||||
examRoomId: Number(payload.data?.examRoomId ?? 0) || undefined,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
export type SystemSection = 'organization' | 'network' | 'templates' | 'seating';
|
||||
|
||||
export type TabId = 'dashboard' | 'classes' | 'students' | 'learning' | 'exams' | 'system' | 'profile' | 'student-affairs' | 'applications';
|
||||
export type TabId = 'dashboard' | 'classes' | 'students' | 'learning' | 'exams' | 'system' | 'profile' | 'student-affairs' | 'applications' | 'rescide';
|
||||
|
||||
export interface NavEntry {
|
||||
id: string;
|
||||
@@ -30,6 +30,7 @@ export const TAB_LABELS: Record<TabId, string> = {
|
||||
profile: 'Tài khoản của tôi',
|
||||
'student-affairs': 'Công Tác Sinh Viên',
|
||||
applications: 'Ứng Dụng',
|
||||
rescide: 'RESCIDE IDE',
|
||||
};
|
||||
|
||||
const HISTORY_KEY = 'sc_nav_history';
|
||||
@@ -45,7 +46,8 @@ function isTabId(value: string | null): value is TabId {
|
||||
value === 'system' ||
|
||||
value === 'profile' ||
|
||||
value === 'student-affairs' ||
|
||||
value === 'applications'
|
||||
value === 'applications' ||
|
||||
value === 'rescide'
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
73
management/src/rescide.ts
Normal file
73
management/src/rescide.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/** RESCIDE IDE — thông báo & link tải (quản lý CRUD tại tab Ứng dụng, tài khoản super-admin). */
|
||||
|
||||
export const RESCIDE_SUPER_ADMIN_EMAIL = 'phuocntb@rikkeiacademy.com';
|
||||
|
||||
export const RESCIDE_POPUP_STORAGE_PREFIX = 'sc_rescide_popup_hidden:';
|
||||
|
||||
export function rescidePopupStorageKey(email: string) {
|
||||
return `${RESCIDE_POPUP_STORAGE_PREFIX}${email.trim().toLowerCase()}`;
|
||||
}
|
||||
|
||||
export function isRescidePopupHidden(email: string | undefined | null): boolean {
|
||||
if (!email) return true;
|
||||
try {
|
||||
return localStorage.getItem(rescidePopupStorageKey(email)) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function setRescidePopupHidden(email: string, hidden: boolean) {
|
||||
try {
|
||||
const key = rescidePopupStorageKey(email);
|
||||
if (hidden) localStorage.setItem(key, '1');
|
||||
else localStorage.removeItem(key);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function isRescideManager(email: string | undefined | null): boolean {
|
||||
return email?.trim().toLowerCase() === RESCIDE_SUPER_ADMIN_EMAIL;
|
||||
}
|
||||
|
||||
export type RescideDownload = {
|
||||
name: string;
|
||||
description: string;
|
||||
downloadUrl: string;
|
||||
platform: string;
|
||||
};
|
||||
|
||||
export const RESCIDE_DEFAULT_DOWNLOADS: RescideDownload[] = [
|
||||
{
|
||||
name: 'RESCIDE (macOS Intel)',
|
||||
description: 'IDE thi thực hành — macOS chip Intel',
|
||||
downloadUrl: 'https://save.rikkeiraia.org/RESCIDE_intel-installer.dmg',
|
||||
platform: 'macOS',
|
||||
},
|
||||
{
|
||||
name: 'RESCIDE (macOS Apple Silicon)',
|
||||
description: 'IDE thi thực hành — macOS chip Apple ARM',
|
||||
downloadUrl: 'https://save.rikkeiraia.org/RESCIDE_arm-installer.dmg',
|
||||
platform: 'macOS',
|
||||
},
|
||||
{
|
||||
name: 'RESCIDE (Windows)',
|
||||
description: 'IDE thi thực hành — Windows 10+',
|
||||
downloadUrl: 'https://save.rikkeiraia.org/RESCIDE-1.73.100-win.zip',
|
||||
platform: 'Windows',
|
||||
},
|
||||
{
|
||||
name: 'RESCIDE (Linux)',
|
||||
description: 'IDE thi thực hành — Linux x64',
|
||||
downloadUrl: 'https://save.rikkeiraia.org/RESCIDE-1.73.100-x64.tar.gz',
|
||||
platform: 'Linux',
|
||||
},
|
||||
];
|
||||
|
||||
export const RESCIDE_FEATURES = [
|
||||
'Kiểm soát code thời gian thực, cộng hưởng cùng RAIA DESKTOP chặn gian lận.',
|
||||
'Ngăn chặn paste code từ bên ngoài workspace.',
|
||||
'Chặn trình duyệt online và extension AI.',
|
||||
'Chặn Emmet / snippet code gian lận và nhiều chức năng ẩn khác.',
|
||||
] as const;
|
||||
@@ -73,15 +73,74 @@ func AutoMigrate(db *gorm.DB) error {
|
||||
&models.GuideLink{},
|
||||
&models.AppDownload{},
|
||||
&models.AppGuide{},
|
||||
&models.LocalLeaveRequest{},
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := purgeLegacyWifiWithoutBSSID(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := seedRescideAppDownloads(db); err != nil {
|
||||
log.Printf("Warning: seed RESCIDE app downloads: %v", err)
|
||||
}
|
||||
return migrateLegacyDiscoveredApps(db)
|
||||
}
|
||||
|
||||
// seedRescideAppDownloads thêm link tải RESCIDE nếu chưa có (quản lý tiếp tại tab Ứng dụng — super-admin).
|
||||
func seedRescideAppDownloads(db *gorm.DB) error {
|
||||
type seedItem struct {
|
||||
Name string
|
||||
Description string
|
||||
DownloadURL string
|
||||
Platform string
|
||||
}
|
||||
items := []seedItem{
|
||||
{
|
||||
Name: "RESCIDE (macOS Intel)",
|
||||
Description: "IDE thi thực hành — macOS chip Intel",
|
||||
DownloadURL: "https://save.rikkeiraia.org/RESCIDE_intel-installer.dmg",
|
||||
Platform: "macOS",
|
||||
},
|
||||
{
|
||||
Name: "RESCIDE (macOS Apple Silicon)",
|
||||
Description: "IDE thi thực hành — macOS chip Apple ARM",
|
||||
DownloadURL: "https://save.rikkeiraia.org/RESCIDE_arm-installer.dmg",
|
||||
Platform: "macOS",
|
||||
},
|
||||
{
|
||||
Name: "RESCIDE (Windows)",
|
||||
Description: "IDE thi thực hành — Windows 10+",
|
||||
DownloadURL: "https://save.rikkeiraia.org/RESCIDE-1.73.100-win.zip",
|
||||
Platform: "Windows",
|
||||
},
|
||||
{
|
||||
Name: "RESCIDE (Linux)",
|
||||
Description: "IDE thi thực hành — Linux x64",
|
||||
DownloadURL: "https://save.rikkeiraia.org/RESCIDE-1.73.100-x64.tar.gz",
|
||||
Platform: "Linux",
|
||||
},
|
||||
}
|
||||
for _, item := range items {
|
||||
var count int64
|
||||
if err := db.Model(&models.AppDownload{}).Where("download_url = ?", item.DownloadURL).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
continue
|
||||
}
|
||||
if err := db.Create(&models.AppDownload{
|
||||
Name: item.Name,
|
||||
Description: item.Description,
|
||||
DownloadURL: item.DownloadURL,
|
||||
Platform: item.Platform,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("Seeded app download: %s", item.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Bảng WiFi cũ dùng ssid_key làm PK; schema mới cần id + bssid_key — GORM không tự đổi PK.
|
||||
func migrateLegacyWifiTables(db *gorm.DB) error {
|
||||
if db.Migrator().HasTable("accepted_wifis") {
|
||||
|
||||
@@ -54,6 +54,11 @@ func ExamRoomCanCancel(room models.ExamRoom, now time.Time) bool {
|
||||
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.
|
||||
func ProcessExamRoomLifecycle(db *gorm.DB) {
|
||||
now := time.Now()
|
||||
|
||||
@@ -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()})
|
||||
}
|
||||
|
||||
var data any
|
||||
var data []map[string]interface{}
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
var fallback any
|
||||
if errFallback := json.Unmarshal(body, &fallback); errFallback == nil {
|
||||
return c.JSON(fallback)
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to parse QLDT response"})
|
||||
}
|
||||
|
||||
// Ghi đè trạng thái duyệt local nếu có
|
||||
var localReqs []models.LocalLeaveRequest
|
||||
if err := db.Find(&localReqs).Error; err == nil && len(localReqs) > 0 {
|
||||
localMap := make(map[int64]string)
|
||||
for _, lr := range localReqs {
|
||||
localMap[lr.LeaveID] = lr.Status
|
||||
}
|
||||
for i, item := range data {
|
||||
if idVal, ok := item["id"]; ok {
|
||||
var idInt int64
|
||||
switch v := idVal.(type) {
|
||||
case float64:
|
||||
idInt = int64(v)
|
||||
case int64:
|
||||
idInt = v
|
||||
case int:
|
||||
idInt = int64(v)
|
||||
}
|
||||
if localStatus, found := localMap[idInt]; found {
|
||||
data[i]["status"] = localStatus
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(data)
|
||||
}
|
||||
}
|
||||
@@ -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ệ"})
|
||||
}
|
||||
|
||||
token := GetQldtToken(db)
|
||||
if token == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "QLDT_TOKEN chưa cấu hình"})
|
||||
}
|
||||
|
||||
// 1. Gửi lệnh cập nhật status qua QLDT Portal API
|
||||
body, err := qldtClient.UpdateLeaveStatus(context.Background(), token, leaveID, req.Status)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{"error": err.Error(), "body": string(body)})
|
||||
// 1. Lưu trạng thái đơn phép vào database local thay vì gọi QLDT Portal API
|
||||
var localLeave models.LocalLeaveRequest
|
||||
errLocal := db.Where("leave_id = ?", leaveID).First(&localLeave).Error
|
||||
if errLocal == gorm.ErrRecordNotFound {
|
||||
localLeave = models.LocalLeaveRequest{
|
||||
LeaveID: leaveID,
|
||||
Status: req.Status,
|
||||
}
|
||||
if err := db.Create(&localLeave).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Lỗi lưu đơn phép local: " + err.Error()})
|
||||
}
|
||||
} else if errLocal == nil {
|
||||
localLeave.Status = req.Status
|
||||
if err := db.Save(&localLeave).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Lỗi cập nhật đơn phép local: " + err.Error()})
|
||||
}
|
||||
} else {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": errLocal.Error()})
|
||||
}
|
||||
|
||||
// 2. Nếu status là "Phê duyệt", tự động cập nhật điểm danh trong hệ thống của chúng ta thành "Nghỉ có phép"
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
internalDb "server/internal/db"
|
||||
"server/internal/middleware"
|
||||
"server/internal/models"
|
||||
internalWs "server/internal/websocket"
|
||||
|
||||
@@ -120,15 +121,23 @@ func deliverExamPaper(db *gorm.DB, enrollmentID uint) {
|
||||
// GET /api/exam-rooms
|
||||
func ListExamRoomsHandler(db *gorm.DB) fiber.Handler {
|
||||
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
|
||||
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()})
|
||||
}
|
||||
type row struct {
|
||||
models.ExamRoom
|
||||
StudentCount int `json:"studentCount"`
|
||||
PaperCount int `json:"paperCount"`
|
||||
DisplayStatus string `json:"displayStatus"`
|
||||
StudentCount int `json:"studentCount"`
|
||||
PaperCount int `json:"paperCount"`
|
||||
DisplayStatus string `json:"displayStatus"`
|
||||
CreatedByStaffID uint `json:"createdByStaffId"`
|
||||
}
|
||||
out := make([]row, 0, len(rooms))
|
||||
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.ExamPaper{}).Where("exam_room_id = ?", r.ID).Count(&pc).Error
|
||||
out = append(out, row{
|
||||
ExamRoom: r,
|
||||
StudentCount: int(sc),
|
||||
PaperCount: int(pc),
|
||||
DisplayStatus: internalDb.ExamDisplayStatus(r, now),
|
||||
ExamRoom: r,
|
||||
StudentCount: int(sc),
|
||||
PaperCount: int(pc),
|
||||
DisplayStatus: internalDb.ExamDisplayStatus(r, now),
|
||||
CreatedByStaffID: r.CreatedByStaffID,
|
||||
})
|
||||
}
|
||||
return c.JSON(fiber.Map{"data": out})
|
||||
@@ -177,12 +187,13 @@ func CreateExamRoomHandler(db *gorm.DB) fiber.Handler {
|
||||
}
|
||||
apps := strings.TrimSpace(req.AllowedApps)
|
||||
room := models.ExamRoom{
|
||||
Name: req.Name,
|
||||
StartTime: start,
|
||||
EndTime: end,
|
||||
AllowedApps: apps,
|
||||
QuizURL: strings.TrimSpace(req.QuizURL),
|
||||
Status: models.ExamStatusDraft,
|
||||
Name: req.Name,
|
||||
StartTime: start,
|
||||
EndTime: end,
|
||||
AllowedApps: apps,
|
||||
QuizURL: strings.TrimSpace(req.QuizURL),
|
||||
Status: models.ExamStatusDraft,
|
||||
CreatedByStaffID: middleware.StaffIDFromCtx(c),
|
||||
}
|
||||
if err := db.Create(&room).Error; err != nil {
|
||||
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),
|
||||
"prepEditable": internalDb.ExamRoomPrepEditable(room, now),
|
||||
"canPublish": room.Status == models.ExamStatusDraft,
|
||||
"canUnpublish": internalDb.ExamRoomCanUnpublish(room, time.Now()),
|
||||
"canCancel": internalDb.ExamRoomCanCancel(room, time.Now()),
|
||||
"canUnpublish": internalDb.ExamRoomCanUnpublish(room, 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 {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "Invalid body"})
|
||||
}
|
||||
now := time.Now()
|
||||
ds := internalDb.ExamDisplayStatus(room, now)
|
||||
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 {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
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
|
||||
func DeleteExamRoomHandler(db *gorm.DB) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
staffID := middleware.StaffIDFromCtx(c)
|
||||
email, _ := c.Locals("staffEmail").(string)
|
||||
if 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"})
|
||||
}
|
||||
isSuperAdmin := email == "phuocntb@rikkeiacademy.com"
|
||||
|
||||
id, err := parseUintParam(c, "id")
|
||||
if err != nil {
|
||||
@@ -436,6 +504,9 @@ func DeleteExamRoomHandler(db *gorm.DB) fiber.Handler {
|
||||
if err := db.First(&room, id).Error; err != nil {
|
||||
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()) {
|
||||
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_room_id = ?", id).Delete(&models.ExamPaper{}).Error
|
||||
_ = db.Where("exam_room_id = ?", id).Delete(&models.ExamSeatingLayout{}).Error
|
||||
_ = db.Delete(&room).Error
|
||||
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 {
|
||||
return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy"})
|
||||
}
|
||||
if !internalDb.ExamRoomPrepEditable(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"})
|
||||
ds := internalDb.ExamDisplayStatus(room, time.Now())
|
||||
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
|
||||
return c.JSON(fiber.Map{"ok": true})
|
||||
|
||||
@@ -744,6 +744,8 @@ func ReportViolationHandler(db *gorm.DB) fiber.Handler {
|
||||
Reason string `json:"reason"`
|
||||
MonitorMode string `json:"monitorMode"`
|
||||
ClientAt string `json:"clientAt"` // RFC3339 optional
|
||||
ClassRkID int64 `json:"classRkId"`
|
||||
ExamRoomID uint `json:"examRoomId"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payload format"})
|
||||
@@ -763,7 +765,19 @@ func ReportViolationHandler(db *gorm.DB) fiber.Handler {
|
||||
if reason == "" {
|
||||
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()
|
||||
if t, err := time.Parse(time.RFC3339, strings.TrimSpace(req.ClientAt)); err == nil {
|
||||
clientAt = t
|
||||
@@ -772,6 +786,7 @@ func ReportViolationHandler(db *gorm.DB) fiber.Handler {
|
||||
row := models.StudentViolation{
|
||||
StudentRkID: req.StudentRkID,
|
||||
ClassRkID: classID,
|
||||
ExamRoomID: examRoomID,
|
||||
Kind: kind,
|
||||
Reason: reason,
|
||||
MonitorMode: monitorMode,
|
||||
@@ -802,6 +817,7 @@ func ReportViolationHandler(db *gorm.DB) fiber.Handler {
|
||||
"studentName": studentName,
|
||||
"studentCode": studentCode,
|
||||
"classId": classID,
|
||||
"examRoomId": examRoomID,
|
||||
"kind": kind,
|
||||
"reason": reason,
|
||||
"monitorMode": row.MonitorMode,
|
||||
@@ -809,7 +825,7 @@ func ReportViolationHandler(db *gorm.DB) fiber.Handler {
|
||||
"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})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -819,6 +835,7 @@ type studentViolationItem struct {
|
||||
StudentCode string `json:"studentCode"`
|
||||
FullName string `json:"fullName"`
|
||||
ClassRkID int64 `json:"classRkId"`
|
||||
ExamRoomID uint `json:"examRoomId"`
|
||||
Kind string `json:"kind"`
|
||||
Reason string `json:"reason"`
|
||||
MonitorMode string `json:"monitorMode"`
|
||||
@@ -844,7 +861,7 @@ func ListClassViolationsHandler(db *gorm.DB) fiber.Handler {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
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))
|
||||
for _, m := range mappings {
|
||||
@@ -866,7 +883,11 @@ func ListClassViolationsHandler(db *gorm.DB) fiber.Handler {
|
||||
}
|
||||
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 != "" {
|
||||
q = q.Where("kind = ?", kindFilter)
|
||||
}
|
||||
@@ -878,12 +899,16 @@ func ListClassViolationsHandler(db *gorm.DB) fiber.Handler {
|
||||
out := make([]studentViolationItem, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
st := nameByID[r.StudentRkID]
|
||||
if st.RkID == 0 {
|
||||
_ = db.Where("rk_id = ?", r.StudentRkID).First(&st).Error
|
||||
}
|
||||
out = append(out, studentViolationItem{
|
||||
ID: r.ID,
|
||||
StudentRkID: r.StudentRkID,
|
||||
StudentCode: st.StudentCode,
|
||||
FullName: st.FullName,
|
||||
ClassRkID: r.ClassRkID,
|
||||
ExamRoomID: r.ExamRoomID,
|
||||
Kind: r.Kind,
|
||||
Reason: r.Reason,
|
||||
MonitorMode: r.MonitorMode,
|
||||
@@ -913,7 +938,7 @@ func ListExamRoomViolationsHandler(db *gorm.DB) fiber.Handler {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
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))
|
||||
for _, s := range roster {
|
||||
@@ -933,7 +958,11 @@ func ListExamRoomViolationsHandler(db *gorm.DB) fiber.Handler {
|
||||
}
|
||||
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 != "" {
|
||||
q = q.Where("kind = ?", kindFilter)
|
||||
}
|
||||
@@ -951,6 +980,7 @@ func ListExamRoomViolationsHandler(db *gorm.DB) fiber.Handler {
|
||||
StudentCode: st.StudentCode,
|
||||
FullName: st.FullName,
|
||||
ClassRkID: r.ClassRkID,
|
||||
ExamRoomID: r.ExamRoomID,
|
||||
Kind: r.Kind,
|
||||
Reason: r.Reason,
|
||||
MonitorMode: r.MonitorMode,
|
||||
|
||||
@@ -11,10 +11,16 @@ import (
|
||||
func RequireStaff() fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
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"})
|
||||
}
|
||||
token := strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
|
||||
claims, err := auth.ParseToken(token)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid or expired token"})
|
||||
|
||||
@@ -11,18 +11,19 @@ const (
|
||||
|
||||
// ExamRoom — phòng thi độc lập với lớp học
|
||||
type ExamRoom struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
Name string `gorm:"column:name;size:255;not null" json:"name"`
|
||||
StartTime time.Time `gorm:"column:start_time;not null;index" json:"startTime"`
|
||||
EndTime time.Time `gorm:"column:end_time;not null;index" json:"endTime"`
|
||||
AllowedApps string `gorm:"column:allowed_apps;type:text" json:"allowedApps"`
|
||||
QuizURL string `gorm:"column:quiz_url;size:1024" json:"quizUrl"`
|
||||
GitRepoURL string `gorm:"column:git_repo_url;size:512" json:"gitRepoUrl"`
|
||||
GitBranch string `gorm:"column:git_branch;size:64;default:main" json:"gitBranch"`
|
||||
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"`
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
Name string `gorm:"column:name;size:255;not null" json:"name"`
|
||||
StartTime time.Time `gorm:"column:start_time;not null;index" json:"startTime"`
|
||||
EndTime time.Time `gorm:"column:end_time;not null;index" json:"endTime"`
|
||||
AllowedApps string `gorm:"column:allowed_apps;type:text" json:"allowedApps"`
|
||||
QuizURL string `gorm:"column:quiz_url;size:1024" json:"quizUrl"`
|
||||
GitRepoURL string `gorm:"column:git_repo_url;size:512" json:"gitRepoUrl"`
|
||||
GitBranch string `gorm:"column:git_branch;size:64;default:main" json:"gitBranch"`
|
||||
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"`
|
||||
CreatedByStaffID uint `gorm:"column:created_by_staff_id;index;default:0" json:"createdByStaffId"`
|
||||
}
|
||||
|
||||
func (ExamRoom) TableName() string { return "exam_rooms" }
|
||||
|
||||
@@ -203,6 +203,7 @@ type StudentViolation struct {
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
StudentRkID int64 `gorm:"column:student_rk_id;not null;index" json:"studentRkId"`
|
||||
ClassRkID int64 `gorm:"column:class_rk_id;index" json:"classRkId"`
|
||||
ExamRoomID uint `gorm:"column:exam_room_id;index;default:0" json:"examRoomId"`
|
||||
Kind string `gorm:"column:kind;size:64;not null;index" json:"kind"` // app_closed | unclean_shutdown | multi_monitor | user_switch | session_change | virtual_desktop | wifi | guard
|
||||
Reason string `gorm:"column:reason;type:text" json:"reason"`
|
||||
MonitorMode string `gorm:"column:monitor_mode;size:32" json:"monitorMode"`
|
||||
@@ -255,4 +256,16 @@ type AppGuide struct {
|
||||
|
||||
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" }
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
internalDb "server/internal/db"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/websocket/v2"
|
||||
"gorm.io/gorm"
|
||||
internalDb "server/internal/db"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -53,22 +59,30 @@ type offlineGrace struct {
|
||||
}
|
||||
|
||||
type WsHub struct {
|
||||
mu sync.RWMutex
|
||||
students map[int64]*SocketClient
|
||||
teachers map[string]*SocketClient
|
||||
teachersByStaff map[uint][]string // staffId -> teacher connection addresses
|
||||
subscribers map[int64][]string // studentId -> list of teacher connection addresses
|
||||
grace map[int64]offlineGrace
|
||||
graceTimers map[int64]*time.Timer
|
||||
mu sync.RWMutex
|
||||
students map[int64]*SocketClient
|
||||
teachers map[string]*SocketClient
|
||||
teachersByStaff map[uint][]string // staffId -> teacher connection addresses
|
||||
subscribers map[int64][]string // studentId -> list of teacher connection addresses
|
||||
grace map[int64]offlineGrace
|
||||
graceTimers map[int64]*time.Timer
|
||||
subscriberModes map[string]string // "teacherAddr_studentId" -> "grid"|"focus"
|
||||
lastRelayed map[string]time.Time // "teacherAddr_studentId_event" -> time
|
||||
httpScreenSubscribers map[int64][]chan []byte // studentId -> list of channels for screen MJPEG
|
||||
httpWebcamSubscribers map[int64][]chan []byte // studentId -> list of channels for webcam MJPEG
|
||||
}
|
||||
|
||||
var Hub = &WsHub{
|
||||
students: make(map[int64]*SocketClient),
|
||||
teachers: make(map[string]*SocketClient),
|
||||
teachersByStaff: make(map[uint][]string),
|
||||
subscribers: make(map[int64][]string),
|
||||
grace: make(map[int64]offlineGrace),
|
||||
graceTimers: make(map[int64]*time.Timer),
|
||||
students: make(map[int64]*SocketClient),
|
||||
teachers: make(map[string]*SocketClient),
|
||||
teachersByStaff: make(map[uint][]string),
|
||||
subscribers: make(map[int64][]string),
|
||||
grace: make(map[int64]offlineGrace),
|
||||
graceTimers: make(map[int64]*time.Timer),
|
||||
subscriberModes: make(map[string]string),
|
||||
lastRelayed: make(map[string]time.Time),
|
||||
httpScreenSubscribers: make(map[int64][]chan []byte),
|
||||
httpWebcamSubscribers: make(map[int64][]chan []byte),
|
||||
}
|
||||
|
||||
func (h *WsHub) IsStudentOnline(studentRkID int64) bool {
|
||||
@@ -317,6 +331,12 @@ func (h *WsHub) Unregister(c *SocketClient) {
|
||||
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 {
|
||||
delete(h.subscribers, sID)
|
||||
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()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
@@ -344,9 +364,12 @@ func (h *WsHub) Subscribe(teacherAddr string, studentID int64) {
|
||||
}
|
||||
if !alreadySubscribed {
|
||||
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 {
|
||||
_ = student.WriteJSON(SocketMsg{Event: "start_screenshot_stream"})
|
||||
_ = student.WriteJSON(SocketMsg{Event: "start_webcam_stream"})
|
||||
@@ -357,6 +380,11 @@ func (h *WsHub) Unsubscribe(teacherAddr string, studentID int64) {
|
||||
h.mu.Lock()
|
||||
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]
|
||||
if !exists {
|
||||
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) {
|
||||
h.mu.RLock()
|
||||
func (h *WsHub) RelayFrameRaw(studentID int64, event string, rawImageBuffer json.RawMessage) {
|
||||
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]
|
||||
if !exists || len(teachersList) == 0 {
|
||||
h.mu.RUnlock()
|
||||
return
|
||||
}
|
||||
addrs := make([]string, len(teachersList))
|
||||
copy(addrs, teachersList)
|
||||
h.mu.RUnlock()
|
||||
|
||||
relayEvent := "teacher:screenshot-stream-frame"
|
||||
if event == "webcam_stream_frame" {
|
||||
relayEvent = "teacher:webcam-stream-frame"
|
||||
}
|
||||
|
||||
msg := SocketMsg{
|
||||
Event: relayEvent,
|
||||
Data: map[string]any{
|
||||
"studentId": studentID,
|
||||
"imageBuffer": data["imageBuffer"],
|
||||
},
|
||||
type RelayFrameRawMsg struct {
|
||||
Event string `json:"event"`
|
||||
Data struct {
|
||||
StudentID int64 `json:"studentId"`
|
||||
ImageBuffer json.RawMessage `json:"imageBuffer"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
msg := RelayFrameRawMsg{
|
||||
Event: relayEvent,
|
||||
}
|
||||
msg.Data.StudentID = studentID
|
||||
msg.Data.ImageBuffer = rawImageBuffer
|
||||
|
||||
msgBytes, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var dead []string
|
||||
h.mu.RLock()
|
||||
for _, addr := range addrs {
|
||||
if t, found := h.teachers[addr]; found {
|
||||
if err := t.WriteRaw(msgBytes); err != nil {
|
||||
log.Printf("[WS] Relay to teacher %s failed: %v", addr, err)
|
||||
dead = append(dead, addr)
|
||||
}
|
||||
now := time.Now()
|
||||
for _, addr := range teachersList {
|
||||
t, found := h.teachers[addr]
|
||||
if !found || t == nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
h.mu.RUnlock()
|
||||
|
||||
for _, addr := range dead {
|
||||
if t, ok := func() (*SocketClient, bool) {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
t, ok := h.teachers[addr]
|
||||
return t, ok
|
||||
}(); ok && t != nil {
|
||||
_ = t.Conn.Close()
|
||||
subKey := addr + "_" + strconv.FormatInt(studentID, 10)
|
||||
mode := h.subscriberModes[subKey]
|
||||
|
||||
if mode == "grid" {
|
||||
// In grid mode, rate limit to max 1 frame per 3 seconds per stream type
|
||||
relayKey := subKey + "_" + event
|
||||
lastTime, ok := h.lastRelayed[relayKey]
|
||||
if ok && now.Sub(lastTime) < 3*time.Second {
|
||||
continue
|
||||
}
|
||||
h.lastRelayed[relayKey] = now
|
||||
}
|
||||
|
||||
// Send asynchronously to avoid head-of-line blocking on slower clients
|
||||
go func(client *SocketClient, data []byte) {
|
||||
if err := client.WriteRaw(data); err != nil {
|
||||
log.Printf("[WS] Relay to teacher %s failed: %v", client.Addr, err)
|
||||
_ = client.Conn.Close()
|
||||
}
|
||||
}(t, msgBytes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -497,6 +575,29 @@ func WebSocketHandler(db *gorm.DB) func(*websocket.Conn) {
|
||||
}
|
||||
_ = 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
|
||||
if err := json.Unmarshal(msgBytes, &msg); err != nil {
|
||||
continue
|
||||
@@ -506,9 +607,6 @@ func WebSocketHandler(db *gorm.DB) func(*websocket.Conn) {
|
||||
case "client:ping":
|
||||
_ = 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":
|
||||
if client.Role == "teacher" {
|
||||
if sIDVal, ok := msg.Data["studentId"]; ok {
|
||||
@@ -519,8 +617,15 @@ func WebSocketHandler(db *gorm.DB) func(*websocket.Conn) {
|
||||
case string:
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -141,6 +141,8 @@ func main() {
|
||||
|
||||
// Students endpoints
|
||||
staff.Get("/students", handlers.ListAllStudentsHandler(gormDB))
|
||||
staff.Get("/students/:studentId/stream/screen", internalWs.GetStudentScreenStreamHandler)
|
||||
staff.Get("/students/:studentId/stream/webcam", internalWs.GetStudentWebcamStreamHandler)
|
||||
|
||||
// Sync endpoints
|
||||
staff.Post("/sync/classes/start", handlers.StartClassesSyncHandler(gormDB, qldtClient, classesJob))
|
||||
@@ -211,6 +213,7 @@ func main() {
|
||||
staff.Post("/exam-rooms/:id/publish", handlers.PublishExamRoomHandler(gormDB))
|
||||
staff.Post("/exam-rooms/:id/unpublish", handlers.UnpublishExamRoomHandler(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-papers-batch", handlers.AssignExamPapersBatchHandler(gormDB))
|
||||
staff.Get("/exam-rooms/:id/seating-layout", handlers.GetExamSeatingLayoutHandler(gormDB))
|
||||
|
||||
73
walkthrough.md
Normal file
73
walkthrough.md
Normal 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 (`×`).
|
||||
- **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.
|
||||
Reference in New Issue
Block a user