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;
|
||||
|
||||
Reference in New Issue
Block a user