fix api security issues and update pages issue
This commit is contained in:
+9
-6
@@ -371,6 +371,9 @@ const GradingMainPage = lazy(() =>
|
||||
const GradingDecisionsPage = lazy(() =>
|
||||
import('./pages/grading/GradingDecisionsPage').then((m) => ({ default: m.GradingDecisionsPage })),
|
||||
)
|
||||
const BelowSixtyDecisionsPage = lazy(() =>
|
||||
import('./pages/grading/BelowSixtyDecisionsPage').then((m) => ({ default: m.BelowSixtyDecisionsPage })),
|
||||
)
|
||||
const BelowSixtyPage = lazy(() =>
|
||||
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/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-sixty-decisions" element={<BelowSixtyDecisionsPage />} />
|
||||
<Route path="administrator/grading/below-60/decisions" element={<BelowSixtyDecisionsPage />} />
|
||||
<Route path="administrator/grading/below-sixty/decisions" element={<BelowSixtyDecisionsPage />} />
|
||||
<Route
|
||||
path="administrator/grading/below-60/decisions/email-editor"
|
||||
element={<BelowSixtyDecisionEmailEditorPage />}
|
||||
@@ -1142,9 +1145,9 @@ export default function App() {
|
||||
<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-sixty-decisions" element={<BelowSixtyDecisionsPage />} />
|
||||
<Route path="grading/below-60/decisions" element={<BelowSixtyDecisionsPage />} />
|
||||
<Route path="grading/below-sixty/decisions" element={<BelowSixtyDecisionsPage />} />
|
||||
<Route
|
||||
path="grading/below-60/decisions/email-editor"
|
||||
element={<BelowSixtyDecisionEmailEditorPage />}
|
||||
|
||||
@@ -50,6 +50,31 @@ export type GradingSectionScorePayload = {
|
||||
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 {
|
||||
const s = sp.toString()
|
||||
return s ? `?${s}` : ''
|
||||
@@ -123,6 +148,23 @@ export async function fetchBelowSixty(searchParams: URLSearchParams): Promise<un
|
||||
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(
|
||||
searchParams: URLSearchParams,
|
||||
): Promise<unknown> {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* 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'
|
||||
|
||||
const BASE = '/api/v1/role-permission'
|
||||
const BASE = '/api/v1/role-permissions'
|
||||
|
||||
type DataEnvelope<T> = { data?: T }
|
||||
|
||||
|
||||
@@ -125,6 +125,17 @@ export async function fetchDashboardRoute(): Promise<ApiEnvelope<DashboardPayloa
|
||||
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> {
|
||||
return apiFetch<LandingPageResponse>(`/api/v1/landing/${kind}`)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { fetchDashboardRoute } from '../api/session'
|
||||
import { fetchDashboardRoute, postRoleSwitch } from '../api/session'
|
||||
import { useAuth } from '../auth/AuthProvider'
|
||||
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`). */
|
||||
export function useContinueWithRole() {
|
||||
@@ -19,19 +20,30 @@ export function useContinueWithRole() {
|
||||
setBusyRole(nextRole)
|
||||
setError(null)
|
||||
try {
|
||||
setSelectedRole(nextRole)
|
||||
const roleLower = nextRole.toLowerCase()
|
||||
const fromApp = from.startsWith('/app') ? from : ''
|
||||
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) {
|
||||
target = fromApp
|
||||
} else if (roleLower === 'parent') {
|
||||
} else if (isParentPortalRole(nextRole)) {
|
||||
target = '/app/parent/home'
|
||||
} else if (roleLower === 'teacher' || roleLower === 'teacher_assistant') {
|
||||
} else if (isTeacherPortalRole(nextRole)) {
|
||||
target = '/app/teacher_dashboard'
|
||||
} else {
|
||||
} else if (!target) {
|
||||
target = '/app/home'
|
||||
try {
|
||||
const dash = await fetchDashboardRoute()
|
||||
@@ -43,7 +55,7 @@ export function useContinueWithRole() {
|
||||
}
|
||||
}
|
||||
|
||||
navigate(target, { replace: true })
|
||||
navigate(target ?? '/app/home', { replace: true })
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Unable to continue.')
|
||||
} finally {
|
||||
|
||||
@@ -9,5 +9,6 @@ export function isTeacherPortalRole(role: string): boolean {
|
||||
}
|
||||
|
||||
export function isParentPortalRole(role: string): boolean {
|
||||
return normalizePortalRole(role) === 'parent'
|
||||
const r = normalizePortalRole(role)
|
||||
return r === 'parent' || r === 'guest'
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { fetchDashboardRoute } from '../api/session'
|
||||
import { useAuth } from '../auth/AuthProvider'
|
||||
import { spaPathFromCiUrl } from '../lib/ciSpaPaths'
|
||||
import { isParentPortalRole, isTeacherPortalRole } from '../lib/portalRoles'
|
||||
|
||||
export function LoginPage() {
|
||||
const { login } = useAuth()
|
||||
@@ -35,15 +36,15 @@ export function LoginPage() {
|
||||
return
|
||||
}
|
||||
|
||||
const singleRole = result.roles.length === 1 ? result.roles[0].toLowerCase() : ''
|
||||
const singleRole = result.roles.length === 1 ? result.roles[0] : ''
|
||||
|
||||
let target = '/app/home'
|
||||
if (from.startsWith('/app')) target = from
|
||||
else if (from.startsWith('/docs')) target = from
|
||||
|
||||
if (result.roles.length === 1 && target === '/app/home') {
|
||||
if (singleRole === 'parent') target = '/app/parent/home'
|
||||
if (singleRole === 'teacher' || singleRole === 'teacher_assistant') {
|
||||
if (isParentPortalRole(singleRole)) target = '/app/parent/home'
|
||||
if (isTeacherPortalRole(singleRole)) {
|
||||
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. */
|
||||
if (result.roles.length === 1) {
|
||||
if (singleRole === 'teacher' || singleRole === 'teacher_assistant') {
|
||||
if (isTeacherPortalRole(singleRole)) {
|
||||
if (target === '/app/home' || target.startsWith('/app/parent')) {
|
||||
target = '/app/teacher_dashboard'
|
||||
}
|
||||
} else if (singleRole === 'parent') {
|
||||
} else if (isParentPortalRole(singleRole)) {
|
||||
if (target === '/app/home' || target.startsWith('/app/teacher')) {
|
||||
target = '/app/parent/home'
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
fetchBelowSixtyDecisionEmailEditor,
|
||||
postBelowSixtyDecisionEmail,
|
||||
} from '../../api/grading'
|
||||
import { GRADING_DECISIONS_PATH } from './gradingPaths'
|
||||
import { GRADING_BELOW_SIXTY_DECISIONS_PATH } from './gradingPaths'
|
||||
|
||||
export function BelowSixtyDecisionEmailEditorPage() {
|
||||
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) {
|
||||
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 & 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>
|
||||
)
|
||||
}
|
||||
@@ -5,7 +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_DECISIONS_PATH,
|
||||
GRADING_BELOW_SIXTY_EMAIL_EDITOR_PATH,
|
||||
GRADING_PATH,
|
||||
GRADING_SCHEDULE_MEETING_PATH,
|
||||
@@ -181,7 +181,7 @@ export function BelowSixtyPage() {
|
||||
{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)}`}>
|
||||
<Link className="btn btn-outline-primary btn-sm" to={`${GRADING_BELOW_SIXTY_DECISIONS_PATH}?school_year=${encodeURIComponent(schoolYear)}`}>
|
||||
School Year Decisions
|
||||
</Link>
|
||||
<Link className="btn btn-outline-secondary btn-sm" to={`${GRADING_PATH}${qs ? `?${qs}` : ''}`}>
|
||||
|
||||
@@ -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 { ApiHttpError } from '../../api/http'
|
||||
import {
|
||||
fetchBelowSixtyDecisionDetails,
|
||||
fetchBelowSixtyDecisions,
|
||||
postBelowSixtyDecision,
|
||||
type AllDecisionRow,
|
||||
fetchAllDecisions,
|
||||
postGenerateAllDecisions,
|
||||
} from '../../api/grading'
|
||||
import { fetchGradingOverview } from '../../api/session'
|
||||
import {
|
||||
GRADING_BELOW_SIXTY_DECISION_EMAIL_EDITOR_PATH,
|
||||
GRADING_BELOW_SIXTY_PATH,
|
||||
GRADING_BELOW_SIXTY_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' },
|
||||
]
|
||||
type SourceKey = 'auto' | 'manual' | 'pending'
|
||||
type GenderKey = 'boys' | 'girls' | 'other'
|
||||
|
||||
function displayScore(value: unknown): string {
|
||||
if (value === null || value === undefined || value === '') return '—'
|
||||
@@ -68,16 +28,11 @@ function numericScore(value: unknown): number | null {
|
||||
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 fieldText(value: unknown): string {
|
||||
return String(value ?? '').trim()
|
||||
}
|
||||
|
||||
function decisionBadgeClass(decision: string): string | null {
|
||||
function decisionBadgeClass(decision: string): string {
|
||||
switch (decision) {
|
||||
case 'Pass':
|
||||
return 'text-bg-success'
|
||||
@@ -90,12 +45,53 @@ function decisionBadgeClass(decision: string): string | null {
|
||||
case 'Withdrawn':
|
||||
return 'text-bg-secondary'
|
||||
default:
|
||||
return null
|
||||
return 'text-bg-secondary'
|
||||
}
|
||||
}
|
||||
|
||||
function fieldText(value: unknown): string {
|
||||
return String(value ?? '').trim()
|
||||
function sourceMeta(source: string): { className: string; label: string } {
|
||||
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() {
|
||||
@@ -103,16 +99,12 @@ export function GradingDecisionsPage() {
|
||||
const schoolYear = searchParams.get('school_year') ?? ''
|
||||
|
||||
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 [savingStudentId, setSavingStudentId] = useState<number | null>(null)
|
||||
const [generating, setGenerating] = useState(false)
|
||||
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) {
|
||||
@@ -141,101 +133,78 @@ export function GradingDecisionsPage() {
|
||||
}
|
||||
}, [schoolYear, searchParams, setSearchParams])
|
||||
|
||||
useEffect(() => {
|
||||
if (!schoolYear) {
|
||||
async function loadRows(params: URLSearchParams, activeSchoolYear: string) {
|
||||
if (!activeSchoolYear) {
|
||||
setRows([])
|
||||
setGenerated(false)
|
||||
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.')
|
||||
const payload = normalizePayload(await fetchAllDecisions(params))
|
||||
setRows(payload.rows)
|
||||
setGenerated(payload.generated)
|
||||
setError(null)
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof ApiHttpError ? e.message : 'Unable to save the decision.')
|
||||
setError(e instanceof ApiHttpError ? e.message : 'Unable to load all decisions.')
|
||||
setRows([])
|
||||
setGenerated(false)
|
||||
} finally {
|
||||
setSavingStudentId(null)
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function openDetails(row: DecisionRow) {
|
||||
const studentId = Number(row.student_id ?? 0)
|
||||
if (!studentId || !schoolYear) return
|
||||
useEffect(() => {
|
||||
void loadRows(searchParams, schoolYear)
|
||||
}, [schoolYear, searchParams])
|
||||
|
||||
setModalStudentName(`${fieldText(row.firstname)} ${fieldText(row.lastname)}`.trim() || 'Student')
|
||||
setModalSemesters([])
|
||||
setModalError(null)
|
||||
setModalLoading(true)
|
||||
setDetailsOpen(true)
|
||||
const stats = useMemo(() => {
|
||||
const totals = { Pass: 0, Other: 0, Pending: 0, Total: rows.length, Trophy: 0 }
|
||||
const gender = {
|
||||
all: { boys: 0, girls: 0, other: 0 },
|
||||
pass: { boys: 0, girls: 0, other: 0 },
|
||||
trophy: { boys: 0, girls: 0, other: 0 },
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
student_id: String(studentId),
|
||||
school_year: schoolYear,
|
||||
})
|
||||
for (const row of rows) {
|
||||
const decision = fieldText(row.decision)
|
||||
const bucket = genderBucket(row.gender)
|
||||
|
||||
gender.all[bucket] += 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
|
||||
}
|
||||
|
||||
if (Boolean(row.is_trophy)) {
|
||||
totals.Trophy += 1
|
||||
gender.trophy[bucket] += 1
|
||||
}
|
||||
}
|
||||
|
||||
return { totals, gender }
|
||||
}, [rows])
|
||||
|
||||
async function onGenerate() {
|
||||
if (!schoolYear) return
|
||||
|
||||
setGenerating(true)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
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)
|
||||
const payload = await postGenerateAllDecisions({ school_year: schoolYear })
|
||||
await loadRows(searchParams, schoolYear)
|
||||
setSuccess(`Decisions generated for ${payload.saved_count ?? 0} students.`)
|
||||
} catch (e: unknown) {
|
||||
setModalError(e instanceof ApiHttpError ? e.message : 'Unable to load the student details.')
|
||||
setError(e instanceof ApiHttpError ? e.message : 'Unable to generate decisions.')
|
||||
} finally {
|
||||
setModalLoading(false)
|
||||
setGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,12 +212,20 @@ export function GradingDecisionsPage() {
|
||||
<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>
|
||||
<h2 className="mb-1">Student Decisions — All Students</h2>
|
||||
<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">
|
||||
<Link className="btn btn-outline-secondary btn-sm" to={`${GRADING_BELOW_SIXTY_PATH}?school_year=${encodeURIComponent(schoolYear)}&semester=fall`}>
|
||||
Back to Below 60
|
||||
<div className="d-flex gap-2 flex-wrap d-print-none">
|
||||
<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 className="btn btn-outline-secondary btn-sm" to={`${GRADING_PATH}${schoolYear ? `?school_year=${encodeURIComponent(schoolYear)}` : ''}`}>
|
||||
Grading
|
||||
@@ -256,7 +233,7 @@ export function GradingDecisionsPage() {
|
||||
</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">
|
||||
<form
|
||||
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>
|
||||
<input
|
||||
className="form-control form-control-sm"
|
||||
style={{ minWidth: 160 }}
|
||||
style={{ minWidth: 180 }}
|
||||
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">
|
||||
<button type="submit" className="btn btn-sm btn-secondary">
|
||||
Apply
|
||||
</button>
|
||||
</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>
|
||||
<div className="text-muted small mt-2">
|
||||
Year score ≥ 60 → <strong>Pass</strong> (auto) | Year score < 60 → pulled from below-60 decisions
|
||||
</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="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 className="card border-success shadow-sm h-100">
|
||||
<div className="card-body text-center">
|
||||
<div className="fs-4 fw-bold text-success">{stats.totals.Pass}</div>
|
||||
<div className="text-muted small">Pass</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 className="card border-primary shadow-sm h-100">
|
||||
<div className="card-body text-center">
|
||||
<div className="fs-4 fw-bold text-primary">{stats.totals.Other}</div>
|
||||
<div className="text-muted small">Other Decision</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 className="card border-warning shadow-sm h-100">
|
||||
<div className="card-body text-center">
|
||||
<div className="fs-4 fw-bold text-warning">{stats.totals.Pending}</div>
|
||||
<div className="text-muted small">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">Year Score Under 50</div>
|
||||
<div className="display-6 text-danger">{metrics.urgent}</div>
|
||||
<div className="card shadow-sm h-100">
|
||||
<div className="card-body text-center">
|
||||
<div className="fs-4 fw-bold">{stats.totals.Total}</div>
|
||||
<div className="text-muted small">Total</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}
|
||||
{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>
|
||||
<div className="alert alert-info">No semester scores found for this school year.</div>
|
||||
) : null}
|
||||
|
||||
{!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">
|
||||
<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>Student Name</th>
|
||||
<th>Section</th>
|
||||
<th className="text-center">Fall Score</th>
|
||||
<th className="text-center">Spring Score</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>
|
||||
<th className="text-center">Decision</th>
|
||||
<th className="text-center">Source</th>
|
||||
<th>Notes</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 yearScore = numericScore(row.year_score)
|
||||
const rowClass = yearScore !== null && yearScore < 60
|
||||
? (yearScore < 50 ? 'table-danger' : 'table-warning')
|
||||
: ''
|
||||
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)}`
|
||||
const source = sourceMeta(fieldText(row.source))
|
||||
|
||||
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">{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">
|
||||
<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>
|
||||
{decision ? (
|
||||
<div className="d-inline-flex flex-wrap gap-1 justify-content-center">
|
||||
<span className={`badge ${decisionBadgeClass(decision)}`}>{decision}</span>
|
||||
{row.is_trophy ? <span className="badge text-bg-dark">Trophy</span> : null}
|
||||
</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 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>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 mb-4 d-flex gap-2 flex-wrap">
|
||||
{DECISION_OPTIONS.filter((option) => option.value !== '').map((option) => {
|
||||
const badgeClass = decisionBadgeClass(option.value)
|
||||
if (!badgeClass) return null
|
||||
return (
|
||||
<span key={option.value} className={`badge ${badgeClass} px-3 py-2`}>
|
||||
{option.label}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{detailsOpen ? (
|
||||
<div
|
||||
className="modal d-block"
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
style={{ backgroundColor: 'rgba(0, 0, 0, 0.45)' }}
|
||||
onClick={() => setDetailsOpen(false)}
|
||||
>
|
||||
<div className="modal-dialog modal-lg modal-dialog-scrollable" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h5 className="modal-title">Score Details{modalStudentName ? ` — ${modalStudentName}` : ''}</h5>
|
||||
<button type="button" className="btn-close" onClick={() => setDetailsOpen(false)} />
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
{modalError ? <div className="alert alert-danger">{modalError}</div> : null}
|
||||
{modalLoading ? <p className="text-muted">Loading details…</p> : null}
|
||||
|
||||
{!modalLoading && !modalError && modalSemesters.length === 0 ? (
|
||||
<div className="text-muted">No detailed score data found.</div>
|
||||
) : null}
|
||||
|
||||
{!modalLoading &&
|
||||
!modalError &&
|
||||
modalSemesters.map((semester) => (
|
||||
<div key={`${semester.semester}-${semester.class_section_name ?? ''}`} className="mb-4">
|
||||
<h6 className="mb-2">
|
||||
{fieldText(semester.semester) || 'Semester'}
|
||||
{fieldText(semester.class_section_name) ? ` — ${fieldText(semester.class_section_name)}` : ''}
|
||||
</h6>
|
||||
<div className="table-responsive">
|
||||
<table className="table table-sm table-bordered mb-2">
|
||||
<thead className="table-light">
|
||||
<tr>
|
||||
<th>Item</th>
|
||||
<th className="text-end">Score</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[
|
||||
['Homework Avg', semester.homework_avg],
|
||||
['Project Avg', semester.project_avg],
|
||||
['Participation', semester.participation_score],
|
||||
['Test Avg', semester.test_avg],
|
||||
['PTAP Score', semester.ptap_score],
|
||||
['Attendance', semester.attendance_score],
|
||||
['Midterm Score', semester.midterm_exam_score],
|
||||
['Final Exam', semester.final_exam_score],
|
||||
['Semester Score', semester.semester_score],
|
||||
]
|
||||
.filter(([, value]) => value !== null && value !== undefined && value !== '')
|
||||
.map(([label, value]) => (
|
||||
<tr key={String(label)}>
|
||||
<td>{label}</td>
|
||||
<td className="text-end">{displayScore(value)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{semester.comments && Object.keys(semester.comments).length > 0 ? (
|
||||
<div>
|
||||
<div className="fw-semibold mb-2">Comments</div>
|
||||
<div className="d-flex flex-column gap-2">
|
||||
{Object.entries(semester.comments).map(([type, text]) => (
|
||||
<div key={type} className="border-start border-4 ps-3 py-2 bg-light">
|
||||
<div className="fw-semibold text-capitalize">{type.replace(/_/g, ' ')}</div>
|
||||
<div>{text}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn btn-outline-secondary" onClick={() => setDetailsOpen(false)}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
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_DECISIONS_PATH = `${GRADING_PATH}/decisions`
|
||||
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_HOMEWORK_TRACKING_PATH = `${GRADING_PATH}/homework-tracking`
|
||||
export const GRADING_PARTICIPATION_PATH = `${GRADING_PATH}/participation`
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export { BelowSixtyEmailEditorPage } from './BelowSixtyEmailEditorPage'
|
||||
export { BelowSixtyDecisionEmailEditorPage } from './BelowSixtyDecisionEmailEditorPage'
|
||||
export { BelowSixtyDecisionsPage } from './BelowSixtyDecisionsPage'
|
||||
export { BelowSixtyPage } from './BelowSixtyPage'
|
||||
export { GradingDecisionsPage } from './GradingDecisionsPage'
|
||||
export { GradingMainPage } from './GradingMainPage'
|
||||
|
||||
@@ -41,7 +41,9 @@ export function AssignRolePage() {
|
||||
useEffect(() => {
|
||||
fetchRolesList()
|
||||
.then((d) => setRoles(d.roles ?? []))
|
||||
.catch(() => {})
|
||||
.catch((e: unknown) =>
|
||||
setError(e instanceof ApiHttpError ? e.message : 'Failed to load roles.'),
|
||||
)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -283,6 +285,9 @@ export function AssignRolePage() {
|
||||
</div>
|
||||
<div className="d-flex flex-column gap-2">
|
||||
<span className="form-label">Select Roles</span>
|
||||
{roles.length === 0 ? (
|
||||
<div className="text-muted small">No roles loaded.</div>
|
||||
) : null}
|
||||
{roles.map((r) => {
|
||||
const id = String(r.id ?? '')
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user