fix teacher page
This commit is contained in:
@@ -6,7 +6,7 @@ import { TeacherShell } from './TeacherShell'
|
|||||||
import { useTeacherResource } from './useTeacherResource'
|
import { useTeacherResource } from './useTeacherResource'
|
||||||
import { postTeacherMultipart } from '../../api/teacherSession'
|
import { postTeacherMultipart } from '../../api/teacherSession'
|
||||||
import { useAuth } from '../../auth/AuthProvider'
|
import { useAuth } from '../../auth/AuthProvider'
|
||||||
import { apiFetch } from '../../api/http'
|
import { ApiHttpError, apiFetch } from '../../api/http'
|
||||||
|
|
||||||
function TeacherApiDumpPage({
|
function TeacherApiDumpPage({
|
||||||
title,
|
title,
|
||||||
@@ -117,6 +117,132 @@ function teacherExamFilePath(kind: 'teacher' | 'final', filename?: string | null
|
|||||||
return filename ? `/api/v1/files/exams/${kind}/${encodeURIComponent(filename)}` : null
|
return filename ? `/api/v1/files/exams/${kind}/${encodeURIComponent(filename)}` : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TeacherAbsenceRow = {
|
||||||
|
id?: number
|
||||||
|
user_id?: number
|
||||||
|
date?: string
|
||||||
|
semester?: string | null
|
||||||
|
school_year?: string | null
|
||||||
|
status?: string | null
|
||||||
|
reason?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type TeacherAbsenceFormPayload = {
|
||||||
|
ok?: boolean
|
||||||
|
teacher_name?: string | null
|
||||||
|
semester?: string | null
|
||||||
|
school_year?: string | null
|
||||||
|
existing?: TeacherAbsenceRow[]
|
||||||
|
available_dates?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type TeacherAbsenceSubmitResponse = {
|
||||||
|
ok?: boolean
|
||||||
|
message?: string
|
||||||
|
saved?: number
|
||||||
|
dates?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTeacherAbsenceDate(value: string | null | undefined): string {
|
||||||
|
if (!value) return ''
|
||||||
|
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(value))
|
||||||
|
if (!match) return String(value)
|
||||||
|
return `${match[2]}-${match[3]}-${match[1]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function teacherAbsenceStatusBadgeClass(value: string | null | undefined): string {
|
||||||
|
const status = String(value ?? '').trim().toLowerCase()
|
||||||
|
if (status === 'absent') return 'danger'
|
||||||
|
if (status === 'late') return 'warning text-dark'
|
||||||
|
if (status === 'present') return 'success'
|
||||||
|
return 'secondary'
|
||||||
|
}
|
||||||
|
|
||||||
|
type TeacherScoreComment = {
|
||||||
|
comment?: string | null
|
||||||
|
comment_review?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type TeacherScoresStudent = {
|
||||||
|
student_id: number
|
||||||
|
firstname?: string | null
|
||||||
|
lastname?: string | null
|
||||||
|
school_id?: string | null
|
||||||
|
scores?: Record<string, string | number | null | undefined>
|
||||||
|
comments?: Record<string, TeacherScoreComment> | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type TeacherScoresPayload = {
|
||||||
|
ok?: boolean
|
||||||
|
students?: TeacherScoresStudent[]
|
||||||
|
class_section_id?: number | string | null
|
||||||
|
class_section_name?: string | null
|
||||||
|
semester?: string | null
|
||||||
|
school_year?: string | null
|
||||||
|
scores_locked?: boolean
|
||||||
|
missing_ok_map?: Record<string, Record<string, Record<string, boolean | number> | boolean | number>>
|
||||||
|
}
|
||||||
|
|
||||||
|
type TeacherScoresRow = {
|
||||||
|
student_id: number
|
||||||
|
firstname: string
|
||||||
|
lastname: string
|
||||||
|
school_id: string
|
||||||
|
attendance: string
|
||||||
|
homework: string
|
||||||
|
project: string
|
||||||
|
quiz: string
|
||||||
|
participation: string
|
||||||
|
ptap: string
|
||||||
|
term_score: string
|
||||||
|
semester_score: string
|
||||||
|
ptap_comment: string
|
||||||
|
ptap_comment_review: string
|
||||||
|
term_comment: string
|
||||||
|
term_comment_review: string
|
||||||
|
ptap_missing_ok: boolean
|
||||||
|
term_missing_ok: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function teacherScoreDisplay(value: unknown): string {
|
||||||
|
if (value == null || value === '') return ''
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function teacherSemesterKey(value: string | null | undefined): 'midterm' | 'final' | null {
|
||||||
|
const semester = String(value ?? '').trim().toLowerCase()
|
||||||
|
if (semester === 'fall') return 'midterm'
|
||||||
|
if (semester === 'spring') return 'final'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function teacherScoreStartsWithName(value: string, firstName: string): boolean {
|
||||||
|
const text = value.trim()
|
||||||
|
const name = firstName.trim()
|
||||||
|
if (text === '' || name === '') return true
|
||||||
|
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
|
return new RegExp(`^${escaped}\\b`, 'i').test(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
function teacherScoreCommentError(value: string, firstName: string): string | null {
|
||||||
|
const text = value.trim()
|
||||||
|
if (text === '') return null
|
||||||
|
if (text.length < 100 || text.length > 350) {
|
||||||
|
return `Comment for ${firstName || 'student'} must be between 100 and 350 characters.`
|
||||||
|
}
|
||||||
|
if (!teacherScoreStartsWithName(text, firstName)) {
|
||||||
|
return `Comment for ${firstName || 'student'} must start with the student's first name.`
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function teacherScoreInRange(value: string): boolean {
|
||||||
|
const text = value.trim()
|
||||||
|
if (text === '') return false
|
||||||
|
const num = Number(text)
|
||||||
|
return Number.isFinite(num) && num >= 0 && num <= 100
|
||||||
|
}
|
||||||
|
|
||||||
/** `teacher/competition_scores/index.php` */
|
/** `teacher/competition_scores/index.php` */
|
||||||
export function TeacherCompetitionScoresIndexPage() {
|
export function TeacherCompetitionScoresIndexPage() {
|
||||||
const { data, loading, error } = useTeacherResource<CompetitionIndexPayload>('/competition-scores')
|
const { data, loading, error } = useTeacherResource<CompetitionIndexPayload>('/competition-scores')
|
||||||
@@ -221,7 +347,262 @@ export function TeacherCompetitionScoresDetailPage() {
|
|||||||
|
|
||||||
/** `teacher/absence_vacation.php` */
|
/** `teacher/absence_vacation.php` */
|
||||||
export function TeacherAbsenceVacationPage() {
|
export function TeacherAbsenceVacationPage() {
|
||||||
return <TeacherApiDumpPage title="Absence / vacation" path="/absence-vacation" />
|
const [payload, setPayload] = useState<TeacherAbsenceFormPayload | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [message, setMessage] = useState<string | null>(null)
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [selectedDates, setSelectedDates] = useState<string[]>([])
|
||||||
|
const [reasonType, setReasonType] = useState('vacation')
|
||||||
|
const [reason, setReason] = useState('')
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
const body = await apiFetch<TeacherAbsenceFormPayload>('/api/v1/teacher/absence-vacation')
|
||||||
|
setPayload(body)
|
||||||
|
setSelectedDates((current) => {
|
||||||
|
if (current.length > 0) return current
|
||||||
|
return (body.available_dates?.length ?? 0) > 0 ? [''] : []
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Unable to load absence request form.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const availableDates = payload?.available_dates ?? []
|
||||||
|
const hasDates = availableDates.length > 0
|
||||||
|
const usedDates = new Set(selectedDates.filter(Boolean))
|
||||||
|
const subtitleParts = [`Logged in as: ${payload?.teacher_name?.trim() || 'Teacher'}`]
|
||||||
|
if (payload?.semester || payload?.school_year) {
|
||||||
|
subtitleParts.push(`Term: ${payload?.semester ?? ''}${payload?.semester && payload?.school_year ? ' — ' : ''}${payload?.school_year ?? ''}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDateRow(index: number, value: string) {
|
||||||
|
setSelectedDates((current) => current.map((item, itemIndex) => (itemIndex === index ? value : item)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDateRow() {
|
||||||
|
setSelectedDates((current) => {
|
||||||
|
if (current.length >= availableDates.length) return current
|
||||||
|
return [...current, '']
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeDateRow(index: number) {
|
||||||
|
setSelectedDates((current) => current.filter((_, itemIndex) => itemIndex !== index))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault()
|
||||||
|
setSubmitting(true)
|
||||||
|
setError(null)
|
||||||
|
setMessage(null)
|
||||||
|
|
||||||
|
const dates = Array.from(new Set(selectedDates.map((item) => item.trim()).filter(Boolean)))
|
||||||
|
|
||||||
|
if (dates.length === 0) {
|
||||||
|
setError('Select at least one Sunday date.')
|
||||||
|
setSubmitting(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!reason.trim()) {
|
||||||
|
setError('Reason is required.')
|
||||||
|
setSubmitting(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await apiFetch<TeacherAbsenceSubmitResponse>('/api/v1/teacher/absence-vacation', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
dates,
|
||||||
|
reason_type: reasonType.trim() || null,
|
||||||
|
reason: reason.trim(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
setMessage(body.message ?? 'Absence request submitted.')
|
||||||
|
setReason('')
|
||||||
|
setReasonType('vacation')
|
||||||
|
setSelectedDates(hasDates ? [''] : [])
|
||||||
|
await load()
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Unable to submit absence request.')
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TeacherShell
|
||||||
|
title="TimeOff Request"
|
||||||
|
subtitle={subtitleParts.join(' · ')}
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
>
|
||||||
|
{message ? <div className="alert alert-success">{message}</div> : null}
|
||||||
|
|
||||||
|
<div className="card shadow-sm mb-4">
|
||||||
|
<div className="card-header bg-light">
|
||||||
|
<strong>Submit Upcoming Absences</strong>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<form onSubmit={(event) => void submit(event)}>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Select Sunday Date(s)</label>
|
||||||
|
<div className="d-flex flex-column gap-2">
|
||||||
|
{hasDates ? (
|
||||||
|
selectedDates.map((value, index) => (
|
||||||
|
<div key={`absence-date-${index}`} className="d-flex align-items-center gap-2 flex-wrap">
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
style={{ maxWidth: 260 }}
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => updateDateRow(index, event.target.value)}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
Select Sunday date
|
||||||
|
</option>
|
||||||
|
{availableDates.map((date) => {
|
||||||
|
const takenByAnotherRow = usedDates.has(date) && value !== date
|
||||||
|
return (
|
||||||
|
<option key={date} value={date} disabled={takenByAnotherRow}>
|
||||||
|
{formatTeacherAbsenceDate(date)}
|
||||||
|
</option>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{index === 0 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline-secondary btn-sm"
|
||||||
|
onClick={addDateRow}
|
||||||
|
disabled={!hasDates || selectedDates.length >= availableDates.length}
|
||||||
|
>
|
||||||
|
Add another
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline-danger btn-sm"
|
||||||
|
onClick={() => removeDateRow(index)}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="alert alert-info mb-0">
|
||||||
|
No future Sundays are available in the current term.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="form-text">Future Sundays from September to May are available only.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row g-3 mb-3">
|
||||||
|
<div className="col-sm-6">
|
||||||
|
<label className="form-label" htmlFor="teacher-absence-reason-type">
|
||||||
|
Reason Type
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="teacher-absence-reason-type"
|
||||||
|
className="form-select"
|
||||||
|
value={reasonType}
|
||||||
|
onChange={(event) => setReasonType(event.target.value)}
|
||||||
|
>
|
||||||
|
<option value="vacation">Vacation</option>
|
||||||
|
<option value="sick">Sick</option>
|
||||||
|
<option value="personal">Personal</option>
|
||||||
|
<option value="other">Other</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-sm-6">
|
||||||
|
<label className="form-label" htmlFor="teacher-absence-reason">
|
||||||
|
Reason <span className="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="teacher-absence-reason"
|
||||||
|
className="form-control"
|
||||||
|
type="text"
|
||||||
|
value={reason}
|
||||||
|
onChange={(event) => setReason(event.target.value)}
|
||||||
|
placeholder="Provide a reason"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="d-flex justify-content-center">
|
||||||
|
<button className="btn btn-primary px-5" type="submit" disabled={submitting || !hasDates}>
|
||||||
|
{submitting ? 'Submitting…' : 'Submit Absence'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card shadow-sm">
|
||||||
|
<div className="card-header bg-light">
|
||||||
|
<strong>Your Reported Status (This Term)</strong>
|
||||||
|
</div>
|
||||||
|
<div className="card-body p-0">
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-bordered table-striped m-0 align-middle">
|
||||||
|
<thead className="table-light">
|
||||||
|
<tr>
|
||||||
|
<th className="text-center" style={{ width: 80 }}>
|
||||||
|
#
|
||||||
|
</th>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Reason</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(payload?.existing ?? []).length > 0 ? (
|
||||||
|
(payload?.existing ?? []).map((row, index) => {
|
||||||
|
const status = String(row.status ?? '').trim().toLowerCase()
|
||||||
|
return (
|
||||||
|
<tr key={row.id ?? `${row.date ?? 'absence'}-${index}`}>
|
||||||
|
<td className="text-center">{index + 1}</td>
|
||||||
|
<td>{formatTeacherAbsenceDate(row.date)}</td>
|
||||||
|
<td>
|
||||||
|
<span className={`badge bg-${teacherAbsenceStatusBadgeClass(status)}`}>
|
||||||
|
{status ? `${status.charAt(0).toUpperCase()}${status.slice(1)}` : 'N/A'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{row.reason ?? ''}</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="text-center">
|
||||||
|
No reported entries yet.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TeacherShell>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `teacher/add_final_exam.php` */
|
/** `teacher/add_final_exam.php` */
|
||||||
@@ -987,10 +1368,484 @@ export function TeacherHomeworkListPage() {
|
|||||||
|
|
||||||
/** `teacher/scores.php` */
|
/** `teacher/scores.php` */
|
||||||
export function TeacherScoresPage() {
|
export function TeacherScoresPage() {
|
||||||
|
const { user } = useAuth()
|
||||||
const [search] = useSearchParams()
|
const [search] = useSearchParams()
|
||||||
const semester = search.get('semester')
|
const requestedSemester = search.get('semester')?.trim() ?? ''
|
||||||
const q = semester ? `?semester=${encodeURIComponent(semester)}` : ''
|
const requestedSchoolYear = search.get('school_year')?.trim() ?? ''
|
||||||
return <TeacherApiDumpPage title="Scores" path={`/scores${q}`} />
|
|
||||||
|
const [payload, setPayload] = useState<TeacherScoresPayload | null>(null)
|
||||||
|
const [rows, setRows] = useState<TeacherScoresRow[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [locking, setLocking] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [success, setSuccess] = useState<string | null>(null)
|
||||||
|
const [warningLines, setWarningLines] = useState<string[]>([])
|
||||||
|
const [tableSearch, setTableSearch] = useState('')
|
||||||
|
|
||||||
|
const resolvedSemester = payload?.semester?.trim() || requestedSemester
|
||||||
|
const resolvedSchoolYear = payload?.school_year?.trim() || requestedSchoolYear
|
||||||
|
const semesterKey = teacherSemesterKey(resolvedSemester)
|
||||||
|
const termLabel = semesterKey === 'midterm' ? 'Midterm' : semesterKey === 'final' ? 'Final Exam' : 'Term Score'
|
||||||
|
const termCommentLabel = semesterKey === 'midterm' ? 'Midterm Comment' : semesterKey === 'final' ? 'Final Comment' : 'Term Comment'
|
||||||
|
const termCommentOverrideKey = semesterKey === 'midterm' ? 'midterm_comment' : semesterKey === 'final' ? 'final_comment' : ''
|
||||||
|
const classSectionId = Number(payload?.class_section_id ?? 0)
|
||||||
|
const classSectionName = String(payload?.class_section_name ?? '').trim()
|
||||||
|
const scoresLocked = Boolean(payload?.scores_locked)
|
||||||
|
|
||||||
|
function missingOkChecked(
|
||||||
|
missingMap: TeacherScoresPayload['missing_ok_map'],
|
||||||
|
field: string,
|
||||||
|
studentId: number,
|
||||||
|
): boolean {
|
||||||
|
const fieldMap = missingMap?.[field]
|
||||||
|
if (!fieldMap || typeof fieldMap !== 'object') return false
|
||||||
|
const studentEntry = fieldMap[String(studentId)]
|
||||||
|
if (studentEntry === true || studentEntry === 1) return true
|
||||||
|
if (studentEntry && typeof studentEntry === 'object') {
|
||||||
|
const zero = (studentEntry as Record<string, unknown>)['0']
|
||||||
|
return zero === true || zero === 1
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRows(body: TeacherScoresPayload): TeacherScoresRow[] {
|
||||||
|
const bodySemesterKey = teacherSemesterKey(body.semester)
|
||||||
|
const bodyOverrideKey =
|
||||||
|
bodySemesterKey === 'midterm' ? 'midterm_comment' : bodySemesterKey === 'final' ? 'final_comment' : ''
|
||||||
|
return (body.students ?? []).map((student) => {
|
||||||
|
const scoreMap = student.scores ?? {}
|
||||||
|
const comments = student.comments ?? {}
|
||||||
|
return {
|
||||||
|
student_id: Number(student.student_id ?? 0),
|
||||||
|
firstname: String(student.firstname ?? '').trim(),
|
||||||
|
lastname: String(student.lastname ?? '').trim(),
|
||||||
|
school_id: String(student.school_id ?? '').trim(),
|
||||||
|
attendance: teacherScoreDisplay(scoreMap.attendance),
|
||||||
|
homework: teacherScoreDisplay(scoreMap.homework),
|
||||||
|
project: teacherScoreDisplay(scoreMap.project),
|
||||||
|
quiz: teacherScoreDisplay(scoreMap.quiz),
|
||||||
|
participation: teacherScoreDisplay(scoreMap.participation),
|
||||||
|
ptap: teacherScoreDisplay(scoreMap.ptap),
|
||||||
|
term_score: teacherScoreDisplay(
|
||||||
|
bodySemesterKey === 'midterm' ? scoreMap.midterm_exam : bodySemesterKey === 'final' ? scoreMap.final_exam : '',
|
||||||
|
),
|
||||||
|
semester_score: teacherScoreDisplay(scoreMap.semester),
|
||||||
|
ptap_comment: String(comments.ptap?.comment ?? ''),
|
||||||
|
ptap_comment_review: String(comments.ptap?.comment_review ?? ''),
|
||||||
|
term_comment: String((bodySemesterKey ? comments[bodySemesterKey]?.comment : '') ?? ''),
|
||||||
|
term_comment_review: String((bodySemesterKey ? comments[bodySemesterKey]?.comment_review : '') ?? ''),
|
||||||
|
ptap_missing_ok: missingOkChecked(body.missing_ok_map, 'ptap_comment', Number(student.student_id ?? 0)),
|
||||||
|
term_missing_ok: bodyOverrideKey
|
||||||
|
? missingOkChecked(body.missing_ok_map, bodyOverrideKey, Number(student.student_id ?? 0))
|
||||||
|
: false,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatApiErrors(err: unknown, fallback: string): string {
|
||||||
|
if (err instanceof ApiHttpError) {
|
||||||
|
const body = err.body
|
||||||
|
if (body && typeof body === 'object' && Array.isArray((body as { errors?: unknown }).errors)) {
|
||||||
|
const errors = (body as { errors?: unknown[] }).errors
|
||||||
|
?.map((item) => String(item ?? '').trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
if (errors && errors.length > 0) return errors.join(' ')
|
||||||
|
}
|
||||||
|
return err.message || fallback
|
||||||
|
}
|
||||||
|
return err instanceof Error ? err.message : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
setWarningLines([])
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (requestedSemester !== '') params.set('semester', requestedSemester)
|
||||||
|
if (requestedSchoolYear !== '') params.set('school_year', requestedSchoolYear)
|
||||||
|
const query = params.toString()
|
||||||
|
const body = await apiFetch<TeacherScoresPayload>(`/api/v1/teacher/scores${query ? `?${query}` : ''}`)
|
||||||
|
setPayload(body)
|
||||||
|
setRows(buildRows(body))
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Unable to load scores.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load()
|
||||||
|
}, [requestedSchoolYear, requestedSemester])
|
||||||
|
|
||||||
|
const visibleRows = useMemo(() => {
|
||||||
|
const needle = tableSearch.trim().toLowerCase()
|
||||||
|
if (needle === '') return rows
|
||||||
|
return rows.filter((row) =>
|
||||||
|
`${row.school_id} ${row.firstname} ${row.lastname}`.toLowerCase().includes(needle),
|
||||||
|
)
|
||||||
|
}, [rows, tableSearch])
|
||||||
|
|
||||||
|
const subtitleParts = [
|
||||||
|
`Teacher: ${user?.name?.trim() || 'Teacher'}`,
|
||||||
|
classSectionName || (classSectionId > 0 ? `Section ${classSectionId}` : 'No class assigned'),
|
||||||
|
]
|
||||||
|
if (resolvedSemester) subtitleParts.push(`Current semester: ${resolvedSemester}`)
|
||||||
|
if (resolvedSchoolYear) subtitleParts.push(resolvedSchoolYear)
|
||||||
|
|
||||||
|
function updateRow(studentId: number, key: keyof TeacherScoresRow, value: string | boolean) {
|
||||||
|
setRows((current) =>
|
||||||
|
current.map((row) => (row.student_id === studentId ? { ...row, [key]: value } : row)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectCommentErrors(): string[] {
|
||||||
|
const errors: string[] = []
|
||||||
|
for (const row of rows) {
|
||||||
|
const ptapError = teacherScoreCommentError(row.ptap_comment, row.firstname)
|
||||||
|
if (ptapError) errors.push(ptapError)
|
||||||
|
if (semesterKey) {
|
||||||
|
const termError = teacherScoreCommentError(row.term_comment, row.firstname)
|
||||||
|
if (termError) errors.push(termError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMissingOkPayload(): Record<string, Record<string, number>> {
|
||||||
|
const missingOk: Record<string, Record<string, number>> = {}
|
||||||
|
for (const row of rows) {
|
||||||
|
const next: Record<string, number> = {}
|
||||||
|
if (row.ptap_missing_ok) next.ptap_comment = 1
|
||||||
|
if (termCommentOverrideKey && row.term_missing_ok) next[termCommentOverrideKey] = 1
|
||||||
|
if (Object.keys(next).length > 0) {
|
||||||
|
missingOk[String(row.student_id)] = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return missingOk
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveComments(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (!classSectionId || !resolvedSemester || !resolvedSchoolYear) return
|
||||||
|
const validationErrors = collectCommentErrors()
|
||||||
|
if (validationErrors.length > 0) {
|
||||||
|
setWarningLines(validationErrors)
|
||||||
|
setError('Please fix the comment validation errors before saving.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
setSuccess(null)
|
||||||
|
setWarningLines([])
|
||||||
|
|
||||||
|
const comments: Record<string, Record<string, string>> = {}
|
||||||
|
for (const row of rows) {
|
||||||
|
comments[String(row.student_id)] = {
|
||||||
|
ptap: row.ptap_comment,
|
||||||
|
}
|
||||||
|
if (semesterKey) {
|
||||||
|
comments[String(row.student_id)][semesterKey] = row.term_comment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiFetch('/api/v1/scores/comments', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
class_section_id: classSectionId,
|
||||||
|
semester: resolvedSemester,
|
||||||
|
school_year: resolvedSchoolYear,
|
||||||
|
comments,
|
||||||
|
missing_ok: buildMissingOkPayload(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
setSuccess('Comments saved.')
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
setError(formatApiErrors(err, 'Unable to save comments.'))
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitSemesterScores() {
|
||||||
|
if (!classSectionId || !resolvedSemester || !resolvedSchoolYear) return
|
||||||
|
if (!window.confirm('Once you submit, you cannot add any scores or comments. Do you want to continue?')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const invalidEntries: string[] = []
|
||||||
|
const missingEntries: string[] = []
|
||||||
|
for (const row of rows) {
|
||||||
|
const studentName = `${row.firstname} ${row.lastname}`.trim() || `Student #${row.student_id}`
|
||||||
|
const rowInvalid: string[] = []
|
||||||
|
const rowMissing: string[] = []
|
||||||
|
|
||||||
|
const scoreChecks: Array<[string, string]> = [
|
||||||
|
['Homework Avg', row.homework],
|
||||||
|
['Project Avg', row.project],
|
||||||
|
['Test/Quiz Avg', row.quiz],
|
||||||
|
['Participation', row.participation],
|
||||||
|
]
|
||||||
|
if (semesterKey) scoreChecks.push([termLabel, row.term_score])
|
||||||
|
|
||||||
|
for (const [label, value] of scoreChecks) {
|
||||||
|
if (value.trim() === '') rowMissing.push(label)
|
||||||
|
else if (!teacherScoreInRange(value)) rowInvalid.push(label)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.ptap_comment.trim() === '' && !row.ptap_missing_ok) {
|
||||||
|
rowMissing.push('PTAP Comment')
|
||||||
|
}
|
||||||
|
if (semesterKey && row.term_comment.trim() === '' && !row.term_missing_ok) {
|
||||||
|
rowMissing.push(termCommentLabel)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ptapError = teacherScoreCommentError(row.ptap_comment, row.firstname)
|
||||||
|
if (ptapError) rowInvalid.push('PTAP Comment')
|
||||||
|
if (semesterKey) {
|
||||||
|
const termError = teacherScoreCommentError(row.term_comment, row.firstname)
|
||||||
|
if (termError) rowInvalid.push(termCommentLabel)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rowInvalid.length > 0) invalidEntries.push(`${studentName}: ${rowInvalid.join(', ')}`)
|
||||||
|
if (rowMissing.length > 0) missingEntries.push(`${studentName}: ${rowMissing.join(', ')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invalidEntries.length > 0 || missingEntries.length > 0) {
|
||||||
|
const lines: string[] = []
|
||||||
|
if (invalidEntries.length > 0) {
|
||||||
|
lines.push('Scores/comments are invalid for:')
|
||||||
|
lines.push(...invalidEntries.map((item) => `- ${item}`))
|
||||||
|
}
|
||||||
|
if (missingEntries.length > 0) {
|
||||||
|
lines.push('Missing entries (fill or check "Missing ok"):')
|
||||||
|
lines.push(...missingEntries.map((item) => `- ${item}`))
|
||||||
|
}
|
||||||
|
setWarningLines(lines)
|
||||||
|
setError('Please fix the missing or invalid entries before submitting.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLocking(true)
|
||||||
|
setError(null)
|
||||||
|
setSuccess(null)
|
||||||
|
setWarningLines([])
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiFetch('/api/v1/scores/lock', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
class_section_id: classSectionId,
|
||||||
|
semester: resolvedSemester,
|
||||||
|
school_year: resolvedSchoolYear,
|
||||||
|
missing_ok: buildMissingOkPayload(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
setSuccess('Semester scores submitted and locked.')
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
setError(formatApiErrors(err, 'Unable to submit semester scores.'))
|
||||||
|
} finally {
|
||||||
|
setLocking(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TeacherShell title="Students Class Scores" subtitle={subtitleParts.join(' · ')} loading={loading} error={error}>
|
||||||
|
{success ? <div className="alert alert-success text-center">{success}</div> : null}
|
||||||
|
{warningLines.length > 0 ? (
|
||||||
|
<div className="alert alert-danger" role="alert">
|
||||||
|
<p className="mb-2 text-center fw-semibold">
|
||||||
|
Please fix the score/comment issues below before continuing.
|
||||||
|
</p>
|
||||||
|
<pre className="mb-0 small">{warningLines.join('\n')}</pre>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{scoresLocked ? (
|
||||||
|
<div className="alert alert-warning text-center">Scores are submitted and locked for this semester.</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="text-center mb-3">
|
||||||
|
<Link to="/app/teacher/select-semester" className="btn btn-outline-secondary btn-sm">
|
||||||
|
Choose another semester
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!loading && rows.length === 0 ? (
|
||||||
|
<div className="alert alert-info">
|
||||||
|
No students or score rows are available for this class and semester.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!loading && rows.length > 0 ? (
|
||||||
|
<form onSubmit={(event) => void saveComments(event)}>
|
||||||
|
<div className="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-3">
|
||||||
|
<div className="small text-muted">
|
||||||
|
{classSectionName || `Section ${classSectionId || '—'}`} · {resolvedSemester || '—'} · {resolvedSchoolYear || '—'}
|
||||||
|
</div>
|
||||||
|
<div className="d-flex align-items-center gap-2">
|
||||||
|
<label className="mb-0">Search:</label>
|
||||||
|
<input
|
||||||
|
className="form-control form-control-sm"
|
||||||
|
style={{ width: 220 }}
|
||||||
|
value={tableSearch}
|
||||||
|
onChange={(event) => setTableSearch(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-bordered table-striped w-100 align-middle">
|
||||||
|
<thead className="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>First Name</th>
|
||||||
|
<th>Last Name</th>
|
||||||
|
<th className="text-center">Attendance</th>
|
||||||
|
<th className="text-center">Homework Avg</th>
|
||||||
|
<th className="text-center">Project Avg</th>
|
||||||
|
<th className="text-center">Test/Quiz Avg</th>
|
||||||
|
<th className="text-center">Participation</th>
|
||||||
|
<th className="text-center">PTAP</th>
|
||||||
|
<th>PTAP Comment</th>
|
||||||
|
<th>PTAP Comment Review</th>
|
||||||
|
{semesterKey ? <th className="text-center">{termLabel}</th> : null}
|
||||||
|
{semesterKey ? <th>{termCommentLabel}</th> : null}
|
||||||
|
{semesterKey ? <th>{termCommentLabel} Review</th> : null}
|
||||||
|
<th className="text-center">Semester Score</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{visibleRows.map((row) => (
|
||||||
|
<tr key={row.student_id}>
|
||||||
|
<td>{row.firstname || '—'}</td>
|
||||||
|
<td>{row.lastname || '—'}</td>
|
||||||
|
<td className={`text-center ${row.attendance ? '' : 'bg-light text-muted'}`}>{row.attendance || '—'}</td>
|
||||||
|
<td className={`text-center ${row.homework ? '' : 'bg-light text-muted'}`}>{row.homework || '—'}</td>
|
||||||
|
<td className={`text-center ${row.project ? '' : 'bg-light text-muted'}`}>{row.project || '—'}</td>
|
||||||
|
<td className={`text-center ${row.quiz ? '' : 'bg-light text-muted'}`}>{row.quiz || '—'}</td>
|
||||||
|
<td className={`text-center ${row.participation ? '' : 'bg-light text-muted'}`}>{row.participation || '—'}</td>
|
||||||
|
<td className={`text-center ${row.ptap ? '' : 'bg-light text-muted'}`}>{row.ptap || '—'}</td>
|
||||||
|
<td style={{ minWidth: 240 }}>
|
||||||
|
<textarea
|
||||||
|
className={`form-control form-control-sm ${row.ptap_comment.trim() === '' ? 'bg-light' : ''}`}
|
||||||
|
rows={3}
|
||||||
|
placeholder="PTAP Comment"
|
||||||
|
value={row.ptap_comment}
|
||||||
|
disabled={scoresLocked}
|
||||||
|
onChange={(event) => updateRow(row.student_id, 'ptap_comment', event.target.value)}
|
||||||
|
/>
|
||||||
|
{row.ptap_comment.trim() === '' ? (
|
||||||
|
<label className="form-check-label small mt-1 d-inline-flex align-items-center gap-2">
|
||||||
|
<input
|
||||||
|
className="form-check-input mt-0"
|
||||||
|
type="checkbox"
|
||||||
|
checked={row.ptap_missing_ok}
|
||||||
|
disabled={scoresLocked}
|
||||||
|
onChange={(event) => updateRow(row.student_id, 'ptap_missing_ok', event.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Missing ok</span>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
<div className="small text-muted text-end">{row.ptap_comment.trim().length} / 350</div>
|
||||||
|
</td>
|
||||||
|
<td className={row.ptap_comment_review.trim() ? '' : 'bg-light text-muted'} style={{ minWidth: 180, whiteSpace: 'pre-wrap' }}>
|
||||||
|
{row.ptap_comment_review || '—'}
|
||||||
|
</td>
|
||||||
|
{semesterKey ? (
|
||||||
|
<td className={`text-center ${row.term_score ? '' : 'bg-light text-muted'}`}>{row.term_score || '—'}</td>
|
||||||
|
) : null}
|
||||||
|
{semesterKey ? (
|
||||||
|
<td style={{ minWidth: 240 }}>
|
||||||
|
<textarea
|
||||||
|
className={`form-control form-control-sm ${row.term_comment.trim() === '' ? 'bg-light' : ''}`}
|
||||||
|
rows={3}
|
||||||
|
placeholder={termCommentLabel}
|
||||||
|
value={row.term_comment}
|
||||||
|
disabled={scoresLocked}
|
||||||
|
onChange={(event) => updateRow(row.student_id, 'term_comment', event.target.value)}
|
||||||
|
/>
|
||||||
|
{row.term_comment.trim() === '' ? (
|
||||||
|
<label className="form-check-label small mt-1 d-inline-flex align-items-center gap-2">
|
||||||
|
<input
|
||||||
|
className="form-check-input mt-0"
|
||||||
|
type="checkbox"
|
||||||
|
checked={row.term_missing_ok}
|
||||||
|
disabled={scoresLocked}
|
||||||
|
onChange={(event) => updateRow(row.student_id, 'term_missing_ok', event.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Missing ok</span>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
<div className="small text-muted text-end">{row.term_comment.trim().length} / 350</div>
|
||||||
|
</td>
|
||||||
|
) : null}
|
||||||
|
{semesterKey ? (
|
||||||
|
<td className={row.term_comment_review.trim() ? '' : 'bg-light text-muted'} style={{ minWidth: 180, whiteSpace: 'pre-wrap' }}>
|
||||||
|
{row.term_comment_review || '—'}
|
||||||
|
</td>
|
||||||
|
) : null}
|
||||||
|
<td className={`text-center ${row.semester_score ? '' : 'bg-light text-muted'}`}>{row.semester_score || '—'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{visibleRows.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={semesterKey ? 13 : 10} className="text-center text-muted">
|
||||||
|
No students match the current search.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="d-flex flex-wrap gap-2 mt-3">
|
||||||
|
<Link to={`/app/teacher/add-homework?class_section_id=${classSectionId}`} className="btn btn-secondary">
|
||||||
|
Add Homework
|
||||||
|
</Link>
|
||||||
|
<Link to={`/app/teacher/add-quiz?class_section_id=${classSectionId}`} className="btn btn-secondary">
|
||||||
|
Add Quiz
|
||||||
|
</Link>
|
||||||
|
<Link to={`/app/teacher/add-project?class_section_id=${classSectionId}`} className="btn btn-secondary">
|
||||||
|
Add Project
|
||||||
|
</Link>
|
||||||
|
<Link to={`/app/teacher/add-participation?class_section_id=${classSectionId}`} className="btn btn-secondary">
|
||||||
|
Add Participation
|
||||||
|
</Link>
|
||||||
|
{semesterKey === 'midterm' ? (
|
||||||
|
<Link to={`/app/teacher/add-midterm-exam?class_section_id=${classSectionId}`} className="btn btn-secondary">
|
||||||
|
Add Midterm
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
{semesterKey === 'final' ? (
|
||||||
|
<Link to={`/app/teacher/add-final-exam?class_section_id=${classSectionId}`} className="btn btn-secondary">
|
||||||
|
Add Final
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<button type="submit" className="btn btn-info" disabled={scoresLocked || saving || locking}>
|
||||||
|
{saving ? 'Saving…' : 'Save Comments'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline-danger"
|
||||||
|
disabled={scoresLocked || saving || locking}
|
||||||
|
onClick={() => void submitSemesterScores()}
|
||||||
|
>
|
||||||
|
{scoresLocked ? 'Scores Locked' : locking ? 'Submitting…' : 'Submit Semester Scores'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
</TeacherShell>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `teacher/showupdate_attendance.php` */
|
/** `teacher/showupdate_attendance.php` */
|
||||||
|
|||||||
Reference in New Issue
Block a user