fix api security issues and update pages issue

This commit is contained in:
root
2026-06-04 16:41:25 -04:00
parent ad7b13f4d8
commit 1b0d9a36ad
14 changed files with 840 additions and 361 deletions
+9 -6
View File
@@ -371,6 +371,9 @@ const GradingMainPage = lazy(() =>
const GradingDecisionsPage = lazy(() => const GradingDecisionsPage = lazy(() =>
import('./pages/grading/GradingDecisionsPage').then((m) => ({ default: m.GradingDecisionsPage })), import('./pages/grading/GradingDecisionsPage').then((m) => ({ default: m.GradingDecisionsPage })),
) )
const BelowSixtyDecisionsPage = lazy(() =>
import('./pages/grading/BelowSixtyDecisionsPage').then((m) => ({ default: m.BelowSixtyDecisionsPage })),
)
const BelowSixtyPage = lazy(() => const BelowSixtyPage = lazy(() =>
import('./pages/grading/BelowSixtyPage').then((m) => ({ default: m.BelowSixtyPage })), import('./pages/grading/BelowSixtyPage').then((m) => ({ default: m.BelowSixtyPage })),
) )
@@ -1110,9 +1113,9 @@ export default function App() {
<Route path="administrator/grading/main" element={<GradingMainPage />} /> <Route path="administrator/grading/main" element={<GradingMainPage />} />
<Route path="administrator/grading/decisions" element={<GradingDecisionsPage />} /> <Route path="administrator/grading/decisions" element={<GradingDecisionsPage />} />
<Route path="administrator/grading/all-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-sixty-decisions" element={<BelowSixtyDecisionsPage />} />
<Route path="administrator/grading/below-60/decisions" element={<GradingDecisionsPage />} /> <Route path="administrator/grading/below-60/decisions" element={<BelowSixtyDecisionsPage />} />
<Route path="administrator/grading/below-sixty/decisions" element={<GradingDecisionsPage />} /> <Route path="administrator/grading/below-sixty/decisions" element={<BelowSixtyDecisionsPage />} />
<Route <Route
path="administrator/grading/below-60/decisions/email-editor" path="administrator/grading/below-60/decisions/email-editor"
element={<BelowSixtyDecisionEmailEditorPage />} element={<BelowSixtyDecisionEmailEditorPage />}
@@ -1142,9 +1145,9 @@ export default function App() {
<Route path="grading/below-60/email-editor" element={<BelowSixtyEmailEditorPage />} /> <Route path="grading/below-60/email-editor" element={<BelowSixtyEmailEditorPage />} />
<Route path="grading/decisions" element={<GradingDecisionsPage />} /> <Route path="grading/decisions" element={<GradingDecisionsPage />} />
<Route path="grading/all-decisions" element={<GradingDecisionsPage />} /> <Route path="grading/all-decisions" element={<GradingDecisionsPage />} />
<Route path="grading/below-sixty-decisions" element={<GradingDecisionsPage />} /> <Route path="grading/below-sixty-decisions" element={<BelowSixtyDecisionsPage />} />
<Route path="grading/below-60/decisions" element={<GradingDecisionsPage />} /> <Route path="grading/below-60/decisions" element={<BelowSixtyDecisionsPage />} />
<Route path="grading/below-sixty/decisions" element={<GradingDecisionsPage />} /> <Route path="grading/below-sixty/decisions" element={<BelowSixtyDecisionsPage />} />
<Route <Route
path="grading/below-60/decisions/email-editor" path="grading/below-60/decisions/email-editor"
element={<BelowSixtyDecisionEmailEditorPage />} element={<BelowSixtyDecisionEmailEditorPage />}
+42
View File
@@ -50,6 +50,31 @@ export type GradingSectionScorePayload = {
missing_ok_map?: Record<string, unknown> missing_ok_map?: Record<string, unknown>
} }
export type AllDecisionRow = {
student_id: number
school_id?: string | null
firstname?: string | null
lastname?: string | null
gender?: string | null
class_section_id?: 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
source?: string | null
notes?: string | null
saved?: boolean
is_trophy?: boolean
}
export type AllDecisionsPayload = {
ok?: boolean
rows?: AllDecisionRow[]
generated?: boolean
school_year?: string
}
function q(sp: URLSearchParams): string { function q(sp: URLSearchParams): string {
const s = sp.toString() const s = sp.toString()
return s ? `?${s}` : '' return s ? `?${s}` : ''
@@ -123,6 +148,23 @@ export async function fetchBelowSixty(searchParams: URLSearchParams): Promise<un
return apiFetch(`${BASE}/below-sixty${q(searchParams)}`) return apiFetch(`${BASE}/below-sixty${q(searchParams)}`)
} }
export async function fetchAllDecisions(
searchParams: URLSearchParams,
): Promise<AllDecisionsPayload> {
return apiFetch(`${BASE}/decisions${q(searchParams)}`)
}
export async function postGenerateAllDecisions(body: Record<string, unknown>): Promise<{
ok?: boolean
saved_count?: number
}> {
return apiFetch(`${BASE}/decisions/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
}
export async function fetchBelowSixtyDecisions( export async function fetchBelowSixtyDecisions(
searchParams: URLSearchParams, searchParams: URLSearchParams,
): Promise<unknown> { ): Promise<unknown> {
+2 -2
View File
@@ -1,10 +1,10 @@
/** /**
* Roles & permissions admin (legacy CI rolepermission/*). * Roles & permissions admin (legacy CI rolepermission/*).
* Expected under `/api/v1/role-permission`. * Live Laravel routes are exposed under `/api/v1/role-permissions`.
*/ */
import { ApiHttpError, apiFetch } from './http' import { ApiHttpError, apiFetch } from './http'
const BASE = '/api/v1/role-permission' const BASE = '/api/v1/role-permissions'
type DataEnvelope<T> = { data?: T } type DataEnvelope<T> = { data?: T }
+11
View File
@@ -125,6 +125,17 @@ export async function fetchDashboardRoute(): Promise<ApiEnvelope<DashboardPayloa
return apiFetch<ApiEnvelope<DashboardPayload>>('/api/v1/dashboard/route') return apiFetch<ApiEnvelope<DashboardPayload>>('/api/v1/dashboard/route')
} }
export async function postRoleSwitch(role: string): Promise<ApiEnvelope<{
role?: string
dashboard_route?: string
}>> {
return apiFetch<ApiEnvelope<{ role?: string; dashboard_route?: string }>>('/api/v1/role-switcher/switch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role }),
})
}
export async function fetchLandingAdminDashboard(kind: 'admin' | 'administrator'): Promise<LandingPageResponse> { export async function fetchLandingAdminDashboard(kind: 'admin' | 'administrator'): Promise<LandingPageResponse> {
return apiFetch<LandingPageResponse>(`/api/v1/landing/${kind}`) return apiFetch<LandingPageResponse>(`/api/v1/landing/${kind}`)
} }
+20 -8
View File
@@ -1,8 +1,9 @@
import { useState } from 'react' import { useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom' import { useLocation, useNavigate } from 'react-router-dom'
import { fetchDashboardRoute } from '../api/session' import { fetchDashboardRoute, postRoleSwitch } from '../api/session'
import { useAuth } from '../auth/AuthProvider' import { useAuth } from '../auth/AuthProvider'
import { spaPathFromCiUrl } from '../lib/ciSpaPaths' import { spaPathFromCiUrl } from '../lib/ciSpaPaths'
import { isParentPortalRole, isTeacherPortalRole } from '../lib/portalRoles'
/** Shared navigation after JWT login when the account has multiple roles (CI `welcome_back` / `select_role`). */ /** Shared navigation after JWT login when the account has multiple roles (CI `welcome_back` / `select_role`). */
export function useContinueWithRole() { export function useContinueWithRole() {
@@ -19,19 +20,30 @@ export function useContinueWithRole() {
setBusyRole(nextRole) setBusyRole(nextRole)
setError(null) setError(null)
try { try {
setSelectedRole(nextRole)
const roleLower = nextRole.toLowerCase()
const fromApp = from.startsWith('/app') ? from : '' const fromApp = from.startsWith('/app') ? from : ''
const hasDeepLink = Boolean(fromApp && fromApp !== '/app/home') const hasDeepLink = Boolean(fromApp && fromApp !== '/app/home')
let target: string let target: string | null = null
try {
const switched = await postRoleSwitch(nextRole)
const route = switched.data?.dashboard_route
const mapped = route ? spaPathFromCiUrl(route) : null
if (mapped) {
target = mapped
}
} catch {
/* fall back to client-side role defaults */
}
setSelectedRole(nextRole)
if (hasDeepLink) { if (hasDeepLink) {
target = fromApp target = fromApp
} else if (roleLower === 'parent') { } else if (isParentPortalRole(nextRole)) {
target = '/app/parent/home' target = '/app/parent/home'
} else if (roleLower === 'teacher' || roleLower === 'teacher_assistant') { } else if (isTeacherPortalRole(nextRole)) {
target = '/app/teacher_dashboard' target = '/app/teacher_dashboard'
} else { } else if (!target) {
target = '/app/home' target = '/app/home'
try { try {
const dash = await fetchDashboardRoute() const dash = await fetchDashboardRoute()
@@ -43,7 +55,7 @@ export function useContinueWithRole() {
} }
} }
navigate(target, { replace: true }) navigate(target ?? '/app/home', { replace: true })
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : 'Unable to continue.') setError(e instanceof Error ? e.message : 'Unable to continue.')
} finally { } finally {
+2 -1
View File
@@ -9,5 +9,6 @@ export function isTeacherPortalRole(role: string): boolean {
} }
export function isParentPortalRole(role: string): boolean { export function isParentPortalRole(role: string): boolean {
return normalizePortalRole(role) === 'parent' const r = normalizePortalRole(role)
return r === 'parent' || r === 'guest'
} }
+6 -5
View File
@@ -3,6 +3,7 @@ import { Link, useLocation, useNavigate } from 'react-router-dom'
import { fetchDashboardRoute } from '../api/session' import { fetchDashboardRoute } from '../api/session'
import { useAuth } from '../auth/AuthProvider' import { useAuth } from '../auth/AuthProvider'
import { spaPathFromCiUrl } from '../lib/ciSpaPaths' import { spaPathFromCiUrl } from '../lib/ciSpaPaths'
import { isParentPortalRole, isTeacherPortalRole } from '../lib/portalRoles'
export function LoginPage() { export function LoginPage() {
const { login } = useAuth() const { login } = useAuth()
@@ -35,15 +36,15 @@ export function LoginPage() {
return return
} }
const singleRole = result.roles.length === 1 ? result.roles[0].toLowerCase() : '' const singleRole = result.roles.length === 1 ? result.roles[0] : ''
let target = '/app/home' let target = '/app/home'
if (from.startsWith('/app')) target = from if (from.startsWith('/app')) target = from
else if (from.startsWith('/docs')) target = from else if (from.startsWith('/docs')) target = from
if (result.roles.length === 1 && target === '/app/home') { if (result.roles.length === 1 && target === '/app/home') {
if (singleRole === 'parent') target = '/app/parent/home' if (isParentPortalRole(singleRole)) target = '/app/parent/home'
if (singleRole === 'teacher' || singleRole === 'teacher_assistant') { if (isTeacherPortalRole(singleRole)) {
target = '/app/teacher_dashboard' target = '/app/teacher_dashboard'
} }
} }
@@ -59,11 +60,11 @@ export function LoginPage() {
/* API default dashboard can point parents at teacher URLs (or vice versa); honor single-role account. */ /* API default dashboard can point parents at teacher URLs (or vice versa); honor single-role account. */
if (result.roles.length === 1) { if (result.roles.length === 1) {
if (singleRole === 'teacher' || singleRole === 'teacher_assistant') { if (isTeacherPortalRole(singleRole)) {
if (target === '/app/home' || target.startsWith('/app/parent')) { if (target === '/app/home' || target.startsWith('/app/parent')) {
target = '/app/teacher_dashboard' target = '/app/teacher_dashboard'
} }
} else if (singleRole === 'parent') { } else if (isParentPortalRole(singleRole)) {
if (target === '/app/home' || target.startsWith('/app/teacher')) { if (target === '/app/home' || target.startsWith('/app/teacher')) {
target = '/app/parent/home' target = '/app/parent/home'
} }
@@ -5,7 +5,7 @@ import {
fetchBelowSixtyDecisionEmailEditor, fetchBelowSixtyDecisionEmailEditor,
postBelowSixtyDecisionEmail, postBelowSixtyDecisionEmail,
} from '../../api/grading' } from '../../api/grading'
import { GRADING_DECISIONS_PATH } from './gradingPaths' import { GRADING_BELOW_SIXTY_DECISIONS_PATH } from './gradingPaths'
export function BelowSixtyDecisionEmailEditorPage() { export function BelowSixtyDecisionEmailEditorPage() {
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
@@ -70,7 +70,7 @@ export function BelowSixtyDecisionEmailEditorPage() {
} }
} }
const backHref = `${GRADING_DECISIONS_PATH}?school_year=${encodeURIComponent(schoolYear)}` const backHref = `${GRADING_BELOW_SIXTY_DECISIONS_PATH}?school_year=${encodeURIComponent(schoolYear)}`
if (!studentId || !schoolYear) { if (!studentId || !schoolYear) {
return ( return (
@@ -0,0 +1,494 @@
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_DECISIONS_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 BelowSixtyDecisionsPage() {
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-primary btn-sm" to={`${GRADING_DECISIONS_PATH}?school_year=${encodeURIComponent(schoolYear)}`}>
All Decisions
</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"
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…"
/>
<div className="d-flex gap-2 mt-2 align-items-center">
<select
name="decision"
className="form-select form-select-sm"
defaultValue={decision}
>
{DECISION_OPTIONS.map((option) => (
<option key={option.value || 'none'} 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>
{detailsOpen ? (
<div
className="modal fade show 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">
<div>
<h5 className="modal-title mb-0">Whole-Year Details</h5>
<div className="text-muted small">{modalStudentName}</div>
</div>
<button type="button" className="btn-close" onClick={() => setDetailsOpen(false)} aria-label="Close" />
</div>
<div className="modal-body">
{modalError ? <div className="alert alert-danger">{modalError}</div> : null}
{modalLoading ? <p className="text-muted mb-0">Loading</p> : null}
{!modalLoading && !modalError && modalSemesters.length === 0 ? (
<p className="text-muted mb-0">No semester details found.</p>
) : null}
{!modalLoading && modalSemesters.length > 0 ? (
<div className="d-grid gap-3">
{modalSemesters.map((semester, index) => (
<div key={`${semester.semester ?? 'semester'}-${index}`} className="card border-0 shadow-sm">
<div className="card-body">
<div className="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-2">
<div className="fw-semibold text-capitalize">{fieldText(semester.semester) || 'Semester'}</div>
<div className="text-muted small">{fieldText(semester.class_section_name) || '—'}</div>
</div>
<div className="row g-2 small">
<div className="col-6 col-md-3">Homework: {displayScore(semester.homework_avg)}</div>
<div className="col-6 col-md-3">Project: {displayScore(semester.project_avg)}</div>
<div className="col-6 col-md-3">Participation: {displayScore(semester.participation_score)}</div>
<div className="col-6 col-md-3">Test: {displayScore(semester.test_avg)}</div>
<div className="col-6 col-md-3">PTAP: {displayScore(semester.ptap_score)}</div>
<div className="col-6 col-md-3">Attendance: {displayScore(semester.attendance_score)}</div>
<div className="col-6 col-md-3">Midterm: {displayScore(semester.midterm_exam_score)}</div>
<div className="col-6 col-md-3">Final: {displayScore(semester.final_exam_score)}</div>
<div className="col-6 col-md-3 fw-semibold">Semester Score: {displayScore(semester.semester_score)}</div>
</div>
{semester.comments && Object.keys(semester.comments).length > 0 ? (
<div className="mt-3">
<div className="fw-semibold small mb-1">Comments</div>
<ul className="mb-0 small">
{Object.entries(semester.comments).map(([key, value]) => (
<li key={key}>
<span className="text-capitalize">{key.replace(/_/g, ' ')}:</span> {fieldText(value) || '—'}
</li>
))}
</ul>
</div>
) : null}
</div>
</div>
))}
</div>
) : null}
</div>
</div>
</div>
</div>
) : null}
</>
) : null}
</div>
)
}
+2 -2
View File
@@ -5,7 +5,7 @@ import { fetchBelowSixty, postBelowSixtyStatus } from '../../api/grading'
import { fetchGradingOverview } from '../../api/session' import { fetchGradingOverview } from '../../api/session'
import { AcademicFilterBar } from '../discounts/AcademicFilterBar' import { AcademicFilterBar } from '../discounts/AcademicFilterBar'
import { import {
GRADING_DECISIONS_PATH, GRADING_BELOW_SIXTY_DECISIONS_PATH,
GRADING_BELOW_SIXTY_EMAIL_EDITOR_PATH, GRADING_BELOW_SIXTY_EMAIL_EDITOR_PATH,
GRADING_PATH, GRADING_PATH,
GRADING_SCHEDULE_MEETING_PATH, GRADING_SCHEDULE_MEETING_PATH,
@@ -181,7 +181,7 @@ export function BelowSixtyPage() {
{schoolYear} {schoolYear}
</div> </div>
<div className="d-flex gap-2 flex-wrap"> <div className="d-flex gap-2 flex-wrap">
<Link className="btn btn-outline-primary btn-sm" to={`${GRADING_DECISIONS_PATH}?school_year=${encodeURIComponent(schoolYear)}`}> <Link className="btn btn-outline-primary btn-sm" to={`${GRADING_BELOW_SIXTY_DECISIONS_PATH}?school_year=${encodeURIComponent(schoolYear)}`}>
School Year Decisions School Year Decisions
</Link> </Link>
<Link className="btn btn-outline-secondary btn-sm" to={`${GRADING_PATH}${qs ? `?${qs}` : ''}`}> <Link className="btn btn-outline-secondary btn-sm" to={`${GRADING_PATH}${qs ? `?${qs}` : ''}`}>
+235 -327
View File
@@ -1,59 +1,19 @@
import { type FormEvent, useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom' import { Link, useSearchParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http' import { ApiHttpError } from '../../api/http'
import { import {
fetchBelowSixtyDecisionDetails, type AllDecisionRow,
fetchBelowSixtyDecisions, fetchAllDecisions,
postBelowSixtyDecision, postGenerateAllDecisions,
} from '../../api/grading' } from '../../api/grading'
import { fetchGradingOverview } from '../../api/session' import { fetchGradingOverview } from '../../api/session'
import { import {
GRADING_BELOW_SIXTY_DECISION_EMAIL_EDITOR_PATH, GRADING_BELOW_SIXTY_DECISIONS_PATH,
GRADING_BELOW_SIXTY_PATH,
GRADING_PATH, GRADING_PATH,
} from './gradingPaths' } from './gradingPaths'
type DecisionOption = '' | 'Pass' | 'Repeat Class' | 'Make-up exam in fall' | 'Deferred decision' | 'Expel' | 'Withdrawn' type SourceKey = 'auto' | 'manual' | 'pending'
type GenderKey = 'boys' | 'girls' | 'other'
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 { function displayScore(value: unknown): string {
if (value === null || value === undefined || value === '') return '—' if (value === null || value === undefined || value === '') return '—'
@@ -68,16 +28,11 @@ function numericScore(value: unknown): number | null {
return Number.isFinite(numeric) ? numeric : null return Number.isFinite(numeric) ? numeric : null
} }
function normalizeRows(payload: unknown): DecisionRow[] { function fieldText(value: unknown): string {
if (Array.isArray(payload)) return payload as DecisionRow[] return String(value ?? '').trim()
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 { function decisionBadgeClass(decision: string): string {
switch (decision) { switch (decision) {
case 'Pass': case 'Pass':
return 'text-bg-success' return 'text-bg-success'
@@ -90,12 +45,53 @@ function decisionBadgeClass(decision: string): string | null {
case 'Withdrawn': case 'Withdrawn':
return 'text-bg-secondary' return 'text-bg-secondary'
default: default:
return null return 'text-bg-secondary'
} }
} }
function fieldText(value: unknown): string { function sourceMeta(source: string): { className: string; label: string } {
return String(value ?? '').trim() switch ((source || 'pending') as SourceKey) {
case 'auto':
return { className: 'text-bg-success', label: 'Auto' }
case 'manual':
return { className: 'text-bg-primary', label: 'Manual' }
default:
return { className: 'text-bg-warning', label: 'Pending' }
}
}
function genderBucket(value: unknown): GenderKey {
const normalized = fieldText(value).toLowerCase()
if (['male', 'm', 'boy', 'boys'].includes(normalized)) return 'boys'
if (['female', 'f', 'girl', 'girls'].includes(normalized)) return 'girls'
return 'other'
}
function pct(count: number, total: number): string {
if (total <= 0) return '0.0%'
return `${((count / total) * 100).toFixed(1)}%`
}
function normalizePayload(payload: unknown): {
rows: AllDecisionRow[]
generated: boolean
schoolYear: string
} {
if (!payload || typeof payload !== 'object') {
return { rows: [], generated: false, schoolYear: '' }
}
const body = payload as {
rows?: unknown
generated?: unknown
school_year?: unknown
}
return {
rows: Array.isArray(body.rows) ? (body.rows as AllDecisionRow[]) : [],
generated: Boolean(body.generated),
schoolYear: fieldText(body.school_year),
}
} }
export function GradingDecisionsPage() { export function GradingDecisionsPage() {
@@ -103,16 +99,12 @@ export function GradingDecisionsPage() {
const schoolYear = searchParams.get('school_year') ?? '' const schoolYear = searchParams.get('school_year') ?? ''
const [filterYear, setFilterYear] = useState(schoolYear) const [filterYear, setFilterYear] = useState(schoolYear)
const [rows, setRows] = useState<DecisionRow[]>([]) const [rows, setRows] = useState<AllDecisionRow[]>([])
const [generated, setGenerated] = useState(false)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [savingStudentId, setSavingStudentId] = useState<number | null>(null) const [generating, setGenerating] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = 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(() => { useEffect(() => {
if (schoolYear) { if (schoolYear) {
@@ -141,101 +133,78 @@ export function GradingDecisionsPage() {
} }
}, [schoolYear, searchParams, setSearchParams]) }, [schoolYear, searchParams, setSearchParams])
useEffect(() => { async function loadRows(params: URLSearchParams, activeSchoolYear: string) {
if (!schoolYear) { if (!activeSchoolYear) {
setRows([])
setGenerated(false)
setLoading(false) setLoading(false)
return return
} }
let cancelled = false
setLoading(true) setLoading(true)
fetchBelowSixtyDecisions(searchParams) try {
.then((payload) => { const payload = normalizePayload(await fetchAllDecisions(params))
if (cancelled) return setRows(payload.rows)
setRows(normalizeRows(payload)) setGenerated(payload.generated)
setError(null) setError(null)
}) } catch (e: unknown) {
.catch((e: unknown) => { setError(e instanceof ApiHttpError ? e.message : 'Unable to load all decisions.')
if (!cancelled) { setRows([])
setError(e instanceof ApiHttpError ? e.message : 'Unable to load school year decisions.') setGenerated(false)
} finally {
setLoading(false)
}
} }
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => { useEffect(() => {
cancelled = true void loadRows(searchParams, schoolYear)
}
}, [schoolYear, searchParams]) }, [schoolYear, searchParams])
const metrics = useMemo(() => { const stats = useMemo(() => {
let pending = 0 const totals = { Pass: 0, Other: 0, Pending: 0, Total: rows.length, Trophy: 0 }
let decided = 0 const gender = {
let urgent = 0 all: { boys: 0, girls: 0, other: 0 },
pass: { boys: 0, girls: 0, other: 0 },
trophy: { boys: 0, girls: 0, other: 0 },
}
for (const row of rows) { for (const row of rows) {
const decision = fieldText(row.decision) const decision = fieldText(row.decision)
const score = numericScore(row.year_score) const bucket = genderBucket(row.gender)
if (decision === '') pending += 1
else decided += 1 gender.all[bucket] += 1
if (score !== null && score < 50) urgent += 1 if (decision === 'Pass') {
totals.Pass += 1
gender.pass[bucket] += 1
} else if (decision === '' || fieldText(row.source) === 'pending') {
totals.Pending += 1
} else {
totals.Other += 1
} }
return { total: rows.length, pending, decided, urgent } if (Boolean(row.is_trophy)) {
totals.Trophy += 1
gender.trophy[bucket] += 1
}
}
return { totals, gender }
}, [rows]) }, [rows])
async function onSaveDecision(event: FormEvent<HTMLFormElement>) { async function onGenerate() {
event.preventDefault() if (!schoolYear) return
const formData = new FormData(event.currentTarget)
const studentId = Number(formData.get('student_id') ?? 0) setGenerating(true)
setSavingStudentId(studentId)
setError(null) setError(null)
setSuccess(null) setSuccess(null)
try { try {
await postBelowSixtyDecision({ const payload = await postGenerateAllDecisions({ school_year: schoolYear })
student_id: studentId, await loadRows(searchParams, schoolYear)
school_year: formData.get('school_year'), setSuccess(`Decisions generated for ${payload.saved_count ?? 0} students.`)
semester: 'year',
decision: formData.get('decision'),
notes: formData.get('notes'),
})
const refreshed = await fetchBelowSixtyDecisions(searchParams)
setRows(normalizeRows(refreshed))
setSuccess('Decision saved.')
} catch (e: unknown) { } catch (e: unknown) {
setError(e instanceof ApiHttpError ? e.message : 'Unable to save the decision.') setError(e instanceof ApiHttpError ? e.message : 'Unable to generate decisions.')
} finally { } finally {
setSavingStudentId(null) setGenerating(false)
}
}
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)
} }
} }
@@ -243,12 +212,20 @@ export function GradingDecisionsPage() {
<div className="container-fluid py-3"> <div className="container-fluid py-3">
<div className="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3"> <div className="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3">
<div> <div>
<h2 className="mb-1">School Year Decisions</h2> <h2 className="mb-1">Student Decisions All Students</h2>
<div className="text-muted">Whole Year {schoolYear || 'Current school year'}</div> <div className="text-muted">
{schoolYear || 'Current school year'}
<span className={`badge ms-2 ${generated ? 'text-bg-success' : 'text-bg-warning'}`}>
{generated ? 'Saved' : 'Not yet generated'}
</span>
</div> </div>
<div className="d-flex gap-2 flex-wrap"> </div>
<Link className="btn btn-outline-secondary btn-sm" to={`${GRADING_BELOW_SIXTY_PATH}?school_year=${encodeURIComponent(schoolYear)}&semester=fall`}> <div className="d-flex gap-2 flex-wrap d-print-none">
Back to Below 60 <button type="button" className="btn btn-outline-secondary btn-sm" onClick={() => window.print()}>
Print Stats
</button>
<Link className="btn btn-outline-primary btn-sm" to={`${GRADING_BELOW_SIXTY_DECISIONS_PATH}?school_year=${encodeURIComponent(schoolYear)}`}>
Below-60 Decisions
</Link> </Link>
<Link className="btn btn-outline-secondary btn-sm" to={`${GRADING_PATH}${schoolYear ? `?school_year=${encodeURIComponent(schoolYear)}` : ''}`}> <Link className="btn btn-outline-secondary btn-sm" to={`${GRADING_PATH}${schoolYear ? `?school_year=${encodeURIComponent(schoolYear)}` : ''}`}>
Grading Grading
@@ -256,7 +233,7 @@ export function GradingDecisionsPage() {
</div> </div>
</div> </div>
<div className="card shadow-sm mb-3"> <div className="card shadow-sm mb-3 d-print-none">
<div className="card-body py-3"> <div className="card-body py-3">
<form <form
className="row g-2 align-items-end justify-content-center" className="row g-2 align-items-end justify-content-center"
@@ -271,258 +248,189 @@ export function GradingDecisionsPage() {
<label className="form-label mb-1 small fw-semibold">School Year</label> <label className="form-label mb-1 small fw-semibold">School Year</label>
<input <input
className="form-control form-control-sm" className="form-control form-control-sm"
style={{ minWidth: 160 }} style={{ minWidth: 180 }}
value={filterYear} value={filterYear}
onChange={(event) => setFilterYear(event.target.value)} onChange={(event) => setFilterYear(event.target.value)}
placeholder="2025-2026" placeholder="2025-2026"
/> />
</div> </div>
<div className="col-12 col-sm-auto"> <div className="col-12 col-sm-auto">
<button type="submit" className="btn btn-sm btn-primary"> <button type="submit" className="btn btn-sm btn-secondary">
Apply Apply
</button> </button>
</div> </div>
<div className="col-12 col-sm-auto">
<button
type="button"
className="btn btn-sm btn-primary"
disabled={!schoolYear || generating}
onClick={() => void onGenerate()}
>
{generating ? 'Generating…' : generated ? 'Regenerate Decisions' : 'Generate Decisions'}
</button>
</div>
</form> </form>
<div className="text-muted small mt-2">
Year score 60 <strong>Pass</strong> (auto) | Year score &lt; 60 pulled from below-60 decisions
</div>
</div> </div>
</div> </div>
<div className="row g-3 mb-3"> <div className="row g-3 mb-3 d-print-none">
<div className="col-12 col-md-6 col-xl-3"> <div className="col-12 col-md-6 col-xl-3">
<div className="card border-0 shadow-sm h-100"> <div className="card border-success shadow-sm h-100">
<div className="card-body"> <div className="card-body text-center">
<div className="text-muted small text-uppercase">Students</div> <div className="fs-4 fw-bold text-success">{stats.totals.Pass}</div>
<div className="display-6">{metrics.total}</div> <div className="text-muted small">Pass</div>
</div> </div>
</div> </div>
</div> </div>
<div className="col-12 col-md-6 col-xl-3"> <div className="col-12 col-md-6 col-xl-3">
<div className="card border-0 shadow-sm h-100"> <div className="card border-primary shadow-sm h-100">
<div className="card-body"> <div className="card-body text-center">
<div className="text-muted small text-uppercase">Pending</div> <div className="fs-4 fw-bold text-primary">{stats.totals.Other}</div>
<div className="display-6 text-warning">{metrics.pending}</div> <div className="text-muted small">Other Decision</div>
</div> </div>
</div> </div>
</div> </div>
<div className="col-12 col-md-6 col-xl-3"> <div className="col-12 col-md-6 col-xl-3">
<div className="card border-0 shadow-sm h-100"> <div className="card border-warning shadow-sm h-100">
<div className="card-body"> <div className="card-body text-center">
<div className="text-muted small text-uppercase">Decided</div> <div className="fs-4 fw-bold text-warning">{stats.totals.Pending}</div>
<div className="display-6 text-success">{metrics.decided}</div> <div className="text-muted small">Pending</div>
</div> </div>
</div> </div>
</div> </div>
<div className="col-12 col-md-6 col-xl-3"> <div className="col-12 col-md-6 col-xl-3">
<div className="card border-0 shadow-sm h-100"> <div className="card shadow-sm h-100">
<div className="card-body"> <div className="card-body text-center">
<div className="text-muted small text-uppercase">Year Score Under 50</div> <div className="fs-4 fw-bold">{stats.totals.Total}</div>
<div className="display-6 text-danger">{metrics.urgent}</div> <div className="text-muted small">Total</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{success ? <div className="alert alert-success">{success}</div> : null} {success ? <div className="alert alert-success d-print-none">{success}</div> : null}
{error ? <div className="alert alert-danger">{error}</div> : null} {error ? <div className="alert alert-danger">{error}</div> : null}
{loading ? <p className="text-muted">Loading decisions</p> : null} {loading ? <p className="text-muted">Loading decisions</p> : null}
{!loading && rows.length === 0 ? ( {!loading && rows.length === 0 ? (
<div className="alert alert-success">No students below 60 for the whole year in {schoolYear}.</div> <div className="alert alert-info">No semester scores found for this school year.</div>
) : null} ) : null}
{!loading && rows.length > 0 ? ( {!loading && rows.length > 0 ? (
<> <>
<div className="d-none d-print-block mb-3">
<div className="text-center border-bottom pb-2 mb-3">
<h2 className="mb-1">Student Decisions Summary {schoolYear}</h2>
<p className="small text-muted mb-0">
Trophy counts use the same year-score rule as the trophy final page.
</p>
</div>
<h3 className="h5 mb-2">Print Stats</h3>
<div className="table-responsive">
<table className="table table-bordered align-middle mb-0">
<thead>
<tr>
<th>Metric</th>
<th className="text-center">Count</th>
<th className="text-center">Percent</th>
<th className="text-center">Boys</th>
<th className="text-center">Boys %</th>
<th className="text-center">Girls</th>
<th className="text-center">Girls %</th>
</tr>
</thead>
<tbody>
<tr>
<td>Total Students</td>
<td className="text-center">{stats.totals.Total}</td>
<td className="text-center">100.0%</td>
<td className="text-center">{stats.gender.all.boys}</td>
<td className="text-center">{pct(stats.gender.all.boys, stats.totals.Total)}</td>
<td className="text-center">{stats.gender.all.girls}</td>
<td className="text-center">{pct(stats.gender.all.girls, stats.totals.Total)}</td>
</tr>
<tr>
<td>Pass</td>
<td className="text-center">{stats.totals.Pass}</td>
<td className="text-center">{pct(stats.totals.Pass, stats.totals.Total)}</td>
<td className="text-center">{stats.gender.pass.boys}</td>
<td className="text-center">{pct(stats.gender.pass.boys, stats.totals.Pass)}</td>
<td className="text-center">{stats.gender.pass.girls}</td>
<td className="text-center">{pct(stats.gender.pass.girls, stats.totals.Pass)}</td>
</tr>
<tr>
<td>Trophies</td>
<td className="text-center">{stats.totals.Trophy}</td>
<td className="text-center">{pct(stats.totals.Trophy, stats.totals.Total)}</td>
<td className="text-center">{stats.gender.trophy.boys}</td>
<td className="text-center">{pct(stats.gender.trophy.boys, stats.totals.Trophy)}</td>
<td className="text-center">{stats.gender.trophy.girls}</td>
<td className="text-center">{pct(stats.gender.trophy.girls, stats.totals.Trophy)}</td>
</tr>
</tbody>
</table>
</div>
</div>
<div className="table-responsive"> <div className="table-responsive">
<table className="table table-bordered table-striped align-middle"> <table className="table table-bordered table-striped align-middle">
<thead className="table-light"> <thead className="table-light">
<tr> <tr>
<th style={{ minWidth: 160 }}>Student Name</th> <th>Student Name</th>
<th className="text-center">Age</th> <th>Section</th>
<th>Class-Section</th> <th className="text-center">Fall Score</th>
<th className="text-center">Spring Score</th>
<th className="text-center">Year Score</th> <th className="text-center">Year Score</th>
<th style={{ minWidth: 300 }}>Comments / Rationale &amp; Decision</th> <th className="text-center">Decision</th>
<th style={{ minWidth: 170 }} className="text-center">Below-60 Decision</th> <th className="text-center">Source</th>
<th>Notes</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{rows.map((row) => { {rows.map((row) => {
const studentId = Number(row.student_id ?? 0) 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 studentName = `${fieldText(row.firstname)} ${fieldText(row.lastname)}`.trim() || 'N/A'
const yearScore = numericScore(row.year_score)
const rowClass = yearScore !== null && yearScore < 60
? (yearScore < 50 ? 'table-danger' : 'table-warning')
: ''
const decision = fieldText(row.decision) const decision = fieldText(row.decision)
const notes = fieldText(row.decision_notes) const source = sourceMeta(fieldText(row.source))
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 ( return (
<tr key={studentId || studentName} className={rowClass}> <tr key={studentId || studentName} className={rowClass}>
<td>{studentName}</td> <td>{studentName}</td>
<td className="text-center">{fieldText(row.age) || '—'}</td>
<td>{fieldText(row.class_section_name) || '—'}</td> <td>{fieldText(row.class_section_name) || '—'}</td>
<td className="text-center">{displayScore(row.fall_score)}</td>
<td className="text-center">{displayScore(row.spring_score)}</td>
<td className="text-center fw-semibold">{displayScore(row.year_score)}</td>
<td className="text-center"> <td className="text-center">
<div className="fw-semibold">{displayScore(row.year_score)}</div> {decision ? (
<button <div className="d-inline-flex flex-wrap gap-1 justify-content-center">
type="button" <span className={`badge ${decisionBadgeClass(decision)}`}>{decision}</span>
className="btn btn-outline-secondary btn-sm mt-1" {row.is_trophy ? <span className="badge text-bg-dark">Trophy</span> : null}
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> </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> <span className="text-muted small"></span>
)} )}
</td> </td>
<td className="text-center">
<span className={`badge ${source.className}`}>{source.label}</span>
</td>
<td className="text-muted small" style={{ whiteSpace: 'pre-line' }}>
{fieldText(row.notes) || '—'}
</td>
</tr> </tr>
) )
})} })}
</tbody> </tbody>
</table> </table>
</div> </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} ) : 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> </div>
) )
} }
+3 -2
View File
@@ -2,8 +2,9 @@
export const GRADING_PATH = '/app/grading' export const GRADING_PATH = '/app/grading'
/** SPA list + links; Laravel API paths remain `.../below-sixty` (see `api/grading.ts`). */ /** 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_BELOW_SIXTY_PATH = `${GRADING_PATH}/below-60`
export const GRADING_DECISIONS_PATH = `${GRADING_BELOW_SIXTY_PATH}/decisions` export const GRADING_DECISIONS_PATH = `${GRADING_PATH}/decisions`
export const GRADING_BELOW_SIXTY_DECISION_EMAIL_EDITOR_PATH = `${GRADING_DECISIONS_PATH}/email-editor` export const GRADING_BELOW_SIXTY_DECISIONS_PATH = `${GRADING_BELOW_SIXTY_PATH}/decisions`
export const GRADING_BELOW_SIXTY_DECISION_EMAIL_EDITOR_PATH = `${GRADING_BELOW_SIXTY_DECISIONS_PATH}/email-editor`
export const GRADING_BELOW_SIXTY_EMAIL_EDITOR_PATH = `${GRADING_PATH}/below-60/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_HOMEWORK_TRACKING_PATH = `${GRADING_PATH}/homework-tracking`
export const GRADING_PARTICIPATION_PATH = `${GRADING_PATH}/participation` export const GRADING_PARTICIPATION_PATH = `${GRADING_PATH}/participation`
+1
View File
@@ -1,5 +1,6 @@
export { BelowSixtyEmailEditorPage } from './BelowSixtyEmailEditorPage' export { BelowSixtyEmailEditorPage } from './BelowSixtyEmailEditorPage'
export { BelowSixtyDecisionEmailEditorPage } from './BelowSixtyDecisionEmailEditorPage' export { BelowSixtyDecisionEmailEditorPage } from './BelowSixtyDecisionEmailEditorPage'
export { BelowSixtyDecisionsPage } from './BelowSixtyDecisionsPage'
export { BelowSixtyPage } from './BelowSixtyPage' export { BelowSixtyPage } from './BelowSixtyPage'
export { GradingDecisionsPage } from './GradingDecisionsPage' export { GradingDecisionsPage } from './GradingDecisionsPage'
export { GradingMainPage } from './GradingMainPage' export { GradingMainPage } from './GradingMainPage'
+6 -1
View File
@@ -41,7 +41,9 @@ export function AssignRolePage() {
useEffect(() => { useEffect(() => {
fetchRolesList() fetchRolesList()
.then((d) => setRoles(d.roles ?? [])) .then((d) => setRoles(d.roles ?? []))
.catch(() => {}) .catch((e: unknown) =>
setError(e instanceof ApiHttpError ? e.message : 'Failed to load roles.'),
)
}, []) }, [])
useEffect(() => { useEffect(() => {
@@ -283,6 +285,9 @@ export function AssignRolePage() {
</div> </div>
<div className="d-flex flex-column gap-2"> <div className="d-flex flex-column gap-2">
<span className="form-label">Select Roles</span> <span className="form-label">Select Roles</span>
{roles.length === 0 ? (
<div className="text-muted small">No roles loaded.</div>
) : null}
{roles.map((r) => { {roles.map((r) => {
const id = String(r.id ?? '') const id = String(r.id ?? '')
return ( return (