294 lines
11 KiB
TypeScript
294 lines
11 KiB
TypeScript
import { type FormEvent, useEffect, useMemo, useState } from 'react'
|
|
import { Link, useSearchParams } from 'react-router-dom'
|
|
import { useSchoolYearOptions } from '../../hooks/useSchoolYearOptions'
|
|
import { fetchTeacherStaffAttendance, saveTeacherStaffAttendance } from '../../api/session'
|
|
import { TEACHER_ATTENDANCE_MONTH_PATH } from './attendancePaths'
|
|
import type { TeacherStaffAttendanceRow } from '../../api/types'
|
|
|
|
function formatUsDate(ymd: string): string {
|
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(ymd)) return ymd
|
|
const [y, m, d] = ymd.split('-')
|
|
return `${m}-${d}-${y}`
|
|
}
|
|
|
|
/** Port of `Views/attendance/teacher_attendance_form.php` */
|
|
export function TeacherAttendanceFormPage() {
|
|
const [searchParams, setSearchParams] = useSearchParams()
|
|
const schoolYear = searchParams.get('school_year') ?? ''
|
|
const semester = searchParams.get('semester') ?? ''
|
|
const date =
|
|
searchParams.get('date') ?? new Date().toISOString().slice(0, 10)
|
|
const classSectionId = Number(searchParams.get('class_section_id') ?? '0')
|
|
|
|
const [sections, setSections] = useState<Record<string, string>>({})
|
|
const [grid, setGrid] = useState<TeacherStaffAttendanceRow[]>([])
|
|
const [locked, setLocked] = useState(false)
|
|
const [loading, setLoading] = useState(true)
|
|
const [saving, setSaving] = useState(false)
|
|
const [message, setMessage] = useState<string | null>(null)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const { options, selectedYear } = useSchoolYearOptions({
|
|
preferredYear: schoolYear,
|
|
})
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
;(async () => {
|
|
setLoading(true)
|
|
setError(null)
|
|
try {
|
|
const data = await fetchTeacherStaffAttendance({
|
|
date,
|
|
semester: semester || null,
|
|
schoolYear: schoolYear || null,
|
|
classSectionId: classSectionId || null,
|
|
})
|
|
if (cancelled) return
|
|
setSections(data.sections ?? {})
|
|
setGrid(data.grid ?? [])
|
|
setLocked(Boolean(data.locked))
|
|
} catch (e) {
|
|
if (!cancelled) setError(e instanceof Error ? e.message : 'Unable to load teacher attendance.')
|
|
} finally {
|
|
if (!cancelled) setLoading(false)
|
|
}
|
|
})()
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [date, semester, schoolYear, classSectionId])
|
|
|
|
const dateLabel = useMemo(() => formatUsDate(date), [date])
|
|
|
|
function setRow(tid: number, patch: Partial<TeacherStaffAttendanceRow>) {
|
|
setGrid((prev) =>
|
|
prev.map((r) => ((r.teacher_id ?? 0) === tid ? { ...r, ...patch } : r)),
|
|
)
|
|
}
|
|
|
|
function markAllPresent() {
|
|
setGrid((prev) => prev.map((r) => ({ ...r, status: 'present' })))
|
|
}
|
|
|
|
function clearAll() {
|
|
setGrid((prev) => prev.map((r) => ({ ...r, status: '', reason: '' })))
|
|
}
|
|
|
|
async function onSubmit(e: FormEvent) {
|
|
e.preventDefault()
|
|
if (!classSectionId) return
|
|
setSaving(true)
|
|
setMessage(null)
|
|
setError(null)
|
|
try {
|
|
const teachers: Record<string, { status?: string | null; reason?: string | null }> = {}
|
|
for (const r of grid) {
|
|
const tid = r.teacher_id ?? 0
|
|
if (!tid) continue
|
|
teachers[String(tid)] = {
|
|
status: r.status ?? '',
|
|
reason: r.reason ?? '',
|
|
}
|
|
}
|
|
await saveTeacherStaffAttendance({
|
|
date,
|
|
semester,
|
|
school_year: schoolYear,
|
|
class_section_id: classSectionId,
|
|
teachers,
|
|
})
|
|
setMessage('Saved.')
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Save failed.')
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
function applyFilters(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') ?? '')
|
|
const dt = String(fd.get('date') ?? '')
|
|
const cs = String(fd.get('class_section_id') ?? '0')
|
|
if (sy) p.set('school_year', sy)
|
|
if (sem) p.set('semester', sem)
|
|
if (dt) p.set('date', dt)
|
|
if (cs && cs !== '0') p.set('class_section_id', cs)
|
|
setSearchParams(p)
|
|
}
|
|
|
|
const sectionEntries = Object.entries(sections)
|
|
|
|
return (
|
|
<div className="container-xxl py-4">
|
|
<div className="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
|
|
<h2 className="mb-0">Teacher Attendance</h2>
|
|
<Link
|
|
className="btn btn-outline-secondary"
|
|
to={`${TEACHER_ATTENDANCE_MONTH_PATH}?${new URLSearchParams({ date, semester, school_year: schoolYear }).toString()}`}
|
|
>
|
|
← Back to Monthly Attendance
|
|
</Link>
|
|
</div>
|
|
|
|
{message ? <div className="alert alert-success">{message}</div> : null}
|
|
{error ? <div className="alert alert-danger">{error}</div> : null}
|
|
|
|
<form className="row gy-2 gx-3 align-items-end mb-3" onSubmit={applyFilters}>
|
|
<div className="col-sm-3">
|
|
<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-sm-2">
|
|
<label className="form-label">Semester</label>
|
|
<input
|
|
type="text"
|
|
name="semester"
|
|
className="form-control"
|
|
defaultValue={semester}
|
|
placeholder="Fall"
|
|
/>
|
|
</div>
|
|
<div className="col-sm-3">
|
|
<label className="form-label">Date</label>
|
|
<input type="date" name="date" className="form-control" defaultValue={date} />
|
|
</div>
|
|
<div className="col-sm-4">
|
|
<label className="form-label">Class Section</label>
|
|
<select
|
|
name="class_section_id"
|
|
className="form-select"
|
|
defaultValue={classSectionId || 0}
|
|
>
|
|
<option value="0">— Choose Section —</option>
|
|
{sectionEntries.map(([id, label]) => (
|
|
<option key={id} value={id}>
|
|
{label} (ID: {id})
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="col-12 d-flex gap-2">
|
|
<button type="submit" className="btn btn-secondary">
|
|
Load
|
|
</button>
|
|
{classSectionId ? (
|
|
<button
|
|
type="button"
|
|
className="btn btn-outline-secondary"
|
|
onClick={() => setSearchParams(new URLSearchParams())}
|
|
>
|
|
Reset
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
</form>
|
|
|
|
{!classSectionId ? null : (
|
|
<>
|
|
{locked ? (
|
|
<div className="alert alert-warning">
|
|
<strong>Note:</strong> Student/section attendance for this day appears to be <em>finalized</em>. Admin edits
|
|
to teacher attendance here are allowed and will not unlock the section.
|
|
</div>
|
|
) : null}
|
|
|
|
<form onSubmit={onSubmit}>
|
|
<div className="card shadow-sm">
|
|
<div className="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
|
<strong>Teachers on {dateLabel}</strong>
|
|
<div className="d-flex gap-2">
|
|
<button type="button" className="btn btn-sm btn-outline-success" onClick={markAllPresent}>
|
|
Mark All Present
|
|
</button>
|
|
<button type="button" className="btn btn-sm btn-outline-secondary" onClick={clearAll}>
|
|
Clear All
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="card-body table-responsive">
|
|
{loading ? (
|
|
<p className="text-muted mb-0">Loading…</p>
|
|
) : grid.length === 0 ? (
|
|
<p className="text-muted mb-0 text-center">No teachers found for this section/term.</p>
|
|
) : (
|
|
<table className="table table-bordered table-striped align-middle" id="teacherAttTable">
|
|
<thead className="table-light">
|
|
<tr>
|
|
<th className="text-center" style={{ width: 70 }}>
|
|
#
|
|
</th>
|
|
<th>Name</th>
|
|
<th className="text-center" style={{ width: 160 }}>
|
|
Role
|
|
</th>
|
|
<th className="text-center" style={{ width: 200 }}>
|
|
Status
|
|
</th>
|
|
<th>Reason</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{grid.map((row, idx) => {
|
|
const tid = row.teacher_id ?? 0
|
|
const name =
|
|
`${row.firstname ?? ''} ${row.lastname ?? ''}`.trim() || `User#${tid}`
|
|
const pos = String(row.position ?? '').toLowerCase()
|
|
const stat = String(row.status ?? '').toLowerCase()
|
|
return (
|
|
<tr key={tid || idx}>
|
|
<td className="text-center">{idx + 1}</td>
|
|
<td>{name}</td>
|
|
<td className="text-center">{pos === 'main' ? 'Main Teacher' : 'Assistant'}</td>
|
|
<td className="text-center">
|
|
<select
|
|
className="form-select form-select-sm"
|
|
value={
|
|
stat === 'present' || stat === 'absent' || stat === 'late' ? stat : ''
|
|
}
|
|
onChange={(ev) => setRow(tid, { status: ev.target.value || null })}
|
|
>
|
|
<option value="">—</option>
|
|
<option value="present">Present</option>
|
|
<option value="absent">Absent</option>
|
|
<option value="late">Late</option>
|
|
</select>
|
|
</td>
|
|
<td>
|
|
<input
|
|
type="text"
|
|
className="form-control form-control-sm"
|
|
placeholder="Optional reason"
|
|
value={row.reason ?? ''}
|
|
onChange={(ev) => setRow(tid, { reason: ev.target.value })}
|
|
/>
|
|
</td>
|
|
</tr>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
<div className="card-footer d-flex justify-content-end">
|
|
<button type="submit" className="btn btn-primary" disabled={saving || grid.length === 0}>
|
|
{saving ? 'Saving…' : 'Save Changes'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|