diff --git a/src/pages/teacher/TeacherRestPages.tsx b/src/pages/teacher/TeacherRestPages.tsx index 4e53230..01a4cbe 100644 --- a/src/pages/teacher/TeacherRestPages.tsx +++ b/src/pages/teacher/TeacherRestPages.tsx @@ -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 + comments?: Record | 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 | 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('/competition-scores') @@ -221,7 +347,262 @@ export function TeacherCompetitionScoresDetailPage() { /** `teacher/absence_vacation.php` */ export function TeacherAbsenceVacationPage() { - return + const [payload, setPayload] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [message, setMessage] = useState(null) + const [submitting, setSubmitting] = useState(false) + const [selectedDates, setSelectedDates] = useState([]) + const [reasonType, setReasonType] = useState('vacation') + const [reason, setReason] = useState('') + + async function load() { + try { + setLoading(true) + setError(null) + const body = await apiFetch('/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) { + 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('/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 ( + + {message ?
{message}
: null} + +
+
+ Submit Upcoming Absences +
+
+
void submit(event)}> +
+ +
+ {hasDates ? ( + selectedDates.map((value, index) => ( +
+ + + {index === 0 ? ( + + ) : ( + + )} +
+ )) + ) : ( +
+ No future Sundays are available in the current term. +
+ )} +
+
Future Sundays from September to May are available only.
+
+ +
+
+ + +
+ +
+ + setReason(event.target.value)} + placeholder="Provide a reason" + required + /> +
+
+ +
+ +
+
+
+
+ +
+
+ Your Reported Status (This Term) +
+
+
+ + + + + + + + + + + {(payload?.existing ?? []).length > 0 ? ( + (payload?.existing ?? []).map((row, index) => { + const status = String(row.status ?? '').trim().toLowerCase() + return ( + + + + + + + ) + }) + ) : ( + + + + )} + +
+ # + DateStatusReason
{index + 1}{formatTeacherAbsenceDate(row.date)} + + {status ? `${status.charAt(0).toUpperCase()}${status.slice(1)}` : 'N/A'} + + {row.reason ?? ''}
+ No reported entries yet. +
+
+
+
+
+ ) } /** `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 + const requestedSemester = search.get('semester')?.trim() ?? '' + const requestedSchoolYear = search.get('school_year')?.trim() ?? '' + + const [payload, setPayload] = useState(null) + const [rows, setRows] = useState([]) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [locking, setLocking] = useState(false) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(null) + const [warningLines, setWarningLines] = useState([]) + 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)['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(`/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> { + const missingOk: Record> = {} + for (const row of rows) { + const next: Record = {} + 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) { + 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> = {} + 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 ( + + {success ?
{success}
: null} + {warningLines.length > 0 ? ( +
+

+ Please fix the score/comment issues below before continuing. +

+
{warningLines.join('\n')}
+
+ ) : null} + + {scoresLocked ? ( +
Scores are submitted and locked for this semester.
+ ) : null} + +
+ + Choose another semester + +
+ + {!loading && rows.length === 0 ? ( +
+ No students or score rows are available for this class and semester. +
+ ) : null} + + {!loading && rows.length > 0 ? ( +
void saveComments(event)}> +
+
+ {classSectionName || `Section ${classSectionId || '—'}`} · {resolvedSemester || '—'} · {resolvedSchoolYear || '—'} +
+
+ + setTableSearch(event.target.value)} + /> +
+
+ +
+ + + + + + + + + + + + + + {semesterKey ? : null} + {semesterKey ? : null} + {semesterKey ? : null} + + + + + {visibleRows.map((row) => ( + + + + + + + + + +
First NameLast NameAttendanceHomework AvgProject AvgTest/Quiz AvgParticipationPTAPPTAP CommentPTAP Comment Review{termLabel}{termCommentLabel}{termCommentLabel} ReviewSemester Score
{row.firstname || '—'}{row.lastname || '—'}{row.attendance || '—'}{row.homework || '—'}{row.project || '—'}{row.quiz || '—'}{row.participation || '—'}{row.ptap || '—'} +