Autosave: 20260617-141704
This commit is contained in:
parent
d1a08e4c3d
commit
191f79c21c
204
backend/src/routes/schoolOps.js
Normal file
204
backend/src/routes/schoolOps.js
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const db = require('../db/models');
|
||||||
|
const wrapAsync = require('../helpers').wrapAsync;
|
||||||
|
const { checkPermissions } = require('../middlewares/check-permissions');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
const ATTENDANCE_STATUSES = ['present', 'absent', 'late', 'excused'];
|
||||||
|
const SESSION_TYPES = ['homeroom', 'subject', 'exam', 'event'];
|
||||||
|
|
||||||
|
const getUserOrganizationId = (currentUser) => (
|
||||||
|
currentUser?.organization?.id ||
|
||||||
|
currentUser?.organizations?.id ||
|
||||||
|
currentUser?.organizationId ||
|
||||||
|
currentUser?.organizationsId ||
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
const isGlobalAccess = (currentUser) => Boolean(currentUser?.app_role?.globalAccess);
|
||||||
|
|
||||||
|
const scopedWhere = (currentUser) => {
|
||||||
|
if (isGlobalAccess(currentUser)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const organizationId = getUserOrganizationId(currentUser);
|
||||||
|
return organizationId ? { organizationId } : { id: null };
|
||||||
|
};
|
||||||
|
|
||||||
|
const studentName = (student) => [student?.first_name, student?.last_name].filter(Boolean).join(' ') || 'Unnamed student';
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/attendance-context',
|
||||||
|
checkPermissions('READ_CLASSES'),
|
||||||
|
checkPermissions('READ_ATTENDANCE_SESSIONS'),
|
||||||
|
wrapAsync(async (req, res) => {
|
||||||
|
const classWhere = scopedWhere(req.currentUser);
|
||||||
|
const sessionWhere = scopedWhere(req.currentUser);
|
||||||
|
|
||||||
|
const [classes, recentSessions] = await Promise.all([
|
||||||
|
db.classes.findAll({
|
||||||
|
where: classWhere,
|
||||||
|
include: [
|
||||||
|
{ model: db.campuses, as: 'campus' },
|
||||||
|
{ model: db.grades, as: 'grade' },
|
||||||
|
{ model: db.academic_years, as: 'academic_year' },
|
||||||
|
],
|
||||||
|
order: [['name', 'asc']],
|
||||||
|
limit: 100,
|
||||||
|
}),
|
||||||
|
db.attendance_sessions.findAll({
|
||||||
|
where: sessionWhere,
|
||||||
|
include: [
|
||||||
|
{ model: db.campuses, as: 'campus' },
|
||||||
|
{ model: db.classes, as: 'class' },
|
||||||
|
{ model: db.staff, as: 'taken_by' },
|
||||||
|
{ model: db.attendance_records, as: 'attendance_records_attendance_session', required: false },
|
||||||
|
],
|
||||||
|
order: [['session_date', 'desc'], ['createdAt', 'desc']],
|
||||||
|
limit: 8,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.status(200).send({
|
||||||
|
classes: classes.map((item) => item.get({ plain: true })),
|
||||||
|
recentSessions: recentSessions.map((item) => item.get({ plain: true })),
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/attendance-roster',
|
||||||
|
checkPermissions('READ_CLASS_ENROLLMENTS'),
|
||||||
|
wrapAsync(async (req, res) => {
|
||||||
|
const { classId } = req.query;
|
||||||
|
|
||||||
|
if (!classId) {
|
||||||
|
return res.status(400).send({ message: 'classId is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedClass = await db.classes.findByPk(classId);
|
||||||
|
if (!selectedClass) {
|
||||||
|
return res.status(404).send({ message: 'Class not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userOrganizationId = getUserOrganizationId(req.currentUser);
|
||||||
|
if (!isGlobalAccess(req.currentUser) && selectedClass.organizationId !== userOrganizationId) {
|
||||||
|
return res.status(403).send({ message: 'You can only view rosters inside your organization.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const enrollments = await db.class_enrollments.findAll({
|
||||||
|
where: {
|
||||||
|
classId,
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
include: [{ model: db.students, as: 'student' }],
|
||||||
|
order: [['createdAt', 'asc']],
|
||||||
|
limit: 300,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).send({
|
||||||
|
class: selectedClass.get({ plain: true }),
|
||||||
|
students: enrollments
|
||||||
|
.map((enrollment) => enrollment.get({ plain: true }).student)
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((student) => ({
|
||||||
|
id: student.id,
|
||||||
|
student_number: student.student_number,
|
||||||
|
first_name: student.first_name,
|
||||||
|
last_name: student.last_name,
|
||||||
|
status: student.status,
|
||||||
|
displayName: studentName(student),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/attendance-runs',
|
||||||
|
checkPermissions('CREATE_ATTENDANCE_SESSIONS'),
|
||||||
|
checkPermissions('CREATE_ATTENDANCE_RECORDS'),
|
||||||
|
wrapAsync(async (req, res) => {
|
||||||
|
const { classId, sessionDate, sessionType, notes, records } = req.body || {};
|
||||||
|
|
||||||
|
if (!classId) {
|
||||||
|
return res.status(400).send({ message: 'Please select a class.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sessionDate || Number.isNaN(Date.parse(sessionDate))) {
|
||||||
|
return res.status(400).send({ message: 'Please choose a valid attendance date.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!SESSION_TYPES.includes(sessionType)) {
|
||||||
|
return res.status(400).send({ message: 'Please choose a valid session type.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(records) || records.length === 0) {
|
||||||
|
return res.status(400).send({ message: 'Add at least one student attendance record.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const invalidRecord = records.find((record) => !record.studentId || !ATTENDANCE_STATUSES.includes(record.status));
|
||||||
|
if (invalidRecord) {
|
||||||
|
return res.status(400).send({ message: 'Each record needs a student and a valid attendance status.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedClass = await db.classes.findByPk(classId);
|
||||||
|
if (!selectedClass) {
|
||||||
|
return res.status(404).send({ message: 'Class not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userOrganizationId = getUserOrganizationId(req.currentUser);
|
||||||
|
if (!isGlobalAccess(req.currentUser) && selectedClass.organizationId !== userOrganizationId) {
|
||||||
|
return res.status(403).send({ message: 'You can only record attendance inside your organization.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const transaction = await db.sequelize.transaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const attendanceSession = await db.attendance_sessions.create(
|
||||||
|
{
|
||||||
|
session_date: sessionDate,
|
||||||
|
session_type: sessionType,
|
||||||
|
notes: notes || null,
|
||||||
|
organizationId: selectedClass.organizationId,
|
||||||
|
campusId: selectedClass.campusId,
|
||||||
|
classId: selectedClass.id,
|
||||||
|
createdById: req.currentUser.id,
|
||||||
|
updatedById: req.currentUser.id,
|
||||||
|
},
|
||||||
|
{ transaction },
|
||||||
|
);
|
||||||
|
|
||||||
|
const attendanceRecords = await db.attendance_records.bulkCreate(
|
||||||
|
records.map((record) => ({
|
||||||
|
status: record.status,
|
||||||
|
minutes_late: record.status === 'late' ? Number(record.minutes_late || 0) : null,
|
||||||
|
remarks: record.remarks || null,
|
||||||
|
organizationId: selectedClass.organizationId,
|
||||||
|
attendance_sessionId: attendanceSession.id,
|
||||||
|
studentId: record.studentId,
|
||||||
|
createdById: req.currentUser.id,
|
||||||
|
updatedById: req.currentUser.id,
|
||||||
|
})),
|
||||||
|
{ transaction },
|
||||||
|
);
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
|
||||||
|
res.status(201).send({
|
||||||
|
session: attendanceSession.get({ plain: true }),
|
||||||
|
recordsCreated: attendanceRecords.length,
|
||||||
|
summary: ATTENDANCE_STATUSES.reduce((acc, status) => {
|
||||||
|
acc[status] = records.filter((record) => record.status === status).length;
|
||||||
|
return acc;
|
||||||
|
}, {}),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
465
frontend/src/pages/daily-attendance.tsx
Normal file
465
frontend/src/pages/daily-attendance.tsx
Normal file
@ -0,0 +1,465 @@
|
|||||||
|
import { mdiAccountCheckOutline, mdiChartTimelineVariant, mdiCheckCircleOutline, mdiClipboardListOutline, mdiRefresh, mdiSchoolOutline } from '@mdi/js';
|
||||||
|
import axios from 'axios';
|
||||||
|
import Head from 'next/head';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import React, { ReactElement, useEffect, useMemo, useState } from 'react';
|
||||||
|
import BaseButton from '../components/BaseButton';
|
||||||
|
import BaseIcon from '../components/BaseIcon';
|
||||||
|
import CardBox from '../components/CardBox';
|
||||||
|
import SectionMain from '../components/SectionMain';
|
||||||
|
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
|
||||||
|
import { getPageTitle } from '../config';
|
||||||
|
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||||
|
|
||||||
|
type SchoolClass = {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
section?: string;
|
||||||
|
capacity?: number;
|
||||||
|
campus?: { id: string; name?: string };
|
||||||
|
grade?: { id: string; name?: string };
|
||||||
|
academic_year?: { id: string; name?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
type RosterStudent = {
|
||||||
|
id: string;
|
||||||
|
student_number?: string;
|
||||||
|
first_name?: string;
|
||||||
|
last_name?: string;
|
||||||
|
displayName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AttendanceStatus = 'present' | 'absent' | 'late' | 'excused';
|
||||||
|
|
||||||
|
type DraftRecord = {
|
||||||
|
studentId: string;
|
||||||
|
studentName: string;
|
||||||
|
studentNumber?: string;
|
||||||
|
status: AttendanceStatus;
|
||||||
|
minutes_late: number;
|
||||||
|
remarks: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RecentSession = {
|
||||||
|
id: string;
|
||||||
|
session_date?: string;
|
||||||
|
session_type?: string;
|
||||||
|
notes?: string;
|
||||||
|
class?: { name?: string; section?: string };
|
||||||
|
campus?: { name?: string };
|
||||||
|
attendance_records_attendance_session?: Array<{ status?: AttendanceStatus }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusOptions: AttendanceStatus[] = ['present', 'absent', 'late', 'excused'];
|
||||||
|
const statusClasses: Record<AttendanceStatus, string> = {
|
||||||
|
present: 'bg-emerald-50 text-emerald-700 ring-emerald-200',
|
||||||
|
absent: 'bg-rose-50 text-rose-700 ring-rose-200',
|
||||||
|
late: 'bg-amber-50 text-amber-700 ring-amber-200',
|
||||||
|
excused: 'bg-sky-50 text-sky-700 ring-sky-200',
|
||||||
|
};
|
||||||
|
|
||||||
|
const classLabel = (schoolClass?: SchoolClass) => {
|
||||||
|
if (!schoolClass) return 'Select class';
|
||||||
|
return [schoolClass.name, schoolClass.section].filter(Boolean).join(' • ') || 'Unnamed class';
|
||||||
|
};
|
||||||
|
|
||||||
|
const studentLabel = (student: RosterStudent) => student.displayName || [student.first_name, student.last_name].filter(Boolean).join(' ') || 'Unnamed student';
|
||||||
|
|
||||||
|
const DailyAttendance = () => {
|
||||||
|
const [classes, setClasses] = useState<SchoolClass[]>([]);
|
||||||
|
const [recentSessions, setRecentSessions] = useState<RecentSession[]>([]);
|
||||||
|
const [selectedClassId, setSelectedClassId] = useState('');
|
||||||
|
const [sessionDate, setSessionDate] = useState(() => new Date().toISOString().slice(0, 10));
|
||||||
|
const [sessionType, setSessionType] = useState('homeroom');
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
const [records, setRecords] = useState<DraftRecord[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [rosterLoading, setRosterLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [success, setSuccess] = useState('');
|
||||||
|
|
||||||
|
const selectedClass = useMemo(
|
||||||
|
() => classes.find((item) => item.id === selectedClassId),
|
||||||
|
[classes, selectedClassId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const totals = useMemo(
|
||||||
|
() => statusOptions.reduce((acc, status) => {
|
||||||
|
acc[status] = records.filter((record) => record.status === status).length;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<AttendanceStatus, number>),
|
||||||
|
[records],
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadContext = async () => {
|
||||||
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get('school-ops/attendance-context');
|
||||||
|
setClasses(response.data.classes || []);
|
||||||
|
setRecentSessions(response.data.recentSessions || []);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Daily attendance context failed:', err);
|
||||||
|
setError(err?.response?.data?.message || 'Could not load attendance workspace.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadRoster = async (classId: string) => {
|
||||||
|
setSelectedClassId(classId);
|
||||||
|
setSuccess('');
|
||||||
|
setError('');
|
||||||
|
setRecords([]);
|
||||||
|
|
||||||
|
if (!classId) return;
|
||||||
|
|
||||||
|
setRosterLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`school-ops/attendance-roster?classId=${classId}`);
|
||||||
|
const students: RosterStudent[] = response.data.students || [];
|
||||||
|
setRecords(students.map((student) => ({
|
||||||
|
studentId: student.id,
|
||||||
|
studentName: studentLabel(student),
|
||||||
|
studentNumber: student.student_number,
|
||||||
|
status: 'present',
|
||||||
|
minutes_late: 0,
|
||||||
|
remarks: '',
|
||||||
|
})));
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Daily attendance roster failed:', err);
|
||||||
|
setError(err?.response?.data?.message || 'Could not load the class roster.');
|
||||||
|
} finally {
|
||||||
|
setRosterLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadContext();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateRecord = (studentId: string, patch: Partial<DraftRecord>) => {
|
||||||
|
setRecords((current) => current.map((record) => (
|
||||||
|
record.studentId === studentId ? { ...record, ...patch } : record
|
||||||
|
)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const markAll = (status: AttendanceStatus) => {
|
||||||
|
setRecords((current) => current.map((record) => ({
|
||||||
|
...record,
|
||||||
|
status,
|
||||||
|
minutes_late: status === 'late' ? record.minutes_late : 0,
|
||||||
|
})));
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitAttendance = async () => {
|
||||||
|
setError('');
|
||||||
|
setSuccess('');
|
||||||
|
|
||||||
|
if (!selectedClassId) {
|
||||||
|
setError('Please select a class before saving attendance.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sessionDate) {
|
||||||
|
setError('Please choose an attendance date.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!records.length) {
|
||||||
|
setError('This class has no active students to mark yet. Add enrollments first.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const response = await axios.post('school-ops/attendance-runs', {
|
||||||
|
classId: selectedClassId,
|
||||||
|
sessionDate,
|
||||||
|
sessionType,
|
||||||
|
notes,
|
||||||
|
records: records.map((record) => ({
|
||||||
|
studentId: record.studentId,
|
||||||
|
status: record.status,
|
||||||
|
minutes_late: record.minutes_late,
|
||||||
|
remarks: record.remarks,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
setSuccess(`Saved ${response.data.recordsCreated} attendance records for ${classLabel(selectedClass)}.`);
|
||||||
|
await loadContext();
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Daily attendance save failed:', err);
|
||||||
|
setError(err?.response?.data?.message || 'Attendance could not be saved. Please review the roster and try again.');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>{getPageTitle('Daily Attendance')}</title>
|
||||||
|
</Head>
|
||||||
|
<SectionMain>
|
||||||
|
<SectionTitleLineWithButton icon={mdiClipboardListOutline} title="Daily Attendance" main>
|
||||||
|
<BaseButton href="/attendance_sessions/attendance_sessions-list" label="All sessions" color="whiteDark" small />
|
||||||
|
</SectionTitleLineWithButton>
|
||||||
|
|
||||||
|
<div className="mb-6 overflow-hidden rounded-3xl bg-gradient-to-br from-slate-950 via-slate-900 to-emerald-900 p-6 text-white shadow-xl">
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[1.2fr_0.8fr] lg:items-end">
|
||||||
|
<div>
|
||||||
|
<div className="mb-4 inline-flex items-center rounded-full border border-white/15 bg-white/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.25em] text-emerald-100">
|
||||||
|
Multi-tenant operations
|
||||||
|
</div>
|
||||||
|
<h2 className="max-w-3xl text-3xl font-black tracking-tight md:text-5xl">
|
||||||
|
Record a clean daily attendance run in one guided workspace.
|
||||||
|
</h2>
|
||||||
|
<p className="mt-4 max-w-2xl text-sm leading-6 text-slate-200 md:text-base">
|
||||||
|
Pick a class, load its active roster, mark exceptions, and save a complete auditable attendance session for the current organization.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/10 p-4 backdrop-blur">
|
||||||
|
<div className="text-3xl font-black">{classes.length}</div>
|
||||||
|
<div className="text-emerald-100">classes available</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/10 p-4 backdrop-blur">
|
||||||
|
<div className="text-3xl font-black">{recentSessions.length}</div>
|
||||||
|
<div className="text-emerald-100">recent sessions</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-6 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm font-medium text-rose-700">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{success && (
|
||||||
|
<div className="mb-6 flex items-center rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-700">
|
||||||
|
<BaseIcon path={mdiCheckCircleOutline} className="mr-2" /> {success}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-6 xl:grid-cols-[1fr_380px]">
|
||||||
|
<CardBox className="overflow-hidden border-0 shadow-sm">
|
||||||
|
<div className="grid gap-4 lg:grid-cols-4">
|
||||||
|
<label className="block lg:col-span-2">
|
||||||
|
<span className="mb-2 block text-sm font-bold text-slate-700 dark:text-slate-200">Class roster</span>
|
||||||
|
<select
|
||||||
|
className="h-12 w-full rounded-2xl border border-slate-200 bg-white px-4 text-sm outline-none transition focus:border-emerald-500 focus:ring-2 focus:ring-emerald-100 dark:border-dark-700 dark:bg-dark-800"
|
||||||
|
value={selectedClassId}
|
||||||
|
onChange={(event) => loadRoster(event.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<option value="">Choose a class</option>
|
||||||
|
{classes.map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>{classLabel(item)}{item.campus?.name ? ` — ${item.campus.name}` : ''}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-bold text-slate-700 dark:text-slate-200">Date</span>
|
||||||
|
<input
|
||||||
|
className="h-12 w-full rounded-2xl border border-slate-200 bg-white px-4 text-sm outline-none transition focus:border-emerald-500 focus:ring-2 focus:ring-emerald-100 dark:border-dark-700 dark:bg-dark-800"
|
||||||
|
type="date"
|
||||||
|
value={sessionDate}
|
||||||
|
onChange={(event) => setSessionDate(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-bold text-slate-700 dark:text-slate-200">Session</span>
|
||||||
|
<select
|
||||||
|
className="h-12 w-full rounded-2xl border border-slate-200 bg-white px-4 text-sm capitalize outline-none transition focus:border-emerald-500 focus:ring-2 focus:ring-emerald-100 dark:border-dark-700 dark:bg-dark-800"
|
||||||
|
value={sessionType}
|
||||||
|
onChange={(event) => setSessionType(event.target.value)}
|
||||||
|
>
|
||||||
|
<option value="homeroom">Homeroom</option>
|
||||||
|
<option value="subject">Subject</option>
|
||||||
|
<option value="exam">Exam</option>
|
||||||
|
<option value="event">Event</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedClass && (
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2 text-xs font-semibold text-slate-500">
|
||||||
|
<span className="rounded-full bg-slate-100 px-3 py-1 dark:bg-dark-800"><BaseIcon path={mdiSchoolOutline} className="mr-1" size={14} />{selectedClass.campus?.name || 'No campus set'}</span>
|
||||||
|
<span className="rounded-full bg-slate-100 px-3 py-1 dark:bg-dark-800">{selectedClass.grade?.name || 'Grade not set'}</span>
|
||||||
|
<span className="rounded-full bg-slate-100 px-3 py-1 dark:bg-dark-800">Capacity {selectedClass.capacity || '—'}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<label className="mt-5 block">
|
||||||
|
<span className="mb-2 block text-sm font-bold text-slate-700 dark:text-slate-200">Session notes</span>
|
||||||
|
<textarea
|
||||||
|
className="min-h-24 w-full rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm outline-none transition focus:border-emerald-500 focus:ring-2 focus:ring-emerald-100 dark:border-dark-700 dark:bg-dark-800"
|
||||||
|
placeholder="Optional context: substitute teacher, assembly day, weather disruption..."
|
||||||
|
value={notes}
|
||||||
|
onChange={(event) => setNotes(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="mt-6 flex flex-wrap items-center justify-between gap-3 border-t border-slate-100 pt-5 dark:border-dark-700">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{statusOptions.map((status) => (
|
||||||
|
<button
|
||||||
|
key={status}
|
||||||
|
type="button"
|
||||||
|
className={`rounded-full px-3 py-1.5 text-xs font-bold capitalize ring-1 ${statusClasses[status]}`}
|
||||||
|
onClick={() => markAll(status)}
|
||||||
|
disabled={!records.length}
|
||||||
|
>
|
||||||
|
Mark all {status}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<BaseButton icon={mdiRefresh} label="Reload" color="whiteDark" small onClick={() => selectedClassId ? loadRoster(selectedClassId) : loadContext()} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 overflow-hidden rounded-3xl border border-slate-100 dark:border-dark-700">
|
||||||
|
<div className="grid grid-cols-12 bg-slate-50 px-4 py-3 text-xs font-black uppercase tracking-wide text-slate-400 dark:bg-dark-800">
|
||||||
|
<div className="col-span-5">Student</div>
|
||||||
|
<div className="col-span-3">Status</div>
|
||||||
|
<div className="col-span-2">Late min</div>
|
||||||
|
<div className="col-span-2">Remark</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading || rosterLoading ? (
|
||||||
|
<div className="p-8 text-center text-sm text-slate-500">Loading attendance workspace...</div>
|
||||||
|
) : !selectedClassId ? (
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-2xl bg-emerald-50 text-emerald-600">
|
||||||
|
<BaseIcon path={mdiAccountCheckOutline} size={24} />
|
||||||
|
</div>
|
||||||
|
<p className="font-bold text-slate-700 dark:text-slate-200">Start by choosing a class.</p>
|
||||||
|
<p className="text-sm text-slate-500">The roster will appear here with everyone marked present by default.</p>
|
||||||
|
</div>
|
||||||
|
) : !records.length ? (
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
<p className="font-bold text-slate-700 dark:text-slate-200">No active enrollments found.</p>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">Use Class enrollments to add students before taking attendance.</p>
|
||||||
|
<BaseButton href="/class_enrollments/class_enrollments-new" label="Add enrollment" color="info" small className="mt-4" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-slate-100 dark:divide-dark-700">
|
||||||
|
{records.map((record) => (
|
||||||
|
<div key={record.studentId} className="grid grid-cols-12 items-center gap-3 px-4 py-3 text-sm">
|
||||||
|
<div className="col-span-12 sm:col-span-5">
|
||||||
|
<div className="font-bold text-slate-800 dark:text-slate-100">{record.studentName}</div>
|
||||||
|
<div className="text-xs text-slate-400">{record.studentNumber || 'No student number'}</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-6 sm:col-span-3">
|
||||||
|
<select
|
||||||
|
className="h-10 w-full rounded-xl border border-slate-200 bg-white px-3 text-sm capitalize outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-100 dark:border-dark-700 dark:bg-dark-800"
|
||||||
|
value={record.status}
|
||||||
|
onChange={(event) => updateRecord(record.studentId, {
|
||||||
|
status: event.target.value as AttendanceStatus,
|
||||||
|
minutes_late: event.target.value === 'late' ? record.minutes_late : 0,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{statusOptions.map((status) => <option key={status} value={status}>{status}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-3 sm:col-span-2">
|
||||||
|
<input
|
||||||
|
className="h-10 w-full rounded-xl border border-slate-200 bg-white px-3 text-sm outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-100 disabled:bg-slate-50 dark:border-dark-700 dark:bg-dark-800"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={record.minutes_late}
|
||||||
|
disabled={record.status !== 'late'}
|
||||||
|
onChange={(event) => updateRecord(record.studentId, { minutes_late: Number(event.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-3 sm:col-span-2">
|
||||||
|
<input
|
||||||
|
className="h-10 w-full rounded-xl border border-slate-200 bg-white px-3 text-sm outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-100 dark:border-dark-700 dark:bg-dark-800"
|
||||||
|
placeholder="Optional"
|
||||||
|
value={record.remarks}
|
||||||
|
onChange={(event) => updateRecord(record.studentId, { remarks: event.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{statusOptions.map((status) => (
|
||||||
|
<span key={status} className={`rounded-full px-3 py-1 text-xs font-black capitalize ring-1 ${statusClasses[status]}`}>
|
||||||
|
{status}: {totals[status] || 0}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<BaseButton
|
||||||
|
label={saving ? 'Saving...' : 'Save attendance run'}
|
||||||
|
color="success"
|
||||||
|
icon={mdiCheckCircleOutline}
|
||||||
|
disabled={saving || rosterLoading || !records.length}
|
||||||
|
onClick={submitAttendance}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<CardBox className="border-0 shadow-sm">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-black text-slate-800 dark:text-slate-100">Recent sessions</h3>
|
||||||
|
<p className="text-sm text-slate-500">Latest attendance activity in your accessible schools.</p>
|
||||||
|
</div>
|
||||||
|
<BaseIcon path={mdiChartTimelineVariant} className="text-emerald-600" size={26} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{recentSessions.length ? recentSessions.map((session) => {
|
||||||
|
const sessionRecords = session.attendance_records_attendance_session || [];
|
||||||
|
return (
|
||||||
|
<Link key={session.id} href={`/attendance_sessions/${session.id}`} className="block rounded-2xl border border-slate-100 p-4 transition hover:border-emerald-200 hover:bg-emerald-50/40 dark:border-dark-700 dark:hover:bg-dark-800">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="font-black text-slate-800 dark:text-slate-100">{classLabel(session.class as SchoolClass)}</div>
|
||||||
|
<div className="text-xs text-slate-500">{session.campus?.name || 'Campus not set'} • {session.session_type || 'session'}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-full bg-slate-100 px-2 py-1 text-xs font-bold text-slate-500 dark:bg-dark-800">
|
||||||
|
{sessionRecords.length} records
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-xs font-semibold text-slate-400">
|
||||||
|
{session.session_date ? new Date(session.session_date).toLocaleDateString() : 'No date'}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}) : (
|
||||||
|
<div className="rounded-2xl border border-dashed border-slate-200 p-6 text-center text-sm text-slate-500 dark:border-dark-700">
|
||||||
|
No sessions yet. Your first saved attendance run will appear here.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
|
||||||
|
<CardBox className="border-0 bg-gradient-to-br from-emerald-50 to-sky-50 shadow-sm dark:from-dark-900 dark:to-dark-800">
|
||||||
|
<h3 className="text-lg font-black text-slate-800 dark:text-slate-100">Next operational links</h3>
|
||||||
|
<div className="mt-4 grid gap-3 text-sm font-semibold">
|
||||||
|
<Link href="/classes/classes-list" className="rounded-2xl bg-white/80 px-4 py-3 text-slate-700 shadow-sm transition hover:text-emerald-700 dark:bg-dark-800 dark:text-slate-200">Manage classes</Link>
|
||||||
|
<Link href="/students/students-list" className="rounded-2xl bg-white/80 px-4 py-3 text-slate-700 shadow-sm transition hover:text-emerald-700 dark:bg-dark-800 dark:text-slate-200">Review students</Link>
|
||||||
|
<Link href="/attendance_records/attendance_records-list" className="rounded-2xl bg-white/80 px-4 py-3 text-slate-700 shadow-sm transition hover:text-emerald-700 dark:bg-dark-800 dark:text-slate-200">Open records table</Link>
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SectionMain>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
DailyAttendance.getLayout = function getLayout(page: ReactElement) {
|
||||||
|
return <LayoutAuthenticated permission="READ_ATTENDANCE_SESSIONS">{page}</LayoutAuthenticated>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DailyAttendance;
|
||||||
Loading…
x
Reference in New Issue
Block a user