From dc7acf2536b9c5e5a58fd569b5a3612e4fbe76ce Mon Sep 17 00:00:00 2001 From: root Date: Wed, 29 Apr 2026 17:39:37 -0400 Subject: [PATCH] add grading, attendnace management --- package-lock.json | 4 +- src/App.tsx | 16 +- src/api/badgeScan.ts | 39 + src/api/grading.ts | 96 +- src/api/paymentManagement.ts | 63 +- src/api/reportsCombined.ts | 83 +- src/api/session.ts | 163 +- src/api/slips.ts | 5 +- src/api/types.ts | 14 +- src/api/whatsapp.ts | 12 +- src/lib/ciRouteLookup.ts | 11 +- src/lib/ciSpaPaths.ts | 1 + src/pages/AdminDailyAttendance.css | 240 +++ src/pages/AdminProgressPages.tsx | 130 +- src/pages/AdministratorToolPages.tsx | 1399 ++++++++++++----- .../AttendanceViolationsHubPage.tsx | 12 +- src/pages/attendance/ViolationsPages.tsx | 48 +- .../enrollWithdraw/RegisteredStudentsPage.tsx | 98 +- src/pages/grading/BelowSixtyPage.tsx | 125 +- src/pages/grading/GradingMainPage.tsx | 626 +++++++- src/pages/grading/GradingScoreEntryPage.tsx | 402 ++++- src/pages/grading/HomeworkTrackingPage.tsx | 181 ++- src/pages/grading/ParticipationPage.tsx | 178 ++- .../landing/AdminLandingDashboardPage.tsx | 127 ++ src/pages/report/CombinedReportPage.tsx | 668 +++++--- src/pages/rfid/BadgeScanLogsPage.tsx | 188 +++ src/pages/slips/SlipPreviewListPage.tsx | 181 ++- 27 files changed, 4150 insertions(+), 960 deletions(-) create mode 100644 src/api/badgeScan.ts create mode 100644 src/pages/AdminDailyAttendance.css create mode 100644 src/pages/landing/AdminLandingDashboardPage.tsx create mode 100644 src/pages/rfid/BadgeScanLogsPage.tsx diff --git a/package-lock.json b/package-lock.json index 778e85f..0bc25bb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/src/App.tsx b/src/App.tsx index 574e917..b4b16de 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> } /> @@ -1107,7 +1111,8 @@ export default function App() { } /> } /> } /> - } /> + } /> + } /> } /> } /> } /> @@ -1118,7 +1123,8 @@ export default function App() { } /> } /> } /> - } /> + } /> + } /> } /> } /> } /> @@ -1279,7 +1285,7 @@ export default function App() { } /> } /> } /> - } /> + } /> } /> } /> } /> diff --git a/src/api/badgeScan.ts b/src/api/badgeScan.ts new file mode 100644 index 0000000..a4661be --- /dev/null +++ b/src/api/badgeScan.ts @@ -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 + if ('data' in o && o.data !== null && typeof o.data === 'object') return o.data + return body +} + +export async function fetchBadgeScanLogs(): Promise { + const raw = await apiFetch('/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 +} diff --git a/src/api/grading.ts b/src/api/grading.ts index 9a06649..74ccade 100644 --- a/src/api/grading.ts +++ b/src/api/grading.ts @@ -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 + 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 +} + function q(sp: URLSearchParams): string { const s = sp.toString() return s ? `?${s}` : '' } export async function fetchGradingMain(searchParams: URLSearchParams): Promise { - 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 { - 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 { + const next = new URLSearchParams(searchParams) + next.set('class_section_id', classSectionId) + return apiFetch(`${scoreCollectionPath(scoreType)}${q(next)}`) } export async function postGradingScoreUpdate( + body: Record, +): Promise { + return apiFetch(`${BASE}/scores`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +export async function postGradingSectionScoreUpdate( scoreType: string, body: Record, ): Promise { - 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 { - return apiFetch(`${BASE}/participation${q(searchParams)}`) + return apiFetch(`/api/v1/scores/participation${q(searchParams)}`) } export async function postParticipation(body: Record): Promise { - return apiFetch(`${BASE}/participation`, { + return apiFetch('/api/v1/scores/participation', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), diff --git a/src/api/paymentManagement.ts b/src/api/paymentManagement.ts index b859c97..4248739 100644 --- a/src/api/paymentManagement.ts +++ b/src/api/paymentManagement.ts @@ -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 { 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 { @@ -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 = { diff --git a/src/api/reportsCombined.ts b/src/api/reportsCombined.ts index c0e2fbb..8628a14 100644 --- a/src/api/reportsCombined.ts +++ b/src/api/reportsCombined.ts @@ -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> + 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 + 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(`/api/v1/reports/combined${qs}`) + return apiFetch(`/api/v1/scores/predictor${qs}`) } diff --git a/src/api/session.ts b/src/api/session.ts index a278998..5dab829 100644 --- a/src/api/session.ts +++ b/src/api/session.ts @@ -22,6 +22,7 @@ import type { FamilyAdminIndexResponse, GradingOverviewResponse, IpBansResponse, + LandingPageResponse, LoginFailure, LoginResponse, LoginSuccess, @@ -120,10 +121,69 @@ export async function fetchDashboardRoute(): Promise>('/api/v1/dashboard/route') } +export async function fetchLandingAdminDashboard(kind: 'admin' | 'administrator'): Promise { + return apiFetch(`/api/v1/landing/${kind}`) +} + export async function fetchAdministratorDashboardMetrics(): Promise { return apiFetch('/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 { 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 + 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 + 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 { @@ -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(`/api/v1/attendance/admin/daily${qs}`) + const raw = await apiFetch(`/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(`/api/v1/attendance/violations/pending${qs}`) + const raw = await apiFetch(`/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(`/api/v1/attendance/violations/notified${qs}`) + const raw = await apiFetch(`/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): 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): 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(`/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), + }), }) } diff --git a/src/api/slips.ts b/src/api/slips.ts index 7de3f0b..edd5fd8 100644 --- a/src/api/slips.ts +++ b/src/api/slips.ts @@ -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 ?? [] } } diff --git a/src/api/types.ts b/src/api/types.ts index 4331875..0a34946 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -34,6 +34,18 @@ export type DashboardPayload = { } } +export type LandingPageRecord = { + role?: string | null + dashboard?: string | null + summary?: Record | null +} + +export type LandingPagePayload = { + landing?: LandingPageRecord +} + +export type LandingPageResponse = ApiEnvelope + export type NavItem = { id: number menu_parent_id: number | null @@ -182,7 +194,7 @@ export type AdministratorDailyAttendanceResponse = { grades?: Record dates_by_section?: Record date_list?: string[] - no_school_days?: Record + no_school_days?: Record total_passed_days?: number semester?: string | null school_year?: string | null diff --git a/src/api/whatsapp.ts b/src/api/whatsapp.ts index a874ff5..c3f09e3 100644 --- a/src/api/whatsapp.ts +++ b/src/api/whatsapp.ts @@ -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), diff --git a/src/lib/ciRouteLookup.ts b/src/lib/ciRouteLookup.ts index 2092efe..e73a8c8 100644 --- a/src/lib/ciRouteLookup.ts +++ b/src/lib/ciRouteLookup.ts @@ -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 = { - // 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', diff --git a/src/lib/ciSpaPaths.ts b/src/lib/ciSpaPaths.ts index 14975c7..389a823 100644 --- a/src/lib/ciSpaPaths.ts +++ b/src/lib/ciSpaPaths.ts @@ -67,6 +67,7 @@ export function spaPathFromCiUrl(url: string | null | undefined): string | null } const shortcuts: Record = { + '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', diff --git a/src/pages/AdminDailyAttendance.css b/src/pages/AdminDailyAttendance.css new file mode 100644 index 0000000..f7eab0c --- /dev/null +++ b/src/pages/AdminDailyAttendance.css @@ -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; +} diff --git a/src/pages/AdminProgressPages.tsx b/src/pages/AdminProgressPages.tsx index 83baf3f..8537b2b 100644 --- a/src/pages/AdminProgressPages.tsx +++ b/src/pages/AdminProgressPages.tsx @@ -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([]) const [error, setError] = useState(null) + const [search, setSearch] = useState('') + const [sortKey, setSortKey] = useState>('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(() => + 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 ( + toggleSort(col)} + > + {label} {active ? (sortDir === 'asc' ? '▲' : '▼') : '⇅'} + + ) + } + return ( {error ?
{error}
: null} + +
+ {displayed.length} record{displayed.length !== 1 ? 's' : ''} + setSearch(e.target.value)} + /> +
+
- - - - - + + + + + - {groups.flatMap((group) => - (group.contacts ?? []).map((contact) => ( - - - - - - + {displayed.length === 0 ? ( + + + + ) : ( + displayed.map((row) => ( + + + + + + - )), + )) )} - {groups.length === 0 ? ( - - - - ) : null}
Parent NameStudentsContact NameRelationPhoneActions
{group.parent_name ?? '—'} - {(group.students ?? []) - .map((student) => `${student.firstname ?? ''} ${student.lastname ?? ''}`.trim()) - .join(', ') || '—'} - {contact.name ?? '—'}{contact.relation ?? '—'}{contact.cellphone || group.parent_phones?.join(' / ') || 'N/A'}
No emergency contacts found.
{row.parentName || '—'}{row.students || '—'}{row.contactName || '—'}{row.relation || '—'}{row.phone || 'N/A'} Edit
- No emergency contacts found. -
diff --git a/src/pages/AdministratorToolPages.tsx b/src/pages/AdministratorToolPages.tsx index 9825984..f0d6dfb 100644 --- a/src/pages/AdministratorToolPages.tsx +++ b/src/pages/AdministratorToolPages.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from 'react' import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom' +import './AdminDailyAttendance.css' import { ApiHttpError } from '../api/http' import { assignTeacherClass, @@ -19,6 +20,7 @@ import { fetchAdministratorCalendarEvents, fetchAdministratorDailyAttendance, fetchBroadcastEmailOptions, + fetchClassAssignmentOverview, fetchClassSections, fetchExamDraftAdminData, fetchFamilyAdminIndex, @@ -51,12 +53,17 @@ import { setStudentActive, unbanIp, uploadLegacyExamDraft, + updateAdministratorAttendance, updateAdministratorCalendarEvent, updateClassSection, updateStudentProfile, updateSubjectCurriculum, } from '../api/session' import type { + AdministratorDailyAttendanceEntry, + AdministratorDailyAttendanceSection, + AdministratorDailyAttendanceStudent, + AdministratorDailyAttendanceSummary, AdministratorDashboardSearchResponse, BroadcastEmailParentOption, ClassSectionRow, @@ -79,15 +86,19 @@ import type { function AdminParityShell({ title, children, + showTitle = true, }: { title: string children: ReactNode + showTitle?: boolean }) { return (
-
-

{title}

-
+ {showTitle ? ( +
+

{title}

+
+ ) : null} {children}
) @@ -116,6 +127,50 @@ function attendanceSearchMatchesPrefix(query: string, values: string[]) { }) } +/** MM-DD-YYYY for table headers (matches legacy attendance grid). */ +function formatAttendanceMdY(value?: string | null) { + if (!value) return '—' + const s = String(value).slice(0, 10) + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s) + if (m) return `${m[2]}-${m[3]}-${m[1]}` + return formatDate(value) +} + +function nextSundayAfter(dateStr: string): string | null { + if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) return null + const d = new Date(dateStr + 'T00:00:00') + if (isNaN(d.getTime())) return null + const daysUntil = d.getDay() === 0 ? 7 : 7 - d.getDay() + d.setDate(d.getDate() + daysUntil) + return d.toISOString().slice(0, 10) +} + +function attendanceEntryIsReported(isReported: string | null | undefined) { + const v = String(isReported ?? '') + .trim() + .toLowerCase() + return v === 'yes' || v === '1' || v === 'true' +} + +function entryToAttendanceSelectValue(entry?: { status: string; isReported?: boolean }) { + if (!entry) return '' + const s = String(entry.status ?? '').toLowerCase() + if (s === 'present') return 'present' + if (s === 'absent') return entry.isReported ? 'absent_reported' : 'absent' + if (s === 'late') return entry.isReported ? 'late_reported' : 'late' + return '' +} + +function attendanceCellSelectClass(val: string) { + const base = 'form-select form-select-sm att-cell-select' + if (val === 'present') return `${base} att-sel-present` + if (val === 'absent') return `${base} att-sel-absent` + if (val === 'absent_reported') return `${base} att-sel-absent-reported` + if (val === 'late_reported') return `${base} att-sel-late-reported` + if (val === 'late') return `${base} att-sel-late` + return `${base} att-sel-empty` +} + function monthLabel(value: string) { const date = new Date(`${value}T00:00:00`) if (Number.isNaN(date.getTime())) return value @@ -147,7 +202,7 @@ type AttendanceSectionSnapshot = { schoolId: string name: string summary: { present: number; late: number; absent: number; total: number } - entriesByDate: Record + entriesByDate: Record }> dates: string[] } @@ -165,22 +220,64 @@ type AttendanceGradeGroup = { sections: AttendanceSectionSnapshot[] } +function recordPick(map: Record | undefined, id: string): T | undefined { + if (!map) return undefined + if (map[id] !== undefined) return map[id] + const n = Number(id) + if (!Number.isNaN(n) && map[String(n)] !== undefined) return map[String(n)] + return undefined +} + function buildAttendanceSections(data: Awaited>): AttendanceSectionSnapshot[] { - const grades = data.grades ?? {} - const studentsBySection = data.students_by_section ?? {} - const attendanceData = data.attendance_data ?? {} - const attendanceRecord = data.attendance_record ?? {} - const datesBySection = data.dates_by_section ?? {} + const raw = data as unknown as Record + const grades = (data.grades ?? raw.Grades ?? raw.grade_sections ?? raw.sections) as + | Record + | AdministratorDailyAttendanceSection[] + | undefined + const gradesEntries = Array.isArray(grades) + ? grades.map((row, idx) => [String(idx), [row]] as const) + : Object.entries(grades ?? {}) + const studentsBySection = (data.students_by_section ?? raw.studentsBySection ?? {}) as Record< + string, + AdministratorDailyAttendanceStudent[] + > + const attendanceData = (data.attendance_data ?? raw.attendanceData ?? {}) as Record< + string, + Record + > + const attendanceRecord = (data.attendance_record ?? raw.attendanceRecord ?? {}) as Record< + string, + Record + > + const datesBySection = (data.dates_by_section ?? raw.datesBySection ?? {}) as Record + const globalDateList = Array.isArray(data.date_list) + ? data.date_list.filter((x): x is string => typeof x === 'string' && x.length > 0) + : Array.isArray(raw.dateList) + ? (raw.dateList as unknown[]).filter((x): x is string => typeof x === 'string' && x.length > 0) + : [] const sections: AttendanceSectionSnapshot[] = [] - for (const [classIdKey, sectionRows] of Object.entries(grades)) { + for (const [classIdKey, sectionRows] of gradesEntries) { for (const row of sectionRows ?? []) { - const sectionId = String(row.class_section_id ?? row.id ?? '') + const rowIds = row as AdministratorDailyAttendanceSection & { section_id?: number | string } + const sectionId = String(row.class_section_id ?? rowIds.section_id ?? row.id ?? '') if (!sectionId) continue - const students = (studentsBySection[sectionId] ?? []).map((student) => { + + const rowExtra = row as AdministratorDailyAttendanceSection & { + students?: AdministratorDailyAttendanceStudent[] + } + let studentSource = recordPick(studentsBySection, sectionId) + if (!studentSource?.length && Array.isArray(rowExtra.students)) { + studentSource = rowExtra.students + } + + const sectionAttendance = recordPick(attendanceData, sectionId) + const sectionSummaryMap = recordPick(attendanceRecord, sectionId) + + const students = (studentSource ?? []).map((student) => { const studentId = Number(student.id) - const entries = attendanceData[sectionId]?.[String(studentId)] ?? [] - const summary = attendanceRecord[sectionId]?.[String(studentId)] ?? {} + const entries = sectionAttendance?.[String(studentId)] ?? [] + const summary = sectionSummaryMap?.[String(studentId)] ?? {} return { id: studentId, schoolId: student.school_id ?? '—', @@ -197,6 +294,7 @@ function buildAttendanceSections(data: Awaited 0 ? perSectionDates : globalDateList + sections.push({ sectionId, classId: Number(classIdKey), className: row.class_section_name ?? `Section ${sectionId}`, students, - dates: datesBySection[sectionId] ?? [], + dates, }) } } @@ -934,6 +1035,12 @@ export function AdminCalendarEditPage() { /** School calendar admin — `GET /api/v1/settings/school-calendar/events` (JWT). SPA: `/app/administrator/events` or `calendar_view`. */ +function currentSchoolYear(): string { + const now = new Date() + const y = now.getFullYear() + return now.getMonth() >= 8 ? `${y}-${y + 1}` : `${y - 1}-${y}` +} + export function AdminCalendarViewPage() { const navigate = useNavigate() const [searchParams, setSearchParams] = useSearchParams() @@ -941,14 +1048,19 @@ export function AdminCalendarViewPage() { const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const schoolYear = searchParams.get('school_year') ?? '' - const semester = searchParams.get('semester') ?? '' + + useEffect(() => { + if (!schoolYear) { + setSearchParams(new URLSearchParams({ school_year: currentSchoolYear() }), { replace: true }) + } + }, []) async function load() { setLoading(true) try { const data = await fetchAdministratorCalendarEvents({ schoolYear: schoolYear || null, - semester: semester || null, + semester: null, }) setEvents(data.data?.events ?? []) setError(null) @@ -961,7 +1073,7 @@ export function AdminCalendarViewPage() { useEffect(() => { void load() - }, [schoolYear, semester]) + }, [schoolYear]) async function onDelete(eventId: number) { if (!window.confirm('Delete this event?')) return @@ -977,7 +1089,10 @@ export function AdminCalendarViewPage() { Browse calendar - + Add New Event @@ -986,30 +1101,23 @@ export function AdminCalendarViewPage() { className="mb-3 d-flex gap-2 justify-content-center align-items-end flex-wrap" onSubmit={(event) => { event.preventDefault() - const formData = new FormData(event.currentTarget) + const year = String(new FormData(event.currentTarget).get('school_year') ?? '').trim() const next = new URLSearchParams() - const year = String(formData.get('school_year') ?? '') - const sem = String(formData.get('semester') ?? '') if (year) next.set('school_year', year) - if (sem) next.set('semester', sem) setSearchParams(next) }} >
- - + +
-
- - -
-
+
-
@@ -1148,32 +1256,54 @@ export function AdminCalendarPage() { } export function AdminClassAssignmentPage() { - const [assignments, setAssignments] = useState([]) - const [classes, setClasses] = useState([]) + type ClassAssignmentStudent = { + 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 + } + type ClassAssignmentSortKey = 'school_id' | 'firstname' | 'lastname' | 'age' | 'gender' | 'photo_consent' + const [sections, setSections] = useState< + Array<{ + class_section_id?: number | null + class_section_name?: string | null + main_teachers?: string[] + mainTeachers?: string[] + teacher_assistants?: string[] + teacherAssistants?: string[] + students?: ClassAssignmentStudent[] + semester?: string | null + school_year?: string | null + description?: string | null + }> + >([]) const [schoolYears, setSchoolYears] = useState([]) const [selectedYear, setSelectedYear] = useState('') - const [selectedClassByStudent, setSelectedClassByStudent] = useState>({}) const [loading, setLoading] = useState(true) - const [savingStudentId, setSavingStudentId] = useState(null) const [message, setMessage] = useState(null) const [error, setError] = useState(null) + const [selectedSemester, setSelectedSemester] = useState('') + const [expandedSections, setExpandedSections] = useState>(new Set(['0'])) + const [sortConfig, setSortConfig] = useState<{ key: ClassAssignmentSortKey; direction: 'asc' | 'desc' }>({ + key: 'school_id', + direction: 'asc', + }) async function load(year?: string | null) { setLoading(true) try { - const data = await fetchStudentAssignments(year ?? null) - setAssignments(data.students ?? []) - setClasses(data.classes ?? []) + const response = await fetchClassAssignmentOverview(year ?? null, null) + const data = response.data ?? {} + const classSections = data.classSections ?? [] + setSections(classSections) setSchoolYears(data.schoolYears ?? []) - setSelectedYear(data.selectedYear ?? '') - setSelectedClassByStudent( - Object.fromEntries( - (data.students ?? []).map((row) => [ - row.student_id, - Number(row.class_section_ids?.[0] ?? 0), - ]), - ), - ) + setSelectedYear(data.selectedYear ?? data.schoolYear ?? '') + setSelectedSemester(data.selectedSemester ?? data.semester ?? '') + setExpandedSections(new Set(classSections.length > 0 ? ['0'] : [])) setError(null) } catch (e) { setError(e instanceof Error ? e.message : 'Unable to load class assignments.') @@ -1186,28 +1316,51 @@ export function AdminClassAssignmentPage() { void load(null) }, []) - async function saveStudent(studentId: number) { - const classSectionId = selectedClassByStudent[studentId] - if (!classSectionId) return - setSavingStudentId(studentId) - setMessage(null) - setError(null) - try { - const result = await assignStudentClass({ - student_id: studentId, - class_section_ids: [classSectionId], - }) - if (!result.ok) { - setError(result.message ?? 'Unable to assign class.') - } else { - setMessage('Class assignment updated.') - await load(selectedYear) + function toggleSection(key: string) { + setExpandedSections((prev) => { + const next = new Set(prev) + next.has(key) ? next.delete(key) : next.add(key) + return next + }) + } + + function requestSort(key: ClassAssignmentSortKey) { + setSortConfig((prev) => + prev.key === key + ? { key, direction: prev.direction === 'asc' ? 'desc' : 'asc' } + : { key, direction: 'asc' }, + ) + } + + function sortIndicator(key: ClassAssignmentSortKey) { + if (sortConfig.key !== key) return '↕' + return sortConfig.direction === 'asc' ? '↑' : '↓' + } + + function sortedStudents(students: ClassAssignmentStudent[]) { + return [...students].sort((left, right) => { + const dir = sortConfig.direction === 'asc' ? 1 : -1 + const leftBool = Boolean(left.photo_consent) + const rightBool = Boolean(right.photo_consent) + + switch (sortConfig.key) { + case 'age': { + const a = Number(left.age ?? 0) + const b = Number(right.age ?? 0) + return (a - b) * dir + } + case 'photo_consent': + return (Number(leftBool) - Number(rightBool)) * dir + case 'school_id': + case 'firstname': + case 'lastname': + case 'gender': { + const a = String(left[sortConfig.key] ?? '').toLowerCase() + const b = String(right[sortConfig.key] ?? '').toLowerCase() + return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }) * dir + } } - } catch (e) { - setError(e instanceof Error ? e.message : 'Unable to assign class.') - } finally { - setSavingStudentId(null) - } + }) } return ( @@ -1233,84 +1386,132 @@ export function AdminClassAssignmentPage() { onChange={(event) => setSelectedYear(event.target.value)} > {schoolYears.map((year) => ( - + ))}
- +
+ {loading ? ( +

Loading class sections…

+ ) : sections.length === 0 ? ( +

No class sections available.

+ ) : ( +
+ {sections.map((section, index) => { + const key = String(index) + const expanded = expandedSections.has(key) + const students = section.students ?? [] + const displayedStudents = sortedStudents(students) + const mainTeachers = + section.main_teachers ?? section.mainTeachers ?? [] + const teacherAssistants = + section.teacher_assistants ?? section.teacherAssistants ?? [] + return ( +
+
toggleSection(key)} + > +
+ + {expanded ? '▼' : '▶'}  Grade : {section.class_section_name ?? 'Class'} + + + Teacher: {mainTeachers.length > 0 ? mainTeachers.join(', ') : 'None'} + {' '}| TA: {teacherAssistants.length > 0 ? teacherAssistants.join(', ') : 'None'} + +
+ {students.length} +
+ {expanded && ( +
+
+ Main Teacher:{' '} + {mainTeachers.length > 0 + ? mainTeachers.join(', ') + : 'None'} +
+
+ Teacher-Assistant:{' '} + {teacherAssistants.length > 0 + ? teacherAssistants.join(', ') + : 'None'} +
+
Semester: {selectedSemester || 'N/A'}
+
School Year: {section.school_year ?? selectedYear ?? 'N/A'}
+

{section.description ?? ''}

-
- - - - - - - - - - - - - - {loading ? ( - - ) : assignments.length === 0 ? ( - - ) : ( - assignments.map((row) => ( - - - - - - - - - - )) - )} - -
StudentAgeRegistration GradeCurrent ClassAssign ClassContactSave
Loading assignments…
No students found.
{row.name}{row.age ?? '—'}{row.registration_grade ?? '—'}{row.class_section_name ?? 'No class assigned'} - - -
{row.email || '—'}
-
{row.phone || '—'}
-
- -
-
+ {students.length > 0 ? ( +
+ + + + + + + + + + + + + {displayedStudents.map((student, studentIndex) => ( + + + + + + + + + ))} + +
+ + + + + + + + + + + +
{student.school_id ?? '—'}{student.firstname ?? '—'}{student.lastname ?? '—'}{student.age ?? '—'}{student.gender ?? '—'} + + {student.photo_consent ? 'Yes' : 'No'} + +
+
+ ) : ( +

No students registered in this class section.

+ )} +
+ )} +
+ ) + })} +
+ )} ) } @@ -1509,10 +1710,20 @@ export function AdminDailyAttendancePage() { const [tableSearch, setTableSearch] = useState('') const [activeGradeKey, setActiveGradeKey] = useState('') const [activeSectionId, setActiveSectionId] = useState('') - const [termFilters, setTermFilters] = useState({ - semester: semesterParam, + const [termFilters, setTermFilters] = useState(() => ({ + semester: semesterParam.trim() === 'Spring' ? 'Spring' : 'Fall', school_year: schoolYearParam, - }) + })) + const [pageSize, setPageSize] = useState(100) + const [datePageIndex, setDatePageIndex] = useState(0) + const [busyKey, setBusyKey] = useState(null) + const [actionMessage, setActionMessage] = useState(null) + + useEffect(() => { + if (!actionMessage) return + const t = window.setTimeout(() => setActionMessage(null), 2500) + return () => window.clearTimeout(t) + }, [actionMessage]) useEffect(() => { let cancelled = false @@ -1525,6 +1736,21 @@ export function AdminDailyAttendancePage() { }) if (cancelled) return setData(result) + const apiSchoolYear = result.school_year != null ? String(result.school_year).trim() : '' + const apiSemester = result.semester != null ? String(result.semester).trim() : '' + setTermFilters((current) => ({ + school_year: schoolYearParam.trim() || current.school_year.trim() || apiSchoolYear, + semester: + semesterParam.trim() === 'Spring' + ? 'Spring' + : semesterParam.trim() === 'Fall' + ? 'Fall' + : apiSemester === 'Spring' || apiSemester === 'Fall' + ? apiSemester + : current.semester === 'Spring' + ? 'Spring' + : 'Fall', + })) const sections = buildAttendanceSections(result) const groups = buildAttendanceGradeGroups(sections) setActiveGradeKey(groups[0]?.key ?? '') @@ -1542,10 +1768,17 @@ export function AdminDailyAttendancePage() { }, [schoolYearParam, semesterParam]) useEffect(() => { - setTermFilters({ - semester: semesterParam, - school_year: schoolYearParam, - }) + setTermFilters((current) => ({ + school_year: schoolYearParam.trim() || current.school_year, + semester: + semesterParam.trim() === 'Spring' + ? 'Spring' + : semesterParam.trim() === 'Fall' + ? 'Fall' + : current.semester === 'Spring' + ? 'Spring' + : 'Fall', + })) }, [schoolYearParam, semesterParam]) const sections = useMemo(() => (data ? buildAttendanceSections(data) : []), [data]) @@ -1624,16 +1857,48 @@ export function AdminDailyAttendancePage() { student, })) }, [displayedSection, normalizedSearch, searchMatches, sectionFilter]) - const visibleDates = useMemo(() => { + const allVisibleDates = useMemo(() => { if (normalizedSearch !== '') { - const dates = new Set() + const set = new Set() for (const match of searchMatches) { - for (const date of match.section.dates) dates.add(date) + for (const date of match.section.dates) set.add(date) } - return Array.from(dates).sort() + return Array.from(set).sort() } return displayedSection?.dates ?? [] }, [displayedSection, normalizedSearch, searchMatches]) + + const allDates = useMemo(() => { + const recorded = new Set(allVisibleDates) + const semesterDates = Array.isArray(data?.date_list) ? data.date_list as string[] : [] + let coming: string[] + if (semesterDates.length > 0) { + // Show all semester Sundays not yet recorded, including no-school days (they'll be flagged visually) + coming = semesterDates.filter((d) => !recorded.has(d)) + } else { + const today = new Date().toISOString().slice(0, 10) + const anchor = allVisibleDates.length > 0 + ? allVisibleDates[allVisibleDates.length - 1] + : today + coming = [] + let cur = nextSundayAfter(anchor) + while (cur && coming.length < 20) { + if (!recorded.has(cur)) coming.push(cur) + cur = nextSundayAfter(cur) + } + } + return [...allVisibleDates, ...coming].sort() + }, [allVisibleDates, data?.date_list]) + + const noSchoolDays: Record = data?.no_school_days ?? {} + + const DATE_PAGE_SIZE = 5 + const datePageCount = Math.max(1, Math.ceil(allDates.length / DATE_PAGE_SIZE)) + const clampedDatePage = Math.min(datePageIndex, datePageCount - 1) + const visibleDates = allDates.slice( + clampedDatePage * DATE_PAGE_SIZE, + clampedDatePage * DATE_PAGE_SIZE + DATE_PAGE_SIZE, + ) const visibleStudents = useMemo(() => { return renderedStudents.filter(({ student, sectionName }) => { if (sectionFilter === '') return true @@ -1647,6 +1912,18 @@ export function AdminDailyAttendancePage() { }) }, [renderedStudents, sectionFilter]) + const tableRows = useMemo(() => visibleStudents.slice(0, pageSize), [visibleStudents, pageSize]) + const tableEmptyColSpan = 1 + (normalizedSearch !== '' ? 1 : 0) + 1 + visibleDates.length + 4 + + const schoolYearOptions = useMemo(() => { + const set = new Set() + for (const y of [termFilters.school_year, data?.school_year != null ? String(data.school_year) : '']) { + const t = String(y).trim() + if (t) set.add(t) + } + return Array.from(set).sort() + }, [termFilters.school_year, data?.school_year]) + useEffect(() => { if (!displayedGrade) return if (!gradeSections.some((section) => section.sectionId === activeSectionId)) { @@ -1654,273 +1931,384 @@ export function AdminDailyAttendancePage() { } }, [activeSectionId, displayedGrade, gradeSections]) + useEffect(() => { + setDatePageIndex(Number.MAX_SAFE_INTEGER) + }, [activeSectionId, activeGradeKey, normalizedSearch]) + + async function persistAttendanceCell( + section: AttendanceSectionSnapshot, + student: AttendanceSectionSnapshot['students'][number], + date: string, + raw: string, + ) { + const cellKey = `${section.sectionId}-${student.id}-${date}` + if (raw === '') return + let status: 'present' | 'absent' | 'late' + let is_reported: string | null = null + if (raw === 'present') status = 'present' + else if (raw === 'absent') status = 'absent' + else if (raw === 'absent_reported') { + status = 'absent' + is_reported = 'yes' + } else if (raw === 'late_reported') { + status = 'late' + is_reported = 'yes' + } else if (raw === 'late') { + status = 'late' + is_reported = 'no' + } else { + return + } + + const semesterVal = String(data?.semester ?? semesterParam ?? '').trim() + const schoolYearVal = String(data?.school_year ?? schoolYearParam ?? '').trim() + + // Optimistic update — patch local data immediately so the UI reflects the change at once + const prevData = data + setData((current) => { + if (!current) return current + const secId = String(section.sectionId) + const stuId = String(student.id) + const prevEntries: typeof current.attendance_data = JSON.parse(JSON.stringify(current.attendance_data ?? {})) + if (!prevEntries[secId]) prevEntries[secId] = {} + const existing = (prevEntries[secId][stuId] ?? []).filter((e) => String(e.date ?? '').slice(0, 10) !== date) + prevEntries[secId][stuId] = [ + ...existing, + { date, status, reason: null, is_reported: is_reported ?? 'no' }, + ] + return { ...current, attendance_data: prevEntries } + }) + + setBusyKey(cellKey) + setError(null) + try { + await updateAdministratorAttendance({ + class_section_id: Number(section.sectionId), + student_id: student.id, + school_id: student.schoolId && student.schoolId !== '—' ? student.schoolId : null, + status, + date, + class_id: section.classId, + semester: semesterVal || null, + school_year: schoolYearVal || null, + is_reported: (status === 'late' || status === 'absent') ? is_reported : null, + }) + setActionMessage('Saved.') + } catch (e) { + setData(prevData) + setError(e instanceof Error ? e.message : 'Unable to save attendance.') + } finally { + setBusyKey(null) + } + } + return ( - -
-

Attendance Management

-
+ +
+
+

Attendance Management

+
-
{ - event.preventDefault() - const next = new URLSearchParams() - if (termFilters.school_year.trim() !== '') next.set('school_year', termFilters.school_year.trim()) - if (termFilters.semester.trim() !== '') next.set('semester', termFilters.semester.trim()) - setSearchParams(next) - }} - > -
- - -
-
- - -
-
- - -
-
- -
-
-
- Search Student - setStudentSearch(event.target.value)} - /> - +
- - {studentSuggestions.map((label) => ( - - Searches all grades. - {normalizedSearch !== '' ? ( -
0 ? 'text-success' : 'text-danger'}`}> - {searchMatches.length > 0 - ? `${searchMatches.length} match${searchMatches.length === 1 ? '' : 'es'} found.${selectedSearchMatch ? ` First match: ${selectedSearchMatch.section.className}.` : ''}` - : 'No student matched the current search.'} -
- ) : null} - {normalizedSearch !== '' && searchMatches.length > 0 ? ( -
- {searchMatches.slice(0, 12).map((match) => ( - - ))} -
- ) : null} -
-
+ -
-
- +
+
+
+ Search Student + setStudentSearch(event.target.value)} + /> + +
+ Searches all grades. + {normalizedSearch !== '' ? ( +
0 ? 'text-success' : 'text-danger'}`}> + {searchMatches.length > 0 + ? `${searchMatches.length} match${searchMatches.length === 1 ? '' : 'es'} found.${ + selectedSearchMatch ? ` First match: ${selectedSearchMatch.section.className}.` : '' + }` + : 'No student matched the current search.'} +
+ ) : null} + {normalizedSearch !== '' && searchMatches.length > 0 ? ( +
+ {searchMatches.slice(0, 12).map((match) => ( + + ))} +
+ ) : null} +
+
+ +
+ Attendance Analysis
-
- {error ?
{error}
: null} - {loading ? ( -

Loading attendance…

- ) : sections.length === 0 ? ( -

No attendance sections found.

- ) : ( - <> -
    - {gradeGroups.map((group) => ( -
  • - -
  • - ))} -
-
- {gradeSections.map((section) => ( - - ))} -
- - {displayedSection ? ( - <> -
-

{normalizedSearch !== '' ? 'Search Results' : displayedSection.className}

-
- - - {visibleDates[0] ? formatDate(visibleDates[0]) : '—'} {visibleDates.length > 1 ? `– ${formatDate(visibleDates[visibleDates.length - 1])}` : ''} - -
+ + {gradeSections.length > 1 ? ( +
+ {gradeSections.map((section) => ( + -
+ ))}
+ ) : null} -
-
- Show - - entries + {displayedSection ? ( + <> +
+

+ {normalizedSearch !== '' ? 'Search Results' : displayedSection.className} +

+
+ + + {visibleDates[0] ? formatAttendanceMdY(visibleDates[0]) : '—'} + {visibleDates.length > 1 + ? ` — ${formatAttendanceMdY(visibleDates[visibleDates.length - 1])}` + : ''} + + +
-
- - setTableSearch(event.target.value)} - /> -
-
-
- - - - - {normalizedSearch !== '' ? : null} - - {visibleDates.map((date) => ( - +
+
+ Show +
- - - - - - - {visibleStudents.map(({ sectionId, sectionName, student }) => ( - - - {normalizedSearch !== '' ? : null} - + + entries + +
+ + setTableSearch(event.target.value)} + /> +
+ + +
+
School IDSectionStudent Name{formatDate(date)}PresentLateAbsentT
{student.schoolId}{sectionName}{student.name}
+ + + + {normalizedSearch !== '' ? : null} + {visibleDates.map((date) => { - const entry = student.entriesByDate[date] - const status = entry?.status ?? '' - const badgeClass = - status === 'present' - ? 'success' - : status === 'late' - ? 'warning' - : status === 'absent' - ? 'danger' - : 'secondary' - const shortLabel = - status === 'present' - ? 'P' - : status === 'late' - ? 'L' - : status === 'absent' - ? 'A' - : '—' + const noSchoolTitle = noSchoolDays[date] return ( - + ) })} - - - - + + + + - ))} - {visibleStudents.length === 0 ? ( - - - - ) : null} - -
School IDSectionStudent Name -
- -
- {shortLabel} -
-
-
+ {formatAttendanceMdY(date)} + {noSchoolTitle ?
{noSchoolTitle}
: null} +
{student.summary.present}{student.summary.late}{student.summary.absent}{student.summary.total}PLAT
- No students match the current search. -
-
- - ) : null} - - )} + + + {tableRows.map(({ sectionId, sectionName, student }) => { + const sectionSnap = sections.find((s) => s.sectionId === sectionId) + if (!sectionSnap) return null + return ( + + {student.schoolId} + {normalizedSearch !== '' ? {sectionName} : null} + {student.name} + {visibleDates.map((date) => { + const noSchoolTitle = noSchoolDays[date] + const entry = student.entriesByDate[date] + const cellVal = entryToAttendanceSelectValue(entry) + const cellBusy = busyKey === `${sectionId}-${student.id}-${date}` + return ( + + + + ) + })} + {student.summary.present} + {student.summary.late} + {student.summary.absent} + {student.summary.total} + + ) + })} + {tableRows.length === 0 ? ( + + + No students match the current search. + + + ) : null} + + +
+ + ) : null} + + )} +
) } @@ -3499,6 +3887,14 @@ export function AdminSectionsAutoDistributePage() { } export function AdminStudentClassAssignmentPage() { + type StudentAssignmentSortKey = + | 'name' + | 'age' + | 'registration_grade' + | 'new_student' + | 'assigned_classes' + | 'registration_date' + | 'school_year' const [schoolYear, setSchoolYear] = useState('') const [students, setStudents] = useState([]) const [classes, setClasses] = useState([]) @@ -3507,6 +3903,14 @@ export function AdminStudentClassAssignmentPage() { const [eventOnly, setEventOnly] = useState(false) const [loading, setLoading] = useState(true) const [message, setMessage] = useState(null) + const [tableSearch, setTableSearch] = useState('') + const [sortConfig, setSortConfig] = useState<{ + key: StudentAssignmentSortKey + direction: 'asc' | 'desc' + }>({ + key: 'name', + direction: 'asc', + }) async function load(year = schoolYear) { setLoading(true) @@ -3544,6 +3948,63 @@ export function AdminStudentClassAssignmentPage() { await load() } + function startAssign(student: StudentAssignmentRow) { + setSelectedStudentId(student.student_id) + setSelectedClassIds(student.class_section_ids ?? []) + setEventOnly(false) + window.scrollTo({ top: 0, behavior: 'smooth' }) + } + + function requestSort(key: StudentAssignmentSortKey) { + setSortConfig((prev) => + prev.key === key + ? { key, direction: prev.direction === 'asc' ? 'desc' : 'asc' } + : { key, direction: 'asc' }, + ) + } + + function sortArrow(key: StudentAssignmentSortKey) { + if (sortConfig.key !== key) return '↕' + return sortConfig.direction === 'asc' ? '↑' : '↓' + } + + const displayedStudents = useMemo(() => { + const query = tableSearch.trim().toLowerCase() + const filtered = students.filter((student) => { + if (!query) return true + const haystack = [ + student.name, + student.registration_grade, + student.new_student, + student.school_year, + student.registration_date, + ...(student.class_section_names ?? []), + ] + .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 + } + + if (sortConfig.key === 'assigned_classes') { + const a = (left.class_section_names ?? []).join(', ') + const b = (right.class_section_names ?? []).join(', ') + return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }) * direction + } + + const a = String(left[sortConfig.key] ?? '') + const b = String(right[sortConfig.key] ?? '') + return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }) * direction + }) + }, [students, tableSearch, sortConfig]) + return ( {message ?
{message}
: null} @@ -3588,18 +4049,49 @@ export function AdminStudentClassAssignmentPage() {
+
+
+ setTableSearch(event.target.value)} + placeholder="Search students or classes" + /> + +
+
- + + + + + + + + + + - {loading ? : students.length === 0 ? : students.map((student) => ( + {loading ? : displayedStudents.length === 0 ? : displayedStudents.map((student) => ( + {rows.map((st, idx) => ( { setNoteModal(st) @@ -457,6 +477,8 @@ export function ViolationsNotifiedPage() {
Student NameAgeRegistration GradeNew StudentAssigned ClassesRegistration DateSchool Year
requestSort('name')}>Student Name {sortArrow('name')} requestSort('age')}>Age {sortArrow('age')} requestSort('registration_grade')}>Registration Grade {sortArrow('registration_grade')} requestSort('new_student')}>New Student {sortArrow('new_student')}Assign Class requestSort('assigned_classes')}>Assigned Classes {sortArrow('assigned_classes')} requestSort('registration_date')}>Registration Date {sortArrow('registration_date')} requestSort('school_year')}>School Year {sortArrow('school_year')}
Loading students…
No students found.
Loading students…
No students found.
{student.name} {student.age ?? '—'} {student.registration_grade ?? '—'} {student.new_student ?? '—'} + + {student.class_section_ids && student.class_section_ids.length > 0 ? (
@@ -3626,6 +4118,17 @@ export function AdminStudentClassAssignmentPage() { } export function AdminStudentProfilesPage() { + type StudentProfileSortKey = + | 'school_id' + | 'firstname' + | 'lastname' + | 'dob' + | 'age' + | 'gender' + | 'registration_grade' + | 'is_active' + | 'photo_consent' + | 'registration_date' const [schoolYear, setSchoolYear] = useState('') const [students, setStudents] = useState([]) const [editing, setEditing] = useState(null) @@ -3633,6 +4136,14 @@ export function AdminStudentProfilesPage() { const [emergencyContacts, setEmergencyContacts] = useState>>([]) const [message, setMessage] = useState(null) const [loading, setLoading] = useState(true) + const [tableSearch, setTableSearch] = useState('') + const [sortConfig, setSortConfig] = useState<{ + key: StudentProfileSortKey + direction: 'asc' | 'desc' + }>({ + key: 'lastname', + direction: 'asc', + }) async function load(year = schoolYear) { setLoading(true) @@ -3686,20 +4197,96 @@ export function AdminStudentProfilesPage() { await load() } + function requestSort(key: StudentProfileSortKey) { + setSortConfig((prev) => + prev.key === key + ? { key, direction: prev.direction === 'asc' ? 'desc' : 'asc' } + : { key, direction: 'asc' }, + ) + } + + function sortArrow(key: StudentProfileSortKey) { + if (sortConfig.key !== key) return '↕' + return sortConfig.direction === 'asc' ? '↑' : '↓' + } + + const displayedStudents = useMemo(() => { + const query = tableSearch.trim().toLowerCase() + const filtered = students.filter((student) => { + if (!query) return true + const haystack = [ + student.school_id, + student.firstname, + student.lastname, + student.gender, + student.registration_grade, + student.dob, + student.registration_date, + Boolean(student.is_active) ? 'yes active' : 'no inactive', + Boolean(student.photo_consent) ? 'yes consent' : 'no consent', + ] + .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 + } + if (sortConfig.key === 'is_active' || sortConfig.key === 'photo_consent') { + const a = Number(Boolean(left[sortConfig.key])) + const b = Number(Boolean(right[sortConfig.key])) + 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 + }) + }, [students, tableSearch, sortConfig]) + return ( {message ?
{message}
: null} -
- setSchoolYear(e.target.value)} placeholder="School year" /> - +
+
+ setSchoolYear(e.target.value)} placeholder="School year" /> + +
+
+ setTableSearch(event.target.value)} + placeholder="Search students" + /> + +
- + + + + + + + + + + + + + - {loading ? : students.length === 0 ? : students.map((student) => ( + {loading ? : displayedStudents.length === 0 ? : displayedStudents.map((student) => ( diff --git a/src/pages/attendance/AttendanceViolationsHubPage.tsx b/src/pages/attendance/AttendanceViolationsHubPage.tsx index 7415fe2..5f7ade4 100644 --- a/src/pages/attendance/AttendanceViolationsHubPage.tsx +++ b/src/pages/attendance/AttendanceViolationsHubPage.tsx @@ -37,12 +37,16 @@ export function AttendanceViolationsHubPage() {
- - + +
- - + +
{filtered.map((st, idx) => ( - + ))}
School IDFirst NameLast NameDOBAgeGenderAssigned GradeActivePhoto ConsentRegistration DateAction
requestSort('school_id')}>School ID {sortArrow('school_id')} requestSort('firstname')}>First Name {sortArrow('firstname')} requestSort('lastname')}>Last Name {sortArrow('lastname')} requestSort('dob')}>DOB {sortArrow('dob')} requestSort('age')}>Age {sortArrow('age')} requestSort('gender')}>Gender {sortArrow('gender')} requestSort('registration_grade')}>Assigned Grade {sortArrow('registration_grade')} requestSort('is_active')}>Active {sortArrow('is_active')} requestSort('photo_consent')}>Photo Consent {sortArrow('photo_consent')} requestSort('registration_date')}>Registration Date {sortArrow('registration_date')}Action
Loading students…
No students found.
Loading students…
No students found.
{student.school_id ?? '—'} {student.firstname ?? '—'}
@@ -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() {
- - + +
- - + +