This commit is contained in:
2026-06-30 09:31:33 +07:00
parent c736326162
commit bbf8336664
77 changed files with 12601 additions and 556 deletions

View File

@@ -0,0 +1,163 @@
import React, { useCallback, useEffect, useState } from 'react';
import {
ATTENDANCE_STATUS_OPTIONS,
apiFetchAttendance,
apiFetchAttendanceShifts,
apiPushAttendanceQLDT,
apiUpdateAttendanceStatus,
type AttendanceRow,
} from '../api';
interface AttendancePanelProps {
classId: number;
}
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<any>(null);
const [loading, setLoading] = useState(false);
const [pushing, setPushing] = useState(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 () => {
try {
setLoading(true);
const res = await apiFetchAttendance(classId, date, period);
setRows(res.data || []);
setShiftInfo(res.shift || null);
} catch (e: any) {
alert(e.message || 'Không tải được điểm danh');
} finally {
setLoading(false);
}
}, [classId, date, period]);
useEffect(() => { loadShifts(); }, [loadShifts]);
useEffect(() => { loadAttendance(); }, [loadAttendance]);
const handleStatusChange = async (studentRkId: number, status: number) => {
try {
await apiUpdateAttendanceStatus(classId, { date, period, studentRkId, status });
await loadAttendance();
} catch (e: any) {
alert(e.message || 'Cập nhật trạng thái thất bại');
}
};
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');
} catch (e: any) {
alert(e.message || 'Đẩy QLĐT thất bại');
} finally {
setPushing(false);
}
};
const currentShift = shifts.find(s => s.period === period);
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>
<button type="button" className="btn btn-secondary" onClick={loadAttendance} disabled={loading}>Tải lại</button>
<button type="button" className="btn btn-primary" onClick={handlePushQLDT} disabled={pushing}>
{pushing ? 'Đang đẩy...' : 'Đẩy QLĐT'}
</button>
</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>
)}
<p className="schedule-hint" style={{ margin: '0.25rem 0 0.5rem' }}>
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 className="attendance-table-scroll table-wrapper">
{loading ? (
<div className="empty-state"><div className="sync-spinner" style={{ width: 28, height: 28 }} /></div>
) : (
<table className="data-table">
<thead>
<tr>
<th>Sinh viên</th>
<th>Online (phút)</th>
<th>Trạng thái</th>
<th>Ghi chú</th>
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr><td colSpan={4} style={{ textAlign: 'center', color: 'var(--text-muted)' }}>Chưa dữ liệu điểm danh</td></tr>
) : rows.map(row => (
<tr key={row.studentRkId}>
<td>
<div style={{ fontWeight: 600 }}>{row.fullName}</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>{row.studentCode}</div>
</td>
<td style={{ fontFamily: 'monospace' }}>{row.onlineMinutes}</td>
<td>
<select
className="select-filter"
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>
{row.statusEditedByTeacher && (
<span className="badge badge-info" title="Giáo viên đã sửa — không bị ghi đè">Đã khóa</span>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
};