All checks were successful
Deploy on Master Change / deploy (push) Successful in 1m7s
630 lines
26 KiB
TypeScript
630 lines
26 KiB
TypeScript
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||
import {
|
||
ATTENDANCE_STATUS_OPTIONS,
|
||
apiFetchAttendance,
|
||
apiFetchAttendanceShifts,
|
||
apiPushAttendanceQLDT,
|
||
apiUpdateAttendanceStatus,
|
||
apiUpdateAttendanceBulkStatus,
|
||
attendanceStatusClass,
|
||
type AttendanceRow,
|
||
type AttendanceShiftInfo,
|
||
apiFetchLeaveRequests,
|
||
apiUpdateLeaveStatus,
|
||
type LeaveRequestItem,
|
||
} from '../api';
|
||
|
||
interface AttendancePanelProps {
|
||
classId: number;
|
||
}
|
||
|
||
function formatQldtTime(iso?: string): string {
|
||
if (!iso) return '';
|
||
return new Date(iso).toLocaleString('vi-VN', {
|
||
day: '2-digit',
|
||
month: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
});
|
||
}
|
||
|
||
export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) => {
|
||
const today = new Date().toISOString().slice(0, 10);
|
||
const [date, setDate] = useState(today);
|
||
const [period, setPeriod] = useState(1);
|
||
const [rows, setRows] = useState<AttendanceRow[]>([]);
|
||
const [shifts, setShifts] = useState<any[]>([]);
|
||
const [shiftInfo, setShiftInfo] = useState<AttendanceShiftInfo | null>(null);
|
||
const [loading, setLoading] = useState(false);
|
||
const [pushing, setPushing] = useState(false);
|
||
const [searchQuery, setSearchQuery] = useState('');
|
||
const [selectedStudentRkIds, setSelectedStudentRkIds] = useState<number[]>([]);
|
||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||
const [leaveRequests, setLeaveRequests] = useState<LeaveRequestItem[]>([]);
|
||
const [loadingLeave, setLoadingLeave] = useState(false);
|
||
const [showLeaveModal, setShowLeaveModal] = useState(false);
|
||
|
||
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||
const scrollTop = e.currentTarget.scrollTop;
|
||
if (scrollTop > 20) {
|
||
setIsCollapsed(true);
|
||
} else if (scrollTop <= 5) {
|
||
setIsCollapsed(false);
|
||
}
|
||
}, []);
|
||
|
||
const loadShifts = useCallback(async () => {
|
||
try {
|
||
const res = await apiFetchAttendanceShifts(classId, date);
|
||
setShifts(res.data || []);
|
||
if (res.data?.length && !res.data.find((s: any) => s.period === period)) {
|
||
setPeriod(res.data[0].period || 1);
|
||
}
|
||
} catch {
|
||
setShifts([]);
|
||
}
|
||
}, [classId, date, period]);
|
||
|
||
const loadAttendance = useCallback(async (isBackground: boolean | any = false) => {
|
||
const isBg = isBackground === true;
|
||
try {
|
||
if (!isBg) setLoading(true);
|
||
const res = await apiFetchAttendance(classId, date, period);
|
||
setRows(res.data || []);
|
||
setShiftInfo(res.shift || null);
|
||
if (!isBg) setSelectedStudentRkIds([]); // Clear selection when data changes
|
||
} catch (e: any) {
|
||
alert(e.message || 'Không tải được điểm danh');
|
||
} finally {
|
||
if (!isBg) setLoading(false);
|
||
}
|
||
}, [classId, date, period]);
|
||
|
||
const currentShift = useMemo(() => {
|
||
return shifts.find(s => s.period === period);
|
||
}, [shifts, period]);
|
||
|
||
const courseId = currentShift?.courseId;
|
||
|
||
const loadLeaveRequests = useCallback(async () => {
|
||
if (!courseId) {
|
||
setLeaveRequests([]);
|
||
return;
|
||
}
|
||
try {
|
||
setLoadingLeave(true);
|
||
const res = await apiFetchLeaveRequests(classId, courseId, date);
|
||
setLeaveRequests(res || []);
|
||
} catch {
|
||
setLeaveRequests([]);
|
||
} finally {
|
||
setLoadingLeave(false);
|
||
}
|
||
}, [classId, courseId, date]);
|
||
|
||
useEffect(() => { loadShifts(); }, [loadShifts]);
|
||
useEffect(() => { loadAttendance(); }, [loadAttendance]);
|
||
useEffect(() => { loadLeaveRequests(); }, [loadLeaveRequests]);
|
||
|
||
const handleUpdateLeaveStatus = async (leaveId: number, status: string, studentRkId: number) => {
|
||
try {
|
||
await apiUpdateLeaveStatus(classId, leaveId, {
|
||
status,
|
||
studentRkId,
|
||
date,
|
||
period,
|
||
});
|
||
await loadAttendance();
|
||
await loadLeaveRequests();
|
||
} catch (e: any) {
|
||
alert(e.message || 'Cập nhật đơn xin nghỉ thất bại');
|
||
}
|
||
};
|
||
|
||
const filteredRows = useMemo(() => {
|
||
const q = searchQuery.trim().toLowerCase();
|
||
if (!q) return rows;
|
||
return rows.filter(row => {
|
||
const haystack = [row.fullName, row.studentCode, row.email, row.statusLabel]
|
||
.filter(Boolean)
|
||
.join(' ')
|
||
.toLowerCase();
|
||
return haystack.includes(q);
|
||
});
|
||
}, [rows, searchQuery]);
|
||
|
||
const statusCounts = useMemo(() => {
|
||
const counts: Record<number, number> = { 0: 0, 1: 0, 2: 0, 3: 0, 4: 0 };
|
||
for (const row of rows) {
|
||
if (counts[row.status] !== undefined) counts[row.status]++;
|
||
}
|
||
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) => {
|
||
// Optimistic state update so the UI reacts instantly
|
||
setRows(prevRows =>
|
||
prevRows.map(row => {
|
||
if (row.studentRkId === studentRkId) {
|
||
const matchedOpt = ATTENDANCE_STATUS_OPTIONS.find(o => o.value === status);
|
||
return {
|
||
...row,
|
||
status,
|
||
statusLabel: matchedOpt ? matchedOpt.label : row.statusLabel,
|
||
statusEditedByTeacher: true,
|
||
};
|
||
}
|
||
return row;
|
||
})
|
||
);
|
||
|
||
try {
|
||
await apiUpdateAttendanceStatus(classId, { date, period, studentRkId, status });
|
||
await loadAttendance(true);
|
||
} catch (e: any) {
|
||
alert(e.message || 'Cập nhật trạng thái thất bại');
|
||
await loadAttendance(); // Rollback on failure
|
||
}
|
||
};
|
||
|
||
const handleBulkStatusChange = async (status: number) => {
|
||
if (selectedStudentRkIds.length === 0) return;
|
||
|
||
// Optimistic state update for selected students
|
||
setRows(prevRows =>
|
||
prevRows.map(row => {
|
||
if (selectedStudentRkIds.includes(row.studentRkId)) {
|
||
const matchedOpt = ATTENDANCE_STATUS_OPTIONS.find(o => o.value === status);
|
||
return {
|
||
...row,
|
||
status,
|
||
statusLabel: matchedOpt ? matchedOpt.label : row.statusLabel,
|
||
statusEditedByTeacher: true,
|
||
};
|
||
}
|
||
return row;
|
||
})
|
||
);
|
||
|
||
const idsToUpdate = [...selectedStudentRkIds];
|
||
setSelectedStudentRkIds([]);
|
||
|
||
try {
|
||
await apiUpdateAttendanceBulkStatus(classId, {
|
||
date,
|
||
period,
|
||
studentRkIds: idsToUpdate,
|
||
status,
|
||
});
|
||
await loadAttendance(true);
|
||
} catch (e: any) {
|
||
alert(e.message || 'Cập nhật hàng loạt thất bại');
|
||
await loadAttendance(); // Rollback
|
||
}
|
||
};
|
||
|
||
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.');
|
||
return;
|
||
}
|
||
if (!confirm(`Đẩy điểm danh Ca ${period} ngày ${date} lên QLĐT?`)) return;
|
||
try {
|
||
setPushing(true);
|
||
const res = await apiPushAttendanceQLDT(classId, date, period);
|
||
alert(res.message || 'Đã đẩy lên QLĐT');
|
||
await loadAttendance();
|
||
} catch (e: any) {
|
||
alert(e.message || 'Đẩy QLĐT thất bại');
|
||
} finally {
|
||
setPushing(false);
|
||
}
|
||
};
|
||
|
||
const qldtSynced = Boolean(shiftInfo?.pushedToQldtAt);
|
||
const qldtDirty = Boolean(shiftInfo?.qldtDirty);
|
||
|
||
return (
|
||
<div className="attendance-panel">
|
||
<div className="attendance-toolbar">
|
||
<label className="attendance-field">
|
||
<span>Ngày</span>
|
||
<input
|
||
type="date"
|
||
className="search-input"
|
||
style={{ padding: '0.5rem 0.75rem' }}
|
||
value={date}
|
||
onChange={e => setDate(e.target.value)}
|
||
/>
|
||
</label>
|
||
<label className="attendance-field">
|
||
<span>Ca học</span>
|
||
<select className="select-filter" value={period} onChange={e => setPeriod(Number(e.target.value))}>
|
||
{(shifts.length ? shifts : [{ period: 1 }, { period: 2 }, { period: 3 }, { period: 4 }]).map((s: any) => (
|
||
<option key={s.period} value={s.period}>
|
||
Ca {s.period}{s.startTime ? ` (${s.startTime}–${s.endTime})` : ''}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label className="attendance-field attendance-field--grow">
|
||
<span>Tìm sinh viên</span>
|
||
<input
|
||
type="search"
|
||
className="app-pool-search attendance-search"
|
||
placeholder="Mã SV, tên, email..."
|
||
value={searchQuery}
|
||
onChange={e => setSearchQuery(e.target.value)}
|
||
/>
|
||
</label>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary"
|
||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||
title={isCollapsed ? 'Hiện đầy đủ thông tin chi tiết' : 'Ẩn bớt thông tin chi tiết để xem bảng to hơn'}
|
||
>
|
||
{isCollapsed ? '📂 Chi tiết' : '📁 Thu gọn'}
|
||
</button>
|
||
<button type="button" className="btn btn-secondary" onClick={() => loadAttendance(false)} disabled={loading}>
|
||
Tải lại
|
||
</button>
|
||
{courseId && (
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary"
|
||
onClick={() => setShowLeaveModal(true)}
|
||
style={{ display: 'flex', alignItems: 'center', gap: '6px' }}
|
||
>
|
||
✉️ Đơn phép
|
||
{leaveRequests.length > 0 && (
|
||
<span
|
||
className="badge"
|
||
style={{
|
||
background: leaveRequests.some(r => r.status === 'Đang chờ') ? 'var(--accent)' : 'var(--text-secondary)',
|
||
color: '#fff',
|
||
fontSize: '0.72rem',
|
||
padding: '0.1rem 0.35rem',
|
||
borderRadius: '10px',
|
||
fontWeight: 700
|
||
}}
|
||
>
|
||
{leaveRequests.length}
|
||
</span>
|
||
)}
|
||
</button>
|
||
)}
|
||
<button type="button" className="btn btn-primary" onClick={handlePushQLDT} disabled={pushing || rows.length === 0}>
|
||
{pushing ? 'Đang tải...' : 'Đẩy QLĐT'}
|
||
</button>
|
||
</div>
|
||
|
||
<div className={`attendance-collapsible-wrapper ${isCollapsed ? 'collapsed' : ''}`}>
|
||
<div
|
||
className={`attendance-qldt-banner${
|
||
qldtSynced ? (qldtDirty ? ' attendance-qldt-banner--stale' : ' attendance-qldt-banner--synced') : ''
|
||
}`}
|
||
>
|
||
{qldtSynced ? (
|
||
qldtDirty ? (
|
||
<>
|
||
<strong>Đã lưu QLĐT — có thay đổi mới</strong>
|
||
<span>
|
||
Lần đẩy: {formatQldtTime(shiftInfo?.pushedToQldtAt)} · Cần đẩy lại sau khi chỉnh sửa
|
||
</span>
|
||
</>
|
||
) : (
|
||
<>
|
||
<strong>Đã lưu QLĐT</strong>
|
||
<span>Lần đẩy gần nhất: {formatQldtTime(shiftInfo?.pushedToQldtAt)}</span>
|
||
</>
|
||
)
|
||
) : (
|
||
<>
|
||
<strong>Chưa đẩy lên QLĐT</strong>
|
||
<span>Ca {period} ngày {date} — bấm "Đẩy QLĐT" sau khi kiểm tra trạng thái</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{currentShift && (
|
||
<div className="attendance-shift-info">
|
||
<span>{currentShift.startTime}–{currentShift.endTime}</span>
|
||
<span>{currentShift.courseName || 'Chưa chọn môn'}</span>
|
||
{!currentShift.isActive && <span className="badge badge-muted">Ca tắt</span>}
|
||
</div>
|
||
)}
|
||
|
||
<div className="attendance-status-summary">
|
||
{ATTENDANCE_STATUS_OPTIONS.map(opt => (
|
||
<button
|
||
key={opt.value}
|
||
type="button"
|
||
className={`attendance-summary-chip ${attendanceStatusClass(opt.value)}`}
|
||
onClick={() => setSearchQuery('')}
|
||
title={`${opt.label}: ${statusCounts[opt.value] ?? 0} sinh viên`}
|
||
>
|
||
<span className="attendance-summary-chip-label">{opt.short}</span>
|
||
<span className="attendance-summary-chip-count">{statusCounts[opt.value] ?? 0}</span>
|
||
</button>
|
||
))}
|
||
<span className="attendance-summary-total">
|
||
{filteredRows.length}/{rows.length} SV
|
||
{searchQuery.trim() ? ' (đã lọc)' : ''}
|
||
</span>
|
||
</div>
|
||
|
||
<p className="schedule-hint" style={{ margin: '0 0 4px 0' }}>
|
||
Sửa trạng thái thủ công sẽ được khóa — hệ thống tự tính sẽ không ghi đè.
|
||
</p>
|
||
|
||
<div style={{ padding: '0.65rem 0.85rem', background: '#fef3c7', border: '1px solid #fcd34d', borderRadius: '4px', color: '#92400e', fontSize: '0.82rem', display: 'flex', alignItems: 'center', gap: '0.35rem', margin: '4px 0 4px 0', lineHeight: '1.4' }}>
|
||
💡 <strong>Lưu ý:</strong> Hệ thống hiện tại chỉ ghi nhận điểm danh học tập nội bộ và <strong>KHÔNG tự động lưu lên QLĐT</strong>. Thầy cô vui lòng kiểm tra kỹ trạng thái của sinh viên, sau đó bấm nút <strong>Đẩy QLĐT</strong> ở góc phải bên trên để đồng bộ điểm danh chính thức.
|
||
</div>
|
||
</div>
|
||
|
||
{/* Leave Requests Approval Modal */}
|
||
{showLeaveModal && courseId && (
|
||
<div className="modal-overlay" onClick={() => setShowLeaveModal(false)}>
|
||
<div className="modal-container" style={{ maxWidth: '680px', width: '95%', display: 'flex', flexDirection: 'column' }} onClick={e => e.stopPropagation()}>
|
||
<div className="modal-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||
<div>
|
||
<h3 className="modal-title" style={{ margin: 0 }}>✉️ Đơn xin nghỉ phép trong ca</h3>
|
||
<p style={{ margin: '4px 0 0 0', color: 'var(--text-secondary)', fontSize: '0.8rem' }}>
|
||
Ca {period} ngày {date} - Giao diện duyệt đơn xin nghỉ từ cổng QLĐT portal.
|
||
</p>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary btn-sm"
|
||
onClick={loadLeaveRequests}
|
||
disabled={loadingLeave}
|
||
style={{ padding: '0.3rem 0.6rem', fontSize: '0.78rem' }}
|
||
>
|
||
{loadingLeave ? 'Đang tải...' : '🔄 Làm mới'}
|
||
</button>
|
||
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setShowLeaveModal(false)}>
|
||
Đóng
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="modal-body" style={{ padding: '1rem', overflowY: 'auto', maxHeight: '60vh', display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||
{loadingLeave ? (
|
||
<div style={{ display: 'flex', justifyContent: 'center', padding: '2rem 0' }}>
|
||
<div className="sync-spinner" style={{ width: 28, height: 28 }} />
|
||
</div>
|
||
) : leaveRequests.length === 0 ? (
|
||
<div style={{ textAlign: 'center', padding: '2rem 0', color: 'var(--text-muted)', fontSize: '0.85rem', fontStyle: 'italic' }}>
|
||
Không có đơn xin nghỉ phép nào cho ca học này trong ngày hôm nay.
|
||
</div>
|
||
) : (
|
||
leaveRequests.map((req) => (
|
||
<div
|
||
key={req.id}
|
||
style={{
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: '8px',
|
||
padding: '0.85rem',
|
||
border: '1px solid var(--border-color)',
|
||
borderRadius: '8px',
|
||
backgroundColor: '#ffffff',
|
||
boxShadow: 'var(--shadow-sm)'
|
||
}}
|
||
>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: '8px' }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||
<div style={{ fontWeight: 600, fontSize: '0.9rem' }}>{req.student.fullName}</div>
|
||
<code style={{ fontSize: '0.8rem', color: 'var(--text-secondary)' }}>{req.student.studentCode}</code>
|
||
<span
|
||
className="badge"
|
||
style={{
|
||
fontSize: '0.72rem',
|
||
fontWeight: 600,
|
||
backgroundColor: req.status === 'Đang chờ' ? '#fef3c7' : req.status === 'Phê duyệt' ? '#d1fae5' : '#fee2e2',
|
||
color: req.status === 'Đang chờ' ? '#b45309' : req.status === 'Phê duyệt' ? '#065f46' : '#991b1b',
|
||
padding: '0.15rem 0.45rem',
|
||
borderRadius: '4px'
|
||
}}
|
||
>
|
||
{req.status}
|
||
</span>
|
||
</div>
|
||
|
||
{req.status === 'Đang chờ' && (
|
||
<div style={{ display: 'flex', gap: '6px' }}>
|
||
<button
|
||
type="button"
|
||
className="btn btn-primary btn-sm"
|
||
style={{ padding: '0.25rem 0.65rem', fontSize: '0.78rem', backgroundColor: 'var(--success)', borderColor: 'var(--success)' }}
|
||
onClick={() => {
|
||
if (confirm(`Phê duyệt đơn xin nghỉ phép của ${req.student.fullName}? Trạng thái điểm danh ca sẽ chuyển thành Nghỉ có phép.`)) {
|
||
handleUpdateLeaveStatus(req.id, 'Phê duyệt', req.student.id);
|
||
}
|
||
}}
|
||
>
|
||
✓ Phê duyệt
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary btn-sm text-danger"
|
||
style={{ padding: '0.25rem 0.65rem', fontSize: '0.78rem', borderColor: '#fca5a5' }}
|
||
onClick={() => {
|
||
if (confirm(`Từ chối đơn xin nghỉ phép của ${req.student.fullName}?`)) {
|
||
handleUpdateLeaveStatus(req.id, 'Từ chối', req.student.id);
|
||
}
|
||
}}
|
||
>
|
||
✗ Từ chối
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div style={{ fontSize: '0.82rem', color: 'var(--text-primary)', background: '#f9fafb', padding: '8px 10px', borderRadius: '4px', borderLeft: '4px solid #cbd5e1', lineHeight: '1.4' }}>
|
||
<strong>Lý do nghỉ:</strong> {req.note || 'Không có ghi chú'}
|
||
</div>
|
||
|
||
{req.reasonImage && (
|
||
<div style={{ marginTop: '4px' }}>
|
||
<a
|
||
href={req.reasonImage}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
style={{ fontSize: '0.75rem', color: 'var(--accent)', textDecoration: 'underline', display: 'inline-flex', alignItems: 'center', gap: '4px', fontWeight: 500 }}
|
||
>
|
||
🖼️ Xem ảnh minh chứng phép
|
||
</a>
|
||
</div>
|
||
)}
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{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" onScroll={handleScroll}>
|
||
{loading ? (
|
||
<div className="empty-state"><div className="sync-spinner" style={{ width: 28, height: 28 }} /></div>
|
||
) : (
|
||
<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>
|
||
<th>QLĐT / Ghi chú</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{filteredRows.length === 0 ? (
|
||
<tr>
|
||
<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 => {
|
||
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)}
|
||
>
|
||
<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>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|