add grading, attendnace management

This commit is contained in:
root
2026-04-29 17:39:37 -04:00
parent ea495f8382
commit dc7acf2536
27 changed files with 4150 additions and 960 deletions
+2 -2
View File
@@ -1654,7 +1654,7 @@
"version": "19.2.14", "version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
@@ -2473,7 +2473,7 @@
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/debug": { "node_modules/debug": {
+11 -5
View File
@@ -67,6 +67,9 @@ const RoleSelectPage = lazy(() =>
import('./pages/RoleSelectPage').then((m) => ({ default: m.RoleSelectPage })), import('./pages/RoleSelectPage').then((m) => ({ default: m.RoleSelectPage })),
) )
const AppHome = lazy(() => import('./pages/AppHome').then((m) => ({ default: m.AppHome }))) const AppHome = lazy(() => import('./pages/AppHome').then((m) => ({ default: m.AppHome })))
const AdminLandingDashboardPage = lazy(() =>
import('./pages/landing/AdminLandingDashboardPage').then((m) => ({ default: m.AdminLandingDashboardPage })),
)
const CiSitemapPage = lazy(() => const CiSitemapPage = lazy(() =>
import('./pages/CiSitemapPage').then((m) => ({ default: m.CiSitemapPage })), import('./pages/CiSitemapPage').then((m) => ({ default: m.CiSitemapPage })),
) )
@@ -521,8 +524,8 @@ const ReimbursementsUnderProcessingPage = lazy(() =>
const RefundsListPage = lazy(() => const RefundsListPage = lazy(() =>
import('./pages/refunds/RefundsListPage').then((m) => ({ default: m.RefundsListPage })), import('./pages/refunds/RefundsListPage').then((m) => ({ default: m.RefundsListPage })),
) )
const RfidComingSoonPage = lazy(() => const BadgeScanLogsPage = lazy(() =>
import('./pages/rfid/RfidComingSoonPage').then((m) => ({ default: m.RfidComingSoonPage })), import('./pages/rfid/BadgeScanLogsPage').then((m) => ({ default: m.BadgeScanLogsPage })),
) )
const StaffIndexPage = lazy(() => const StaffIndexPage = lazy(() =>
import('./pages/staff/StaffIndexPage').then((m) => ({ default: m.StaffIndexPage })), import('./pages/staff/StaffIndexPage').then((m) => ({ default: m.StaffIndexPage })),
@@ -991,6 +994,7 @@ export default function App() {
<Route index element={<Navigate to="home" replace />} /> <Route index element={<Navigate to="home" replace />} />
<Route path="select-role" element={<RoleSelectPage />} /> <Route path="select-role" element={<RoleSelectPage />} />
<Route path="home" element={<AppHome />} /> <Route path="home" element={<AppHome />} />
<Route path="landing-page/admin-dashboard" element={<AdminLandingDashboardPage />} />
<Route path="admin/dashboard" element={<AppHome />} /> <Route path="admin/dashboard" element={<AppHome />} />
<Route path="administrator/dashboard" element={<AppHome />} /> <Route path="administrator/dashboard" element={<AppHome />} />
<Route path="administrator/administratordashboard" element={<AppHome />} /> <Route path="administrator/administratordashboard" element={<AppHome />} />
@@ -1107,7 +1111,8 @@ export default function App() {
<Route path="administrator/grading/placement" element={<PlacementSectionPage />} /> <Route path="administrator/grading/placement" element={<PlacementSectionPage />} />
<Route path="administrator/grading/placement-batch/:batchId" element={<PlacementBatchPage />} /> <Route path="administrator/grading/placement-batch/:batchId" element={<PlacementBatchPage />} />
<Route path="administrator/grading/schedule-meeting" element={<ScheduleMeetingPage />} /> <Route path="administrator/grading/schedule-meeting" element={<ScheduleMeetingPage />} />
<Route path="administrator/grading/scores/:scoreType" element={<GradingScoreEntryPage />} /> <Route path="administrator/grading/scores/:scoreType/:classSectionId" element={<GradingScoreEntryPage />} />
<Route path="administrator/grading/scores/:scoreType/:classSectionId/:studentId" element={<GradingScoreEntryPage />} />
<Route path="grading/below-60/email-editor" element={<BelowSixtyEmailEditorPage />} /> <Route path="grading/below-60/email-editor" element={<BelowSixtyEmailEditorPage />} />
<Route path="grading/below-60" element={<BelowSixtyPage />} /> <Route path="grading/below-60" element={<BelowSixtyPage />} />
<Route path="grading/below-sixty/email-editor" element={<BelowSixtyEmailEditorPage />} /> <Route path="grading/below-sixty/email-editor" element={<BelowSixtyEmailEditorPage />} />
@@ -1118,7 +1123,8 @@ export default function App() {
<Route path="grading/placement-batch/:batchId" element={<PlacementBatchPage />} /> <Route path="grading/placement-batch/:batchId" element={<PlacementBatchPage />} />
<Route path="grading/placement" element={<PlacementSectionPage />} /> <Route path="grading/placement" element={<PlacementSectionPage />} />
<Route path="grading/schedule-meeting" element={<ScheduleMeetingPage />} /> <Route path="grading/schedule-meeting" element={<ScheduleMeetingPage />} />
<Route path="grading/scores/:scoreType" element={<GradingScoreEntryPage />} /> <Route path="grading/scores/:scoreType/:classSectionId" element={<GradingScoreEntryPage />} />
<Route path="grading/scores/:scoreType/:classSectionId/:studentId" element={<GradingScoreEntryPage />} />
<Route path="grading/main" element={<GradingMainPage />} /> <Route path="grading/main" element={<GradingMainPage />} />
<Route path="grading" element={<GradingMainPage />} /> <Route path="grading" element={<GradingMainPage />} />
<Route path="administrator/inventory/book" element={<InventoryBookIndexPage />} /> <Route path="administrator/inventory/book" element={<InventoryBookIndexPage />} />
@@ -1279,7 +1285,7 @@ export default function App() {
<Route path="rolepermission/permissions/create" element={<AddPermissionPage />} /> <Route path="rolepermission/permissions/create" element={<AddPermissionPage />} />
<Route path="rolepermission/permissions/:permissionId/edit" element={<EditPermissionPage />} /> <Route path="rolepermission/permissions/:permissionId/edit" element={<EditPermissionPage />} />
<Route path="rolepermission/assign" element={<AssignRolePage />} /> <Route path="rolepermission/assign" element={<AssignRolePage />} />
<Route path="rfid_coming_soon" element={<RfidComingSoonPage />} /> <Route path="rfid_coming_soon" element={<BadgeScanLogsPage />} />
<Route path="nav-builder" element={<NavBuilderPage />} /> <Route path="nav-builder" element={<NavBuilderPage />} />
<Route path="nav-builder/index" element={<NavBuilderPage />} /> <Route path="nav-builder/index" element={<NavBuilderPage />} />
<Route path="nav-builder/edit" element={<NavBuilderPage />} /> <Route path="nav-builder/edit" element={<NavBuilderPage />} />
+39
View File
@@ -0,0 +1,39 @@
import { apiFetch } from './http'
export type BadgeScanLogRow = {
id: number
user_id: number | null
card_id: string
scan_time: string
school_year: string | null
semester: string | null
user_firstname: string | null
user_lastname: string | null
student_firstname: string | null
student_lastname: string | null
}
export type BadgeScanLogsResponse = {
logs: BadgeScanLogRow[]
}
function unwrap(body: unknown): unknown {
if (body === null || body === undefined || typeof body !== 'object') return body
const o = body as Record<string, unknown>
if ('data' in o && o.data !== null && typeof o.data === 'object') return o.data
return body
}
export async function fetchBadgeScanLogs(): Promise<BadgeScanLogsResponse> {
const raw = await apiFetch<unknown>('/api/v1/badge_scan/logs')
const data = unwrap(raw) as BadgeScanLogsResponse
return { logs: Array.isArray(data?.logs) ? data.logs : [] }
}
export function displayNameOf(row: BadgeScanLogRow): string {
const userFull = [row.user_firstname, row.user_lastname].filter(Boolean).join(' ').trim()
if (userFull) return userFull
const stuFull = [row.student_firstname, row.student_lastname].filter(Boolean).join(' ').trim()
if (stuFull) return stuFull
return row.card_id
}
+82 -14
View File
@@ -1,10 +1,10 @@
/** /**
* Administrator grading API (parity with CI `Views/grading/*.php`). * Administrator grading API (parity with CI `Views/grading/*.php`).
* Base: `/api/v1/administrator/grading/...` with JWT. * Base: `/api/v1/grading/...` with JWT.
*/ */
import { apiFetch } from './http' import { apiFetch } from './http'
const BASE = '/api/v1/administrator/grading' const BASE = '/api/v1/grading'
export type GradingScoreRow = { export type GradingScoreRow = {
id?: number id?: number
@@ -12,40 +12,108 @@ export type GradingScoreRow = {
comment?: string | null comment?: string | null
} }
export type GradingSectionScoreStudent = {
student_id: number
firstname?: string | null
lastname?: string | null
school_id?: string | null
score?: number | string | null
scores?: Record<string, number | string | null>
comments?: Record<
string,
{
comment?: string | null
comment_review?: string | null
commented_by?: string | number | null
reviewed_by?: string | number | null
}
>
}
export type GradingScoreFormPayload = { export type GradingScoreFormPayload = {
scoresLocked?: boolean scoresLocked?: boolean
student?: { id?: number; firstname?: string; lastname?: string } student?: { id?: number; firstname?: string; lastname?: string; school_id?: string | null }
classSectionId?: number classSectionId?: number
class_section_name?: string | null
scores?: GradingScoreRow[] scores?: GradingScoreRow[]
} }
export type GradingSectionScorePayload = {
ok?: boolean
students?: GradingSectionScoreStudent[]
headers?: number[]
semester?: string
school_year?: string
class_section_id?: number | string
class_section_name?: string | null
scores_locked?: boolean
missing_ok_map?: Record<string, unknown>
}
function q(sp: URLSearchParams): string { function q(sp: URLSearchParams): string {
const s = sp.toString() const s = sp.toString()
return s ? `?${s}` : '' return s ? `?${s}` : ''
} }
export async function fetchGradingMain(searchParams: URLSearchParams): Promise<unknown> { export async function fetchGradingMain(searchParams: URLSearchParams): Promise<unknown> {
return apiFetch(`${BASE}/main${q(searchParams)}`) return apiFetch(`${BASE}/overview${q(searchParams)}`)
} }
export async function fetchGradingScoreForm( export async function fetchGradingScoreForm(
scoreType: string, scoreType: string,
studentId: string,
classSectionId: string, classSectionId: string,
studentId: string,
searchParams: URLSearchParams,
): Promise<GradingScoreFormPayload> { ): Promise<GradingScoreFormPayload> {
const qs = new URLSearchParams({ return apiFetch(`${BASE}/scores/${encodeURIComponent(scoreType)}/${encodeURIComponent(classSectionId)}/${encodeURIComponent(studentId)}${q(searchParams)}`)
student_id: studentId, }
class_section_id: classSectionId,
}) function scoreCollectionPath(scoreType: string): string {
return apiFetch(`${BASE}/scores/${encodeURIComponent(scoreType)}?${qs}`) switch (scoreType) {
case 'homework':
return '/api/v1/scores/homework'
case 'quiz':
return '/api/v1/scores/quizzes'
case 'project':
return '/api/v1/scores/projects'
case 'midterm':
return '/api/v1/scores/midterm'
case 'final':
return '/api/v1/scores/final'
case 'comments':
return '/api/v1/scores/comments'
default:
throw new Error(`Unsupported score type: ${scoreType}`)
}
}
export async function fetchGradingSectionScoreTable(
scoreType: string,
classSectionId: string,
searchParams: URLSearchParams,
): Promise<GradingSectionScorePayload> {
const next = new URLSearchParams(searchParams)
next.set('class_section_id', classSectionId)
return apiFetch(`${scoreCollectionPath(scoreType)}${q(next)}`)
} }
export async function postGradingScoreUpdate( export async function postGradingScoreUpdate(
body: Record<string, unknown>,
): Promise<unknown> {
return apiFetch(`${BASE}/scores`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
}
export async function postGradingSectionScoreUpdate(
scoreType: string, scoreType: string,
body: Record<string, unknown>, body: Record<string, unknown>,
): Promise<unknown> { ): Promise<unknown> {
return apiFetch(`${BASE}/scores/${encodeURIComponent(scoreType)}`, { const method = scoreType === 'comments' ? 'PUT' : 'POST'
method: 'POST', return apiFetch(scoreCollectionPath(scoreType), {
method,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body), body: JSON.stringify(body),
}) })
@@ -89,11 +157,11 @@ export async function fetchHomeworkTracking(
} }
export async function fetchParticipation(searchParams: URLSearchParams): Promise<unknown> { export async function fetchParticipation(searchParams: URLSearchParams): Promise<unknown> {
return apiFetch(`${BASE}/participation${q(searchParams)}`) return apiFetch(`/api/v1/scores/participation${q(searchParams)}`)
} }
export async function postParticipation(body: Record<string, unknown>): Promise<unknown> { export async function postParticipation(body: Record<string, unknown>): Promise<unknown> {
return apiFetch(`${BASE}/participation`, { return apiFetch('/api/v1/scores/participation', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body), body: JSON.stringify(body),
+51 -12
View File
@@ -85,32 +85,51 @@ export async function fetchManualPayPage(params: {
const qs = new URLSearchParams() const qs = new URLSearchParams()
qs.set('search_term', params.search_term) qs.set('search_term', params.search_term)
if (params.page != null) qs.set('page', String(params.page)) if (params.page != null) qs.set('page', String(params.page))
return apiFetch(`/api/v1/administrator/payments/manual-pay?${qs}`) return apiFetch(`/api/v1/finance/manual-pay/search?${qs}`)
} }
type ManualPaySuggestRow = { id?: number | string; label?: string; email?: string; phone?: string } type ManualPaySuggestRow = { id?: number | string; label?: string; email?: string; phone?: string }
export async function fetchManualPaySuggest(q: string): Promise<ManualPaySuggestRow[]> { export async function fetchManualPaySuggest(q: string): Promise<ManualPaySuggestRow[]> {
const qs = new URLSearchParams({ q }) const qs = new URLSearchParams({ q })
const body = await apiFetch<{ suggestions?: unknown[]; data?: unknown[] }>( const body = await apiFetch<{ items?: unknown[]; suggestions?: unknown[]; data?: unknown[] }>(
`/api/v1/administrator/payments/manual-pay/suggest?${qs}`, `/api/v1/finance/manual-pay/suggest?${qs}`,
) )
const raw = body.suggestions ?? body.data ?? [] const raw = body.items ?? body.suggestions ?? body.data ?? []
return Array.isArray(raw) ? (raw as ManualPaySuggestRow[]) : [] return Array.isArray(raw) ? (raw as ManualPaySuggestRow[]) : []
} }
export async function submitManualPayUpdate(formData: FormData): Promise<{ ok?: boolean; message?: string }> { export async function submitManualPayUpdate(formData: FormData): Promise<{ ok?: boolean; message?: string }> {
return postMultipart('/api/v1/administrator/payments/manual-pay/update', formData) const invoiceId = String(formData.get('invoice_id') ?? '').trim()
if (!invoiceId) {
throw new ApiHttpError('Invoice is required.', 422, { errors: { invoice_id: ['Invoice is required.'] } })
}
const payload = new FormData()
const amount = formData.get('amount') ?? formData.get('paid_amount')
if (amount != null) payload.set('amount', String(amount))
for (const [key, value] of formData.entries()) {
if (key === 'invoice_id' || key === 'paid_amount') continue
payload.append(key, value)
}
return postMultipart(`/api/v1/finance/manual-pay/invoices/${encodeURIComponent(invoiceId)}/payments`, payload)
} }
export async function submitManualPayEdit(formData: FormData): Promise<{ ok?: boolean; message?: string }> { export async function submitManualPayEdit(formData: FormData): Promise<{ ok?: boolean; message?: string }> {
return postMultipart('/api/v1/administrator/payments/manual-pay/edit', formData) const paymentId = String(formData.get('payment_id') ?? '').trim()
if (!paymentId) {
throw new ApiHttpError('Payment is required.', 422, { errors: { payment_id: ['Payment is required.'] } })
}
formData.delete('payment_id')
return apiFetch(`/api/v1/finance/manual-pay/payments/${encodeURIComponent(paymentId)}`, {
method: 'PUT',
body: formData,
})
} }
/** Authenticated URL for embedding or downloading check/card files (legacy CI: serveCheckFile). */ /** Authenticated URL for embedding or downloading check/card files (legacy CI: serveCheckFile). */
export function manualPayCheckFileUrl(tokenOrKey: string, mode: 'inline' | 'download'): string { export function manualPayCheckFileUrl(tokenOrKey: string, mode: 'inline' | 'download'): string {
const enc = encodeURIComponent(tokenOrKey) const enc = encodeURIComponent(tokenOrKey)
return `/api/v1/administrator/payments/check-file/${enc}/${mode}` return `/api/v1/finance/manual-pay/files/${enc}?mode=${encodeURIComponent(mode)}`
} }
export async function fetchAuthenticatedBlobUrl(apiPath: string): Promise<string> { export async function fetchAuthenticatedBlobUrl(apiPath: string): Promise<string> {
@@ -213,21 +232,41 @@ export async function fetchExtraChargesPage(params?: {
if (params?.page != null) qs.set('page', String(params.page)) if (params?.page != null) qs.set('page', String(params.page))
if (params?.parent_id != null) qs.set('parent_id', String(params.parent_id)) if (params?.parent_id != null) qs.set('parent_id', String(params.parent_id))
const suffix = qs.toString() ? `?${qs}` : '' const suffix = qs.toString() ? `?${qs}` : ''
return apiFetch(`/api/v1/administrator/charges${suffix}`) return Promise.all([
apiFetch<{
rows?: ExtraChargeRow[]
school_year?: string
semester?: string
pager?: { current_page?: number; last_page?: number }
}>(`/api/v1/extra-charges${suffix}`),
apiFetch<{
schoolYear?: string
schoolYears?: string[]
semester?: string
parents?: Array<{ id: number; firstname?: string; lastname?: string }>
}>('/api/v1/extra-charges/options'),
]).then(([list, options]) => ({
rows: Array.isArray(list?.rows) ? list.rows : [],
schoolYear: options?.schoolYear ?? list?.school_year,
schoolYears: Array.isArray(options?.schoolYears) ? options.schoolYears : [],
semester: options?.semester ?? list?.semester,
parents: Array.isArray(options?.parents) ? options.parents : [],
pager: list?.pager,
}))
} }
export async function fetchChargeInvoicesForParent(parentId: number | string): Promise< export async function fetchChargeInvoicesForParent(parentId: number | string): Promise<
Array<{ id?: number; invoice_number?: string }> Array<{ id?: number; invoice_number?: string }>
> { > {
const body = await apiFetch<{ invoices?: unknown[] }>( const body = await apiFetch<{ results?: unknown[] }>(
`/api/v1/administrator/charges/invoices-for-parent?parent_id=${encodeURIComponent(String(parentId))}`, `/api/v1/extra-charges/invoices?parent_id=${encodeURIComponent(String(parentId))}`,
) )
const raw = body.invoices const raw = body.results
return Array.isArray(raw) ? (raw as Array<{ id?: number; invoice_number?: string }>) : [] return Array.isArray(raw) ? (raw as Array<{ id?: number; invoice_number?: string }>) : []
} }
export async function submitExtraCharge(formData: FormData): Promise<{ ok?: boolean; message?: string }> { export async function submitExtraCharge(formData: FormData): Promise<{ ok?: boolean; message?: string }> {
return postMultipart('/api/v1/administrator/charges', formData) return postMultipart('/api/v1/extra-charges', formData)
} }
export type FinancialDetailedJson = { export type FinancialDetailedJson = {
+82 -1
View File
@@ -21,9 +21,90 @@ export type CombinedReportPayload = {
[key: string]: unknown [key: string]: unknown
} }
type ScorePredictorApiRow = {
student_id?: number
school_id?: string | null
firstname?: string | null
lastname?: string | null
class_section_id?: number | string | null
fall_score?: string | number | null
spring_score?: string | number | null
final_average?: string | number | null
required_spring_score?: string | number | null
winning_risk?: string | null
required_spring_to_pass?: string | number | null
failure_risk?: string | null
status?: string | null
trophy_awarded?: boolean
trophy_reason?: string | null
}
type ScorePredictorApiPayload = {
ok?: boolean
students?: ScorePredictorApiRow[]
school_year?: string | null
class_sections?: Array<Record<string, unknown>>
selected_class_section_id?: number | string | null
semester?: string | null
message?: string | null
}
function mapScorePredictorPayload(raw: ScorePredictorApiPayload): CombinedReportPayload {
const rows = Array.isArray(raw.students)
? raw.students.map((student) => ({
school_id: student.school_id ?? '—',
firstname: student.firstname ?? '—',
lastname: student.lastname ?? '—',
class_section_id: student.class_section_id ?? '—',
fall_score: student.fall_score ?? 'N/A',
spring_score: student.spring_score ?? 'N/A',
final_average: student.final_average ?? 'N/A',
required_spring_score: student.required_spring_score ?? 'N/A',
winning_risk: student.winning_risk ?? '—',
required_spring_to_pass: student.required_spring_to_pass ?? 'N/A',
failure_risk: student.failure_risk ?? '—',
status: student.status ?? '—',
trophy_awarded: student.trophy_awarded ? 'Yes' : 'No',
trophy_reason: student.trophy_reason ?? '—',
}))
: []
const subtitleParts = [
raw.school_year ? `School Year: ${raw.school_year}` : null,
raw.semester ? `Semester: ${raw.semester}` : null,
raw.selected_class_section_id ? `Class Section: ${raw.selected_class_section_id}` : null,
].filter(Boolean)
return {
title: 'Score Analysis',
subtitle: subtitleParts.join(' • '),
message: raw.message ?? undefined,
columns: [
'school_id',
'firstname',
'lastname',
'class_section_id',
'fall_score',
'spring_score',
'final_average',
'required_spring_score',
'winning_risk',
'required_spring_to_pass',
'failure_risk',
'status',
'trophy_awarded',
'trophy_reason',
],
rows,
}
}
export function unwrapCombinedReport(raw: unknown): CombinedReportPayload | null { export function unwrapCombinedReport(raw: unknown): CombinedReportPayload | null {
if (raw == null || typeof raw !== 'object') return null if (raw == null || typeof raw !== 'object') return null
const o = raw as ApiEnvelope<CombinedReportPayload> & CombinedReportPayload const o = raw as ApiEnvelope<CombinedReportPayload> & CombinedReportPayload
if (Array.isArray((raw as ScorePredictorApiPayload).students)) {
return mapScorePredictorPayload(raw as ScorePredictorApiPayload)
}
if (o.data != null && typeof o.data === 'object') { if (o.data != null && typeof o.data === 'object') {
return o.data as CombinedReportPayload return o.data as CombinedReportPayload
} }
@@ -46,5 +127,5 @@ export async function fetchCombinedReport(params?: {
q.set('class_section_id', String(params.class_section_id)) q.set('class_section_id', String(params.class_section_id))
} }
const qs = q.size > 0 ? `?${q.toString()}` : '' const qs = q.size > 0 ? `?${q.toString()}` : ''
return apiFetch<unknown>(`/api/v1/reports/combined${qs}`) return apiFetch<unknown>(`/api/v1/scores/predictor${qs}`)
} }
+152 -11
View File
@@ -22,6 +22,7 @@ import type {
FamilyAdminIndexResponse, FamilyAdminIndexResponse,
GradingOverviewResponse, GradingOverviewResponse,
IpBansResponse, IpBansResponse,
LandingPageResponse,
LoginFailure, LoginFailure,
LoginResponse, LoginResponse,
LoginSuccess, LoginSuccess,
@@ -120,10 +121,69 @@ 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 fetchLandingAdminDashboard(kind: 'admin' | 'administrator'): Promise<LandingPageResponse> {
return apiFetch<LandingPageResponse>(`/api/v1/landing/${kind}`)
}
export async function fetchAdministratorDashboardMetrics(): Promise<AdministratorDashboardMetricsResponse> { export async function fetchAdministratorDashboardMetrics(): Promise<AdministratorDashboardMetricsResponse> {
return apiFetch<AdministratorDashboardMetricsResponse>('/api/v1/administrator/dashboard/metrics') return apiFetch<AdministratorDashboardMetricsResponse>('/api/v1/administrator/dashboard/metrics')
} }
export async function toggleGradingLock(payload: {
class_section_id: number
semester: string
school_year: string
}): Promise<{ ok?: boolean; locked?: boolean }> {
return apiFetch<{ ok?: boolean; locked?: boolean }>('/api/v1/grading/locks/toggle', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
}
export async function lockAllGradingScores(payload: {
semester: string
school_year: string
}): Promise<{ ok?: boolean; locked_count?: number }> {
return apiFetch<{ ok?: boolean; locked_count?: number }>('/api/v1/grading/locks/all', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
}
export async function refreshGradingScores(payload: {
class_section_id: number
semester: string
school_year: string
}): Promise<{ ok?: boolean }> {
return apiFetch<{ ok?: boolean }>('/api/v1/grading/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
}
export async function fetchConfigurationByKey(configKey: string): Promise<{ ok?: boolean; config?: { id?: number; config_key?: string; config_value?: string | null } }> {
return apiFetch<{ ok?: boolean; config?: { id?: number; config_key?: string; config_value?: string | null } }>(
`/api/v1/configuration/key/${encodeURIComponent(configKey)}`,
)
}
export async function updateConfigurationValue(configId: number, payload: {
config_key: string
config_value: string
}): Promise<{ ok?: boolean; config?: { id?: number; config_key?: string; config_value?: string | null } }> {
return apiFetch<{ ok?: boolean; config?: { id?: number; config_key?: string; config_value?: string | null } }>(
`/api/v1/configuration/${configId}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
)
}
export async function searchAdministratorDashboard(query: string): Promise<AdministratorDashboardSearchResponse> { export async function searchAdministratorDashboard(query: string): Promise<AdministratorDashboardSearchResponse> {
const q = query.trim() const q = query.trim()
const qs = q ? `?query=${encodeURIComponent(q)}` : '' const qs = q ? `?query=${encodeURIComponent(q)}` : ''
@@ -146,6 +206,42 @@ export async function submitAdministratorAbsence(payload: {
}) })
} }
/** Laravel often wraps JSON in `{ data: { ... } }`; some clients emit camelCase keys. */
function unwrapApiDataLayer(body: unknown): unknown {
if (body === null || body === undefined || typeof body !== 'object') return body
const o = body as Record<string, unknown>
if ('data' in o && o.data !== null && typeof o.data === 'object') return o.data
return body
}
function normalizeAdministratorDailyAttendanceResponse(raw: unknown): AdministratorDailyAttendanceResponse {
const body = unwrapApiDataLayer(raw) as Record<string, unknown>
const d = { ...(body as unknown as AdministratorDailyAttendanceResponse) }
if (d.grades === undefined && body.Grades != null) d.grades = body.Grades as AdministratorDailyAttendanceResponse['grades']
if (d.students_by_section === undefined && body.studentsBySection != null) {
d.students_by_section = body.studentsBySection as AdministratorDailyAttendanceResponse['students_by_section']
}
if (d.attendance_data === undefined && body.attendanceData != null) {
d.attendance_data = body.attendanceData as AdministratorDailyAttendanceResponse['attendance_data']
}
if (d.attendance_record === undefined && body.attendanceRecord != null) {
d.attendance_record = body.attendanceRecord as AdministratorDailyAttendanceResponse['attendance_record']
}
if (d.dates_by_section === undefined && body.datesBySection != null) {
d.dates_by_section = body.datesBySection as AdministratorDailyAttendanceResponse['dates_by_section']
}
if (d.date_list === undefined && body.dateList != null) {
d.date_list = body.dateList as AdministratorDailyAttendanceResponse['date_list']
}
if (d.school_year === undefined && body.schoolYear != null) {
d.school_year = String(body.schoolYear)
}
if (d.total_passed_days === undefined && body.totalPassedDays != null) {
d.total_passed_days = Number(body.totalPassedDays)
}
return d
}
export async function fetchAdministratorDailyAttendance( export async function fetchAdministratorDailyAttendance(
params?: { semester?: string | null; schoolYear?: string | null }, params?: { semester?: string | null; schoolYear?: string | null },
): Promise<AdministratorDailyAttendanceResponse> { ): Promise<AdministratorDailyAttendanceResponse> {
@@ -153,7 +249,8 @@ export async function fetchAdministratorDailyAttendance(
if (params?.semester) query.set('semester', params.semester) if (params?.semester) query.set('semester', params.semester)
if (params?.schoolYear) query.set('school_year', params.schoolYear) if (params?.schoolYear) query.set('school_year', params.schoolYear)
const qs = query.size > 0 ? `?${query.toString()}` : '' const qs = query.size > 0 ? `?${query.toString()}` : ''
return apiFetch<AdministratorDailyAttendanceResponse>(`/api/v1/attendance/admin/daily${qs}`) const raw = await apiFetch<unknown>(`/api/v1/attendance/admin/daily${qs}`)
return normalizeAdministratorDailyAttendanceResponse(raw)
} }
export async function addAdministratorAttendanceEntry(payload: { export async function addAdministratorAttendanceEntry(payload: {
@@ -295,7 +392,8 @@ export async function fetchAttendanceViolationsPending(params?: {
if (params?.schoolYear) query.set('school_year', params.schoolYear) if (params?.schoolYear) query.set('school_year', params.schoolYear)
if (params?.semester) query.set('semester', params.semester) if (params?.semester) query.set('semester', params.semester)
const qs = query.size > 0 ? `?${query.toString()}` : '' const qs = query.size > 0 ? `?${query.toString()}` : ''
return apiFetch<AttendanceViolationsResponse>(`/api/v1/attendance/violations/pending${qs}`) const raw = await apiFetch<unknown>(`/api/v1/attendance-tracking/pending-violations${qs}`)
return unwrapApiDataLayer(raw) as AttendanceViolationsResponse
} }
export async function fetchAttendanceViolationsNotified(params?: { export async function fetchAttendanceViolationsNotified(params?: {
@@ -306,7 +404,8 @@ export async function fetchAttendanceViolationsNotified(params?: {
if (params?.schoolYear) query.set('school_year', params.schoolYear) if (params?.schoolYear) query.set('school_year', params.schoolYear)
if (params?.semester) query.set('semester', params.semester) if (params?.semester) query.set('semester', params.semester)
const qs = query.size > 0 ? `?${query.toString()}` : '' const qs = query.size > 0 ? `?${query.toString()}` : ''
return apiFetch<AttendanceViolationsResponse>(`/api/v1/attendance/violations/notified${qs}`) const raw = await apiFetch<unknown>(`/api/v1/attendance-tracking/notified-violations${qs}`)
return unwrapApiDataLayer(raw) as AttendanceViolationsResponse
} }
export async function fetchParentAttendanceReportsAdmin(params?: { export async function fetchParentAttendanceReportsAdmin(params?: {
@@ -411,7 +510,7 @@ export async function deleteAttendanceCommentTemplate(id: number): Promise<{ sta
export async function saveAttendanceViolationNote(payload: Record<string, string>): Promise<{ message?: string }> { export async function saveAttendanceViolationNote(payload: Record<string, string>): Promise<{ message?: string }> {
const body = new URLSearchParams() const body = new URLSearchParams()
for (const [k, v] of Object.entries(payload)) body.set(k, v) for (const [k, v] of Object.entries(payload)) body.set(k, v)
return apiFetch('/api/v1/attendance/violations/save-note', { return apiFetch('/api/v1/attendance-tracking/save-notification-note', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(), body: body.toString(),
@@ -421,7 +520,7 @@ export async function saveAttendanceViolationNote(payload: Record<string, string
export async function sendAttendanceViolationEmail(payload: Record<string, string>): Promise<{ message?: string }> { export async function sendAttendanceViolationEmail(payload: Record<string, string>): Promise<{ message?: string }> {
const body = new URLSearchParams() const body = new URLSearchParams()
for (const [k, v] of Object.entries(payload)) body.set(k, v) for (const [k, v] of Object.entries(payload)) body.set(k, v)
return apiFetch('/api/v1/attendance/violations/send-email-manual', { return apiFetch('/api/v1/attendance-tracking/send-manual-email', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(), body: body.toString(),
@@ -439,7 +538,7 @@ export async function fetchAttendanceComposeEmailContext(params: {
q.set('code', params.code) q.set('code', params.code)
if (params.variant) q.set('variant', params.variant) if (params.variant) q.set('variant', params.variant)
if (params.incidentDate) q.set('incident_date', params.incidentDate) if (params.incidentDate) q.set('incident_date', params.incidentDate)
return apiFetch(`/api/v1/attendance/violations/compose?${q.toString()}`) return apiFetch(`/api/v1/attendance-tracking/compose?${q.toString()}`)
} }
export async function fetchAttendanceStudentViolationsView( export async function fetchAttendanceStudentViolationsView(
@@ -451,7 +550,7 @@ export async function fetchAttendanceStudentViolationsView(
if (query?.incident_date) q.set('incident_date', query.incident_date) if (query?.incident_date) q.set('incident_date', query.incident_date)
if (query?.include_notified) q.set('include_notified', query.include_notified) if (query?.include_notified) q.set('include_notified', query.include_notified)
const qs = q.size > 0 ? `?${q.toString()}` : '' const qs = q.size > 0 ? `?${q.toString()}` : ''
return apiFetch(`/api/v1/attendance/violations/student/${studentId}${qs}`) return apiFetch(`/api/v1/attendance-tracking/student-case/${studentId}${qs}`)
} }
export async function fetchAttendanceTrackingStudents(): Promise<{ export async function fetchAttendanceTrackingStudents(): Promise<{
@@ -560,6 +659,46 @@ export async function fetchStudentAssignments(
return apiFetch<StudentAssignmentsResponse>(`/api/v1/students/assignments${query}`) return apiFetch<StudentAssignmentsResponse>(`/api/v1/students/assignments${query}`)
} }
export async function fetchClassAssignmentOverview(
schoolYear?: string | null,
semester?: string | null,
): Promise<{
message?: string
data?: {
classSections?: Array<{
class_section_id?: number | null
class_section_name?: string | null
main_teachers?: string[]
teacher_assistants?: string[]
students?: Array<{
id?: number
firstname?: string | null
lastname?: string | null
age?: number | string | null
gender?: string | null
photo_consent?: boolean | number | null
tuition_paid?: boolean | number | null
school_id?: string | null
}>
semester?: string | null
school_year?: string | null
description?: string | null
}>
schoolYears?: string[]
schoolYear?: string | null
selectedYear?: string | null
selectedSemester?: string | null
semester?: string | null
current_school_year?: string | null
}
}> {
const query = new URLSearchParams()
if (schoolYear) query.set('school_year', schoolYear)
if (semester) query.set('semester', semester)
const qs = query.size > 0 ? `?${query.toString()}` : ''
return apiFetch(`/api/v1/assignments${qs}`)
}
export async function assignStudentClass(payload: { export async function assignStudentClass(payload: {
student_id: number student_id: number
class_section_ids?: number[] class_section_ids?: number[]
@@ -1942,7 +2081,7 @@ export async function fetchRegisteredNewStudents(params?: {
const suffix = qs.toString() ? `?${qs}` : '' const suffix = qs.toString() ? `?${qs}` : ''
const body = await apiFetch< const body = await apiFetch<
RegisteredNewStudentsResponse & { data?: RegisteredNewStudentsResponse } RegisteredNewStudentsResponse & { data?: RegisteredNewStudentsResponse }
>(`/api/v1/administrator/enroll-withdrawal/registered-new-students${suffix}`) >(`/api/v1/administrator/enroll-withdrawal/new-students${suffix}`)
const d = unwrapData(body) const d = unwrapData(body)
return { return {
new_students: d.new_students ?? [], new_students: d.new_students ?? [],
@@ -1972,7 +2111,7 @@ export async function postEnrollmentWithdrawalAssignClass(payload: {
parent_id: number parent_id: number
class_section_id: number | string class_section_id: number | string
}): Promise<{ ok?: boolean; message?: string }> { }): Promise<{ ok?: boolean; message?: string }> {
return apiFetch(`/api/v1/administrator/enroll-withdrawal/assign-class`, { return apiFetch(`/api/v1/administrator/enroll-withdrawal`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
@@ -1984,10 +2123,12 @@ export async function postEnrollmentWithdrawalStatusBatch(payload: {
school_year?: string school_year?: string
semester?: string semester?: string
}): Promise<{ ok?: boolean; message?: string }> { }): Promise<{ ok?: boolean; message?: string }> {
return apiFetch(`/api/v1/administrator/enroll-withdrawal/status-batch`, { return apiFetch(`/api/v1/administrator/enroll-withdrawal/update-statuses`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify({
enrollment_status: payload.updates.map((row) => row.enrollment_status),
}),
}) })
} }
+4 -1
View File
@@ -13,5 +13,8 @@ export async function fetchSlipPreviewList(params?: {
if (params?.school_year) qs.set('school_year', params.school_year) if (params?.school_year) qs.set('school_year', params.school_year)
if (params?.semester) qs.set('semester', params.semester) if (params?.semester) qs.set('semester', params.semester)
const suffix = qs.toString() ? `?${qs}` : '' const suffix = qs.toString() ? `?${qs}` : ''
return apiFetch(`/api/v1/administrator/slips/preview${suffix}`) const res = await apiFetch<{ ok: boolean; logs?: SlipPreviewRow[] }>(
`/api/v1/reports/slips/logs${suffix}`,
)
return { rows: res.logs ?? [] }
} }
+13 -1
View File
@@ -34,6 +34,18 @@ export type DashboardPayload = {
} }
} }
export type LandingPageRecord = {
role?: string | null
dashboard?: string | null
summary?: Record<string, unknown> | null
}
export type LandingPagePayload = {
landing?: LandingPageRecord
}
export type LandingPageResponse = ApiEnvelope<LandingPagePayload>
export type NavItem = { export type NavItem = {
id: number id: number
menu_parent_id: number | null menu_parent_id: number | null
@@ -182,7 +194,7 @@ export type AdministratorDailyAttendanceResponse = {
grades?: Record<string, AdministratorDailyAttendanceSection[]> grades?: Record<string, AdministratorDailyAttendanceSection[]>
dates_by_section?: Record<string, string[]> dates_by_section?: Record<string, string[]>
date_list?: string[] date_list?: string[]
no_school_days?: Record<string, boolean> no_school_days?: Record<string, string>
total_passed_days?: number total_passed_days?: number
semester?: string | null semester?: string | null
school_year?: string | null school_year?: string | null
+6 -6
View File
@@ -1,10 +1,10 @@
/** /**
* WhatsApp admin tools (legacy CI `whatsapp/*`). * WhatsApp admin tools (legacy CI `whatsapp/*`).
* Expected Laravel routes under `/api/v1/administrator/whatsapp`. * Expected Laravel routes under `/api/v1/whatsapp`.
*/ */
import { apiFetch } from './http' import { apiFetch } from './http'
const BASE = '/api/v1/administrator/whatsapp' const BASE = '/api/v1/whatsapp'
export type WhatsappSectionRow = { export type WhatsappSectionRow = {
class_section_id?: number class_section_id?: number
@@ -80,7 +80,7 @@ export async function fetchWhatsappManageLinks(params?: {
if (params?.school_year) qs.set('school_year', params.school_year) if (params?.school_year) qs.set('school_year', params.school_year)
if (params?.semester) qs.set('semester', params.semester) if (params?.semester) qs.set('semester', params.semester)
const suffix = qs.toString() ? `?${qs}` : '' const suffix = qs.toString() ? `?${qs}` : ''
return apiFetch(`${BASE}/manage-links${suffix}`) return apiFetch(`${BASE}/links${suffix}`)
} }
export async function saveWhatsappLink(payload: { export async function saveWhatsappLink(payload: {
@@ -89,7 +89,7 @@ export async function saveWhatsappLink(payload: {
invite_link: string invite_link: string
active?: boolean active?: boolean
}): Promise<{ ok?: boolean; message?: string }> { }): Promise<{ ok?: boolean; message?: string }> {
return apiFetch(`${BASE}/save-link`, { return apiFetch(`${BASE}/links`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
@@ -101,7 +101,7 @@ export async function sendWhatsappInvites(payload: {
class_section_id?: number | null class_section_id?: number | null
parent_ids?: number[] parent_ids?: number[]
}): Promise<{ ok?: boolean; message?: string }> { }): Promise<{ ok?: boolean; message?: string }> {
return apiFetch(`${BASE}/send-invites`, { return apiFetch(`${BASE}/invites/send`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
@@ -132,7 +132,7 @@ export async function updateWhatsappMembership(payload: {
primary_member?: string primary_member?: string
second_member?: string second_member?: string
}): Promise<{ status?: string; message?: string }> { }): Promise<{ status?: string; message?: string }> {
return apiFetch(`${BASE}/update-membership`, { return apiFetch(`${BASE}/membership`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
+1
View File
@@ -6,6 +6,7 @@ const CI_REGISTRY_APP_PATH_OVERRIDES: Record<string, string> = {
// Nested views map to flat `/app/teacher/competition-scores` (+ dynamic detail route). // Nested views map to flat `/app/teacher/competition-scores` (+ dynamic detail route).
'teacher/competition_scores/index': '/app/teacher/competition-scores', 'teacher/competition_scores/index': '/app/teacher/competition-scores',
'teacher/competition_scores/scores': '/app/teacher/competition-scores', 'teacher/competition_scores/scores': '/app/teacher/competition-scores',
'landing_page/admin_dashboard': '/app/landing-page/admin-dashboard',
/** CI `landing_page/teacher_dashboard.php` → SPA (underscore segment per legacy URL). */ /** CI `landing_page/teacher_dashboard.php` → SPA (underscore segment per legacy URL). */
'landing_page/teacher_dashboard': '/app/teacher_dashboard', 'landing_page/teacher_dashboard': '/app/teacher_dashboard',
'flags/flags_management': '/app/administrator/flags/management', 'flags/flags_management': '/app/administrator/flags/management',
+1
View File
@@ -67,6 +67,7 @@ export function spaPathFromCiUrl(url: string | null | undefined): string | null
} }
const shortcuts: Record<string, string> = { const shortcuts: Record<string, string> = {
'landing_page/admin_dashboard': '/app/landing-page/admin-dashboard',
'landing_page/guest_dashboard': '/app/home', 'landing_page/guest_dashboard': '/app/home',
'landing_page/parent_dashboard': '/app/parent/home', 'landing_page/parent_dashboard': '/app/parent/home',
'landing_page/teacher_dashboard': '/app/teacher_dashboard', 'landing_page/teacher_dashboard': '/app/teacher_dashboard',
+240
View File
@@ -0,0 +1,240 @@
/* Admin daily attendance — aligned with legacy Attendance Management UI */
.att-mgmt-page {
max-width: 1400px;
margin: 0 auto;
}
.att-mgmt-hero-title {
font-size: 1.75rem;
font-weight: 600;
color: #1e3a5f;
letter-spacing: 0.02em;
}
.att-mgmt-filter-bar {
background: #f4f7fb;
border: 1px solid #d8e0ea;
border-radius: 8px;
padding: 1rem 1.25rem;
}
.att-mgmt-filter-bar .form-label {
font-size: 0.8rem;
font-weight: 600;
color: #475569;
margin-bottom: 0.25rem;
}
.att-btn-apply {
background: #3d5a80;
border-color: #3d5a80;
color: #fff;
min-width: 5rem;
}
.att-btn-apply:hover {
background: #2f4763;
border-color: #2f4763;
color: #fff;
}
.att-search-wrap .input-group-text {
background: #fff;
font-weight: 600;
color: #475569;
border-color: #ced4da;
}
.att-btn-analysis {
border-width: 2px;
font-weight: 600;
padding: 0.45rem 1.5rem;
}
.att-grade-tabs {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.4rem;
padding: 0.55rem;
background: #e8edf3;
border: 1px solid #cfd8e3;
border-radius: 8px;
}
.att-grade-tab {
display: inline-flex;
align-items: center;
gap: 0.4rem;
border: 1px solid transparent;
border-radius: 6px;
padding: 0.4rem 0.65rem;
background: #d3dce6;
color: #334155;
font-size: 0.9rem;
cursor: pointer;
transition:
background 0.15s ease,
border-color 0.15s ease,
box-shadow 0.15s ease;
}
.att-grade-tab:hover {
background: #c5d0dc;
}
.att-grade-tab.active {
background: #fff;
border-color: #c5ced8;
border-top: 3px solid #dc3545;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
}
.att-grade-tab .att-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #dc3545;
flex-shrink: 0;
}
.att-grade-tab .att-count {
display: inline-block;
min-width: 1.5rem;
text-align: center;
font-size: 0.72rem;
font-weight: 600;
padding: 0.12rem 0.45rem;
border-radius: 999px;
background: #94a3b8;
color: #fff;
}
.att-section-pills {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.35rem;
}
.att-section-pills .btn {
font-size: 0.85rem;
border-radius: 6px;
}
.att-section-heading {
font-size: 1.35rem;
font-weight: 600;
color: #1e3a5f;
}
.att-date-range {
font-size: 0.95rem;
font-weight: 500;
color: #475569;
}
.att-date-nav {
width: 2.25rem;
height: 2.25rem;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
font-size: 1.1rem;
line-height: 1;
}
.att-table-toolbar {
font-size: 0.875rem;
color: #475569;
}
.att-mgmt-table {
font-size: 0.875rem;
border-color: #c5ced8 !important;
}
.att-mgmt-table thead.att-mgmt-thead th {
background: linear-gradient(180deg, #e8f0fa 0%, #d9e7f5 100%);
color: #1e3a5f;
font-weight: 600;
border-color: #c5ced8 !important;
white-space: nowrap;
vertical-align: middle;
}
.att-mgmt-table tbody td {
border-color: #d8e0ea !important;
vertical-align: middle;
}
.att-mgmt-table .school-id-col {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.att-mgmt-table .student-name-col {
min-width: 8rem;
}
.att-mgmt-table .date-col {
min-width: 7.5rem;
}
.att-cell-select {
min-width: 7.25rem;
padding-top: 0.2rem;
padding-bottom: 0.2rem;
border-width: 1px;
}
.att-cell-select.att-sel-present {
background: #157347;
color: #fff;
border-color: #0f5132;
}
.att-cell-select.att-sel-absent {
background: #bb2d3b;
color: #fff;
border-color: #842029;
}
.att-cell-select.att-sel-absent-reported {
background: #dc3545;
color: #fff;
border-color: #842029;
font-weight: 500;
}
.att-cell-select.att-sel-late {
background: #ffca2c;
color: #111;
border-color: #997404;
}
.att-cell-select.att-sel-late-reported {
background: #ffc107;
color: #111;
border-color: #856404;
font-weight: 500;
}
.att-cell-select.att-sel-empty {
background: #fff;
color: #212529;
border-color: #adb5bd;
}
.att-cell-select:disabled {
opacity: 0.65;
}
.att-summary-col {
text-align: center;
font-variant-numeric: tabular-nums;
font-weight: 600;
background: #f8fafc;
}
+99 -31
View File
@@ -6,7 +6,7 @@ export { ClassProgressListPage as AdminClassProgressListPage } from './classProg
export { ClassProgressViewPage as AdminClassProgressViewPage } from './classProgress/ClassProgressViewPage' export { ClassProgressViewPage as AdminClassProgressViewPage } from './classProgress/ClassProgressViewPage'
export { AdminStudentScoreCardPage } from './admin/AdminStudentScoreCardPage' export { AdminStudentScoreCardPage } from './admin/AdminStudentScoreCardPage'
import { type FormEvent, useEffect, useState, type ReactNode } from 'react' import { type FormEvent, useEffect, useMemo, useState, type ReactNode } from 'react'
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom' import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { import {
deleteAdministratorEmergencyContact, deleteAdministratorEmergencyContact,
@@ -27,9 +27,22 @@ function PageShell({ title, children }: { title: string; children: ReactNode })
) )
} }
type FlatContact = {
contactId: number
parentId: number
parentName: string
students: string
contactName: string
relation: string
phone: string
}
export function AdminEmergencyContactIndexPage() { export function AdminEmergencyContactIndexPage() {
const [groups, setGroups] = useState<EmergencyContactGroupRow[]>([]) const [groups, setGroups] = useState<EmergencyContactGroupRow[]>([])
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [search, setSearch] = useState('')
const [sortKey, setSortKey] = useState<keyof Omit<FlatContact, 'contactId' | 'parentId'>>('parentName')
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc')
async function load() { async function load() {
try { try {
@@ -41,68 +54,123 @@ export function AdminEmergencyContactIndexPage() {
} }
} }
useEffect(() => { useEffect(() => { void load() }, [])
void load()
}, [])
async function removeContact(contactId: number, parentId: number) { async function removeContact(contactId: number, parentId: number) {
await deleteAdministratorEmergencyContact(contactId, parentId) await deleteAdministratorEmergencyContact(contactId, parentId)
await load() await load()
} }
function toggleSort(key: typeof sortKey) {
if (sortKey === key) setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))
else { setSortKey(key); setSortDir('asc') }
}
const flatRows = useMemo<FlatContact[]>(() =>
groups.flatMap((group) =>
(group.contacts ?? []).map((contact) => ({
contactId: contact.id,
parentId: group.parent_id,
parentName: group.parent_name ?? '',
students: (group.students ?? []).map((s) => `${s.firstname ?? ''} ${s.lastname ?? ''}`.trim()).join(', '),
contactName: contact.name ?? '',
relation: contact.relation ?? '',
phone: contact.cellphone || group.parent_phones?.join(' / ') || '',
}))
),
[groups])
const displayed = useMemo(() => {
const q = search.trim().toLowerCase()
let rows = q
? flatRows.filter((r) =>
r.parentName.toLowerCase().includes(q) ||
r.students.toLowerCase().includes(q) ||
r.contactName.toLowerCase().includes(q) ||
r.relation.toLowerCase().includes(q) ||
r.phone.includes(q),
)
: flatRows
return [...rows].sort((a, b) => {
const cmp = a[sortKey].localeCompare(b[sortKey], undefined, { sensitivity: 'base' })
return sortDir === 'asc' ? cmp : -cmp
})
}, [flatRows, search, sortKey, sortDir])
function SortTh({ col, label }: { col: typeof sortKey; label: string }) {
const active = sortKey === col
return (
<th
role="button"
style={{ cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap' }}
onClick={() => toggleSort(col)}
>
{label} <span className="text-muted small">{active ? (sortDir === 'asc' ? '▲' : '▼') : '⇅'}</span>
</th>
)
}
return ( return (
<PageShell title="Emergency Contact Information"> <PageShell title="Emergency Contact Information">
{error ? <div className="alert alert-danger">{error}</div> : null} {error ? <div className="alert alert-danger">{error}</div> : null}
<div className="d-flex justify-content-between align-items-center mb-2 flex-wrap gap-2">
<span className="text-muted small">{displayed.length} record{displayed.length !== 1 ? 's' : ''}</span>
<input
id="ec-search"
name="search"
type="search"
className="form-control form-control-sm"
style={{ maxWidth: 260 }}
placeholder="Search name, student, relation, phone…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</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-dark"> <thead className="table-dark">
<tr> <tr>
<th>Parent Name</th> <SortTh col="parentName" label="Parent Name" />
<th>Students</th> <SortTh col="students" label="Students" />
<th>Contact Name</th> <SortTh col="contactName" label="Contact Name" />
<th>Relation</th> <SortTh col="relation" label="Relation" />
<th>Phone</th> <SortTh col="phone" label="Phone" />
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{groups.flatMap((group) => {displayed.length === 0 ? (
(group.contacts ?? []).map((contact) => ( <tr>
<tr key={contact.id}> <td colSpan={6} className="text-muted">No emergency contacts found.</td>
<td>{group.parent_name ?? '—'}</td> </tr>
<td> ) : (
{(group.students ?? []) displayed.map((row) => (
.map((student) => `${student.firstname ?? ''} ${student.lastname ?? ''}`.trim()) <tr key={row.contactId}>
.join(', ') || '—'} <td>{row.parentName || '—'}</td>
</td> <td>{row.students || '—'}</td>
<td>{contact.name ?? '—'}</td> <td>{row.contactName || '—'}</td>
<td>{contact.relation ?? '—'}</td> <td>{row.relation || '—'}</td>
<td>{contact.cellphone || group.parent_phones?.join(' / ') || 'N/A'}</td> <td>{row.phone || 'N/A'}</td>
<td className="d-flex gap-2"> <td className="d-flex gap-2">
<Link <Link
className="btn btn-sm btn-primary" className="btn btn-sm btn-primary"
to={`/app/administrator/emergency-contacts/${contact.id}/edit?parent_id=${group.parent_id}`} to={`/app/administrator/emergency-contacts/${row.contactId}/edit?parent_id=${row.parentId}`}
> >
Edit Edit
</Link> </Link>
<button <button
className="btn btn-sm btn-danger" className="btn btn-sm btn-danger"
type="button" type="button"
onClick={() => void removeContact(contact.id, group.parent_id)} onClick={() => void removeContact(row.contactId, row.parentId)}
> >
Delete Delete
</button> </button>
</td> </td>
</tr> </tr>
)), ))
)} )}
{groups.length === 0 ? (
<tr>
<td colSpan={6} className="text-muted">
No emergency contacts found.
</td>
</tr>
) : null}
</tbody> </tbody>
</table> </table>
</div> </div>
File diff suppressed because it is too large Load Diff
@@ -37,12 +37,16 @@ export function AttendanceViolationsHubPage() {
<div className="d-flex justify-content-center mb-4"> <div className="d-flex justify-content-center mb-4">
<form className="row g-2 align-items-end" onSubmit={applyFilter}> <form className="row g-2 align-items-end" onSubmit={applyFilter}>
<div className="col-auto"> <div className="col-auto">
<label className="form-label small mb-0">School Year</label> <label htmlFor="hub-school-year" className="form-label small mb-0">School Year</label>
<input name="school_year" className="form-control form-control-sm" defaultValue={schoolYear} /> <input id="hub-school-year" name="school_year" className="form-control form-control-sm" defaultValue={schoolYear} />
</div> </div>
<div className="col-auto"> <div className="col-auto">
<label className="form-label small mb-0">Semester</label> <label htmlFor="hub-semester" className="form-label small mb-0">Semester</label>
<input name="semester" className="form-control form-control-sm" defaultValue={semester} /> <select id="hub-semester" name="semester" className="form-select form-select-sm" defaultValue={semester}>
<option value=""> All </option>
<option value="Fall">Fall</option>
<option value="Spring">Spring</option>
</select>
</div> </div>
<div className="col-auto"> <div className="col-auto">
<button type="submit" className="btn btn-sm btn-secondary"> <button type="submit" className="btn btn-sm btn-secondary">
+35 -13
View File
@@ -53,6 +53,11 @@ export function ViolationsPendingPage() {
if (!cancelled) { if (!cancelled) {
setRows(data.students ?? []) setRows(data.students ?? [])
setDebug((data.debug as Record<string, unknown>) ?? null) setDebug((data.debug as Record<string, unknown>) ?? null)
if (!schoolYear && data.school_year) {
const p = new URLSearchParams(searchParams)
p.set('school_year', data.school_year)
setSearchParams(p, { replace: true })
}
} }
} catch (e) { } catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : 'Unable to load violations.') if (!cancelled) setError(e instanceof Error ? e.message : 'Unable to load violations.')
@@ -88,12 +93,16 @@ export function ViolationsPendingPage() {
<div className="d-flex justify-content-center mb-2"> <div className="d-flex justify-content-center mb-2">
<form className="row g-2 align-items-end" onSubmit={applyFilter}> <form className="row g-2 align-items-end" onSubmit={applyFilter}>
<div className="col-auto"> <div className="col-auto">
<label className="form-label small mb-0">School Year</label> <label htmlFor="pending-school-year" className="form-label small mb-0">School Year</label>
<input name="school_year" className="form-control form-control-sm" defaultValue={schoolYear} /> <input id="pending-school-year" name="school_year" className="form-control form-control-sm" defaultValue={schoolYear} />
</div> </div>
<div className="col-auto"> <div className="col-auto">
<label className="form-label small mb-0">Semester</label> <label htmlFor="pending-semester" className="form-label small mb-0">Semester</label>
<input name="semester" className="form-control form-control-sm" defaultValue={semester} /> <select id="pending-semester" name="semester" className="form-select form-select-sm" defaultValue={semester}>
<option value=""> All </option>
<option value="Fall">Fall</option>
<option value="Spring">Spring</option>
</select>
</div> </div>
<div className="col-auto"> <div className="col-auto">
<button type="submit" className="btn btn-sm btn-secondary"> <button type="submit" className="btn btn-sm btn-secondary">
@@ -112,7 +121,6 @@ export function ViolationsPendingPage() {
<h5 className="mt-2 mb-0">Pending Attendance Violations</h5> <h5 className="mt-2 mb-0">Pending Attendance Violations</h5>
<div className="d-flex flex-wrap gap-2 align-items-center"> <div className="d-flex flex-wrap gap-2 align-items-center">
<span className="badge bg-primary">School Year: {schoolYear || '—'}</span> <span className="badge bg-primary">School Year: {schoolYear || '—'}</span>
{semester ? <span className="badge bg-secondary">Semester: {semester}</span> : null}
<Link <Link
to={`${ATTENDANCE_VIOLATIONS_NOTIFIED_PATH}${filterQuery}`} to={`${ATTENDANCE_VIOLATIONS_NOTIFIED_PATH}${filterQuery}`}
className="btn btn-outline-dark btn-sm" className="btn btn-outline-dark btn-sm"
@@ -125,7 +133,9 @@ export function ViolationsPendingPage() {
<div className="row mb-3"> <div className="row mb-3">
<div className="col-md-4"> <div className="col-md-4">
<input <input
id="pending-quick-filter"
type="search" type="search"
name="q"
className="form-control form-control-sm" className="form-control form-control-sm"
placeholder="Quick filter…" placeholder="Quick filter…"
value={q} value={q}
@@ -176,7 +186,7 @@ export function ViolationsPendingPage() {
</thead> </thead>
<tbody> <tbody>
{filtered.map((st, idx) => ( {filtered.map((st, idx) => (
<PendingRow key={studentIdOf(st) || idx} st={st} /> <PendingRow key={`${studentIdOf(st)}-${idx}`} st={st} />
))} ))}
</tbody> </tbody>
</table> </table>
@@ -327,7 +337,14 @@ export function ViolationsNotifiedPage() {
schoolYear: schoolYear || null, schoolYear: schoolYear || null,
semester: semester || null, semester: semester || null,
}) })
if (!cancelled) setRows(data.students ?? []) if (!cancelled) {
setRows(data.students ?? [])
if (!schoolYear && data.school_year) {
const p = new URLSearchParams(searchParams)
p.set('school_year', data.school_year)
setSearchParams(p, { replace: true })
}
}
} catch (e) { } catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : 'Unable to load.') if (!cancelled) setError(e instanceof Error ? e.message : 'Unable to load.')
} finally { } finally {
@@ -381,12 +398,16 @@ export function ViolationsNotifiedPage() {
<div className="d-flex justify-content-center mb-2"> <div className="d-flex justify-content-center mb-2">
<form className="row g-2 align-items-end" onSubmit={applyFilter}> <form className="row g-2 align-items-end" onSubmit={applyFilter}>
<div className="col-auto"> <div className="col-auto">
<label className="form-label small mb-0">School Year</label> <label htmlFor="notified-school-year" className="form-label small mb-0">School Year</label>
<input name="school_year" className="form-control form-control-sm" defaultValue={schoolYear} /> <input id="notified-school-year" name="school_year" className="form-control form-control-sm" defaultValue={schoolYear} />
</div> </div>
<div className="col-auto"> <div className="col-auto">
<label className="form-label small mb-0">Semester</label> <label htmlFor="notified-semester" className="form-label small mb-0">Semester</label>
<input name="semester" className="form-control form-control-sm" defaultValue={semester} /> <select id="notified-semester" name="semester" className="form-select form-select-sm" defaultValue={semester}>
<option value=""> All </option>
<option value="Fall">Fall</option>
<option value="Spring">Spring</option>
</select>
</div> </div>
<div className="col-auto"> <div className="col-auto">
<button type="submit" className="btn btn-sm btn-secondary"> <button type="submit" className="btn btn-sm btn-secondary">
@@ -403,7 +424,6 @@ export function ViolationsNotifiedPage() {
<h5 className="mt-2 mb-0">Notified Attendance Violations</h5> <h5 className="mt-2 mb-0">Notified Attendance Violations</h5>
<div className="d-flex flex-wrap gap-2 align-items-center"> <div className="d-flex flex-wrap gap-2 align-items-center">
<span className="badge bg-primary">School Year: {schoolYear || '—'}</span> <span className="badge bg-primary">School Year: {schoolYear || '—'}</span>
{semester ? <span className="badge bg-secondary">Semester: {semester}</span> : null}
<Link to={`${ATTENDANCE_VIOLATIONS_PENDING_PATH}${filterQuery}`} className="btn btn-outline-dark btn-sm"> <Link to={`${ATTENDANCE_VIOLATIONS_PENDING_PATH}${filterQuery}`} className="btn btn-outline-dark btn-sm">
View Pending View Pending
</Link> </Link>
@@ -432,7 +452,7 @@ export function ViolationsNotifiedPage() {
<tbody> <tbody>
{rows.map((st, idx) => ( {rows.map((st, idx) => (
<NotifiedRow <NotifiedRow
key={studentIdOf(st) || idx} key={`${studentIdOf(st)}-${idx}`}
st={st} st={st}
onEditNote={() => { onEditNote={() => {
setNoteModal(st) setNoteModal(st)
@@ -457,6 +477,8 @@ export function ViolationsNotifiedPage() {
</div> </div>
<div className="modal-body"> <div className="modal-body">
<textarea <textarea
id="note-text"
name="note"
className="form-control" className="form-control"
rows={5} rows={5}
required required
@@ -1,4 +1,4 @@
import { useEffect, 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 { fetchRegisteredNewStudents } from '../../api/session' import { fetchRegisteredNewStudents } from '../../api/session'
@@ -21,6 +21,14 @@ function formatRegistrationDate(raw?: string | null): string {
} }
export function RegisteredStudentsPage() { export function RegisteredStudentsPage() {
type RegisteredStudentsSortKey =
| 'firstname'
| 'lastname'
| 'new_student'
| 'class_section'
| 'enrollment_status'
| 'age'
| 'registration_date'
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
const schoolYear = searchParams.get('school_year') ?? undefined const schoolYear = searchParams.get('school_year') ?? undefined
const semester = searchParams.get('semester') ?? undefined const semester = searchParams.get('semester') ?? undefined
@@ -31,6 +39,14 @@ export function RegisteredStudentsPage() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [contactModal, setContactModal] = useState<RegisteredNewStudentRow | null>(null) const [contactModal, setContactModal] = useState<RegisteredNewStudentRow | null>(null)
const [tableSearch, setTableSearch] = useState('')
const [sortConfig, setSortConfig] = useState<{
key: RegisteredStudentsSortKey
direction: 'asc' | 'desc'
}>({
key: 'lastname',
direction: 'asc',
})
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
@@ -55,6 +71,52 @@ export function RegisteredStudentsPage() {
} }
}, [schoolYear, semester]) }, [schoolYear, semester])
function requestSort(key: RegisteredStudentsSortKey) {
setSortConfig((prev) =>
prev.key === key
? { key, direction: prev.direction === 'asc' ? 'desc' : 'asc' }
: { key, direction: 'asc' },
)
}
function sortArrow(key: RegisteredStudentsSortKey) {
if (sortConfig.key !== key) return '↕'
return sortConfig.direction === 'asc' ? '↑' : '↓'
}
const displayedRows = useMemo(() => {
const query = tableSearch.trim().toLowerCase()
const filtered = rows.filter((student) => {
if (!query) return true
const haystack = [
student.firstname,
student.lastname,
student.new_student,
student.class_section,
student.enrollment_status,
student.age,
student.registration_date,
student.parent_firstname,
student.parent_lastname,
]
.map((value) => String(value ?? '').toLowerCase())
.join(' ')
return haystack.includes(query)
})
return [...filtered].sort((left, right) => {
const direction = sortConfig.direction === 'asc' ? 1 : -1
if (sortConfig.key === 'age') {
const a = Number(left.age ?? 0)
const b = Number(right.age ?? 0)
return (a - b) * direction
}
const a = String(left[sortConfig.key] ?? '')
const b = String(right[sortConfig.key] ?? '')
return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }) * direction
})
}, [rows, tableSearch, sortConfig])
return ( return (
<div className="container-fluid"> <div className="container-fluid">
<div className="wrapper"> <div className="wrapper">
@@ -70,29 +132,43 @@ export function RegisteredStudentsPage() {
{loading ? <p className="text-muted text-center">Loading</p> : null} {loading ? <p className="text-muted text-center">Loading</p> : null}
{!loading && error ? <div className="alert alert-danger">{error}</div> : null} {!loading && error ? <div className="alert alert-danger">{error}</div> : null}
<div className="d-flex justify-content-end mt-3">
<div className="input-group" style={{ maxWidth: 360 }}>
<input
className="form-control"
value={tableSearch}
onChange={(event) => setTableSearch(event.target.value)}
placeholder="Search students"
/>
<button className="btn btn-outline-secondary" type="button" onClick={() => setTableSearch('')}>
Clear
</button>
</div>
</div>
<div className="table-responsive"> <div className="table-responsive">
<table className="table table-bordered table-striped mt-4 align-middle w-100"> <table className="table table-bordered table-striped mt-4 align-middle w-100">
<thead> <thead>
<tr> <tr>
<th>First Name</th> <th role="button" style={{ cursor: 'pointer' }} onClick={() => requestSort('firstname')}>First Name {sortArrow('firstname')}</th>
<th>Last Name</th> <th role="button" style={{ cursor: 'pointer' }} onClick={() => requestSort('lastname')}>Last Name {sortArrow('lastname')}</th>
<th>New Student</th> <th role="button" style={{ cursor: 'pointer' }} onClick={() => requestSort('new_student')}>New Student {sortArrow('new_student')}</th>
<th>Current Class</th> <th role="button" style={{ cursor: 'pointer' }} onClick={() => requestSort('class_section')}>Current Class {sortArrow('class_section')}</th>
<th>Actual Status</th> <th role="button" style={{ cursor: 'pointer' }} onClick={() => requestSort('enrollment_status')}>Actual Status {sortArrow('enrollment_status')}</th>
<th>Age</th> <th role="button" style={{ cursor: 'pointer' }} onClick={() => requestSort('age')}>Age {sortArrow('age')}</th>
<th>Registration Date</th> <th role="button" style={{ cursor: 'pointer' }} onClick={() => requestSort('registration_date')}>Registration Date {sortArrow('registration_date')}</th>
<th>Action</th> <th>Action</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{!loading && !error && rows.length === 0 ? ( {!loading && !error && displayedRows.length === 0 ? (
<tr> <tr>
<td colSpan={8} className="text-center"> <td colSpan={8} className="text-center">
No students found. No students found.
</td> </td>
</tr> </tr>
) : null} ) : null}
{rows.map((student) => { {displayedRows.map((student) => {
const sid = Number(student.id ?? 0) const sid = Number(student.id ?? 0)
const familyHref = const familyHref =
sid > 0 ? `/app/administrator/family?student_id=${encodeURIComponent(String(sid))}` : '#' sid > 0 ? `/app/administrator/family?student_id=${encodeURIComponent(String(sid))}` : '#'
+112 -13
View File
@@ -1,7 +1,8 @@
import { type FormEvent, useEffect, useState } from 'react' import { type FormEvent, 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 { fetchBelowSixty, postBelowSixtyStatus } from '../../api/grading' import { fetchBelowSixty, postBelowSixtyStatus } from '../../api/grading'
import { fetchGradingOverview } from '../../api/session'
import { AcademicFilterBar } from '../discounts/AcademicFilterBar' import { AcademicFilterBar } from '../discounts/AcademicFilterBar'
import { import {
GRADING_BELOW_SIXTY_EMAIL_EDITOR_PATH, GRADING_BELOW_SIXTY_EMAIL_EDITOR_PATH,
@@ -19,7 +20,18 @@ function displayScore(value: unknown): string {
/** CI `grading/below_sixty.php` */ /** CI `grading/below_sixty.php` */
export function BelowSixtyPage() { export function BelowSixtyPage() {
const [searchParams] = useSearchParams() type BelowSixtySortKey =
| 'student_name'
| 'class_section_name'
| 'homework_avg'
| 'project_avg'
| 'participation_score'
| 'test_avg'
| 'ptap_score'
| 'attendance_score'
| 'midterm_exam_score'
| 'semester_score'
const [searchParams, setSearchParams] = useSearchParams()
const semester = searchParams.get('semester') ?? '' const semester = searchParams.get('semester') ?? ''
const schoolYear = searchParams.get('school_year') ?? '' const schoolYear = searchParams.get('school_year') ?? ''
const qs = searchParams.toString() const qs = searchParams.toString()
@@ -27,8 +39,49 @@ export function BelowSixtyPage() {
const [rows, setRows] = useState<BelowRow[]>([]) const [rows, setRows] = useState<BelowRow[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [sortConfig, setSortConfig] = useState<{
key: BelowSixtySortKey
direction: 'asc' | 'desc'
}>({
key: 'student_name',
direction: 'asc',
})
useEffect(() => {
if (semester && schoolYear) return
let cancelled = false
;(async () => {
try {
const overview = await fetchGradingOverview({
semester: semester || null,
schoolYear: schoolYear || null,
})
if (cancelled) return
const next = new URLSearchParams(searchParams)
if (!semester && (overview.semester ?? '').trim() !== '') {
next.set('semester', String(overview.semester))
}
if (!schoolYear && (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 grading term.')
setLoading(false)
}
}
})()
return () => {
cancelled = true
}
}, [schoolYear, searchParams, semester, setSearchParams])
const load = () => { const load = () => {
if (!semester || !schoolYear) {
setLoading(false)
return
}
setLoading(true) setLoading(true)
fetchBelowSixty(searchParams) fetchBelowSixty(searchParams)
.then((data) => { .then((data) => {
@@ -71,6 +124,51 @@ export function BelowSixtyPage() {
} }
} }
function requestSort(key: BelowSixtySortKey) {
setSortConfig((prev) =>
prev.key === key
? { key, direction: prev.direction === 'asc' ? 'desc' : 'asc' }
: { key, direction: 'asc' },
)
}
function sortArrow(key: BelowSixtySortKey) {
if (sortConfig.key !== key) return '↕'
return sortConfig.direction === 'asc' ? '↑' : '↓'
}
const displayedRows = useMemo(() => {
return [...rows].sort((left, right) => {
const direction = sortConfig.direction === 'asc' ? 1 : -1
if (sortConfig.key === 'student_name') {
const leftName = `${String(left.firstname ?? '')} ${String(left.lastname ?? '')}`.trim()
const rightName = `${String(right.firstname ?? '')} ${String(right.lastname ?? '')}`.trim()
return leftName.localeCompare(rightName, undefined, { sensitivity: 'base' }) * direction
}
const numericKeys = new Set<BelowSixtySortKey>([
'homework_avg',
'project_avg',
'participation_score',
'test_avg',
'ptap_score',
'attendance_score',
'midterm_exam_score',
'semester_score',
])
if (numericKeys.has(sortConfig.key)) {
const a = Number(left[sortConfig.key] ?? Number.NEGATIVE_INFINITY)
const b = Number(right[sortConfig.key] ?? Number.NEGATIVE_INFINITY)
return (a - b) * direction
}
const a = String(left[sortConfig.key] ?? '')
const b = String(right[sortConfig.key] ?? '')
return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }) * direction
})
}, [rows, sortConfig])
return ( return (
<div className="container-fluid"> <div className="container-fluid">
<h2 className="text-center mt-4 mb-4">Below 60 Summary</h2> <h2 className="text-center mt-4 mb-4">Below 60 Summary</h2>
@@ -100,23 +198,23 @@ export function BelowSixtyPage() {
<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>Student Name</th> <th role="button" style={{ cursor: 'pointer' }} onClick={() => requestSort('student_name')}>Student Name {sortArrow('student_name')}</th>
<th>Section</th> <th role="button" style={{ cursor: 'pointer' }} onClick={() => requestSort('class_section_name')}>Section {sortArrow('class_section_name')}</th>
<th>Hwk Avg</th> <th role="button" style={{ cursor: 'pointer' }} onClick={() => requestSort('homework_avg')}>Hwk Avg {sortArrow('homework_avg')}</th>
<th>Project Avg</th> <th role="button" style={{ cursor: 'pointer' }} onClick={() => requestSort('project_avg')}>Project Avg {sortArrow('project_avg')}</th>
<th>Participation</th> <th role="button" style={{ cursor: 'pointer' }} onClick={() => requestSort('participation_score')}>Participation {sortArrow('participation_score')}</th>
<th>Test Avg</th> <th role="button" style={{ cursor: 'pointer' }} onClick={() => requestSort('test_avg')}>Test Avg {sortArrow('test_avg')}</th>
<th>PTAP Score</th> <th role="button" style={{ cursor: 'pointer' }} onClick={() => requestSort('ptap_score')}>PTAP Score {sortArrow('ptap_score')}</th>
<th>Attendance</th> <th role="button" style={{ cursor: 'pointer' }} onClick={() => requestSort('attendance_score')}>Attendance {sortArrow('attendance_score')}</th>
<th>Midterm Score</th> <th role="button" style={{ cursor: 'pointer' }} onClick={() => requestSort('midterm_exam_score')}>Midterm Score {sortArrow('midterm_exam_score')}</th>
<th>{semLabel}</th> <th role="button" style={{ cursor: 'pointer' }} onClick={() => requestSort('semester_score')}>{semLabel} {sortArrow('semester_score')}</th>
<th>Status</th> <th>Status</th>
<th>Email Parent</th> <th>Email Parent</th>
<th>Schedule Meeting</th> <th>Schedule Meeting</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{rows.map((row, i) => { {displayedRows.map((row, i) => {
const sid = Number(row.student_id ?? 0) const sid = Number(row.student_id ?? 0)
const first = String(row.firstname ?? '') const first = String(row.firstname ?? '')
const last = String(row.lastname ?? '') const last = String(row.lastname ?? '')
@@ -151,6 +249,7 @@ export function BelowSixtyPage() {
<td className="text-center">{displayScore(row.semester_score)}</td> <td className="text-center">{displayScore(row.semester_score)}</td>
<td className="text-center"> <td className="text-center">
<form <form
key={`${sid}-${String(row.status ?? 'Open')}-${String(row.note ?? '')}`}
className="d-flex align-items-center gap-2 justify-content-center flex-wrap" className="d-flex align-items-center gap-2 justify-content-center flex-wrap"
onSubmit={(ev) => void onStatusSubmit(ev)} onSubmit={(ev) => void onStatusSubmit(ev)}
> >
+550 -60
View File
@@ -1,99 +1,589 @@
import { useEffect, 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 {
import { fetchGradingMain } from '../../api/grading' fetchConfigurationByKey,
import { AcademicFilterBar } from '../discounts/AcademicFilterBar' fetchGradingOverview,
lockAllGradingScores,
refreshGradingScores,
toggleGradingLock,
updateConfigurationValue,
} from '../../api/session'
import type { GradingOverviewStudentRow } from '../../api/types'
import { import {
GRADING_BELOW_SIXTY_PATH, GRADING_BELOW_SIXTY_PATH,
GRADING_HOMEWORK_TRACKING_PATH, GRADING_HOMEWORK_TRACKING_PATH,
GRADING_PARTICIPATION_PATH, GRADING_PARTICIPATION_PATH,
GRADING_PLACEMENT_INDEX_PATH, GRADING_PLACEMENT_INDEX_PATH,
gradingScoresPath,
} from './gradingPaths' } from './gradingPaths'
/** CI `grading/grading_main.php` — hub + filters; detail tabs come from API payload when available. */ const SCORE_ACTIONS = [
{ key: 'homework', label: 'Homework' },
{ key: 'quiz', label: 'Quiz' },
{ key: 'project', label: 'Project' },
{ key: 'comments', label: 'Comments' },
] as const
function normalizeQuery(value: string) {
return value.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim()
}
export function GradingMainPage() { export function GradingMainPage() {
const [searchParams] = useSearchParams() const [searchParams, setSearchParams] = useSearchParams()
const [payload, setPayload] = useState<unknown>(null) const semesterParam = searchParams.get('semester') ?? ''
const schoolYearParam = searchParams.get('school_year') ?? ''
const [data, setData] = useState<Awaited<ReturnType<typeof fetchGradingOverview>> | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [activeGradeKey, setActiveGradeKey] = useState<string>('')
const [activeSectionId, setActiveSectionId] = useState<string>('')
const [studentSearch, setStudentSearch] = useState('')
const [tableSearch, setTableSearch] = useState('')
const [actionBusy, setActionBusy] = useState<string | null>(null)
const [filters, setFilters] = useState({
school_year: schoolYearParam,
semester: semesterParam,
})
async function loadOverview() {
const result = await fetchGradingOverview({
semester: semesterParam || null,
schoolYear: schoolYearParam || null,
})
setData(result)
const firstGradeKey = String(result.requested_class_id ?? Object.keys(result.grades ?? {})[0] ?? '')
setActiveGradeKey(firstGradeKey)
const firstSection = (result.grades?.[firstGradeKey] ?? [])[0]
setActiveSectionId(String(firstSection?.class_section_id ?? ''))
}
useEffect(() => { useEffect(() => {
let c = false let cancelled = false
;(async () => {
setLoading(true) setLoading(true)
fetchGradingMain(searchParams) try {
.then((p) => { const result = await fetchGradingOverview({
if (!c) { semester: semesterParam || null,
setPayload(p) schoolYear: schoolYearParam || null,
})
if (cancelled) return
setData(result)
const firstGradeKey = String(result.requested_class_id ?? Object.keys(result.grades ?? {})[0] ?? '')
setActiveGradeKey(firstGradeKey)
const firstSection = (result.grades?.[firstGradeKey] ?? [])[0]
setActiveSectionId(String(firstSection?.class_section_id ?? ''))
if (schoolYearParam === '' || semesterParam === '') {
const next = new URLSearchParams(searchParams)
if (schoolYearParam === '' && (result.school_year ?? '').trim() !== '') {
next.set('school_year', String(result.school_year))
}
if (semesterParam === '' && (result.semester ?? '').trim() !== '') {
next.set('semester', String(result.semester))
}
setFilters({
school_year: String(next.get('school_year') ?? result.school_year ?? ''),
semester: String(next.get('semester') ?? result.semester ?? ''),
})
setSearchParams(next, { replace: true })
}
setError(null) setError(null)
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : 'Unable to load grading hub.')
} finally {
if (!cancelled) setLoading(false)
} }
}) })()
.catch((e: unknown) => {
if (!c)
setError(e instanceof ApiHttpError ? e.message : 'Unable to load grading hub.')
})
.finally(() => {
if (!c) setLoading(false)
})
return () => { return () => {
c = true cancelled = true
}
}, [schoolYearParam, searchParams, semesterParam, setSearchParams])
useEffect(() => {
setFilters({
school_year: schoolYearParam,
semester: semesterParam,
})
}, [schoolYearParam, semesterParam])
const grades = data?.grades ?? {}
const studentsBySection = data?.students_by_section ?? {}
const gradeKeys = Object.keys(grades)
const activeGrade = activeGradeKey && grades[activeGradeKey] ? activeGradeKey : gradeKeys[0] ?? ''
const gradeSections = grades[activeGrade] ?? []
const displayedSection =
gradeSections.find((section) => String(section.class_section_id) === activeSectionId) ??
gradeSections[0] ??
null
useEffect(() => {
if (!displayedSection && gradeSections[0]) {
setActiveSectionId(String(gradeSections[0].class_section_id))
}
}, [displayedSection, gradeSections])
const normalizedSearch = normalizeQuery(studentSearch)
const sectionQuery = normalizeQuery(tableSearch)
const searchMatches = useMemo(() => {
if (normalizedSearch === '') return [] as Array<{
sectionId: string
sectionName: string
classId: string
student: GradingOverviewStudentRow
}>
const matches: Array<{
sectionId: string
sectionName: string
classId: string
student: GradingOverviewStudentRow
}> = []
for (const [classId, sections] of Object.entries(grades)) {
for (const section of sections ?? []) {
const sectionId = String(section.class_section_id ?? '')
const sectionName = section.class_section_name ?? `Section ${sectionId}`
const rows = studentsBySection[sectionId] ?? []
for (const student of rows) {
const haystack = normalizeQuery(`${student.school_id ?? ''} ${student.firstname ?? ''} ${student.lastname ?? ''} ${sectionName}`)
if (haystack.includes(normalizedSearch)) {
matches.push({ sectionId, sectionName, classId, student })
}
}
}
}
return matches
}, [grades, normalizedSearch, studentsBySection])
const renderedRows = useMemo(() => {
if (normalizedSearch !== '') return searchMatches
if (!displayedSection) return [] as Array<{
sectionId: string
sectionName: string
classId: string
student: GradingOverviewStudentRow
}>
const sectionId = String(displayedSection.class_section_id ?? '')
const sectionName = displayedSection.class_section_name ?? `Section ${sectionId}`
return (studentsBySection[sectionId] ?? []).map((student) => ({
sectionId,
sectionName,
classId: activeGrade,
student,
}))
}, [activeGrade, displayedSection, normalizedSearch, searchMatches, studentsBySection])
const visibleRows = useMemo(() => {
if (sectionQuery === '') return renderedRows
return renderedRows.filter(({ sectionName, student }) => {
const haystack = normalizeQuery(`${student.school_id ?? ''} ${student.firstname ?? ''} ${student.lastname ?? ''} ${sectionName}`)
return haystack.includes(sectionQuery)
})
}, [renderedRows, sectionQuery])
const gradeCount = (classId: string) =>
(grades[classId] ?? []).reduce((count, section) => count + (studentsBySection[String(section.class_section_id)]?.length ?? 0), 0)
const gradeLabel = (classId: string) => {
const numeric = Number(classId)
if (numeric === 13) return 'KG'
if (numeric === 12) return 'Youth'
return `Grade ${classId}`
} }
}, [searchParams])
const q = searchParams.toString() const q = searchParams.toString()
const qs = q ? `?${q}` : '' const qs = q ? `?${q}` : ''
const selectedSectionId = Number(displayedSection?.class_section_id ?? 0)
const selectedSemester = data?.semester ?? semesterParam
const selectedSchoolYear = data?.school_year ?? schoolYearParam
const selectedLocked = selectedSectionId > 0 ? Boolean(data?.score_locks?.[String(selectedSectionId)]) : false
const normalizedSemester = selectedSemester.trim().toLowerCase()
async function handleToggleSectionLock() {
if (!selectedSectionId || !selectedSemester || !selectedSchoolYear) return
setActionBusy('toggle-lock')
try {
await toggleGradingLock({
class_section_id: selectedSectionId,
semester: selectedSemester,
school_year: selectedSchoolYear,
})
await loadOverview()
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : 'Unable to update score lock.')
} finally {
setActionBusy(null)
}
}
async function handleRefreshScores() {
if (!selectedSectionId || !selectedSemester || !selectedSchoolYear) return
setActionBusy('refresh')
try {
await refreshGradingScores({
class_section_id: selectedSectionId,
semester: selectedSemester,
school_year: selectedSchoolYear,
})
await loadOverview()
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : 'Unable to refresh scores.')
} finally {
setActionBusy(null)
}
}
async function handleLockAllScores() {
if (!selectedSemester || !selectedSchoolYear) return
setActionBusy('lock-all')
try {
await lockAllGradingScores({
semester: selectedSemester,
school_year: selectedSchoolYear,
})
await loadOverview()
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : 'Unable to lock all scores.')
} finally {
setActionBusy(null)
}
}
async function handleToggleParentRelease(semester: 'Fall' | 'Spring') {
const key = semester === 'Fall' ? 'parent_scores_released_fall' : 'parent_scores_released_spring'
const current = semester === 'Fall' ? Boolean(data?.scores_released_fall) : Boolean(data?.scores_released_spring)
setActionBusy(`release-${semester.toLowerCase()}`)
try {
const configResponse = await fetchConfigurationByKey(key)
const configId = Number(configResponse.config?.id ?? 0)
if (!configId) {
throw new Error(`Configuration ${key} was not found.`)
}
await updateConfigurationValue(configId, {
config_key: key,
config_value: current ? '0' : '1',
})
await loadOverview()
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : 'Unable to update parent score release.')
} finally {
setActionBusy(null)
}
}
return ( return (
<div className="container-fluid py-3"> <div className="container-fluid py-3">
<h2 className="text-center mt-4 mb-3">Grading Management</h2> <div className="text-center mt-2 mb-3">
<h2 className="display-6 mb-2">Grading Management</h2>
</div>
<AcademicFilterBar /> <form
className="row g-2 justify-content-center align-items-end mb-3"
onSubmit={(event) => {
event.preventDefault()
const next = new URLSearchParams()
if (filters.school_year.trim() !== '') next.set('school_year', filters.school_year.trim())
if (filters.semester.trim() !== '') next.set('semester', filters.semester.trim())
setSearchParams(next)
}}
>
<div className="col-12 col-md-auto">
<label className="form-label">School year</label>
<input
className="form-control"
value={filters.school_year}
onChange={(event) => setFilters((current) => ({ ...current, school_year: event.target.value }))}
placeholder={data?.school_year ?? '2025-2026'}
/>
</div>
<div className="col-12 col-md-auto">
<label className="form-label">Semester</label>
<select
className="form-select"
value={filters.semester}
onChange={(event) => setFilters((current) => ({ ...current, semester: event.target.value }))}
>
{(data?.semester_options ?? ['Fall', 'Spring']).map((option) => (
<option key={option} value={option}>{option}</option>
))}
</select>
</div>
<div className="col-12 col-md-auto d-flex gap-2">
<button className="btn btn-primary" type="submit">Apply</button>
<button
className="btn btn-outline-secondary"
type="button"
onClick={() => {
setFilters({ school_year: '', semester: '' })
setSearchParams(new URLSearchParams())
}}
>
Reset
</button>
</div>
</form>
<div className="row g-2 justify-content-center mb-3">
<div className="col-12 col-md-8 col-lg-6">
<div className="input-group">
<span className="input-group-text">Search Student</span>
<input
className="form-control"
type="text"
placeholder="Type School ID or Student Name"
value={studentSearch}
onChange={(event) => setStudentSearch(event.target.value)}
/>
<button className="btn btn-outline-secondary" type="button" onClick={() => setStudentSearch('')}>
Clear
</button>
</div>
<small className="text-muted">Searches all grades.</small>
</div>
</div>
<div className="row justify-content-center mb-3">
<div className="col-auto d-flex flex-wrap gap-2 justify-content-center">
<Link className="btn btn-outline-primary" to={`${GRADING_BELOW_SIXTY_PATH}${qs}`}>Below 60 Summary</Link>
<Link className="btn btn-outline-primary" to={`${GRADING_HOMEWORK_TRACKING_PATH}${qs}`}>Homework Tracking</Link>
<Link className="btn btn-outline-primary" to={`${GRADING_PLACEMENT_INDEX_PATH}${qs}`}>Placement Scores</Link>
</div>
</div>
<div className="row justify-content-center mb-3">
<div className="col-auto d-flex flex-wrap gap-2 justify-content-center">
<button
className={`btn ${data?.scores_released_fall ? 'btn-outline-danger' : 'btn-outline-success'}`}
type="button"
disabled={actionBusy !== null}
onClick={() => void handleToggleParentRelease('Fall')}
>
{actionBusy === 'release-fall'
? 'Updating…'
: `Release scores for Parent - Fall: ${data?.scores_released_fall ? 'ON' : 'OFF'}`}
</button>
<button
className={`btn ${data?.scores_released_spring ? 'btn-outline-danger' : 'btn-outline-success'}`}
type="button"
disabled={actionBusy !== null}
onClick={() => void handleToggleParentRelease('Spring')}
>
{actionBusy === 'release-spring'
? 'Updating…'
: `Release scores for Parent - Spring: ${data?.scores_released_spring ? 'ON' : 'OFF'}`}
</button>
<button
className="btn btn-outline-danger"
type="button"
disabled={!selectedSemester || !selectedSchoolYear || actionBusy !== null}
onClick={() => void handleLockAllScores()}
>
{actionBusy === 'lock-all' ? 'Locking…' : 'Lock All Scores'}
</button>
</div>
</div>
{error ? <div className="alert alert-danger">{error}</div> : null} {error ? <div className="alert alert-danger">{error}</div> : null}
{loading ? <p className="text-muted">Loading</p> : null} {loading ? (
<p className="text-muted">Loading grading overview</p>
<div className="card mb-4"> ) : gradeKeys.length === 0 ? (
<div className="card-header">Quick links</div> <p className="text-muted">No grading sections found.</p>
<div className="card-body row g-2"> ) : (
<div className="col-md-6 col-lg-4"> <>
<Link className="btn btn-outline-primary w-100" to={`${GRADING_BELOW_SIXTY_PATH}${qs}`}> <ul className="nav nav-tabs flex-wrap justify-content-center mb-4 border-bottom">
Below 60 Summary {gradeKeys.map((classId) => (
</Link> <li key={classId} className="nav-item">
</div> <button
<div className="col-md-6 col-lg-4"> type="button"
<Link className={`nav-link d-flex align-items-center gap-2 ${classId === activeGrade ? 'active' : ''}`}
className="btn btn-outline-primary w-100" onClick={() => setActiveGradeKey(classId)}
to={`${GRADING_HOMEWORK_TRACKING_PATH}${qs}`} style={{ color: classId === activeGrade ? '#1f2937' : '#334155' }}
> >
Homework Tracking <span
</Link> aria-hidden="true"
style={{
width: 10,
height: 10,
borderRadius: '50%',
backgroundColor: (grades[classId] ?? []).every((section) => Boolean(data?.score_locks?.[String(section.class_section_id)]))
? '#198754'
: '#dc3545',
display: 'inline-block',
}}
/>
<span>{gradeLabel(classId)}</span>
<span className="badge rounded-pill text-bg-secondary">{gradeCount(classId)}</span>
</button>
</li>
))}
</ul>
<div className="d-flex flex-wrap justify-content-center gap-2 mb-3">
{gradeSections.map((section) => (
<button
key={section.class_section_id}
type="button"
className={`btn btn-sm ${String(section.class_section_id) === String(displayedSection?.class_section_id ?? '') ? 'btn-primary' : 'btn-outline-primary'}`}
onClick={() => setActiveSectionId(String(section.class_section_id))}
>
{section.class_section_name}
</button>
))}
</div> </div>
<div className="col-md-6 col-lg-4">
<Link className="btn btn-outline-primary w-100" to={`${GRADING_PARTICIPATION_PATH}${qs}`}> {normalizedSearch !== '' && searchMatches.length > 0 ? (
Participation Scores <div className="row g-2 justify-content-center mb-3">
</Link> <div className="col-12 col-md-8 col-lg-6">
<div className="list-group" style={{ maxHeight: 240, overflowY: 'auto' }}>
{searchMatches.slice(0, 12).map((match) => (
<button
key={`${match.sectionId}-${match.student.id}`}
type="button"
className="list-group-item list-group-item-action"
onClick={() => {
setActiveGradeKey(match.classId)
setActiveSectionId(match.sectionId)
}}
>
<div className="fw-semibold">{match.student.school_id ?? '—'} - {`${match.student.firstname ?? ''} ${match.student.lastname ?? ''}`.trim()}</div>
<div className="small text-muted">{match.sectionName}</div>
</button>
))}
</div> </div>
<div className="col-md-6 col-lg-4">
<Link className="btn btn-outline-primary w-100" to={`${GRADING_PLACEMENT_INDEX_PATH}${qs}`}>
Placement Scores
</Link>
</div> </div>
<div className="col-md-6 col-lg-4">
<Link className="btn btn-outline-secondary w-100" to={`${gradingScoresPath('homework')}${qs}`}>
Example: Homework scores (needs student_id &amp; class_section_id)
</Link>
</div> </div>
) : null}
{displayedSection || normalizedSearch !== '' ? (
<>
<div className="text-center mb-3">
<h3 className="h2 mb-2">{normalizedSearch !== '' ? 'Search Results' : (displayedSection?.class_section_name ?? 'Scores')}</h3>
<div className="text-muted small">
{data?.semester || '—'} {data?.school_year ? `${data.school_year}` : ''}
</div> </div>
</div> </div>
{payload != null && !loading ? ( <div className="d-flex flex-wrap justify-content-center gap-2 mb-3">
<details className="mb-4"> <button
<summary className="small text-muted">Raw API payload (tabs/sections render here when wired)</summary> className="btn btn-sm btn-outline-danger"
<pre className="small bg-light p-3 rounded mt-2 overflow-auto" style={{ maxHeight: 420 }}> type="button"
{JSON.stringify(payload, null, 2)} disabled={!selectedSectionId || !selectedSemester || !selectedSchoolYear || actionBusy !== null}
</pre> onClick={() => void handleToggleSectionLock()}
</details> >
{actionBusy === 'toggle-lock' ? 'Updating…' : selectedLocked ? 'Unlock Scores' : 'Lock Scores'}
</button>
{SCORE_ACTIONS.map((action) => (
<Link
key={action.key}
className="btn btn-sm btn-outline-primary"
to={displayedSection ? `/app/grading/scores/${action.key}/${displayedSection.class_section_id}${qs}` : '#'}
>
{action.label}
</Link>
))}
{normalizedSemester === 'fall' ? (
<Link
className="btn btn-sm btn-outline-primary"
to={displayedSection ? `/app/grading/scores/midterm/${displayedSection.class_section_id}${qs}` : '#'}
>
Midterm
</Link>
) : null} ) : null}
{normalizedSemester === 'spring' ? (
<Link
className="btn btn-sm btn-outline-primary"
to={displayedSection ? `/app/grading/scores/final/${displayedSection.class_section_id}${qs}` : '#'}
>
Final
</Link>
) : null}
<Link
className="btn btn-sm btn-outline-primary"
to={
selectedSectionId
? `${GRADING_PARTICIPATION_PATH}?class_section_id=${encodeURIComponent(String(selectedSectionId))}${selectedSemester ? `&semester=${encodeURIComponent(selectedSemester)}` : ''}${selectedSchoolYear ? `&school_year=${encodeURIComponent(selectedSchoolYear)}` : ''}`
: GRADING_PARTICIPATION_PATH
}
>
Participation
</Link>
<button
className="btn btn-sm btn-outline-secondary"
type="button"
disabled={!selectedSectionId || !selectedSemester || !selectedSchoolYear || actionBusy !== null}
onClick={() => void handleRefreshScores()}
>
{actionBusy === 'refresh' ? 'Recomputing…' : 'Refresh / Recalculate Scores'}
</button>
</div>
<div className="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-2">
<div className="d-flex align-items-center gap-2">
<span>Show</span>
<select className="form-select form-select-sm" style={{ width: 78 }} defaultValue="100" disabled>
<option value="100">100</option>
</select>
<span>entries</span>
</div>
<div className="d-flex align-items-center gap-2">
<label className="mb-0">Search:</label>
<input
className="form-control form-control-sm"
style={{ width: 220 }}
value={tableSearch}
onChange={(event) => setTableSearch(event.target.value)}
/>
</div>
</div>
<div className="table-responsive">
<table className="table table-bordered table-striped align-middle">
<thead className="table-light">
<tr>
<th>School ID</th>
{normalizedSearch !== '' ? <th>Section</th> : null}
<th>Student Name</th>
<th>Homework</th>
<th>Quiz</th>
<th>Project</th>
<th>Participation</th>
<th>Midterm</th>
<th>Final</th>
<th>Semester Score</th>
</tr>
</thead>
<tbody>
{visibleRows.map(({ sectionId, sectionName, student }) => (
<tr key={`${sectionId}-${student.id}`}>
<td>{student.school_id ?? '—'}</td>
{normalizedSearch !== '' ? <td>{sectionName}</td> : null}
<td>{`${student.firstname ?? ''} ${student.lastname ?? ''}`.trim() || '—'}</td>
<td>{student.homework_avg ?? '—'}</td>
<td>{student.quiz_avg ?? '—'}</td>
<td>{student.project_avg ?? '—'}</td>
<td>{student.participation ?? '—'}</td>
<td>{student.midterm_exam ?? '—'}</td>
<td>{student.final_exam ?? '—'}</td>
<td>{student.semester_score ?? '—'}</td>
</tr>
))}
{visibleRows.length === 0 ? (
<tr>
<td colSpan={normalizedSearch !== '' ? 10 : 9} className="text-muted text-center">
No students match the current search.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</>
) : null}
</>
)}
</div> </div>
) )
} }
+348 -20
View File
@@ -1,9 +1,13 @@
import { type FormEvent, useEffect, useState } from 'react' import { type FormEvent, useEffect, useMemo, useState } from 'react'
import { Link, useParams, useSearchParams } from 'react-router-dom' import { Link, useParams, useSearchParams } from 'react-router-dom'
import { ApiHttpError } from '../../api/http' import { ApiHttpError } from '../../api/http'
import { import {
fetchGradingSectionScoreTable,
fetchGradingScoreForm, fetchGradingScoreForm,
postGradingSectionScoreUpdate,
postGradingScoreUpdate, postGradingScoreUpdate,
type GradingSectionScorePayload,
type GradingSectionScoreStudent,
type GradingScoreFormPayload, type GradingScoreFormPayload,
type GradingScoreRow, type GradingScoreRow,
} from '../../api/grading' } from '../../api/grading'
@@ -12,12 +16,13 @@ import { GRADING_PATH } from './gradingPaths'
/** CI `grading/test.php`, `homework.php`, `quiz.php`, `project.php`, `midterm.php`, `final.php`, `comments.php`. */ /** CI `grading/test.php`, `homework.php`, `quiz.php`, `project.php`, `midterm.php`, `final.php`, `comments.php`. */
export function GradingScoreEntryPage() { export function GradingScoreEntryPage() {
const { scoreType = '' } = useParams() const { scoreType = '', classSectionId = '', studentId = '' } = useParams()
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
const studentId = searchParams.get('student_id') ?? '' const semester = searchParams.get('semester') ?? ''
const classSectionId = searchParams.get('class_section_id') ?? '' const schoolYear = searchParams.get('school_year') ?? ''
const [data, setData] = useState<GradingScoreFormPayload | null>(null) const [data, setData] = useState<GradingScoreFormPayload | null>(null)
const [sectionData, setSectionData] = useState<GradingSectionScorePayload | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
@@ -25,38 +30,70 @@ export function GradingScoreEntryPage() {
const [scoreRows, setScoreRows] = useState<GradingScoreRow[]>([]) const [scoreRows, setScoreRows] = useState<GradingScoreRow[]>([])
const [singleScore, setSingleScore] = useState('') const [singleScore, setSingleScore] = useState('')
const [generalComment, setGeneralComment] = useState('') const [generalComment, setGeneralComment] = useState('')
const [sectionRows, setSectionRows] = useState<GradingSectionScoreStudent[]>([])
const [tableSearch, setTableSearch] = useState('')
const isSectionMode = studentId === ''
const resolvedSemester = sectionData?.semester ?? semester
const resolvedSchoolYear = sectionData?.school_year ?? schoolYear
const resolvedSectionName = (sectionData?.class_section_name ?? data?.class_section_name ?? '').trim()
function sectionQuery() {
const query = new URLSearchParams()
if (semester !== '') query.set('semester', semester)
if (schoolYear !== '') query.set('school_year', schoolYear)
return query
}
useEffect(() => { useEffect(() => {
if (!isGradingScoreType(scoreType) || !studentId || !classSectionId) { if (!isGradingScoreType(scoreType) || !studentId || !classSectionId || !semester || !schoolYear) {
if (!isSectionMode || !isGradingScoreType(scoreType) || !classSectionId) {
setLoading(false) setLoading(false)
setData(null) setData(null)
setSectionData(null)
return return
} }
}
let c = false let c = false
setLoading(true) setLoading(true)
fetchGradingScoreForm(scoreType, studentId, classSectionId) if (isSectionMode) {
fetchGradingSectionScoreTable(scoreType, classSectionId, sectionQuery())
.then((payload) => {
if (c) return
setSectionData(payload)
setSectionRows(Array.isArray(payload.students) ? payload.students.map((row) => ({ ...row })) : [])
setData(null)
setError(null)
})
.catch((e: unknown) => {
if (!c) setError(e instanceof ApiHttpError ? e.message : 'Unable to load score table.')
})
.finally(() => {
if (!c) setLoading(false)
})
} else {
fetchGradingScoreForm(scoreType, classSectionId, studentId, sectionQuery())
.then((p) => { .then((p) => {
if (c) return if (c) return
setData(p) setData(p)
setScoreRows(Array.isArray(p.scores) ? [...p.scores] : []) setScoreRows(Array.isArray(p.scores) ? [...p.scores] : [])
const first = p.scores?.[0] const first = p.scores?.[0]
setSingleScore( setSingleScore(first?.score !== undefined && first?.score !== null ? String(first.score) : '')
first?.score !== undefined && first?.score !== null ? String(first.score) : '',
)
setGeneralComment(first?.comment != null ? String(first.comment) : '') setGeneralComment(first?.comment != null ? String(first.comment) : '')
setSectionData(null)
setError(null) setError(null)
}) })
.catch((e: unknown) => { .catch((e: unknown) => {
if (!c) if (!c) setError(e instanceof ApiHttpError ? e.message : 'Unable to load score form.')
setError(e instanceof ApiHttpError ? e.message : 'Unable to load score form.')
}) })
.finally(() => { .finally(() => {
if (!c) setLoading(false) if (!c) setLoading(false)
}) })
}
return () => { return () => {
c = true c = true
} }
}, [scoreType, studentId, classSectionId]) }, [classSectionId, isSectionMode, schoolYear, scoreType, semester, studentId])
const title = const title =
scoreType === 'homework' scoreType === 'homework'
@@ -76,15 +113,125 @@ export function GradingScoreEntryPage() {
: 'Scores' : 'Scores'
const locked = Boolean(data?.scoresLocked) const locked = Boolean(data?.scoresLocked)
const sectionLocked = Boolean(sectionData?.scores_locked)
const student = data?.student const student = data?.student
const name = const name =
student?.firstname || student?.lastname student?.firstname || student?.lastname
? `${student?.firstname ?? ''} ${student?.lastname ?? ''}`.trim() ? `${student?.firstname ?? ''} ${student?.lastname ?? ''}`.trim()
: 'Student' : 'Student'
const studentSchoolId = student?.school_id ?? null
const visibleSectionRows = useMemo(() => {
const normalized = tableSearch.toLowerCase().trim()
if (normalized === '') return sectionRows
return sectionRows.filter((row) => {
const haystack = `${row.school_id ?? ''} ${row.firstname ?? ''} ${row.lastname ?? ''}`.toLowerCase()
return haystack.includes(normalized)
})
}, [sectionRows, tableSearch])
const sectionHeaders = sectionData?.headers ?? []
function updateSectionScore(studentIdValue: number, key: string, value: string) {
setSectionRows((current) =>
current.map((row) =>
row.student_id === studentIdValue
? {
...row,
scores: {
...(row.scores ?? {}),
[key]: value,
},
}
: row,
),
)
}
function updateSectionExamScore(studentIdValue: number, value: string) {
setSectionRows((current) =>
current.map((row) => (row.student_id === studentIdValue ? { ...row, score: value } : row)),
)
}
function updateSectionComment(
studentIdValue: number,
key: string,
field: 'comment' | 'comment_review',
value: string,
) {
setSectionRows((current) =>
current.map((row) =>
row.student_id === studentIdValue
? {
...row,
comments: {
...(row.comments ?? {}),
[key]: {
...(row.comments?.[key] ?? {}),
[field]: value,
},
},
}
: row,
),
)
}
async function onSubmit(e: FormEvent) { async function onSubmit(e: FormEvent) {
e.preventDefault() e.preventDefault()
if (!isGradingScoreType(scoreType) || !studentId || !classSectionId) return if (isSectionMode) {
if (!isGradingScoreType(scoreType) || !classSectionId || !resolvedSemester || !resolvedSchoolYear) return
setSaving(true)
setError(null)
try {
let body: Record<string, unknown> = {
class_section_id: Number(classSectionId),
semester: resolvedSemester,
school_year: resolvedSchoolYear,
}
if (['homework', 'quiz', 'project'].includes(scoreType)) {
const scores: Record<string, Record<string, number | string | null>> = {}
for (const row of sectionRows) {
scores[String(row.student_id)] = {}
for (const header of sectionHeaders) {
scores[String(row.student_id)][String(header)] = row.scores?.[String(header)] ?? ''
}
}
body = { ...body, scores }
} else if (['midterm', 'final'].includes(scoreType)) {
const scores: Record<string, { score: number | string | null }> = {}
for (const row of sectionRows) {
scores[String(row.student_id)] = { score: row.score ?? '' }
}
body = { ...body, scores }
} else if (scoreType === 'comments') {
const comments: Record<string, Record<string, string>> = {}
const reviews: Record<string, Record<string, string>> = {}
for (const row of sectionRows) {
comments[String(row.student_id)] = {}
reviews[String(row.student_id)] = {}
for (const [commentType, commentData] of Object.entries(row.comments ?? {})) {
comments[String(row.student_id)][commentType] = commentData?.comment ?? ''
reviews[String(row.student_id)][commentType] = commentData?.comment_review ?? ''
}
}
body = { ...body, comments, reviews }
}
await postGradingSectionScoreUpdate(scoreType, body)
const next = await fetchGradingSectionScoreTable(scoreType, classSectionId, sectionQuery())
setSectionData(next)
setSectionRows(Array.isArray(next.students) ? next.students.map((row) => ({ ...row })) : [])
} catch (err: unknown) {
setError(err instanceof ApiHttpError ? err.message : 'Save failed.')
} finally {
setSaving(false)
}
return
}
if (!isGradingScoreType(scoreType) || !studentId || !classSectionId || !semester || !schoolYear) return
setSaving(true) setSaving(true)
setError(null) setError(null)
try { try {
@@ -92,6 +239,8 @@ export function GradingScoreEntryPage() {
type: scoreType, type: scoreType,
student_id: Number(studentId), student_id: Number(studentId),
class_section_id: Number(classSectionId), class_section_id: Number(classSectionId),
semester,
school_year: schoolYear,
} }
if (['homework', 'quiz', 'project'].includes(scoreType)) { if (['homework', 'quiz', 'project'].includes(scoreType)) {
body = { body = {
@@ -105,10 +254,21 @@ export function GradingScoreEntryPage() {
} else if (scoreType === 'comments') { } else if (scoreType === 'comments') {
body = { ...body, comment: generalComment } body = { ...body, comment: generalComment }
} }
await postGradingScoreUpdate(scoreType, body) await postGradingScoreUpdate(body)
const next = await fetchGradingScoreForm(scoreType, studentId, classSectionId) const next = await fetchGradingScoreForm(
scoreType,
classSectionId,
studentId,
new URLSearchParams({
semester,
school_year: schoolYear,
}),
)
setData(next) setData(next)
setScoreRows(Array.isArray(next.scores) ? [...next.scores] : []) setScoreRows(Array.isArray(next.scores) ? [...next.scores] : [])
const first = next.scores?.[0]
setSingleScore(first?.score !== undefined && first?.score !== null ? String(first.score) : '')
setGeneralComment(first?.comment != null ? String(first.comment) : '')
} catch (err: unknown) { } catch (err: unknown) {
setError(err instanceof ApiHttpError ? err.message : 'Save failed.') setError(err instanceof ApiHttpError ? err.message : 'Save failed.')
} finally { } finally {
@@ -124,12 +284,12 @@ export function GradingScoreEntryPage() {
) )
} }
if (!studentId || !classSectionId) { if ((!isSectionMode && (!studentId || !classSectionId || !semester || !schoolYear)) || (isSectionMode && !classSectionId)) {
return ( return (
<div className="container mt-4"> <div className="container mt-4">
<div className="alert alert-info"> <div className="alert alert-info">
Add <code>student_id</code> and <code>class_section_id</code> query parameters (open from Open this page from Grading Management so the route includes the section and the URL includes
Grading Management). <code>semester</code> and <code>school_year</code>.
</div> </div>
<Link className="btn btn-secondary" to={GRADING_PATH}> <Link className="btn btn-secondary" to={GRADING_PATH}>
Return to Grading Management Return to Grading Management
@@ -141,9 +301,14 @@ export function GradingScoreEntryPage() {
return ( return (
<div className="container mt-5"> <div className="container mt-5">
<h2 className="text-center mb-4"> <h2 className="text-center mb-4">
{title} Scores for <span className="text-decoration-none">{name}</span> {isSectionMode ? `Update ${title} Scores` : `${title} Scores for `}
{!isSectionMode ? <span className="text-decoration-none">{name}</span> : null}
</h2> </h2>
{locked ? ( <p className="text-center text-muted small">
{isSectionMode ? null : <>School ID {studentSchoolId || '—'} </>}
{(resolvedSectionName || `Section ${classSectionId}`)} {resolvedSemester || '—'} {resolvedSchoolYear || '—'}
</p>
{(isSectionMode ? sectionLocked : locked) ? (
<div className="alert alert-warning text-center"> <div className="alert alert-warning text-center">
Scores are locked for this class. Unlock to edit. Scores are locked for this class. Unlock to edit.
</div> </div>
@@ -231,6 +396,169 @@ export function GradingScoreEntryPage() {
</form> </form>
) : null} ) : null}
{!loading && isSectionMode && sectionData ? (
<form onSubmit={(e) => void onSubmit(e)}>
<div className="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-3">
<div className="d-flex align-items-center gap-2">
<span>Show</span>
<select className="form-select form-select-sm" style={{ width: 78 }} defaultValue="100" disabled>
<option value="100">100</option>
</select>
<span>entries</span>
</div>
<div className="d-flex align-items-center gap-2">
<label className="mb-0">Search:</label>
<input
className="form-control form-control-sm"
style={{ width: 220 }}
value={tableSearch}
onChange={(event) => setTableSearch(event.target.value)}
/>
</div>
</div>
<div className="table-responsive">
<table className="table table-bordered table-striped align-middle">
<thead className="table-light">
<tr>
<th>#</th>
<th>School ID</th>
<th>First Name</th>
<th>Last Name</th>
{['homework', 'quiz', 'project'].includes(scoreType)
? sectionHeaders.map((header) => <th key={header} className="text-center">{title.replace(' Scores', '')} {header}</th>)
: null}
{['midterm', 'final'].includes(scoreType) ? <th className="text-center">Score</th> : null}
{scoreType === 'comments' ? (
<>
<th>PTAP Comment</th>
<th>PTAP Review</th>
{semester === 'Fall' ? <th>Midterm Comment</th> : <th>Final Comment</th>}
{semester === 'Fall' ? <th>Midterm Review</th> : <th>Final Review</th>}
<th>Attendance Comment</th>
</>
) : null}
</tr>
</thead>
<tbody>
{visibleSectionRows.map((row, index) => (
<tr key={row.student_id}>
<td>{index + 1}</td>
<td>{row.school_id ?? '—'}</td>
<td>{row.firstname ?? '—'}</td>
<td>{row.lastname ?? '—'}</td>
{['homework', 'quiz', 'project'].includes(scoreType)
? sectionHeaders.map((header) => (
<td key={`${row.student_id}-${header}`}>
<input
type="number"
min="0"
max="100"
step="0.01"
className="form-control text-center"
disabled={sectionLocked}
value={row.scores?.[String(header)] ?? ''}
onChange={(event) => updateSectionScore(row.student_id, String(header), event.target.value)}
/>
</td>
))
: null}
{['midterm', 'final'].includes(scoreType) ? (
<td>
<input
type="number"
min="0"
max="100"
step="0.01"
className="form-control text-center"
disabled={sectionLocked}
value={row.score ?? ''}
onChange={(event) => updateSectionExamScore(row.student_id, event.target.value)}
/>
</td>
) : null}
{scoreType === 'comments' ? (
<>
<td>
<textarea
className="form-control"
rows={3}
disabled={sectionLocked}
value={row.comments?.ptap?.comment ?? ''}
onChange={(event) => updateSectionComment(row.student_id, 'ptap', 'comment', event.target.value)}
/>
</td>
<td>
<textarea
className="form-control"
rows={3}
disabled={sectionLocked}
value={row.comments?.ptap?.comment_review ?? ''}
onChange={(event) => updateSectionComment(row.student_id, 'ptap', 'comment_review', event.target.value)}
/>
</td>
<td>
<textarea
className="form-control"
rows={3}
disabled={sectionLocked}
value={row.comments?.[semester === 'Fall' ? 'midterm' : 'final']?.comment ?? ''}
onChange={(event) =>
updateSectionComment(row.student_id, semester === 'Fall' ? 'midterm' : 'final', 'comment', event.target.value)
}
/>
</td>
<td>
<textarea
className="form-control"
rows={3}
disabled={sectionLocked}
value={row.comments?.[semester === 'Fall' ? 'midterm' : 'final']?.comment_review ?? ''}
onChange={(event) =>
updateSectionComment(row.student_id, semester === 'Fall' ? 'midterm' : 'final', 'comment_review', event.target.value)
}
/>
</td>
<td>
<textarea
className="form-control"
rows={3}
disabled={sectionLocked}
value={row.comments?.attendance?.comment ?? ''}
onChange={(event) => updateSectionComment(row.student_id, 'attendance', 'comment', event.target.value)}
/>
</td>
</>
) : null}
</tr>
))}
{visibleSectionRows.length === 0 ? (
<tr>
<td
colSpan={
4 +
(['homework', 'quiz', 'project'].includes(scoreType) ? sectionHeaders.length : 0) +
(['midterm', 'final'].includes(scoreType) ? 1 : 0) +
(scoreType === 'comments' ? 5 : 0)
}
className="text-muted text-center"
>
No students match the current search.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
<div className="text-center mt-3">
<button type="submit" className="btn btn-primary" disabled={sectionLocked || saving}>
{saving ? 'Saving…' : 'Save Changes'}
</button>
</div>
</form>
) : null}
<div className="text-center mt-4"> <div className="text-center mt-4">
<Link className="btn btn-secondary" to={GRADING_PATH}> <Link className="btn btn-secondary" to={GRADING_PATH}>
Return to Main Page Return to Main Page
+124 -39
View File
@@ -1,20 +1,56 @@
import { useEffect, 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 { fetchHomeworkTracking } from '../../api/grading' import { fetchHomeworkTracking } from '../../api/grading'
import { AcademicFilterBar } from '../discounts/AcademicFilterBar' import { AcademicFilterBar } from '../discounts/AcademicFilterBar'
import { trackingSectionsFromPayload } from './gradingPayloadHelpers'
import { GRADING_PATH } from './gradingPaths' import { GRADING_PATH } from './gradingPaths'
function cell(v: unknown): string { type HomeworkTrackingTeacherRow = {
if (v === null || v === undefined || v === '') return '—' class_section_id?: number
return String(v) class_id?: number | string | null
class_section_name?: string | null
teachers?: string[]
tas?: string[]
}
type HomeworkTrackingPayload = {
ok?: boolean
semester?: string
school_year?: string
sundays?: string[]
event_days?: Record<string, unknown>
has_homework_by_date?: Record<string, Record<string, unknown>>
hw_entered_at_by_date?: Record<string, Record<string, string>>
teachers?: HomeworkTrackingTeacherRow[]
page?: number
total_pages?: number
}
function classLabel(classId: number) {
if (classId === 13) return 'KG'
if (classId === 12) return 'Youth'
if (classId >= 1 && classId <= 11) return `Grade ${classId}`
return `Class ${classId}`
}
function formatHeaderDate(value: string) {
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(value)
if (match) return `${match[2]}-${match[3]}-${match[1]}`
return value
}
function formatEnteredDate(value?: string) {
if (!value) return '✓'
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(value)
if (match) return `${match[2]}-${match[3]}-${match[1]}`
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return `${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}-${date.getFullYear()}`
} }
/** CI `grading/homework_tracking.php` */
export function HomeworkTrackingPage() { export function HomeworkTrackingPage() {
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
const [payload, setPayload] = useState<unknown>(null) const [payload, setPayload] = useState<HomeworkTrackingPayload | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -39,14 +75,34 @@ export function HomeworkTrackingPage() {
} }
}, [searchParams]) }, [searchParams])
const sections = trackingSectionsFromPayload(payload) const teachers = Array.isArray(payload?.teachers) ? payload.teachers : []
const sundays = Array.isArray(payload?.sundays) ? payload.sundays : []
const eventDays = payload?.event_days ?? {}
const hasHomeworkByDate = payload?.has_homework_by_date ?? {}
const enteredAtByDate = payload?.hw_entered_at_by_date ?? {}
const semester = payload?.semester ?? searchParams.get('semester') ?? ''
const schoolYear = payload?.school_year ?? searchParams.get('school_year') ?? ''
const qs = searchParams.toString() const qs = searchParams.toString()
const todayYmd = useMemo(() => new Date().toISOString().slice(0, 10), [])
return ( return (
<div className="container-fluid py-3"> <div className="container-fluid py-3">
<h2 className="text-center mt-2 mb-3">Homework tracking</h2> <h2 className="text-center mt-2 mb-3">Homework tracking</h2>
<AcademicFilterBar /> <AcademicFilterBar />
<div className="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
<div className="text-muted">
{semester ? `${semester}` : ''}
{schoolYear}
</div>
<div className="small d-flex gap-2 flex-wrap">
<span className="badge text-bg-success">Entered</span>
<span className="badge text-bg-danger">Missing</span>
<span className="badge text-bg-warning">No School</span>
<span className="badge text-dark border" style={{ backgroundColor: '#e7f1ff' }}>Future</span>
</div>
</div>
<div className="mb-3"> <div className="mb-3">
<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}` : ''}`}>
Grading hub Grading hub
@@ -56,51 +112,80 @@ export function HomeworkTrackingPage() {
{error ? <div className="alert alert-danger">{error}</div> : null} {error ? <div className="alert alert-danger">{error}</div> : null}
{loading ? <p className="text-muted">Loading</p> : null} {loading ? <p className="text-muted">Loading</p> : null}
{!loading && sections.length === 0 ? ( {!loading && teachers.length === 0 ? (
<div className="alert alert-light border">No rows returned for this filter.</div> <div className="alert alert-light border">No rows returned for this filter.</div>
) : null} ) : null}
{!loading && {!loading && teachers.length > 0 ? (
sections.map((sec) => {
const columns =
sec.rows[0] && typeof sec.rows[0] === 'object'
? Object.keys(sec.rows[0] as object)
: ['student_id', 'name', 'score', 'comment']
return (
<div key={sec.title} className="mb-5">
<h5 className="border-bottom pb-2">{sec.title}</h5>
<div className="table-responsive"> <div className="table-responsive">
<table className="table table-sm table-bordered align-middle"> <table className="table table-bordered align-middle">
<thead className="table-light"> <thead className="table-light">
<tr> <tr>
{columns.map((col) => ( <th>Grade</th>
<th key={col}>{col.replace(/_/g, ' ')}</th> <th style={{ minWidth: 300 }}>Teacher &amp; TAs</th>
))} <th className="text-center">HW Submitted</th>
{sundays.map((date) => {
const isEventDay = Boolean(eventDays[date])
const isFuture = date > todayYmd
return (
<th
key={date}
className={`text-center${isEventDay ? ' bg-warning' : ''}`}
style={isFuture && !isEventDay ? { backgroundColor: '#e7f1ff' } : undefined}
title={date}
>
{formatHeaderDate(date)}
</th>
)
})}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{sec.rows.map((row, ri) => ( {teachers.map((row, index) => {
<tr key={ri}> const classId = Number(row.class_id ?? 0)
{columns.map((col) => ( const sectionId = Number(row.class_section_id ?? 0)
<td key={col}>{cell(row[col])}</td> const sectionName = row.class_section_name ?? ''
))} const teacherNames = Array.isArray(row.teachers) ? row.teachers : []
const taNames = Array.isArray(row.tas) ? row.tas : []
const submissionCount = Object.values(hasHomeworkByDate[String(sectionId)] ?? {}).filter(Boolean).length
return (
<tr key={`${sectionId || index}`}>
<td>
{classLabel(classId)}
{sectionName ? <div className="text-muted small">Section: {sectionName}</div> : null}
</td>
<td style={{ minWidth: 300 }}>
<div><strong>Teacher{teacherNames.length > 1 ? 's' : ''}:</strong> {teacherNames.length > 0 ? teacherNames.join(', ') : 'N/A'}</div>
<div><strong>TA{taNames.length !== 1 ? 's' : ''}:</strong> {taNames.length > 0 ? taNames.join(', ') : '—'}</div>
</td>
<td className="text-center">{submissionCount}</td>
{sundays.map((date) => {
const isEventDay = Boolean(eventDays[date])
const isFuture = date > todayYmd
const ok = Boolean(hasHomeworkByDate[String(sectionId)]?.[date])
const rawDate = enteredAtByDate[String(sectionId)]?.[date]
if (isEventDay) {
return <td key={date} className="bg-warning text-center"></td>
}
if (isFuture) {
return <td key={date} className="text-center" style={{ backgroundColor: '#e7f1ff' }}></td>
}
if (ok) {
return <td key={date} className="text-center text-white" style={{ backgroundColor: '#198754' }}>{formatEnteredDate(rawDate)}</td>
}
return <td key={date} className="text-center text-white" style={{ backgroundColor: '#dc3545' }}></td>
})}
</tr> </tr>
))} )
})}
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
)
})}
{payload != null && !loading ? (
<details className="mt-4">
<summary className="small text-muted">Raw payload</summary>
<pre className="small bg-light p-3 rounded mt-2 overflow-auto" style={{ maxHeight: 320 }}>
{JSON.stringify(payload, null, 2)}
</pre>
</details>
) : null} ) : null}
<div className="mt-2 small text-muted">Dates are Sundays within the current school term. Yellow indicates a no-school day; light blue indicates a future Sunday.</div>
</div> </div>
) )
} }
+123 -45
View File
@@ -1,20 +1,32 @@
import { type FormEvent, useEffect, useState } from 'react' import { type FormEvent, 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 { fetchParticipation, postParticipation } from '../../api/grading' import { fetchParticipation, postParticipation } from '../../api/grading'
import { AcademicFilterBar } from '../discounts/AcademicFilterBar'
import { rowsFromUnknown } from './gradingPayloadHelpers'
import { GRADING_PATH } from './gradingPaths' import { GRADING_PATH } from './gradingPaths'
/** CI `grading/participation.php` */ /** CI `grading/participation.php` */
export function ParticipationPage() { export function ParticipationPage() {
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
const [rows, setRows] = useState<Record<string, unknown>[]>([]) const [rows, setRows] = useState<
Array<{
student_id: number
school_id?: string | null
firstname?: string | null
lastname?: string | null
score?: number | string | null
}>
>([])
const [locked, setLocked] = useState(false) const [locked, setLocked] = useState(false)
const [title, setTitle] = useState('Participation scores') const [title, setTitle] = useState('Participation Scores')
const [sectionName, setSectionName] = useState('')
const [semester, setSemester] = useState(searchParams.get('semester') ?? '')
const [schoolYear, setSchoolYear] = useState(searchParams.get('school_year') ?? '')
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [tableSearch, setTableSearch] = useState('')
const classSectionId = searchParams.get('class_section_id') ?? ''
useEffect(() => { useEffect(() => {
let c = false let c = false
@@ -22,11 +34,26 @@ export function ParticipationPage() {
fetchParticipation(searchParams) fetchParticipation(searchParams)
.then((p) => { .then((p) => {
if (c) return if (c) return
setRows(rowsFromUnknown(p))
if (p && typeof p === 'object') { if (p && typeof p === 'object') {
const o = p as Record<string, unknown> const o = p as Record<string, unknown>
setLocked(Boolean(o.scoresLocked ?? o.locked)) const students = Array.isArray(o.students) ? o.students : []
if (typeof o.title === 'string') setTitle(o.title) setRows(
students.map((row) => {
const student = row as Record<string, unknown>
const scores = (student.scores ?? {}) as Record<string, unknown>
return {
student_id: Number(student.student_id ?? 0),
school_id: typeof student.school_id === 'string' ? student.school_id : null,
firstname: typeof student.firstname === 'string' ? student.firstname : null,
lastname: typeof student.lastname === 'string' ? student.lastname : null,
score: scores.score ?? null,
}
}),
)
setLocked(Boolean(o.scores_locked ?? o.scoresLocked ?? o.locked))
setSectionName(typeof o.class_section_name === 'string' ? o.class_section_name : '')
setSemester(typeof o.semester === 'string' ? o.semester : (searchParams.get('semester') ?? ''))
setSchoolYear(typeof o.school_year === 'string' ? o.school_year : (searchParams.get('school_year') ?? ''))
} }
setError(null) setError(null)
}) })
@@ -41,17 +68,18 @@ export function ParticipationPage() {
} }
}, [searchParams]) }, [searchParams])
const scoreKey = (row: Record<string, unknown>): string => { const visibleRows = useMemo(() => {
if ('participation_score' in row) return 'participation_score' const normalized = tableSearch.toLowerCase().trim()
if ('score' in row) return 'score' if (normalized === '') return rows
return 'participation_score' return rows.filter((row) =>
} `${row.school_id ?? ''} ${row.firstname ?? ''} ${row.lastname ?? ''}`.toLowerCase().includes(normalized),
)
}, [rows, tableSearch])
function setScore(i: number, value: string) { function setScore(i: number, value: string) {
setRows((prev) => { setRows((prev) => {
const next = [...prev] const next = [...prev]
const k = scoreKey(next[i] ?? {}) next[i] = { ...next[i], score: value === '' ? null : Number(value) }
next[i] = { ...next[i], [k]: value === '' ? null : Number(value) }
return next return next
}) })
} }
@@ -62,13 +90,33 @@ export function ParticipationPage() {
setError(null) setError(null)
try { try {
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
rows, class_section_id: classSectionId ? Number(classSectionId) : undefined,
school_year: searchParams.get('school_year') ?? undefined, school_year: schoolYear || undefined,
semester: searchParams.get('semester') ?? undefined, semester: semester || undefined,
scores: rows.reduce<Record<string, { score: number | string | null }>>((carry, row) => {
carry[String(row.student_id)] = { score: row.score ?? '' }
return carry
}, {}),
} }
await postParticipation(body) await postParticipation(body)
const next = await fetchParticipation(searchParams) const next = await fetchParticipation(searchParams)
setRows(rowsFromUnknown(next)) if (next && typeof next === 'object') {
const o = next as Record<string, unknown>
const students = Array.isArray(o.students) ? o.students : []
setRows(
students.map((row) => {
const student = row as Record<string, unknown>
const scores = (student.scores ?? {}) as Record<string, unknown>
return {
student_id: Number(student.student_id ?? 0),
school_id: typeof student.school_id === 'string' ? student.school_id : null,
firstname: typeof student.firstname === 'string' ? student.firstname : null,
lastname: typeof student.lastname === 'string' ? student.lastname : null,
score: scores.score ?? null,
}
}),
)
}
} catch (err: unknown) { } catch (err: unknown) {
setError(err instanceof ApiHttpError ? err.message : 'Save failed.') setError(err instanceof ApiHttpError ? err.message : 'Save failed.')
} finally { } finally {
@@ -77,12 +125,13 @@ export function ParticipationPage() {
} }
const qs = searchParams.toString() const qs = searchParams.toString()
const cols = rows[0] ? Object.keys(rows[0]) : ['student_id', 'firstname', 'lastname', 'participation_score']
return ( return (
<div className="container-fluid py-3"> <div className="container mt-5">
<h2 className="text-center mt-2 mb-3">{title}</h2> <h2 className="text-center mb-4">{title}</h2>
<AcademicFilterBar /> <p className="text-center text-muted small">
{(sectionName || (classSectionId ? `Section ${classSectionId}` : 'Section'))} {semester || '—'} {schoolYear || '—'}
</p>
<div className="mb-3"> <div className="mb-3">
<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}` : ''}`}>
@@ -98,44 +147,73 @@ export function ParticipationPage() {
{!loading && rows.length > 0 ? ( {!loading && rows.length > 0 ? (
<form onSubmit={(ev) => void onSubmit(ev)}> <form onSubmit={(ev) => void onSubmit(ev)}>
<div className="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-3">
<div className="d-flex align-items-center gap-2">
<span>Show</span>
<select className="form-select form-select-sm" style={{ width: 78 }} defaultValue="100" disabled>
<option value="100">100</option>
</select>
<span>entries</span>
</div>
<div className="d-flex align-items-center gap-2">
<label className="mb-0">Search:</label>
<input
className="form-control form-control-sm"
style={{ width: 220 }}
value={tableSearch}
onChange={(event) => setTableSearch(event.target.value)}
/>
</div>
</div>
<div className="table-responsive"> <div className="table-responsive">
<table className="table table-bordered align-middle"> <table className="table table-bordered table-striped align-middle">
<thead className="table-light"> <thead className="table-light">
<tr> <tr>
{cols.map((c) => ( <th>#</th>
<th key={c}>{c.replace(/_/g, ' ')}</th> <th>School ID</th>
))} <th>First Name</th>
<th>Last Name</th>
<th className="text-center">Score</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{rows.map((row, i) => { {visibleRows.map((row, i) => (
const sk = scoreKey(row) <tr key={row.student_id}>
return ( <td>{i + 1}</td>
<tr key={i}> <td>{row.school_id ?? '—'}</td>
{cols.map((col) => <td>{row.firstname ?? '—'}</td>
col === sk && !locked ? ( <td>{row.lastname ?? '—'}</td>
<td key={col}> <td>
<input <input
type="number" type="number"
className="form-control form-control-sm" min="0"
style={{ width: 96 }} max="100"
value={row[col] === null || row[col] === undefined ? '' : String(row[col])} step="0.01"
onChange={(ev) => setScore(i, ev.target.value)} className="form-control text-center"
disabled={locked}
value={row.score ?? ''}
onChange={(ev) => {
const originalIndex = rows.findIndex((candidate) => candidate.student_id === row.student_id)
if (originalIndex >= 0) setScore(originalIndex, ev.target.value)
}}
/> />
</td> </td>
) : (
<td key={col}>{row[col] === null || row[col] === undefined ? '—' : String(row[col])}</td>
),
)}
</tr> </tr>
) ))}
})} {visibleRows.length === 0 ? (
<tr>
<td colSpan={5} className="text-muted text-center">
No students match the current search.
</td>
</tr>
) : null}
</tbody> </tbody>
</table> </table>
</div> </div>
<div className="text-center mt-3"> <div className="text-center mt-3">
<button type="submit" className="btn btn-primary" disabled={locked || saving}> <button type="submit" className="btn btn-primary" disabled={locked || saving}>
{saving ? 'Saving…' : 'Save'} {saving ? 'Saving…' : 'Save Changes'}
</button> </button>
</div> </div>
</form> </form>
@@ -0,0 +1,127 @@
import { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { fetchAdministratorDashboardMetrics, fetchLandingAdminDashboard } from '../../api/session'
import type { AdministratorDashboardMetricsResponse, LandingPageRecord } from '../../api/types'
import { useAuth } from '../../auth/AuthProvider'
export function AdminLandingDashboardPage() {
const { user } = useAuth()
const landingKind = user?.roles?.administrator ? 'administrator' : 'admin'
const [landing, setLanding] = useState<LandingPageRecord | null>(null)
const [metrics, setMetrics] = useState<AdministratorDashboardMetricsResponse | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
;(async () => {
setLoading(true)
try {
const [landingResponse, metricsResponse] = await Promise.all([
fetchLandingAdminDashboard(landingKind),
fetchAdministratorDashboardMetrics(),
])
if (cancelled) return
setLanding(landingResponse.data?.landing ?? null)
setMetrics(metricsResponse)
setError(null)
} catch (e) {
if (!cancelled) {
setError(e instanceof Error ? e.message : 'Unable to load admin landing dashboard.')
}
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [landingKind])
const counts = metrics?.counts ?? {}
const cards = useMemo(
() => [
{ label: 'Students', value: counts.students ?? 0, icon: 'bi bi-mortarboard-fill', tone: 'primary' },
{ label: 'Teachers', value: counts.teachers ?? 0, icon: 'bi bi-person-video3', tone: 'success' },
{ label: 'Teacher Assistants', value: counts.teacherAssistants ?? 0, icon: 'bi bi-people-fill', tone: 'warning' },
{ label: 'Admins', value: counts.admins ?? 0, icon: 'bi bi-person-badge-fill', tone: 'info' },
{ label: 'Parents', value: counts.parents ?? 0, icon: 'bi bi-people', tone: 'secondary' },
],
[counts.admins, counts.parents, counts.students, counts.teacherAssistants, counts.teachers],
)
const shortcuts = [
{ label: 'Administrator Dashboard', to: '/app/administrator/dashboard', icon: 'bi bi-speedometer2' },
{ label: 'Manage Users', to: '/app/administrator/manage_users', icon: 'bi bi-people' },
{ label: 'Daily Attendance', to: '/app/administrator/daily_attendance', icon: 'bi bi-calendar-check' },
{ label: 'Teacher Submissions', to: '/app/administrator/teacher-submissions', icon: 'bi bi-journal-check' },
{ label: 'Calendar', to: '/app/administrator/calendar', icon: 'bi bi-calendar3' },
{ label: 'Support / Contact', to: '/app/administrator/communications', icon: 'bi bi-life-preserver' },
]
return (
<div className="container-fluid py-4">
<section className="rounded-4 p-4 p-lg-5 mb-4 text-white" style={{ background: 'linear-gradient(135deg, #1f7a1f 0%, #3ca73c 55%, #91c84a 100%)' }}>
<div className="row align-items-center g-4">
<div className="col-lg-8">
<div className="text-uppercase small fw-semibold opacity-75 mb-2">Landing Page</div>
<h1 className="display-6 mb-3">Welcome to Al Rahma Sunday School</h1>
<p className="lead mb-2">Welcome, {user?.name || 'Admin'}.</p>
<p className="mb-0 opacity-75">
This is the {landing?.role || landingKind} dashboard.
</p>
</div>
<div className="col-lg-4">
<div className="bg-white bg-opacity-10 rounded-4 p-4 border border-white border-opacity-25">
<div className="small text-uppercase fw-semibold mb-2">API Status</div>
<div className="fs-5 fw-semibold mb-1">{landing?.dashboard || 'admin'}</div>
<div className="small opacity-75">
Role: {landing?.role || 'admin'}
</div>
</div>
</div>
</div>
</section>
{error ? <div className="alert alert-danger">{error}</div> : null}
{loading ? <p className="text-muted">Loading admin landing dashboard</p> : null}
<section className="mb-4">
<div className="row g-3">
{cards.map((card) => (
<div key={card.label} className="col-6 col-xl">
<div className={`card border-0 shadow-sm h-100 bg-${card.tone} bg-opacity-10`}>
<div className="card-body">
<div className="d-flex align-items-center justify-content-between mb-3">
<div className={`text-${card.tone}`}><i className={card.icon} aria-hidden /></div>
<div className="fs-3 fw-bold">{card.value}</div>
</div>
<div className="text-muted small">{card.label}</div>
</div>
</div>
</div>
))}
</div>
</section>
<section className="card border-0 shadow-sm">
<div className="card-body">
<div className="d-flex justify-content-between align-items-center mb-3">
<h2 className="h5 mb-0">Quick Links</h2>
<Link className="btn btn-outline-secondary btn-sm" to="/app/home">Open full dashboard</Link>
</div>
<div className="row g-3">
{shortcuts.map((item) => (
<div key={item.to} className="col-md-6 col-xl-4">
<Link className="btn btn-outline-primary w-100 text-start d-flex align-items-center gap-2 py-3" to={item.to}>
<i className={item.icon} aria-hidden />
<span>{item.label}</span>
</Link>
</div>
))}
</div>
</div>
</section>
</div>
)
}
+482 -178
View File
@@ -1,74 +1,136 @@
import { type FormEvent, useEffect, useMemo, useState } from 'react' import { type FormEvent, 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 type { ApiEnvelope } from '../../api/types' import { fetchCombinedReport } from '../../api/reportsCombined'
import {
fetchCombinedReport,
unwrapCombinedReport,
type CombinedReportPayload,
} from '../../api/reportsCombined'
import { REPORT_COMBINED_PATH } from './reportPaths' import { REPORT_COMBINED_PATH } from './reportPaths'
function columnKeysForRow(row: Record<string, unknown>, preferred?: string[]): string[] { type ScorePredictorStudent = {
if (preferred?.length) { student_id?: number
const set = new Set(Object.keys(row)) school_id?: string | null
const ordered = preferred.filter((k) => set.has(k)) firstname?: string | null
const rest = Object.keys(row).filter((k) => !preferred.includes(k)) lastname?: string | null
return [...ordered, ...rest.sort()] class_section_id?: number | string | null
} fall_score?: string | number | null
return Object.keys(row).sort() spring_score?: string | number | null
final_average?: string | number | null
required_spring_score?: string | number | null
winning_risk?: string | null
required_spring_to_pass?: string | number | null
failure_risk?: string | null
status?: string | null
trophy_awarded?: boolean
trophy_reason?: string | null
} }
function renderCell(v: unknown): string { type ScorePredictorSection = {
if (v == null) return '—' class_section_id?: number | string | null
if (typeof v === 'object') return JSON.stringify(v) class_section_name?: string | null
return String(v)
} }
function ReportTable({ type ScorePredictorPayload = {
rows, ok?: boolean
columns, students?: ScorePredictorStudent[]
}: { school_year?: string | null
rows: Array<Record<string, unknown>> class_sections?: ScorePredictorSection[]
columns?: string[] selected_class_section_id?: number | string | null
}) { semester?: string | null
if (rows.length === 0) return null message?: string | null
const keys = columnKeysForRow(rows[0] ?? {}, columns) }
return (
<div className="table-responsive"> type SortKey =
<table className="table table-striped table-bordered align-middle"> | 'school_id'
<thead className="table-light"> | 'firstname'
<tr> | 'lastname'
{keys.map((k) => ( | 'class_name'
<th key={k}>{k.replace(/_/g, ' ')}</th> | 'fall_score'
))} | 'spring_score'
</tr> | 'final_average'
</thead> | 'required_spring_score'
<tbody> | 'required_spring_to_pass'
{rows.map((r, i) => (
<tr key={i}> function asNumber(value: unknown): number | null {
{keys.map((k) => ( if (typeof value === 'number' && Number.isFinite(value)) return value
<td key={k}>{renderCell(r[k])}</td> if (typeof value === 'string' && value.trim() !== '' && !Number.isNaN(Number(value))) return Number(value)
))} return null
</tr> }
))}
</tbody> function displayValue(value: unknown, fallback = 'N/A') {
</table> if (value == null || value === '') return fallback
</div> return String(value)
) }
function sortArrow(active: boolean, direction: 'asc' | 'desc') {
if (!active) return '↕'
return direction === 'asc' ? '↑' : '↓'
}
function trophyChanceBadge(risk?: string | null) {
switch (risk) {
case 'Achieved Trophy':
return <span className="text-success fw-bold">Trophy Achieved</span>
case 'Top 3 Trophy':
return <span className="text-primary fw-bold">Top 3 Trophy</span>
case 'Low':
return <span className="text-success fw-bold">Trophy within Reach</span>
case 'Medium':
return <span className="text-warning fw-bold">Needs Strong Effort</span>
case 'High':
return <span className="text-danger fw-bold">Unlikely to Win Trophy</span>
case 'Unreachable':
return <span className="badge text-bg-warning">Not Possible</span>
case 'New student':
return <span className="text-muted fst-italic">No Data Yet</span>
default:
return <span className="text-body-secondary">Unknown</span>
}
}
function failRiskBadge(risk?: string | null) {
switch (risk) {
case 'Low':
return <span className="text-success fw-bold">Safe</span>
case 'Medium':
return <span className="text-warning fw-bold">Some Risk</span>
case 'High':
return <span className="text-danger fw-bold">At Risk</span>
case 'Unlikely to pass':
return <span className="badge text-bg-danger">Cannot Pass</span>
case 'New student':
return <span className="text-muted fst-italic">No Data Yet</span>
default:
return <span className="text-body-secondary">Unknown</span>
}
}
function statusBadge(status?: string | null) {
switch (status) {
case 'Passed':
return <span className="badge text-bg-success">Passed</span>
case 'Failed':
return <span className="badge text-bg-danger">Failed</span>
case 'Can pass':
return <span className="badge text-bg-primary">Can pass</span>
case 'May fail':
return <span className="badge text-bg-warning">May fail</span>
case 'Undetermined':
return <span className="badge text-bg-secondary">Undetermined</span>
default:
return <span className="badge text-bg-light">N/A</span>
}
} }
/** Admin combined report — driven by `GET /api/v1/reports/combined`. */
export function CombinedReportPage() { export function CombinedReportPage() {
const [searchParams, setSearchParams] = useSearchParams() const [searchParams, setSearchParams] = useSearchParams()
const schoolYear = searchParams.get('school_year') ?? '' const schoolYear = searchParams.get('school_year') ?? ''
const semester = searchParams.get('semester') ?? ''
const studentId = searchParams.get('student_id') ?? ''
const classSectionId = searchParams.get('class_section_id') ?? '' const classSectionId = searchParams.get('class_section_id') ?? ''
const [raw, setRaw] = useState<unknown>(null) const [raw, setRaw] = useState<ScorePredictorPayload | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [sortConfig, setSortConfig] = useState<{ key: SortKey; direction: 'asc' | 'desc' }>({
key: 'lastname',
direction: 'asc',
})
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
@@ -76,16 +138,14 @@ export function CombinedReportPage() {
setLoading(true) setLoading(true)
setError(null) setError(null)
try { try {
const res = await fetchCombinedReport({ const res = (await fetchCombinedReport({
school_year: schoolYear || null, school_year: schoolYear || null,
semester: semester || null, class_section_id: classSectionId || null,
student_id: studentId.trim() || null, })) as ScorePredictorPayload
class_section_id: classSectionId.trim() || null,
})
if (!cancelled) setRaw(res) if (!cancelled) setRaw(res)
} catch (e) { } catch (e) {
if (!cancelled) { if (!cancelled) {
setError(e instanceof ApiHttpError ? e.message : 'Unable to load combined report.') setError(e instanceof ApiHttpError ? e.message : 'Unable to load score analysis.')
setRaw(null) setRaw(null)
} }
} finally { } finally {
@@ -95,159 +155,403 @@ export function CombinedReportPage() {
return () => { return () => {
cancelled = true cancelled = true
} }
}, [schoolYear, semester, studentId, classSectionId]) }, [schoolYear, classSectionId])
const data = useMemo(() => unwrapCombinedReport(raw), [raw]) as CombinedReportPayload | null const students = Array.isArray(raw?.students) ? raw.students : []
const classSections = Array.isArray(raw?.class_sections) ? raw.class_sections : []
const selectedYear = schoolYear || raw?.school_year || ''
const selectedSemester = raw?.semester || ''
const apiFailureMessage = useMemo(() => { const sectionMap = useMemo(() => {
if (!raw || typeof raw !== 'object') return null return Object.fromEntries(
const o = raw as ApiEnvelope<unknown> classSections.map((section) => [
if (o.status === false && o.message) return String(o.message) String(section.class_section_id ?? ''),
return null section.class_section_name || `Section ${String(section.class_section_id ?? '')}`,
}, [raw]) ]),
)
}, [classSections])
const title = typeof data?.title === 'string' && data.title.trim() ? data.title.trim() : 'Combined report' const sortedStudents = useMemo(() => {
const subtitle = const rows = [...students]
typeof data?.subtitle === 'string' && data.subtitle.trim() ? data.subtitle.trim() : undefined rows.sort((left, right) => {
const message = const direction = sortConfig.direction === 'asc' ? 1 : -1
typeof data?.message === 'string' && data.message.trim() ? data.message.trim() : undefined const leftClass = sectionMap[String(left.class_section_id ?? '')] ?? `Section ${String(left.class_section_id ?? '')}`
const html = typeof data?.html === 'string' ? data.html : undefined const rightClass = sectionMap[String(right.class_section_id ?? '')] ?? `Section ${String(right.class_section_id ?? '')}`
const topRows = Array.isArray(data?.rows) ? (data?.rows as Array<Record<string, unknown>>) : [] if (
const topColumns = Array.isArray(data?.columns) ? (data.columns as string[]) : undefined sortConfig.key === 'fall_score' ||
sortConfig.key === 'spring_score' ||
sortConfig.key === 'final_average' ||
sortConfig.key === 'required_spring_score' ||
sortConfig.key === 'required_spring_to_pass'
) {
const a = asNumber(left[sortConfig.key])
const b = asNumber(right[sortConfig.key])
if (a == null && b == null) return 0
if (a == null) return 1
if (b == null) return -1
return (a - b) * direction
}
const sections = Array.isArray(data?.sections) const a =
? (data?.sections as CombinedReportPayload['sections']) sortConfig.key === 'class_name'
: undefined ? leftClass
: String(left[sortConfig.key] ?? '')
const b =
sortConfig.key === 'class_name'
? rightClass
: String(right[sortConfig.key] ?? '')
return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }) * direction
})
return rows
}, [students, sortConfig, sectionMap])
function applyFilter(e: FormEvent<HTMLFormElement>) { const summary = useMemo(() => {
e.preventDefault() const trophyCounts: Record<string, number> = {}
const fd = new FormData(e.currentTarget) const statusCounts = { Passed: 0, Failed: 0, 'Can pass': 0, 'May fail': 0, Undetermined: 0 }
const p = new URLSearchParams() const riskCounts = { Low: 0, Medium: 0, High: 0, 'Unlikely to pass': 0, 'New student': 0 }
const sy = String(fd.get('school_year') ?? '')
const sem = String(fd.get('semester') ?? '') for (const student of students) {
const sid = String(fd.get('student_id') ?? '').trim() const sectionId = String(student.class_section_id ?? '')
const cid = String(fd.get('class_section_id') ?? '').trim() if (student.trophy_awarded) {
if (sy) p.set('school_year', sy) trophyCounts[sectionId] = (trophyCounts[sectionId] ?? 0) + 1
if (sem) p.set('semester', sem) }
if (sid) p.set('student_id', sid)
if (cid) p.set('class_section_id', cid) const status = String(student.status ?? 'Undetermined') as keyof typeof statusCounts
setSearchParams(p) statusCounts[status] = (statusCounts[status] ?? 0) + 1
const risk = String(student.failure_risk ?? 'New student') as keyof typeof riskCounts
riskCounts[risk] = (riskCounts[risk] ?? 0) + 1
}
const totalStudents = students.length
const totalTrophies = Object.values(trophyCounts).reduce((sum, value) => sum + value, 0)
const passRate = totalStudents > 0 ? ((statusCounts.Passed / totalStudents) * 100).toFixed(1) : '0.0'
const failRate = totalStudents > 0 ? ((statusCounts.Failed / totalStudents) * 100).toFixed(1) : '0.0'
const pendingCount = statusCounts.Undetermined + statusCounts['Can pass'] + statusCounts['May fail']
const pendingRate = totalStudents > 0 ? ((pendingCount / totalStudents) * 100).toFixed(1) : '0.0'
return {
trophyCounts,
statusCounts,
riskCounts,
totalStudents,
totalTrophies,
passRate,
failRate,
pendingCount,
pendingRate,
}
}, [students])
function applyFilter(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const formData = new FormData(event.currentTarget)
const next = new URLSearchParams()
const nextYear = String(formData.get('school_year') ?? '').trim()
const nextClassSectionId = String(formData.get('class_section_id') ?? '').trim()
if (nextYear) next.set('school_year', nextYear)
if (nextClassSectionId) next.set('class_section_id', nextClassSectionId)
setSearchParams(next)
}
function requestSort(key: SortKey) {
setSortConfig((prev) =>
prev.key === key
? { key, direction: prev.direction === 'asc' ? 'desc' : 'asc' }
: { key, direction: 'asc' },
)
}
const trophySummaryIds = Object.keys(summary.trophyCounts).sort((left, right) => {
const a = sectionMap[left] ?? left
const b = sectionMap[right] ?? right
return a.localeCompare(b, undefined, { sensitivity: 'base' })
})
const sortableHeaderCellStyle = {
color: '#0f172a',
fontWeight: 600,
cursor: 'pointer',
userSelect: 'none' as const,
} }
return ( return (
<div className="container-xxl py-4"> <div className="container-fluid py-4">
<div className="d-flex flex-wrap justify-content-between align-items-start gap-2 mb-3"> <h2 className="text-center mt-2 mb-3">Student Success &amp; Risk Report</h2>
<div>
<h2 className="mb-0">{title}</h2> <div className="mb-4 text-center">
{subtitle ? <div className="text-muted small">{subtitle}</div> : null} <p className="text-muted small mb-1">Active Students</p>
</div> <div className="display-4 fw-semibold">{summary.totalStudents}</div>
<p className="text-muted small mb-0">Reflects only currently active students.</p>
</div> </div>
<form className="row gy-2 gx-3 align-items-end mb-4" onSubmit={applyFilter}> <form className="row g-3 mb-4 align-items-end" onSubmit={applyFilter}>
<div className="col-md-3"> <div className="col-md-6 col-lg-4">
<label className="form-label">School year</label> <label htmlFor="school_year" className="form-label">School Year</label>
<input <input
id="school_year"
type="text" type="text"
name="school_year" name="school_year"
className="form-control" className="form-control"
defaultValue={schoolYear} defaultValue={selectedYear}
placeholder="e.g. 2025-2026" placeholder="e.g. 2025-2026"
/> />
</div> </div>
<div className="col-md-2">
<label className="form-label">Semester</label> <div className="col-md-6 col-lg-4">
<select name="semester" className="form-select" defaultValue={semester}> <label htmlFor="class_section_id" className="form-label">Class Section</label>
<option value="">All</option> <div className="input-group">
<option value="Fall">Fall</option> <select
<option value="Spring">Spring</option> id="class_section_id"
</select>
</div>
<div className="col-md-2">
<label className="form-label">Student ID</label>
<input
type="text"
name="student_id"
className="form-control"
defaultValue={studentId}
placeholder="Optional"
inputMode="numeric"
/>
</div>
<div className="col-md-2">
<label className="form-label">Class section ID</label>
<input
type="text"
name="class_section_id" name="class_section_id"
className="form-control" className="form-select"
defaultValue={classSectionId} defaultValue={classSectionId}
placeholder="Optional" >
inputMode="numeric" <option value="">-- All Sections --</option>
/> {classSections.map((section) => (
<option key={String(section.class_section_id ?? '')} value={String(section.class_section_id ?? '')}>
{section.class_section_name ?? `Section ${String(section.class_section_id ?? '')}`}
</option>
))}
</select>
<button type="submit" className="btn btn-primary">Filter</button>
</div> </div>
<div className="col-md-2 d-flex gap-2"> </div>
<button type="submit" className="btn btn-primary">
Apply <div className="col-md-12 col-lg-4 d-flex justify-content-lg-end gap-2">
</button> <div className="text-muted small align-self-center">
<Link className="btn btn-outline-secondary" to={REPORT_COMBINED_PATH}> Current semester: <strong>{selectedSemester || 'N/A'}</strong>
</div>
<Link className="btn btn-outline-secondary btn-sm align-self-center" to={REPORT_COMBINED_PATH}>
Reset Reset
</Link> </Link>
</div> </div>
</form> </form>
{error ? <div className="alert alert-danger">{error}</div> : null} {error ? <div className="alert alert-danger">{error}</div> : null}
{apiFailureMessage ? <div className="alert alert-warning">{apiFailureMessage}</div> : null} {raw?.message ? <div className="alert alert-info">{raw.message}</div> : null}
{message ? <div className="alert alert-info">{message}</div> : null} {loading ? <p className="text-muted">Loading score analysis</p> : null}
{loading ? <p className="text-muted">Loading</p> : null} {!loading && !error ? (
<>
<div className="row g-3 mb-4">
<div className="col-lg-4">
<div className="card shadow-sm h-100">
<div className="card-header py-2 d-flex justify-content-between align-items-center">
<strong>Active Student Snapshot</strong>
<span className="badge bg-primary">{summary.totalStudents} active</span>
</div>
<div className="card-body">
<div className="d-flex flex-wrap gap-3">
<div className="flex-fill">
<div className="small text-muted">Pass rate</div>
<div className="fs-3 text-success fw-semibold">{summary.passRate}%</div>
<div className="text-muted small">{summary.statusCounts.Passed} students</div>
</div>
<div className="flex-fill">
<div className="small text-muted">Failing</div>
<div className="fs-4 text-danger fw-semibold">{summary.statusCounts.Failed}</div>
<div className="text-muted small">{summary.failRate}% of roster</div>
</div>
<div className="flex-fill">
<div className="small text-muted">Pending / no data</div>
<div className="fs-4 text-secondary fw-semibold">{summary.pendingCount}</div>
<div className="text-muted small">{summary.pendingRate}% of roster</div>
</div>
</div>
<hr />
<p className="mb-1 small text-muted">Analyst notes</p>
<ul className="mb-0 small">
<li>Focus on {summary.riskCounts.High} high-risk students first.</li>
<li>{summary.statusCounts.Failed} students are currently failing.</li>
<li>{summary.totalTrophies} trophy-eligible students right now.</li>
</ul>
</div>
</div>
</div>
{!loading && !error && html ? ( <div className="col-lg-4">
<iframe <div className="card shadow-sm h-100">
title="Combined report" <div className="card-header py-2"><strong>Pass vs Fail</strong></div>
srcDoc={html} <div className="card-body">
className="w-100 border rounded" <div className="small text-muted mb-2">Current status mix</div>
style={{ minHeight: 480 }} {Object.entries(summary.statusCounts).map(([label, count]) => {
sandbox="allow-same-origin allow-scripts allow-popups allow-popups-to-escape-sandbox" const percent = summary.totalStudents > 0 ? (count / summary.totalStudents) * 100 : 0
/> const barClass =
) : null} label === 'Passed'
? 'bg-success'
{!loading && !error && !html && topRows.length > 0 ? ( : label === 'Failed'
<ReportTable rows={topRows} columns={topColumns} /> ? 'bg-danger'
) : null} : label === 'Can pass'
? 'bg-primary'
{!loading && !error && !html && sections?.length : label === 'May fail'
? sections.map((sec, idx) => { ? 'bg-warning'
const secTitle = String(sec.title ?? sec.heading ?? `Section ${idx + 1}`) : 'bg-secondary'
const secRows = Array.isArray(sec.rows) ? sec.rows : []
const secCols = Array.isArray(sec.columns) ? sec.columns : undefined
return ( return (
<div key={idx} className="mb-4"> <div key={label} className="mb-2">
<h5 className="h6">{secTitle}</h5> <div className="d-flex justify-content-between small mb-1">
<ReportTable rows={secRows} columns={secCols} /> <span>{label}</span>
<span>{count}</span>
</div>
<div className="progress" role="progressbar" aria-valuenow={percent} aria-valuemin={0} aria-valuemax={100}>
<div className={`progress-bar ${barClass}`} style={{ width: `${percent}%` }} />
</div>
</div> </div>
) )
}) })}
: null} </div>
</div>
</div>
{!loading && <div className="col-lg-4">
!error && <div className="card shadow-sm h-100">
!apiFailureMessage && <div className="card-header py-2"><strong>Fail Risk Mix</strong></div>
!html && <div className="card-body">
topRows.length === 0 && <div className="small text-muted mb-2">Risk distribution</div>
(!sections || sections.length === 0) && {Object.entries(summary.riskCounts).map(([label, count]) => {
raw ? ( const percent = summary.totalStudents > 0 ? (count / summary.totalStudents) * 100 : 0
<details className="small text-muted"> const barClass =
<summary className="mb-2">Raw JSON (no recognized table layout)</summary> label === 'Low'
<pre className="bg-light border rounded p-2 small mb-0 overflow-auto" style={{ maxHeight: 320 }}> ? 'bg-success'
{JSON.stringify(raw, null, 2)} : label === 'Medium'
</pre> ? 'bg-warning'
</details> : label === 'High'
? 'bg-danger'
: label === 'Unlikely to pass'
? 'bg-dark'
: 'bg-secondary'
return (
<div key={label} className="mb-2">
<div className="d-flex justify-content-between small mb-1">
<span>{label}</span>
<span>{count}</span>
</div>
<div className="progress" role="progressbar" aria-valuenow={percent} aria-valuemin={0} aria-valuemax={100}>
<div className={`progress-bar ${barClass}`} style={{ width: `${percent}%` }} />
</div>
</div>
)
})}
</div>
</div>
</div>
</div>
<div className="row mb-3">
<div className="col-12">
<div className="card shadow-sm">
<div className="card-header py-2">
<strong>Trophies Summary</strong>
</div>
<div className="card-body p-2">
<div className="small text-muted mb-2">
If a class section has no students meeting the trophy threshold, the top ranked students in that section receive trophies.
</div>
<div className="table-responsive">
<table className="table table-sm table-bordered table-striped mb-2 text-center align-middle">
<thead className="table-light">
<tr>
<th className="text-start">Class Section</th>
{trophySummaryIds.length > 0 ? (
trophySummaryIds.map((sectionId) => (
<th key={sectionId}>{sectionMap[sectionId] ?? `Section ${sectionId}`}</th>
))
) : (
<th className="text-muted fst-italic">No trophies yet.</th>
)}
</tr>
</thead>
<tbody>
{trophySummaryIds.length > 0 ? (
<>
<tr>
<th className="text-start">Trophy Counts</th>
{trophySummaryIds.map((sectionId) => (
<td key={sectionId}>{summary.trophyCounts[sectionId]}</td>
))}
</tr>
<tr className="fw-bold table-light">
<th className="text-start">Total Trophies</th>
<td colSpan={trophySummaryIds.length} className="text-center">{summary.totalTrophies}</td>
</tr>
</>
) : (
<tr>
<td colSpan={2} className="text-muted fst-italic">No data available</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<div className="table-responsive">
<table className="table table-bordered table-striped align-middle w-100">
<thead style={{ backgroundColor: '#dcefe8', color: '#0f172a' }}>
<tr>
<th style={{ width: 60 }}>#</th>
<th style={sortableHeaderCellStyle} onClick={() => requestSort('school_id')}>
School ID {sortArrow(sortConfig.key === 'school_id', sortConfig.direction)}
</th>
<th style={sortableHeaderCellStyle} onClick={() => requestSort('firstname')}>
First Name {sortArrow(sortConfig.key === 'firstname', sortConfig.direction)}
</th>
<th style={sortableHeaderCellStyle} onClick={() => requestSort('lastname')}>
Last Name {sortArrow(sortConfig.key === 'lastname', sortConfig.direction)}
</th>
<th style={sortableHeaderCellStyle} onClick={() => requestSort('class_name')}>
Class {sortArrow(sortConfig.key === 'class_name', sortConfig.direction)}
</th>
<th style={sortableHeaderCellStyle} onClick={() => requestSort('fall_score')}>
Fall Score {sortArrow(sortConfig.key === 'fall_score', sortConfig.direction)}
</th>
<th style={sortableHeaderCellStyle} onClick={() => requestSort('spring_score')}>
Spring Score {sortArrow(sortConfig.key === 'spring_score', sortConfig.direction)}
</th>
<th style={sortableHeaderCellStyle} onClick={() => requestSort('final_average')}>
Final Average {sortArrow(sortConfig.key === 'final_average', sortConfig.direction)}
</th>
<th style={sortableHeaderCellStyle} onClick={() => requestSort('required_spring_score')}>
Needed for Trophy {sortArrow(sortConfig.key === 'required_spring_score', sortConfig.direction)}
</th>
<th>Chance to Win Trophy</th>
<th style={sortableHeaderCellStyle} onClick={() => requestSort('required_spring_to_pass')}>
Needed to Pass (60+) {sortArrow(sortConfig.key === 'required_spring_to_pass', sortConfig.direction)}
</th>
<th>Risk of Failing</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{sortedStudents.map((student, index) => {
const className = sectionMap[String(student.class_section_id ?? '')] ?? (student.class_section_id ? `Section ${String(student.class_section_id)}` : '—')
return (
<tr key={String(student.student_id ?? `${student.school_id ?? 'row'}-${index}`)}>
<td>{index + 1}</td>
<td>{displayValue(student.school_id, '—')}</td>
<td>{displayValue(student.firstname, '—')}</td>
<td>{displayValue(student.lastname, '—')}</td>
<td>{className}</td>
<td>{displayValue(student.fall_score)}</td>
<td>{displayValue(student.spring_score)}</td>
<td>{displayValue(student.final_average)}</td>
<td>{displayValue(student.required_spring_score)}</td>
<td>{trophyChanceBadge(student.winning_risk)}</td>
<td>{displayValue(student.required_spring_to_pass)}</td>
<td>{failRiskBadge(student.failure_risk)}</td>
<td>{statusBadge(student.status)}</td>
</tr>
)
})}
</tbody>
</table>
</div>
</>
) : null} ) : null}
<p className="small text-muted mt-4 mb-0">
API: <code>GET /api/v1/reports/combined</code> see <Link to="/docs">API docs</Link> for the response
shape your Laravel app returns.
</p>
</div> </div>
) )
} }
+188
View File
@@ -0,0 +1,188 @@
import { type FormEvent, useEffect, useMemo, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import {
displayNameOf,
fetchBadgeScanLogs,
type BadgeScanLogRow,
} from '../../api/badgeScan'
function formatDateTime(raw: string | null): string {
if (!raw) return '—'
const d = new Date(raw)
if (isNaN(d.getTime())) return raw
return d.toLocaleString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
function userType(row: BadgeScanLogRow): string {
const hasStu = Boolean(row.student_firstname || row.student_lastname)
const hasUser = Boolean(row.user_firstname || row.user_lastname)
if (hasStu && !hasUser) return 'Student'
if (hasUser) return 'Staff / User'
return '—'
}
export function BadgeScanLogsPage() {
const [searchParams, setSearchParams] = useSearchParams()
const schoolYear = searchParams.get('school_year') ?? ''
const semester = searchParams.get('semester') ?? ''
const [logs, setLogs] = useState<BadgeScanLogRow[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [q, setQ] = useState('')
function load() {
let cancelled = false
setLoading(true)
setError(null)
fetchBadgeScanLogs()
.then((res) => { if (!cancelled) setLogs(res.logs) })
.catch((e) => { if (!cancelled) setError(e instanceof Error ? e.message : 'Failed to load.') })
.finally(() => { if (!cancelled) setLoading(false) })
return () => { cancelled = true }
}
useEffect(load, [])
function applyFilter(e: FormEvent<HTMLFormElement>) {
e.preventDefault()
const fd = new FormData(e.currentTarget)
const p = new URLSearchParams()
const sy = String(fd.get('school_year') ?? '').trim()
const sem = String(fd.get('semester') ?? '').trim()
if (sy) p.set('school_year', sy)
if (sem) p.set('semester', sem)
setSearchParams(p, { replace: true })
}
const filtered = useMemo(() => {
let rows = logs
if (schoolYear) rows = rows.filter((r) => r.school_year === schoolYear)
if (semester) rows = rows.filter((r) => r.semester === semester)
if (q.trim()) {
const lq = q.toLowerCase()
rows = rows.filter(
(r) =>
displayNameOf(r).toLowerCase().includes(lq) ||
r.card_id.toLowerCase().includes(lq),
)
}
return rows
}, [logs, schoolYear, semester, q])
const schoolYears = useMemo(
() => [...new Set(logs.map((r) => r.school_year).filter(Boolean))].sort().reverse() as string[],
[logs],
)
return (
<div className="container-fluid px-0">
<h2 className="text-center mt-4 mb-1">Badge Scan Log</h2>
<p className="text-center text-muted small mb-3">
All badge scans for administrators, staff, teachers, and students.
</p>
<div className="d-flex justify-content-center mb-3">
<form className="row g-2 align-items-end" onSubmit={applyFilter}>
<div className="col-auto">
<label htmlFor="bsl-school-year" className="form-label small mb-0">School Year</label>
<select id="bsl-school-year" name="school_year" className="form-select form-select-sm" defaultValue={schoolYear}>
<option value=""> All </option>
{schoolYears.map((sy) => (
<option key={sy} value={sy}>{sy}</option>
))}
</select>
</div>
<div className="col-auto">
<label htmlFor="bsl-semester" className="form-label small mb-0">Semester</label>
<select id="bsl-semester" name="semester" className="form-select form-select-sm" defaultValue={semester}>
<option value=""> All </option>
<option value="Fall">Fall</option>
<option value="Spring">Spring</option>
</select>
</div>
<div className="col-auto">
<button type="submit" className="btn btn-sm btn-secondary">Apply</button>
</div>
<div className="col-auto">
<button type="button" className="btn btn-sm btn-outline-secondary" onClick={load} disabled={loading}>
{loading ? 'Loading…' : 'Refresh'}
</button>
</div>
</form>
</div>
{error ? <div className="alert alert-danger mx-3">{error}</div> : null}
<div className="px-3">
<div className="card border-0 shadow-sm rounded-3">
<div className="card-body p-0">
<div className="d-flex align-items-center justify-content-between px-3 pt-3 pb-2 gap-2 flex-wrap">
<span className="text-muted small">
{loading ? 'Loading…' : `${filtered.length} scan${filtered.length !== 1 ? 's' : ''}`}
</span>
<input
id="bsl-quick-filter"
name="q"
type="search"
className="form-control form-control-sm"
style={{ maxWidth: 220 }}
placeholder="Filter by name or card ID…"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
</div>
<div className="table-responsive">
<table className="table table-sm table-hover mb-0">
<thead className="table-light">
<tr>
<th>#</th>
<th>Name</th>
<th>Type</th>
<th>Card ID</th>
<th>Scan Time</th>
<th>School Year</th>
<th>Semester</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={7} className="text-center text-muted py-4">Loading</td>
</tr>
) : filtered.length === 0 ? (
<tr>
<td colSpan={7} className="text-center text-muted py-4">No scans found.</td>
</tr>
) : (
filtered.map((row, idx) => (
<tr key={`${row.id}-${idx}`}>
<td className="text-muted small">{row.id}</td>
<td>{displayNameOf(row)}</td>
<td>
<span className={`badge ${userType(row) === 'Student' ? 'bg-info text-dark' : 'bg-secondary'}`}>
{userType(row)}
</span>
</td>
<td><code className="small">{row.card_id}</code></td>
<td className="small">{formatDateTime(row.scan_time)}</td>
<td className="small">{row.school_year ?? '—'}</td>
<td className="small">{row.semester ?? '—'}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
)
}
+114 -11
View File
@@ -41,12 +41,25 @@ function slipPrintSearchParams(row: SlipPreviewRow): string {
} }
export function SlipPreviewListPage() { export function SlipPreviewListPage() {
type SlipSortKey =
| 'student_name'
| 'time_in'
| 'grade'
| 'reason'
| 'school_year'
| 'semester'
| 'admin_name'
const [searchParams, setSearchParams] = useSearchParams() const [searchParams, setSearchParams] = useSearchParams()
const schoolYear = searchParams.get('school_year') ?? '' const schoolYear = searchParams.get('school_year') ?? ''
const semester = searchParams.get('semester') ?? '' const semester = searchParams.get('semester') ?? ''
const [rows, setRows] = useState<SlipPreviewRow[]>([]) const [rows, setRows] = useState<SlipPreviewRow[]>([])
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [expandedDates, setExpandedDates] = useState<Set<string>>(new Set())
const [sortConfig, setSortConfig] = useState<{ key: SlipSortKey; direction: 'asc' | 'desc' }>({
key: 'student_name',
direction: 'asc',
})
useEffect(() => { useEffect(() => {
setError(null) setError(null)
@@ -54,7 +67,17 @@ export function SlipPreviewListPage() {
school_year: schoolYear || undefined, school_year: schoolYear || undefined,
semester: semester || undefined, semester: semester || undefined,
}) })
.then((d) => setRows(d.rows ?? [])) .then((d) => {
const nextRows = d.rows ?? []
setRows(nextRows)
const dateKeys = new Set(
nextRows.map((row) => {
const raw = String(row.date ?? '').trim()
return raw || 'Unknown Date'
}),
)
setExpandedDates(dateKeys)
})
.catch((e: unknown) => .catch((e: unknown) =>
setError(e instanceof ApiHttpError ? e.message : 'Failed to load slips.'), setError(e instanceof ApiHttpError ? e.message : 'Failed to load slips.'),
) )
@@ -83,6 +106,47 @@ export function SlipPreviewListPage() {
window.open(`${SLIPS_PRINT_PATH}?${q}`, '_blank', 'noopener') window.open(`${SLIPS_PRINT_PATH}?${q}`, '_blank', 'noopener')
} }
function toggleDate(dateKey: string) {
setExpandedDates((prev) => {
const next = new Set(prev)
next.has(dateKey) ? next.delete(dateKey) : next.add(dateKey)
return next
})
}
function requestSort(key: SlipSortKey) {
setSortConfig((prev) =>
prev.key === key
? { key, direction: prev.direction === 'asc' ? 'desc' : 'asc' }
: { key, direction: 'asc' },
)
}
function sortIndicator(key: SlipSortKey) {
if (sortConfig.key !== key) return '↕'
return sortConfig.direction === 'asc' ? '↑' : '↓'
}
function sortRows(groupRows: SlipPreviewRow[]) {
return [...groupRows].sort((left, right) => {
const direction = sortConfig.direction === 'asc' ? 1 : -1
const leftValue =
sortConfig.key === 'semester'
? displaySemester(left.semester)
: String(left[sortConfig.key] ?? '')
const rightValue =
sortConfig.key === 'semester'
? displaySemester(right.semester)
: String(right[sortConfig.key] ?? '')
return (
leftValue.localeCompare(rightValue, undefined, {
numeric: true,
sensitivity: 'base',
}) * direction
)
})
}
return ( return (
<div className="container-fluid px-0 py-4"> <div className="container-fluid px-0 py-4">
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3"> <div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
@@ -179,28 +243,66 @@ export function SlipPreviewListPage() {
{dateOrder.map((dateKey) => { {dateOrder.map((dateKey) => {
const groupRows = groupedRows[dateKey] ?? [] const groupRows = groupedRows[dateKey] ?? []
const expanded = expandedDates.has(dateKey)
const displayedRows = sortRows(groupRows)
return ( return (
<div key={dateKey} className="card mb-4"> <div key={dateKey} className="card mb-4">
<div className="card-header d-flex align-items-center justify-content-between flex-wrap gap-2"> <div
<div className="fw-semibold">Date: {formatPreviewDateKey(dateKey)}</div> className="card-header d-flex align-items-center justify-content-between flex-wrap gap-2"
role="button"
style={{ cursor: 'pointer', userSelect: 'none' }}
onClick={() => toggleDate(dateKey)}
>
<div className="fw-semibold">
{expanded ? '▼' : '▶'} Date: {formatPreviewDateKey(dateKey)}
</div>
<span className="badge bg-secondary">{groupRows.length} slips</span> <span className="badge bg-secondary">{groupRows.length} slips</span>
</div> </div>
{expanded ? (
<div className="table-responsive"> <div className="table-responsive">
<table className="table table-striped table-bordered align-middle w-100"> <table className="table table-striped table-bordered align-middle w-100">
<thead className="table-light"> <thead className="table-light">
<tr> <tr>
<th>Student Name</th> <th>
<th>Time In</th> <button type="button" className="btn btn-link btn-sm p-0 text-decoration-none" onClick={() => requestSort('student_name')}>
<th>Grade</th> Student Name {sortIndicator('student_name')}
<th>Reason</th> </button>
<th>School Year</th> </th>
<th>Semester</th> <th>
<th>Admin Name</th> <button type="button" className="btn btn-link btn-sm p-0 text-decoration-none" onClick={() => requestSort('time_in')}>
Time In {sortIndicator('time_in')}
</button>
</th>
<th>
<button type="button" className="btn btn-link btn-sm p-0 text-decoration-none" onClick={() => requestSort('grade')}>
Grade {sortIndicator('grade')}
</button>
</th>
<th>
<button type="button" className="btn btn-link btn-sm p-0 text-decoration-none" onClick={() => requestSort('reason')}>
Reason {sortIndicator('reason')}
</button>
</th>
<th>
<button type="button" className="btn btn-link btn-sm p-0 text-decoration-none" onClick={() => requestSort('school_year')}>
School Year {sortIndicator('school_year')}
</button>
</th>
<th>
<button type="button" className="btn btn-link btn-sm p-0 text-decoration-none" onClick={() => requestSort('semester')}>
Semester {sortIndicator('semester')}
</button>
</th>
<th>
<button type="button" className="btn btn-link btn-sm p-0 text-decoration-none" onClick={() => requestSort('admin_name')}>
Admin Name {sortIndicator('admin_name')}
</button>
</th>
<th>Action</th> <th>Action</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{groupRows.map((r, idx) => ( {displayedRows.map((r, idx) => (
<tr key={idx}> <tr key={idx}>
<td>{String(r.student_name ?? '')}</td> <td>{String(r.student_name ?? '')}</td>
<td>{String(r.time_in ?? '')}</td> <td>{String(r.time_in ?? '')}</td>
@@ -223,6 +325,7 @@ export function SlipPreviewListPage() {
</tbody> </tbody>
</table> </table>
</div> </div>
) : null}
</div> </div>
) )
})} })}