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();
};
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',

View File

@@ -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 &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">
{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>

View File

@@ -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;

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
func PushAttendanceToQLDTHandler(db *gorm.DB, qldtClient *qldt.Client, token string) fiber.Handler {
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/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