This commit is contained in:
2026-06-30 16:11:12 +07:00
parent 74b540cac2
commit 3e53cb6664
5 changed files with 259 additions and 42 deletions

View File

@@ -649,6 +649,21 @@ export const apiUpdateAttendanceStatus = async (
return res.json(); 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) => { export const apiPushAttendanceQLDT = async (rkId: number, date: string, period: number) => {
const res = await staffFetch(`/classes/${rkId}/attendance/push-qldt`, { const res = await staffFetch(`/classes/${rkId}/attendance/push-qldt`, {
method: 'POST', method: 'POST',

View File

@@ -5,6 +5,7 @@ import {
apiFetchAttendanceShifts, apiFetchAttendanceShifts,
apiPushAttendanceQLDT, apiPushAttendanceQLDT,
apiUpdateAttendanceStatus, apiUpdateAttendanceStatus,
apiUpdateAttendanceBulkStatus,
attendanceStatusClass, attendanceStatusClass,
type AttendanceRow, type AttendanceRow,
type AttendanceShiftInfo, type AttendanceShiftInfo,
@@ -34,6 +35,7 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [pushing, setPushing] = useState(false); const [pushing, setPushing] = useState(false);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [selectedStudentRkIds, setSelectedStudentRkIds] = useState<number[]>([]);
const loadShifts = useCallback(async () => { const loadShifts = useCallback(async () => {
try { try {
@@ -53,6 +55,7 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
const res = await apiFetchAttendance(classId, date, period); const res = await apiFetchAttendance(classId, date, period);
setRows(res.data || []); setRows(res.data || []);
setShiftInfo(res.shift || null); setShiftInfo(res.shift || null);
setSelectedStudentRkIds([]); // Clear selection when data changes
} catch (e: any) { } catch (e: any) {
alert(e.message || 'Không tải được điểm danh'); alert(e.message || 'Không tải được điểm danh');
} finally { } finally {
@@ -83,6 +86,12 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
return counts; return counts;
}, [rows]); }, [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) => { const handleStatusChange = async (studentRkId: number, status: number) => {
try { try {
await apiUpdateAttendanceStatus(classId, { date, period, studentRkId, status }); 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 () => { const handlePushQLDT = async () => {
if (!shiftInfo?.courseId) { 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.'); 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 đè. Sửa trạng thái thủ công sẽ đưc khóa hệ thống tự tính sẽ không ghi đè.
</p> </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 &quot;{opt.label}&quot;
</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"> <div className="attendance-table-scroll table-wrapper">
{loading ? ( {loading ? (
<div className="empty-state"><div className="sync-spinner" style={{ width: 28, height: 28 }} /></div> <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"> <table className="data-table attendance-table">
<thead> <thead>
<tr> <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>Sinh viên</th>
<th>Online</th> <th>Online</th>
<th>Trạng thái</th> <th>Trạng thái</th>
@@ -229,14 +302,33 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
<tbody> <tbody>
{filteredRows.length === 0 ? ( {filteredRows.length === 0 ? (
<tr> <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'} {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> </td>
</tr> </tr>
) : ( ) : (
filteredRows.map(row => ( filteredRows.map(row => {
const isSelected = selectedStudentRkIds.includes(row.studentRkId);
return (
<tr key={row.studentRkId} className={attendanceStatusClass(row.status)}> <tr key={row.studentRkId} className={attendanceStatusClass(row.status)}>
<td> <td
style={{ textAlign: 'center', cursor: 'pointer' }}
onClick={() => toggleStudentSelection(row.studentRkId)}
>
<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-name">{row.fullName}</div>
<div className="attendance-student-meta"> <div className="attendance-student-meta">
<code>{row.studentCode}</code> <code>{row.studentCode}</code>
@@ -275,7 +367,8 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
</div> </div>
</td> </td>
</tr> </tr>
)) );
})
)} )}
</tbody> </tbody>
</table> </table>

View File

@@ -2102,6 +2102,47 @@ input:checked + .slider:before {
font-weight: 600; 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 */ /* Trạng thái điểm danh — màu theo status 0..4 */
.attendance-status--0, .attendance-status--0,
.attendance-summary-chip.attendance-status--0 { .attendance-summary-chip.attendance-status--0 {
@@ -2156,6 +2197,10 @@ input:checked + .slider:before {
background: var(--att-bg); background: var(--att-bg);
} }
.attendance-table tbody tr:hover {
filter: brightness(0.97);
}
.attendance-student-name { .attendance-student-name {
font-weight: 700; font-weight: 700;
font-size: 0.88rem; font-size: 0.88rem;

View File

@@ -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 // POST /api/classes/:rkId/attendance/push-qldt
func PushAttendanceToQLDTHandler(db *gorm.DB, qldtClient *qldt.Client, token string) fiber.Handler { func PushAttendanceToQLDTHandler(db *gorm.DB, qldtClient *qldt.Client, token string) fiber.Handler {
return func(c *fiber.Ctx) error { return func(c *fiber.Ctx) error {

View File

@@ -158,6 +158,7 @@ func main() {
staff.Get("/classes/:rkId/attendance", handlers.GetClassAttendanceHandler(gormDB)) staff.Get("/classes/:rkId/attendance", handlers.GetClassAttendanceHandler(gormDB))
staff.Get("/classes/:rkId/attendance/shifts", handlers.ListAttendanceShiftsHandler(gormDB)) staff.Get("/classes/:rkId/attendance/shifts", handlers.ListAttendanceShiftsHandler(gormDB))
staff.Put("/classes/:rkId/attendance/status", handlers.UpdateAttendanceStatusHandler(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)) staff.Post("/classes/:rkId/attendance/push-qldt", handlers.PushAttendanceToQLDTHandler(gormDB, qldtClient, qldtToken))
// Chat & quản lý tài khoản // Chat & quản lý tài khoản