From 647b96cafc9f8da3f2dec5a416cc81fc79de838b Mon Sep 17 00:00:00 2001 From: root Date: Thu, 4 Jun 2026 20:49:24 -0400 Subject: [PATCH] fix attendance --- src/App.tsx | 9 + src/api/attendanceManagement.ts | 146 +++++++++ src/api/session.ts | 5 +- src/lib/ciSpaPaths.ts | 4 + .../attendance/AttendanceManagementPage.tsx | 283 ++++++++++++++++++ .../AttendanceViolationsHubPage.tsx | 98 +----- .../attendance/BadgeScanningListPage.tsx | 186 ++++++++++++ .../attendance/TeacherAttendanceMonthPage.tsx | 10 +- src/pages/attendance/index.ts | 3 + 9 files changed, 650 insertions(+), 94 deletions(-) create mode 100644 src/api/attendanceManagement.ts create mode 100644 src/pages/attendance/AttendanceManagementPage.tsx create mode 100644 src/pages/attendance/BadgeScanningListPage.tsx diff --git a/src/App.tsx b/src/App.tsx index 13881f4..d1efd67 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -274,6 +274,12 @@ const AdministratorAbsencePage = lazy(() => const AttendanceTrackingPage = lazy(() => import('./pages/attendance').then((m) => ({ default: m.AttendanceTrackingPage })), ) +const AttendanceManagementPage = lazy(() => + import('./pages/attendance').then((m) => ({ default: m.AttendanceManagementPage })), +) +const BadgeScanningListPage = lazy(() => + import('./pages/attendance').then((m) => ({ default: m.BadgeScanningListPage })), +) const ComposeAttendanceEmailPage = lazy(() => import('./pages/attendance').then((m) => ({ default: m.ComposeAttendanceEmailPage })), ) @@ -1073,6 +1079,9 @@ export default function App() { } /> } /> } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/src/api/attendanceManagement.ts b/src/api/attendanceManagement.ts new file mode 100644 index 0000000..a9c0ca4 --- /dev/null +++ b/src/api/attendanceManagement.ts @@ -0,0 +1,146 @@ +import { apiFetch } from './http' + +const BASE = '/api/v1/attendance/management' + +type ApiEnvelope = { status?: boolean; message?: string; data?: T } + +function unwrap(body: ApiEnvelope | T): T { + if (body && typeof body === 'object' && 'data' in body) return (body as ApiEnvelope).data as T + return body as T +} + +export type AttendanceManagementSummary = { + present: number + absent: number + late: number + early_dismissal: number + not_reported: number + follow_up_required: number + badge_exceptions: number + late_slip_reprints: number +} + +export type AttendanceManagementEvent = { + id: number + person_type: string + person_id: number | null + person_name: string | null + role_grade: string | null + badge_id: string | null + event_date: string + attendance_status: string + report_status: string + entry_time: string | null + exit_time: string | null + official_entry_time: string | null + official_exit_time: string | null + entry_method: string | null + exit_method: string | null + manual_reason: string | null + reason: string | null + scan_location: string | null + exit_location: string | null + absence_count: number + late_count: number + early_dismissal_count: number + badge_exception_count: number + combination_code: string | null + risk_level: string + follow_up_required: boolean | number + follow_up_completed: boolean | number + action_needed: string | null + final_decision: string | null + notes: string | null +} + +export type AttendanceManagementDashboard = { + date: string + summary: AttendanceManagementSummary + filters: { + attendance_status: string[] + report_status: string[] + risk_level: string[] + person_type: string[] + } + rows: AttendanceManagementEvent[] +} + +export type ManualEntryPayload = { + person_type?: string + person_id?: number + person_name?: string + role_grade?: string + badge_id?: string + entry_time?: string + manual_reason?: string + report_status?: string + reason?: string + notes?: string +} + +export type ScanPayload = { + badge_scan?: string + badge_id?: string + scan_type?: 'entry' | 'exit' + scan_time?: string + location?: string + report_status?: string + authorized?: boolean + reason?: string +} + +export async function fetchAttendanceManagementDashboard(params: Record = {}) { + const qs = new URLSearchParams() + Object.entries(params).forEach(([k, v]) => { + if (v.trim()) qs.set(k, v.trim()) + }) + const suffix = qs.toString() ? `?${qs.toString()}` : '' + return unwrap(await apiFetch(`${BASE}/dashboard${suffix}`)) +} + +export async function fetchBadgeScanningList(params: Record = {}) { + return fetchAttendanceManagementDashboard({ + ...params, + entry_method: 'badge_scan', + }) +} + +export async function recordManualAttendanceEntry(payload: ManualEntryPayload) { + return unwrap<{ event: AttendanceManagementEvent }>( + await apiFetch(`${BASE}/manual-entry`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }), + ) +} + +export async function recordAttendanceBadgeScan(payload: ScanPayload) { + return unwrap<{ event: AttendanceManagementEvent }>( + await apiFetch(`${BASE}/scan`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }), + ) +} + +export async function completeAttendanceFollowUp(id: number, payload: { report_status?: string; final_decision?: string; notes?: string }) { + return unwrap<{ event: AttendanceManagementEvent }>( + await apiFetch(`${BASE}/${id}/follow-up`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }), + ) +} + +export async function reprintAttendanceLateSlip(id: number, payload: { reason?: string; slip_number?: string } = {}) { + return unwrap<{ reprint: Record }>( + await apiFetch(`${BASE}/${id}/late-slip-reprint`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }), + ) +} diff --git a/src/api/session.ts b/src/api/session.ts index bbac468..9f05218 100644 --- a/src/api/session.ts +++ b/src/api/session.ts @@ -381,7 +381,8 @@ export async function fetchStaffMonthlyAttendanceOverview(params?: { if (params?.semester) query.set('semester', params.semester) if (params?.schoolYear) query.set('school_year', params.schoolYear) const qs = query.size > 0 ? `?${query.toString()}` : '' - return apiFetch(`/api/v1/attendance/staff/month${qs}`) + const raw = await apiFetch(`/api/v1/attendance/staff/month${qs}`) + return unwrapApiDataLayer(raw) as StaffMonthlyOverviewPayload } /** Matches CI `teacher_attendance_month` save cell (form body). */ @@ -392,7 +393,7 @@ export async function saveStaffMonthlyAttendanceCell(payload: Record { const body = new URLSearchParams() for (const [k, v] of Object.entries(payload)) body.set(k, v) - return apiFetch('/api/v1/attendance/staff/month-save', { + return apiFetch('/api/v1/attendance/staff/cell', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString(), diff --git a/src/lib/ciSpaPaths.ts b/src/lib/ciSpaPaths.ts index 348a32a..40b9ed9 100644 --- a/src/lib/ciSpaPaths.ts +++ b/src/lib/ciSpaPaths.ts @@ -8,6 +8,10 @@ export function spaPathFromCiUrl(url: string | null | undefined): string | null const path = u.replace(/^\/+/, '').replace(/\/+$/, '') if (!path) return '/app/home' + if (path === 'app' || path.startsWith('app/')) { + return `/${path}` + } + const querySuffix = path.includes('?') ? `?${path.slice(path.indexOf('?') + 1)}` : '' const pathOnly = path.split('?')[0] ?? path diff --git a/src/pages/attendance/AttendanceManagementPage.tsx b/src/pages/attendance/AttendanceManagementPage.tsx new file mode 100644 index 0000000..35ffe9a --- /dev/null +++ b/src/pages/attendance/AttendanceManagementPage.tsx @@ -0,0 +1,283 @@ +import { useEffect, useMemo, useState, type FormEvent } from 'react' +import { Link } from 'react-router-dom' +import { + completeAttendanceFollowUp, + fetchAttendanceManagementDashboard, + recordAttendanceBadgeScan, + recordManualAttendanceEntry, + reprintAttendanceLateSlip, + type AttendanceManagementDashboard, + type AttendanceManagementEvent, +} from '../../api/attendanceManagement' +import { ApiHttpError } from '../../api/http' + +type FormState = { + badge_scan: string + person_name: string + person_type: string + role_grade: string + entry_time: string + manual_reason: string + report_status: string + reason: string +} + +const today = new Date().toISOString().slice(0, 10) + +const emptyForm: FormState = { + badge_scan: '', + person_name: '', + person_type: 'student', + role_grade: '', + entry_time: '', + manual_reason: 'badge missing', + report_status: 'pending_clarification', + reason: '', +} + +function nice(value: string | null | undefined): string { + if (!value) return '—' + return value.replace(/_/g, ' ') +} + +function boolish(value: boolean | number): boolean { + return value === true || value === 1 +} + +export function AttendanceManagementPage() { + const [date, setDate] = useState(today) + const [status, setStatus] = useState('') + const [reportStatus, setReportStatus] = useState('') + const [q, setQ] = useState('') + const [dashboard, setDashboard] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [message, setMessage] = useState(null) + const [form, setForm] = useState(emptyForm) + + const params = useMemo(() => ({ date, attendance_status: status, report_status: reportStatus, q }), [date, status, reportStatus, q]) + + async function load() { + setLoading(true) + setError(null) + try { + setDashboard(await fetchAttendanceManagementDashboard(params)) + } catch (e) { + setError(e instanceof ApiHttpError ? e.message : 'Unable to load attendance management dashboard.') + } finally { + setLoading(false) + } + } + + useEffect(() => { + void load() + }, [params]) + + async function submitScan(event: FormEvent) { + event.preventDefault() + setMessage(null) + setError(null) + try { + await recordAttendanceBadgeScan({ + badge_scan: form.badge_scan, + scan_type: 'entry', + report_status: form.report_status, + reason: form.reason, + }) + setMessage('Badge scan classified. Late students were automatically added to late-slip workflow.') + setForm((f) => ({ ...f, badge_scan: '', reason: '' })) + await load() + } catch (e) { + setError(e instanceof ApiHttpError ? e.message : 'Unable to record badge scan.') + } + } + + async function submitManual(event: FormEvent) { + event.preventDefault() + setMessage(null) + setError(null) + try { + await recordManualAttendanceEntry({ + person_type: form.person_type, + person_name: form.person_name, + role_grade: form.role_grade, + entry_time: form.entry_time || undefined, + manual_reason: form.manual_reason, + report_status: form.report_status, + reason: form.reason, + }) + setMessage('Manual entry recorded and counted as a badge exception. Yes, accountability survived another checkbox.') + setForm(emptyForm) + await load() + } catch (e) { + setError(e instanceof ApiHttpError ? e.message : 'Unable to record manual entry.') + } + } + + async function complete(row: AttendanceManagementEvent) { + setMessage(null) + setError(null) + try { + await completeAttendanceFollowUp(row.id, { + report_status: row.report_status === 'not_reported' ? 'pending_clarification' : row.report_status, + final_decision: row.final_decision || 'Resolved after contact attempt', + notes: row.notes || 'Follow-up completed from management dashboard.', + }) + setMessage('Follow-up marked completed.') + await load() + } catch (e) { + setError(e instanceof ApiHttpError ? e.message : 'Unable to complete follow-up.') + } + } + + async function reprint(row: AttendanceManagementEvent) { + setMessage(null) + setError(null) + try { + await reprintAttendanceLateSlip(row.id, { reason: 'Dashboard reprint' }) + setMessage('Late slip reprint logged.') + await load() + } catch (e) { + setError(e instanceof ApiHttpError ? e.message : 'Unable to log late-slip reprint.') + } + } + + const summary = dashboard?.summary + + return ( +
+
+
+

School Attendance Management

+

Badge scans, manual entries, not-reported follow-up, late slips, early dismissal risk, and badge exceptions in one place.

+
+
+ + Badge Scanning List + + +
+
+ + {error ?
{error}
: null} + {message ?
{message}
: null} + +
+ {summary ? Object.entries(summary).map(([key, value]) => ( +
+
+
+
{nice(key)}
+
{value}
+
+
+
+ )) : null} +
+ +
+
+
+
+ + setDate(e.target.value)} /> +
+
+ + +
+
+ + +
+
+ + setQ(e.target.value)} placeholder="Name, badge, grade, combination" /> +
+
+
+
+ +
+
+
+
+

Badge entry scan

+

Classifies present/late and creates late-slip records for late students.

+ + setForm({ ...form, badge_scan: e.target.value })} required /> + + + + setForm({ ...form, reason: e.target.value })} /> + +
+
+
+
+
+
+

Manual verified entry

+

Used for missing, lost, damaged, not-issued badges, or scanner failure.

+
+
setForm({ ...form, person_name: e.target.value })} required />
+
+
setForm({ ...form, role_grade: e.target.value })} />
+
setForm({ ...form, entry_time: e.target.value })} />
+
setForm({ ...form, manual_reason: e.target.value })} />
+
+
setForm({ ...form, reason: e.target.value })} />
+
+ +
+
+
+
+ +
+
+

