diff --git a/src/App.tsx b/src/App.tsx index 4c55600..13881f4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { } /> } /> } /> - } /> - } /> - } /> + } /> + } /> + } /> } @@ -1142,9 +1145,9 @@ export default function App() { } /> } /> } /> - } /> - } /> - } /> + } /> + } /> + } /> } diff --git a/src/api/grading.ts b/src/api/grading.ts index 9e5ba13..78b7b25 100644 --- a/src/api/grading.ts +++ b/src/api/grading.ts @@ -50,6 +50,31 @@ export type GradingSectionScorePayload = { missing_ok_map?: Record } +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 { + return apiFetch(`${BASE}/decisions${q(searchParams)}`) +} + +export async function postGenerateAllDecisions(body: Record): 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 { diff --git a/src/api/rolePermission.ts b/src/api/rolePermission.ts index f152e10..e7fa1bb 100644 --- a/src/api/rolePermission.ts +++ b/src/api/rolePermission.ts @@ -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 = { data?: T } diff --git a/src/api/session.ts b/src/api/session.ts index 85cb776..bbac468 100644 --- a/src/api/session.ts +++ b/src/api/session.ts @@ -125,6 +125,17 @@ export async function fetchDashboardRoute(): Promise>('/api/v1/dashboard/route') } +export async function postRoleSwitch(role: string): Promise> { + return apiFetch>('/api/v1/role-switcher/switch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ role }), + }) +} + export async function fetchLandingAdminDashboard(kind: 'admin' | 'administrator'): Promise { return apiFetch(`/api/v1/landing/${kind}`) } diff --git a/src/hooks/useContinueWithRole.ts b/src/hooks/useContinueWithRole.ts index 8dcce8b..3ba6b57 100644 --- a/src/hooks/useContinueWithRole.ts +++ b/src/hooks/useContinueWithRole.ts @@ -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 { diff --git a/src/lib/portalRoles.ts b/src/lib/portalRoles.ts index f05ff43..2cdc64c 100644 --- a/src/lib/portalRoles.ts +++ b/src/lib/portalRoles.ts @@ -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' } diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index c91542d..14d725e 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -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' } diff --git a/src/pages/grading/BelowSixtyDecisionEmailEditorPage.tsx b/src/pages/grading/BelowSixtyDecisionEmailEditorPage.tsx index e31b62b..340e1a2 100644 --- a/src/pages/grading/BelowSixtyDecisionEmailEditorPage.tsx +++ b/src/pages/grading/BelowSixtyDecisionEmailEditorPage.tsx @@ -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 ( diff --git a/src/pages/grading/BelowSixtyDecisionsPage.tsx b/src/pages/grading/BelowSixtyDecisionsPage.tsx new file mode 100644 index 0000000..1773f62 --- /dev/null +++ b/src/pages/grading/BelowSixtyDecisionsPage.tsx @@ -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 +} + +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([]) + const [loading, setLoading] = useState(true) + const [savingStudentId, setSavingStudentId] = useState(null) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(null) + const [modalStudentName, setModalStudentName] = useState('') + const [modalSemesters, setModalSemesters] = useState([]) + const [modalLoading, setModalLoading] = useState(false) + const [modalError, setModalError] = useState(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) { + 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 ( +
+
+
+

School Year Decisions

+
Whole Year • {schoolYear || 'Current school year'}
+
+
+ + Back to Below 60 + + + All Decisions + + + Grading + +
+
+ +
+
+
{ + event.preventDefault() + const next = new URLSearchParams() + if (filterYear.trim() !== '') next.set('school_year', filterYear.trim()) + setSearchParams(next) + }} + > +
+ + setFilterYear(event.target.value)} + placeholder="2025-2026" + /> +
+
+ +
+
+
+
+ +
+
+
+
+
Students
+
{metrics.total}
+
+
+
+
+
+
+
Pending
+
{metrics.pending}
+
+
+
+
+
+
+
Decided
+
{metrics.decided}
+
+
+
+
+
+
+
Year Score Under 50
+
{metrics.urgent}
+
+
+
+
+ + {success ?
{success}
: null} + {error ?
{error}
: null} + {loading ?

Loading decisions…

: null} + + {!loading && rows.length === 0 ? ( +
No students below 60 for the whole year in {schoolYear}.
+ ) : null} + + {!loading && rows.length > 0 ? ( + <> +
+ + + + + + + + + + + + + {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 ( + + + + + +
Student NameAgeClass-SectionYear ScoreComments / Rationale & DecisionBelow-60 Decision
{studentName}{fieldText(row.age) || '—'}{fieldText(row.class_section_name) || '—'} +
{displayScore(row.year_score)}
+ +
+
void onSaveDecision(event)}> + + +