Files
rikkei_simple_care/server/internal/handlers/handlers_attendance.go
PhuocNTB 487e9eb8ea
All checks were successful
Deploy on Master Change / deploy (push) Successful in 1m30s
fix ui
2026-07-14 07:39:18 +07:00

697 lines
22 KiB
Go

package handlers
import (
"context"
"encoding/json"
"fmt"
"strconv"
"time"
internalDb "server/internal/db"
"server/internal/models"
"server/internal/qldt"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
var standardShiftTemplate = []struct {
Period int
StartTime string
EndTime string
}{
{1, "07:00", "09:00"},
{2, "09:10", "11:10"},
{3, "12:10", "14:10"},
{4, "14:20", "16:20"},
}
// GET /api/classes/:rkId/courses
func GetClassCoursesHandler(db *gorm.DB, qldtClient *qldt.Client) fiber.Handler {
return func(c *fiber.Ctx) error {
rkID, err := strconv.ParseInt(c.Params("rkId"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid class rkId"})
}
token := GetQldtToken(db)
var liveErr error
if token != "" {
res, err := qldtClient.GetClassCourses(context.Background(), token, rkID)
if err == nil && len(res.Data) > 0 {
dtos := qldtCourseItemsToDTO(res.Data)
_ = internalDb.UpsertClassCourses(db, rkID, dtos)
return c.JSON(fiber.Map{"data": dtos, "source": "qldt"})
}
liveErr = err
} else {
liveErr = fmt.Errorf("QLDT_TOKEN chưa cấu hình")
}
cached, err := internalDb.ListClassCourses(db, rkID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
if len(cached) > 0 {
resp := fiber.Map{
"data": cached,
"source": "cache",
"cached": true,
}
if liveErr != nil {
resp["warning"] = liveErr.Error()
}
return c.JSON(resp)
}
if liveErr != nil {
return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{
"error": liveErr.Error(),
"hint": "Cập nhật QLDT_TOKEN trong server/.env rồi chạy Sync lớp học để tải danh sách môn.",
})
}
return c.JSON(fiber.Map{"data": []internalDb.ClassCourseDTO{}})
}
}
func qldtCourseItemsToDTO(items []qldt.CourseItem) []internalDb.ClassCourseDTO {
out := make([]internalDb.ClassCourseDTO, 0, len(items))
for _, it := range items {
if it.ID <= 0 {
continue
}
out = append(out, internalDb.ClassCourseDTO{
ID: it.ID,
Name: it.Name,
CourseCode: it.CourseCode,
})
}
return out
}
// POST /api/classes/:rkId/schedule/apply-template
func ApplyScheduleTemplateHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
rkID, err := strconv.ParseInt(c.Params("rkId"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid class rkId"})
}
created := 0
skipped := 0
for day := 0; day <= 4; day++ {
for _, slot := range standardShiftTemplate {
var existing int64
db.Model(&models.ClassSchedule{}).
Where("class_rk_id = ? AND day_of_week = ? AND period = ?", rkID, day, slot.Period).
Count(&existing)
if existing > 0 {
skipped++
continue
}
row := models.ClassSchedule{
ClassRkID: rkID,
DayOfWeek: day,
Period: slot.Period,
StartTime: slot.StartTime,
EndTime: slot.EndTime,
CourseID: 0,
CourseName: "",
IsActive: false,
}
if err := db.Create(&row).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
created++
}
}
return c.JSON(fiber.Map{"ok": true, "created": created, "skipped": skipped})
}
}
type attendanceRow struct {
StudentRkID int64 `json:"studentRkId"`
StudentCode string `json:"studentCode"`
FullName string `json:"fullName"`
Email string `json:"email"`
Status int `json:"status"`
StatusLabel string `json:"statusLabel"`
OnlineMinutes int `json:"onlineMinutes"`
StatusEditedByTeacher bool `json:"statusEditedByTeacher"`
PushedToQLDTAt *time.Time `json:"pushedToQldtAt,omitempty"`
}
func resolveScheduleForPeriod(db *gorm.DB, classRkID int64, dayOfWeek, period int) (*models.ClassSchedule, error) {
var row models.ClassSchedule
err := db.Where("class_rk_id = ? AND day_of_week = ? AND period = ?", classRkID, dayOfWeek, period).First(&row).Error
if err == nil {
return &row, nil
}
var slots []models.ClassSchedule
if err := db.Where("class_rk_id = ? AND day_of_week = ?", classRkID, dayOfWeek).
Order("start_time").Find(&slots).Error; err != nil {
return nil, err
}
if period < 1 || period > len(slots) {
return nil, gorm.ErrRecordNotFound
}
return &slots[period-1], nil
}
func recalcAttendanceForPeriod(db *gorm.DB, classRkID int64, date string, period int, dayOfWeek int) error {
sched, err := resolveScheduleForPeriod(db, classRkID, dayOfWeek, period)
if err != nil {
return err
}
shiftMins := qldt.ShiftDurationMinutes(sched.StartTime, sched.EndTime)
var mappings []models.ClassStudent
if err := db.Where("class_rk_id = ?", classRkID).Find(&mappings).Error; err != nil {
return err
}
for _, m := range mappings {
var session models.StudentSession
onlineMins := 0
periods := []int{period}
if period == 1 {
periods = append(periods, 0)
}
err := db.Where("student_rk_id = ? AND class_rk_id = ? AND session_date = ? AND period IN ?",
m.StudentRkID, classRkID, date, periods).
Order("period DESC").First(&session).Error
if err == nil {
onlineMins = session.OnlineSeconds / 60
}
status := qldt.InferStatusFromOnlineMinutes(onlineMins, shiftMins)
label := qldt.AttendanceStatusLabel(status)
row := models.AttendanceResult{
ClassRkID: classRkID,
SessionDate: date,
Period: period,
StudentRkID: m.StudentRkID,
Status: status,
StatusLabel: label,
OnlineMinutes: onlineMins,
}
if err := db.Clauses(clause.OnConflict{
Columns: []clause.Column{
{Name: "class_rk_id"},
{Name: "session_date"},
{Name: "period"},
{Name: "student_rk_id"},
},
DoUpdates: clause.Assignments(map[string]interface{}{
"online_minutes": gorm.Expr("VALUES(online_minutes)"),
"status": gorm.Expr("IF(status_edited_by_teacher, status, VALUES(status))"),
"status_label": gorm.Expr("IF(status_edited_by_teacher, status_label, VALUES(status_label))"),
"updated_at": time.Now(),
}),
}).Create(&row).Error; err != nil {
return err
}
}
return nil
}
func scheduleDayFromDate(date string) (int, error) {
t, err := time.Parse("2006-01-02", date)
if err != nil {
return 0, err
}
wd := t.Weekday()
if wd == time.Sunday {
return 6, nil
}
return int(wd) - 1, nil
}
// GET /api/classes/:rkId/attendance?date=&period=
func GetClassAttendanceHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
classRkID, _ := strconv.ParseInt(c.Params("rkId"), 10, 64)
date := c.Query("date", qldt.NowDateVN())
period, _ := strconv.Atoi(c.Query("period", "1"))
if period < 1 {
period = 1
}
dayOfWeek, err := scheduleDayFromDate(date)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid date"})
}
_ = recalcAttendanceForPeriod(db, classRkID, date, period, dayOfWeek)
var mappings []models.ClassStudent
db.Where("class_rk_id = ?", classRkID).Find(&mappings)
studentIDs := make([]int64, 0, len(mappings))
for _, m := range mappings {
studentIDs = append(studentIDs, m.StudentRkID)
}
if len(studentIDs) == 0 {
return c.JSON(fiber.Map{"data": []any{}, "period": period, "date": date})
}
var students []models.Student
db.Where("rk_id IN ?", studentIDs).Find(&students)
var results []models.AttendanceResult
db.Where("class_rk_id = ? AND session_date = ? AND period = ?", classRkID, date, period).Find(&results)
resMap := map[int64]models.AttendanceResult{}
for _, r := range results {
resMap[r.StudentRkID] = r
}
out := make([]attendanceRow, 0, len(students))
for _, st := range students {
r, ok := resMap[st.RkID]
row := attendanceRow{
StudentRkID: st.RkID,
StudentCode: st.StudentCode,
FullName: st.FullName,
Email: st.Email,
}
if ok {
row.Status = r.Status
row.StatusLabel = r.StatusLabel
row.OnlineMinutes = r.OnlineMinutes
row.StatusEditedByTeacher = r.StatusEditedByTeacher
row.PushedToQLDTAt = r.PushedToQLDTAt
}
out = append(out, row)
}
var shiftPushedAt *time.Time
qldtDirty := false
for _, r := range results {
if r.PushedToQLDTAt != nil {
if shiftPushedAt == nil || r.PushedToQLDTAt.After(*shiftPushedAt) {
t := *r.PushedToQLDTAt
shiftPushedAt = &t
}
if r.UpdatedAt.After(*r.PushedToQLDTAt) {
qldtDirty = true
}
}
}
sched, _ := resolveScheduleForPeriod(db, classRkID, dayOfWeek, period)
shiftInfo := fiber.Map{
"pushedToQldtAt": shiftPushedAt,
"qldtDirty": qldtDirty,
}
if sched != nil {
shiftInfo["startTime"] = sched.StartTime
shiftInfo["endTime"] = sched.EndTime
shiftInfo["courseId"] = sched.CourseID
shiftInfo["courseName"] = sched.CourseName
shiftInfo["isActive"] = sched.IsActive
}
return c.JSON(fiber.Map{"data": out, "period": period, "date": date, "shift": shiftInfo})
}
}
// PUT /api/classes/:rkId/attendance/status
func UpdateAttendanceStatusHandler(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"`
StudentRkID int64 `json:"studentRkId"`
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
}
label := qldt.AttendanceStatusLabel(req.Status)
var row models.AttendanceResult
err := db.Where("class_rk_id = ? AND session_date = ? AND period = ? AND student_rk_id = ?",
classRkID, req.Date, req.Period, req.StudentRkID).First(&row).Error
if err == gorm.ErrRecordNotFound {
row = models.AttendanceResult{
ClassRkID: classRkID,
SessionDate: req.Date,
Period: req.Period,
StudentRkID: req.StudentRkID,
Status: req.Status,
StatusLabel: label,
StatusEditedByTeacher: true,
}
if err := db.Create(&row).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
} else if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
} else {
row.Status = req.Status
row.StatusLabel = label
row.StatusEditedByTeacher = true
if err := db.Save(&row).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
}
return c.JSON(fiber.Map{"ok": true, "data": row})
}
}
// 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) 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"`
}
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
}
token := GetQldtToken(db)
if token == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "QLDT_TOKEN chưa cấu hình"})
}
dayOfWeek, _ := scheduleDayFromDate(req.Date)
sched, err := resolveScheduleForPeriod(db, classRkID, dayOfWeek, req.Period)
if err != nil || sched == nil || sched.CourseID == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Ca học chưa cấu hình môn học (courseId)"})
}
userID, err := qldt.ParseUserIDFromToken(token)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Không đọc được users_id từ QLDT_TOKEN: " + err.Error()})
}
var results []models.AttendanceResult
db.Where("class_rk_id = ? AND session_date = ? AND period = ?", classRkID, req.Date, req.Period).Find(&results)
if len(results) == 0 {
_ = recalcAttendanceForPeriod(db, classRkID, req.Date, req.Period, dayOfWeek)
db.Where("class_rk_id = ? AND session_date = ? AND period = ?", classRkID, req.Date, req.Period).Find(&results)
}
students := make([]qldt.AttendanceStudent, 0, len(results))
for _, r := range results {
students = append(students, qldt.AttendanceStudent{
StudentID: r.StudentRkID,
Status: r.Status,
})
}
payload := &qldt.AttendancePayload{
UsersID: userID,
CoursesID: int(sched.CourseID),
Period: req.Period,
Students: students,
ClassID: classRkID,
Date: req.Date,
Type: "OFFLINE",
}
body, code, err := qldtClient.SaveAttendance(context.Background(), token, payload)
if err != nil {
return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{"error": err.Error(), "statusCode": code, "body": string(body)})
}
now := time.Now()
db.Model(&models.AttendanceResult{}).
Where("class_rk_id = ? AND session_date = ? AND period = ?", classRkID, req.Date, req.Period).
Update("pushed_to_qldt_at", now)
return c.JSON(fiber.Map{"ok": true, "message": "Đã đẩy điểm danh lên QLĐT", "studentCount": len(students)})
}
}
// GET /api/classes/:rkId/attendance/shifts?date=
func ListAttendanceShiftsHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
classRkID, _ := strconv.ParseInt(c.Params("rkId"), 10, 64)
date := c.Query("date", qldt.NowDateVN())
dayOfWeek, err := scheduleDayFromDate(date)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid date"})
}
var slots []models.ClassSchedule
db.Where("class_rk_id = ? AND day_of_week = ?", classRkID, dayOfWeek).
Order("period, start_time").Find(&slots)
if len(slots) == 0 {
for p := 1; p <= 4; p++ {
slots = append(slots, models.ClassSchedule{
Period: p, DayOfWeek: dayOfWeek, IsActive: false,
})
}
}
out := make([]fiber.Map, 0, len(slots))
for _, s := range slots {
period := s.Period
if period == 0 {
period = 1
}
out = append(out, fiber.Map{
"period": period,
"startTime": s.StartTime,
"endTime": s.EndTime,
"courseId": s.CourseID,
"courseName": s.CourseName,
"isActive": s.IsActive,
})
}
return c.JSON(fiber.Map{"data": out, "date": date, "dayOfWeek": dayOfWeek})
}
}
// GET /api/classes/:rkId/leave-requests
func GetLeaveRequestsHandler(db *gorm.DB, qldtClient *qldt.Client) fiber.Handler {
return func(c *fiber.Ctx) error {
classRkID, _ := strconv.ParseInt(c.Params("rkId"), 10, 64)
courseID, _ := strconv.ParseInt(c.Query("courseId"), 10, 64)
date := c.Query("date", qldt.NowDateVN())
if courseID == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Missing courseId"})
}
token := GetQldtToken(db)
if token == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "QLDT_TOKEN chưa cấu hình"})
}
body, err := qldtClient.GetLeaveRequests(context.Background(), token, classRkID, courseID, date)
if err != nil {
return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{"error": err.Error()})
}
var data []map[string]interface{}
if err := json.Unmarshal(body, &data); err != nil {
var fallback any
if errFallback := json.Unmarshal(body, &fallback); errFallback == nil {
return c.JSON(fallback)
}
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to parse QLDT response"})
}
// Ghi đè trạng thái duyệt local nếu có
var localReqs []models.LocalLeaveRequest
if err := db.Find(&localReqs).Error; err == nil && len(localReqs) > 0 {
localMap := make(map[int64]string)
for _, lr := range localReqs {
localMap[lr.LeaveID] = lr.Status
}
for i, item := range data {
if idVal, ok := item["id"]; ok {
var idInt int64
switch v := idVal.(type) {
case float64:
idInt = int64(v)
case int64:
idInt = v
case int:
idInt = int64(v)
}
if localStatus, found := localMap[idInt]; found {
data[i]["status"] = localStatus
}
}
}
}
return c.JSON(data)
}
}
// POST /api/classes/:rkId/leave-requests/:leaveId/status
func UpdateLeaveStatusHandler(db *gorm.DB, qldtClient *qldt.Client) fiber.Handler {
return func(c *fiber.Ctx) error {
classRkID, _ := strconv.ParseInt(c.Params("rkId"), 10, 64)
leaveID, _ := strconv.ParseInt(c.Params("leaveId"), 10, 64)
var req struct {
Status string `json:"status"` // "Phê duyệt" or "Từ chối"
StudentRkID int64 `json:"studentRkId"`
Date string `json:"date"`
Period int `json:"period"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid body payload"})
}
if req.Status != "Phê duyệt" && req.Status != "Từ chối" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Trạng thái phê duyệt không hợp lệ"})
}
// 1. Lưu trạng thái đơn phép vào database local thay vì gọi QLDT Portal API
var localLeave models.LocalLeaveRequest
errLocal := db.Where("leave_id = ?", leaveID).First(&localLeave).Error
if errLocal == gorm.ErrRecordNotFound {
localLeave = models.LocalLeaveRequest{
LeaveID: leaveID,
Status: req.Status,
}
if err := db.Create(&localLeave).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Lỗi lưu đơn phép local: " + err.Error()})
}
} else if errLocal == nil {
localLeave.Status = req.Status
if err := db.Save(&localLeave).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Lỗi cập nhật đơn phép local: " + err.Error()})
}
} else {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": errLocal.Error()})
}
// 2. Nếu status là "Phê duyệt", tự động cập nhật điểm danh trong hệ thống của chúng ta thành "Nghỉ có phép"
if req.Status == "Phê duyệt" {
if req.StudentRkID == 0 || req.Date == "" || req.Period == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Thiếu thông tin cập nhật điểm danh (studentRkId, date, period)"})
}
dayOfWeek, err := scheduleDayFromDate(req.Date)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Ngày không hợp lệ"})
}
sched, err := resolveScheduleForPeriod(db, classRkID, dayOfWeek, req.Period)
if err != nil || sched == nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Không tìm thấy lịch học cho ca này"})
}
// Cập nhật hoặc Tạo mới AttendanceResult
var row models.AttendanceResult
errFind := db.Where("class_rk_id = ? AND session_date = ? AND period = ? AND student_rk_id = ?",
classRkID, req.Date, req.Period, req.StudentRkID).First(&row).Error
if errFind == nil {
// Cập nhật hàng có sẵn
row.Status = 1 // Nghỉ có phép
row.StatusLabel = "Nghỉ có phép"
row.StatusEditedByTeacher = true
if err := db.Save(&row).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Lỗi lưu điểm danh: " + err.Error()})
}
} else if errFind == gorm.ErrRecordNotFound {
// Tạo mới hàng
row = models.AttendanceResult{
ClassRkID: classRkID,
SessionDate: req.Date,
Period: req.Period,
StudentRkID: req.StudentRkID,
Status: 1,
StatusLabel: "Nghỉ có phép",
StatusEditedByTeacher: true,
}
if err := db.Create(&row).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Lỗi tạo điểm danh: " + err.Error()})
}
} else {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": errFind.Error()})
}
}
return c.JSON(fiber.Map{"ok": true, "message": "Cập nhật đơn xin nghỉ thành công!"})
}
}