From d10b40e81119d5e797616070adec9db5c55a78b1 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Mon, 13 Apr 2026 15:19:38 +0000 Subject: [PATCH] 1 --- backend/src/routes/attendance_sessions.js | 441 ++++++++++++++- frontend/src/components/NavBarItem.tsx | 5 +- frontend/src/layouts/Authenticated.tsx | 3 +- frontend/src/menuAside.ts | 8 + frontend/src/pages/attendance-hub.tsx | 661 ++++++++++++++++++++++ frontend/src/pages/index.tsx | 274 +++++---- 6 files changed, 1240 insertions(+), 152 deletions(-) create mode 100644 frontend/src/pages/attendance-hub.tsx diff --git a/backend/src/routes/attendance_sessions.js b/backend/src/routes/attendance_sessions.js index aa70ae0..c0447da 100644 --- a/backend/src/routes/attendance_sessions.js +++ b/backend/src/routes/attendance_sessions.js @@ -3,9 +3,12 @@ const express = require('express'); const Attendance_sessionsService = require('../services/attendance_sessions'); const Attendance_sessionsDBApi = require('../db/api/attendance_sessions'); +const ValidationError = require('../services/notifications/errors/validation'); +const db = require('../db/models'); +const Sequelize = require('sequelize'); const wrapAsync = require('../helpers').wrapAsync; - +const { Op } = Sequelize; const router = express.Router(); const { parse } = require('json2csv'); @@ -18,6 +21,298 @@ const { router.use(checkCrudPermissions('attendance_sessions')); + +const ATTENDANCE_STATUSES = ['present', 'absent', 'late', 'excused']; +const SECTION_STATUS_ORDER = { + active: 0, + planned: 1, + completed: 2, + archived: 3, +}; + +const createValidationError = (message) => { + const error = new ValidationError(); + error.message = message; + return error; +}; + +const buildStudentDisplayName = (student) => { + if (!student) { + return 'Student'; + } + + const fullName = [student.user?.firstName, student.user?.lastName].filter(Boolean).join(' ').trim(); + return fullName || student.user?.email || student.student_number || 'Student'; +}; + +const addStudentToMap = (rosterMap, student, source, lastStatus = null) => { + if (!student?.id) { + return; + } + + const existingStudent = rosterMap.get(student.id); + + if (!existingStudent) { + rosterMap.set(student.id, { + studentId: student.id, + displayName: buildStudentDisplayName(student), + studentNumber: student.student_number || null, + source, + lastStatus, + }); + return; + } + + if (!existingStudent.lastStatus && lastStatus) { + existingStudent.lastStatus = lastStatus; + } + + if (!existingStudent.source && source) { + existingStudent.source = source; + } +}; + +const countSessionSummary = (records = []) => { + const summary = { + present: 0, + absent: 0, + late: 0, + excused: 0, + }; + + records.forEach((record) => { + if (record?.attendance_status && summary[record.attendance_status] !== undefined) { + summary[record.attendance_status] += 1; + } + }); + + return summary; +}; + +const findTeacherProfilesForUser = async (currentUser) => { + if (Array.isArray(currentUser?.teachers_user) && currentUser.teachers_user.length > 0) { + return currentUser.teachers_user; + } + + if (!currentUser?.id) { + return []; + } + + return db.teachers.findAll({ + where: { userId: currentUser.id }, + attributes: ['id'], + }); +}; + +const fetchAvailableSections = async (currentUser) => { + const teacherProfiles = await findTeacherProfilesForUser(currentUser); + const teacherIds = teacherProfiles.map((teacher) => teacher.id).filter(Boolean); + const isAdministrator = currentUser?.app_role?.name === 'Administrator'; + const where = !isAdministrator && teacherIds.length ? { teacherId: { [Op.in]: teacherIds } } : {}; + + const sections = await db.course_sections.findAll({ + where, + include: [ + { model: db.subjects, as: 'subject', attributes: ['name'], required: false }, + { model: db.terms, as: 'term', attributes: ['name'], required: false }, + { model: db.classes, as: 'class', attributes: ['name'], required: false }, + { + model: db.teachers, + as: 'teacher', + attributes: ['id'], + required: false, + include: [{ model: db.users, as: 'user', attributes: ['firstName', 'lastName', 'email'], required: false }], + }, + ], + order: [['updatedAt', 'DESC']], + }); + + return sections + .map((section) => ({ + id: section.id, + name: section.name, + section_code: section.section_code, + status: section.status, + subjectName: section.subject?.name || null, + termName: section.term?.name || null, + className: section.class?.name || null, + teacherName: section.teacher?.user ? buildStudentDisplayName({ user: section.teacher.user }) : null, + })) + .sort((left, right) => { + const leftWeight = SECTION_STATUS_ORDER[left.status] ?? 99; + const rightWeight = SECTION_STATUS_ORDER[right.status] ?? 99; + + if (leftWeight !== rightWeight) { + return leftWeight - rightWeight; + } + + return (left.name || '').localeCompare(right.name || ''); + }); +}; + +const findCourseSectionOrThrow = async (courseSectionId) => { + const section = await db.course_sections.findByPk(courseSectionId, { + include: [ + { model: db.subjects, as: 'subject', attributes: ['name'], required: false }, + { model: db.terms, as: 'term', attributes: ['name'], required: false }, + { model: db.classes, as: 'class', attributes: ['id', 'name'], required: false }, + { + model: db.teachers, + as: 'teacher', + attributes: ['id'], + required: false, + include: [{ model: db.users, as: 'user', attributes: ['firstName', 'lastName', 'email'], required: false }], + }, + ], + }); + + if (!section) { + const error = new Error('Course section not found'); + error.code = 404; + throw error; + } + + return section; +}; + +const fetchRecentSessions = async (courseSectionId) => { + const sessions = await db.attendance_sessions.findAll({ + where: { course_sectionId: courseSectionId }, + include: [ + { + model: db.attendance_records, + as: 'attendance_records_attendance_session', + attributes: ['attendance_status'], + required: false, + }, + ], + order: [['session_start', 'DESC']], + limit: 5, + }); + + return sessions.map((session) => { + const sessionRecords = session.attendance_records_attendance_session || []; + + return { + id: session.id, + session_start: session.session_start, + session_end: session.session_end, + status: session.status, + notes: session.notes, + summary: countSessionSummary(sessionRecords), + totalRecords: sessionRecords.length, + }; + }); +}; + +const buildRosterForSection = async (courseSection) => { + const rosterMap = new Map(); + let rosterSource = null; + + if (courseSection.classId) { + const enrollments = await db.class_enrollments.findAll({ + where: { classId: courseSection.classId }, + include: [ + { + model: db.students, + as: 'student', + required: false, + include: [{ model: db.users, as: 'user', attributes: ['firstName', 'lastName', 'email'], required: false }], + }, + ], + order: [['createdAt', 'ASC']], + }); + + enrollments.forEach((enrollment) => addStudentToMap(rosterMap, enrollment.student, 'class enrollment')); + + if (enrollments.length > 0) { + rosterSource = 'class enrollment'; + } + } + + const attendanceHistory = await db.attendance_records.findAll({ + include: [ + { + model: db.attendance_sessions, + as: 'attendance_session', + required: true, + where: { course_sectionId: courseSection.id }, + attributes: ['id', 'session_start'], + }, + { + model: db.students, + as: 'student', + required: false, + include: [{ model: db.users, as: 'user', attributes: ['firstName', 'lastName', 'email'], required: false }], + }, + ], + order: [['createdAt', 'DESC']], + }); + + attendanceHistory.forEach((record) => addStudentToMap(rosterMap, record.student, 'attendance history', record.attendance_status)); + + if (!rosterSource && attendanceHistory.length > 0) { + rosterSource = 'attendance history'; + } + + const submissions = await db.submissions.findAll({ + include: [ + { + model: db.assignments, + as: 'assignment', + required: true, + where: { course_sectionId: courseSection.id }, + attributes: ['id'], + }, + { + model: db.students, + as: 'student', + required: false, + include: [{ model: db.users, as: 'user', attributes: ['firstName', 'lastName', 'email'], required: false }], + }, + ], + order: [['createdAt', 'DESC']], + }); + + submissions.forEach((submission) => addStudentToMap(rosterMap, submission.student, 'assignment activity')); + + if (!rosterSource && submissions.length > 0) { + rosterSource = 'assignment activity'; + } + + const gradeRecords = await db.grade_records.findAll({ + include: [ + { + model: db.grade_items, + as: 'grade_item', + required: true, + where: { course_sectionId: courseSection.id }, + attributes: ['id'], + }, + { + model: db.students, + as: 'student', + required: false, + include: [{ model: db.users, as: 'user', attributes: ['firstName', 'lastName', 'email'], required: false }], + }, + ], + order: [['createdAt', 'DESC']], + }); + + gradeRecords.forEach((record) => addStudentToMap(rosterMap, record.student, 'gradebook activity')); + + if (!rosterSource && gradeRecords.length > 0) { + rosterSource = 'gradebook activity'; + } + + return { + rosterSource, + roster: Array.from(rosterMap.values()).sort((left, right) => + left.displayName.localeCompare(right.displayName), + ), + }; +}; + /** * @swagger * components: @@ -174,6 +469,150 @@ router.post('/bulk-import', wrapAsync(async (req, res) => { * 500: * description: Some server error */ + +router.get('/workflow/overview', wrapAsync(async (req, res) => { + const sections = await fetchAvailableSections(req.currentUser); + const requestedSectionId = typeof req.query.courseSectionId === 'string' ? req.query.courseSectionId : ''; + const selectedSectionId = requestedSectionId || sections[0]?.id || null; + + let roster = []; + let rosterSource = null; + let recentSessions = []; + + if (selectedSectionId) { + const courseSection = await findCourseSectionOrThrow(selectedSectionId); + const rosterResult = await buildRosterForSection(courseSection); + roster = rosterResult.roster; + rosterSource = rosterResult.rosterSource; + recentSessions = await fetchRecentSessions(selectedSectionId); + } + + res.status(200).send({ + sections, + selectedSectionId, + roster, + rosterSource, + recentSessions, + summary: { + totalSections: sections.length, + totalStudents: roster.length, + recentSessionsCount: recentSessions.length, + }, + }); +})); + +router.post('/workflow/submit', wrapAsync(async (req, res) => { + const { + courseSectionId, + sessionDate, + startTime, + endTime, + notes, + status, + records, + } = req.body || {}; + + if (!courseSectionId) { + throw createValidationError('Course section is required'); + } + + if (!sessionDate || !startTime || !endTime) { + throw createValidationError('Session date, start time, and end time are required'); + } + + if (!Array.isArray(records) || records.length === 0) { + throw createValidationError('At least one attendance record is required'); + } + + const sessionStart = new Date(`${sessionDate}T${startTime}:00`); + const sessionEnd = new Date(`${sessionDate}T${endTime}:00`); + + if (Number.isNaN(sessionStart.getTime()) || Number.isNaN(sessionEnd.getTime())) { + throw createValidationError('Attendance session times are invalid'); + } + + if (sessionEnd <= sessionStart) { + throw createValidationError('Attendance session end time must be later than the start time'); + } + + const courseSection = await findCourseSectionOrThrow(courseSectionId); + const teacherProfiles = await findTeacherProfilesForUser(req.currentUser); + const fallbackTeacherId = teacherProfiles[0]?.id || null; + + const normalizedRecords = records.map((record) => { + if (!record?.studentId) { + throw createValidationError('Each attendance row must include a student'); + } + + if (!ATTENDANCE_STATUSES.includes(record.attendance_status)) { + throw createValidationError('Attendance status is invalid'); + } + + const minutesLate = Number(record.minutes_late || 0); + + if (record.attendance_status === 'late' && (Number.isNaN(minutesLate) || minutesLate < 0)) { + throw createValidationError('Minutes late must be zero or greater'); + } + + return { + studentId: record.studentId, + attendance_status: record.attendance_status, + minutes_late: record.attendance_status === 'late' ? minutesLate : 0, + comment: typeof record.comment === 'string' ? record.comment.trim() : null, + }; + }); + + const summary = normalizedRecords.reduce( + (accumulator, record) => { + if (record.attendance_status === 'present') { + accumulator.present += 1; + } else { + accumulator.flagged += 1; + } + + accumulator.total += 1; + return accumulator; + }, + { total: 0, present: 0, flagged: 0 }, + ); + + const session = await db.sequelize.transaction(async (transaction) => { + const createdSession = await db.attendance_sessions.create( + { + session_start: sessionStart, + session_end: sessionEnd, + status: status === 'open' ? 'open' : 'closed', + notes: typeof notes === 'string' && notes.trim() ? notes.trim() : null, + course_sectionId: courseSection.id, + teacherId: courseSection.teacherId || fallbackTeacherId, + createdById: req.currentUser?.id || null, + updatedById: req.currentUser?.id || null, + }, + { transaction }, + ); + + await db.attendance_records.bulkCreate( + normalizedRecords.map((record) => ({ + attendance_status: record.attendance_status, + minutes_late: record.minutes_late, + comment: record.comment, + attendance_sessionId: createdSession.id, + studentId: record.studentId, + createdById: req.currentUser?.id || null, + updatedById: req.currentUser?.id || null, + })), + { transaction }, + ); + + return createdSession; + }); + + res.status(200).send({ + sessionId: session.id, + summary, + }); +})); + router.put('/:id', wrapAsync(async (req, res) => { await Attendance_sessionsService.update(req.body.data, req.body.id, req.currentUser); const payload = true; diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx index 72935e6..52a75b0 100644 --- a/frontend/src/components/NavBarItem.tsx +++ b/frontend/src/components/NavBarItem.tsx @@ -1,6 +1,5 @@ -import React, {useEffect, useRef} from 'react' +import React, { useEffect, useRef, useState } from 'react' import Link from 'next/link' -import { useState } from 'react' import { mdiChevronUp, mdiChevronDown } from '@mdi/js' import BaseDivider from './BaseDivider' import BaseIcon from './BaseIcon' @@ -64,7 +63,7 @@ export default function NavBarItem({ item }: Props) { } } - const getItemId = (label) => { + const getItemId = (label: string) => { switch (label) { case 'Light/Dark': return 'themeToggle'; diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx index 1b9907d..73d8391 100644 --- a/frontend/src/layouts/Authenticated.tsx +++ b/frontend/src/layouts/Authenticated.tsx @@ -1,5 +1,4 @@ -import React, { ReactNode, useEffect } from 'react' -import { useState } from 'react' +import React, { ReactNode, useEffect, useState } from 'react' import jwt from 'jsonwebtoken'; import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js' import menuAside from '../menuAside' diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index 619f650..6401cab 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -7,6 +7,14 @@ const menuAside: MenuAsideItem[] = [ icon: icon.mdiViewDashboardOutline, label: 'Dashboard', }, + { + href: '/attendance-hub', + label: 'Attendance Hub', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: 'mdiClipboardCheckOutline' in icon ? icon['mdiClipboardCheckOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, + permissions: 'READ_ATTENDANCE_SESSIONS' + }, { href: '/users/users-list', diff --git a/frontend/src/pages/attendance-hub.tsx b/frontend/src/pages/attendance-hub.tsx new file mode 100644 index 0000000..5ea3023 --- /dev/null +++ b/frontend/src/pages/attendance-hub.tsx @@ -0,0 +1,661 @@ +import { + mdiAccountGroupOutline, + mdiCalendarCheckOutline, + mdiClipboardCheckOutline, + mdiSchoolOutline, +} from '@mdi/js'; +import axios from 'axios'; +import Head from 'next/head'; +import Link from 'next/link'; +import React, { ReactElement, useCallback, useEffect, useMemo, useState } from 'react'; +import BaseButton from '../components/BaseButton'; +import CardBox from '../components/CardBox'; +import LayoutAuthenticated from '../layouts/Authenticated'; +import SectionMain from '../components/SectionMain'; +import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../config'; +import { useAppSelector } from '../stores/hooks'; + +type AttendanceStatus = 'present' | 'absent' | 'late' | 'excused'; + +type SectionOption = { + id: string; + name: string; + section_code?: string | null; + status?: string | null; + subjectName?: string | null; + termName?: string | null; + className?: string | null; + teacherName?: string | null; +}; + +type RosterEntry = { + studentId: string; + displayName: string; + studentNumber?: string | null; + source?: string | null; + lastStatus?: AttendanceStatus | null; +}; + +type RecentSession = { + id: string; + session_start: string; + session_end?: string | null; + status: string; + notes?: string | null; + summary: Record; + totalRecords: number; +}; + +type WorkflowResponse = { + sections: SectionOption[]; + selectedSectionId: string | null; + roster: RosterEntry[]; + rosterSource: string | null; + recentSessions: RecentSession[]; + summary: { + totalSections: number; + totalStudents: number; + recentSessionsCount: number; + }; +}; + +type RosterFormRow = { + status: AttendanceStatus; + minutesLate: string; + comment: string; +}; + +const STATUS_OPTIONS: { value: AttendanceStatus; label: string; tone: string }[] = [ + { value: 'present', label: 'Present', tone: 'border-emerald-200 bg-emerald-50 text-emerald-700' }, + { value: 'late', label: 'Late', tone: 'border-amber-200 bg-amber-50 text-amber-700' }, + { value: 'absent', label: 'Absent', tone: 'border-rose-200 bg-rose-50 text-rose-700' }, + { value: 'excused', label: 'Excused', tone: 'border-sky-200 bg-sky-50 text-sky-700' }, +]; + +const EMPTY_WORKFLOW: WorkflowResponse = { + sections: [], + selectedSectionId: null, + roster: [], + rosterSource: null, + recentSessions: [], + summary: { + totalSections: 0, + totalStudents: 0, + recentSessionsCount: 0, + }, +}; + +const todayIso = () => new Date().toISOString().slice(0, 10); + +const combineDateAndTime = (date: string, time: string) => new Date(`${date}T${time}:00`); + +const formatSectionLabel = (section: SectionOption) => { + const meta = [section.subjectName, section.className].filter(Boolean).join(' • '); + return meta ? `${section.name} — ${meta}` : section.name; +}; + +const formatSessionDate = (date: string) => + new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }).format(new Date(date)); + +const buildDefaultRows = (roster: RosterEntry[]) => + roster.reduce>((acc, student) => { + acc[student.studentId] = { + status: student.lastStatus ?? 'present', + minutesLate: student.lastStatus === 'late' ? '5' : '', + comment: '', + }; + + return acc; + }, {}); + +const StatCard = ({ icon, label, value, accent }: { icon: string; label: string; value: string; accent: string }) => ( +
+
+ + + + + +
+

{label}

+

{value}

+
+); + +const AttendanceHubPage = () => { + const { currentUser } = useAppSelector((state) => state.auth); + const [workflow, setWorkflow] = useState(EMPTY_WORKFLOW); + const [selectedSectionId, setSelectedSectionId] = useState(''); + const [attendanceDate, setAttendanceDate] = useState(todayIso()); + const [startTime, setStartTime] = useState('08:30'); + const [endTime, setEndTime] = useState('09:15'); + const [notes, setNotes] = useState(''); + const [sessionStatus, setSessionStatus] = useState<'open' | 'closed'>('closed'); + const [rows, setRows] = useState>({}); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [errorMessage, setErrorMessage] = useState(''); + const [successMessage, setSuccessMessage] = useState(''); + const [savedSessionId, setSavedSessionId] = useState(''); + + const loadWorkflow = useCallback(async (courseSectionId?: string) => { + setIsLoading(true); + setErrorMessage(''); + + try { + const { data } = await axios.get('/attendance_sessions/workflow/overview', { + params: courseSectionId ? { courseSectionId } : undefined, + }); + + setWorkflow(data); + setRows(buildDefaultRows(data.roster)); + + if (data.selectedSectionId && data.selectedSectionId !== courseSectionId) { + setSelectedSectionId(data.selectedSectionId); + } + } catch (error) { + console.error('Attendance workflow load failed', error); + setErrorMessage('We could not load the attendance workflow. Please refresh and try again.'); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + void loadWorkflow(); + }, [loadWorkflow]); + + useEffect(() => { + if (!selectedSectionId || selectedSectionId === workflow.selectedSectionId) { + return; + } + + void loadWorkflow(selectedSectionId); + }, [loadWorkflow, selectedSectionId, workflow.selectedSectionId]); + + const selectedSection = useMemo( + () => workflow.sections.find((section) => section.id === (selectedSectionId || workflow.selectedSectionId)) ?? null, + [selectedSectionId, workflow.sections, workflow.selectedSectionId], + ); + + const totalMarkedPresent = useMemo( + () => Object.values(rows).filter((row) => row.status === 'present').length, + [rows], + ); + + const totalFlagged = useMemo( + () => Object.values(rows).filter((row) => row.status !== 'present').length, + [rows], + ); + + const setBulkStatus = (status: AttendanceStatus) => { + setRows((currentRows) => + Object.entries(currentRows).reduce>((acc, [studentId, row]) => { + acc[studentId] = { + ...row, + status, + minutesLate: status === 'late' ? row.minutesLate || '5' : '', + }; + + return acc; + }, {}), + ); + }; + + const updateRow = (studentId: string, nextValue: Partial) => { + setRows((currentRows) => ({ + ...currentRows, + [studentId]: { + ...currentRows[studentId], + ...nextValue, + }, + })); + }; + + const saveSession = async () => { + setErrorMessage(''); + setSuccessMessage(''); + setSavedSessionId(''); + + if (!selectedSectionId && !workflow.selectedSectionId) { + setErrorMessage('Choose a course section before saving attendance.'); + return; + } + + if (workflow.roster.length === 0) { + setErrorMessage('This section has no students yet. Link a roster first or choose a section with attendance history.'); + return; + } + + if (combineDateAndTime(attendanceDate, endTime) <= combineDateAndTime(attendanceDate, startTime)) { + setErrorMessage('Session end time must be later than the start time.'); + return; + } + + const recordInputs = workflow.roster.map((student) => { + const row = rows[student.studentId]; + const minutesLate = row?.status === 'late' && row.minutesLate ? Number(row.minutesLate) : 0; + + return { + studentId: student.studentId, + attendance_status: row?.status ?? 'present', + minutes_late: Number.isNaN(minutesLate) ? 0 : minutesLate, + comment: row?.comment?.trim() || '', + }; + }); + + setIsSaving(true); + + try { + const activeSectionId = selectedSectionId || workflow.selectedSectionId; + const { data } = await axios.post('/attendance_sessions/workflow/submit', { + courseSectionId: activeSectionId, + sessionDate: attendanceDate, + startTime, + endTime, + notes, + status: sessionStatus, + records: recordInputs, + }); + + setSavedSessionId(data.sessionId); + setSuccessMessage(`Attendance saved for ${data.summary.total} students. ${data.summary.present} present, ${data.summary.flagged} need follow-up.`); + setNotes(''); + await loadWorkflow(activeSectionId || undefined); + } catch (error: unknown) { + console.error('Attendance workflow save failed', error); + const message = axios.isAxiosError(error) ? error.response?.data : null; + setErrorMessage(typeof message === 'string' ? message : 'Unable to save attendance right now.'); + } finally { + setIsSaving(false); + } + }; + + return ( + <> + + {getPageTitle('Attendance Hub')} + + + +
+ + +
+
+ + +
+
+
+ Daily teacher workflow +
+

+ Take attendance in one focused flow instead of bouncing between generic CRUD screens. +

+

+ Pick a section, mark the roster, save the session, and immediately review the latest attendance trend. This first slice is tuned for busy school mornings. +

+
+ {currentUser?.firstName || 'Staff'} signed in + {currentUser?.app_role?.name || 'School staff'} + {selectedSection?.teacherName ? ( + Lead teacher: {selectedSection.teacherName} + ) : null} +
+
+
+ + + +
+
+
+ + {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} + + {successMessage ? ( +
+
+ {successMessage} + {savedSessionId ? ( + + Open saved session + + + ) : null} +
+
+ ) : null} + +
+ +
+
+

Step 1

+

Build today's attendance session

+

+ Choose a course section and capture the attendance snapshot that becomes the source of truth for follow-up. +

+
+
+
{totalMarkedPresent} present
+
{totalFlagged} flagged
+
+
+ +
+
+ + + {selectedSection ? ( +

+ {selectedSection.section_code || 'Section code pending'} + {selectedSection.termName ? ` • ${selectedSection.termName}` : ''} + {selectedSection.status ? ` • ${selectedSection.status}` : ''} +

+ ) : null} +
+ +
+
+ + setAttendanceDate(event.target.value)} + /> +
+
+ + +
+
+ +
+
+ + setStartTime(event.target.value)} + /> +
+
+ + setEndTime(event.target.value)} + /> +
+
+ +
+ +