fix ui
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
apiFetchAttendanceShifts,
|
||||
apiPushAttendanceQLDT,
|
||||
apiUpdateAttendanceStatus,
|
||||
apiUpdateAttendanceBulkStatus,
|
||||
attendanceStatusClass,
|
||||
type AttendanceRow,
|
||||
type AttendanceShiftInfo,
|
||||
@@ -34,6 +35,7 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pushing, setPushing] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedStudentRkIds, setSelectedStudentRkIds] = useState<number[]>([]);
|
||||
|
||||
const loadShifts = useCallback(async () => {
|
||||
try {
|
||||
@@ -53,6 +55,7 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ 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<AttendancePanelProps> = ({ 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<AttendancePanelProps> = ({ 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<AttendancePanelProps> = ({ classId }) =>
|
||||
Sửa trạng thái thủ công sẽ được khóa — hệ thống tự tính sẽ không ghi đè.
|
||||
</p>
|
||||
|
||||
{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">
|
||||
{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>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-muted attendance-bulk-btn-close"
|
||||
style={{ padding: '0.25rem 0.75rem', fontSize: '0.875rem' }}
|
||||
onClick={() => setSelectedStudentRkIds([])}
|
||||
>
|
||||
Hủy chọn
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="attendance-table-scroll table-wrapper">
|
||||
{loading ? (
|
||||
<div className="empty-state"><div className="sync-spinner" style={{ width: 28, height: 28 }} /></div>
|
||||
@@ -220,6 +277,22 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
|
||||
<table className="data-table attendance-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '45px', textAlign: 'center' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filteredRows.length > 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)));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</th>
|
||||
<th>Sinh viên</th>
|
||||
<th>Online</th>
|
||||
<th>Trạng thái</th>
|
||||
@@ -229,53 +302,73 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
|
||||
<tbody>
|
||||
{filteredRows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} style={{ textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||
<td colSpan={5} style={{ textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||
{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>
|
||||
) : (
|
||||
filteredRows.map(row => (
|
||||
<tr key={row.studentRkId} className={attendanceStatusClass(row.status)}>
|
||||
<td>
|
||||
<div className="attendance-student-name">{row.fullName}</div>
|
||||
<div className="attendance-student-meta">
|
||||
<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>
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
className={`attendance-status-select ${attendanceStatusClass(row.status)}`}
|
||||
value={row.status}
|
||||
onChange={e => handleStatusChange(row.studentRkId, Number(e.target.value))}
|
||||
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)}
|
||||
>
|
||||
{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>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => {}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
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">
|
||||
<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>
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
className={`attendance-status-select ${attendanceStatusClass(row.status)}`}
|
||||
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>
|
||||
))}
|
||||
</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>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user