Files
rikkei_simple_care/management/src/components/AttendancePanel.tsx
PhuocNTB 6502531ad7
All checks were successful
Deploy on Master Change / deploy (push) Successful in 1m7s
fix tam
2026-07-06 11:04:12 +07:00

630 lines
26 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 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 &quot;Đy QLĐT&quot; 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ộ <strong>KHÔNG tự đng lưu lên QLĐT</strong>. Thầy 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 đơ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> 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 &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" 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>
);
};