fix trang thai sinh vien
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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 — có 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 "Đẩy QLĐT" 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 có 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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user