Admin follow-up dashboard

+
+ + + + + + + + {(dashboard?.rows ?? []).map((row) => ( + + + + + + + + + + + + + ))} + {!loading && (dashboard?.rows.length ?? 0) === 0 ? : null} + +
NameRole/GradeStatusScan/manual timeReportedCountsCombinationActionDecision
{row.person_name || '—'}{row.role_grade || row.person_type}{nice(row.attendance_status)}
Risk: {nice(row.risk_level)}
{row.official_entry_time || row.official_exit_time || '—'}
{nice(row.entry_method || row.exit_method)}
{nice(row.report_status)}{row.absence_count}ABS / {row.late_count}Late / {row.early_dismissal_count}ED / {row.badge_exception_count}Badge{row.combination_code || 'Clear'}{row.action_needed || 'Record and monitor'}{row.final_decision || '—'} + {boolish(row.follow_up_required) && !boolish(row.follow_up_completed) ? : null} + {row.attendance_status === 'late' ? : null} +
No attendance management rows for these filters.
+
+
+
+
+ ) +} diff --git a/src/pages/attendance/AttendanceViolationsHubPage.tsx b/src/pages/attendance/AttendanceViolationsHubPage.tsx index 5f7ade4..b97611f 100644 --- a/src/pages/attendance/AttendanceViolationsHubPage.tsx +++ b/src/pages/attendance/AttendanceViolationsHubPage.tsx @@ -1,95 +1,11 @@ -import { type FormEvent, useMemo } from 'react' -import { Link, useSearchParams } from 'react-router-dom' -import { - ATTENDANCE_VIOLATIONS_NOTIFIED_PATH, - ATTENDANCE_VIOLATIONS_PENDING_PATH, -} from './attendancePaths' +import { Navigate, useSearchParams } from 'react-router-dom' +import { ATTENDANCE_VIOLATIONS_PENDING_PATH } from './attendancePaths' -/** Landing screen for attendance violations — links to pending / notified lists (same APIs as subpages). */ +/** Legacy `/attendance/violations` opened the pending list directly. Keep this route as a query-preserving alias. */ export function AttendanceViolationsHubPage() { - const [searchParams, setSearchParams] = useSearchParams() - const schoolYear = searchParams.get('school_year') ?? '' - const semester = searchParams.get('semester') ?? '' + const [searchParams] = useSearchParams() + const query = searchParams.toString() + const to = query ? `${ATTENDANCE_VIOLATIONS_PENDING_PATH}?${query}` : ATTENDANCE_VIOLATIONS_PENDING_PATH - const filterQuery = useMemo(() => { - const s = searchParams.toString() - return s ? `?${s}` : '' - }, [searchParams]) - - function applyFilter(e: FormEvent) { - e.preventDefault() - const fd = new FormData(e.currentTarget) - const p = new URLSearchParams() - const sy = String(fd.get('school_year') ?? '') - const sem = String(fd.get('semester') ?? '') - if (sy) p.set('school_year', sy) - if (sem) p.set('semester', sem) - setSearchParams(p) - } - - return ( -
-

Attendance Violations

-

- Review students who have crossed absence or late thresholds. Data loads on each list via the API. -

- -
-
-
- - -
-
- - -
-
- -
-
-
- -
-
-
-
-
Pending
-

- Students who need notification or follow-up per policy (team notify, auto email, etc.). -

- - Open pending list - -
-
-
-
-
-
-
Notified
-

- Students whose parents were already notified; add or edit notes as needed. -

- - Open notified list - -
-
-
-
-
- ) + return } diff --git a/src/pages/attendance/BadgeScanningListPage.tsx b/src/pages/attendance/BadgeScanningListPage.tsx new file mode 100644 index 0000000..b1c6991 --- /dev/null +++ b/src/pages/attendance/BadgeScanningListPage.tsx @@ -0,0 +1,186 @@ +import { useEffect, useMemo, useState } from 'react' +import { + fetchBadgeScanningList, + type AttendanceManagementDashboard, +} from '../../api/attendanceManagement' +import { ApiHttpError } from '../../api/http' + +const today = new Date().toISOString().slice(0, 10) + +function nice(value: string | null | undefined): string { + if (!value) return '—' + return value.replace(/_/g, ' ') +} + +export function BadgeScanningListPage() { + const [date, setDate] = useState(today) + const [status, setStatus] = useState('') + const [q, setQ] = useState('') + const [dashboard, setDashboard] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const params = useMemo( + () => ({ + date, + attendance_status: status, + q, + }), + [date, status, q], + ) + + async function load() { + setLoading(true) + setError(null) + + try { + setDashboard(await fetchBadgeScanningList(params)) + } catch (e) { + setError(e instanceof ApiHttpError ? e.message : 'Unable to load badge scanning list.') + } finally { + setLoading(false) + } + } + + useEffect(() => { + void load() + }, [params]) + + const rows = dashboard?.rows ?? [] + + return ( +
+
+
+

Badge Scanning List

+

+ Students, staff, contractors, and visitors recorded through badge scan. +

+
+ + +
+ + {error ?
{error}
: null} + +
+
+
+
+ + setDate(e.target.value)} + /> +
+ +
+ + +
+ +
+ + setQ(e.target.value)} + placeholder="Name, badge, grade, department" + /> +
+ +
+ +
+
+
+
+ +
+
+
+

