core v1
This commit is contained in:
163
management/src/components/AttendancePanel.tsx
Normal file
163
management/src/components/AttendancePanel.tsx
Normal 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 có 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user