fix exam grading

This commit is contained in:
root
2026-06-04 13:25:41 -04:00
parent 1af02dadd3
commit ad7b13f4d8
17 changed files with 853 additions and 46 deletions
+34
View File
@@ -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={<IncidentAnalysisPage />}
/>
<Route path="administrator/grading/main" element={<GradingMainPage />} />
<Route path="administrator/grading/decisions" element={<GradingDecisionsPage />} />
<Route path="administrator/grading/all-decisions" element={<GradingDecisionsPage />} />
<Route path="administrator/grading/below-sixty-decisions" element={<GradingDecisionsPage />} />
<Route path="administrator/grading/below-60/decisions" element={<GradingDecisionsPage />} />
<Route path="administrator/grading/below-sixty/decisions" element={<GradingDecisionsPage />} />
<Route
path="administrator/grading/below-60/decisions/email-editor"
element={<BelowSixtyDecisionEmailEditorPage />}
/>
<Route
path="administrator/grading/below-sixty/decisions/email-editor"
element={<BelowSixtyDecisionEmailEditorPage />}
/>
<Route path="administrator/grading/below-60" element={<BelowSixtyPage />} />
<Route
path="administrator/grading/below-60/email-editor"
@@ -1119,6 +1140,19 @@ export default function App() {
<Route path="administrator/grading/scores/:scoreType/:classSectionId" element={<GradingScoreEntryPage />} />
<Route path="administrator/grading/scores/:scoreType/:classSectionId/:studentId" element={<GradingScoreEntryPage />} />
<Route path="grading/below-60/email-editor" element={<BelowSixtyEmailEditorPage />} />
<Route path="grading/decisions" element={<GradingDecisionsPage />} />
<Route path="grading/all-decisions" element={<GradingDecisionsPage />} />
<Route path="grading/below-sixty-decisions" element={<GradingDecisionsPage />} />
<Route path="grading/below-60/decisions" element={<GradingDecisionsPage />} />
<Route path="grading/below-sixty/decisions" element={<GradingDecisionsPage />} />
<Route
path="grading/below-60/decisions/email-editor"
element={<BelowSixtyDecisionEmailEditorPage />}
/>
<Route
path="grading/below-sixty/decisions/email-editor"
element={<BelowSixtyDecisionEmailEditorPage />}
/>
<Route path="grading/below-60" element={<BelowSixtyPage />} />
<Route path="grading/below-sixty/email-editor" element={<BelowSixtyEmailEditorPage />} />
<Route path="grading/below-sixty" element={<BelowSixtyPage />} />
+43
View File
@@ -123,6 +123,49 @@ export async function fetchBelowSixty(searchParams: URLSearchParams): Promise<un
return apiFetch(`${BASE}/below-sixty${q(searchParams)}`)
}
export async function fetchBelowSixtyDecisions(
searchParams: URLSearchParams,
): Promise<unknown> {
return apiFetch(`${BASE}/below-sixty/decisions${q(searchParams)}`)
}
export async function postBelowSixtyDecision(body: Record<string, unknown>): Promise<unknown> {
return apiFetch(`${BASE}/below-sixty/decisions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
}
export async function fetchBelowSixtyDecisionDetails(
searchParams: URLSearchParams,
): Promise<unknown> {
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<string, unknown>): Promise<unknown> {
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
+8 -4
View File
@@ -6,10 +6,14 @@ import { ApiHttpError, apiFetch } from './http'
const BASE = '/api/v1/role-permission'
function unwrapData<T>(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<T> = { data?: T }
function unwrapData<T>(body: T | DataEnvelope<T>): T {
if (body && typeof body === 'object' && 'data' in body) {
const data = (body as DataEnvelope<T>).data
if (data !== undefined) return data
}
return body as T
}
export type RoleRow = {
+24 -2
View File
@@ -99,7 +99,7 @@ export async function loginRequest(
email: string,
password: string,
): Promise<LoginResponse> {
const body = await apiFetch<LoginResponse>(
const body = await apiFetch<unknown>(
'/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<Fami
return raw as FamilyLegacyImportResult
}
export async function importLegacySecondParents(): Promise<{
message?: string
data?: {
ok?: boolean
message?: string
created_users?: number
guardians_linked?: number
skipped?: number
total_source?: number
} | null
}> {
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
+12 -5
View File
@@ -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
}
+3
View File
@@ -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',
+3
View File
@@ -13,7 +13,10 @@ const VIEW_REGISTRY_APP_PATH_OVERRIDES: Record<string, string> = {
'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',
+36 -27
View File
@@ -1299,7 +1299,7 @@ export function AdminClassAssignmentPage() {
const [schoolYears, setSchoolYears] = useState<string[]>([])
const [selectedYear, setSelectedYear] = useState('')
const [loading, setLoading] = useState(true)
const [message, setMessage] = useState<string | null>(null)
const [message] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [selectedSemester, setSelectedSemester] = useState('')
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(['0']))
@@ -1818,17 +1818,6 @@ export function AdminDailyAttendancePage() {
})),
)
}, [normalizedSearch, sections])
const studentSuggestions = useMemo(() => {
const seen = new Set<string>()
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<typeof current.attendance_data>
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<Awaited<ReturnType<typeof fetchExamDraftAdminData>> | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [message, setMessage] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState<'submissions' | 'legacy'>('submissions')
@@ -2471,7 +2461,6 @@ export function AdminExamDraftsPage() {
const [reviewState, setReviewState] = useState<Record<number, { review_status: string; admin_comments: string; final_file: File | null }>>({})
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() {
<td>{id}</td>
<td>{name || '—'}</td>
<td>{String(user.email ?? '—')}</td>
<td>{(row.roles ?? []).map((r) => String(r.name ?? '')).filter(Boolean).join(', ') || '—'}</td>
<td>
{(row.roles ?? [])
.map((role) => {
if (typeof role === 'string') return role
return String(role.name ?? role.slug ?? '')
})
.filter(Boolean)
.join(', ') || '—'}
</td>
<td><button className="btn btn-sm btn-danger" type="button" onClick={() => void deleteUser(id).then(load)}>Delete</button></td>
</tr>
)
@@ -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<RemovedStudentRow[]>([])
@@ -4830,11 +4839,11 @@ export function AdminTeacherSubmissionsPage() {
</td>
<td>{row.class_section ?? '—'}</td>
<td>{(row.teachers ?? []).map((teacher) => teacher.label).join(', ') || '—'}</td>
<td><span className={`badge ${submissionBadgeClass(row.midterm_score_status)}`}>{row.midterm_score_status ?? 'missing'}</span></td>
<td><span className={`badge ${submissionBadgeClass(row.midterm_comment_status)}`}>{row.midterm_comment_status ?? 'missing'}</span></td>
<td><span className={`badge ${submissionBadgeClass(row.participation_status)}`}>{row.participation_status ?? 'missing'}</span></td>
<td><span className={`badge ${submissionBadgeClass(row.ptap_comment_status)}`}>{row.ptap_comment_status ?? 'missing'}</span></td>
<td><span className={`badge ${submissionBadgeClass(row.attendance_status)}`}>{row.attendance_status ?? 'missing'}</span></td>
<td><span className={`badge ${submissionBadgeClass(row.midterm_score_status)}`}>{submissionBadgeLabel(row.midterm_score_status)}</span></td>
<td><span className={`badge ${submissionBadgeClass(row.midterm_comment_status)}`}>{submissionBadgeLabel(row.midterm_comment_status)}</span></td>
<td><span className={`badge ${submissionBadgeClass(row.participation_status)}`}>{submissionBadgeLabel(row.participation_status)}</span></td>
<td><span className={`badge ${submissionBadgeClass(row.ptap_comment_status)}`}>{submissionBadgeLabel(row.ptap_comment_status)}</span></td>
<td><span className={`badge ${submissionBadgeClass(row.attendance_status)}`}>{submissionBadgeLabel(row.attendance_status)}</span></td>
<td>{(row.missing_items ?? []).join(', ') || '—'}</td>
<td>{row.student_count ?? 0}</td>
</tr>
@@ -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<string | null>(null)
const [success, setSuccess] = useState<string | null>(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<HTMLFormElement>) {
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 (
<div className="container mt-4">
<div className="alert alert-info">Missing <code>student_id</code> or <code>school_year</code>.</div>
<Link className="btn btn-secondary" to={backHref}>
Back to Decisions
</Link>
</div>
)
}
return (
<div className="container py-4">
<div className="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
<div>
<h2 className="mb-1">Send Decision Email</h2>
<div className="text-muted">
{studentName || 'Student'} {schoolYear}
{decision ? `${decision}` : ''}
</div>
</div>
<Link className="btn btn-outline-secondary btn-sm" to={backHref}>
Back to Decisions
</Link>
</div>
{error ? <div className="alert alert-danger">{error}</div> : null}
{success ? <div className="alert alert-success">{success}</div> : null}
{loading ? <p className="text-muted">Loading</p> : null}
{!loading ? (
<form onSubmit={(event) => void onSubmit(event)} className="card shadow-sm">
<div className="card-body">
<div className="mb-3">
<label className="form-label">Subject</label>
<input
type="text"
className="form-control"
value={subjectLine}
onChange={(event) => setSubjectLine(event.target.value)}
/>
</div>
<div className="mb-3">
<label className="form-label">Message (HTML)</label>
<textarea
className="form-control font-monospace"
rows={18}
value={html}
onChange={(event) => setHtml(event.target.value)}
/>
</div>
<div className="d-flex justify-content-end gap-2">
<Link className="btn btn-outline-secondary" to={backHref}>
Cancel
</Link>
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? 'Sending…' : 'Send Email'}
</button>
</div>
</div>
</form>
) : null}
</div>
)
}
+9 -3
View File
@@ -5,6 +5,7 @@ import { fetchBelowSixty, postBelowSixtyStatus } from '../../api/grading'
import { fetchGradingOverview } from '../../api/session'
import { AcademicFilterBar } from '../discounts/AcademicFilterBar'
import {
GRADING_DECISIONS_PATH,
GRADING_BELOW_SIXTY_EMAIL_EDITOR_PATH,
GRADING_PATH,
GRADING_SCHEDULE_MEETING_PATH,
@@ -179,9 +180,14 @@ export function BelowSixtyPage() {
{semester ? `${semester}` : ''}
{schoolYear}
</div>
<Link className="btn btn-outline-secondary btn-sm" to={`${GRADING_PATH}${qs ? `?${qs}` : ''}`}>
Back to Grading
</Link>
<div className="d-flex gap-2 flex-wrap">
<Link className="btn btn-outline-primary btn-sm" to={`${GRADING_DECISIONS_PATH}?school_year=${encodeURIComponent(schoolYear)}`}>
School Year Decisions
</Link>
<Link className="btn btn-outline-secondary btn-sm" to={`${GRADING_PATH}${qs ? `?${qs}` : ''}`}>
Back to Grading
</Link>
</div>
</div>
{error ? <div className="alert alert-danger">{error}</div> : null}
+528
View File
@@ -0,0 +1,528 @@
import { type FormEvent, useEffect, useMemo, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http'
import {
fetchBelowSixtyDecisionDetails,
fetchBelowSixtyDecisions,
postBelowSixtyDecision,
} from '../../api/grading'
import { fetchGradingOverview } from '../../api/session'
import {
GRADING_BELOW_SIXTY_DECISION_EMAIL_EDITOR_PATH,
GRADING_BELOW_SIXTY_PATH,
GRADING_PATH,
} from './gradingPaths'
type DecisionOption = '' | 'Pass' | 'Repeat Class' | 'Make-up exam in fall' | 'Deferred decision' | 'Expel' | 'Withdrawn'
type DecisionRow = {
student_id: number
school_id?: string | null
firstname?: string | null
lastname?: string | null
age?: number | string | null
class_section_name?: string | null
fall_score?: number | string | null
spring_score?: number | string | null
year_score?: number | string | null
decision?: string | null
decision_notes?: string | null
certificate_number?: string | null
}
type SemesterDetails = {
semester?: string
class_section_name?: string | null
homework_avg?: number | string | null
project_avg?: number | string | null
participation_score?: number | string | null
test_avg?: number | string | null
ptap_score?: number | string | null
attendance_score?: number | string | null
midterm_exam_score?: number | string | null
final_exam_score?: number | string | null
semester_score?: number | string | null
comments?: Record<string, string>
}
const DECISION_OPTIONS: Array<{ value: DecisionOption; label: string }> = [
{ value: '', label: '— No decision yet —' },
{ value: 'Pass', label: 'Pass' },
{ value: 'Repeat Class', label: 'Repeat Class' },
{ value: 'Make-up exam in fall', label: 'Make-up exam in fall' },
{ value: 'Deferred decision', label: 'Deferred decision' },
{ value: 'Expel', label: 'Expel' },
{ value: 'Withdrawn', label: 'Withdrawn' },
]
function displayScore(value: unknown): string {
if (value === null || value === undefined || value === '') return '—'
if (typeof value === 'number' && Number.isFinite(value)) return value.toFixed(2)
const numeric = Number(value)
return Number.isFinite(numeric) ? numeric.toFixed(2) : String(value)
}
function numericScore(value: unknown): number | null {
if (value === null || value === undefined || value === '') return null
const numeric = typeof value === 'number' ? value : Number(value)
return Number.isFinite(numeric) ? numeric : null
}
function normalizeRows(payload: unknown): DecisionRow[] {
if (Array.isArray(payload)) return payload as DecisionRow[]
if (payload && typeof payload === 'object' && 'rows' in payload) {
const rows = (payload as { rows?: unknown }).rows
if (Array.isArray(rows)) return rows as DecisionRow[]
}
return []
}
function decisionBadgeClass(decision: string): string | null {
switch (decision) {
case 'Pass':
return 'text-bg-success'
case 'Repeat Class':
case 'Expel':
return 'text-bg-danger'
case 'Make-up exam in fall':
case 'Deferred decision':
return 'text-bg-info'
case 'Withdrawn':
return 'text-bg-secondary'
default:
return null
}
}
function fieldText(value: unknown): string {
return String(value ?? '').trim()
}
export function GradingDecisionsPage() {
const [searchParams, setSearchParams] = useSearchParams()
const schoolYear = searchParams.get('school_year') ?? ''
const [filterYear, setFilterYear] = useState(schoolYear)
const [rows, setRows] = useState<DecisionRow[]>([])
const [loading, setLoading] = useState(true)
const [savingStudentId, setSavingStudentId] = useState<number | null>(null)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const [modalStudentName, setModalStudentName] = useState('')
const [modalSemesters, setModalSemesters] = useState<SemesterDetails[]>([])
const [modalLoading, setModalLoading] = useState(false)
const [modalError, setModalError] = useState<string | null>(null)
const [detailsOpen, setDetailsOpen] = useState(false)
useEffect(() => {
if (schoolYear) {
setFilterYear(schoolYear)
return
}
let cancelled = false
;(async () => {
try {
const overview = await fetchGradingOverview()
if (cancelled) return
const next = new URLSearchParams(searchParams)
if ((overview.school_year ?? '').trim() !== '') {
next.set('school_year', String(overview.school_year))
setSearchParams(next, { replace: true })
}
} catch (e) {
if (!cancelled) {
setError(e instanceof ApiHttpError ? e.message : 'Unable to determine the current school year.')
setLoading(false)
}
}
})()
return () => {
cancelled = true
}
}, [schoolYear, searchParams, setSearchParams])
useEffect(() => {
if (!schoolYear) {
setLoading(false)
return
}
let cancelled = false
setLoading(true)
fetchBelowSixtyDecisions(searchParams)
.then((payload) => {
if (cancelled) return
setRows(normalizeRows(payload))
setError(null)
})
.catch((e: unknown) => {
if (!cancelled) {
setError(e instanceof ApiHttpError ? e.message : 'Unable to load school year decisions.')
}
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [schoolYear, searchParams])
const metrics = useMemo(() => {
let pending = 0
let decided = 0
let urgent = 0
for (const row of rows) {
const decision = fieldText(row.decision)
const score = numericScore(row.year_score)
if (decision === '') pending += 1
else decided += 1
if (score !== null && score < 50) urgent += 1
}
return { total: rows.length, pending, decided, urgent }
}, [rows])
async function onSaveDecision(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const formData = new FormData(event.currentTarget)
const studentId = Number(formData.get('student_id') ?? 0)
setSavingStudentId(studentId)
setError(null)
setSuccess(null)
try {
await postBelowSixtyDecision({
student_id: studentId,
school_year: formData.get('school_year'),
semester: 'year',
decision: formData.get('decision'),
notes: formData.get('notes'),
})
const refreshed = await fetchBelowSixtyDecisions(searchParams)
setRows(normalizeRows(refreshed))
setSuccess('Decision saved.')
} catch (e: unknown) {
setError(e instanceof ApiHttpError ? e.message : 'Unable to save the decision.')
} finally {
setSavingStudentId(null)
}
}
async function openDetails(row: DecisionRow) {
const studentId = Number(row.student_id ?? 0)
if (!studentId || !schoolYear) return
setModalStudentName(`${fieldText(row.firstname)} ${fieldText(row.lastname)}`.trim() || 'Student')
setModalSemesters([])
setModalError(null)
setModalLoading(true)
setDetailsOpen(true)
const params = new URLSearchParams({
student_id: String(studentId),
school_year: schoolYear,
})
try {
const payload = await fetchBelowSixtyDecisionDetails(params)
const semesters =
payload && typeof payload === 'object' && 'semesters' in payload && Array.isArray((payload as { semesters?: unknown }).semesters)
? ((payload as { semesters: SemesterDetails[] }).semesters ?? [])
: []
setModalSemesters(semesters)
} catch (e: unknown) {
setModalError(e instanceof ApiHttpError ? e.message : 'Unable to load the student details.')
} finally {
setModalLoading(false)
}
}
return (
<div className="container-fluid py-3">
<div className="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3">
<div>
<h2 className="mb-1">School Year Decisions</h2>
<div className="text-muted">Whole Year {schoolYear || 'Current school year'}</div>
</div>
<div className="d-flex gap-2 flex-wrap">
<Link className="btn btn-outline-secondary btn-sm" to={`${GRADING_BELOW_SIXTY_PATH}?school_year=${encodeURIComponent(schoolYear)}&semester=fall`}>
Back to Below 60
</Link>
<Link className="btn btn-outline-secondary btn-sm" to={`${GRADING_PATH}${schoolYear ? `?school_year=${encodeURIComponent(schoolYear)}` : ''}`}>
Grading
</Link>
</div>
</div>
<div className="card shadow-sm mb-3">
<div className="card-body py-3">
<form
className="row g-2 align-items-end justify-content-center"
onSubmit={(event) => {
event.preventDefault()
const next = new URLSearchParams()
if (filterYear.trim() !== '') next.set('school_year', filterYear.trim())
setSearchParams(next)
}}
>
<div className="col-12 col-sm-auto">
<label className="form-label mb-1 small fw-semibold">School Year</label>
<input
className="form-control form-control-sm"
style={{ minWidth: 160 }}
value={filterYear}
onChange={(event) => setFilterYear(event.target.value)}
placeholder="2025-2026"
/>
</div>
<div className="col-12 col-sm-auto">
<button type="submit" className="btn btn-sm btn-primary">
Apply
</button>
</div>
</form>
</div>
</div>
<div className="row g-3 mb-3">
<div className="col-12 col-md-6 col-xl-3">
<div className="card border-0 shadow-sm h-100">
<div className="card-body">
<div className="text-muted small text-uppercase">Students</div>
<div className="display-6">{metrics.total}</div>
</div>
</div>
</div>
<div className="col-12 col-md-6 col-xl-3">
<div className="card border-0 shadow-sm h-100">
<div className="card-body">
<div className="text-muted small text-uppercase">Pending</div>
<div className="display-6 text-warning">{metrics.pending}</div>
</div>
</div>
</div>
<div className="col-12 col-md-6 col-xl-3">
<div className="card border-0 shadow-sm h-100">
<div className="card-body">
<div className="text-muted small text-uppercase">Decided</div>
<div className="display-6 text-success">{metrics.decided}</div>
</div>
</div>
</div>
<div className="col-12 col-md-6 col-xl-3">
<div className="card border-0 shadow-sm h-100">
<div className="card-body">
<div className="text-muted small text-uppercase">Year Score Under 50</div>
<div className="display-6 text-danger">{metrics.urgent}</div>
</div>
</div>
</div>
</div>
{success ? <div className="alert alert-success">{success}</div> : null}
{error ? <div className="alert alert-danger">{error}</div> : null}
{loading ? <p className="text-muted">Loading decisions</p> : null}
{!loading && rows.length === 0 ? (
<div className="alert alert-success">No students below 60 for the whole year in {schoolYear}.</div>
) : null}
{!loading && rows.length > 0 ? (
<>
<div className="table-responsive">
<table className="table table-bordered table-striped align-middle">
<thead className="table-light">
<tr>
<th style={{ minWidth: 160 }}>Student Name</th>
<th className="text-center">Age</th>
<th>Class-Section</th>
<th className="text-center">Year Score</th>
<th style={{ minWidth: 300 }}>Comments / Rationale &amp; Decision</th>
<th style={{ minWidth: 170 }} className="text-center">Below-60 Decision</th>
</tr>
</thead>
<tbody>
{rows.map((row) => {
const studentId = Number(row.student_id ?? 0)
const yearScore = numericScore(row.year_score)
const rowClass = yearScore !== null ? (yearScore < 50 ? 'table-danger' : 'table-warning') : ''
const studentName = `${fieldText(row.firstname)} ${fieldText(row.lastname)}`.trim() || 'N/A'
const decision = fieldText(row.decision)
const notes = fieldText(row.decision_notes)
const badgeClass = decisionBadgeClass(decision)
const isSaving = savingStudentId === studentId
const emailHref = `${GRADING_BELOW_SIXTY_DECISION_EMAIL_EDITOR_PATH}?student_id=${studentId}&school_year=${encodeURIComponent(schoolYear)}`
return (
<tr key={studentId || studentName} className={rowClass}>
<td>{studentName}</td>
<td className="text-center">{fieldText(row.age) || '—'}</td>
<td>{fieldText(row.class_section_name) || '—'}</td>
<td className="text-center">
<div className="fw-semibold">{displayScore(row.year_score)}</div>
<button
type="button"
className="btn btn-outline-secondary btn-sm mt-1"
style={{ fontSize: '0.72rem', padding: '1px 7px' }}
onClick={() => void openDetails(row)}
>
Details
</button>
</td>
<td>
<form onSubmit={(event) => void onSaveDecision(event)}>
<input type="hidden" name="student_id" value={studentId} />
<input type="hidden" name="school_year" value={schoolYear} />
<textarea
name="notes"
className="form-control form-control-sm"
rows={3}
defaultValue={notes}
placeholder="Add comments or rationale…"
disabled={isSaving}
/>
<div className="d-flex gap-2 mt-2 align-items-center">
<select
name="decision"
className="form-select form-select-sm flex-grow-1"
defaultValue={decision}
disabled={isSaving}
>
{DECISION_OPTIONS.map((option) => (
<option key={option.value || 'empty'} value={option.value}>
{option.label}
</option>
))}
</select>
<button type="submit" className="btn btn-sm btn-primary" disabled={isSaving}>
{isSaving ? 'Saving…' : 'Save'}
</button>
</div>
</form>
</td>
<td className="text-center align-middle">
{decision !== '' && badgeClass ? (
<>
<span className={`badge ${badgeClass} fs-6 px-3 py-2 d-block mb-2`}>
{decision}
</span>
<Link className="btn btn-sm btn-outline-primary" to={emailHref}>
Send Email
</Link>
</>
) : (
<span className="text-muted small">Pending</span>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
<div className="mt-3 mb-4 d-flex gap-2 flex-wrap">
{DECISION_OPTIONS.filter((option) => option.value !== '').map((option) => {
const badgeClass = decisionBadgeClass(option.value)
if (!badgeClass) return null
return (
<span key={option.value} className={`badge ${badgeClass} px-3 py-2`}>
{option.label}
</span>
)
})}
</div>
</>
) : null}
{detailsOpen ? (
<div
className="modal d-block"
tabIndex={-1}
role="dialog"
style={{ backgroundColor: 'rgba(0, 0, 0, 0.45)' }}
onClick={() => setDetailsOpen(false)}
>
<div className="modal-dialog modal-lg modal-dialog-scrollable" onClick={(event) => event.stopPropagation()}>
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">Score Details{modalStudentName ? `${modalStudentName}` : ''}</h5>
<button type="button" className="btn-close" onClick={() => setDetailsOpen(false)} />
</div>
<div className="modal-body">
{modalError ? <div className="alert alert-danger">{modalError}</div> : null}
{modalLoading ? <p className="text-muted">Loading details</p> : null}
{!modalLoading && !modalError && modalSemesters.length === 0 ? (
<div className="text-muted">No detailed score data found.</div>
) : null}
{!modalLoading &&
!modalError &&
modalSemesters.map((semester) => (
<div key={`${semester.semester}-${semester.class_section_name ?? ''}`} className="mb-4">
<h6 className="mb-2">
{fieldText(semester.semester) || 'Semester'}
{fieldText(semester.class_section_name) ? `${fieldText(semester.class_section_name)}` : ''}
</h6>
<div className="table-responsive">
<table className="table table-sm table-bordered mb-2">
<thead className="table-light">
<tr>
<th>Item</th>
<th className="text-end">Score</th>
</tr>
</thead>
<tbody>
{[
['Homework Avg', semester.homework_avg],
['Project Avg', semester.project_avg],
['Participation', semester.participation_score],
['Test Avg', semester.test_avg],
['PTAP Score', semester.ptap_score],
['Attendance', semester.attendance_score],
['Midterm Score', semester.midterm_exam_score],
['Final Exam', semester.final_exam_score],
['Semester Score', semester.semester_score],
]
.filter(([, value]) => value !== null && value !== undefined && value !== '')
.map(([label, value]) => (
<tr key={String(label)}>
<td>{label}</td>
<td className="text-end">{displayScore(value)}</td>
</tr>
))}
</tbody>
</table>
</div>
{semester.comments && Object.keys(semester.comments).length > 0 ? (
<div>
<div className="fw-semibold mb-2">Comments</div>
<div className="d-flex flex-column gap-2">
{Object.entries(semester.comments).map(([type, text]) => (
<div key={type} className="border-start border-4 ps-3 py-2 bg-light">
<div className="fw-semibold text-capitalize">{type.replace(/_/g, ' ')}</div>
<div>{text}</div>
</div>
))}
</div>
</div>
) : null}
</div>
))}
</div>
<div className="modal-footer">
<button type="button" className="btn btn-outline-secondary" onClick={() => setDetailsOpen(false)}>
Close
</button>
</div>
</div>
</div>
</div>
) : null}
</div>
)
}
+2
View File
@@ -11,6 +11,7 @@ import {
import type { GradingOverviewStudentRow } from '../../api/types'
import {
GRADING_BELOW_SIXTY_PATH,
GRADING_DECISIONS_PATH,
GRADING_HOMEWORK_TRACKING_PATH,
GRADING_PARTICIPATION_PATH,
GRADING_PLACEMENT_INDEX_PATH,
@@ -347,6 +348,7 @@ export function GradingMainPage() {
<div className="row justify-content-center mb-3">
<div className="col-auto d-flex flex-wrap gap-2 justify-content-center">
<Link className="btn btn-outline-primary" to={`${GRADING_DECISIONS_PATH}${qs}`}>Decisions</Link>
<Link className="btn btn-outline-primary" to={`${GRADING_BELOW_SIXTY_PATH}${qs}`}>Below 60 Summary</Link>
<Link className="btn btn-outline-primary" to={`${GRADING_HOMEWORK_TRACKING_PATH}${qs}`}>Homework Tracking</Link>
<Link className="btn btn-outline-primary" to={`${GRADING_PLACEMENT_INDEX_PATH}${qs}`}>Placement Scores</Link>
+1 -1
View File
@@ -60,7 +60,7 @@ export function HomeworkTrackingPage() {
fetchHomeworkTracking(searchParams)
.then((p) => {
if (!c) {
setPayload(p)
setPayload(p as HomeworkTrackingPayload)
setError(null)
}
})
+7 -3
View File
@@ -5,6 +5,10 @@ import { fetchParticipation, postParticipation } from '../../api/grading'
import { GRADING_PATH } from './gradingPaths'
/** CI `grading/participation.php` */
function normalizeParticipationScore(value: unknown): number | string | null {
return typeof value === 'number' || typeof value === 'string' ? value : null
}
export function ParticipationPage() {
const [searchParams] = useSearchParams()
const [rows, setRows] = useState<
@@ -17,7 +21,7 @@ export function ParticipationPage() {
}>
>([])
const [locked, setLocked] = useState(false)
const [title, setTitle] = useState('Participation Scores')
const [title] = useState('Participation Scores')
const [sectionName, setSectionName] = useState('')
const [semester, setSemester] = useState(searchParams.get('semester') ?? '')
const [schoolYear, setSchoolYear] = useState(searchParams.get('school_year') ?? '')
@@ -46,7 +50,7 @@ export function ParticipationPage() {
school_id: typeof student.school_id === 'string' ? student.school_id : null,
firstname: typeof student.firstname === 'string' ? student.firstname : null,
lastname: typeof student.lastname === 'string' ? student.lastname : null,
score: scores.score ?? null,
score: normalizeParticipationScore(scores.score),
}
}),
)
@@ -112,7 +116,7 @@ export function ParticipationPage() {
school_id: typeof student.school_id === 'string' ? student.school_id : null,
firstname: typeof student.firstname === 'string' ? student.firstname : null,
lastname: typeof student.lastname === 'string' ? student.lastname : null,
score: scores.score ?? null,
score: normalizeParticipationScore(scores.score),
}
}),
)
+2
View File
@@ -2,6 +2,8 @@
export const GRADING_PATH = '/app/grading'
/** SPA list + links; Laravel API paths remain `.../below-sixty` (see `api/grading.ts`). */
export const GRADING_BELOW_SIXTY_PATH = `${GRADING_PATH}/below-60`
export const GRADING_DECISIONS_PATH = `${GRADING_BELOW_SIXTY_PATH}/decisions`
export const GRADING_BELOW_SIXTY_DECISION_EMAIL_EDITOR_PATH = `${GRADING_DECISIONS_PATH}/email-editor`
export const GRADING_BELOW_SIXTY_EMAIL_EDITOR_PATH = `${GRADING_PATH}/below-60/email-editor`
export const GRADING_HOMEWORK_TRACKING_PATH = `${GRADING_PATH}/homework-tracking`
export const GRADING_PARTICIPATION_PATH = `${GRADING_PATH}/participation`
+2
View File
@@ -1,5 +1,7 @@
export { BelowSixtyEmailEditorPage } from './BelowSixtyEmailEditorPage'
export { BelowSixtyDecisionEmailEditorPage } from './BelowSixtyDecisionEmailEditorPage'
export { BelowSixtyPage } from './BelowSixtyPage'
export { GradingDecisionsPage } from './GradingDecisionsPage'
export { GradingMainPage } from './GradingMainPage'
export { GradingScoreEntryPage } from './GradingScoreEntryPage'
export { HomeworkTrackingPage } from './HomeworkTrackingPage'
@@ -1,6 +1,5 @@
import { type FormEvent, useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http'
import { fetchRole, updateRole } from '../../api/rolePermission'
import { ROLE_PERMISSION_BASE } from './rolePermissionPaths'