fix attendance

This commit is contained in:
root
2026-06-04 20:49:24 -04:00
parent dec023f03e
commit 647b96cafc
9 changed files with 650 additions and 94 deletions
+9
View File
@@ -274,6 +274,12 @@ const AdministratorAbsencePage = lazy(() =>
const AttendanceTrackingPage = lazy(() => const AttendanceTrackingPage = lazy(() =>
import('./pages/attendance').then((m) => ({ default: m.AttendanceTrackingPage })), 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(() => const ComposeAttendanceEmailPage = lazy(() =>
import('./pages/attendance').then((m) => ({ default: m.ComposeAttendanceEmailPage })), import('./pages/attendance').then((m) => ({ default: m.ComposeAttendanceEmailPage })),
) )
@@ -1073,6 +1079,9 @@ export default function App() {
<Route path="administrator/absence" element={<AdministratorAbsencePage />} /> <Route path="administrator/absence" element={<AdministratorAbsencePage />} />
<Route path="administrator/attendance/admins-form" element={<AdminsAttendanceFormPage />} /> <Route path="administrator/attendance/admins-form" element={<AdminsAttendanceFormPage />} />
<Route path="administrator/attendance/tracking" element={<AttendanceTrackingPage />} /> <Route path="administrator/attendance/tracking" element={<AttendanceTrackingPage />} />
<Route path="administrator/attendance/management" element={<AttendanceManagementPage />} />
<Route path="administrator/attendance/badge-scans" element={<BadgeScanningListPage />} />
<Route path="attendance/management" element={<AttendanceManagementPage />} />
<Route path="administrator/attendance/compose-email" element={<ComposeAttendanceEmailPage />} /> <Route path="administrator/attendance/compose-email" element={<ComposeAttendanceEmailPage />} />
<Route path="administrator/attendance/early-dismissals" element={<EarlyDismissalsPage />} /> <Route path="administrator/attendance/early-dismissals" element={<EarlyDismissalsPage />} />
<Route path="administrator/attendance/early-dismissals/new" element={<EarlyDismissalsAddPage />} /> <Route path="administrator/attendance/early-dismissals/new" element={<EarlyDismissalsAddPage />} />
+146
View File
@@ -0,0 +1,146 @@
import { apiFetch } from './http'
const BASE = '/api/v1/attendance/management'
type ApiEnvelope<T> = { status?: boolean; message?: string; data?: T }
function unwrap<T>(body: ApiEnvelope<T> | T): T {
if (body && typeof body === 'object' && 'data' in body) return (body as ApiEnvelope<T>).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<string, string> = {}) {
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<AttendanceManagementDashboard>(await apiFetch(`${BASE}/dashboard${suffix}`))
}
export async function fetchBadgeScanningList(params: Record<string, string> = {}) {
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<string, unknown> }>(
await apiFetch(`${BASE}/${id}/late-slip-reprint`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}),
)
}
+3 -2
View File
@@ -381,7 +381,8 @@ export async function fetchStaffMonthlyAttendanceOverview(params?: {
if (params?.semester) query.set('semester', params.semester) if (params?.semester) query.set('semester', params.semester)
if (params?.schoolYear) query.set('school_year', params.schoolYear) if (params?.schoolYear) query.set('school_year', params.schoolYear)
const qs = query.size > 0 ? `?${query.toString()}` : '' const qs = query.size > 0 ? `?${query.toString()}` : ''
return apiFetch<StaffMonthlyOverviewPayload>(`/api/v1/attendance/staff/month${qs}`) const raw = await apiFetch<unknown>(`/api/v1/attendance/staff/month${qs}`)
return unwrapApiDataLayer(raw) as StaffMonthlyOverviewPayload
} }
/** Matches CI `teacher_attendance_month` save cell (form body). */ /** Matches CI `teacher_attendance_month` save cell (form body). */
@@ -392,7 +393,7 @@ export async function saveStaffMonthlyAttendanceCell(payload: Record<string, str
}> { }> {
const body = new URLSearchParams() const body = new URLSearchParams()
for (const [k, v] of Object.entries(payload)) body.set(k, v) 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', method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(), body: body.toString(),
+4
View File
@@ -8,6 +8,10 @@ export function spaPathFromCiUrl(url: string | null | undefined): string | null
const path = u.replace(/^\/+/, '').replace(/\/+$/, '') const path = u.replace(/^\/+/, '').replace(/\/+$/, '')
if (!path) return '/app/home' if (!path) return '/app/home'
if (path === 'app' || path.startsWith('app/')) {
return `/${path}`
}
const querySuffix = path.includes('?') ? `?${path.slice(path.indexOf('?') + 1)}` : '' const querySuffix = path.includes('?') ? `?${path.slice(path.indexOf('?') + 1)}` : ''
const pathOnly = path.split('?')[0] ?? path const pathOnly = path.split('?')[0] ?? path
@@ -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<AttendanceManagementDashboard | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [message, setMessage] = useState<string | null>(null)
const [form, setForm] = useState<FormState>(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 (
<div className="container-fluid py-4">
<div className="d-flex flex-wrap justify-content-between gap-3 align-items-start mb-4">
<div>
<h1 className="h3 mb-1">School Attendance Management</h1>
<p className="text-muted mb-0">Badge scans, manual entries, not-reported follow-up, late slips, early dismissal risk, and badge exceptions in one place.</p>
</div>
<div className="d-flex flex-wrap gap-2">
<Link className="btn btn-outline-primary" to="/app/administrator/attendance/badge-scans">
Badge Scanning List
</Link>
<button className="btn btn-outline-secondary" type="button" onClick={() => void load()} disabled={loading}>Refresh</button>
</div>
</div>
{error ? <div className="alert alert-danger">{error}</div> : null}
{message ? <div className="alert alert-success">{message}</div> : null}
<div className="row g-3 mb-4">
{summary ? Object.entries(summary).map(([key, value]) => (
<div className="col-6 col-md-3 col-xl-2" key={key}>
<div className="card h-100 shadow-sm">
<div className="card-body">
<div className="small text-muted text-uppercase">{nice(key)}</div>
<div className="display-6">{value}</div>
</div>
</div>
</div>
)) : null}
</div>
<div className="card mb-4 shadow-sm">
<div className="card-body">
<div className="row g-3 align-items-end">
<div className="col-md-3">
<label className="form-label">Date</label>
<input className="form-control" type="date" value={date} onChange={(e) => setDate(e.target.value)} />
</div>
<div className="col-md-3">
<label className="form-label">Attendance status</label>
<select className="form-select" value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="">All</option>
{(dashboard?.filters.attendance_status ?? []).map((x) => <option key={x} value={x}>{nice(x)}</option>)}
</select>
</div>
<div className="col-md-3">
<label className="form-label">Report status</label>
<select className="form-select" value={reportStatus} onChange={(e) => setReportStatus(e.target.value)}>
<option value="">All</option>
{(dashboard?.filters.report_status ?? []).map((x) => <option key={x} value={x}>{nice(x)}</option>)}
</select>
</div>
<div className="col-md-3">
<label className="form-label">Search</label>
<input className="form-control" value={q} onChange={(e) => setQ(e.target.value)} placeholder="Name, badge, grade, combination" />
</div>
</div>
</div>
</div>
<div className="row g-4 mb-4">
<div className="col-lg-6">
<form className="card shadow-sm h-100" onSubmit={submitScan}>
<div className="card-body">
<h2 className="h5">Badge entry scan</h2>
<p className="text-muted small">Classifies present/late and creates late-slip records for late students.</p>
<label className="form-label">Badge scan</label>
<input className="form-control mb-3" value={form.badge_scan} onChange={(e) => setForm({ ...form, badge_scan: e.target.value })} required />
<label className="form-label">Report status</label>
<select className="form-select mb-3" value={form.report_status} onChange={(e) => setForm({ ...form, report_status: e.target.value })}>
<option value="reported">Reported</option>
<option value="not_reported">Not reported</option>
<option value="pending_clarification">Pending clarification</option>
</select>
<label className="form-label">Reason</label>
<input className="form-control mb-3" value={form.reason} onChange={(e) => setForm({ ...form, reason: e.target.value })} />
<button className="btn btn-primary" type="submit">Record badge scan</button>
</div>
</form>
</div>
<div className="col-lg-6">
<form className="card shadow-sm h-100" onSubmit={submitManual}>
<div className="card-body">
<h2 className="h5">Manual verified entry</h2>
<p className="text-muted small">Used for missing, lost, damaged, not-issued badges, or scanner failure.</p>
<div className="row g-3">
<div className="col-md-6"><label className="form-label">Name</label><input className="form-control" value={form.person_name} onChange={(e) => setForm({ ...form, person_name: e.target.value })} required /></div>
<div className="col-md-3"><label className="form-label">Type</label><select className="form-select" value={form.person_type} onChange={(e) => setForm({ ...form, person_type: e.target.value })}><option value="student">Student</option><option value="staff">Staff</option><option value="contractor">Contractor</option><option value="visitor">Visitor</option></select></div>
<div className="col-md-3"><label className="form-label">Grade/Dept</label><input className="form-control" value={form.role_grade} onChange={(e) => setForm({ ...form, role_grade: e.target.value })} /></div>
<div className="col-md-6"><label className="form-label">Entry time</label><input className="form-control" type="datetime-local" value={form.entry_time} onChange={(e) => setForm({ ...form, entry_time: e.target.value })} /></div>
<div className="col-md-6"><label className="form-label">Manual reason</label><input className="form-control" value={form.manual_reason} onChange={(e) => setForm({ ...form, manual_reason: e.target.value })} /></div>
<div className="col-md-6"><label className="form-label">Report status</label><select className="form-select" value={form.report_status} onChange={(e) => setForm({ ...form, report_status: e.target.value })}><option value="reported">Reported</option><option value="not_reported">Not reported</option><option value="pending_clarification">Pending clarification</option></select></div>
<div className="col-md-6"><label className="form-label">Reason</label><input className="form-control" value={form.reason} onChange={(e) => setForm({ ...form, reason: e.target.value })} /></div>
</div>
<button className="btn btn-primary mt-3" type="submit">Record manual entry</button>
</div>
</form>
</div>
</div>
<div className="card shadow-sm">
<div className="card-body">
<h2 className="h5">Admin follow-up dashboard</h2>
<div className="table-responsive">
<table className="table table-sm align-middle">
<thead>
<tr>
<th>Name</th><th>Role/Grade</th><th>Status</th><th>Scan/manual time</th><th>Reported</th><th>Counts</th><th>Combination</th><th>Action</th><th>Decision</th><th></th>
</tr>
</thead>
<tbody>
{(dashboard?.rows ?? []).map((row) => (
<tr key={row.id} className={boolish(row.follow_up_required) && !boolish(row.follow_up_completed) ? 'table-warning' : ''}>
<td>{row.person_name || '—'}</td>
<td>{row.role_grade || row.person_type}</td>
<td>{nice(row.attendance_status)}<div className="small text-muted">Risk: {nice(row.risk_level)}</div></td>
<td>{row.official_entry_time || row.official_exit_time || '—'}<div className="small text-muted">{nice(row.entry_method || row.exit_method)}</div></td>
<td>{nice(row.report_status)}</td>
<td>{row.absence_count}ABS / {row.late_count}Late / {row.early_dismissal_count}ED / {row.badge_exception_count}Badge</td>
<td>{row.combination_code || 'Clear'}</td>
<td>{row.action_needed || 'Record and monitor'}</td>
<td>{row.final_decision || '—'}</td>
<td className="text-nowrap">
{boolish(row.follow_up_required) && !boolish(row.follow_up_completed) ? <button className="btn btn-sm btn-outline-success me-2" onClick={() => void complete(row)}>Complete</button> : null}
{row.attendance_status === 'late' ? <button className="btn btn-sm btn-outline-secondary" onClick={() => void reprint(row)}>Reprint slip</button> : null}
</td>
</tr>
))}
{!loading && (dashboard?.rows.length ?? 0) === 0 ? <tr><td colSpan={10} className="text-center text-muted py-4">No attendance management rows for these filters.</td></tr> : null}
</tbody>
</table>
</div>
</div>
</div>
</div>
)
}
@@ -1,95 +1,11 @@
import { type FormEvent, useMemo } from 'react' import { Navigate, useSearchParams } from 'react-router-dom'
import { Link, useSearchParams } from 'react-router-dom' import { ATTENDANCE_VIOLATIONS_PENDING_PATH } from './attendancePaths'
import {
ATTENDANCE_VIOLATIONS_NOTIFIED_PATH,
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() { export function AttendanceViolationsHubPage() {
const [searchParams, setSearchParams] = useSearchParams() const [searchParams] = useSearchParams()
const schoolYear = searchParams.get('school_year') ?? '' const query = searchParams.toString()
const semester = searchParams.get('semester') ?? '' const to = query ? `${ATTENDANCE_VIOLATIONS_PENDING_PATH}?${query}` : ATTENDANCE_VIOLATIONS_PENDING_PATH
const filterQuery = useMemo(() => { return <Navigate to={to} replace />
const s = searchParams.toString()
return s ? `?${s}` : ''
}, [searchParams])
function applyFilter(e: FormEvent<HTMLFormElement>) {
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 (
<div className="container-fluid px-0">
<h2 className="text-center mt-4 mb-3">Attendance Violations</h2>
<p className="text-center text-muted small mb-4">
Review students who have crossed absence or late thresholds. Data loads on each list via the API.
</p>
<div className="d-flex justify-content-center mb-4">
<form className="row g-2 align-items-end" onSubmit={applyFilter}>
<div className="col-auto">
<label htmlFor="hub-school-year" className="form-label small mb-0">School Year</label>
<input id="hub-school-year" name="school_year" className="form-control form-control-sm" defaultValue={schoolYear} />
</div>
<div className="col-auto">
<label htmlFor="hub-semester" className="form-label small mb-0">Semester</label>
<select id="hub-semester" name="semester" className="form-select form-select-sm" defaultValue={semester}>
<option value=""> All </option>
<option value="Fall">Fall</option>
<option value="Spring">Spring</option>
</select>
</div>
<div className="col-auto">
<button type="submit" className="btn btn-sm btn-secondary">
Apply
</button>
</div>
</form>
</div>
<div className="row g-3 justify-content-center px-2 pb-4">
<div className="col-12 col-md-5">
<div className="card border-0 rounded-3 shadow-sm h-100">
<div className="card-body d-flex flex-column">
<h5 className="card-title">Pending</h5>
<p className="card-text small text-muted flex-grow-1">
Students who need notification or follow-up per policy (team notify, auto email, etc.).
</p>
<Link
className="btn btn-primary"
to={`${ATTENDANCE_VIOLATIONS_PENDING_PATH}${filterQuery}`}
>
Open pending list
</Link>
</div>
</div>
</div>
<div className="col-12 col-md-5">
<div className="card border-0 rounded-3 shadow-sm h-100">
<div className="card-body d-flex flex-column">
<h5 className="card-title">Notified</h5>
<p className="card-text small text-muted flex-grow-1">
Students whose parents were already notified; add or edit notes as needed.
</p>
<Link
className="btn btn-outline-primary"
to={`${ATTENDANCE_VIOLATIONS_NOTIFIED_PATH}${filterQuery}`}
>
Open notified list
</Link>
</div>
</div>
</div>
</div>
</div>
)
} }
@@ -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<AttendanceManagementDashboard | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(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 (
<div className="container-fluid py-4">
<div className="d-flex flex-wrap justify-content-between gap-3 align-items-start mb-4">
<div>
<h1 className="h3 mb-1">Badge Scanning List</h1>
<p className="text-muted mb-0">
Students, staff, contractors, and visitors recorded through badge scan.
</p>
</div>
<button
className="btn btn-outline-secondary"
type="button"
onClick={() => void load()}
disabled={loading}
>
Refresh
</button>
</div>
{error ? <div className="alert alert-danger">{error}</div> : null}
<div className="card shadow-sm mb-4">
<div className="card-body">
<div className="row g-3 align-items-end">
<div className="col-md-3">
<label className="form-label">Date</label>
<input
className="form-control"
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
/>
</div>
<div className="col-md-3">
<label className="form-label">Status</label>
<select
className="form-select"
value={status}
onChange={(e) => setStatus(e.target.value)}
>
<option value="">All</option>
<option value="present">Present</option>
<option value="late">Late</option>
<option value="early_dismissal">Early dismissal</option>
</select>
</div>
<div className="col-md-4">
<label className="form-label">Search</label>
<input
className="form-control"
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Name, badge, grade, department"
/>
</div>
<div className="col-md-2">
<button
className="btn btn-primary w-100"
type="button"
onClick={() => void load()}
disabled={loading}
>
Search
</button>
</div>
</div>
</div>
</div>
<div className="card shadow-sm">
<div className="card-body">
<div className="d-flex justify-content-between align-items-center mb-3">
<h2 className="h5 mb-0">Scanned Badge Records</h2>
<span className="badge bg-secondary">{rows.length} records</span>
</div>
<div className="table-responsive">
<table className="table table-sm align-middle">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Grade/Dept</th>
<th>Badge ID</th>
<th>Date</th>
<th>Scan Time</th>
<th>Location</th>
<th>Status</th>
<th>Reported</th>
<th>Decision</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.id}>
<td>{row.person_name || '—'}</td>
<td>{nice(row.person_type)}</td>
<td>{row.role_grade || '—'}</td>
<td>{row.badge_id || '—'}</td>
<td>{row.event_date}</td>
<td>{row.official_entry_time || row.official_exit_time || '—'}</td>
<td>{row.scan_location || row.exit_location || '—'}</td>
<td>{nice(row.attendance_status)}</td>
<td>{nice(row.report_status)}</td>
<td>{row.final_decision || '—'}</td>
</tr>
))}
{!loading && rows.length === 0 ? (
<tr>
<td colSpan={10} className="text-center text-muted py-4">
No badge scans found for this filter.
</td>
</tr>
) : null}
{loading ? (
<tr>
<td colSpan={10} className="text-center text-muted py-4">
Loading badge scans...
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</div>
</div>
</div>
)
}
@@ -66,7 +66,15 @@ export function TeacherAttendanceMonthPage() {
semester: semesterParam === '---' ? null : semesterParam, semester: semesterParam === '---' ? null : semesterParam,
schoolYear: schoolYearParam || null, 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) { } catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : 'Unable to load monthly attendance.') if (!cancelled) setError(e instanceof Error ? e.message : 'Unable to load monthly attendance.')
} finally { } finally {
+3
View File
@@ -12,3 +12,6 @@ export { AttendanceStudentViolationsViewPage } from './AttendanceStudentViolatio
export { AttendanceViolationsHubPage } from './AttendanceViolationsHubPage' export { AttendanceViolationsHubPage } from './AttendanceViolationsHubPage'
export { ViolationsNotifiedPage, ViolationsPendingPage } from './ViolationsPages' export { ViolationsNotifiedPage, ViolationsPendingPage } from './ViolationsPages'
export { AttendanceTemplatesIndexPage } from './AttendanceTemplatesIndexPage' export { AttendanceTemplatesIndexPage } from './AttendanceTemplatesIndexPage'
export { AttendanceManagementPage } from './AttendanceManagementPage'
export { BadgeScanningListPage } from './BadgeScanningListPage'