fix teacher page

This commit is contained in:
root
2026-06-04 19:26:30 -04:00
parent 1b0d9a36ad
commit dec023f03e
+860 -5
View File
@@ -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` */