Files
alrahma_web_client/src/pages/slips/SlipPreviewListPage.tsx
T
2026-06-07 00:52:04 -04:00

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>
)
}