diff --git a/management/src/api.ts b/management/src/api.ts index 3800dfd..a9e32c0 100644 --- a/management/src/api.ts +++ b/management/src/api.ts @@ -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; diff --git a/management/src/components/AttendancePanel.tsx b/management/src/components/AttendancePanel.tsx index 8666e35..e1db89e 100644 --- a/management/src/components/AttendancePanel.tsx +++ b/management/src/components/AttendancePanel.tsx @@ -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 = ({ classId }) => { const today = new Date().toISOString().slice(0, 10); const [date, setDate] = useState(today); const [period, setPeriod] = useState(1); const [rows, setRows] = useState([]); const [shifts, setShifts] = useState([]); - const [shiftInfo, setShiftInfo] = useState(null); + const [shiftInfo, setShiftInfo] = useState(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 = ({ 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 = { 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 = ({ 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 = ({ classId }) => }; const currentShift = shifts.find(s => s.period === period); + const qldtSynced = Boolean(shiftInfo?.pushedToQldtAt); + const qldtDirty = Boolean(shiftInfo?.qldtDirty); return (
- - +
+
+ {qldtSynced ? ( + qldtDirty ? ( + <> + Đã lưu QLĐT — có thay đổi mới + + Lần đẩy: {formatQldtTime(shiftInfo?.pushedToQldtAt)} · Cần đẩy lại sau khi chỉnh sửa + + + ) : ( + <> + Đã lưu QLĐT + Lần đẩy gần nhất: {formatQldtTime(shiftInfo?.pushedToQldtAt)} + + ) + ) : ( + <> + Chưa đẩy lên QLĐT + Ca {period} ngày {date} — bấm "Đẩy QLĐT" sau khi kiểm tra trạng thái + + )} +
+ {currentShift && (
{currentShift.startTime}–{currentShift.endTime} @@ -109,7 +190,26 @@ export const AttendancePanel: React.FC = ({ classId }) =>
)} -

+

+ {ATTENDANCE_STATUS_OPTIONS.map(opt => ( + + ))} + + {filteredRows.length}/{rows.length} SV + {searchQuery.trim() ? ' (đã lọc)' : ''} + +
+ +

Sửa trạng thái thủ công sẽ được khóa — hệ thống tự tính sẽ không ghi đè.

@@ -117,43 +217,66 @@ export const AttendancePanel: React.FC = ({ classId }) => {loading ? (
) : ( - +
- + - + - {rows.length === 0 ? ( - - ) : rows.map(row => ( - - - - - + - ))} + ) : ( + filteredRows.map(row => ( + + + + + + + )) + )}
Sinh viênOnline (phút)Online Trạng tháiGhi chúQLĐT / Ghi chú
Chưa có dữ liệu điểm danh
-
{row.fullName}
-
{row.studentCode}
-
{row.onlineMinutes} - - - {row.statusEditedByTeacher && ( - Đã khóa - )} + {filteredRows.length === 0 ? ( +
+ {rows.length === 0 ? 'Chưa có dữ liệu điểm danh' : 'Không tìm thấy sinh viên phù hợp'}
+
{row.fullName}
+
+ {row.studentCode} + {row.email && {row.email}} +
+
+ {row.onlineMinutes} + phút + + + +
+ {row.pushedToQldtAt ? ( + + QLĐT ✓ + + ) : ( + Chưa QLĐT + )} + {row.statusEditedByTeacher && ( + + Đã khóa + + )} +
+
)} diff --git a/management/src/index.css b/management/src/index.css index de8119b..d749479 100644 --- a/management/src/index.css +++ b/management/src/index.css @@ -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); diff --git a/server/internal/handlers/handlers_attendance.go b/server/internal/handlers/handlers_attendance.go index 9c777a7..6e6e447 100644 --- a/server/internal/handlers/handlers_attendance.go +++ b/server/internal/handlers/handlers_attendance.go @@ -129,14 +129,15 @@ func ApplyScheduleTemplateHandler(db *gorm.DB) fiber.Handler { } type attendanceRow struct { - StudentRkID int64 `json:"studentRkId"` - StudentCode string `json:"studentCode"` - FullName string `json:"fullName"` - Email string `json:"email"` - Status int `json:"status"` - StatusLabel string `json:"statusLabel"` - OnlineMinutes int `json:"onlineMinutes"` - StatusEditedByTeacher bool `json:"statusEditedByTeacher"` + StudentRkID int64 `json:"studentRkId"` + StudentCode string `json:"studentCode"` + FullName string `json:"fullName"` + Email string `json:"email"` + Status int `json:"status"` + 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,22 +279,38 @@ 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}) } }