fix attendance
This commit is contained in:
@@ -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() {
|
||||
<Route path="administrator/absence" element={<AdministratorAbsencePage />} />
|
||||
<Route path="administrator/attendance/admins-form" element={<AdminsAttendanceFormPage />} />
|
||||
<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/early-dismissals" element={<EarlyDismissalsPage />} />
|
||||
<Route path="administrator/attendance/early-dismissals/new" element={<EarlyDismissalsAddPage />} />
|
||||
|
||||
@@ -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
@@ -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<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). */
|
||||
@@ -392,7 +393,7 @@ export async function saveStaffMonthlyAttendanceCell(payload: Record<string, str
|
||||
}> {
|
||||
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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 { 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<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>
|
||||
)
|
||||
return <Navigate to={to} replace />
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
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 {
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user