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; statusLabel: string;
onlineMinutes: number; onlineMinutes: number;
statusEditedByTeacher: boolean; 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 = [ export const ATTENDANCE_STATUS_OPTIONS = [
{ value: 0, label: 'Nghỉ không phép' }, { value: 0, label: 'Nghỉ không phép', short: 'NKP' },
{ value: 1, label: 'Nghỉ có phép' }, { value: 1, label: 'Nghỉ có phép', short: 'NCP' },
{ value: 2, label: 'Nghỉ nửa buổi' }, { value: 2, label: 'Nghỉ nửa buổi', short: 'Nửa buổi' },
{ value: 3, label: 'Đi học muộn' }, { value: 3, label: 'Đi học muộn', short: 'Muộn' },
{ value: 4, label: 'Đi học đầy đủ' }, { 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 { export interface StudentSessionLogItem {
id: number; id: number;

View File

@@ -1,26 +1,39 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { import {
ATTENDANCE_STATUS_OPTIONS, ATTENDANCE_STATUS_OPTIONS,
apiFetchAttendance, apiFetchAttendance,
apiFetchAttendanceShifts, apiFetchAttendanceShifts,
apiPushAttendanceQLDT, apiPushAttendanceQLDT,
apiUpdateAttendanceStatus, apiUpdateAttendanceStatus,
attendanceStatusClass,
type AttendanceRow, type AttendanceRow,
type AttendanceShiftInfo,
} from '../api'; } from '../api';
interface AttendancePanelProps { interface AttendancePanelProps {
classId: number; 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 }) => { export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) => {
const today = new Date().toISOString().slice(0, 10); const today = new Date().toISOString().slice(0, 10);
const [date, setDate] = useState(today); const [date, setDate] = useState(today);
const [period, setPeriod] = useState(1); const [period, setPeriod] = useState(1);
const [rows, setRows] = useState<AttendanceRow[]>([]); const [rows, setRows] = useState<AttendanceRow[]>([]);
const [shifts, setShifts] = useState<any[]>([]); const [shifts, setShifts] = useState<any[]>([]);
const [shiftInfo, setShiftInfo] = useState<any>(null); const [shiftInfo, setShiftInfo] = useState<AttendanceShiftInfo | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [pushing, setPushing] = useState(false); const [pushing, setPushing] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const loadShifts = useCallback(async () => { const loadShifts = useCallback(async () => {
try { try {
@@ -50,6 +63,26 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
useEffect(() => { loadShifts(); }, [loadShifts]); useEffect(() => { loadShifts(); }, [loadShifts]);
useEffect(() => { loadAttendance(); }, [loadAttendance]); 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) => { const handleStatusChange = async (studentRkId: number, status: number) => {
try { try {
await apiUpdateAttendanceStatus(classId, { date, period, studentRkId, status }); await apiUpdateAttendanceStatus(classId, { date, period, studentRkId, status });
@@ -69,6 +102,7 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
setPushing(true); setPushing(true);
const res = await apiPushAttendanceQLDT(classId, date, period); const res = await apiPushAttendanceQLDT(classId, date, period);
alert(res.message || 'Đã đẩy lên QLĐT'); alert(res.message || 'Đã đẩy lên QLĐT');
await loadAttendance();
} catch (e: any) { } catch (e: any) {
alert(e.message || 'Đẩy QLĐT thất bại'); alert(e.message || 'Đẩy QLĐT thất bại');
} finally { } finally {
@@ -77,13 +111,21 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
}; };
const currentShift = shifts.find(s => s.period === period); const currentShift = shifts.find(s => s.period === period);
const qldtSynced = Boolean(shiftInfo?.pushedToQldtAt);
const qldtDirty = Boolean(shiftInfo?.qldtDirty);
return ( return (
<div className="attendance-panel"> <div className="attendance-panel">
<div className="attendance-toolbar"> <div className="attendance-toolbar">
<label className="attendance-field"> <label className="attendance-field">
<span>Ngày</span> <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>
<label className="attendance-field"> <label className="attendance-field">
<span>Ca học</span> <span>Ca học</span>
@@ -95,12 +137,51 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
))} ))}
</select> </select>
</label> </label>
<button type="button" className="btn btn-secondary" onClick={loadAttendance} disabled={loading}>Tải lại</button> <label className="attendance-field attendance-field--grow">
<button type="button" className="btn btn-primary" onClick={handlePushQLDT} disabled={pushing}> <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'} {pushing ? 'Đang đẩy...' : 'Đẩy QLĐT'}
</button> </button>
</div> </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 && ( {currentShift && (
<div className="attendance-shift-info"> <div className="attendance-shift-info">
<span>{currentShift.startTime}{currentShift.endTime}</span> <span>{currentShift.startTime}{currentShift.endTime}</span>
@@ -109,7 +190,26 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
</div> </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 đè. Sửa trạng thái thủ công sẽ đưc khóa hệ thống tự tính sẽ không ghi đè.
</p> </p>
@@ -117,43 +217,66 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
{loading ? ( {loading ? (
<div className="empty-state"><div className="sync-spinner" style={{ width: 28, height: 28 }} /></div> <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> <thead>
<tr> <tr>
<th>Sinh viên</th> <th>Sinh viên</th>
<th>Online (phút)</th> <th>Online</th>
<th>Trạng thái</th> <th>Trạng thái</th>
<th>Ghi chú</th> <th>QLĐT / Ghi chú</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{rows.length === 0 ? ( {filteredRows.length === 0 ? (
<tr><td colSpan={4} style={{ textAlign: 'center', color: 'var(--text-muted)' }}>Chưa dữ liệu điểm danh</td></tr> <tr>
) : rows.map(row => ( <td colSpan={4} style={{ textAlign: 'center', color: 'var(--text-muted)' }}>
<tr key={row.studentRkId}> {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>
<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> </td>
</tr> </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> </tbody>
</table> </table>
)} )}

View File

@@ -1959,6 +1959,240 @@ input:checked + .slider:before {
border: 1px solid var(--border-light); 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 { .tab-btn-group {
display: flex; display: flex;
background: var(--bg-subtle); background: var(--bg-subtle);

View File

@@ -129,14 +129,15 @@ func ApplyScheduleTemplateHandler(db *gorm.DB) fiber.Handler {
} }
type attendanceRow struct { type attendanceRow struct {
StudentRkID int64 `json:"studentRkId"` StudentRkID int64 `json:"studentRkId"`
StudentCode string `json:"studentCode"` StudentCode string `json:"studentCode"`
FullName string `json:"fullName"` FullName string `json:"fullName"`
Email string `json:"email"` Email string `json:"email"`
Status int `json:"status"` Status int `json:"status"`
StatusLabel string `json:"statusLabel"` StatusLabel string `json:"statusLabel"`
OnlineMinutes int `json:"onlineMinutes"` OnlineMinutes int `json:"onlineMinutes"`
StatusEditedByTeacher bool `json:"statusEditedByTeacher"` StatusEditedByTeacher bool `json:"statusEditedByTeacher"`
PushedToQLDTAt *time.Time `json:"pushedToQldtAt,omitempty"`
} }
func resolveScheduleForPeriod(db *gorm.DB, classRkID int64, dayOfWeek, period int) (*models.ClassSchedule, error) { func resolveScheduleForPeriod(db *gorm.DB, classRkID int64, dayOfWeek, period int) (*models.ClassSchedule, error) {
@@ -278,22 +279,38 @@ func GetClassAttendanceHandler(db *gorm.DB) fiber.Handler {
row.StatusLabel = r.StatusLabel row.StatusLabel = r.StatusLabel
row.OnlineMinutes = r.OnlineMinutes row.OnlineMinutes = r.OnlineMinutes
row.StatusEditedByTeacher = r.StatusEditedByTeacher row.StatusEditedByTeacher = r.StatusEditedByTeacher
row.PushedToQLDTAt = r.PushedToQLDTAt
} }
out = append(out, row) out = append(out, row)
} }
sched, _ := resolveScheduleForPeriod(db, classRkID, dayOfWeek, period) var shiftPushedAt *time.Time
shiftInfo := fiber.Map{} qldtDirty := false
if sched != nil { for _, r := range results {
shiftInfo = fiber.Map{ if r.PushedToQLDTAt != nil {
"startTime": sched.StartTime, if shiftPushedAt == nil || r.PushedToQLDTAt.After(*shiftPushedAt) {
"endTime": sched.EndTime, t := *r.PushedToQLDTAt
"courseId": sched.CourseID, shiftPushedAt = &t
"courseName": sched.CourseName, }
"isActive": sched.IsActive, 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}) return c.JSON(fiber.Map{"data": out, "period": period, "date": date, "shift": shiftInfo})
} }
} }