fix exam grading
This commit is contained in:
+34
@@ -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 />} />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,10 +180,15 @@ export function BelowSixtyPage() {
|
||||
{semester ? `${semester} • ` : ''}
|
||||
{schoolYear}
|
||||
</div>
|
||||
<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}
|
||||
{loading ? <p className="text-muted">Loading…</p> : null}
|
||||
|
||||
@@ -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 & 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -60,7 +60,7 @@ export function HomeworkTrackingPage() {
|
||||
fetchHomeworkTracking(searchParams)
|
||||
.then((p) => {
|
||||
if (!c) {
|
||||
setPayload(p)
|
||||
setPayload(p as HomeworkTrackingPayload)
|
||||
setError(null)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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,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`
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user