341 lines
13 KiB
TypeScript
341 lines
13 KiB
TypeScript
import { type FormEvent, useEffect, useMemo, useState } from 'react'
|
|
import { useSearchParams } from 'react-router-dom'
|
|
import { ApiHttpError } from '../../api/http'
|
|
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
|
|
import { fetchSlipPreviewList, type SlipPreviewRow } from '../../api/slips'
|
|
import { displaySemester, formatPreviewDateKey } from './slipFormatting'
|
|
import { SLIPS_PRINT_PATH } from './slipPaths'
|
|
|
|
function groupRowsByDate(rows: SlipPreviewRow[]) {
|
|
const groupedRows: Record<string, SlipPreviewRow[]> = {}
|
|
const dateOrder: string[] = []
|
|
for (const r of rows) {
|
|
let dateKey = String(r.date ?? '').trim()
|
|
if (!dateKey) dateKey = 'Unknown Date'
|
|
if (!(dateKey in groupedRows)) {
|
|
groupedRows[dateKey] = []
|
|
dateOrder.push(dateKey)
|
|
}
|
|
groupedRows[dateKey].push(r)
|
|
}
|
|
const chartData = dateOrder.map((d) => groupedRows[d].length)
|
|
const chartLabels = dateOrder.map((d) => formatPreviewDateKey(d))
|
|
return { groupedRows, dateOrder, chartData, chartLabels }
|
|
}
|
|
|
|
function slipPrintSearchParams(row: SlipPreviewRow): string {
|
|
const fields = [
|
|
'student_name',
|
|
'date',
|
|
'time_in',
|
|
'grade',
|
|
'reason',
|
|
'school_year',
|
|
'semester',
|
|
'admin_name',
|
|
] as const
|
|
const qs = new URLSearchParams()
|
|
for (const f of fields) {
|
|
qs.set(f, String(row[f] ?? ''))
|
|
}
|
|
return qs.toString()
|
|
}
|
|
|
|
export function SlipPreviewListPage() {
|
|
type SlipSortKey =
|
|
| 'student_name'
|
|
| 'time_in'
|
|
| 'grade'
|
|
| 'reason'
|
|
| 'school_year'
|
|
| 'semester'
|
|
| 'admin_name'
|
|
const [searchParams, setSearchParams] = useSearchParams()
|
|
const schoolYear = searchParams.get('school_year') ?? ''
|
|
const semester = searchParams.get('semester') ?? ''
|
|
|
|
const [rows, setRows] = useState<SlipPreviewRow[]>([])
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [expandedDates, setExpandedDates] = useState<Set<string>>(new Set())
|
|
const [sortConfig, setSortConfig] = useState<{ key: SlipSortKey; direction: 'asc' | 'desc' }>({
|
|
key: 'student_name',
|
|
direction: 'asc',
|
|
})
|
|
const { options, selectedYear } = useSchoolYearOptions({
|
|
preferredYear: schoolYear,
|
|
})
|
|
|
|
useEffect(() => {
|
|
setError(null)
|
|
fetchSlipPreviewList({
|
|
school_year: schoolYear || undefined,
|
|
semester: semester || undefined,
|
|
})
|
|
.then((d) => {
|
|
const nextRows = d.rows ?? []
|
|
setRows(nextRows)
|
|
const dateKeys = new Set(
|
|
nextRows.map((row) => {
|
|
const raw = String(row.date ?? '').trim()
|
|
return raw || 'Unknown Date'
|
|
}),
|
|
)
|
|
setExpandedDates(dateKeys)
|
|
})
|
|
.catch((e: unknown) =>
|
|
setError(e instanceof ApiHttpError ? e.message : 'Failed to load slips.'),
|
|
)
|
|
}, [schoolYear, semester])
|
|
|
|
const { groupedRows, dateOrder, chartData, chartLabels } = useMemo(
|
|
() => groupRowsByDate(rows),
|
|
[rows],
|
|
)
|
|
|
|
const maxCount = Math.max(1, ...chartData)
|
|
|
|
function applyFilter(e: FormEvent<HTMLFormElement>) {
|
|
e.preventDefault()
|
|
const fd = new FormData(e.currentTarget)
|
|
const next = new URLSearchParams()
|
|
const sy = String(fd.get('school_year') ?? '')
|
|
const sem = String(fd.get('semester') ?? '')
|
|
if (sy) next.set('school_year', sy)
|
|
if (sem) next.set('semester', sem)
|
|
setSearchParams(next)
|
|
}
|
|
|
|
function openPrint(row: SlipPreviewRow) {
|
|
const q = slipPrintSearchParams(row)
|
|
window.open(`${SLIPS_PRINT_PATH}?${q}`, '_blank', 'noopener')
|
|
}
|
|
|
|
function toggleDate(dateKey: string) {
|
|
setExpandedDates((prev) => {
|
|
const next = new Set(prev)
|
|
next.has(dateKey) ? next.delete(dateKey) : next.add(dateKey)
|
|
return next
|
|
})
|
|
}
|
|
|
|
function requestSort(key: SlipSortKey) {
|
|
setSortConfig((prev) =>
|
|
prev.key === key
|
|
? { key, direction: prev.direction === 'asc' ? 'desc' : 'asc' }
|
|
: { key, direction: 'asc' },
|
|
)
|
|
}
|
|
|
|
function sortIndicator(key: SlipSortKey) {
|
|
if (sortConfig.key !== key) return '↕'
|
|
return sortConfig.direction === 'asc' ? '↑' : '↓'
|
|
}
|
|
|
|
function sortRows(groupRows: SlipPreviewRow[]) {
|
|
return [...groupRows].sort((left, right) => {
|
|
const direction = sortConfig.direction === 'asc' ? 1 : -1
|
|
const leftValue =
|
|
sortConfig.key === 'semester'
|
|
? displaySemester(left.semester)
|
|
: String(left[sortConfig.key] ?? '')
|
|
const rightValue =
|
|
sortConfig.key === 'semester'
|
|
? displaySemester(right.semester)
|
|
: String(right[sortConfig.key] ?? '')
|
|
return (
|
|
leftValue.localeCompare(rightValue, undefined, {
|
|
numeric: true,
|
|
sensitivity: 'base',
|
|
}) * direction
|
|
)
|
|
})
|
|
}
|
|
|
|
return (
|
|
<div className="container-fluid px-0 py-4">
|
|
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
|
|
<div>
|
|
<h2 className="mb-0">Late Slip Preview</h2>
|
|
<small className="text-muted">View and print student late slips</small>
|
|
</div>
|
|
</div>
|
|
|
|
<form className="row gy-2 gx-3 align-items-end mb-4" onSubmit={applyFilter}>
|
|
<div className="col-md-4">
|
|
<label className="form-label">School Year</label>
|
|
<select name="school_year" className="form-select" defaultValue={schoolYear || selectedYear}>
|
|
{(options.length > 0 ? options : [{ value: selectedYear, label: selectedYear }]).map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="col-md-3">
|
|
<label className="form-label">Semester</label>
|
|
<select name="semester" className="form-select" defaultValue={semester}>
|
|
<option value="">All Semesters</option>
|
|
<option value="fall">Fall</option>
|
|
<option value="spring">Spring</option>
|
|
</select>
|
|
</div>
|
|
<div className="col-md-2">
|
|
<button type="submit" className="btn btn-primary w-100">
|
|
Filter
|
|
</button>
|
|
</div>
|
|
</form>
|
|
|
|
{error ? <div className="alert alert-danger">{error}</div> : null}
|
|
|
|
{rows.length === 0 ? (
|
|
<div className="alert alert-light border">No slips found.</div>
|
|
) : (
|
|
<>
|
|
<div className="row mb-4">
|
|
<div className="col-12">
|
|
<div className="card shadow-sm border-0">
|
|
<div className="card-header bg-white py-3">
|
|
<h5 className="card-title mb-0 fw-bold text-primary">Late Statistics by Date</h5>
|
|
</div>
|
|
<div className="card-body">
|
|
<div style={{ width: '100%' }}>
|
|
<div
|
|
className="d-flex align-items-end gap-1 px-2 border-bottom"
|
|
style={{ height: 240 }}
|
|
>
|
|
{chartLabels.map((label, i) => {
|
|
const barH = Math.round((chartData[i] / maxCount) * 220)
|
|
return (
|
|
<div
|
|
key={label + i}
|
|
className="d-flex flex-column align-items-center flex-fill"
|
|
style={{ maxWidth: 90 }}
|
|
>
|
|
<div
|
|
className="w-100 rounded-top bg-primary bg-opacity-75"
|
|
style={{
|
|
height: barH,
|
|
minHeight: chartData[i] > 0 ? 6 : 0,
|
|
}}
|
|
title={`${label}: ${chartData[i]}`}
|
|
/>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
<div className="d-flex gap-1 px-2 mt-1 flex-wrap justify-content-between">
|
|
{chartLabels.map((label, i) => (
|
|
<div
|
|
key={`lbl-${label}-${i}`}
|
|
className="small text-muted text-center text-truncate flex-fill"
|
|
style={{ fontSize: 10, maxWidth: 90 }}
|
|
>
|
|
{label}
|
|
<span className="d-block fw-semibold text-dark">{chartData[i]}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="small text-muted text-center mt-2">
|
|
Recorded Dates · Total slips per day
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{dateOrder.map((dateKey) => {
|
|
const groupRows = groupedRows[dateKey] ?? []
|
|
const expanded = expandedDates.has(dateKey)
|
|
const displayedRows = sortRows(groupRows)
|
|
return (
|
|
<div key={dateKey} className="card mb-4">
|
|
<div
|
|
className="card-header d-flex align-items-center justify-content-between flex-wrap gap-2"
|
|
role="button"
|
|
style={{ cursor: 'pointer', userSelect: 'none' }}
|
|
onClick={() => toggleDate(dateKey)}
|
|
>
|
|
<div className="fw-semibold">
|
|
{expanded ? '▼' : '▶'} Date: {formatPreviewDateKey(dateKey)}
|
|
</div>
|
|
<span className="badge bg-secondary">{groupRows.length} slips</span>
|
|
</div>
|
|
{expanded ? (
|
|
<div className="table-responsive">
|
|
<table className="table table-striped table-bordered align-middle w-100">
|
|
<thead className="table-light">
|
|
<tr>
|
|
<th>
|
|
<button type="button" className="btn btn-link btn-sm p-0 text-decoration-none" onClick={() => requestSort('student_name')}>
|
|
Student Name {sortIndicator('student_name')}
|
|
</button>
|
|
</th>
|
|
<th>
|
|
<button type="button" className="btn btn-link btn-sm p-0 text-decoration-none" onClick={() => requestSort('time_in')}>
|
|
Time In {sortIndicator('time_in')}
|
|
</button>
|
|
</th>
|
|
<th>
|
|
<button type="button" className="btn btn-link btn-sm p-0 text-decoration-none" onClick={() => requestSort('grade')}>
|
|
Grade {sortIndicator('grade')}
|
|
</button>
|
|
</th>
|
|
<th>
|
|
<button type="button" className="btn btn-link btn-sm p-0 text-decoration-none" onClick={() => requestSort('reason')}>
|
|
Reason {sortIndicator('reason')}
|
|
</button>
|
|
</th>
|
|
<th>
|
|
<button type="button" className="btn btn-link btn-sm p-0 text-decoration-none" onClick={() => requestSort('school_year')}>
|
|
School Year {sortIndicator('school_year')}
|
|
</button>
|
|
</th>
|
|
<th>
|
|
<button type="button" className="btn btn-link btn-sm p-0 text-decoration-none" onClick={() => requestSort('semester')}>
|
|
Semester {sortIndicator('semester')}
|
|
</button>
|
|
</th>
|
|
<th>
|
|
<button type="button" className="btn btn-link btn-sm p-0 text-decoration-none" onClick={() => requestSort('admin_name')}>
|
|
Admin Name {sortIndicator('admin_name')}
|
|
</button>
|
|
</th>
|
|
<th>Action</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{displayedRows.map((r, idx) => (
|
|
<tr key={idx}>
|
|
<td>{String(r.student_name ?? '')}</td>
|
|
<td>{String(r.time_in ?? '')}</td>
|
|
<td>{String(r.grade ?? '')}</td>
|
|
<td>{String(r.reason ?? '')}</td>
|
|
<td>{String(r.school_year ?? '')}</td>
|
|
<td>{displaySemester(r.semester)}</td>
|
|
<td>{String(r.admin_name ?? '')}</td>
|
|
<td>
|
|
<button
|
|
type="button"
|
|
className="btn btn-sm btn-primary"
|
|
onClick={() => openPrint(r)}
|
|
>
|
|
Print
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)
|
|
})}
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|