Scanned Badge Records

+ {rows.length} records +
+ +
+ + + + + + + + + + + + + + + + + + {rows.map((row) => ( + + + + + + + + + + + + + ))} + + {!loading && rows.length === 0 ? ( + + + + ) : null} + + {loading ? ( + + + + ) : null} + +
NameTypeGrade/DeptBadge IDDateScan TimeLocationStatusReportedDecision
{row.person_name || '—'}{nice(row.person_type)}{row.role_grade || '—'}{row.badge_id || '—'}{row.event_date}{row.official_entry_time || row.official_exit_time || '—'}{row.scan_location || row.exit_location || '—'}{nice(row.attendance_status)}{nice(row.report_status)}{row.final_decision || '—'}
+ No badge scans found for this filter. +
+ Loading badge scans... +
+
+
+
+
+ ) +} diff --git a/src/pages/attendance/TeacherAttendanceMonthPage.tsx b/src/pages/attendance/TeacherAttendanceMonthPage.tsx index 7a4c752..a64cc24 100644 --- a/src/pages/attendance/TeacherAttendanceMonthPage.tsx +++ b/src/pages/attendance/TeacherAttendanceMonthPage.tsx @@ -66,7 +66,15 @@ export function TeacherAttendanceMonthPage() { semester: semesterParam === '---' ? null : semesterParam, schoolYear: schoolYearParam || null, }) - if (!cancelled) setPayload(data) + if (!cancelled) { + setPayload(data) + if (!schoolYearParam && data.filters?.school_year) { + const next = new URLSearchParams(searchParams) + next.set('school_year', data.filters.school_year) + if (!next.get('semester')) next.set('semester', semesterParam) + setSearchParams(next, { replace: true }) + } + } } catch (e) { if (!cancelled) setError(e instanceof Error ? e.message : 'Unable to load monthly attendance.') } finally { diff --git a/src/pages/attendance/index.ts b/src/pages/attendance/index.ts index a1af8e4..821101e 100644 --- a/src/pages/attendance/index.ts +++ b/src/pages/attendance/index.ts @@ -12,3 +12,6 @@ export { AttendanceStudentViolationsViewPage } from './AttendanceStudentViolatio export { AttendanceViolationsHubPage } from './AttendanceViolationsHubPage' export { ViolationsNotifiedPage, ViolationsPendingPage } from './ViolationsPages' export { AttendanceTemplatesIndexPage } from './AttendanceTemplatesIndexPage' + +export { AttendanceManagementPage } from './AttendanceManagementPage' +export { BadgeScanningListPage } from './BadgeScanningListPage'