From ad7b13f4d8681cb0938767720f281b8e218b5509 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 4 Jun 2026 13:25:41 -0400 Subject: [PATCH] fix exam grading --- src/App.tsx | 34 ++ src/api/grading.ts | 43 ++ src/api/rolePermission.ts | 12 +- src/api/session.ts | 26 +- src/api/types.ts | 17 +- src/lib/ciSpaPaths.ts | 3 + src/lib/viewRouteLookup.ts | 3 + src/pages/AdministratorToolPages.tsx | 63 ++- .../BelowSixtyDecisionEmailEditorPage.tsx | 139 +++++ src/pages/grading/BelowSixtyPage.tsx | 12 +- src/pages/grading/GradingDecisionsPage.tsx | 528 ++++++++++++++++++ src/pages/grading/GradingMainPage.tsx | 2 + src/pages/grading/HomeworkTrackingPage.tsx | 2 +- src/pages/grading/ParticipationPage.tsx | 10 +- src/pages/grading/gradingPaths.ts | 2 + src/pages/grading/index.ts | 2 + src/pages/rolePermission/EditRolePage.tsx | 1 - 17 files changed, 853 insertions(+), 46 deletions(-) create mode 100644 src/pages/grading/BelowSixtyDecisionEmailEditorPage.tsx create mode 100644 src/pages/grading/GradingDecisionsPage.tsx diff --git a/src/App.tsx b/src/App.tsx index a759695..4c55600 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -368,6 +368,9 @@ const IncidentAnalysisPage = lazy(() => const GradingMainPage = lazy(() => import('./pages/grading/GradingMainPage').then((m) => ({ default: m.GradingMainPage })), ) +const GradingDecisionsPage = lazy(() => + import('./pages/grading/GradingDecisionsPage').then((m) => ({ default: m.GradingDecisionsPage })), +) const BelowSixtyPage = lazy(() => import('./pages/grading/BelowSixtyPage').then((m) => ({ default: m.BelowSixtyPage })), ) @@ -376,6 +379,11 @@ const BelowSixtyEmailEditorPage = lazy(() => default: m.BelowSixtyEmailEditorPage, })), ) +const BelowSixtyDecisionEmailEditorPage = lazy(() => + import('./pages/grading/BelowSixtyDecisionEmailEditorPage').then((m) => ({ + default: m.BelowSixtyDecisionEmailEditorPage, + })), +) const HomeworkTrackingPage = lazy(() => import('./pages/grading/HomeworkTrackingPage').then((m) => ({ default: m.HomeworkTrackingPage })), ) @@ -1100,6 +1108,19 @@ export default function App() { element={} /> } /> + } /> + } /> + } /> + } /> + } /> + } + /> + } + /> } /> } /> } /> } /> + } /> + } /> + } /> + } /> + } /> + } + /> + } + /> } /> } /> } /> diff --git a/src/api/grading.ts b/src/api/grading.ts index 74ccade..9e5ba13 100644 --- a/src/api/grading.ts +++ b/src/api/grading.ts @@ -123,6 +123,49 @@ export async function fetchBelowSixty(searchParams: URLSearchParams): Promise { + return apiFetch(`${BASE}/below-sixty/decisions${q(searchParams)}`) +} + +export async function postBelowSixtyDecision(body: Record): Promise { + return apiFetch(`${BASE}/below-sixty/decisions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +export async function fetchBelowSixtyDecisionDetails( + searchParams: URLSearchParams, +): Promise { + return apiFetch(`${BASE}/below-sixty/decisions/details${q(searchParams)}`) +} + +export async function fetchBelowSixtyDecisionEmailEditor( + searchParams: URLSearchParams, +): Promise<{ + student_id?: number + student_name?: string + school_year?: string + semester?: string + decision?: string + subject?: string + html?: string + year_score?: number | string | null +}> { + return apiFetch(`${BASE}/below-sixty/decisions/email${q(searchParams)}`) +} + +export async function postBelowSixtyDecisionEmail(body: Record): Promise { + return apiFetch(`${BASE}/below-sixty/decisions/email`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) +} + export async function fetchBelowSixtyEmailEditor(searchParams: URLSearchParams): Promise<{ studentId?: number studentName?: string diff --git a/src/api/rolePermission.ts b/src/api/rolePermission.ts index 562f0c2..f152e10 100644 --- a/src/api/rolePermission.ts +++ b/src/api/rolePermission.ts @@ -6,10 +6,14 @@ import { ApiHttpError, apiFetch } from './http' const BASE = '/api/v1/role-permission' -function unwrapData(body: T): T { - if (!body || typeof body !== 'object') return body - const maybeEnvelope = body as { data?: unknown } - return (maybeEnvelope.data as T | undefined) ?? body +type DataEnvelope = { data?: T } + +function unwrapData(body: T | DataEnvelope): T { + if (body && typeof body === 'object' && 'data' in body) { + const data = (body as DataEnvelope).data + if (data !== undefined) return data + } + return body as T } export type RoleRow = { diff --git a/src/api/session.ts b/src/api/session.ts index 5dab829..85cb776 100644 --- a/src/api/session.ts +++ b/src/api/session.ts @@ -99,7 +99,7 @@ export async function loginRequest( email: string, password: string, ): Promise { - const body = await apiFetch( + const body = await apiFetch( '/api/v1/auth/login', { method: 'POST', @@ -109,7 +109,11 @@ export async function loginRequest( { attachAuth: false }, ) - if (body.status === false) { + if (!body || typeof body !== 'object' || !('status' in body)) { + throw new Error('Invalid login response from API.') + } + + if ((body as { status?: boolean }).status === false) { const fail = body as LoginFailure return { status: false, message: fail.message } } @@ -1465,6 +1469,24 @@ export async function submitFamilyLegacyImport(formData: FormData): Promise { + return apiFetch('/api/v1/families/import-legacy-second-parents', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) +} + export async function fetchPaypalTransactions(params?: { q?: string perPage?: number diff --git a/src/api/types.ts b/src/api/types.ts index 0a34946..6629c8e 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -931,15 +931,22 @@ export type TeacherSubmissionTeacherRow = { role_key?: string | null } +export type TeacherSubmissionStatus = { + label?: string | null + badge?: string | null + detail?: string | null + completed?: boolean +} + export type TeacherSubmissionReportRow = { class_section?: string | null class_section_id?: number teachers?: TeacherSubmissionTeacherRow[] - midterm_score_status?: string | null - midterm_comment_status?: string | null - participation_status?: string | null - ptap_comment_status?: string | null - attendance_status?: string | null + midterm_score_status?: TeacherSubmissionStatus | null + midterm_comment_status?: TeacherSubmissionStatus | null + participation_status?: TeacherSubmissionStatus | null + ptap_comment_status?: TeacherSubmissionStatus | null + attendance_status?: TeacherSubmissionStatus | null missing_items?: string[] student_count?: number } diff --git a/src/lib/ciSpaPaths.ts b/src/lib/ciSpaPaths.ts index 1b6e78d..348a32a 100644 --- a/src/lib/ciSpaPaths.ts +++ b/src/lib/ciSpaPaths.ts @@ -198,7 +198,10 @@ export function spaPathFromCiUrl(url: string | null | undefined): string | null 'emails/parent_email_extractor': '/app/administrator/parent-email-extractor', 'emails/broadcast': '/app/administrator/email-templates', 'grading/grading_main': '/app/grading', + 'grading/all_decisions': '/app/grading/decisions', 'grading/below_sixty': '/app/grading/below-60', + 'grading/below_sixty_decision_email_editor': '/app/grading/below-60/decisions/email-editor', + 'grading/below_sixty_decisions': '/app/grading/below-60/decisions', 'grading/below_sixty_email_editor': '/app/grading/below-60/email-editor', 'grading/homework_tracking': '/app/grading/homework-tracking', 'grading/participation': '/app/grading/participation', diff --git a/src/lib/viewRouteLookup.ts b/src/lib/viewRouteLookup.ts index 3ff7490..cd76a23 100644 --- a/src/lib/viewRouteLookup.ts +++ b/src/lib/viewRouteLookup.ts @@ -13,7 +13,10 @@ const VIEW_REGISTRY_APP_PATH_OVERRIDES: Record = { 'flags/processed_flags': '/app/administrator/flags/processed', 'flags/incident_analysis': '/app/administrator/flags/incident-analysis', 'grading/grading_main': '/app/grading', + 'grading/all_decisions': '/app/grading/decisions', 'grading/below_sixty': '/app/grading/below-60', + 'grading/below_sixty_decision_email_editor': '/app/grading/below-60/decisions/email-editor', + 'grading/below_sixty_decisions': '/app/grading/below-60/decisions', 'grading/below_sixty_email_editor': '/app/grading/below-60/email-editor', 'grading/comments': '/app/grading/scores/comments', 'grading/final': '/app/grading/scores/final', diff --git a/src/pages/AdministratorToolPages.tsx b/src/pages/AdministratorToolPages.tsx index 929abe4..197c11a 100644 --- a/src/pages/AdministratorToolPages.tsx +++ b/src/pages/AdministratorToolPages.tsx @@ -1299,7 +1299,7 @@ export function AdminClassAssignmentPage() { const [schoolYears, setSchoolYears] = useState([]) const [selectedYear, setSelectedYear] = useState('') const [loading, setLoading] = useState(true) - const [message, setMessage] = useState(null) + const [message] = useState(null) const [error, setError] = useState(null) const [selectedSemester, setSelectedSemester] = useState('') const [expandedSections, setExpandedSections] = useState>(new Set(['0'])) @@ -1818,17 +1818,6 @@ export function AdminDailyAttendancePage() { })), ) }, [normalizedSearch, sections]) - const studentSuggestions = useMemo(() => { - const seen = new Set() - return sections.flatMap((section) => - section.students.flatMap((student) => { - const label = `${student.schoolId} - ${student.name}${section.className ? ` (${section.className})` : ''}` - if (seen.has(label)) return [] - seen.add(label) - return [label] - }), - ) - }, [sections]) const selectedSearchMatch = searchMatches[0] ?? null const displayedGrade = gradeGroups.find((group) => group.key === activeGradeKey) ?? gradeGroups[0] const gradeSections = displayedGrade?.sections ?? [] @@ -1984,7 +1973,9 @@ export function AdminDailyAttendancePage() { if (!current) return current const secId = String(section.sectionId) const stuId = String(student.id) - const prevEntries: typeof current.attendance_data = JSON.parse(JSON.stringify(current.attendance_data ?? {})) + const prevEntries = JSON.parse( + JSON.stringify(current.attendance_data ?? {}), + ) as NonNullable if (!prevEntries[secId]) prevEntries[secId] = {} const existing = (prevEntries[secId][stuId] ?? []).filter((e) => String(e.date ?? '').slice(0, 10) !== date) prevEntries[secId][stuId] = [ @@ -2457,7 +2448,6 @@ export function AdminDailyAttendanceAnalysisPage() { export function AdminExamDraftsPage() { const [data, setData] = useState> | null>(null) - const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [message, setMessage] = useState(null) const [activeTab, setActiveTab] = useState<'submissions' | 'legacy'>('submissions') @@ -2471,7 +2461,6 @@ export function AdminExamDraftsPage() { const [reviewState, setReviewState] = useState>({}) async function load() { - setLoading(true) try { const result = await fetchExamDraftAdminData() setData(result) @@ -2483,8 +2472,6 @@ export function AdminExamDraftsPage() { setError(null) } catch (e) { setError(e instanceof Error ? e.message : 'Unable to load exam drafts.') - } finally { - setLoading(false) } } @@ -3324,7 +3311,15 @@ export function AdminManageUsersPage() { {id} {name || '—'} {String(user.email ?? '—')} - {(row.roles ?? []).map((r) => String(r.name ?? '')).filter(Boolean).join(', ') || '—'} + + {(row.roles ?? []) + .map((role) => { + if (typeof role === 'string') return role + return String(role.name ?? role.slug ?? '') + }) + .filter(Boolean) + .join(', ') || '—'} + ) @@ -3594,13 +3589,27 @@ export function AdminPaypalTransactionsPage() { ) } -function submissionBadgeClass(status?: string | null) { - const normalized = String(status ?? '').toLowerCase() - if (normalized === 'submitted') return 'bg-success' - if (normalized === 'partial') return 'bg-warning text-dark' +function submissionBadgeClass(status?: { + label?: string | null + badge?: string | null + completed?: boolean +} | null) { + const badge = String(status?.badge ?? '').trim() + if (badge !== '') return badge + if (status?.completed === true) return 'bg-success' + if (status?.completed === false) return 'bg-danger' return 'bg-secondary' } +function submissionBadgeLabel(status?: { + label?: string | null + detail?: string | null +} | null) { + const label = String(status?.label ?? '').trim() || 'Missing' + const detail = String(status?.detail ?? '').trim() + return detail !== '' ? `${label} (${detail})` : label +} + export function AdminRemovedStudentsPage() { const [schoolYear, setSchoolYear] = useState('') const [activeStudents, setActiveStudents] = useState([]) @@ -4830,11 +4839,11 @@ export function AdminTeacherSubmissionsPage() { {row.class_section ?? '—'} {(row.teachers ?? []).map((teacher) => teacher.label).join(', ') || '—'} - {row.midterm_score_status ?? 'missing'} - {row.midterm_comment_status ?? 'missing'} - {row.participation_status ?? 'missing'} - {row.ptap_comment_status ?? 'missing'} - {row.attendance_status ?? 'missing'} + {submissionBadgeLabel(row.midterm_score_status)} + {submissionBadgeLabel(row.midterm_comment_status)} + {submissionBadgeLabel(row.participation_status)} + {submissionBadgeLabel(row.ptap_comment_status)} + {submissionBadgeLabel(row.attendance_status)} {(row.missing_items ?? []).join(', ') || '—'} {row.student_count ?? 0} diff --git a/src/pages/grading/BelowSixtyDecisionEmailEditorPage.tsx b/src/pages/grading/BelowSixtyDecisionEmailEditorPage.tsx new file mode 100644 index 0000000..e31b62b --- /dev/null +++ b/src/pages/grading/BelowSixtyDecisionEmailEditorPage.tsx @@ -0,0 +1,139 @@ +import { type FormEvent, useEffect, useState } from 'react' +import { Link, useSearchParams } from 'react-router-dom' +import { ApiHttpError } from '../../api/http' +import { + fetchBelowSixtyDecisionEmailEditor, + postBelowSixtyDecisionEmail, +} from '../../api/grading' +import { GRADING_DECISIONS_PATH } from './gradingPaths' + +export function BelowSixtyDecisionEmailEditorPage() { + const [searchParams] = useSearchParams() + const studentId = searchParams.get('student_id') ?? '' + const schoolYear = searchParams.get('school_year') ?? '' + + const [html, setHtml] = useState('') + const [subjectLine, setSubjectLine] = useState('') + const [studentName, setStudentName] = useState('') + const [decision, setDecision] = useState('') + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(null) + + useEffect(() => { + if (!studentId || !schoolYear) { + setLoading(false) + return + } + let cancelled = false + setLoading(true) + fetchBelowSixtyDecisionEmailEditor(searchParams) + .then((payload) => { + if (cancelled) return + setStudentName(payload.student_name ?? '') + setDecision(payload.decision ?? '') + setSubjectLine(payload.subject ?? '') + setHtml(payload.html ?? '') + setError(null) + }) + .catch((e: unknown) => { + if (!cancelled) { + setError(e instanceof ApiHttpError ? e.message : 'Unable to load the decision email editor.') + } + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + return () => { + cancelled = true + } + }, [schoolYear, searchParams, studentId]) + + async function onSubmit(event: FormEvent) { + event.preventDefault() + setSaving(true) + setError(null) + setSuccess(null) + try { + await postBelowSixtyDecisionEmail({ + student_id: Number(studentId), + school_year: schoolYear, + subject: subjectLine || undefined, + html, + }) + setSuccess('Decision email sent.') + } catch (e: unknown) { + setError(e instanceof ApiHttpError ? e.message : 'Unable to send the decision email.') + } finally { + setSaving(false) + } + } + + const backHref = `${GRADING_DECISIONS_PATH}?school_year=${encodeURIComponent(schoolYear)}` + + if (!studentId || !schoolYear) { + return ( +
+
Missing student_id or school_year.
+ + Back to Decisions + +
+ ) + } + + return ( +
+
+
+

Send Decision Email

+
+ {studentName || 'Student'} • {schoolYear} + {decision ? ` — ${decision}` : ''} +
+
+ + Back to Decisions + +
+ + {error ?
{error}
: null} + {success ?
{success}
: null} + {loading ?

Loading…

: null} + + {!loading ? ( +
void onSubmit(event)} className="card shadow-sm"> +
+
+ + setSubjectLine(event.target.value)} + /> +
+
+ +