diff --git a/src/App.tsx b/src/App.tsx index 98ffeb9..097593f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -495,6 +495,20 @@ const InventoryMovementFormPage = lazy(() => default: m.InventoryMovementFormPage, })), ) +const InventoryDashboardPage = lazy(() => + import('./pages/inventory/InventoryDashboardPage').then((m) => ({ default: m.InventoryDashboardPage })), +) +const InventoryLowStockPage = lazy(() => + import('./pages/inventory/InventoryLowStockPage').then((m) => ({ default: m.InventoryLowStockPage })), +) +const InventoryReorderSuggestionsPage = lazy(() => + import('./pages/inventory/InventoryReorderSuggestionsPage').then((m) => ({ + default: m.InventoryReorderSuggestionsPage, + })), +) +const InventoryStockStatusPage = lazy(() => + import('./pages/inventory/InventoryStockStatusPage').then((m) => ({ default: m.InventoryStockStatusPage })), +) const TeacherBookDistributePage = lazy(() => import('./pages/inventory/TeacherBookDistributePage').then((m) => ({ default: m.TeacherBookDistributePage, @@ -718,7 +732,7 @@ const FamilyComposeEmailPage = lazy(() => })), ) const FamilyLegacyImportPage = lazy(() => - import('./pages/family/FamilyLegacyImportPage').then((m) => ({ default: m.FamilyLegacyImportPage })), + import('./pages/FamiliesImportLegacyPage').then((m) => ({ default: m.FamiliesImportLegacyPage })), ) const EmailTemplatesIndexPage = lazy(() => import('./pages/emails').then((m) => ({ default: m.EmailTemplatesIndexPage })), @@ -1237,6 +1251,16 @@ export default function App() { } /> } /> } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> } /> } /> @@ -1278,6 +1302,7 @@ export default function App() { } /> } /> } /> + } /> } /> { - return apiFetch(`${BASE}/below-sixty/email-editor${q(searchParams)}`) + return apiFetch(`${BASE}/below-sixty/email${q(searchParams)}`) } export async function postBelowSixtyEmail(body: Record): Promise { @@ -278,14 +278,14 @@ export async function postPlacementSection(body: Record): Promi } export async function fetchPlacementBatch(batchId: string | number): Promise { - return apiFetch(`${BASE}/placement-batch/${batchId}`) + return apiFetch(`${BASE}/placement/batch/${batchId}`) } export async function postPlacementBatch( batchId: string | number, body: Record, ): Promise { - return apiFetch(`${BASE}/placement-batch/${batchId}`, { + return apiFetch(`${BASE}/placement/batch/${batchId}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), @@ -295,11 +295,11 @@ export async function postPlacementBatch( export async function fetchScheduleMeetingForm( searchParams: URLSearchParams, ): Promise { - return apiFetch(`${BASE}/schedule-meeting${q(searchParams)}`) + return apiFetch(`${BASE}/below-sixty/meeting${q(searchParams)}`) } export async function postScheduleMeeting(body: Record): Promise { - return apiFetch(`${BASE}/schedule-meeting`, { + return apiFetch(`${BASE}/below-sixty/meeting`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), diff --git a/src/api/inventory.ts b/src/api/inventory.ts index 7db169d..1cab01b 100644 --- a/src/api/inventory.ts +++ b/src/api/inventory.ts @@ -11,6 +11,13 @@ function q(searchParams: URLSearchParams): string { return s ? `?${s}` : '' } +function unwrapData(body: T | { data?: T }): T { + if (body && typeof body === 'object' && 'data' in body && (body as { data?: T }).data != null) { + return (body as { data?: T }).data as T + } + return body as T +} + export type InventoryKind = 'book' | 'classroom' | 'kitchen' | 'office' /** Index list: expect `{ items, categories, userNames }` (names optional). */ @@ -20,11 +27,11 @@ export async function fetchInventoryIndex( ): Promise { const params = new URLSearchParams(searchParams) params.set('type', kind) - return apiFetch(`${BASE}/items?${params.toString()}`) + return unwrapData(await apiFetch(`${BASE}/items?${params.toString()}`)) } export async function postInventoryCategory(body: Record): Promise { - return apiFetch(`${BASE}/category`, { + return apiFetch(`${BASE}/categories`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), @@ -37,7 +44,7 @@ export async function fetchInventoryItem(itemId: string | number): Promise<{ conditionOptions?: Record [key: string]: unknown }> { - return apiFetch(`${BASE}/items/${itemId}`) + return unwrapData(await apiFetch(`${BASE}/items/${itemId}`)) } /** Create form payload (categories, defaults). */ @@ -45,7 +52,9 @@ export async function fetchInventoryCreateForm( kind: InventoryKind, searchParams: URLSearchParams, ): Promise { - return apiFetch(`${BASE}/${kind}/create${q(searchParams)}`) + const params = new URLSearchParams(searchParams) + params.set('type', kind) + return unwrapData(await apiFetch(`${BASE}/items?${params.toString()}`)) } export async function postInventoryItem(body: Record): Promise { @@ -61,7 +70,7 @@ export async function putInventoryItem( body: Record, ): Promise { return apiFetch(`${BASE}/items/${itemId}`, { - method: 'PUT', + method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) @@ -86,7 +95,7 @@ export async function postClassroomAudit( itemId: string | number, body: Record, ): Promise { - return apiFetch(`${BASE}/items/${itemId}/classroom-audit`, { + return apiFetch(`${BASE}/items/${itemId}/audit`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), @@ -98,11 +107,11 @@ export async function fetchInventorySummary(searchParams: URLSearchParams): Prom const params = new URLSearchParams(searchParams) params.delete('type') const qs = params.toString() - return apiFetch(`${BASE}/summary/${type}${qs ? `?${qs}` : ''}`) + return unwrapData(await apiFetch(`${BASE}/summary/${type}${qs ? `?${qs}` : ''}`)) } export async function fetchMovements(searchParams: URLSearchParams): Promise { - return apiFetch(`${BASE}/movements${q(searchParams)}`) + return unwrapData(await apiFetch(`${BASE}/movements${q(searchParams)}`)) } export async function fetchMovementForm( @@ -110,9 +119,9 @@ export async function fetchMovementForm( searchParams: URLSearchParams, ): Promise { if (movementId === undefined || movementId === '') { - return apiFetch(`${BASE}/movements/create${q(searchParams)}`) + return unwrapData(await apiFetch(`${BASE}/movements${q(searchParams)}`)) } - return apiFetch(`${BASE}/movements/${movementId}/edit${q(searchParams)}`) + return unwrapData(await apiFetch(`${BASE}/movements/${movementId}/edit${q(searchParams)}`)) } export async function postMovement(body: Record): Promise { @@ -128,7 +137,7 @@ export async function putMovement( body: Record, ): Promise { return apiFetch(`${BASE}/movements/${movementId}`, { - method: 'PUT', + method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) @@ -167,26 +176,26 @@ export async function postTeacherBookDistribute(body: Record): /** Fetch low-stock items (quantity <= reorder_point). */ export async function fetchLowStock(): Promise { - return apiFetch(`${BASE}/low-stock`) + return unwrapData(await apiFetch(`${BASE}/low-stock`)) } /** Fetch reorder suggestions for low-stock items. */ export async function fetchReorderSuggestions(): Promise { - return apiFetch(`${BASE}/reorder-suggestions`) + return unwrapData(await apiFetch(`${BASE}/reorder-suggestions`)) } /** Fetch stock status for all items (ok/low_stock/out_of_stock). */ export async function fetchStockStatus( searchParams: URLSearchParams, ): Promise { - return apiFetch(`${BASE}/stock-status${q(searchParams)}`) + return unwrapData(await apiFetch(`${BASE}/stock-status${q(searchParams)}`)) } /** Fetch inventory dashboard summary counts. */ export async function fetchInventoryDashboard( searchParams: URLSearchParams, ): Promise { - return apiFetch(`${BASE}/dashboard${q(searchParams)}`) + return unwrapData(await apiFetch(`${BASE}/dashboard${q(searchParams)}`)) } /** Void a movement (append-only correction with reverse entry). */ diff --git a/src/api/printRequests.ts b/src/api/printRequests.ts index 996871a..68c4f9e 100644 --- a/src/api/printRequests.ts +++ b/src/api/printRequests.ts @@ -6,6 +6,13 @@ import { ApiHttpError, apiFetch, applyStoredSchoolYearHeaders, getStoredToken } export type PrintRequestRow = Record +function unwrapData(body: T | { data?: T }): T { + if (body && typeof body === 'object' && 'data' in body && (body as { data?: T }).data != null) { + return (body as { data?: T }).data as T + } + return body as T +} + export type TeacherPrintRequestsContext = { sundays?: string[] times?: string[] @@ -61,15 +68,15 @@ export async function deletePrintRequest(requestId: number | string): Promise<{ } export async function fetchAdminPrintRequests(): Promise<{ print_requests: PrintRequestRow[] }> { - return apiFetch('/api/v1/administrator/print-requests') + return unwrapData(await apiFetch('/api/v1/print-requests/admin')) } export async function adminUpdatePrintRequestStatus( requestId: number | string, payload: { status: string; admin_id?: number | string }, ): Promise<{ ok?: boolean; message?: string }> { - return apiFetch(`/api/v1/administrator/print-requests/${encodeURIComponent(String(requestId))}/status`, { - method: 'POST', + return apiFetch(`/api/v1/print-requests/${encodeURIComponent(String(requestId))}/status`, { + method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }) diff --git a/src/api/printables.ts b/src/api/printables.ts index 7c77629..3099c9b 100644 --- a/src/api/printables.ts +++ b/src/api/printables.ts @@ -25,15 +25,23 @@ export async function fetchStickerFormOptions(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/printables/stickers/form-options${suffix}`) + const body = await apiFetch<{ + ok?: boolean + data?: { classes?: ClassOption[]; students?: StudentOption[] } + classes?: ClassOption[] + students?: StudentOption[] + }>(`/api/v1/reports/stickers/form-data${suffix}`) + return body.data ?? body } export async function fetchStudentsByClass(classId: number | string): Promise { - const body = await apiFetch<{ students?: StudentOption[] } | StudentOption[]>( - `/api/v1/administrator/students/by-class/${encodeURIComponent(String(classId))}`, - ) - if (Array.isArray(body)) return body - return body.students ?? [] + const qs = new URLSearchParams({ class_section_id: String(classId) }) + const body = await apiFetch<{ + ok?: boolean + data?: { students?: StudentOption[] } + students?: StudentOption[] + }>(`/api/v1/reports/stickers/students?${qs}`) + return body.data?.students ?? body.students ?? [] } export async function postStickerGenerate(formData: FormData): Promise { @@ -41,7 +49,7 @@ export async function postStickerGenerate(formData: FormData): Promise { const token = getStoredToken() if (token) headers.set('Authorization', `Bearer ${token}`) applyStoredSchoolYearHeaders(headers) - const res = await fetch(apiUrl('/api/v1/administrator/printables/stickers/generate'), { + const res = await fetch(apiUrl('/api/v1/reports/stickers/print'), { method: 'POST', headers, body: formData, @@ -91,7 +99,7 @@ export async function fetchStickerPreviewCounts(classId?: number | string | null const suffix = qs.toString() ? `?${qs}` : '' const body = await apiFetch< StickerPreviewCounts & { status?: string; data?: StickerPreviewCounts } - >(`/api/v1/administrator/printables/stickers/preview${suffix}`) + >(`/api/v1/reports/stickers/preview${suffix}`) if (body.data) return body.data return body } @@ -172,7 +180,8 @@ export async function fetchReportCardMeta(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/printables/report-card/meta${suffix}`) + const body = await apiFetch(`/api/v1/administrator/printables/report-card/meta${suffix}`) + return body.data ?? body } export async function fetchReportCardAck(params: { @@ -184,9 +193,11 @@ export async function fetchReportCardAck(params: { qs.set('student_id', String(params.student_id)) if (params.school_year) qs.set('school_year', params.school_year) if (params.semester) qs.set('semester', params.semester ?? '') - return apiFetch(`/api/v1/administrator/printables/report-card/ack?${qs}`) + const body = await apiFetch<{ data?: { viewed_at?: string; signed_at?: string; signed_name?: string }; viewed_at?: string; signed_at?: string; signed_name?: string }>(`/api/v1/administrator/printables/report-card/ack?${qs}`) + return body.data ?? body } + export async function fetchReportCardCompleteness(params: { class_section_id: number | string school_year?: string @@ -201,9 +212,16 @@ export async function fetchReportCardCompleteness(params: { qs.set('class_section_id', String(params.class_section_id)) if (params.school_year) qs.set('school_year', params.school_year ?? '') if (params.semester) qs.set('semester', params.semester ?? '') - return apiFetch(`/api/v1/administrator/printables/report-card/completeness?${qs}`) + const body = await apiFetch<{ + data?: { students?: Array>; summary?: Record; used_fallback?: boolean } + students?: Array> + summary?: Record + used_fallback?: boolean + }>(`/api/v1/administrator/printables/report-card/completeness?${qs}`) + return body.data ?? body } + /** Build absolute API URL for iframe / window.open (report PDF HTML). */ export function reportCardStudentUrl( studentId: number | string, diff --git a/src/api/reimbursements.ts b/src/api/reimbursements.ts index e062b7d..7ee8cbd 100644 --- a/src/api/reimbursements.ts +++ b/src/api/reimbursements.ts @@ -67,7 +67,7 @@ export async function downloadReimbursementsExport(params?: { export async function downloadBatchCsv(batchId: number | string): Promise { const qs = new URLSearchParams({ batch_id: String(batchId) }) - const url = apiUrl(`${BASE}/batch/export?${qs}`) + const url = apiUrl(`${BASE}/batches/export?${qs}`) const headers = new Headers({ Accept: 'text/csv,*/*' }) const token = getStoredToken() if (token) headers.set('Authorization', `Bearer ${token}`) @@ -83,7 +83,7 @@ export async function postBatchEmail(formData: FormData): Promise<{ success?: bo const token = getStoredToken() if (token) headers.set('Authorization', `Bearer ${token}`) applyStoredSchoolYearHeaders(headers) - const res = await fetch(apiUrl(`${BASE}/batch/send`), { method: 'POST', headers, body: formData }) + const res = await fetch(apiUrl(`${BASE}/batches/email`), { method: 'POST', headers, body: formData }) const body: unknown = await res.json().catch(() => null) if (!res.ok) { const msg = @@ -114,7 +114,7 @@ export async function createReimbursementBatch(): Promise<{ csrf_hash?: string }> { const fd = new FormData() - return postFormExpectJson(`${BASE}/batch/create`, fd) + return postFormExpectJson(`${BASE}/batches`, fd) } async function postFormExpectJson(path: string, form: FormData): Promise<{ success?: boolean; error?: string; csrf_hash?: string; reimbursement_id?: number } & Record> { @@ -135,15 +135,15 @@ async function postFormExpectJson(path: string, form: FormData): Promise<{ succe } export function postBatchUpdate(form: FormData) { - return postFormExpectJson(`${BASE}/batch/update`, form) + return postFormExpectJson(`${BASE}/batches/assign`, form) } export function postBatchLock(form: FormData) { - return postFormExpectJson(`${BASE}/batch/lock`, form) + return postFormExpectJson(`${BASE}/batches/lock`, form) } export function postAdminCheckUpload(form: FormData) { - return postFormExpectJson(`${BASE}/batch/admin-file/upload`, form) + return postFormExpectJson(`${BASE}/batches/admin-files`, form) } export function postMarkDonation(form: FormData) { diff --git a/src/api/session.ts b/src/api/session.ts index d67de19..0419253 100644 --- a/src/api/session.ts +++ b/src/api/session.ts @@ -32,6 +32,7 @@ import type { NavMenuResponse, ParentAttendanceResponse, ParentAttendanceReportFormResponse, + ParentEnrollmentActionResponse, ParentEnrollmentsResponse, ParentEventsOverviewResponse, ParentInvoicesResponse, @@ -1784,7 +1785,7 @@ export async function fetchPaypalTransactions(params?: { if (params?.q) query.set('q', params.q) if (params?.perPage) query.set('per_page', String(params.perPage)) const qs = query.size > 0 ? `?${query.toString()}` : '' - return apiFetch(`/api/v1/paypal-transactions${qs}`) + return apiFetch(`/api/v1/administrator/paypal-transactions${qs}`) } export async function fetchPaypalTransactionsCsv(params?: { q?: string }): Promise { @@ -1795,7 +1796,7 @@ export async function fetchPaypalTransactionsCsv(params?: { q?: string }): Promi const query = new URLSearchParams() if (params?.q) query.set('q', params.q) const suffix = query.size > 0 ? `?${query.toString()}` : '' - const res = await fetch(apiUrl(`/api/v1/paypal-transactions/csv${suffix}`), { headers }) + const res = await fetch(apiUrl(`/api/v1/administrator/paypal-transactions/csv${suffix}`), { headers }) if (!res.ok) throw new Error(`HTTP ${res.status}`) return res.blob() } @@ -1858,6 +1859,17 @@ export async function fetchParentEnrollments( return apiFetch(`/api/v1/parents/enrollments${q}`) } +export async function updateParentEnrollments(payload: { + enroll: number[] + withdraw: number[] +}): Promise { + return apiFetch('/api/v1/parents/enrollments', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) +} + export async function fetchParentInvoices( schoolYear: string | null, ): Promise { diff --git a/src/api/staff.ts b/src/api/staff.ts index 5826e64..2f73b29 100644 --- a/src/api/staff.ts +++ b/src/api/staff.ts @@ -6,6 +6,13 @@ import { apiFetch } from './http' const BASE = '/api/v1/administrator/staff' +function unwrapData(body: T | { data?: T }): T { + if (body && typeof body === 'object' && 'data' in body && (body as { data?: T }).data != null) { + return (body as { data?: T }).data as T + } + return body as T +} + export type StaffListRow = { id: number user_id?: number @@ -57,15 +64,15 @@ export async function fetchStaffIndex(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}${suffix}`) + return unwrapData(await apiFetch(`${BASE}${suffix}`)) } export async function fetchStaffFormOptions(): Promise { - return apiFetch(`${BASE}/form-options`) + return unwrapData(await apiFetch(`${BASE}/form-options`)) } export async function fetchStaffMember(id: number): Promise { - return apiFetch(`${BASE}/${id}`) + return unwrapData(await apiFetch(`${BASE}/${id}`)) } export async function createStaff(payload: { diff --git a/src/api/types.ts b/src/api/types.ts index c9192cd..ee2efd6 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -377,6 +377,19 @@ export type ParentEnrollmentsResponse = { students: ParentEnrollmentStudent[] schoolYears: { school_year: string }[] selectedYear: string | null + withdrawalDeadline?: string | null + lastDayOfRegistration?: string | null + schoolStartDate?: string | null +} + +export type ParentEnrollmentActionResponse = { + ok: true + enrolled: number[] + withdrawn: number[] + data?: { + enrolled?: number[] + withdrawn?: number[] + } } export type ContactSubmitResponse = { diff --git a/src/api/whatsapp.ts b/src/api/whatsapp.ts index c3f09e3..148cf8c 100644 --- a/src/api/whatsapp.ts +++ b/src/api/whatsapp.ts @@ -6,6 +6,13 @@ import { apiFetch } from './http' const BASE = '/api/v1/whatsapp' +function unwrapData(body: T | { data?: T }): T { + if (body && typeof body === 'object' && 'data' in body && (body as { data?: T }).data != null) { + return (body as { data?: T }).data as T + } + return body as T +} + export type WhatsappSectionRow = { class_section_id?: number id?: number @@ -16,6 +23,9 @@ export type WhatsappSectionRow = { } export type WhatsappLinkRow = { + id?: number + class_section_id?: number + class_section_name?: string invite_link?: string | null active?: number | boolean } @@ -79,8 +89,29 @@ export async function fetchWhatsappManageLinks(params?: { const qs = new URLSearchParams() if (params?.school_year) qs.set('school_year', params.school_year) if (params?.semester) qs.set('semester', params.semester) + if (!qs.has('per_page')) qs.set('per_page', '200') const suffix = qs.toString() ? `?${qs}` : '' - return apiFetch(`${BASE}/links${suffix}`) + const body = await apiFetch(`${BASE}/links${suffix}`) + const data = unwrapData(body) as WhatsappManageLinksResponse & { + links?: WhatsappLinkRow[] + class_sections?: ParentContactsByClassSection[] + } + + if (!data.sections && data.class_sections) data.sections = data.class_sections + + if (!data.linksBySection && Array.isArray(data.links)) { + data.linksBySection = Object.fromEntries( + data.links + .filter((link) => link.class_section_id != null) + .map((link) => [String(link.class_section_id), link]), + ) + } + + return data } export async function saveWhatsappLink(payload: { @@ -89,11 +120,11 @@ export async function saveWhatsappLink(payload: { invite_link: string active?: boolean }): Promise<{ ok?: boolean; message?: string }> { - return apiFetch(`${BASE}/links`, { + return unwrapData(await apiFetch(`${BASE}/links`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), - }) + })) } export async function sendWhatsappInvites(payload: { @@ -101,11 +132,11 @@ export async function sendWhatsappInvites(payload: { class_section_id?: number | null parent_ids?: number[] }): Promise<{ ok?: boolean; message?: string }> { - return apiFetch(`${BASE}/invites/send`, { + return unwrapData(await apiFetch(`${BASE}/invites/send`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), - }) + })) } export async function fetchWhatsappParentContacts(params?: { @@ -116,11 +147,25 @@ export async function fetchWhatsappParentContacts(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}/parent-contacts${suffix}`) + const body = await apiFetch(`${BASE}/parent-contacts${suffix}`) + return unwrapData(body) } -export async function fetchWhatsappParentContactsByClass(): Promise { - return apiFetch(`${BASE}/parent-contacts-by-class`) +export async function fetchWhatsappParentContactsByClass(params?: { + school_year?: string + semester?: string +}): Promise { + const qs = new URLSearchParams() + if (params?.school_year) qs.set('school_year', params.school_year) + if (params?.semester) qs.set('semester', params.semester) + const suffix = qs.toString() ? `?${qs}` : '' + const body = await apiFetch<{ + data?: { class_sections?: ParentContactsByClassSection[]; classSections?: ParentContactsByClassSection[] } + class_sections?: ParentContactsByClassSection[] + classSections?: ParentContactsByClassSection[] + }>(`${BASE}/parent-contacts-by-class${suffix}`) + const data = unwrapData(body) + return { classSections: data.classSections ?? data.class_sections ?? [] } } export async function updateWhatsappMembership(payload: { @@ -132,9 +177,10 @@ export async function updateWhatsappMembership(payload: { primary_member?: string second_member?: string }): Promise<{ status?: string; message?: string }> { - return apiFetch(`${BASE}/membership`, { + const body = await apiFetch<{ status?: boolean; message?: string; data?: unknown }>(`${BASE}/membership`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }) + return { status: body.status ? 'ok' : undefined, message: body.message } } diff --git a/src/pages/parent/WiredParentScreens.tsx b/src/pages/parent/WiredParentScreens.tsx index 1bb7e67..f283f13 100644 --- a/src/pages/parent/WiredParentScreens.tsx +++ b/src/pages/parent/WiredParentScreens.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, type FormEvent } from 'react' +import { useEffect, useMemo, useState, type FormEvent } from 'react' import { ParentParityShell } from './ParentParityShell' import { fetchAttendanceReportForm, @@ -7,9 +7,10 @@ import { fetchParentInvoices, fetchParentProfile, fetchParentRegistrationOverview, - submitParentAttendanceReport, - submitContactMessage, -} from '../../api/session' + submitParentAttendanceReport, + submitContactMessage, + updateParentEnrollments, +} from '../../api/session' import type { ParentAttendanceReportFormResponse, ParentEnrollmentStudent, @@ -149,87 +150,335 @@ export function ParentContactPage() { ) } -/** `Views/parent/enroll_classes.php` — GET/POST /api/v1/parents/enrollments */ -export function ParentEnrollClassesPage() { - const [error, setError] = useState(null) - const [loading, setLoading] = useState(true) - const [schoolYears, setSchoolYears] = useState<{ school_year: string }[]>([]) - const [year, setYear] = useState(null) - const [students, setStudents] = useState([]) - - useEffect(() => { - let cancelled = false - ;(async () => { - setLoading(true) - try { - const data = await fetchParentEnrollments(null) - if (cancelled) return - setSchoolYears(data.schoolYears ?? []) - setYear(data.selectedYear ?? null) - setStudents(data.students ?? []) - setError(null) - } catch (e) { - if (!cancelled) setError(e instanceof Error ? e.message : 'Failed to load enrollments.') +/** `Views/parent/enroll_classes.php` — GET/POST /api/v1/parents/enrollments */ +export function ParentEnrollClassesPage() { + const [error, setError] = useState(null) + const [message, setMessage] = useState(null) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [schoolYears, setSchoolYears] = useState<{ school_year: string }[]>([]) + const [year, setYear] = useState(null) + const [dates, setDates] = useState<{ + withdrawalDeadline?: string | null + lastDayOfRegistration?: string | null + schoolStartDate?: string | null + }>({}) + const [students, setStudents] = useState([]) + const [actions, setActions] = useState>({}) + + useEffect(() => { + let cancelled = false + ;(async () => { + setLoading(true) + try { + const requestedYear = new URLSearchParams(window.location.search).get('school_year') + const data = await fetchParentEnrollments(requestedYear) + if (cancelled) return + setSchoolYears(data.schoolYears ?? []) + setYear(data.selectedYear ?? null) + setDates({ + withdrawalDeadline: data.withdrawalDeadline, + lastDayOfRegistration: data.lastDayOfRegistration, + schoolStartDate: data.schoolStartDate, + }) + setStudents(data.students ?? []) + setActions({}) + setError(null) + } catch (e) { + if (!cancelled) setError(e instanceof Error ? e.message : 'Failed to load enrollments.') } finally { if (!cancelled) setLoading(false) } })() return () => { cancelled = true - } - }, []) - - return ( - - {error ?
{error}
: null} - {loading ? ( -

Loading…

- ) : ( - <> -

- School year: {year ?? '—'}. Updates use{' '} - POST /api/v1/parents/enrollments with enroll / withdraw payloads. -

-
- - - - - - - - - {students.length === 0 ? ( - - - - ) : ( - students.map((s) => ( - - - - - )) - )} - -
StudentClass
- No students for this overview. -
- {s.firstname} {s.lastname} - {s.class_section ?? '—'}
-
- {schoolYears.length > 1 ? ( -

Additional years in response: {schoolYears.length}

- ) : null} - - )} -
- ) -} + } + }, []) + + const counts = useMemo(() => { + let enrollable = 0 + let withdrawable = 0 + for (const student of students) { + if (canEnroll(student)) enrollable += 1 + if (canWithdraw(student)) withdrawable += 1 + } + return { enrollable, withdrawable } + }, [students]) + + async function loadYear(nextYear: string | null) { + setLoading(true) + setError(null) + setMessage(null) + try { + const data = await fetchParentEnrollments(nextYear) + setSchoolYears(data.schoolYears ?? []) + setYear(data.selectedYear ?? nextYear) + setDates({ + withdrawalDeadline: data.withdrawalDeadline, + lastDayOfRegistration: data.lastDayOfRegistration, + schoolStartDate: data.schoolStartDate, + }) + setStudents(data.students ?? []) + setActions({}) + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to load enrollments.') + } finally { + setLoading(false) + } + } + + function setAction(studentId: number, action: 'enroll' | 'withdraw' | null) { + setActions((current) => { + const next = { ...current } + if (action) next[studentId] = action + else delete next[studentId] + return next + }) + } + + async function onSubmit(e: FormEvent) { + e.preventDefault() + setSaving(true) + setError(null) + setMessage(null) + try { + const enroll = Object.entries(actions) + .filter(([, action]) => action === 'enroll') + .map(([id]) => Number(id)) + const withdraw = Object.entries(actions) + .filter(([, action]) => action === 'withdraw') + .map(([id]) => Number(id)) + if (enroll.length === 0 && withdraw.length === 0) { + setError('Choose at least one enrollment or withdrawal action.') + return + } + const result = await updateParentEnrollments({ enroll, withdraw }) + const enrolledCount = result.enrolled?.length ?? 0 + const withdrawnCount = result.withdrawn?.length ?? 0 + await loadYear(year) + setMessage(`Saved ${enrolledCount} enrollment request(s) and ${withdrawnCount} withdrawal request(s).`) + } catch (e) { + setError(e instanceof Error ? e.message : 'Could not save enrollment changes.') + } finally { + setSaving(false) + } + } + + return ( + + {message ?
{message}
: null} + {error ?
{error}
: null} + {loading ? ( +

Loading…

+ ) : ( +
+
+
+ + +
+
+
+ + + +
+
+
+ +
+
+
+
Last day of registration
+
{formatDate(dates.lastDayOfRegistration)}
+
+
+
+
+
Withdrawal deadline
+
{formatDate(dates.withdrawalDeadline)}
+
+
+
+
+
School start date
+
{formatDate(dates.schoolStartDate)}
+
+
+
+ +
+ + + + + + + + + + + + + {students.length === 0 ? ( + + + + ) : ( + students.map((student) => { + const selected = actions[student.id] ?? null + const enrollEnabled = canEnroll(student) + const withdrawEnabled = canWithdraw(student) + return ( + + + + + + + + + ) + }) + )} + +
StudentClassStatusEnrollWithdrawChoice
+ No students for this overview. +
+
+ {student.firstname} {student.lastname} +
+
+ {student.registration_grade ? `Grade ${student.registration_grade}` : 'Grade not set'} + {student.gender ? ` · ${student.gender}` : ''} +
+
{student.class_section ?? 'Class not Assigned'}{statusBadge(student.enrollment_status ?? student.admission_status)} + + + + {choiceBadge(selected)}
+
+
+ + +
+
+ )} +
+ ) +} + +function normalizedStatus(student: ParentEnrollmentStudent): string { + return String(student.enrollment_status ?? student.admission_status ?? 'not enrolled').trim().toLowerCase() +} + +function canEnroll(student: ParentEnrollmentStudent): boolean { + if (student.disable_enroll) return false + return ['not enrolled', 'withdrawn', 'refund pending'].includes(normalizedStatus(student)) +} + +function canWithdraw(student: ParentEnrollmentStudent): boolean { + return ['admission under review', 'payment pending', 'enrolled', 'waitlist'].includes(normalizedStatus(student)) +} + +function formatDate(value?: string | null): string { + if (!value) return 'Not set' + return value +} + +function statusBadge(status?: string | null) { + const value = String(status ?? 'not enrolled').trim() || 'not enrolled' + const normalized = value.toLowerCase() + let className = 'bg-secondary' + if (normalized === 'enrolled') className = 'bg-success' + else if (normalized === 'payment pending') className = 'bg-info text-dark' + else if (normalized === 'admission under review' || normalized === 'waitlist') className = 'bg-warning text-dark' + else if (normalized === 'withdraw under review' || normalized === 'refund pending') className = 'bg-warning text-dark' + else if (normalized === 'withdrawn' || normalized === 'denied') className = 'bg-danger' + return {value} +} + +function choiceBadge(action: 'enroll' | 'withdraw' | null) { + if (action === 'enroll') return Enroll requested + if (action === 'withdraw') return Withdraw requested + return No change +} /** `Views/parent/payment_view.php` — GET /api/v1/parents/invoices */ export function ParentPaymentInvoicesPage() { diff --git a/src/pages/printRequests/AdminPrintRequestsPage.tsx b/src/pages/printRequests/AdminPrintRequestsPage.tsx index 757cd4c..7a7afad 100644 --- a/src/pages/printRequests/AdminPrintRequestsPage.tsx +++ b/src/pages/printRequests/AdminPrintRequestsPage.tsx @@ -112,7 +112,7 @@ export function AdminPrintRequestsPage() {