diff --git a/backend/src/routes/schoolOps.js b/backend/src/routes/schoolOps.js new file mode 100644 index 0000000..12e6b47 --- /dev/null +++ b/backend/src/routes/schoolOps.js @@ -0,0 +1,204 @@ +const express = require('express'); +const db = require('../db/models'); +const wrapAsync = require('../helpers').wrapAsync; +const { checkPermissions } = require('../middlewares/check-permissions'); + +const router = express.Router(); + +const ATTENDANCE_STATUSES = ['present', 'absent', 'late', 'excused']; +const SESSION_TYPES = ['homeroom', 'subject', 'exam', 'event']; + +const getUserOrganizationId = (currentUser) => ( + currentUser?.organization?.id || + currentUser?.organizations?.id || + currentUser?.organizationId || + currentUser?.organizationsId || + null +); + +const isGlobalAccess = (currentUser) => Boolean(currentUser?.app_role?.globalAccess); + +const scopedWhere = (currentUser) => { + if (isGlobalAccess(currentUser)) { + return {}; + } + + const organizationId = getUserOrganizationId(currentUser); + return organizationId ? { organizationId } : { id: null }; +}; + +const studentName = (student) => [student?.first_name, student?.last_name].filter(Boolean).join(' ') || 'Unnamed student'; + +router.get( + '/attendance-context', + checkPermissions('READ_CLASSES'), + checkPermissions('READ_ATTENDANCE_SESSIONS'), + wrapAsync(async (req, res) => { + const classWhere = scopedWhere(req.currentUser); + const sessionWhere = scopedWhere(req.currentUser); + + const [classes, recentSessions] = await Promise.all([ + db.classes.findAll({ + where: classWhere, + include: [ + { model: db.campuses, as: 'campus' }, + { model: db.grades, as: 'grade' }, + { model: db.academic_years, as: 'academic_year' }, + ], + order: [['name', 'asc']], + limit: 100, + }), + db.attendance_sessions.findAll({ + where: sessionWhere, + include: [ + { model: db.campuses, as: 'campus' }, + { model: db.classes, as: 'class' }, + { model: db.staff, as: 'taken_by' }, + { model: db.attendance_records, as: 'attendance_records_attendance_session', required: false }, + ], + order: [['session_date', 'desc'], ['createdAt', 'desc']], + limit: 8, + }), + ]); + + res.status(200).send({ + classes: classes.map((item) => item.get({ plain: true })), + recentSessions: recentSessions.map((item) => item.get({ plain: true })), + }); + }), +); + +router.get( + '/attendance-roster', + checkPermissions('READ_CLASS_ENROLLMENTS'), + wrapAsync(async (req, res) => { + const { classId } = req.query; + + if (!classId) { + return res.status(400).send({ message: 'classId is required' }); + } + + const selectedClass = await db.classes.findByPk(classId); + if (!selectedClass) { + return res.status(404).send({ message: 'Class not found' }); + } + + const userOrganizationId = getUserOrganizationId(req.currentUser); + if (!isGlobalAccess(req.currentUser) && selectedClass.organizationId !== userOrganizationId) { + return res.status(403).send({ message: 'You can only view rosters inside your organization.' }); + } + + const enrollments = await db.class_enrollments.findAll({ + where: { + classId, + status: 'active', + }, + include: [{ model: db.students, as: 'student' }], + order: [['createdAt', 'asc']], + limit: 300, + }); + + res.status(200).send({ + class: selectedClass.get({ plain: true }), + students: enrollments + .map((enrollment) => enrollment.get({ plain: true }).student) + .filter(Boolean) + .map((student) => ({ + id: student.id, + student_number: student.student_number, + first_name: student.first_name, + last_name: student.last_name, + status: student.status, + displayName: studentName(student), + })), + }); + }), +); + +router.post( + '/attendance-runs', + checkPermissions('CREATE_ATTENDANCE_SESSIONS'), + checkPermissions('CREATE_ATTENDANCE_RECORDS'), + wrapAsync(async (req, res) => { + const { classId, sessionDate, sessionType, notes, records } = req.body || {}; + + if (!classId) { + return res.status(400).send({ message: 'Please select a class.' }); + } + + if (!sessionDate || Number.isNaN(Date.parse(sessionDate))) { + return res.status(400).send({ message: 'Please choose a valid attendance date.' }); + } + + if (!SESSION_TYPES.includes(sessionType)) { + return res.status(400).send({ message: 'Please choose a valid session type.' }); + } + + if (!Array.isArray(records) || records.length === 0) { + return res.status(400).send({ message: 'Add at least one student attendance record.' }); + } + + const invalidRecord = records.find((record) => !record.studentId || !ATTENDANCE_STATUSES.includes(record.status)); + if (invalidRecord) { + return res.status(400).send({ message: 'Each record needs a student and a valid attendance status.' }); + } + + const selectedClass = await db.classes.findByPk(classId); + if (!selectedClass) { + return res.status(404).send({ message: 'Class not found' }); + } + + const userOrganizationId = getUserOrganizationId(req.currentUser); + if (!isGlobalAccess(req.currentUser) && selectedClass.organizationId !== userOrganizationId) { + return res.status(403).send({ message: 'You can only record attendance inside your organization.' }); + } + + const transaction = await db.sequelize.transaction(); + + try { + const attendanceSession = await db.attendance_sessions.create( + { + session_date: sessionDate, + session_type: sessionType, + notes: notes || null, + organizationId: selectedClass.organizationId, + campusId: selectedClass.campusId, + classId: selectedClass.id, + createdById: req.currentUser.id, + updatedById: req.currentUser.id, + }, + { transaction }, + ); + + const attendanceRecords = await db.attendance_records.bulkCreate( + records.map((record) => ({ + status: record.status, + minutes_late: record.status === 'late' ? Number(record.minutes_late || 0) : null, + remarks: record.remarks || null, + organizationId: selectedClass.organizationId, + attendance_sessionId: attendanceSession.id, + studentId: record.studentId, + createdById: req.currentUser.id, + updatedById: req.currentUser.id, + })), + { transaction }, + ); + + await transaction.commit(); + + res.status(201).send({ + session: attendanceSession.get({ plain: true }), + recordsCreated: attendanceRecords.length, + summary: ATTENDANCE_STATUSES.reduce((acc, status) => { + acc[status] = records.filter((record) => record.status === status).length; + return acc; + }, {}), + }); + } catch (error) { + await transaction.rollback(); + throw error; + } + }), +); + +module.exports = router; diff --git a/frontend/src/pages/daily-attendance.tsx b/frontend/src/pages/daily-attendance.tsx new file mode 100644 index 0000000..99f046f --- /dev/null +++ b/frontend/src/pages/daily-attendance.tsx @@ -0,0 +1,465 @@ +import { mdiAccountCheckOutline, mdiChartTimelineVariant, mdiCheckCircleOutline, mdiClipboardListOutline, mdiRefresh, mdiSchoolOutline } from '@mdi/js'; +import axios from 'axios'; +import Head from 'next/head'; +import Link from 'next/link'; +import React, { ReactElement, useEffect, useMemo, useState } from 'react'; +import BaseButton from '../components/BaseButton'; +import BaseIcon from '../components/BaseIcon'; +import CardBox from '../components/CardBox'; +import SectionMain from '../components/SectionMain'; +import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../config'; +import LayoutAuthenticated from '../layouts/Authenticated'; + +type SchoolClass = { + id?: string; + name?: string; + section?: string; + capacity?: number; + campus?: { id: string; name?: string }; + grade?: { id: string; name?: string }; + academic_year?: { id: string; name?: string }; +}; + +type RosterStudent = { + id: string; + student_number?: string; + first_name?: string; + last_name?: string; + displayName?: string; +}; + +type AttendanceStatus = 'present' | 'absent' | 'late' | 'excused'; + +type DraftRecord = { + studentId: string; + studentName: string; + studentNumber?: string; + status: AttendanceStatus; + minutes_late: number; + remarks: string; +}; + +type RecentSession = { + id: string; + session_date?: string; + session_type?: string; + notes?: string; + class?: { name?: string; section?: string }; + campus?: { name?: string }; + attendance_records_attendance_session?: Array<{ status?: AttendanceStatus }>; +}; + +const statusOptions: AttendanceStatus[] = ['present', 'absent', 'late', 'excused']; +const statusClasses: Record = { + present: 'bg-emerald-50 text-emerald-700 ring-emerald-200', + absent: 'bg-rose-50 text-rose-700 ring-rose-200', + late: 'bg-amber-50 text-amber-700 ring-amber-200', + excused: 'bg-sky-50 text-sky-700 ring-sky-200', +}; + +const classLabel = (schoolClass?: SchoolClass) => { + if (!schoolClass) return 'Select class'; + return [schoolClass.name, schoolClass.section].filter(Boolean).join(' • ') || 'Unnamed class'; +}; + +const studentLabel = (student: RosterStudent) => student.displayName || [student.first_name, student.last_name].filter(Boolean).join(' ') || 'Unnamed student'; + +const DailyAttendance = () => { + const [classes, setClasses] = useState([]); + const [recentSessions, setRecentSessions] = useState([]); + const [selectedClassId, setSelectedClassId] = useState(''); + const [sessionDate, setSessionDate] = useState(() => new Date().toISOString().slice(0, 10)); + const [sessionType, setSessionType] = useState('homeroom'); + const [notes, setNotes] = useState(''); + const [records, setRecords] = useState([]); + const [loading, setLoading] = useState(true); + const [rosterLoading, setRosterLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + + const selectedClass = useMemo( + () => classes.find((item) => item.id === selectedClassId), + [classes, selectedClassId], + ); + + const totals = useMemo( + () => statusOptions.reduce((acc, status) => { + acc[status] = records.filter((record) => record.status === status).length; + return acc; + }, {} as Record), + [records], + ); + + const loadContext = async () => { + setError(''); + setLoading(true); + + try { + const response = await axios.get('school-ops/attendance-context'); + setClasses(response.data.classes || []); + setRecentSessions(response.data.recentSessions || []); + } catch (err: any) { + console.error('Daily attendance context failed:', err); + setError(err?.response?.data?.message || 'Could not load attendance workspace.'); + } finally { + setLoading(false); + } + }; + + const loadRoster = async (classId: string) => { + setSelectedClassId(classId); + setSuccess(''); + setError(''); + setRecords([]); + + if (!classId) return; + + setRosterLoading(true); + try { + const response = await axios.get(`school-ops/attendance-roster?classId=${classId}`); + const students: RosterStudent[] = response.data.students || []; + setRecords(students.map((student) => ({ + studentId: student.id, + studentName: studentLabel(student), + studentNumber: student.student_number, + status: 'present', + minutes_late: 0, + remarks: '', + }))); + } catch (err: any) { + console.error('Daily attendance roster failed:', err); + setError(err?.response?.data?.message || 'Could not load the class roster.'); + } finally { + setRosterLoading(false); + } + }; + + useEffect(() => { + loadContext(); + }, []); + + const updateRecord = (studentId: string, patch: Partial) => { + setRecords((current) => current.map((record) => ( + record.studentId === studentId ? { ...record, ...patch } : record + ))); + }; + + const markAll = (status: AttendanceStatus) => { + setRecords((current) => current.map((record) => ({ + ...record, + status, + minutes_late: status === 'late' ? record.minutes_late : 0, + }))); + }; + + const submitAttendance = async () => { + setError(''); + setSuccess(''); + + if (!selectedClassId) { + setError('Please select a class before saving attendance.'); + return; + } + + if (!sessionDate) { + setError('Please choose an attendance date.'); + return; + } + + if (!records.length) { + setError('This class has no active students to mark yet. Add enrollments first.'); + return; + } + + setSaving(true); + try { + const response = await axios.post('school-ops/attendance-runs', { + classId: selectedClassId, + sessionDate, + sessionType, + notes, + records: records.map((record) => ({ + studentId: record.studentId, + status: record.status, + minutes_late: record.minutes_late, + remarks: record.remarks, + })), + }); + + setSuccess(`Saved ${response.data.recordsCreated} attendance records for ${classLabel(selectedClass)}.`); + await loadContext(); + } catch (err: any) { + console.error('Daily attendance save failed:', err); + setError(err?.response?.data?.message || 'Attendance could not be saved. Please review the roster and try again.'); + } finally { + setSaving(false); + } + }; + + return ( + <> + + {getPageTitle('Daily Attendance')} + + + + + + +
+
+
+
+ Multi-tenant operations +
+

+ Record a clean daily attendance run in one guided workspace. +

+

+ Pick a class, load its active roster, mark exceptions, and save a complete auditable attendance session for the current organization. +

+
+
+
+
{classes.length}
+
classes available
+
+
+
{recentSessions.length}
+
recent sessions
+
+
+
+
+ + {error && ( +
+ {error} +
+ )} + {success && ( +
+ {success} +
+ )} + +
+ +
+ + + +
+ + {selectedClass && ( +
+ {selectedClass.campus?.name || 'No campus set'} + {selectedClass.grade?.name || 'Grade not set'} + Capacity {selectedClass.capacity || '—'} +
+ )} + +