Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d10b40e811 |
@ -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;
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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',
|
||||
|
||||
661
frontend/src/pages/attendance-hub.tsx
Normal file
661
frontend/src/pages/attendance-hub.tsx
Normal file
@ -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<AttendanceStatus, number>;
|
||||
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<Record<string, RosterFormRow>>((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 }) => (
|
||||
<div className="rounded-3xl border border-white/70 bg-white/85 p-4 shadow-sm backdrop-blur">
|
||||
<div className="mb-3 inline-flex rounded-2xl p-2" style={{ backgroundColor: accent }}>
|
||||
<span className="text-white">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" className="inline-block">
|
||||
<path fill="currentColor" d={icon} />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500">{label}</p>
|
||||
<p className="text-2xl font-semibold text-slate-900">{value}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const AttendanceHubPage = () => {
|
||||
const { currentUser } = useAppSelector((state) => state.auth);
|
||||
const [workflow, setWorkflow] = useState<WorkflowResponse>(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<Record<string, RosterFormRow>>({});
|
||||
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<WorkflowResponse>('/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<Record<string, RosterFormRow>>((acc, [studentId, row]) => {
|
||||
acc[studentId] = {
|
||||
...row,
|
||||
status,
|
||||
minutesLate: status === 'late' ? row.minutesLate || '5' : '',
|
||||
};
|
||||
|
||||
return acc;
|
||||
}, {}),
|
||||
);
|
||||
};
|
||||
|
||||
const updateRow = (studentId: string, nextValue: Partial<RosterFormRow>) => {
|
||||
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 (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Attendance Hub')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={mdiClipboardCheckOutline} title="Attendance hub" main>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<BaseButton href="/attendance_sessions/attendance_sessions-list" color="whiteDark" label="All sessions" />
|
||||
<BaseButton href="/attendance_records/attendance_records-list" color="info" label="Attendance records" />
|
||||
</div>
|
||||
</SectionTitleLineWithButton>
|
||||
|
||||
<CardBox className="mb-6 overflow-hidden border-0 bg-gradient-to-br from-slate-950 via-sky-900 to-cyan-700 text-white shadow-2xl">
|
||||
<div className="grid gap-8 lg:grid-cols-[1.5fr,1fr]">
|
||||
<div>
|
||||
<div className="mb-4 inline-flex items-center rounded-full border border-white/20 bg-white/10 px-4 py-2 text-sm font-medium text-cyan-50">
|
||||
Daily teacher workflow
|
||||
</div>
|
||||
<h2 className="max-w-2xl text-3xl font-semibold leading-tight md:text-4xl">
|
||||
Take attendance in one focused flow instead of bouncing between generic CRUD screens.
|
||||
</h2>
|
||||
<p className="mt-4 max-w-2xl text-sm leading-7 text-sky-100 md:text-base">
|
||||
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.
|
||||
</p>
|
||||
<div className="mt-6 flex flex-wrap items-center gap-3 text-sm text-sky-100">
|
||||
<span className="rounded-full border border-white/15 bg-white/10 px-3 py-1">{currentUser?.firstName || 'Staff'} signed in</span>
|
||||
<span className="rounded-full border border-white/15 bg-white/10 px-3 py-1">{currentUser?.app_role?.name || 'School staff'}</span>
|
||||
{selectedSection?.teacherName ? (
|
||||
<span className="rounded-full border border-white/15 bg-white/10 px-3 py-1">Lead teacher: {selectedSection.teacherName}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-3 lg:grid-cols-1">
|
||||
<StatCard icon={mdiSchoolOutline} label="Sections ready" value={String(workflow.summary.totalSections)} accent="#0f766e" />
|
||||
<StatCard icon={mdiAccountGroupOutline} label="Students in roster" value={String(workflow.summary.totalStudents)} accent="#0284c7" />
|
||||
<StatCard icon={mdiCalendarCheckOutline} label="Recent sessions" value={String(workflow.summary.recentSessionsCount)} accent="#7c3aed" />
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
{errorMessage ? (
|
||||
<div className="mb-6 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{successMessage ? (
|
||||
<div className="mb-6 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<span>{successMessage}</span>
|
||||
{savedSessionId ? (
|
||||
<Link className="inline-flex items-center gap-2 font-semibold text-emerald-700 hover:text-emerald-800" href={`/attendance_sessions/${savedSessionId}`}>
|
||||
Open saved session
|
||||
<span aria-hidden="true">→</span>
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[1.2fr,1.8fr]">
|
||||
<CardBox className="border-slate-200 bg-white shadow-sm">
|
||||
<div className="mb-6 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.2em] text-sky-600">Step 1</p>
|
||||
<h3 className="mt-2 text-2xl font-semibold text-slate-900">Build today's attendance session</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-500">
|
||||
Choose a course section and capture the attendance snapshot that becomes the source of truth for follow-up.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-sky-50 px-4 py-3 text-right text-sm text-sky-700">
|
||||
<div className="font-semibold">{totalMarkedPresent} present</div>
|
||||
<div>{totalFlagged} flagged</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-semibold text-slate-700" htmlFor="courseSectionId">
|
||||
Course section
|
||||
</label>
|
||||
<select
|
||||
id="courseSectionId"
|
||||
className="h-12 w-full rounded-2xl border border-slate-200 bg-white px-4 text-sm text-slate-900 shadow-sm outline-none transition focus:border-sky-400 focus:ring-4 focus:ring-sky-100"
|
||||
value={selectedSectionId || workflow.selectedSectionId || ''}
|
||||
onChange={(event) => setSelectedSectionId(event.target.value)}
|
||||
>
|
||||
<option value="">Select a section</option>
|
||||
{workflow.sections.map((section) => (
|
||||
<option key={section.id} value={section.id}>
|
||||
{formatSectionLabel(section)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedSection ? (
|
||||
<p className="mt-2 text-sm text-slate-500">
|
||||
{selectedSection.section_code || 'Section code pending'}
|
||||
{selectedSection.termName ? ` • ${selectedSection.termName}` : ''}
|
||||
{selectedSection.status ? ` • ${selectedSection.status}` : ''}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-semibold text-slate-700" htmlFor="attendanceDate">
|
||||
Session date
|
||||
</label>
|
||||
<input
|
||||
id="attendanceDate"
|
||||
type="date"
|
||||
className="h-12 w-full rounded-2xl border border-slate-200 px-4 text-sm text-slate-900 shadow-sm outline-none transition focus:border-sky-400 focus:ring-4 focus:ring-sky-100"
|
||||
value={attendanceDate}
|
||||
onChange={(event) => setAttendanceDate(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-semibold text-slate-700" htmlFor="sessionStatus">
|
||||
Session state
|
||||
</label>
|
||||
<select
|
||||
id="sessionStatus"
|
||||
className="h-12 w-full rounded-2xl border border-slate-200 px-4 text-sm text-slate-900 shadow-sm outline-none transition focus:border-sky-400 focus:ring-4 focus:ring-sky-100"
|
||||
value={sessionStatus}
|
||||
onChange={(event) => setSessionStatus(event.target.value as 'open' | 'closed')}
|
||||
>
|
||||
<option value="closed">Closed after save</option>
|
||||
<option value="open">Keep open</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-semibold text-slate-700" htmlFor="startTime">
|
||||
Start time
|
||||
</label>
|
||||
<input
|
||||
id="startTime"
|
||||
type="time"
|
||||
className="h-12 w-full rounded-2xl border border-slate-200 px-4 text-sm text-slate-900 shadow-sm outline-none transition focus:border-sky-400 focus:ring-4 focus:ring-sky-100"
|
||||
value={startTime}
|
||||
onChange={(event) => setStartTime(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-semibold text-slate-700" htmlFor="endTime">
|
||||
End time
|
||||
</label>
|
||||
<input
|
||||
id="endTime"
|
||||
type="time"
|
||||
className="h-12 w-full rounded-2xl border border-slate-200 px-4 text-sm text-slate-900 shadow-sm outline-none transition focus:border-sky-400 focus:ring-4 focus:ring-sky-100"
|
||||
value={endTime}
|
||||
onChange={(event) => setEndTime(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-semibold text-slate-700" htmlFor="notes">
|
||||
Notes for this session
|
||||
</label>
|
||||
<textarea
|
||||
id="notes"
|
||||
rows={4}
|
||||
className="w-full rounded-2xl border border-slate-200 px-4 py-3 text-sm text-slate-900 shadow-sm outline-none transition focus:border-sky-400 focus:ring-4 focus:ring-sky-100"
|
||||
placeholder="Examples: quiz day, substitute teacher, assembly started late..."
|
||||
value={notes}
|
||||
onChange={(event) => setNotes(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-900">Quick mark</p>
|
||||
<p className="text-sm text-slate-500">Set the whole roster before adjusting individual students.</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<BaseButton color="success" label="All present" onClick={() => setBulkStatus('present')} />
|
||||
<BaseButton color="warning" label="All late" onClick={() => setBulkStatus('late')} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<BaseButton color="info" label={isSaving ? 'Saving...' : 'Save attendance session'} onClick={saveSession} disabled={isSaving || isLoading} />
|
||||
<BaseButton color="whiteDark" label="Reset roster" onClick={() => setRows(buildDefaultRows(workflow.roster))} disabled={isLoading} />
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
<div className="space-y-6">
|
||||
<CardBox className="border-slate-200 bg-white shadow-sm">
|
||||
<div className="mb-6 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.2em] text-sky-600">Step 2</p>
|
||||
<h3 className="mt-2 text-2xl font-semibold text-slate-900">Mark the roster</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-500">
|
||||
Default everything to present, then only update students who need a note or follow-up.
|
||||
</p>
|
||||
</div>
|
||||
{workflow.rosterSource ? (
|
||||
<span className="rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">
|
||||
Roster from {workflow.rosterSource}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="rounded-2xl border border-dashed border-slate-200 bg-slate-50 px-6 py-12 text-center text-sm text-slate-500">
|
||||
Loading roster…
|
||||
</div>
|
||||
) : workflow.roster.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-slate-200 bg-slate-50 px-6 py-12 text-center">
|
||||
<p className="text-lg font-semibold text-slate-900">No students are linked to this section yet.</p>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-500">
|
||||
The hub looks for class enrollments first, then falls back to existing attendance history, submissions, and grades to build a usable roster.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{workflow.roster.map((student) => {
|
||||
const row = rows[student.studentId] ?? { status: 'present', minutesLate: '', comment: '' };
|
||||
|
||||
return (
|
||||
<div key={student.studentId} className="rounded-3xl border border-slate-200 p-4 shadow-sm transition hover:border-sky-200 hover:bg-slate-50/80">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div>
|
||||
<p className="text-lg font-semibold text-slate-900">{student.displayName}</p>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
{student.studentNumber || 'Student profile'}
|
||||
{student.source ? ` • sourced from ${student.source}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{STATUS_OPTIONS.map((option) => {
|
||||
const isActive = row.status === option.value;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
updateRow(student.studentId, {
|
||||
status: option.value,
|
||||
minutesLate: option.value === 'late' ? row.minutesLate || '5' : '',
|
||||
})
|
||||
}
|
||||
className={`rounded-full border px-4 py-2 text-sm font-semibold transition ${
|
||||
isActive
|
||||
? option.tone
|
||||
: 'border-slate-200 bg-white text-slate-500 hover:border-slate-300 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-semibold text-slate-700" htmlFor={`comment-${student.studentId}`}>
|
||||
Follow-up note
|
||||
</label>
|
||||
<input
|
||||
id={`comment-${student.studentId}`}
|
||||
type="text"
|
||||
value={row.comment}
|
||||
onChange={(event) => updateRow(student.studentId, { comment: event.target.value })}
|
||||
placeholder="Optional note for parent call, counselor, or teacher"
|
||||
className="h-11 w-full rounded-2xl border border-slate-200 px-4 text-sm text-slate-900 shadow-sm outline-none transition focus:border-sky-400 focus:ring-4 focus:ring-sky-100"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-semibold text-slate-700" htmlFor={`minutes-${student.studentId}`}>
|
||||
Minutes late
|
||||
</label>
|
||||
<input
|
||||
id={`minutes-${student.studentId}`}
|
||||
type="number"
|
||||
min={0}
|
||||
value={row.minutesLate}
|
||||
onChange={(event) => updateRow(student.studentId, { minutesLate: event.target.value })}
|
||||
placeholder="0"
|
||||
disabled={row.status !== 'late'}
|
||||
className="h-11 w-full rounded-2xl border border-slate-200 px-4 text-sm text-slate-900 shadow-sm outline-none transition focus:border-sky-400 focus:ring-4 focus:ring-sky-100 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardBox>
|
||||
|
||||
<CardBox className="border-slate-200 bg-white shadow-sm">
|
||||
<div className="mb-6 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.2em] text-sky-600">Step 3</p>
|
||||
<h3 className="mt-2 text-2xl font-semibold text-slate-900">Review recent sessions</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-500">
|
||||
After each save, the newest attendance snapshot appears here so staff can quickly verify what changed.
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/attendance_sessions/attendance_sessions-list" className="text-sm font-semibold text-sky-700 hover:text-sky-900">
|
||||
Open all sessions
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{workflow.recentSessions.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-slate-200 bg-slate-50 px-6 py-12 text-center text-sm text-slate-500">
|
||||
No attendance sessions have been saved for this section yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{workflow.recentSessions.map((session) => (
|
||||
<div key={session.id} className="rounded-3xl border border-slate-200 p-4 shadow-sm">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">
|
||||
{session.status}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-2 text-sm text-slate-500">
|
||||
<span aria-hidden="true">🕒</span>
|
||||
{formatSessionDate(session.session_start)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-3 text-base font-semibold text-slate-900">{session.notes || 'Attendance session saved without notes'}</p>
|
||||
<p className="mt-2 text-sm text-slate-500">{session.totalRecords} students recorded</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<span key={option.value} className={`rounded-full border px-3 py-1 text-xs font-semibold uppercase tracking-[0.18em] ${option.tone}`}>
|
||||
{option.label}: {session.summary[option.value] || 0}
|
||||
</span>
|
||||
))}
|
||||
<Link href={`/attendance_sessions/${session.id}`} className="inline-flex items-center gap-2 rounded-full border border-slate-200 px-4 py-2 text-sm font-semibold text-slate-700 transition hover:border-slate-300 hover:text-slate-900">
|
||||
View details
|
||||
<span aria-hidden="true">→</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardBox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardBox className="mt-6 border-slate-200 bg-white shadow-sm">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.2em] text-sky-600">What this first slice covers</p>
|
||||
<h3 className="mt-2 text-2xl font-semibold text-slate-900">Create → confirm → review</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-500">
|
||||
This hub intentionally focuses on the highest-frequency teacher action. Once you confirm the flow, the next layer can add student and parent-facing attendance visibility.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<BaseButton href="/assignments/assignments-list" color="whiteDark" label="Assignments" />
|
||||
<BaseButton href="/announcements/announcements-list" color="whiteDark" label="Announcements" />
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
</SectionMain>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
AttendanceHubPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutAuthenticated permission={'READ_ATTENDANCE_SESSIONS'}>{page}</LayoutAuthenticated>;
|
||||
};
|
||||
|
||||
export default AttendanceHubPage;
|
||||
@ -1,166 +1,148 @@
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import { mdiArrowRight, mdiCalendarCheckOutline, mdiLogin, mdiSchoolOutline, mdiViewDashboardOutline } from '@mdi/js';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import React, { ReactElement } from 'react';
|
||||
import BaseButton from '../components/BaseButton';
|
||||
import CardBox from '../components/CardBox';
|
||||
import SectionFullScreen from '../components/SectionFullScreen';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
import BaseDivider from '../components/BaseDivider';
|
||||
import BaseButtons from '../components/BaseButtons';
|
||||
import { getPageTitle } from '../config';
|
||||
import { useAppSelector } from '../stores/hooks';
|
||||
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
|
||||
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
|
||||
|
||||
|
||||
export default function Starter() {
|
||||
const [illustrationImage, setIllustrationImage] = useState({
|
||||
src: undefined,
|
||||
photographer: undefined,
|
||||
photographer_url: undefined,
|
||||
})
|
||||
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
|
||||
const [contentType, setContentType] = useState('image');
|
||||
const [contentPosition, setContentPosition] = useState('right');
|
||||
const textColor = useAppSelector((state) => state.style.linkColor);
|
||||
|
||||
const title = 'School Portal'
|
||||
|
||||
// Fetch Pexels image/video
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
const image = await getPexelsImage();
|
||||
const video = await getPexelsVideo();
|
||||
setIllustrationImage(image);
|
||||
setIllustrationVideo(video);
|
||||
}
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const imageBlock = (image) => (
|
||||
<div
|
||||
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
|
||||
style={{
|
||||
backgroundImage: `${
|
||||
image
|
||||
? `url(${image?.src?.original})`
|
||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
||||
}`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'left center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}}
|
||||
>
|
||||
<div className='flex justify-center w-full bg-blue-300/20'>
|
||||
<a
|
||||
className='text-[8px]'
|
||||
href={image?.photographer_url}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
Photo by {image?.photographer} on Pexels
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const videoBlock = (video) => {
|
||||
if (video?.video_files?.length > 0) {
|
||||
return (
|
||||
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
|
||||
<video
|
||||
className='absolute top-0 left-0 w-full h-full object-cover'
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
>
|
||||
<source src={video?.video_files[0]?.link} type='video/mp4'/>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
|
||||
<a
|
||||
className='text-[8px]'
|
||||
href={video?.user?.url}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
Video by {video.user.name} on Pexels
|
||||
</a>
|
||||
</div>
|
||||
</div>)
|
||||
}
|
||||
type FeatureCardProps = {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
const FeatureCard = ({ eyebrow, title, description }: FeatureCardProps) => (
|
||||
<CardBox className="h-full border border-white/70 bg-white/90 shadow-sm backdrop-blur">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-sky-600">{eyebrow}</p>
|
||||
<h3 className="mt-3 text-xl font-semibold text-slate-900">{title}</h3>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-500">{description}</p>
|
||||
</CardBox>
|
||||
);
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<div
|
||||
style={
|
||||
contentPosition === 'background'
|
||||
? {
|
||||
backgroundImage: `${
|
||||
illustrationImage
|
||||
? `url(${illustrationImage.src?.original})`
|
||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
||||
}`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'left center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Starter Page')}</title>
|
||||
<title>{getPageTitle('School Portal')}</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="School portal for staff, teachers, students and parents to manage schedules, attendance, grades, assignments and announcements."
|
||||
/>
|
||||
</Head>
|
||||
|
||||
<SectionFullScreen bg='violet'>
|
||||
<div
|
||||
className={`flex ${
|
||||
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
|
||||
} min-h-screen w-full`}
|
||||
>
|
||||
{contentType === 'image' && contentPosition !== 'background'
|
||||
? imageBlock(illustrationImage)
|
||||
: null}
|
||||
{contentType === 'video' && contentPosition !== 'background'
|
||||
? videoBlock(illustrationVideo)
|
||||
: null}
|
||||
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
||||
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
||||
<CardBoxComponentTitle title="Welcome to your School Portal app!"/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<p className='text-center text-gray-500'>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
|
||||
<p className='text-center text-gray-500'>For guides and documentation please check
|
||||
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
|
||||
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(14,165,233,0.14),_transparent_35%),radial-gradient(circle_at_top_right,_rgba(16,185,129,0.12),_transparent_25%),linear-gradient(180deg,_#f8fbff_0%,_#eef6ff_48%,_#ffffff_100%)] text-slate-900">
|
||||
<header className="mx-auto flex w-full max-w-7xl items-center justify-between px-6 py-6 lg:px-10">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-sky-600">Northstar Academy Suite</p>
|
||||
<h1 className="mt-2 text-lg font-semibold text-slate-900">Modern school operations + parent portal</h1>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<BaseButton href="/dashboard" color="whiteDark" icon={mdiViewDashboardOutline} label="Admin interface" />
|
||||
<BaseButton href="/login" color="info" icon={mdiLogin} label="Login" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<BaseButtons>
|
||||
<BaseButton
|
||||
href='/login'
|
||||
label='Login'
|
||||
color='info'
|
||||
className='w-full'
|
||||
/>
|
||||
|
||||
</BaseButtons>
|
||||
<main className="mx-auto flex w-full max-w-7xl flex-col gap-10 px-6 pb-16 pt-4 lg:px-10 lg:pb-24 lg:pt-10">
|
||||
<section className="grid gap-10 lg:grid-cols-[1.2fr,0.8fr] lg:items-center">
|
||||
<div>
|
||||
<div className="inline-flex items-center rounded-full border border-sky-200 bg-white/80 px-4 py-2 text-sm font-medium text-sky-700 shadow-sm backdrop-blur">
|
||||
School-branded MVP • ready for staff + families
|
||||
</div>
|
||||
<h2 className="mt-6 max-w-3xl text-4xl font-semibold leading-tight text-slate-950 md:text-6xl">
|
||||
Keep classes moving with a clean school dashboard that feels built for real mornings.
|
||||
</h2>
|
||||
<p className="mt-6 max-w-2xl text-lg leading-8 text-slate-600">
|
||||
This first iteration gives your school a polished public landing page and a real teacher workflow for taking attendance end-to-end, while keeping the admin interface and portal foundation in place.
|
||||
</p>
|
||||
<div className="mt-8 flex flex-wrap gap-4">
|
||||
<BaseButton href="/attendance-hub" color="info" icon={mdiCalendarCheckOutline} label="Open attendance hub" />
|
||||
<BaseButton href="/dashboard" color="whiteDark" icon={mdiSchoolOutline} label="Open admin interface" />
|
||||
</div>
|
||||
<div className="mt-10 grid gap-4 sm:grid-cols-3">
|
||||
<CardBox className="border border-white/70 bg-white/90 shadow-sm">
|
||||
<p className="text-sm font-semibold text-slate-500">For admins</p>
|
||||
<p className="mt-2 text-2xl font-semibold text-slate-900">Students, classes, staff</p>
|
||||
</CardBox>
|
||||
<CardBox className="border border-white/70 bg-white/90 shadow-sm">
|
||||
<p className="text-sm font-semibold text-slate-500">For teachers</p>
|
||||
<p className="mt-2 text-2xl font-semibold text-slate-900">Attendance, grades, assignments</p>
|
||||
</CardBox>
|
||||
<CardBox className="border border-white/70 bg-white/90 shadow-sm">
|
||||
<p className="text-sm font-semibold text-slate-500">For families</p>
|
||||
<p className="mt-2 text-2xl font-semibold text-slate-900">Timetable, homework, updates</p>
|
||||
</CardBox>
|
||||
</div>
|
||||
</div>
|
||||
</SectionFullScreen>
|
||||
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
|
||||
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
|
||||
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<CardBox className="overflow-hidden border-0 bg-gradient-to-br from-slate-950 via-sky-900 to-cyan-700 text-white shadow-2xl">
|
||||
<div className="rounded-3xl border border-white/10 bg-white/10 p-6 backdrop-blur">
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.2em] text-cyan-100">Today's highlight</p>
|
||||
<h3 className="mt-3 text-3xl font-semibold">Attendance Hub</h3>
|
||||
<p className="mt-3 text-sm leading-7 text-sky-100">
|
||||
A focused operational flow where staff can pick a section, mark presence, save the session, and immediately review the latest attendance snapshot.
|
||||
</p>
|
||||
<div className="mt-6 space-y-3 text-sm text-sky-50">
|
||||
<div className="flex items-center justify-between rounded-2xl bg-white/10 px-4 py-3">
|
||||
<span>1. Select section + session time</span>
|
||||
<span className="font-semibold">Fast setup</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-2xl bg-white/10 px-4 py-3">
|
||||
<span>2. Mark students in one roster</span>
|
||||
<span className="font-semibold">Clear status chips</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-2xl bg-white/10 px-4 py-3">
|
||||
<span>3. Review latest saved sessions</span>
|
||||
<span className="font-semibold">Immediate feedback</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<BaseButton href="/attendance-hub" color="success" icon={mdiArrowRight} label="Try the workflow" />
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-6 lg:grid-cols-3">
|
||||
<FeatureCard
|
||||
eyebrow="Operations"
|
||||
title="School admin control"
|
||||
description="Manage the core records that keep a school running: students, parents, teachers, campuses, classes, sections, and events."
|
||||
/>
|
||||
<FeatureCard
|
||||
eyebrow="Teaching"
|
||||
title="Teacher-friendly daily actions"
|
||||
description="Move the most frequent staff task into a dedicated flow, so attendance no longer feels like generic data entry."
|
||||
/>
|
||||
<FeatureCard
|
||||
eyebrow="Portal"
|
||||
title="Student + parent visibility"
|
||||
description="Build toward a family-facing experience for schedules, assignments, messages, and announcements without opening public data by default."
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[2rem] border border-slate-200 bg-white/90 p-8 shadow-sm backdrop-blur lg:p-10">
|
||||
<div className="grid gap-6 lg:grid-cols-[1.2fr,0.8fr] lg:items-center">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-sky-600">What's included right now</p>
|
||||
<h3 className="mt-3 text-3xl font-semibold text-slate-900">A polished first win, not just a pretty shell.</h3>
|
||||
<p className="mt-4 max-w-2xl text-sm leading-7 text-slate-500">
|
||||
Your seed app already has the school entities and CRUD. This first pass adds a branded front door and a meaningful workflow slice so you can validate the product direction quickly.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3 rounded-[1.5rem] bg-slate-50 p-6">
|
||||
<div className="rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-600">✓ Public landing page with modern school branding</div>
|
||||
<div className="rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-600">✓ Clear path to login and admin interface</div>
|
||||
<div className="rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-600">✓ Attendance workflow tailored for staff</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Starter.getLayout = function getLayout(page: ReactElement) {
|
||||
HomePage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutGuest>{page}</LayoutGuest>;
|
||||
};
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user