fix trang thai sinh vien

This commit is contained in:
2026-06-30 09:45:38 +07:00
parent 8ecc9c5664
commit e94a87cbd9
4 changed files with 450 additions and 57 deletions

View File

@@ -1,26 +1,39 @@
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
ATTENDANCE_STATUS_OPTIONS,
apiFetchAttendance,
apiFetchAttendanceShifts,
apiPushAttendanceQLDT,
apiUpdateAttendanceStatus,
attendanceStatusClass,
type AttendanceRow,
type AttendanceShiftInfo,
} 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<any>(null);
const [shiftInfo, setShiftInfo] = useState<AttendanceShiftInfo | null>(null);
const [loading, setLoading] = useState(false);
const [pushing, setPushing] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const loadShifts = useCallback(async () => {
try {
@@ -50,6 +63,26 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
useEffect(() => { loadShifts(); }, [loadShifts]);
useEffect(() => { loadAttendance(); }, [loadAttendance]);
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 handleStatusChange = async (studentRkId: number, status: number) => {
try {
await apiUpdateAttendanceStatus(classId, { date, period, studentRkId, status });
@@ -69,6 +102,7 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
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 {
@@ -77,13 +111,21 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
};
const currentShift = shifts.find(s => s.period === period);
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)} />
<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>
@@ -95,12 +137,51 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
))}
</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}>
<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={loadAttendance} disabled={loading}>
Tải lại
</button>
<button type="button" className="btn btn-primary" onClick={handlePushQLDT} disabled={pushing || rows.length === 0}>
{pushing ? 'Đang đẩy...' : 'Đẩy QLĐT'}
</button>
</div>
<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>
@@ -109,7 +190,26 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
</div>
)}
<p className="schedule-hint" style={{ margin: '0.25rem 0 0.5rem' }}>
<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 }}>
Sửa trạng thái thủ công sẽ đưc khóa hệ thống tự tính sẽ không ghi đè.
</p>
@@ -117,43 +217,66 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
{loading ? (
<div className="empty-state"><div className="sync-spinner" style={{ width: 28, height: 28 }} /></div>
) : (
<table className="data-table">
<table className="data-table attendance-table">
<thead>
<tr>
<th>Sinh viên</th>
<th>Online (phút)</th>
<th>Online</th>
<th>Trạng thái</th>
<th>Ghi chú</th>
<th>QLĐT / 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>
)}
{filteredRows.length === 0 ? (
<tr>
<td colSpan={4} 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))}
>
{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>
)}