fix ui
This commit is contained in:
@@ -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',
|
||||||
|
|||||||
@@ -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 "{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">
|
<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,53 +302,73 @@ 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 => {
|
||||||
<tr key={row.studentRkId} className={attendanceStatusClass(row.status)}>
|
const isSelected = selectedStudentRkIds.includes(row.studentRkId);
|
||||||
<td>
|
return (
|
||||||
<div className="attendance-student-name">{row.fullName}</div>
|
<tr key={row.studentRkId} className={attendanceStatusClass(row.status)}>
|
||||||
<div className="attendance-student-meta">
|
<td
|
||||||
<code>{row.studentCode}</code>
|
style={{ textAlign: 'center', cursor: 'pointer' }}
|
||||||
{row.email && <span>{row.email}</span>}
|
onClick={() => toggleStudentSelection(row.studentRkId)}
|
||||||
</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 => (
|
<input
|
||||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
type="checkbox"
|
||||||
))}
|
checked={isSelected}
|
||||||
</select>
|
onChange={() => {}}
|
||||||
</td>
|
onClick={(e) => {
|
||||||
<td>
|
e.stopPropagation();
|
||||||
<div className="attendance-notes">
|
toggleStudentSelection(row.studentRkId);
|
||||||
{row.pushedToQldtAt ? (
|
}}
|
||||||
<span className="attendance-qldt-tag attendance-qldt-tag--ok" title={row.pushedToQldtAt}>
|
/>
|
||||||
QLĐT ✓
|
</td>
|
||||||
</span>
|
<td
|
||||||
) : (
|
style={{ cursor: 'pointer' }}
|
||||||
<span className="attendance-qldt-tag attendance-qldt-tag--pending">Chưa QLĐT</span>
|
onClick={() => toggleStudentSelection(row.studentRkId)}
|
||||||
)}
|
>
|
||||||
{row.statusEditedByTeacher && (
|
<div className="attendance-student-name">{row.fullName}</div>
|
||||||
<span className="attendance-lock-tag" title="Giáo viên đã sửa — không bị ghi đè">
|
<div className="attendance-student-meta">
|
||||||
Đã khóa
|
<code>{row.studentCode}</code>
|
||||||
</span>
|
{row.email && <span>{row.email}</span>}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</td>
|
||||||
</td>
|
<td>
|
||||||
</tr>
|
<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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user