add grading, attendnace management
This commit is contained in:
Generated
+2
-2
@@ -1654,7 +1654,7 @@
|
||||
"version": "19.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
@@ -2473,7 +2473,7 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
|
||||
+11
-5
@@ -67,6 +67,9 @@ const RoleSelectPage = lazy(() =>
|
||||
import('./pages/RoleSelectPage').then((m) => ({ default: m.RoleSelectPage })),
|
||||
)
|
||||
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(() =>
|
||||
import('./pages/CiSitemapPage').then((m) => ({ default: m.CiSitemapPage })),
|
||||
)
|
||||
@@ -521,8 +524,8 @@ const ReimbursementsUnderProcessingPage = lazy(() =>
|
||||
const RefundsListPage = lazy(() =>
|
||||
import('./pages/refunds/RefundsListPage').then((m) => ({ default: m.RefundsListPage })),
|
||||
)
|
||||
const RfidComingSoonPage = lazy(() =>
|
||||
import('./pages/rfid/RfidComingSoonPage').then((m) => ({ default: m.RfidComingSoonPage })),
|
||||
const BadgeScanLogsPage = lazy(() =>
|
||||
import('./pages/rfid/BadgeScanLogsPage').then((m) => ({ default: m.BadgeScanLogsPage })),
|
||||
)
|
||||
const StaffIndexPage = lazy(() =>
|
||||
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 path="select-role" element={<RoleSelectPage />} />
|
||||
<Route path="home" element={<AppHome />} />
|
||||
<Route path="landing-page/admin-dashboard" element={<AdminLandingDashboardPage />} />
|
||||
<Route path="admin/dashboard" element={<AppHome />} />
|
||||
<Route path="administrator/dashboard" 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-batch/:batchId" element={<PlacementBatchPage />} />
|
||||
<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" element={<BelowSixtyPage />} />
|
||||
<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" element={<PlacementSectionPage />} />
|
||||
<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" element={<GradingMainPage />} />
|
||||
<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/:permissionId/edit" element={<EditPermissionPage />} />
|
||||
<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/index" element={<NavBuilderPage />} />
|
||||
<Route path="nav-builder/edit" element={<NavBuilderPage />} />
|
||||
|
||||
@@ -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
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* 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'
|
||||
|
||||
const BASE = '/api/v1/administrator/grading'
|
||||
const BASE = '/api/v1/grading'
|
||||
|
||||
export type GradingScoreRow = {
|
||||
id?: number
|
||||
@@ -12,40 +12,108 @@ export type GradingScoreRow = {
|
||||
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 = {
|
||||
scoresLocked?: boolean
|
||||
student?: { id?: number; firstname?: string; lastname?: string }
|
||||
student?: { id?: number; firstname?: string; lastname?: string; school_id?: string | null }
|
||||
classSectionId?: number
|
||||
class_section_name?: string | null
|
||||
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 {
|
||||
const s = sp.toString()
|
||||
return s ? `?${s}` : ''
|
||||
}
|
||||
|
||||
export async function fetchGradingMain(searchParams: URLSearchParams): Promise<unknown> {
|
||||
return apiFetch(`${BASE}/main${q(searchParams)}`)
|
||||
return apiFetch(`${BASE}/overview${q(searchParams)}`)
|
||||
}
|
||||
|
||||
export async function fetchGradingScoreForm(
|
||||
scoreType: string,
|
||||
studentId: string,
|
||||
classSectionId: string,
|
||||
studentId: string,
|
||||
searchParams: URLSearchParams,
|
||||
): Promise<GradingScoreFormPayload> {
|
||||
const qs = new URLSearchParams({
|
||||
student_id: studentId,
|
||||
class_section_id: classSectionId,
|
||||
})
|
||||
return apiFetch(`${BASE}/scores/${encodeURIComponent(scoreType)}?${qs}`)
|
||||
return apiFetch(`${BASE}/scores/${encodeURIComponent(scoreType)}/${encodeURIComponent(classSectionId)}/${encodeURIComponent(studentId)}${q(searchParams)}`)
|
||||
}
|
||||
|
||||
function scoreCollectionPath(scoreType: string): string {
|
||||
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(
|
||||
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,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<unknown> {
|
||||
return apiFetch(`${BASE}/scores/${encodeURIComponent(scoreType)}`, {
|
||||
method: 'POST',
|
||||
const method = scoreType === 'comments' ? 'PUT' : 'POST'
|
||||
return apiFetch(scoreCollectionPath(scoreType), {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
@@ -89,11 +157,11 @@ export async function fetchHomeworkTracking(
|
||||
}
|
||||
|
||||
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> {
|
||||
return apiFetch(`${BASE}/participation`, {
|
||||
return apiFetch('/api/v1/scores/participation', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
|
||||
@@ -85,32 +85,51 @@ export async function fetchManualPayPage(params: {
|
||||
const qs = new URLSearchParams()
|
||||
qs.set('search_term', params.search_term)
|
||||
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 }
|
||||
|
||||
export async function fetchManualPaySuggest(q: string): Promise<ManualPaySuggestRow[]> {
|
||||
const qs = new URLSearchParams({ q })
|
||||
const body = await apiFetch<{ suggestions?: unknown[]; data?: unknown[] }>(
|
||||
`/api/v1/administrator/payments/manual-pay/suggest?${qs}`,
|
||||
const body = await apiFetch<{ items?: unknown[]; suggestions?: unknown[]; data?: unknown[] }>(
|
||||
`/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[]) : []
|
||||
}
|
||||
|
||||
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 }> {
|
||||
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). */
|
||||
export function manualPayCheckFileUrl(tokenOrKey: string, mode: 'inline' | 'download'): string {
|
||||
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> {
|
||||
@@ -213,21 +232,41 @@ export async function fetchExtraChargesPage(params?: {
|
||||
if (params?.page != null) qs.set('page', String(params.page))
|
||||
if (params?.parent_id != null) qs.set('parent_id', String(params.parent_id))
|
||||
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<
|
||||
Array<{ id?: number; invoice_number?: string }>
|
||||
> {
|
||||
const body = await apiFetch<{ invoices?: unknown[] }>(
|
||||
`/api/v1/administrator/charges/invoices-for-parent?parent_id=${encodeURIComponent(String(parentId))}`,
|
||||
const body = await apiFetch<{ results?: unknown[] }>(
|
||||
`/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 }>) : []
|
||||
}
|
||||
|
||||
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 = {
|
||||
|
||||
@@ -21,9 +21,90 @@ export type CombinedReportPayload = {
|
||||
[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 {
|
||||
if (raw == null || typeof raw !== 'object') return null
|
||||
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') {
|
||||
return o.data as CombinedReportPayload
|
||||
}
|
||||
@@ -46,5 +127,5 @@ export async function fetchCombinedReport(params?: {
|
||||
q.set('class_section_id', String(params.class_section_id))
|
||||
}
|
||||
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
@@ -22,6 +22,7 @@ import type {
|
||||
FamilyAdminIndexResponse,
|
||||
GradingOverviewResponse,
|
||||
IpBansResponse,
|
||||
LandingPageResponse,
|
||||
LoginFailure,
|
||||
LoginResponse,
|
||||
LoginSuccess,
|
||||
@@ -120,10 +121,69 @@ export async function fetchDashboardRoute(): Promise<ApiEnvelope<DashboardPayloa
|
||||
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> {
|
||||
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> {
|
||||
const q = query.trim()
|
||||
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(
|
||||
params?: { semester?: string | null; schoolYear?: string | null },
|
||||
): Promise<AdministratorDailyAttendanceResponse> {
|
||||
@@ -153,7 +249,8 @@ export async function fetchAdministratorDailyAttendance(
|
||||
if (params?.semester) query.set('semester', params.semester)
|
||||
if (params?.schoolYear) query.set('school_year', params.schoolYear)
|
||||
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: {
|
||||
@@ -295,7 +392,8 @@ export async function fetchAttendanceViolationsPending(params?: {
|
||||
if (params?.schoolYear) query.set('school_year', params.schoolYear)
|
||||
if (params?.semester) query.set('semester', params.semester)
|
||||
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?: {
|
||||
@@ -306,7 +404,8 @@ export async function fetchAttendanceViolationsNotified(params?: {
|
||||
if (params?.schoolYear) query.set('school_year', params.schoolYear)
|
||||
if (params?.semester) query.set('semester', params.semester)
|
||||
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?: {
|
||||
@@ -411,7 +510,7 @@ export async function deleteAttendanceCommentTemplate(id: number): Promise<{ sta
|
||||
export async function saveAttendanceViolationNote(payload: Record<string, string>): Promise<{ message?: string }> {
|
||||
const body = new URLSearchParams()
|
||||
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',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
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 }> {
|
||||
const body = new URLSearchParams()
|
||||
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',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
@@ -439,7 +538,7 @@ export async function fetchAttendanceComposeEmailContext(params: {
|
||||
q.set('code', params.code)
|
||||
if (params.variant) q.set('variant', params.variant)
|
||||
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(
|
||||
@@ -451,7 +550,7 @@ export async function fetchAttendanceStudentViolationsView(
|
||||
if (query?.incident_date) q.set('incident_date', query.incident_date)
|
||||
if (query?.include_notified) q.set('include_notified', query.include_notified)
|
||||
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<{
|
||||
@@ -560,6 +659,46 @@ export async function fetchStudentAssignments(
|
||||
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: {
|
||||
student_id: number
|
||||
class_section_ids?: number[]
|
||||
@@ -1942,7 +2081,7 @@ export async function fetchRegisteredNewStudents(params?: {
|
||||
const suffix = qs.toString() ? `?${qs}` : ''
|
||||
const body = await apiFetch<
|
||||
RegisteredNewStudentsResponse & { data?: RegisteredNewStudentsResponse }
|
||||
>(`/api/v1/administrator/enroll-withdrawal/registered-new-students${suffix}`)
|
||||
>(`/api/v1/administrator/enroll-withdrawal/new-students${suffix}`)
|
||||
const d = unwrapData(body)
|
||||
return {
|
||||
new_students: d.new_students ?? [],
|
||||
@@ -1972,7 +2111,7 @@ export async function postEnrollmentWithdrawalAssignClass(payload: {
|
||||
parent_id: number
|
||||
class_section_id: number | string
|
||||
}): Promise<{ ok?: boolean; message?: string }> {
|
||||
return apiFetch(`/api/v1/administrator/enroll-withdrawal/assign-class`, {
|
||||
return apiFetch(`/api/v1/administrator/enroll-withdrawal`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
@@ -1984,10 +2123,12 @@ export async function postEnrollmentWithdrawalStatusBatch(payload: {
|
||||
school_year?: string
|
||||
semester?: 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',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
body: JSON.stringify({
|
||||
enrollment_status: payload.updates.map((row) => row.enrollment_status),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+4
-1
@@ -13,5 +13,8 @@ export async function fetchSlipPreviewList(params?: {
|
||||
if (params?.school_year) qs.set('school_year', params.school_year)
|
||||
if (params?.semester) qs.set('semester', params.semester)
|
||||
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
@@ -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 = {
|
||||
id: number
|
||||
menu_parent_id: number | null
|
||||
@@ -182,7 +194,7 @@ export type AdministratorDailyAttendanceResponse = {
|
||||
grades?: Record<string, AdministratorDailyAttendanceSection[]>
|
||||
dates_by_section?: Record<string, string[]>
|
||||
date_list?: string[]
|
||||
no_school_days?: Record<string, boolean>
|
||||
no_school_days?: Record<string, string>
|
||||
total_passed_days?: number
|
||||
semester?: string | null
|
||||
school_year?: string | null
|
||||
|
||||
+6
-6
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* 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'
|
||||
|
||||
const BASE = '/api/v1/administrator/whatsapp'
|
||||
const BASE = '/api/v1/whatsapp'
|
||||
|
||||
export type WhatsappSectionRow = {
|
||||
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?.semester) qs.set('semester', params.semester)
|
||||
const suffix = qs.toString() ? `?${qs}` : ''
|
||||
return apiFetch(`${BASE}/manage-links${suffix}`)
|
||||
return apiFetch(`${BASE}/links${suffix}`)
|
||||
}
|
||||
|
||||
export async function saveWhatsappLink(payload: {
|
||||
@@ -89,7 +89,7 @@ export async function saveWhatsappLink(payload: {
|
||||
invite_link: string
|
||||
active?: boolean
|
||||
}): Promise<{ ok?: boolean; message?: string }> {
|
||||
return apiFetch(`${BASE}/save-link`, {
|
||||
return apiFetch(`${BASE}/links`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
@@ -101,7 +101,7 @@ export async function sendWhatsappInvites(payload: {
|
||||
class_section_id?: number | null
|
||||
parent_ids?: number[]
|
||||
}): Promise<{ ok?: boolean; message?: string }> {
|
||||
return apiFetch(`${BASE}/send-invites`, {
|
||||
return apiFetch(`${BASE}/invites/send`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
@@ -132,7 +132,7 @@ export async function updateWhatsappMembership(payload: {
|
||||
primary_member?: string
|
||||
second_member?: string
|
||||
}): Promise<{ status?: string; message?: string }> {
|
||||
return apiFetch(`${BASE}/update-membership`, {
|
||||
return apiFetch(`${BASE}/membership`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
|
||||
@@ -3,11 +3,12 @@ import { ciRouteManual } from './ciRouteManual'
|
||||
|
||||
/** CI registry keys whose folder layout does not match the SPA route (see `App.tsx`). */
|
||||
const CI_REGISTRY_APP_PATH_OVERRIDES: Record<string, string> = {
|
||||
// Nested views map to flat `/app/teacher/competition-scores` (+ dynamic detail route).
|
||||
'teacher/competition_scores/index': '/app/teacher/competition-scores',
|
||||
'teacher/competition_scores/scores': '/app/teacher/competition-scores',
|
||||
/** CI `landing_page/teacher_dashboard.php` → SPA (underscore segment per legacy URL). */
|
||||
'landing_page/teacher_dashboard': '/app/teacher_dashboard',
|
||||
// Nested views map to flat `/app/teacher/competition-scores` (+ dynamic detail route).
|
||||
'teacher/competition_scores/index': '/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). */
|
||||
'landing_page/teacher_dashboard': '/app/teacher_dashboard',
|
||||
'flags/flags_management': '/app/administrator/flags/management',
|
||||
'flags/processed_flags': '/app/administrator/flags/processed',
|
||||
'flags/incident_analysis': '/app/administrator/flags/incident-analysis',
|
||||
|
||||
@@ -67,6 +67,7 @@ export function spaPathFromCiUrl(url: string | null | undefined): string | null
|
||||
}
|
||||
|
||||
const shortcuts: Record<string, string> = {
|
||||
'landing_page/admin_dashboard': '/app/landing-page/admin-dashboard',
|
||||
'landing_page/guest_dashboard': '/app/home',
|
||||
'landing_page/parent_dashboard': '/app/parent/home',
|
||||
'landing_page/teacher_dashboard': '/app/teacher_dashboard',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -6,7 +6,7 @@ export { ClassProgressListPage as AdminClassProgressListPage } from './classProg
|
||||
export { ClassProgressViewPage as AdminClassProgressViewPage } from './classProgress/ClassProgressViewPage'
|
||||
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 {
|
||||
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() {
|
||||
const [groups, setGroups] = useState<EmergencyContactGroupRow[]>([])
|
||||
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() {
|
||||
try {
|
||||
@@ -41,68 +54,123 @@ export function AdminEmergencyContactIndexPage() {
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [])
|
||||
useEffect(() => { void load() }, [])
|
||||
|
||||
async function removeContact(contactId: number, parentId: number) {
|
||||
await deleteAdministratorEmergencyContact(contactId, parentId)
|
||||
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 (
|
||||
<PageShell title="Emergency Contact Information">
|
||||
{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">
|
||||
<table className="table table-bordered table-striped align-middle">
|
||||
<thead className="table-dark">
|
||||
<tr>
|
||||
<th>Parent Name</th>
|
||||
<th>Students</th>
|
||||
<th>Contact Name</th>
|
||||
<th>Relation</th>
|
||||
<th>Phone</th>
|
||||
<SortTh col="parentName" label="Parent Name" />
|
||||
<SortTh col="students" label="Students" />
|
||||
<SortTh col="contactName" label="Contact Name" />
|
||||
<SortTh col="relation" label="Relation" />
|
||||
<SortTh col="phone" label="Phone" />
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{groups.flatMap((group) =>
|
||||
(group.contacts ?? []).map((contact) => (
|
||||
<tr key={contact.id}>
|
||||
<td>{group.parent_name ?? '—'}</td>
|
||||
<td>
|
||||
{(group.students ?? [])
|
||||
.map((student) => `${student.firstname ?? ''} ${student.lastname ?? ''}`.trim())
|
||||
.join(', ') || '—'}
|
||||
</td>
|
||||
<td>{contact.name ?? '—'}</td>
|
||||
<td>{contact.relation ?? '—'}</td>
|
||||
<td>{contact.cellphone || group.parent_phones?.join(' / ') || 'N/A'}</td>
|
||||
{displayed.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="text-muted">No emergency contacts found.</td>
|
||||
</tr>
|
||||
) : (
|
||||
displayed.map((row) => (
|
||||
<tr key={row.contactId}>
|
||||
<td>{row.parentName || '—'}</td>
|
||||
<td>{row.students || '—'}</td>
|
||||
<td>{row.contactName || '—'}</td>
|
||||
<td>{row.relation || '—'}</td>
|
||||
<td>{row.phone || 'N/A'}</td>
|
||||
<td className="d-flex gap-2">
|
||||
<Link
|
||||
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
|
||||
</Link>
|
||||
<button
|
||||
className="btn btn-sm btn-danger"
|
||||
type="button"
|
||||
onClick={() => void removeContact(contact.id, group.parent_id)}
|
||||
onClick={() => void removeContact(row.contactId, row.parentId)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)),
|
||||
))
|
||||
)}
|
||||
{groups.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="text-muted">
|
||||
No emergency contacts found.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</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">
|
||||
<form className="row g-2 align-items-end" onSubmit={applyFilter}>
|
||||
<div className="col-auto">
|
||||
<label className="form-label small mb-0">School Year</label>
|
||||
<input name="school_year" className="form-control form-control-sm" defaultValue={schoolYear} />
|
||||
<label htmlFor="hub-school-year" className="form-label small mb-0">School Year</label>
|
||||
<input id="hub-school-year" name="school_year" className="form-control form-control-sm" defaultValue={schoolYear} />
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
<label className="form-label small mb-0">Semester</label>
|
||||
<input name="semester" className="form-control form-control-sm" defaultValue={semester} />
|
||||
<label htmlFor="hub-semester" className="form-label small mb-0">Semester</label>
|
||||
<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 className="col-auto">
|
||||
<button type="submit" className="btn btn-sm btn-secondary">
|
||||
|
||||
@@ -53,6 +53,11 @@ export function ViolationsPendingPage() {
|
||||
if (!cancelled) {
|
||||
setRows(data.students ?? [])
|
||||
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) {
|
||||
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">
|
||||
<form className="row g-2 align-items-end" onSubmit={applyFilter}>
|
||||
<div className="col-auto">
|
||||
<label className="form-label small mb-0">School Year</label>
|
||||
<input name="school_year" className="form-control form-control-sm" defaultValue={schoolYear} />
|
||||
<label htmlFor="pending-school-year" className="form-label small mb-0">School Year</label>
|
||||
<input id="pending-school-year" name="school_year" className="form-control form-control-sm" defaultValue={schoolYear} />
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
<label className="form-label small mb-0">Semester</label>
|
||||
<input name="semester" className="form-control form-control-sm" defaultValue={semester} />
|
||||
<label htmlFor="pending-semester" className="form-label small mb-0">Semester</label>
|
||||
<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 className="col-auto">
|
||||
<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>
|
||||
<div className="d-flex flex-wrap gap-2 align-items-center">
|
||||
<span className="badge bg-primary">School Year: {schoolYear || '—'}</span>
|
||||
{semester ? <span className="badge bg-secondary">Semester: {semester}</span> : null}
|
||||
<Link
|
||||
to={`${ATTENDANCE_VIOLATIONS_NOTIFIED_PATH}${filterQuery}`}
|
||||
className="btn btn-outline-dark btn-sm"
|
||||
@@ -125,7 +133,9 @@ export function ViolationsPendingPage() {
|
||||
<div className="row mb-3">
|
||||
<div className="col-md-4">
|
||||
<input
|
||||
id="pending-quick-filter"
|
||||
type="search"
|
||||
name="q"
|
||||
className="form-control form-control-sm"
|
||||
placeholder="Quick filter…"
|
||||
value={q}
|
||||
@@ -176,7 +186,7 @@ export function ViolationsPendingPage() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((st, idx) => (
|
||||
<PendingRow key={studentIdOf(st) || idx} st={st} />
|
||||
<PendingRow key={`${studentIdOf(st)}-${idx}`} st={st} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -327,7 +337,14 @@ export function ViolationsNotifiedPage() {
|
||||
schoolYear: schoolYear || 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) {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : 'Unable to load.')
|
||||
} finally {
|
||||
@@ -381,12 +398,16 @@ export function ViolationsNotifiedPage() {
|
||||
<div className="d-flex justify-content-center mb-2">
|
||||
<form className="row g-2 align-items-end" onSubmit={applyFilter}>
|
||||
<div className="col-auto">
|
||||
<label className="form-label small mb-0">School Year</label>
|
||||
<input name="school_year" className="form-control form-control-sm" defaultValue={schoolYear} />
|
||||
<label htmlFor="notified-school-year" className="form-label small mb-0">School Year</label>
|
||||
<input id="notified-school-year" name="school_year" className="form-control form-control-sm" defaultValue={schoolYear} />
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
<label className="form-label small mb-0">Semester</label>
|
||||
<input name="semester" className="form-control form-control-sm" defaultValue={semester} />
|
||||
<label htmlFor="notified-semester" className="form-label small mb-0">Semester</label>
|
||||
<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 className="col-auto">
|
||||
<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>
|
||||
<div className="d-flex flex-wrap gap-2 align-items-center">
|
||||
<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">
|
||||
View Pending
|
||||
</Link>
|
||||
@@ -432,7 +452,7 @@ export function ViolationsNotifiedPage() {
|
||||
<tbody>
|
||||
{rows.map((st, idx) => (
|
||||
<NotifiedRow
|
||||
key={studentIdOf(st) || idx}
|
||||
key={`${studentIdOf(st)}-${idx}`}
|
||||
st={st}
|
||||
onEditNote={() => {
|
||||
setNoteModal(st)
|
||||
@@ -457,6 +477,8 @@ export function ViolationsNotifiedPage() {
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<textarea
|
||||
id="note-text"
|
||||
name="note"
|
||||
className="form-control"
|
||||
rows={5}
|
||||
required
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { ApiHttpError } from '../../api/http'
|
||||
import { fetchRegisteredNewStudents } from '../../api/session'
|
||||
@@ -21,6 +21,14 @@ function formatRegistrationDate(raw?: string | null): string {
|
||||
}
|
||||
|
||||
export function RegisteredStudentsPage() {
|
||||
type RegisteredStudentsSortKey =
|
||||
| 'firstname'
|
||||
| 'lastname'
|
||||
| 'new_student'
|
||||
| 'class_section'
|
||||
| 'enrollment_status'
|
||||
| 'age'
|
||||
| 'registration_date'
|
||||
const [searchParams] = useSearchParams()
|
||||
const schoolYear = searchParams.get('school_year') ?? undefined
|
||||
const semester = searchParams.get('semester') ?? undefined
|
||||
@@ -31,6 +39,14 @@ export function RegisteredStudentsPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | 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(() => {
|
||||
let cancelled = false
|
||||
@@ -55,6 +71,52 @@ export function RegisteredStudentsPage() {
|
||||
}
|
||||
}, [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 (
|
||||
<div className="container-fluid">
|
||||
<div className="wrapper">
|
||||
@@ -67,32 +129,46 @@ export function RegisteredStudentsPage() {
|
||||
</h2>
|
||||
<AcademicFilterBar schoolYears={schoolYears} />
|
||||
|
||||
{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}
|
||||
|
||||
<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">
|
||||
<table className="table table-bordered table-striped mt-4 align-middle w-100">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>First Name</th>
|
||||
<th>Last Name</th>
|
||||
<th>New Student</th>
|
||||
<th>Current Class</th>
|
||||
<th>Actual Status</th>
|
||||
<th>Age</th>
|
||||
<th>Registration Date</th>
|
||||
<th role="button" style={{ cursor: 'pointer' }} onClick={() => requestSort('firstname')}>First Name {sortArrow('firstname')}</th>
|
||||
<th role="button" style={{ cursor: 'pointer' }} onClick={() => requestSort('lastname')}>Last Name {sortArrow('lastname')}</th>
|
||||
<th role="button" style={{ cursor: 'pointer' }} onClick={() => requestSort('new_student')}>New Student {sortArrow('new_student')}</th>
|
||||
<th role="button" style={{ cursor: 'pointer' }} onClick={() => requestSort('class_section')}>Current Class {sortArrow('class_section')}</th>
|
||||
<th role="button" style={{ cursor: 'pointer' }} onClick={() => requestSort('enrollment_status')}>Actual Status {sortArrow('enrollment_status')}</th>
|
||||
<th role="button" style={{ cursor: 'pointer' }} onClick={() => requestSort('age')}>Age {sortArrow('age')}</th>
|
||||
<th role="button" style={{ cursor: 'pointer' }} onClick={() => requestSort('registration_date')}>Registration Date {sortArrow('registration_date')}</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!loading && !error && rows.length === 0 ? (
|
||||
{!loading && !error && displayedRows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="text-center">
|
||||
No students found.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{rows.map((student) => {
|
||||
{displayedRows.map((student) => {
|
||||
const sid = Number(student.id ?? 0)
|
||||
const familyHref =
|
||||
sid > 0 ? `/app/administrator/family?student_id=${encodeURIComponent(String(sid))}` : '#'
|
||||
|
||||
@@ -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 { ApiHttpError } from '../../api/http'
|
||||
import { fetchBelowSixty, postBelowSixtyStatus } from '../../api/grading'
|
||||
import { fetchGradingOverview } from '../../api/session'
|
||||
import { AcademicFilterBar } from '../discounts/AcademicFilterBar'
|
||||
import {
|
||||
GRADING_BELOW_SIXTY_EMAIL_EDITOR_PATH,
|
||||
@@ -19,7 +20,18 @@ function displayScore(value: unknown): string {
|
||||
|
||||
/** CI `grading/below_sixty.php` */
|
||||
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 schoolYear = searchParams.get('school_year') ?? ''
|
||||
const qs = searchParams.toString()
|
||||
@@ -27,8 +39,49 @@ export function BelowSixtyPage() {
|
||||
const [rows, setRows] = useState<BelowRow[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
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 = () => {
|
||||
if (!semester || !schoolYear) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
fetchBelowSixty(searchParams)
|
||||
.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 (
|
||||
<div className="container-fluid">
|
||||
<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">
|
||||
<thead className="table-light">
|
||||
<tr>
|
||||
<th>Student Name</th>
|
||||
<th>Section</th>
|
||||
<th>Hwk Avg</th>
|
||||
<th>Project Avg</th>
|
||||
<th>Participation</th>
|
||||
<th>Test Avg</th>
|
||||
<th>PTAP Score</th>
|
||||
<th>Attendance</th>
|
||||
<th>Midterm Score</th>
|
||||
<th>{semLabel}</th>
|
||||
<th role="button" style={{ cursor: 'pointer' }} onClick={() => requestSort('student_name')}>Student Name {sortArrow('student_name')}</th>
|
||||
<th role="button" style={{ cursor: 'pointer' }} onClick={() => requestSort('class_section_name')}>Section {sortArrow('class_section_name')}</th>
|
||||
<th role="button" style={{ cursor: 'pointer' }} onClick={() => requestSort('homework_avg')}>Hwk Avg {sortArrow('homework_avg')}</th>
|
||||
<th role="button" style={{ cursor: 'pointer' }} onClick={() => requestSort('project_avg')}>Project Avg {sortArrow('project_avg')}</th>
|
||||
<th role="button" style={{ cursor: 'pointer' }} onClick={() => requestSort('participation_score')}>Participation {sortArrow('participation_score')}</th>
|
||||
<th role="button" style={{ cursor: 'pointer' }} onClick={() => requestSort('test_avg')}>Test Avg {sortArrow('test_avg')}</th>
|
||||
<th role="button" style={{ cursor: 'pointer' }} onClick={() => requestSort('ptap_score')}>PTAP Score {sortArrow('ptap_score')}</th>
|
||||
<th role="button" style={{ cursor: 'pointer' }} onClick={() => requestSort('attendance_score')}>Attendance {sortArrow('attendance_score')}</th>
|
||||
<th role="button" style={{ cursor: 'pointer' }} onClick={() => requestSort('midterm_exam_score')}>Midterm Score {sortArrow('midterm_exam_score')}</th>
|
||||
<th role="button" style={{ cursor: 'pointer' }} onClick={() => requestSort('semester_score')}>{semLabel} {sortArrow('semester_score')}</th>
|
||||
<th>Status</th>
|
||||
<th>Email Parent</th>
|
||||
<th>Schedule Meeting</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, i) => {
|
||||
{displayedRows.map((row, i) => {
|
||||
const sid = Number(row.student_id ?? 0)
|
||||
const first = String(row.firstname ?? '')
|
||||
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">
|
||||
<form
|
||||
key={`${sid}-${String(row.status ?? 'Open')}-${String(row.note ?? '')}`}
|
||||
className="d-flex align-items-center gap-2 justify-content-center flex-wrap"
|
||||
onSubmit={(ev) => void onStatusSubmit(ev)}
|
||||
>
|
||||
|
||||
@@ -1,99 +1,589 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { ApiHttpError } from '../../api/http'
|
||||
import { fetchGradingMain } from '../../api/grading'
|
||||
import { AcademicFilterBar } from '../discounts/AcademicFilterBar'
|
||||
import {
|
||||
fetchConfigurationByKey,
|
||||
fetchGradingOverview,
|
||||
lockAllGradingScores,
|
||||
refreshGradingScores,
|
||||
toggleGradingLock,
|
||||
updateConfigurationValue,
|
||||
} from '../../api/session'
|
||||
import type { GradingOverviewStudentRow } from '../../api/types'
|
||||
import {
|
||||
GRADING_BELOW_SIXTY_PATH,
|
||||
GRADING_HOMEWORK_TRACKING_PATH,
|
||||
GRADING_PARTICIPATION_PATH,
|
||||
GRADING_PLACEMENT_INDEX_PATH,
|
||||
gradingScoresPath,
|
||||
} 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() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const [payload, setPayload] = useState<unknown>(null)
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
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 [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(() => {
|
||||
let c = false
|
||||
setLoading(true)
|
||||
fetchGradingMain(searchParams)
|
||||
.then((p) => {
|
||||
if (!c) {
|
||||
setPayload(p)
|
||||
setError(null)
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await fetchGradingOverview({
|
||||
semester: semesterParam || null,
|
||||
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 })
|
||||
}
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!c)
|
||||
setError(e instanceof ApiHttpError ? e.message : 'Unable to load grading hub.')
|
||||
})
|
||||
.finally(() => {
|
||||
if (!c) setLoading(false)
|
||||
})
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : 'Unable to load grading hub.')
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
c = true
|
||||
cancelled = true
|
||||
}
|
||||
}, [searchParams])
|
||||
}, [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}`
|
||||
}
|
||||
|
||||
const q = searchParams.toString()
|
||||
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 (
|
||||
<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>
|
||||
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
{loading ? <p className="text-muted">Loading…</p> : null}
|
||||
|
||||
<div className="card mb-4">
|
||||
<div className="card-header">Quick links</div>
|
||||
<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}`}>
|
||||
Below 60 Summary
|
||||
</Link>
|
||||
</div>
|
||||
<div className="col-md-6 col-lg-4">
|
||||
<Link
|
||||
className="btn btn-outline-primary w-100"
|
||||
to={`${GRADING_HOMEWORK_TRACKING_PATH}${qs}`}
|
||||
>
|
||||
Homework Tracking
|
||||
</Link>
|
||||
</div>
|
||||
<div className="col-md-6 col-lg-4">
|
||||
<Link className="btn btn-outline-primary w-100" to={`${GRADING_PARTICIPATION_PATH}${qs}`}>
|
||||
Participation Scores
|
||||
</Link>
|
||||
</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 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 & class_section_id)
|
||||
</Link>
|
||||
<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>
|
||||
|
||||
{payload != null && !loading ? (
|
||||
<details className="mb-4">
|
||||
<summary className="small text-muted">Raw API payload (tabs/sections render here when wired)</summary>
|
||||
<pre className="small bg-light p-3 rounded mt-2 overflow-auto" style={{ maxHeight: 420 }}>
|
||||
{JSON.stringify(payload, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
) : null}
|
||||
<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}
|
||||
{loading ? (
|
||||
<p className="text-muted">Loading grading overview…</p>
|
||||
) : gradeKeys.length === 0 ? (
|
||||
<p className="text-muted">No grading sections found.</p>
|
||||
) : (
|
||||
<>
|
||||
<ul className="nav nav-tabs flex-wrap justify-content-center mb-4 border-bottom">
|
||||
{gradeKeys.map((classId) => (
|
||||
<li key={classId} className="nav-item">
|
||||
<button
|
||||
type="button"
|
||||
className={`nav-link d-flex align-items-center gap-2 ${classId === activeGrade ? 'active' : ''}`}
|
||||
onClick={() => setActiveGradeKey(classId)}
|
||||
style={{ color: classId === activeGrade ? '#1f2937' : '#334155' }}
|
||||
>
|
||||
<span
|
||||
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>
|
||||
|
||||
{normalizedSearch !== '' && searchMatches.length > 0 ? (
|
||||
<div className="row g-2 justify-content-center mb-3">
|
||||
<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>
|
||||
) : 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 className="d-flex flex-wrap justify-content-center gap-2 mb-3">
|
||||
<button
|
||||
className="btn btn-sm btn-outline-danger"
|
||||
type="button"
|
||||
disabled={!selectedSectionId || !selectedSemester || !selectedSchoolYear || actionBusy !== null}
|
||||
onClick={() => void handleToggleSectionLock()}
|
||||
>
|
||||
{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}
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 { ApiHttpError } from '../../api/http'
|
||||
import {
|
||||
fetchGradingSectionScoreTable,
|
||||
fetchGradingScoreForm,
|
||||
postGradingSectionScoreUpdate,
|
||||
postGradingScoreUpdate,
|
||||
type GradingSectionScorePayload,
|
||||
type GradingSectionScoreStudent,
|
||||
type GradingScoreFormPayload,
|
||||
type GradingScoreRow,
|
||||
} 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`. */
|
||||
export function GradingScoreEntryPage() {
|
||||
const { scoreType = '' } = useParams()
|
||||
const { scoreType = '', classSectionId = '', studentId = '' } = useParams()
|
||||
const [searchParams] = useSearchParams()
|
||||
const studentId = searchParams.get('student_id') ?? ''
|
||||
const classSectionId = searchParams.get('class_section_id') ?? ''
|
||||
const semester = searchParams.get('semester') ?? ''
|
||||
const schoolYear = searchParams.get('school_year') ?? ''
|
||||
|
||||
const [data, setData] = useState<GradingScoreFormPayload | null>(null)
|
||||
const [sectionData, setSectionData] = useState<GradingSectionScorePayload | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
@@ -25,38 +30,70 @@ export function GradingScoreEntryPage() {
|
||||
const [scoreRows, setScoreRows] = useState<GradingScoreRow[]>([])
|
||||
const [singleScore, setSingleScore] = 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(() => {
|
||||
if (!isGradingScoreType(scoreType) || !studentId || !classSectionId) {
|
||||
setLoading(false)
|
||||
setData(null)
|
||||
return
|
||||
if (!isGradingScoreType(scoreType) || !studentId || !classSectionId || !semester || !schoolYear) {
|
||||
if (!isSectionMode || !isGradingScoreType(scoreType) || !classSectionId) {
|
||||
setLoading(false)
|
||||
setData(null)
|
||||
setSectionData(null)
|
||||
return
|
||||
}
|
||||
}
|
||||
let c = false
|
||||
setLoading(true)
|
||||
fetchGradingScoreForm(scoreType, studentId, classSectionId)
|
||||
.then((p) => {
|
||||
if (c) return
|
||||
setData(p)
|
||||
setScoreRows(Array.isArray(p.scores) ? [...p.scores] : [])
|
||||
const first = p.scores?.[0]
|
||||
setSingleScore(
|
||||
first?.score !== undefined && first?.score !== null ? String(first.score) : '',
|
||||
)
|
||||
setGeneralComment(first?.comment != null ? String(first.comment) : '')
|
||||
setError(null)
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!c)
|
||||
setError(e instanceof ApiHttpError ? e.message : 'Unable to load score form.')
|
||||
})
|
||||
.finally(() => {
|
||||
if (!c) setLoading(false)
|
||||
})
|
||||
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) => {
|
||||
if (c) return
|
||||
setData(p)
|
||||
setScoreRows(Array.isArray(p.scores) ? [...p.scores] : [])
|
||||
const first = p.scores?.[0]
|
||||
setSingleScore(first?.score !== undefined && first?.score !== null ? String(first.score) : '')
|
||||
setGeneralComment(first?.comment != null ? String(first.comment) : '')
|
||||
setSectionData(null)
|
||||
setError(null)
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!c) setError(e instanceof ApiHttpError ? e.message : 'Unable to load score form.')
|
||||
})
|
||||
.finally(() => {
|
||||
if (!c) setLoading(false)
|
||||
})
|
||||
}
|
||||
return () => {
|
||||
c = true
|
||||
}
|
||||
}, [scoreType, studentId, classSectionId])
|
||||
}, [classSectionId, isSectionMode, schoolYear, scoreType, semester, studentId])
|
||||
|
||||
const title =
|
||||
scoreType === 'homework'
|
||||
@@ -76,15 +113,125 @@ export function GradingScoreEntryPage() {
|
||||
: 'Scores'
|
||||
|
||||
const locked = Boolean(data?.scoresLocked)
|
||||
const sectionLocked = Boolean(sectionData?.scores_locked)
|
||||
const student = data?.student
|
||||
const name =
|
||||
student?.firstname || student?.lastname
|
||||
? `${student?.firstname ?? ''} ${student?.lastname ?? ''}`.trim()
|
||||
: '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) {
|
||||
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)
|
||||
setError(null)
|
||||
try {
|
||||
@@ -92,6 +239,8 @@ export function GradingScoreEntryPage() {
|
||||
type: scoreType,
|
||||
student_id: Number(studentId),
|
||||
class_section_id: Number(classSectionId),
|
||||
semester,
|
||||
school_year: schoolYear,
|
||||
}
|
||||
if (['homework', 'quiz', 'project'].includes(scoreType)) {
|
||||
body = {
|
||||
@@ -102,13 +251,24 @@ export function GradingScoreEntryPage() {
|
||||
}
|
||||
} else if (['midterm', 'final', 'test'].includes(scoreType)) {
|
||||
body = { ...body, score: singleScore === '' ? null : Number(singleScore) }
|
||||
} else if (scoreType === 'comments') {
|
||||
} else if (scoreType === 'comments') {
|
||||
body = { ...body, comment: generalComment }
|
||||
}
|
||||
await postGradingScoreUpdate(scoreType, body)
|
||||
const next = await fetchGradingScoreForm(scoreType, studentId, classSectionId)
|
||||
await postGradingScoreUpdate(body)
|
||||
const next = await fetchGradingScoreForm(
|
||||
scoreType,
|
||||
classSectionId,
|
||||
studentId,
|
||||
new URLSearchParams({
|
||||
semester,
|
||||
school_year: schoolYear,
|
||||
}),
|
||||
)
|
||||
setData(next)
|
||||
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) {
|
||||
setError(err instanceof ApiHttpError ? err.message : 'Save failed.')
|
||||
} finally {
|
||||
@@ -124,12 +284,12 @@ export function GradingScoreEntryPage() {
|
||||
)
|
||||
}
|
||||
|
||||
if (!studentId || !classSectionId) {
|
||||
if ((!isSectionMode && (!studentId || !classSectionId || !semester || !schoolYear)) || (isSectionMode && !classSectionId)) {
|
||||
return (
|
||||
<div className="container mt-4">
|
||||
<div className="alert alert-info">
|
||||
Add <code>student_id</code> and <code>class_section_id</code> query parameters (open from
|
||||
Grading Management).
|
||||
Open this page from Grading Management so the route includes the section and the URL includes
|
||||
<code>semester</code> and <code>school_year</code>.
|
||||
</div>
|
||||
<Link className="btn btn-secondary" to={GRADING_PATH}>
|
||||
Return to Grading Management
|
||||
@@ -141,9 +301,14 @@ export function GradingScoreEntryPage() {
|
||||
return (
|
||||
<div className="container mt-5">
|
||||
<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>
|
||||
{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">
|
||||
Scores are locked for this class. Unlock to edit.
|
||||
</div>
|
||||
@@ -231,6 +396,169 @@ export function GradingScoreEntryPage() {
|
||||
</form>
|
||||
) : 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">
|
||||
<Link className="btn btn-secondary" to={GRADING_PATH}>
|
||||
Return to Main Page
|
||||
|
||||
@@ -1,20 +1,56 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { ApiHttpError } from '../../api/http'
|
||||
import { fetchHomeworkTracking } from '../../api/grading'
|
||||
import { AcademicFilterBar } from '../discounts/AcademicFilterBar'
|
||||
import { trackingSectionsFromPayload } from './gradingPayloadHelpers'
|
||||
import { GRADING_PATH } from './gradingPaths'
|
||||
|
||||
function cell(v: unknown): string {
|
||||
if (v === null || v === undefined || v === '') return '—'
|
||||
return String(v)
|
||||
type HomeworkTrackingTeacherRow = {
|
||||
class_section_id?: number
|
||||
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() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const [payload, setPayload] = useState<unknown>(null)
|
||||
const [payload, setPayload] = useState<HomeworkTrackingPayload | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
@@ -39,14 +75,34 @@ export function HomeworkTrackingPage() {
|
||||
}
|
||||
}, [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 todayYmd = useMemo(() => new Date().toISOString().slice(0, 10), [])
|
||||
|
||||
return (
|
||||
<div className="container-fluid py-3">
|
||||
<h2 className="text-center mt-2 mb-3">Homework tracking</h2>
|
||||
<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">
|
||||
<Link className="btn btn-outline-secondary btn-sm" to={`${GRADING_PATH}${qs ? `?${qs}` : ''}`}>
|
||||
Grading hub
|
||||
@@ -56,51 +112,80 @@ export function HomeworkTrackingPage() {
|
||||
{error ? <div className="alert alert-danger">{error}</div> : 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>
|
||||
) : null}
|
||||
|
||||
{!loading &&
|
||||
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">
|
||||
<table className="table table-sm table-bordered align-middle">
|
||||
<thead className="table-light">
|
||||
<tr>
|
||||
{columns.map((col) => (
|
||||
<th key={col}>{col.replace(/_/g, ' ')}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sec.rows.map((row, ri) => (
|
||||
<tr key={ri}>
|
||||
{columns.map((col) => (
|
||||
<td key={col}>{cell(row[col])}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{!loading && teachers.length > 0 ? (
|
||||
<div className="table-responsive">
|
||||
<table className="table table-bordered align-middle">
|
||||
<thead className="table-light">
|
||||
<tr>
|
||||
<th>Grade</th>
|
||||
<th style={{ minWidth: 300 }}>Teacher & 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>
|
||||
</thead>
|
||||
<tbody>
|
||||
{teachers.map((row, index) => {
|
||||
const classId = Number(row.class_id ?? 0)
|
||||
const sectionId = Number(row.class_section_id ?? 0)
|
||||
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
|
||||
|
||||
{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>
|
||||
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>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 { ApiHttpError } from '../../api/http'
|
||||
import { fetchParticipation, postParticipation } from '../../api/grading'
|
||||
import { AcademicFilterBar } from '../discounts/AcademicFilterBar'
|
||||
import { rowsFromUnknown } from './gradingPayloadHelpers'
|
||||
import { GRADING_PATH } from './gradingPaths'
|
||||
|
||||
/** CI `grading/participation.php` */
|
||||
export function ParticipationPage() {
|
||||
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 [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 [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [tableSearch, setTableSearch] = useState('')
|
||||
|
||||
const classSectionId = searchParams.get('class_section_id') ?? ''
|
||||
|
||||
useEffect(() => {
|
||||
let c = false
|
||||
@@ -22,11 +34,26 @@ export function ParticipationPage() {
|
||||
fetchParticipation(searchParams)
|
||||
.then((p) => {
|
||||
if (c) return
|
||||
setRows(rowsFromUnknown(p))
|
||||
if (p && typeof p === 'object') {
|
||||
const o = p as Record<string, unknown>
|
||||
setLocked(Boolean(o.scoresLocked ?? o.locked))
|
||||
if (typeof o.title === 'string') setTitle(o.title)
|
||||
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,
|
||||
}
|
||||
}),
|
||||
)
|
||||
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)
|
||||
})
|
||||
@@ -41,17 +68,18 @@ export function ParticipationPage() {
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
const scoreKey = (row: Record<string, unknown>): string => {
|
||||
if ('participation_score' in row) return 'participation_score'
|
||||
if ('score' in row) return 'score'
|
||||
return 'participation_score'
|
||||
}
|
||||
const visibleRows = useMemo(() => {
|
||||
const normalized = tableSearch.toLowerCase().trim()
|
||||
if (normalized === '') return rows
|
||||
return rows.filter((row) =>
|
||||
`${row.school_id ?? ''} ${row.firstname ?? ''} ${row.lastname ?? ''}`.toLowerCase().includes(normalized),
|
||||
)
|
||||
}, [rows, tableSearch])
|
||||
|
||||
function setScore(i: number, value: string) {
|
||||
setRows((prev) => {
|
||||
const next = [...prev]
|
||||
const k = scoreKey(next[i] ?? {})
|
||||
next[i] = { ...next[i], [k]: value === '' ? null : Number(value) }
|
||||
next[i] = { ...next[i], score: value === '' ? null : Number(value) }
|
||||
return next
|
||||
})
|
||||
}
|
||||
@@ -62,13 +90,33 @@ export function ParticipationPage() {
|
||||
setError(null)
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
rows,
|
||||
school_year: searchParams.get('school_year') ?? undefined,
|
||||
semester: searchParams.get('semester') ?? undefined,
|
||||
class_section_id: classSectionId ? Number(classSectionId) : undefined,
|
||||
school_year: schoolYear || 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)
|
||||
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) {
|
||||
setError(err instanceof ApiHttpError ? err.message : 'Save failed.')
|
||||
} finally {
|
||||
@@ -77,12 +125,13 @@ export function ParticipationPage() {
|
||||
}
|
||||
|
||||
const qs = searchParams.toString()
|
||||
const cols = rows[0] ? Object.keys(rows[0]) : ['student_id', 'firstname', 'lastname', 'participation_score']
|
||||
|
||||
return (
|
||||
<div className="container-fluid py-3">
|
||||
<h2 className="text-center mt-2 mb-3">{title}</h2>
|
||||
<AcademicFilterBar />
|
||||
<div className="container mt-5">
|
||||
<h2 className="text-center mb-4">{title}</h2>
|
||||
<p className="text-center text-muted small">
|
||||
{(sectionName || (classSectionId ? `Section ${classSectionId}` : 'Section'))} • {semester || '—'} • {schoolYear || '—'}
|
||||
</p>
|
||||
|
||||
<div className="mb-3">
|
||||
<Link className="btn btn-outline-secondary btn-sm" to={`${GRADING_PATH}${qs ? `?${qs}` : ''}`}>
|
||||
@@ -98,44 +147,73 @@ export function ParticipationPage() {
|
||||
|
||||
{!loading && rows.length > 0 ? (
|
||||
<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">
|
||||
<table className="table table-bordered align-middle">
|
||||
<table className="table table-bordered table-striped align-middle">
|
||||
<thead className="table-light">
|
||||
<tr>
|
||||
{cols.map((c) => (
|
||||
<th key={c}>{c.replace(/_/g, ' ')}</th>
|
||||
))}
|
||||
<th>#</th>
|
||||
<th>School ID</th>
|
||||
<th>First Name</th>
|
||||
<th>Last Name</th>
|
||||
<th className="text-center">Score</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, i) => {
|
||||
const sk = scoreKey(row)
|
||||
return (
|
||||
<tr key={i}>
|
||||
{cols.map((col) =>
|
||||
col === sk && !locked ? (
|
||||
<td key={col}>
|
||||
<input
|
||||
type="number"
|
||||
className="form-control form-control-sm"
|
||||
style={{ width: 96 }}
|
||||
value={row[col] === null || row[col] === undefined ? '' : String(row[col])}
|
||||
onChange={(ev) => setScore(i, ev.target.value)}
|
||||
/>
|
||||
</td>
|
||||
) : (
|
||||
<td key={col}>{row[col] === null || row[col] === undefined ? '—' : String(row[col])}</td>
|
||||
),
|
||||
)}
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
{visibleRows.map((row, i) => (
|
||||
<tr key={row.student_id}>
|
||||
<td>{i + 1}</td>
|
||||
<td>{row.school_id ?? '—'}</td>
|
||||
<td>{row.firstname ?? '—'}</td>
|
||||
<td>{row.lastname ?? '—'}</td>
|
||||
<td>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.01"
|
||||
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>
|
||||
</tr>
|
||||
))}
|
||||
{visibleRows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} 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={locked || saving}>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
{saving ? 'Saving…' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -1,74 +1,136 @@
|
||||
import { type FormEvent, useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { ApiHttpError } from '../../api/http'
|
||||
import type { ApiEnvelope } from '../../api/types'
|
||||
import {
|
||||
fetchCombinedReport,
|
||||
unwrapCombinedReport,
|
||||
type CombinedReportPayload,
|
||||
} from '../../api/reportsCombined'
|
||||
import { fetchCombinedReport } from '../../api/reportsCombined'
|
||||
import { REPORT_COMBINED_PATH } from './reportPaths'
|
||||
|
||||
function columnKeysForRow(row: Record<string, unknown>, preferred?: string[]): string[] {
|
||||
if (preferred?.length) {
|
||||
const set = new Set(Object.keys(row))
|
||||
const ordered = preferred.filter((k) => set.has(k))
|
||||
const rest = Object.keys(row).filter((k) => !preferred.includes(k))
|
||||
return [...ordered, ...rest.sort()]
|
||||
type ScorePredictorStudent = {
|
||||
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 ScorePredictorSection = {
|
||||
class_section_id?: number | string | null
|
||||
class_section_name?: string | null
|
||||
}
|
||||
|
||||
type ScorePredictorPayload = {
|
||||
ok?: boolean
|
||||
students?: ScorePredictorStudent[]
|
||||
school_year?: string | null
|
||||
class_sections?: ScorePredictorSection[]
|
||||
selected_class_section_id?: number | string | null
|
||||
semester?: string | null
|
||||
message?: string | null
|
||||
}
|
||||
|
||||
type SortKey =
|
||||
| 'school_id'
|
||||
| 'firstname'
|
||||
| 'lastname'
|
||||
| 'class_name'
|
||||
| 'fall_score'
|
||||
| 'spring_score'
|
||||
| 'final_average'
|
||||
| 'required_spring_score'
|
||||
| 'required_spring_to_pass'
|
||||
|
||||
function asNumber(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value
|
||||
if (typeof value === 'string' && value.trim() !== '' && !Number.isNaN(Number(value))) return Number(value)
|
||||
return null
|
||||
}
|
||||
|
||||
function displayValue(value: unknown, fallback = 'N/A') {
|
||||
if (value == null || value === '') return fallback
|
||||
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>
|
||||
}
|
||||
return Object.keys(row).sort()
|
||||
}
|
||||
|
||||
function renderCell(v: unknown): string {
|
||||
if (v == null) return '—'
|
||||
if (typeof v === 'object') return JSON.stringify(v)
|
||||
return String(v)
|
||||
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 ReportTable({
|
||||
rows,
|
||||
columns,
|
||||
}: {
|
||||
rows: Array<Record<string, unknown>>
|
||||
columns?: string[]
|
||||
}) {
|
||||
if (rows.length === 0) return null
|
||||
const keys = columnKeysForRow(rows[0] ?? {}, columns)
|
||||
return (
|
||||
<div className="table-responsive">
|
||||
<table className="table table-striped table-bordered align-middle">
|
||||
<thead className="table-light">
|
||||
<tr>
|
||||
{keys.map((k) => (
|
||||
<th key={k}>{k.replace(/_/g, ' ')}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r, i) => (
|
||||
<tr key={i}>
|
||||
{keys.map((k) => (
|
||||
<td key={k}>{renderCell(r[k])}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
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() {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
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 [raw, setRaw] = useState<unknown>(null)
|
||||
const [raw, setRaw] = useState<ScorePredictorPayload | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [sortConfig, setSortConfig] = useState<{ key: SortKey; direction: 'asc' | 'desc' }>({
|
||||
key: 'lastname',
|
||||
direction: 'asc',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -76,16 +138,14 @@ export function CombinedReportPage() {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetchCombinedReport({
|
||||
const res = (await fetchCombinedReport({
|
||||
school_year: schoolYear || null,
|
||||
semester: semester || null,
|
||||
student_id: studentId.trim() || null,
|
||||
class_section_id: classSectionId.trim() || null,
|
||||
})
|
||||
class_section_id: classSectionId || null,
|
||||
})) as ScorePredictorPayload
|
||||
if (!cancelled) setRaw(res)
|
||||
} catch (e) {
|
||||
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)
|
||||
}
|
||||
} finally {
|
||||
@@ -95,159 +155,403 @@ export function CombinedReportPage() {
|
||||
return () => {
|
||||
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(() => {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const o = raw as ApiEnvelope<unknown>
|
||||
if (o.status === false && o.message) return String(o.message)
|
||||
return null
|
||||
}, [raw])
|
||||
const sectionMap = useMemo(() => {
|
||||
return Object.fromEntries(
|
||||
classSections.map((section) => [
|
||||
String(section.class_section_id ?? ''),
|
||||
section.class_section_name || `Section ${String(section.class_section_id ?? '')}`,
|
||||
]),
|
||||
)
|
||||
}, [classSections])
|
||||
|
||||
const title = typeof data?.title === 'string' && data.title.trim() ? data.title.trim() : 'Combined report'
|
||||
const subtitle =
|
||||
typeof data?.subtitle === 'string' && data.subtitle.trim() ? data.subtitle.trim() : undefined
|
||||
const message =
|
||||
typeof data?.message === 'string' && data.message.trim() ? data.message.trim() : undefined
|
||||
const html = typeof data?.html === 'string' ? data.html : undefined
|
||||
const sortedStudents = useMemo(() => {
|
||||
const rows = [...students]
|
||||
rows.sort((left, right) => {
|
||||
const direction = sortConfig.direction === 'asc' ? 1 : -1
|
||||
const leftClass = sectionMap[String(left.class_section_id ?? '')] ?? `Section ${String(left.class_section_id ?? '')}`
|
||||
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>>) : []
|
||||
const topColumns = Array.isArray(data?.columns) ? (data.columns as string[]) : undefined
|
||||
if (
|
||||
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)
|
||||
? (data?.sections as CombinedReportPayload['sections'])
|
||||
: undefined
|
||||
const a =
|
||||
sortConfig.key === 'class_name'
|
||||
? 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>) {
|
||||
e.preventDefault()
|
||||
const fd = new FormData(e.currentTarget)
|
||||
const p = new URLSearchParams()
|
||||
const sy = String(fd.get('school_year') ?? '')
|
||||
const sem = String(fd.get('semester') ?? '')
|
||||
const sid = String(fd.get('student_id') ?? '').trim()
|
||||
const cid = String(fd.get('class_section_id') ?? '').trim()
|
||||
if (sy) p.set('school_year', sy)
|
||||
if (sem) p.set('semester', sem)
|
||||
if (sid) p.set('student_id', sid)
|
||||
if (cid) p.set('class_section_id', cid)
|
||||
setSearchParams(p)
|
||||
const summary = useMemo(() => {
|
||||
const trophyCounts: Record<string, number> = {}
|
||||
const statusCounts = { Passed: 0, Failed: 0, 'Can pass': 0, 'May fail': 0, Undetermined: 0 }
|
||||
const riskCounts = { Low: 0, Medium: 0, High: 0, 'Unlikely to pass': 0, 'New student': 0 }
|
||||
|
||||
for (const student of students) {
|
||||
const sectionId = String(student.class_section_id ?? '')
|
||||
if (student.trophy_awarded) {
|
||||
trophyCounts[sectionId] = (trophyCounts[sectionId] ?? 0) + 1
|
||||
}
|
||||
|
||||
const status = String(student.status ?? 'Undetermined') as keyof typeof statusCounts
|
||||
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 (
|
||||
<div className="container-xxl py-4">
|
||||
<div className="d-flex flex-wrap justify-content-between align-items-start gap-2 mb-3">
|
||||
<div>
|
||||
<h2 className="mb-0">{title}</h2>
|
||||
{subtitle ? <div className="text-muted small">{subtitle}</div> : null}
|
||||
</div>
|
||||
<div className="container-fluid py-4">
|
||||
<h2 className="text-center mt-2 mb-3">Student Success & Risk Report</h2>
|
||||
|
||||
<div className="mb-4 text-center">
|
||||
<p className="text-muted small mb-1">Active Students</p>
|
||||
<div className="display-4 fw-semibold">{summary.totalStudents}</div>
|
||||
<p className="text-muted small mb-0">Reflects only currently active students.</p>
|
||||
</div>
|
||||
|
||||
<form className="row gy-2 gx-3 align-items-end mb-4" onSubmit={applyFilter}>
|
||||
<div className="col-md-3">
|
||||
<label className="form-label">School year</label>
|
||||
<form className="row g-3 mb-4 align-items-end" onSubmit={applyFilter}>
|
||||
<div className="col-md-6 col-lg-4">
|
||||
<label htmlFor="school_year" className="form-label">School Year</label>
|
||||
<input
|
||||
id="school_year"
|
||||
type="text"
|
||||
name="school_year"
|
||||
className="form-control"
|
||||
defaultValue={schoolYear}
|
||||
defaultValue={selectedYear}
|
||||
placeholder="e.g. 2025-2026"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-2">
|
||||
<label className="form-label">Semester</label>
|
||||
<select name="semester" className="form-select" defaultValue={semester}>
|
||||
<option value="">All</option>
|
||||
<option value="Fall">Fall</option>
|
||||
<option value="Spring">Spring</option>
|
||||
</select>
|
||||
|
||||
<div className="col-md-6 col-lg-4">
|
||||
<label htmlFor="class_section_id" className="form-label">Class Section</label>
|
||||
<div className="input-group">
|
||||
<select
|
||||
id="class_section_id"
|
||||
name="class_section_id"
|
||||
className="form-select"
|
||||
defaultValue={classSectionId}
|
||||
>
|
||||
<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">
|
||||
<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"
|
||||
className="form-control"
|
||||
defaultValue={classSectionId}
|
||||
placeholder="Optional"
|
||||
inputMode="numeric"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-2 d-flex gap-2">
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Apply
|
||||
</button>
|
||||
<Link className="btn btn-outline-secondary" to={REPORT_COMBINED_PATH}>
|
||||
|
||||
<div className="col-md-12 col-lg-4 d-flex justify-content-lg-end gap-2">
|
||||
<div className="text-muted small align-self-center">
|
||||
Current semester: <strong>{selectedSemester || 'N/A'}</strong>
|
||||
</div>
|
||||
<Link className="btn btn-outline-secondary btn-sm align-self-center" to={REPORT_COMBINED_PATH}>
|
||||
Reset
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
{apiFailureMessage ? <div className="alert alert-warning">{apiFailureMessage}</div> : null}
|
||||
{message ? <div className="alert alert-info">{message}</div> : null}
|
||||
{raw?.message ? <div className="alert alert-info">{raw.message}</div> : null}
|
||||
{loading ? <p className="text-muted">Loading score analysis…</p> : null}
|
||||
|
||||
{loading ? <p className="text-muted">Loading…</p> : null}
|
||||
|
||||
{!loading && !error && html ? (
|
||||
<iframe
|
||||
title="Combined report"
|
||||
srcDoc={html}
|
||||
className="w-100 border rounded"
|
||||
style={{ minHeight: 480 }}
|
||||
sandbox="allow-same-origin allow-scripts allow-popups allow-popups-to-escape-sandbox"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!loading && !error && !html && topRows.length > 0 ? (
|
||||
<ReportTable rows={topRows} columns={topColumns} />
|
||||
) : null}
|
||||
|
||||
{!loading && !error && !html && sections?.length
|
||||
? sections.map((sec, idx) => {
|
||||
const secTitle = String(sec.title ?? sec.heading ?? `Section ${idx + 1}`)
|
||||
const secRows = Array.isArray(sec.rows) ? sec.rows : []
|
||||
const secCols = Array.isArray(sec.columns) ? sec.columns : undefined
|
||||
return (
|
||||
<div key={idx} className="mb-4">
|
||||
<h5 className="h6">{secTitle}</h5>
|
||||
<ReportTable rows={secRows} columns={secCols} />
|
||||
{!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>
|
||||
)
|
||||
})
|
||||
: null}
|
||||
</div>
|
||||
|
||||
{!loading &&
|
||||
!error &&
|
||||
!apiFailureMessage &&
|
||||
!html &&
|
||||
topRows.length === 0 &&
|
||||
(!sections || sections.length === 0) &&
|
||||
raw ? (
|
||||
<details className="small text-muted">
|
||||
<summary className="mb-2">Raw JSON (no recognized table layout)</summary>
|
||||
<pre className="bg-light border rounded p-2 small mb-0 overflow-auto" style={{ maxHeight: 320 }}>
|
||||
{JSON.stringify(raw, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
<div className="col-lg-4">
|
||||
<div className="card shadow-sm h-100">
|
||||
<div className="card-header py-2"><strong>Pass vs Fail</strong></div>
|
||||
<div className="card-body">
|
||||
<div className="small text-muted mb-2">Current status mix</div>
|
||||
{Object.entries(summary.statusCounts).map(([label, count]) => {
|
||||
const percent = summary.totalStudents > 0 ? (count / summary.totalStudents) * 100 : 0
|
||||
const barClass =
|
||||
label === 'Passed'
|
||||
? 'bg-success'
|
||||
: label === 'Failed'
|
||||
? 'bg-danger'
|
||||
: label === 'Can pass'
|
||||
? 'bg-primary'
|
||||
: label === 'May fail'
|
||||
? 'bg-warning'
|
||||
: '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 className="col-lg-4">
|
||||
<div className="card shadow-sm h-100">
|
||||
<div className="card-header py-2"><strong>Fail Risk Mix</strong></div>
|
||||
<div className="card-body">
|
||||
<div className="small text-muted mb-2">Risk distribution</div>
|
||||
{Object.entries(summary.riskCounts).map(([label, count]) => {
|
||||
const percent = summary.totalStudents > 0 ? (count / summary.totalStudents) * 100 : 0
|
||||
const barClass =
|
||||
label === 'Low'
|
||||
? 'bg-success'
|
||||
: label === 'Medium'
|
||||
? 'bg-warning'
|
||||
: 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}
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -41,12 +41,25 @@ function slipPrintSearchParams(row: SlipPreviewRow): string {
|
||||
}
|
||||
|
||||
export function SlipPreviewListPage() {
|
||||
type SlipSortKey =
|
||||
| 'student_name'
|
||||
| 'time_in'
|
||||
| 'grade'
|
||||
| 'reason'
|
||||
| 'school_year'
|
||||
| 'semester'
|
||||
| 'admin_name'
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const schoolYear = searchParams.get('school_year') ?? ''
|
||||
const semester = searchParams.get('semester') ?? ''
|
||||
|
||||
const [rows, setRows] = useState<SlipPreviewRow[]>([])
|
||||
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(() => {
|
||||
setError(null)
|
||||
@@ -54,7 +67,17 @@ export function SlipPreviewListPage() {
|
||||
school_year: schoolYear || 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) =>
|
||||
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')
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="container-fluid px-0 py-4">
|
||||
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
|
||||
@@ -179,50 +243,89 @@ export function SlipPreviewListPage() {
|
||||
|
||||
{dateOrder.map((dateKey) => {
|
||||
const groupRows = groupedRows[dateKey] ?? []
|
||||
const expanded = expandedDates.has(dateKey)
|
||||
const displayedRows = sortRows(groupRows)
|
||||
return (
|
||||
<div key={dateKey} className="card mb-4">
|
||||
<div className="card-header d-flex align-items-center justify-content-between flex-wrap gap-2">
|
||||
<div className="fw-semibold">Date: {formatPreviewDateKey(dateKey)}</div>
|
||||
<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>
|
||||
</div>
|
||||
<div className="table-responsive">
|
||||
<table className="table table-striped table-bordered align-middle w-100">
|
||||
<thead className="table-light">
|
||||
<tr>
|
||||
<th>Student Name</th>
|
||||
<th>Time In</th>
|
||||
<th>Grade</th>
|
||||
<th>Reason</th>
|
||||
<th>School Year</th>
|
||||
<th>Semester</th>
|
||||
<th>Admin Name</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{groupRows.map((r, idx) => (
|
||||
<tr key={idx}>
|
||||
<td>{String(r.student_name ?? '')}</td>
|
||||
<td>{String(r.time_in ?? '')}</td>
|
||||
<td>{String(r.grade ?? '')}</td>
|
||||
<td>{String(r.reason ?? '')}</td>
|
||||
<td>{String(r.school_year ?? '')}</td>
|
||||
<td>{displaySemester(r.semester)}</td>
|
||||
<td>{String(r.admin_name ?? '')}</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-primary"
|
||||
onClick={() => openPrint(r)}
|
||||
>
|
||||
Print
|
||||
{expanded ? (
|
||||
<div className="table-responsive">
|
||||
<table className="table table-striped table-bordered align-middle w-100">
|
||||
<thead className="table-light">
|
||||
<tr>
|
||||
<th>
|
||||
<button type="button" className="btn btn-link btn-sm p-0 text-decoration-none" onClick={() => requestSort('student_name')}>
|
||||
Student Name {sortIndicator('student_name')}
|
||||
</button>
|
||||
</td>
|
||||
</th>
|
||||
<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>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</thead>
|
||||
<tbody>
|
||||
{displayedRows.map((r, idx) => (
|
||||
<tr key={idx}>
|
||||
<td>{String(r.student_name ?? '')}</td>
|
||||
<td>{String(r.time_in ?? '')}</td>
|
||||
<td>{String(r.grade ?? '')}</td>
|
||||
<td>{String(r.reason ?? '')}</td>
|
||||
<td>{String(r.school_year ?? '')}</td>
|
||||
<td>{displaySemester(r.semester)}</td>
|
||||
<td>{String(r.admin_name ?? '')}</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-primary"
|
||||
onClick={() => openPrint(r)}
|
||||
>
|
||||
Print
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user