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

@@ -82,15 +82,34 @@ export interface AttendanceRow {
statusLabel: string;
onlineMinutes: number;
statusEditedByTeacher: boolean;
pushedToQldtAt?: string;
}
export interface AttendanceShiftInfo {
startTime?: string;
endTime?: string;
courseId?: number;
courseName?: string;
isActive?: boolean;
pushedToQldtAt?: string;
qldtDirty?: boolean;
}
export const ATTENDANCE_STATUS_OPTIONS = [
{ value: 0, label: 'Nghỉ không phép' },
{ value: 1, label: 'Nghỉ có phép' },
{ value: 2, label: 'Nghỉ nửa buổi' },
{ value: 3, label: 'Đi học muộn' },
{ value: 4, label: 'Đi học đầy đủ' },
];
{ value: 0, label: 'Nghỉ không phép', short: 'NKP' },
{ value: 1, label: 'Nghỉ có phép', short: 'NCP' },
{ value: 2, label: 'Nghỉ nửa buổi', short: 'Nửa buổi' },
{ value: 3, label: 'Đi học muộn', short: 'Muộn' },
{ value: 4, label: 'Đi học đầy đủ', short: 'Đầy đủ' },
] as const;
export function attendanceStatusClass(status: number): string {
return `attendance-status--${status}`;
}
export function attendanceStatusLabel(status: number): string {
return ATTENDANCE_STATUS_OPTIONS.find(o => o.value === status)?.label ?? `Trạng thái ${status}`;
}
export interface StudentSessionLogItem {
id: number;

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,28 +217,39 @@ 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>
{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 style={{ fontFamily: 'monospace' }}>{row.onlineMinutes}</td>
<td>
<select
className="select-filter"
className={`attendance-status-select ${attendanceStatusClass(row.status)}`}
value={row.status}
onChange={e => handleStatusChange(row.studentRkId, Number(e.target.value))}
>
@@ -148,12 +259,24 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
</select>
</td>
<td>
{row.statusEditedByTeacher && (
<span className="badge badge-info" title="Giáo viên đã sửa — không bị ghi đè">Đã khóa</span>
<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>
)}

View File

@@ -1959,6 +1959,240 @@ input:checked + .slider:before {
border: 1px solid var(--border-light);
}
.attendance-field--grow {
flex: 1;
min-width: 180px;
}
.attendance-search {
width: 100%;
}
.attendance-qldt-banner {
display: flex;
flex-direction: column;
gap: 0.15rem;
padding: 0.65rem 0.85rem;
border-radius: var(--radius-sm);
border: 1px solid var(--border-color);
background: var(--bg-subtle);
font-size: 0.8rem;
color: var(--text-secondary);
}
.attendance-qldt-banner strong {
font-size: 0.85rem;
color: var(--text-primary);
}
.attendance-qldt-banner--synced {
border-color: rgba(46, 125, 50, 0.45);
background: rgba(46, 125, 50, 0.08);
}
.attendance-qldt-banner--synced strong {
color: #2e7d32;
}
.attendance-qldt-banner--stale {
border-color: rgba(239, 108, 0, 0.5);
background: rgba(239, 108, 0, 0.08);
}
.attendance-qldt-banner--stale strong {
color: #ef6c00;
}
.attendance-status-summary {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
align-items: center;
}
.attendance-summary-chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
border: 1px solid transparent;
border-radius: 999px;
padding: 0.25rem 0.55rem;
font-size: 0.72rem;
font-weight: 700;
cursor: default;
background: transparent;
}
.attendance-summary-chip-label {
opacity: 0.9;
}
.attendance-summary-chip-count {
min-width: 1.1rem;
text-align: center;
padding: 0.05rem 0.3rem;
border-radius: 999px;
background: rgba(0, 0, 0, 0.12);
}
.attendance-summary-total {
margin-left: auto;
font-size: 0.75rem;
color: var(--text-muted);
font-weight: 600;
}
/* Trạng thái điểm danh — màu theo status 0..4 */
.attendance-status--0,
.attendance-summary-chip.attendance-status--0 {
--att-fg: #b71c1c;
--att-bg: rgba(183, 28, 28, 0.12);
--att-border: rgba(183, 28, 28, 0.45);
}
.attendance-status--1,
.attendance-summary-chip.attendance-status--1 {
--att-fg: #1565c0;
--att-bg: rgba(21, 101, 192, 0.12);
--att-border: rgba(21, 101, 192, 0.4);
}
.attendance-status--2,
.attendance-summary-chip.attendance-status--2 {
--att-fg: #e65100;
--att-bg: rgba(230, 81, 0, 0.12);
--att-border: rgba(230, 81, 0, 0.45);
}
.attendance-status--3,
.attendance-summary-chip.attendance-status--3 {
--att-fg: #f9a825;
--att-bg: rgba(249, 168, 37, 0.15);
--att-border: rgba(249, 168, 37, 0.5);
}
.attendance-status--4,
.attendance-summary-chip.attendance-status--4 {
--att-fg: #2e7d32;
--att-bg: rgba(46, 125, 50, 0.12);
--att-border: rgba(46, 125, 50, 0.45);
}
.attendance-summary-chip.attendance-status--0,
.attendance-summary-chip.attendance-status--1,
.attendance-summary-chip.attendance-status--2,
.attendance-summary-chip.attendance-status--3,
.attendance-summary-chip.attendance-status--4 {
color: var(--att-fg);
background: var(--att-bg);
border-color: var(--att-border);
}
.attendance-table tbody tr.attendance-status--0,
.attendance-table tbody tr.attendance-status--1,
.attendance-table tbody tr.attendance-status--2,
.attendance-table tbody tr.attendance-status--3,
.attendance-table tbody tr.attendance-status--4 {
background: var(--att-bg);
}
.attendance-student-name {
font-weight: 700;
font-size: 0.88rem;
}
.attendance-student-meta {
display: flex;
flex-wrap: wrap;
gap: 0.35rem 0.6rem;
margin-top: 0.2rem;
font-size: 0.72rem;
color: var(--text-muted);
}
.attendance-student-meta code {
font-size: 0.72rem;
color: var(--text-secondary);
}
.attendance-online-mins {
font-family: monospace;
font-weight: 800;
font-size: 1rem;
}
.attendance-online-unit {
display: block;
font-size: 0.68rem;
color: var(--text-muted);
}
.attendance-status-cell {
display: flex;
flex-direction: column;
gap: 0.35rem;
align-items: flex-start;
}
.attendance-status-badge {
display: inline-block;
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.72rem;
font-weight: 700;
color: var(--att-fg);
background: var(--att-bg);
border: 1px solid var(--att-border);
}
.attendance-status-select {
width: 100%;
max-width: 200px;
padding: 0.35rem 0.5rem;
border-radius: var(--radius-sm);
font-size: 0.78rem;
font-weight: 600;
border: 1px solid var(--att-border);
background: var(--att-bg);
color: var(--att-fg);
cursor: pointer;
}
.attendance-notes {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.attendance-qldt-tag {
font-size: 0.68rem;
font-weight: 700;
padding: 0.15rem 0.4rem;
border-radius: 4px;
border: 1px solid var(--border-color);
}
.attendance-qldt-tag--ok {
color: #2e7d32;
background: rgba(46, 125, 50, 0.1);
border-color: rgba(46, 125, 50, 0.35);
}
.attendance-qldt-tag--pending {
color: var(--text-muted);
background: var(--bg-subtle);
}
.attendance-lock-tag {
font-size: 0.68rem;
font-weight: 700;
padding: 0.15rem 0.4rem;
border-radius: 4px;
color: #1565c0;
background: rgba(21, 101, 192, 0.1);
border: 1px solid rgba(21, 101, 192, 0.3);
}
.tab-btn-group {
display: flex;
background: var(--bg-subtle);

View File

@@ -137,6 +137,7 @@ type attendanceRow struct {
StatusLabel string `json:"statusLabel"`
OnlineMinutes int `json:"onlineMinutes"`
StatusEditedByTeacher bool `json:"statusEditedByTeacher"`
PushedToQLDTAt *time.Time `json:"pushedToQldtAt,omitempty"`
}
func resolveScheduleForPeriod(db *gorm.DB, classRkID int64, dayOfWeek, period int) (*models.ClassSchedule, error) {
@@ -278,20 +279,36 @@ func GetClassAttendanceHandler(db *gorm.DB) fiber.Handler {
row.StatusLabel = r.StatusLabel
row.OnlineMinutes = r.OnlineMinutes
row.StatusEditedByTeacher = r.StatusEditedByTeacher
row.PushedToQLDTAt = r.PushedToQLDTAt
}
out = append(out, row)
}
sched, _ := resolveScheduleForPeriod(db, classRkID, dayOfWeek, period)
shiftInfo := fiber.Map{}
if sched != nil {
shiftInfo = fiber.Map{
"startTime": sched.StartTime,
"endTime": sched.EndTime,
"courseId": sched.CourseID,
"courseName": sched.CourseName,
"isActive": sched.IsActive,
var shiftPushedAt *time.Time
qldtDirty := false
for _, r := range results {
if r.PushedToQLDTAt != nil {
if shiftPushedAt == nil || r.PushedToQLDTAt.After(*shiftPushedAt) {
t := *r.PushedToQLDTAt
shiftPushedAt = &t
}
if r.UpdatedAt.After(*r.PushedToQLDTAt) {
qldtDirty = true
}
}
}
sched, _ := resolveScheduleForPeriod(db, classRkID, dayOfWeek, period)
shiftInfo := fiber.Map{
"pushedToQldtAt": shiftPushedAt,
"qldtDirty": qldtDirty,
}
if sched != nil {
shiftInfo["startTime"] = sched.StartTime
shiftInfo["endTime"] = sched.EndTime
shiftInfo["courseId"] = sched.CourseID
shiftInfo["courseName"] = sched.CourseName
shiftInfo["isActive"] = sched.IsActive
}
return c.JSON(fiber.Map{"data": out, "period": period, "date": date, "shift": shiftInfo})