fix teacher page
This commit is contained in:
@@ -6,7 +6,7 @@ import { TeacherShell } from './TeacherShell'
|
||||
import { useTeacherResource } from './useTeacherResource'
|
||||
import { postTeacherMultipart } from '../../api/teacherSession'
|
||||
import { useAuth } from '../../auth/AuthProvider'
|
||||
import { apiFetch } from '../../api/http'
|
||||
import { ApiHttpError, apiFetch } from '../../api/http'
|
||||
|
||||
function TeacherApiDumpPage({
|
||||
title,
|
||||
@@ -117,6 +117,132 @@ function teacherExamFilePath(kind: 'teacher' | 'final', filename?: string | 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` */
|
||||
export function TeacherCompetitionScoresIndexPage() {
|
||||
const { data, loading, error } = useTeacherResource<CompetitionIndexPayload>('/competition-scores')
|
||||
@@ -221,7 +347,262 @@ export function TeacherCompetitionScoresDetailPage() {
|
||||
|
||||
/** `teacher/absence_vacation.php` */
|
||||
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` */
|
||||
@@ -987,10 +1368,484 @@ export function TeacherHomeworkListPage() {
|
||||
|
||||
/** `teacher/scores.php` */
|
||||
export function TeacherScoresPage() {
|
||||
const { user } = useAuth()
|
||||
const [search] = useSearchParams()
|
||||
const semester = search.get('semester')
|
||||
const q = semester ? `?semester=${encodeURIComponent(semester)}` : ''
|
||||
return <TeacherApiDumpPage title="Scores" path={`/scores${q}`} />
|
||||
const requestedSemester = search.get('semester')?.trim() ?? ''
|
||||
const requestedSchoolYear = search.get('school_year')?.trim() ?? ''
|
||||
|
||||
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` */
|
||||
|
||||
Reference in New Issue
Block a user