This commit is contained in:
@@ -16,6 +16,7 @@ import {
|
||||
|
||||
interface AttendancePanelProps {
|
||||
classId: number;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
function formatQldtTime(iso?: string): string {
|
||||
@@ -28,7 +29,7 @@ function formatQldtTime(iso?: string): string {
|
||||
});
|
||||
}
|
||||
|
||||
export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) => {
|
||||
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);
|
||||
@@ -231,7 +232,25 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
|
||||
const qldtDirty = Boolean(shiftInfo?.qldtDirty);
|
||||
|
||||
return (
|
||||
<div className="attendance-panel">
|
||||
<div className="modal-overlay attendance-modal-overlay">
|
||||
<div className="modal-container attendance-modal-container">
|
||||
<div className="modal-header attendance-modal-header" style={{ padding: '0.75rem 1.25rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<h3 className="modal-title" style={{ margin: 0, fontSize: '1.1rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
📋 Quản lý điểm danh ca học
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="modal-close-btn"
|
||||
style={{ fontSize: '1.5rem', border: 'none', background: 'none', color: 'var(--text-muted)', cursor: 'pointer', padding: '0 0.5rem' }}
|
||||
onClick={onClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body attendance-modal-body" style={{ padding: '1rem', display: 'flex', flexDirection: 'column', gap: '0.75rem', flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||
<div className="attendance-toolbar">
|
||||
<label className="attendance-field">
|
||||
<span>Ngày</span>
|
||||
@@ -370,13 +389,13 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
|
||||
|
||||
{/* Leave Requests Approval 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.
|
||||
Ca {period} ngày {date} - Giao diện duyệt đơn xin nghỉ học.
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
@@ -395,6 +414,11 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-body" style={{ padding: '1rem', overflowY: 'auto', maxHeight: '60vh', display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
<div style={{ padding: '0.65rem 0.85rem', background: '#e0f2fe', border: '1px solid #bae6fd', borderRadius: '6px', color: '#0369a1', fontSize: '0.82rem', display: 'flex', flexDirection: 'column', gap: '0.25rem', lineHeight: '1.4' }}>
|
||||
<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>
|
||||
<div style={{ paddingLeft: '1.15rem' }}>• Khi duyệt đơn, hệ thống sẽ tự động đổi trạng thái điểm danh của sinh viên này thành <strong>"Nghỉ có phép"</strong>.</div>
|
||||
<div style={{ paddingLeft: '1.15rem' }}>• Trạng thái này sẽ được cập nhật lên QLĐT khi thầy cô bấm nút <strong>"Đẩy QLĐT"</strong> ở giao diện điểm danh ca học.</div>
|
||||
</div>
|
||||
{loadingLeave ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '2rem 0' }}>
|
||||
<div className="sync-spinner" style={{ width: 28, height: 28 }} />
|
||||
@@ -624,6 +648,8 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -446,7 +446,7 @@ export const ClassWorkspace: React.FC<ClassWorkspaceProps> = ({ classId, sourceT
|
||||
|
||||
<div className={`workspace-panel-body ${activeSubTab === 'attendance' || activeSubTab === 'logs' || activeSubTab === 'grid' || activeSubTab === 'roster' || activeSubTab === 'violations' ? 'workspace-panel-fill' : ''}`}>
|
||||
{activeSubTab === 'attendance' ? (
|
||||
<AttendancePanel classId={classId} />
|
||||
<AttendancePanel classId={classId} onClose={() => setActiveSubTab('roster')} />
|
||||
) : activeSubTab === 'violations' ? (
|
||||
<ViolationsPanel mode="class" classId={classId} />
|
||||
) : activeSubTab === 'grid' ? (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
apiFetchClassViolations,
|
||||
apiFetchExamViolations,
|
||||
@@ -24,7 +24,7 @@ function formatTime(iso?: string): string {
|
||||
return d.toLocaleString('vi-VN');
|
||||
}
|
||||
|
||||
export const ViolationsPanel: React.FC<Props> = (props) => {
|
||||
export const ViolationsPanel = (props: Props) => {
|
||||
const [date, setDate] = useState(todayLocal);
|
||||
const [kind, setKind] = useState('');
|
||||
const [rows, setRows] = useState<StudentViolationItem[]>([]);
|
||||
@@ -54,6 +54,23 @@ export const ViolationsPanel: React.FC<Props> = (props) => {
|
||||
|
||||
return (
|
||||
<div className="session-logs-panel" style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', height: '100%' }}>
|
||||
{props.mode === 'exam' && (
|
||||
<div style={{
|
||||
padding: '0.65rem 0.85rem',
|
||||
background: '#fef3c7',
|
||||
border: '1px solid #fcd34d',
|
||||
borderRadius: '6px',
|
||||
color: '#92400e',
|
||||
fontSize: '0.82rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.35rem',
|
||||
margin: '0',
|
||||
lineHeight: '1.4'
|
||||
}}>
|
||||
⚠️ <strong>Lưu ý:</strong> 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 thu thập dữ liệu theo các loại máy.
|
||||
</div>
|
||||
)}
|
||||
<div className="attendance-toolbar" style={{ marginBottom: 0, flexWrap: 'wrap' }}>
|
||||
<label className="attendance-field">
|
||||
<span>Ngày</span>
|
||||
|
||||
@@ -2037,6 +2037,40 @@ input:checked + .slider:before {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.attendance-modal-overlay {
|
||||
z-index: 150 !important;
|
||||
background-color: rgba(15, 23, 42, 0.6) !important;
|
||||
padding: 1.5rem;
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.attendance-modal-container {
|
||||
width: 98vw !important;
|
||||
height: 96vh !important;
|
||||
max-width: 1440px !important;
|
||||
max-height: 96vh !important;
|
||||
border-radius: var(--radius-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--bg-app);
|
||||
box-shadow: var(--shadow-2xl);
|
||||
border: 1px solid var(--border-color);
|
||||
animation: modalScaleUp 0.2s ease-out;
|
||||
}
|
||||
|
||||
.attendance-modal-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.attendance-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -73,6 +73,7 @@ func AutoMigrate(db *gorm.DB) error {
|
||||
&models.GuideLink{},
|
||||
&models.AppDownload{},
|
||||
&models.AppGuide{},
|
||||
&models.LocalLeaveRequest{},
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -255,4 +255,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" }
|
||||
|
||||
|
||||
|
||||
|
||||
BIN
server/server_test.exe
Normal file
BIN
server/server_test.exe
Normal file
Binary file not shown.
Reference in New Issue
Block a user