diff --git a/management/src/api.ts b/management/src/api.ts index 6f28e3a..40acb1e 100644 --- a/management/src/api.ts +++ b/management/src/api.ts @@ -813,6 +813,55 @@ export const apiPushAttendanceQLDT = async (rkId: number, date: string, period: return res.json(); }; +export interface LeaveRequestItem { + id: number; + date: string; + note: string; + reasonImage?: string; + rejectReason?: string | null; + period: number; + status: string; // 'Đang chờ', 'Phê duyệt', 'Từ chối' + approverId?: number | null; + createdAt: string; + student: { + id: number; + studentCode: string; + fullName: string; + phone?: string; + email: string; + avatar?: string; + }; +} + +export const apiFetchLeaveRequests = async ( + classId: number, + courseId: number, + date: string +): Promise => { + const res = await staffFetch(`/classes/${classId}/leave-requests?courseId=${courseId}&date=${date}`); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error || 'Failed to fetch leave requests'); + } + return res.json(); +}; + +export const apiUpdateLeaveStatus = async ( + classId: number, + leaveId: number, + payload: { status: string; studentRkId: number; date: string; period: number } +): Promise<{ ok: boolean; message: string }> => { + const res = await staffFetch(`/classes/${classId}/leave-requests/${leaveId}/status`, { + method: 'POST', + body: JSON.stringify(payload), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error || 'Failed to update leave status'); + } + return res.json(); +}; + async function staffUpload(path: string, form: FormData): Promise { const headers = new Headers(); const token = getToken(); diff --git a/management/src/components/AttendancePanel.tsx b/management/src/components/AttendancePanel.tsx index 69502e0..a01b9ba 100644 --- a/management/src/components/AttendancePanel.tsx +++ b/management/src/components/AttendancePanel.tsx @@ -9,6 +9,9 @@ import { attendanceStatusClass, type AttendanceRow, type AttendanceShiftInfo, + apiFetchLeaveRequests, + apiUpdateLeaveStatus, + type LeaveRequestItem, } from '../api'; interface AttendancePanelProps { @@ -37,6 +40,8 @@ export const AttendancePanel: React.FC = ({ classId }) => const [searchQuery, setSearchQuery] = useState(''); const [selectedStudentRkIds, setSelectedStudentRkIds] = useState([]); const [isCollapsed, setIsCollapsed] = useState(false); + const [leaveRequests, setLeaveRequests] = useState([]); + const [loadingLeave, setLoadingLeave] = useState(false); const handleScroll = useCallback((e: React.UIEvent) => { const scrollTop = e.currentTarget.scrollTop; @@ -74,8 +79,46 @@ export const AttendancePanel: React.FC = ({ classId }) => } }, [classId, date, period]); + const currentShift = useMemo(() => { + return shifts.find(s => s.period === period); + }, [shifts, period]); + + const courseId = currentShift?.courseId; + + const loadLeaveRequests = useCallback(async () => { + if (!courseId) { + setLeaveRequests([]); + return; + } + try { + setLoadingLeave(true); + const res = await apiFetchLeaveRequests(classId, courseId, date); + setLeaveRequests(res || []); + } catch { + setLeaveRequests([]); + } finally { + setLoadingLeave(false); + } + }, [classId, courseId, date]); + useEffect(() => { loadShifts(); }, [loadShifts]); useEffect(() => { loadAttendance(); }, [loadAttendance]); + useEffect(() => { loadLeaveRequests(); }, [loadLeaveRequests]); + + const handleUpdateLeaveStatus = async (leaveId: number, status: string, studentRkId: number) => { + try { + await apiUpdateLeaveStatus(classId, leaveId, { + status, + studentRkId, + date, + period, + }); + await loadAttendance(); + await loadLeaveRequests(); + } catch (e: any) { + alert(e.message || 'Cập nhật đơn xin nghỉ thất bại'); + } + }; const filteredRows = useMemo(() => { const q = searchQuery.trim().toLowerCase(); @@ -183,7 +226,6 @@ export const AttendancePanel: React.FC = ({ classId }) => } }; - const currentShift = shifts.find(s => s.period === period); const qldtSynced = Boolean(shiftInfo?.pushedToQldtAt); const qldtDirty = Boolean(shiftInfo?.qldtDirty); @@ -300,6 +342,133 @@ export const AttendancePanel: React.FC = ({ classId }) => + {/* Leave Requests Approval Section */} + {courseId && ( +
+
+

+ ✉️ Đơn xin nghỉ phép trong ca + + {leaveRequests.length} đơn + +

+ +
+ + {loadingLeave ? ( +
+
+
+ ) : leaveRequests.length === 0 ? ( +

+ Không có đơn xin nghỉ phép nào cho ca học này trong ngày hôm nay. +

+ ) : ( +
+ {leaveRequests.map((req) => ( +
+
+
+
{req.student.fullName}
+ {req.student.studentCode} + + {req.status} + +
+ + {req.status === 'Đang chờ' && ( +
+ + +
+ )} +
+ +
+ Lý do nghỉ: {req.note || 'Không có ghi chú'} +
+ + {req.reasonImage && ( + + )} +
+ ))} +
+ )} +
+ )} + {selectedStudentRkIds.length > 0 && (
diff --git a/server/internal/handlers/handlers_attendance.go b/server/internal/handlers/handlers_attendance.go index 86bd8cf..6992db2 100644 --- a/server/internal/handlers/handlers_attendance.go +++ b/server/internal/handlers/handlers_attendance.go @@ -2,6 +2,7 @@ package handlers import ( "context" + "encoding/json" "fmt" "strconv" "time" @@ -542,3 +543,116 @@ func ListAttendanceShiftsHandler(db *gorm.DB) fiber.Handler { 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 any + if err := json.Unmarshal(body, &data); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to parse QLDT response"}) + } + + 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ệ"}) + } + + 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)}) + } + + // 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!"}) + } +} diff --git a/server/internal/qldt/client.go b/server/internal/qldt/client.go index cc7b38d..b5922ae 100644 --- a/server/internal/qldt/client.go +++ b/server/internal/qldt/client.go @@ -1,6 +1,7 @@ package qldt import ( + "bytes" "context" "encoding/json" "fmt" @@ -198,3 +199,65 @@ func (c *Client) GetClassDashboard(ctx context.Context, token string, classID, c } return body, nil } + +func (c *Client) GetLeaveRequests(ctx context.Context, token string, classID, courseID int64, date string) ([]byte, error) { + u := fmt.Sprintf("%s/request-leave?classId=%d&courseId=%d&date=%s", c.baseURL, classID, courseID, date) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, err + } + c.setAuthHeaders(req, token) + + resp, err := c.http.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("qldt request-leave http %d: %s", resp.StatusCode, string(body)) + } + return body, nil +} + +func (c *Client) UpdateLeaveStatus(ctx context.Context, token string, leaveID int64, status string) ([]byte, error) { + payload := map[string]string{"status": status} + jsonData, _ := json.Marshal(payload) + + u := fmt.Sprintf("%s/request-leave/%d/status", c.baseURL, leaveID) + req, err := http.NewRequestWithContext(ctx, http.MethodPatch, u, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + c.setAuthHeaders(req, token) + + resp, err := c.http.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + // Fallback to POST method if PATCH is Method Not Allowed or Not Found + if resp.StatusCode == http.StatusMethodNotAllowed || resp.StatusCode == http.StatusNotFound { + req2, err2 := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewBuffer(jsonData)) + if err2 == nil { + req2.Header.Set("Content-Type", "application/json") + c.setAuthHeaders(req2, token) + resp2, errOr := c.http.Do(req2) + if errOr == nil { + defer resp2.Body.Close() + body2, _ := io.ReadAll(resp2.Body) + if resp2.StatusCode >= 200 && resp2.StatusCode < 300 { + return body2, nil + } + } + } + } + return nil, fmt.Errorf("qldt request-leave/%d/status http %d: %s", leaveID, resp.StatusCode, string(body)) + } + return body, nil +} diff --git a/server/main.go b/server/main.go index 161622b..4ff149a 100644 --- a/server/main.go +++ b/server/main.go @@ -166,6 +166,8 @@ func main() { 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)) + staff.Get("/classes/:rkId/leave-requests", handlers.GetLeaveRequestsHandler(gormDB, qldtClient)) + staff.Post("/classes/:rkId/leave-requests/:leaveId/status", handlers.UpdateLeaveStatusHandler(gormDB, qldtClient)) // Chat & quản lý tài khoản staff.Get("/chat/students", handlers.SearchChatStudentsHandler(gormDB)) diff --git a/server/server.exe b/server/server.exe index 69959f6..5238448 100644 Binary files a/server/server.exe and b/server/server.exe differ