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_sessionsService = require('../services/attendance_sessions');
|
||||||
const Attendance_sessionsDBApi = require('../db/api/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 wrapAsync = require('../helpers').wrapAsync;
|
||||||
|
|
||||||
|
const { Op } = Sequelize;
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
const { parse } = require('json2csv');
|
const { parse } = require('json2csv');
|
||||||
@ -18,6 +21,298 @@ const {
|
|||||||
router.use(checkCrudPermissions('attendance_sessions'));
|
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
|
* @swagger
|
||||||
* components:
|
* components:
|
||||||
@ -174,6 +469,150 @@ router.post('/bulk-import', wrapAsync(async (req, res) => {
|
|||||||
* 500:
|
* 500:
|
||||||
* description: Some server error
|
* 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) => {
|
router.put('/:id', wrapAsync(async (req, res) => {
|
||||||
await Attendance_sessionsService.update(req.body.data, req.body.id, req.currentUser);
|
await Attendance_sessionsService.update(req.body.data, req.body.id, req.currentUser);
|
||||||
const payload = true;
|
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 Link from 'next/link'
|
||||||
import { useState } from 'react'
|
|
||||||
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
||||||
import BaseDivider from './BaseDivider'
|
import BaseDivider from './BaseDivider'
|
||||||
import BaseIcon from './BaseIcon'
|
import BaseIcon from './BaseIcon'
|
||||||
@ -64,7 +63,7 @@ export default function NavBarItem({ item }: Props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getItemId = (label) => {
|
const getItemId = (label: string) => {
|
||||||
switch (label) {
|
switch (label) {
|
||||||
case 'Light/Dark':
|
case 'Light/Dark':
|
||||||
return 'themeToggle';
|
return 'themeToggle';
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import React, { ReactNode, useEffect } from 'react'
|
import React, { ReactNode, useEffect, useState } from 'react'
|
||||||
import { useState } from 'react'
|
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
||||||
import menuAside from '../menuAside'
|
import menuAside from '../menuAside'
|
||||||
|
|||||||
@ -7,6 +7,14 @@ const menuAside: MenuAsideItem[] = [
|
|||||||
icon: icon.mdiViewDashboardOutline,
|
icon: icon.mdiViewDashboardOutline,
|
||||||
label: 'Dashboard',
|
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',
|
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 { mdiArrowRight, mdiCalendarCheckOutline, mdiLogin, mdiSchoolOutline, mdiViewDashboardOutline } from '@mdi/js';
|
||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import type { ReactElement } from 'react';
|
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import Link from 'next/link';
|
import React, { ReactElement } from 'react';
|
||||||
import BaseButton from '../components/BaseButton';
|
import BaseButton from '../components/BaseButton';
|
||||||
import CardBox from '../components/CardBox';
|
import CardBox from '../components/CardBox';
|
||||||
import SectionFullScreen from '../components/SectionFullScreen';
|
|
||||||
import LayoutGuest from '../layouts/Guest';
|
import LayoutGuest from '../layouts/Guest';
|
||||||
import BaseDivider from '../components/BaseDivider';
|
|
||||||
import BaseButtons from '../components/BaseButtons';
|
|
||||||
import { getPageTitle } from '../config';
|
import { getPageTitle } from '../config';
|
||||||
import { useAppSelector } from '../stores/hooks';
|
|
||||||
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
|
|
||||||
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
|
|
||||||
|
|
||||||
|
type FeatureCardProps = {
|
||||||
export default function Starter() {
|
eyebrow: string;
|
||||||
const [illustrationImage, setIllustrationImage] = useState({
|
title: string;
|
||||||
src: undefined,
|
description: string;
|
||||||
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>)
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 (
|
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>
|
<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>
|
</Head>
|
||||||
|
|
||||||
<SectionFullScreen bg='violet'>
|
<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">
|
||||||
<div
|
<header className="mx-auto flex w-full max-w-7xl items-center justify-between px-6 py-6 lg:px-10">
|
||||||
className={`flex ${
|
<div>
|
||||||
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
|
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-sky-600">Northstar Academy Suite</p>
|
||||||
} min-h-screen w-full`}
|
<h1 className="mt-2 text-lg font-semibold text-slate-900">Modern school operations + parent portal</h1>
|
||||||
>
|
|
||||||
{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>
|
</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>
|
<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">
|
||||||
<BaseButton
|
<section className="grid gap-10 lg:grid-cols-[1.2fr,0.8fr] lg:items-center">
|
||||||
href='/login'
|
<div>
|
||||||
label='Login'
|
<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">
|
||||||
color='info'
|
School-branded MVP • ready for staff + families
|
||||||
className='w-full'
|
</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.
|
||||||
</BaseButtons>
|
</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>
|
</CardBox>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
<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>;
|
return <LayoutGuest>{page}</LayoutGuest>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user