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