187 lines
5.7 KiB
TypeScript
187 lines
5.7 KiB
TypeScript
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>
|
|
)
|
|
}
|