Compare commits

...

1 Commits

Author SHA1 Message Date
Flatlogic Bot
d10b40e811 1 2026-04-13 15:19:38 +00:00
6 changed files with 1240 additions and 152 deletions

View File

@ -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;

View File

@ -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';

View File

@ -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'

View File

@ -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',

View 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&apos;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;

View File

@ -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 = {
eyebrow: string;
title: string;
description: string;
};
export default function Starter() { const FeatureCard = ({ eyebrow, title, description }: FeatureCardProps) => (
const [illustrationImage, setIllustrationImage] = useState({ <CardBox className="h-full border border-white/70 bg-white/90 shadow-sm backdrop-blur">
src: undefined, <p className="text-xs font-semibold uppercase tracking-[0.2em] text-sky-600">{eyebrow}</p>
photographer: undefined, <h3 className="mt-3 text-xl font-semibold text-slate-900">{title}</h3>
photographer_url: undefined, <p className="mt-3 text-sm leading-7 text-slate-500">{description}</p>
}) </CardBox>
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' export default function HomePage() {
// 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 ( 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>)
}
};
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&apos;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&apos;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>;
}; };