From 3e53cb6664564e613cefea691f8a680760bd3bba Mon Sep 17 00:00:00 2001 From: PhuocNTB Date: Tue, 30 Jun 2026 16:11:12 +0700 Subject: [PATCH] fix ui --- management/src/api.ts | 15 ++ management/src/components/AttendancePanel.tsx | 177 +++++++++++++----- management/src/index.css | 45 +++++ .../internal/handlers/handlers_attendance.go | 63 +++++++ server/main.go | 1 + 5 files changed, 259 insertions(+), 42 deletions(-) diff --git a/management/src/api.ts b/management/src/api.ts index 82cc84d..1b16e32 100644 --- a/management/src/api.ts +++ b/management/src/api.ts @@ -649,6 +649,21 @@ export const apiUpdateAttendanceStatus = async ( return res.json(); }; +export const apiUpdateAttendanceBulkStatus = async ( + rkId: number, + payload: { date: string; period: number; studentRkIds: number[]; status: number } +) => { + const res = await staffFetch(`/classes/${rkId}/attendance/bulk-status`, { + method: 'PUT', + body: JSON.stringify(payload), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error || 'Failed to update attendance status'); + } + return res.json(); +}; + export const apiPushAttendanceQLDT = async (rkId: number, date: string, period: number) => { const res = await staffFetch(`/classes/${rkId}/attendance/push-qldt`, { method: 'POST', diff --git a/management/src/components/AttendancePanel.tsx b/management/src/components/AttendancePanel.tsx index e1db89e..d065ca3 100644 --- a/management/src/components/AttendancePanel.tsx +++ b/management/src/components/AttendancePanel.tsx @@ -5,6 +5,7 @@ import { apiFetchAttendanceShifts, apiPushAttendanceQLDT, apiUpdateAttendanceStatus, + apiUpdateAttendanceBulkStatus, attendanceStatusClass, type AttendanceRow, type AttendanceShiftInfo, @@ -34,6 +35,7 @@ export const AttendancePanel: React.FC = ({ classId }) => const [loading, setLoading] = useState(false); const [pushing, setPushing] = useState(false); const [searchQuery, setSearchQuery] = useState(''); + const [selectedStudentRkIds, setSelectedStudentRkIds] = useState([]); const loadShifts = useCallback(async () => { try { @@ -53,6 +55,7 @@ export const AttendancePanel: React.FC = ({ classId }) => const res = await apiFetchAttendance(classId, date, period); setRows(res.data || []); setShiftInfo(res.shift || null); + setSelectedStudentRkIds([]); // Clear selection when data changes } catch (e: any) { alert(e.message || 'Không tải được điểm danh'); } finally { @@ -83,6 +86,12 @@ export const AttendancePanel: React.FC = ({ classId }) => return counts; }, [rows]); + const toggleStudentSelection = useCallback((studentRkId: number) => { + setSelectedStudentRkIds(prev => + prev.includes(studentRkId) ? prev.filter(id => id !== studentRkId) : [...prev, studentRkId] + ); + }, []); + const handleStatusChange = async (studentRkId: number, status: number) => { try { await apiUpdateAttendanceStatus(classId, { date, period, studentRkId, status }); @@ -92,6 +101,25 @@ export const AttendancePanel: React.FC = ({ classId }) => } }; + const handleBulkStatusChange = async (status: number) => { + if (selectedStudentRkIds.length === 0) return; + try { + setLoading(true); + await apiUpdateAttendanceBulkStatus(classId, { + date, + period, + studentRkIds: selectedStudentRkIds, + status, + }); + setSelectedStudentRkIds([]); + await loadAttendance(); + } catch (e: any) { + alert(e.message || 'Cập nhật hàng loạt thất bại'); + } finally { + setLoading(false); + } + }; + const handlePushQLDT = async () => { if (!shiftInfo?.courseId) { alert('Ca học này chưa chọn môn học. Vui lòng cấu hình trong lịch học.'); @@ -213,6 +241,35 @@ export const AttendancePanel: React.FC = ({ classId }) => Sửa trạng thái thủ công sẽ được khóa — hệ thống tự tính sẽ không ghi đè.

+ {selectedStudentRkIds.length > 0 && ( +
+ + Đang chọn {selectedStudentRkIds.length} sinh viên: + +
+ {ATTENDANCE_STATUS_OPTIONS.map(opt => ( + + ))} +
+ +
+ )} +
{loading ? (
@@ -220,6 +277,22 @@ export const AttendancePanel: React.FC = ({ classId }) => + @@ -229,53 +302,73 @@ export const AttendancePanel: React.FC = ({ classId }) => {filteredRows.length === 0 ? ( - ) : ( - filteredRows.map(row => ( - - - - + - - - )) + {}} + onClick={(e) => { + e.stopPropagation(); + toggleStudentSelection(row.studentRkId); + }} + /> + + + + + + + ); + }) )}
+ 0 && filteredRows.every(r => selectedStudentRkIds.includes(r.studentRkId))} + onChange={(e) => { + if (e.target.checked) { + const newIds = new Set(selectedStudentRkIds); + filteredRows.forEach(r => newIds.add(r.studentRkId)); + setSelectedStudentRkIds(Array.from(newIds)); + } else { + const filteredIds = filteredRows.map(r => r.studentRkId); + setSelectedStudentRkIds(selectedStudentRkIds.filter(id => !filteredIds.includes(id))); + } + }} + /> + Sinh viên Online Trạng thái
+ {rows.length === 0 ? 'Chưa có dữ liệu điểm danh' : 'Không tìm thấy sinh viên phù hợp'}
-
{row.fullName}
-
- {row.studentCode} - {row.email && {row.email}} -
-
- {row.onlineMinutes} - phút - -
toggleStudentSelection(row.studentRkId)} > - {ATTENDANCE_STATUS_OPTIONS.map(opt => ( - - ))} - - -
- {row.pushedToQldtAt ? ( - - QLĐT ✓ - - ) : ( - Chưa QLĐT - )} - {row.statusEditedByTeacher && ( - - Đã khóa - - )} -
-
toggleStudentSelection(row.studentRkId)} + > +
{row.fullName}
+
+ {row.studentCode} + {row.email && {row.email}} +
+
+ {row.onlineMinutes} + phút + + + +
+ {row.pushedToQldtAt ? ( + + QLĐT ✓ + + ) : ( + Chưa QLĐT + )} + {row.statusEditedByTeacher && ( + + Đã khóa + + )} +
+
diff --git a/management/src/index.css b/management/src/index.css index ac05a0c..cacde50 100644 --- a/management/src/index.css +++ b/management/src/index.css @@ -2102,6 +2102,47 @@ input:checked + .slider:before { font-weight: 600; } +/* Thanh thao tác hàng loạt */ +.attendance-bulk-actions { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.75rem 1.25rem; + background: var(--bg-card, #ffffff); + border: 1px solid var(--border-color, #dee2e6); + border-radius: 8px; + margin-bottom: 1rem; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + animation: attendanceSlideDown 0.2s ease-out; +} + +.attendance-bulk-title { + font-weight: 600; + font-size: 0.9rem; + color: var(--text-color, #212529); +} + +.attendance-bulk-btns { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.attendance-bulk-btn-close { + margin-left: auto; +} + +@keyframes attendanceSlideDown { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + /* Trạng thái điểm danh — màu theo status 0..4 */ .attendance-status--0, .attendance-summary-chip.attendance-status--0 { @@ -2156,6 +2197,10 @@ input:checked + .slider:before { background: var(--att-bg); } +.attendance-table tbody tr:hover { + filter: brightness(0.97); +} + .attendance-student-name { font-weight: 700; font-size: 0.88rem; diff --git a/server/internal/handlers/handlers_attendance.go b/server/internal/handlers/handlers_attendance.go index 6e6e447..d676eac 100644 --- a/server/internal/handlers/handlers_attendance.go +++ b/server/internal/handlers/handlers_attendance.go @@ -366,6 +366,69 @@ func UpdateAttendanceStatusHandler(db *gorm.DB) fiber.Handler { } } +// PUT /api/classes/:rkId/attendance/bulk-status +func UpdateAttendanceBulkStatusHandler(db *gorm.DB) fiber.Handler { + return func(c *fiber.Ctx) error { + classRkID, _ := strconv.ParseInt(c.Params("rkId"), 10, 64) + var req struct { + Date string `json:"date"` + Period int `json:"period"` + StudentRkIDs []int64 `json:"studentRkIds"` + Status int `json:"status"` + } + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payload"}) + } + if req.Date == "" { + req.Date = qldt.NowDateVN() + } + if req.Period < 1 { + req.Period = 1 + } + if len(req.StudentRkIDs) == 0 { + return c.JSON(fiber.Map{"ok": true, "message": "No students selected"}) + } + + label := qldt.AttendanceStatusLabel(req.Status) + tx := db.Begin() + for _, studentRkID := range req.StudentRkIDs { + var row models.AttendanceResult + err := tx.Where("class_rk_id = ? AND session_date = ? AND period = ? AND student_rk_id = ?", + classRkID, req.Date, req.Period, studentRkID).First(&row).Error + if err == gorm.ErrRecordNotFound { + row = models.AttendanceResult{ + ClassRkID: classRkID, + SessionDate: req.Date, + Period: req.Period, + StudentRkID: studentRkID, + Status: req.Status, + StatusLabel: label, + StatusEditedByTeacher: true, + } + if err := tx.Create(&row).Error; err != nil { + tx.Rollback() + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + } else if err != nil { + tx.Rollback() + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } else { + row.Status = req.Status + row.StatusLabel = label + row.StatusEditedByTeacher = true + if err := tx.Save(&row).Error; err != nil { + tx.Rollback() + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + } + } + if err := tx.Commit().Error; err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"ok": true}) + } +} + // POST /api/classes/:rkId/attendance/push-qldt func PushAttendanceToQLDTHandler(db *gorm.DB, qldtClient *qldt.Client, token string) fiber.Handler { return func(c *fiber.Ctx) error { diff --git a/server/main.go b/server/main.go index 2828768..0cf2dc5 100644 --- a/server/main.go +++ b/server/main.go @@ -158,6 +158,7 @@ func main() { staff.Get("/classes/:rkId/attendance", handlers.GetClassAttendanceHandler(gormDB)) staff.Get("/classes/:rkId/attendance/shifts", handlers.ListAttendanceShiftsHandler(gormDB)) staff.Put("/classes/:rkId/attendance/status", handlers.UpdateAttendanceStatusHandler(gormDB)) + staff.Put("/classes/:rkId/attendance/bulk-status", handlers.UpdateAttendanceBulkStatusHandler(gormDB)) staff.Post("/classes/:rkId/attendance/push-qldt", handlers.PushAttendanceToQLDTHandler(gormDB, qldtClient, qldtToken)) // Chat & quản lý tài khoản