fix attendance
This commit is contained in:
@@ -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 />} />
|
||||||
|
|||||||
@@ -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?.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(),
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
Reference in New Issue
Block a user