security fix and fix parent pages
This commit is contained in:
+26
-1
@@ -495,6 +495,20 @@ const InventoryMovementFormPage = lazy(() =>
|
|||||||
default: m.InventoryMovementFormPage,
|
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(() =>
|
const TeacherBookDistributePage = lazy(() =>
|
||||||
import('./pages/inventory/TeacherBookDistributePage').then((m) => ({
|
import('./pages/inventory/TeacherBookDistributePage').then((m) => ({
|
||||||
default: m.TeacherBookDistributePage,
|
default: m.TeacherBookDistributePage,
|
||||||
@@ -718,7 +732,7 @@ const FamilyComposeEmailPage = lazy(() =>
|
|||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
const FamilyLegacyImportPage = lazy(() =>
|
const FamilyLegacyImportPage = lazy(() =>
|
||||||
import('./pages/family/FamilyLegacyImportPage').then((m) => ({ default: m.FamilyLegacyImportPage })),
|
import('./pages/FamiliesImportLegacyPage').then((m) => ({ default: m.FamiliesImportLegacyPage })),
|
||||||
)
|
)
|
||||||
const EmailTemplatesIndexPage = lazy(() =>
|
const EmailTemplatesIndexPage = lazy(() =>
|
||||||
import('./pages/emails').then((m) => ({ default: m.EmailTemplatesIndexPage })),
|
import('./pages/emails').then((m) => ({ default: m.EmailTemplatesIndexPage })),
|
||||||
@@ -1237,6 +1251,16 @@ export default function App() {
|
|||||||
<Route path="grading/scores/:scoreType/:classSectionId/:studentId" element={<GradingScoreEntryPage />} />
|
<Route path="grading/scores/:scoreType/:classSectionId/:studentId" element={<GradingScoreEntryPage />} />
|
||||||
<Route path="grading/main" element={<GradingMainPage />} />
|
<Route path="grading/main" element={<GradingMainPage />} />
|
||||||
<Route path="grading" element={<GradingMainPage />} />
|
<Route path="grading" element={<GradingMainPage />} />
|
||||||
|
<Route path="administrator/inventory" element={<InventoryDashboardPage />} />
|
||||||
|
<Route path="administrator/inventory/dashboard" element={<InventoryDashboardPage />} />
|
||||||
|
<Route path="inventory" element={<InventoryDashboardPage />} />
|
||||||
|
<Route path="inventory/dashboard" element={<InventoryDashboardPage />} />
|
||||||
|
<Route path="administrator/inventory/low-stock" element={<InventoryLowStockPage />} />
|
||||||
|
<Route path="administrator/inventory/reorder-suggestions" element={<InventoryReorderSuggestionsPage />} />
|
||||||
|
<Route path="administrator/inventory/stock-status" element={<InventoryStockStatusPage />} />
|
||||||
|
<Route path="inventory/low-stock" element={<InventoryLowStockPage />} />
|
||||||
|
<Route path="inventory/reorder-suggestions" element={<InventoryReorderSuggestionsPage />} />
|
||||||
|
<Route path="inventory/stock-status" element={<InventoryStockStatusPage />} />
|
||||||
<Route path="administrator/inventory/book" element={<InventoryBookIndexPage />} />
|
<Route path="administrator/inventory/book" element={<InventoryBookIndexPage />} />
|
||||||
<Route path="administrator/inventory/book/create" element={<InventoryBookFormPage />} />
|
<Route path="administrator/inventory/book/create" element={<InventoryBookFormPage />} />
|
||||||
<Route path="administrator/inventory/book/:itemId/edit" element={<InventoryBookFormPage />} />
|
<Route path="administrator/inventory/book/:itemId/edit" element={<InventoryBookFormPage />} />
|
||||||
@@ -1278,6 +1302,7 @@ export default function App() {
|
|||||||
<Route path="notifications/active" element={<NotificationsActivePage />} />
|
<Route path="notifications/active" element={<NotificationsActivePage />} />
|
||||||
<Route path="administrator/notifications/deleted" element={<NotificationsDeletedPage />} />
|
<Route path="administrator/notifications/deleted" element={<NotificationsDeletedPage />} />
|
||||||
<Route path="notifications/deleted" element={<NotificationsDeletedPage />} />
|
<Route path="notifications/deleted" element={<NotificationsDeletedPage />} />
|
||||||
|
<Route path="administrator/tuition-forecast" element={<FinancialReportSummaryPage />} />
|
||||||
<Route path="administrator/payment/manual-pay" element={<ManualPayPage />} />
|
<Route path="administrator/payment/manual-pay" element={<ManualPayPage />} />
|
||||||
<Route
|
<Route
|
||||||
path="administrator/payment/manual-payment"
|
path="administrator/payment/manual-payment"
|
||||||
|
|||||||
+5
-5
@@ -216,7 +216,7 @@ export async function fetchBelowSixtyEmailEditor(searchParams: URLSearchParams):
|
|||||||
subject?: string
|
subject?: string
|
||||||
html?: string
|
html?: string
|
||||||
}> {
|
}> {
|
||||||
return apiFetch(`${BASE}/below-sixty/email-editor${q(searchParams)}`)
|
return apiFetch(`${BASE}/below-sixty/email${q(searchParams)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function postBelowSixtyEmail(body: Record<string, unknown>): Promise<unknown> {
|
export async function postBelowSixtyEmail(body: Record<string, unknown>): Promise<unknown> {
|
||||||
@@ -278,14 +278,14 @@ export async function postPlacementSection(body: Record<string, unknown>): Promi
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchPlacementBatch(batchId: string | number): Promise<unknown> {
|
export async function fetchPlacementBatch(batchId: string | number): Promise<unknown> {
|
||||||
return apiFetch(`${BASE}/placement-batch/${batchId}`)
|
return apiFetch(`${BASE}/placement/batch/${batchId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function postPlacementBatch(
|
export async function postPlacementBatch(
|
||||||
batchId: string | number,
|
batchId: string | number,
|
||||||
body: Record<string, unknown>,
|
body: Record<string, unknown>,
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
return apiFetch(`${BASE}/placement-batch/${batchId}`, {
|
return apiFetch(`${BASE}/placement/batch/${batchId}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
@@ -295,11 +295,11 @@ export async function postPlacementBatch(
|
|||||||
export async function fetchScheduleMeetingForm(
|
export async function fetchScheduleMeetingForm(
|
||||||
searchParams: URLSearchParams,
|
searchParams: URLSearchParams,
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
return apiFetch(`${BASE}/schedule-meeting${q(searchParams)}`)
|
return apiFetch(`${BASE}/below-sixty/meeting${q(searchParams)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function postScheduleMeeting(body: Record<string, unknown>): Promise<unknown> {
|
export async function postScheduleMeeting(body: Record<string, unknown>): Promise<unknown> {
|
||||||
return apiFetch(`${BASE}/schedule-meeting`, {
|
return apiFetch(`${BASE}/below-sixty/meeting`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
|
|||||||
+24
-15
@@ -11,6 +11,13 @@ function q(searchParams: URLSearchParams): string {
|
|||||||
return s ? `?${s}` : ''
|
return s ? `?${s}` : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function unwrapData<T>(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'
|
export type InventoryKind = 'book' | 'classroom' | 'kitchen' | 'office'
|
||||||
|
|
||||||
/** Index list: expect `{ items, categories, userNames }` (names optional). */
|
/** Index list: expect `{ items, categories, userNames }` (names optional). */
|
||||||
@@ -20,11 +27,11 @@ export async function fetchInventoryIndex(
|
|||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
const params = new URLSearchParams(searchParams)
|
const params = new URLSearchParams(searchParams)
|
||||||
params.set('type', kind)
|
params.set('type', kind)
|
||||||
return apiFetch(`${BASE}/items?${params.toString()}`)
|
return unwrapData(await apiFetch(`${BASE}/items?${params.toString()}`))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function postInventoryCategory(body: Record<string, unknown>): Promise<unknown> {
|
export async function postInventoryCategory(body: Record<string, unknown>): Promise<unknown> {
|
||||||
return apiFetch(`${BASE}/category`, {
|
return apiFetch(`${BASE}/categories`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
@@ -37,7 +44,7 @@ export async function fetchInventoryItem(itemId: string | number): Promise<{
|
|||||||
conditionOptions?: Record<string, string>
|
conditionOptions?: Record<string, string>
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}> {
|
}> {
|
||||||
return apiFetch(`${BASE}/items/${itemId}`)
|
return unwrapData(await apiFetch(`${BASE}/items/${itemId}`))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create form payload (categories, defaults). */
|
/** Create form payload (categories, defaults). */
|
||||||
@@ -45,7 +52,9 @@ export async function fetchInventoryCreateForm(
|
|||||||
kind: InventoryKind,
|
kind: InventoryKind,
|
||||||
searchParams: URLSearchParams,
|
searchParams: URLSearchParams,
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
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<string, unknown>): Promise<unknown> {
|
export async function postInventoryItem(body: Record<string, unknown>): Promise<unknown> {
|
||||||
@@ -61,7 +70,7 @@ export async function putInventoryItem(
|
|||||||
body: Record<string, unknown>,
|
body: Record<string, unknown>,
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
return apiFetch(`${BASE}/items/${itemId}`, {
|
return apiFetch(`${BASE}/items/${itemId}`, {
|
||||||
method: 'PUT',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
})
|
})
|
||||||
@@ -86,7 +95,7 @@ export async function postClassroomAudit(
|
|||||||
itemId: string | number,
|
itemId: string | number,
|
||||||
body: Record<string, unknown>,
|
body: Record<string, unknown>,
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
return apiFetch(`${BASE}/items/${itemId}/classroom-audit`, {
|
return apiFetch(`${BASE}/items/${itemId}/audit`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
@@ -98,11 +107,11 @@ export async function fetchInventorySummary(searchParams: URLSearchParams): Prom
|
|||||||
const params = new URLSearchParams(searchParams)
|
const params = new URLSearchParams(searchParams)
|
||||||
params.delete('type')
|
params.delete('type')
|
||||||
const qs = params.toString()
|
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<unknown> {
|
export async function fetchMovements(searchParams: URLSearchParams): Promise<unknown> {
|
||||||
return apiFetch(`${BASE}/movements${q(searchParams)}`)
|
return unwrapData(await apiFetch(`${BASE}/movements${q(searchParams)}`))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchMovementForm(
|
export async function fetchMovementForm(
|
||||||
@@ -110,9 +119,9 @@ export async function fetchMovementForm(
|
|||||||
searchParams: URLSearchParams,
|
searchParams: URLSearchParams,
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
if (movementId === undefined || movementId === '') {
|
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<string, unknown>): Promise<unknown> {
|
export async function postMovement(body: Record<string, unknown>): Promise<unknown> {
|
||||||
@@ -128,7 +137,7 @@ export async function putMovement(
|
|||||||
body: Record<string, unknown>,
|
body: Record<string, unknown>,
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
return apiFetch(`${BASE}/movements/${movementId}`, {
|
return apiFetch(`${BASE}/movements/${movementId}`, {
|
||||||
method: 'PUT',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
})
|
})
|
||||||
@@ -167,26 +176,26 @@ export async function postTeacherBookDistribute(body: Record<string, unknown>):
|
|||||||
|
|
||||||
/** Fetch low-stock items (quantity <= reorder_point). */
|
/** Fetch low-stock items (quantity <= reorder_point). */
|
||||||
export async function fetchLowStock(): Promise<unknown> {
|
export async function fetchLowStock(): Promise<unknown> {
|
||||||
return apiFetch(`${BASE}/low-stock`)
|
return unwrapData(await apiFetch(`${BASE}/low-stock`))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fetch reorder suggestions for low-stock items. */
|
/** Fetch reorder suggestions for low-stock items. */
|
||||||
export async function fetchReorderSuggestions(): Promise<unknown> {
|
export async function fetchReorderSuggestions(): Promise<unknown> {
|
||||||
return apiFetch(`${BASE}/reorder-suggestions`)
|
return unwrapData(await apiFetch(`${BASE}/reorder-suggestions`))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fetch stock status for all items (ok/low_stock/out_of_stock). */
|
/** Fetch stock status for all items (ok/low_stock/out_of_stock). */
|
||||||
export async function fetchStockStatus(
|
export async function fetchStockStatus(
|
||||||
searchParams: URLSearchParams,
|
searchParams: URLSearchParams,
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
return apiFetch(`${BASE}/stock-status${q(searchParams)}`)
|
return unwrapData(await apiFetch(`${BASE}/stock-status${q(searchParams)}`))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fetch inventory dashboard summary counts. */
|
/** Fetch inventory dashboard summary counts. */
|
||||||
export async function fetchInventoryDashboard(
|
export async function fetchInventoryDashboard(
|
||||||
searchParams: URLSearchParams,
|
searchParams: URLSearchParams,
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
return apiFetch(`${BASE}/dashboard${q(searchParams)}`)
|
return unwrapData(await apiFetch(`${BASE}/dashboard${q(searchParams)}`))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Void a movement (append-only correction with reverse entry). */
|
/** Void a movement (append-only correction with reverse entry). */
|
||||||
|
|||||||
@@ -6,6 +6,13 @@ import { ApiHttpError, apiFetch, applyStoredSchoolYearHeaders, getStoredToken }
|
|||||||
|
|
||||||
export type PrintRequestRow = Record<string, unknown>
|
export type PrintRequestRow = Record<string, unknown>
|
||||||
|
|
||||||
|
function unwrapData<T>(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 = {
|
export type TeacherPrintRequestsContext = {
|
||||||
sundays?: string[]
|
sundays?: string[]
|
||||||
times?: string[]
|
times?: string[]
|
||||||
@@ -61,15 +68,15 @@ export async function deletePrintRequest(requestId: number | string): Promise<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchAdminPrintRequests(): Promise<{ print_requests: PrintRequestRow[] }> {
|
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(
|
export async function adminUpdatePrintRequestStatus(
|
||||||
requestId: number | string,
|
requestId: number | string,
|
||||||
payload: { status: string; admin_id?: number | string },
|
payload: { status: string; admin_id?: number | string },
|
||||||
): Promise<{ ok?: boolean; message?: string }> {
|
): Promise<{ ok?: boolean; message?: string }> {
|
||||||
return apiFetch(`/api/v1/administrator/print-requests/${encodeURIComponent(String(requestId))}/status`, {
|
return apiFetch(`/api/v1/print-requests/${encodeURIComponent(String(requestId))}/status`, {
|
||||||
method: 'POST',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
})
|
})
|
||||||
|
|||||||
+29
-11
@@ -25,15 +25,23 @@ export async function fetchStickerFormOptions(params?: {
|
|||||||
if (params?.school_year) qs.set('school_year', params.school_year)
|
if (params?.school_year) qs.set('school_year', params.school_year)
|
||||||
if (params?.semester) qs.set('semester', params.semester)
|
if (params?.semester) qs.set('semester', params.semester)
|
||||||
const suffix = qs.toString() ? `?${qs}` : ''
|
const suffix = qs.toString() ? `?${qs}` : ''
|
||||||
return apiFetch(`/api/v1/administrator/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<StudentOption[]> {
|
export async function fetchStudentsByClass(classId: number | string): Promise<StudentOption[]> {
|
||||||
const body = await apiFetch<{ students?: StudentOption[] } | StudentOption[]>(
|
const qs = new URLSearchParams({ class_section_id: String(classId) })
|
||||||
`/api/v1/administrator/students/by-class/${encodeURIComponent(String(classId))}`,
|
const body = await apiFetch<{
|
||||||
)
|
ok?: boolean
|
||||||
if (Array.isArray(body)) return body
|
data?: { students?: StudentOption[] }
|
||||||
return body.students ?? []
|
students?: StudentOption[]
|
||||||
|
}>(`/api/v1/reports/stickers/students?${qs}`)
|
||||||
|
return body.data?.students ?? body.students ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function postStickerGenerate(formData: FormData): Promise<Blob> {
|
export async function postStickerGenerate(formData: FormData): Promise<Blob> {
|
||||||
@@ -41,7 +49,7 @@ export async function postStickerGenerate(formData: FormData): Promise<Blob> {
|
|||||||
const token = getStoredToken()
|
const token = getStoredToken()
|
||||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
applyStoredSchoolYearHeaders(headers)
|
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',
|
method: 'POST',
|
||||||
headers,
|
headers,
|
||||||
body: formData,
|
body: formData,
|
||||||
@@ -91,7 +99,7 @@ export async function fetchStickerPreviewCounts(classId?: number | string | null
|
|||||||
const suffix = qs.toString() ? `?${qs}` : ''
|
const suffix = qs.toString() ? `?${qs}` : ''
|
||||||
const body = await apiFetch<
|
const body = await apiFetch<
|
||||||
StickerPreviewCounts & { status?: string; data?: StickerPreviewCounts }
|
StickerPreviewCounts & { status?: string; data?: StickerPreviewCounts }
|
||||||
>(`/api/v1/administrator/printables/stickers/preview${suffix}`)
|
>(`/api/v1/reports/stickers/preview${suffix}`)
|
||||||
if (body.data) return body.data
|
if (body.data) return body.data
|
||||||
return body
|
return body
|
||||||
}
|
}
|
||||||
@@ -172,7 +180,8 @@ export async function fetchReportCardMeta(params?: {
|
|||||||
if (params?.school_year) qs.set('school_year', params.school_year)
|
if (params?.school_year) qs.set('school_year', params.school_year)
|
||||||
if (params?.semester) qs.set('semester', params.semester)
|
if (params?.semester) qs.set('semester', params.semester)
|
||||||
const suffix = qs.toString() ? `?${qs}` : ''
|
const suffix = qs.toString() ? `?${qs}` : ''
|
||||||
return apiFetch(`/api/v1/administrator/printables/report-card/meta${suffix}`)
|
const body = await apiFetch<ReportCardMeta & { data?: ReportCardMeta }>(`/api/v1/administrator/printables/report-card/meta${suffix}`)
|
||||||
|
return body.data ?? body
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchReportCardAck(params: {
|
export async function fetchReportCardAck(params: {
|
||||||
@@ -184,9 +193,11 @@ export async function fetchReportCardAck(params: {
|
|||||||
qs.set('student_id', String(params.student_id))
|
qs.set('student_id', String(params.student_id))
|
||||||
if (params.school_year) qs.set('school_year', params.school_year)
|
if (params.school_year) qs.set('school_year', params.school_year)
|
||||||
if (params.semester) qs.set('semester', params.semester ?? '')
|
if (params.semester) qs.set('semester', params.semester ?? '')
|
||||||
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: {
|
export async function fetchReportCardCompleteness(params: {
|
||||||
class_section_id: number | string
|
class_section_id: number | string
|
||||||
school_year?: string
|
school_year?: string
|
||||||
@@ -201,9 +212,16 @@ export async function fetchReportCardCompleteness(params: {
|
|||||||
qs.set('class_section_id', String(params.class_section_id))
|
qs.set('class_section_id', String(params.class_section_id))
|
||||||
if (params.school_year) qs.set('school_year', params.school_year ?? '')
|
if (params.school_year) qs.set('school_year', params.school_year ?? '')
|
||||||
if (params.semester) qs.set('semester', params.semester ?? '')
|
if (params.semester) qs.set('semester', params.semester ?? '')
|
||||||
return apiFetch(`/api/v1/administrator/printables/report-card/completeness?${qs}`)
|
const body = await apiFetch<{
|
||||||
|
data?: { students?: Array<Record<string, unknown>>; summary?: Record<string, unknown>; used_fallback?: boolean }
|
||||||
|
students?: Array<Record<string, unknown>>
|
||||||
|
summary?: Record<string, unknown>
|
||||||
|
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). */
|
/** Build absolute API URL for iframe / window.open (report PDF HTML). */
|
||||||
export function reportCardStudentUrl(
|
export function reportCardStudentUrl(
|
||||||
studentId: number | string,
|
studentId: number | string,
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export async function downloadReimbursementsExport(params?: {
|
|||||||
|
|
||||||
export async function downloadBatchCsv(batchId: number | string): Promise<void> {
|
export async function downloadBatchCsv(batchId: number | string): Promise<void> {
|
||||||
const qs = new URLSearchParams({ batch_id: String(batchId) })
|
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 headers = new Headers({ Accept: 'text/csv,*/*' })
|
||||||
const token = getStoredToken()
|
const token = getStoredToken()
|
||||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
@@ -83,7 +83,7 @@ export async function postBatchEmail(formData: FormData): Promise<{ success?: bo
|
|||||||
const token = getStoredToken()
|
const token = getStoredToken()
|
||||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
applyStoredSchoolYearHeaders(headers)
|
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)
|
const body: unknown = await res.json().catch(() => null)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const msg =
|
const msg =
|
||||||
@@ -114,7 +114,7 @@ export async function createReimbursementBatch(): Promise<{
|
|||||||
csrf_hash?: string
|
csrf_hash?: string
|
||||||
}> {
|
}> {
|
||||||
const fd = new FormData()
|
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<string, unknown>> {
|
async function postFormExpectJson(path: string, form: FormData): Promise<{ success?: boolean; error?: string; csrf_hash?: string; reimbursement_id?: number } & Record<string, unknown>> {
|
||||||
@@ -135,15 +135,15 @@ async function postFormExpectJson(path: string, form: FormData): Promise<{ succe
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function postBatchUpdate(form: FormData) {
|
export function postBatchUpdate(form: FormData) {
|
||||||
return postFormExpectJson(`${BASE}/batch/update`, form)
|
return postFormExpectJson(`${BASE}/batches/assign`, form)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function postBatchLock(form: FormData) {
|
export function postBatchLock(form: FormData) {
|
||||||
return postFormExpectJson(`${BASE}/batch/lock`, form)
|
return postFormExpectJson(`${BASE}/batches/lock`, form)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function postAdminCheckUpload(form: FormData) {
|
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) {
|
export function postMarkDonation(form: FormData) {
|
||||||
|
|||||||
+14
-2
@@ -32,6 +32,7 @@ import type {
|
|||||||
NavMenuResponse,
|
NavMenuResponse,
|
||||||
ParentAttendanceResponse,
|
ParentAttendanceResponse,
|
||||||
ParentAttendanceReportFormResponse,
|
ParentAttendanceReportFormResponse,
|
||||||
|
ParentEnrollmentActionResponse,
|
||||||
ParentEnrollmentsResponse,
|
ParentEnrollmentsResponse,
|
||||||
ParentEventsOverviewResponse,
|
ParentEventsOverviewResponse,
|
||||||
ParentInvoicesResponse,
|
ParentInvoicesResponse,
|
||||||
@@ -1784,7 +1785,7 @@ export async function fetchPaypalTransactions(params?: {
|
|||||||
if (params?.q) query.set('q', params.q)
|
if (params?.q) query.set('q', params.q)
|
||||||
if (params?.perPage) query.set('per_page', String(params.perPage))
|
if (params?.perPage) query.set('per_page', String(params.perPage))
|
||||||
const qs = query.size > 0 ? `?${query.toString()}` : ''
|
const qs = query.size > 0 ? `?${query.toString()}` : ''
|
||||||
return apiFetch<PaypalTransactionsResponse>(`/api/v1/paypal-transactions${qs}`)
|
return apiFetch<PaypalTransactionsResponse>(`/api/v1/administrator/paypal-transactions${qs}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchPaypalTransactionsCsv(params?: { q?: string }): Promise<Blob> {
|
export async function fetchPaypalTransactionsCsv(params?: { q?: string }): Promise<Blob> {
|
||||||
@@ -1795,7 +1796,7 @@ export async function fetchPaypalTransactionsCsv(params?: { q?: string }): Promi
|
|||||||
const query = new URLSearchParams()
|
const query = new URLSearchParams()
|
||||||
if (params?.q) query.set('q', params.q)
|
if (params?.q) query.set('q', params.q)
|
||||||
const suffix = query.size > 0 ? `?${query.toString()}` : ''
|
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}`)
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
return res.blob()
|
return res.blob()
|
||||||
}
|
}
|
||||||
@@ -1858,6 +1859,17 @@ export async function fetchParentEnrollments(
|
|||||||
return apiFetch<ParentEnrollmentsResponse>(`/api/v1/parents/enrollments${q}`)
|
return apiFetch<ParentEnrollmentsResponse>(`/api/v1/parents/enrollments${q}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateParentEnrollments(payload: {
|
||||||
|
enroll: number[]
|
||||||
|
withdraw: number[]
|
||||||
|
}): Promise<ParentEnrollmentActionResponse> {
|
||||||
|
return apiFetch<ParentEnrollmentActionResponse>('/api/v1/parents/enrollments', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchParentInvoices(
|
export async function fetchParentInvoices(
|
||||||
schoolYear: string | null,
|
schoolYear: string | null,
|
||||||
): Promise<ParentInvoicesResponse> {
|
): Promise<ParentInvoicesResponse> {
|
||||||
|
|||||||
+10
-3
@@ -6,6 +6,13 @@ import { apiFetch } from './http'
|
|||||||
|
|
||||||
const BASE = '/api/v1/administrator/staff'
|
const BASE = '/api/v1/administrator/staff'
|
||||||
|
|
||||||
|
function unwrapData<T>(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 = {
|
export type StaffListRow = {
|
||||||
id: number
|
id: number
|
||||||
user_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?.school_year) qs.set('school_year', params.school_year)
|
||||||
if (params?.semester) qs.set('semester', params.semester)
|
if (params?.semester) qs.set('semester', params.semester)
|
||||||
const suffix = qs.toString() ? `?${qs}` : ''
|
const suffix = qs.toString() ? `?${qs}` : ''
|
||||||
return apiFetch(`${BASE}${suffix}`)
|
return unwrapData(await apiFetch(`${BASE}${suffix}`))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchStaffFormOptions(): Promise<StaffFormOptionsResponse> {
|
export async function fetchStaffFormOptions(): Promise<StaffFormOptionsResponse> {
|
||||||
return apiFetch(`${BASE}/form-options`)
|
return unwrapData(await apiFetch(`${BASE}/form-options`))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchStaffMember(id: number): Promise<StaffMemberResponse> {
|
export async function fetchStaffMember(id: number): Promise<StaffMemberResponse> {
|
||||||
return apiFetch(`${BASE}/${id}`)
|
return unwrapData(await apiFetch(`${BASE}/${id}`))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createStaff(payload: {
|
export async function createStaff(payload: {
|
||||||
|
|||||||
@@ -377,6 +377,19 @@ export type ParentEnrollmentsResponse = {
|
|||||||
students: ParentEnrollmentStudent[]
|
students: ParentEnrollmentStudent[]
|
||||||
schoolYears: { school_year: string }[]
|
schoolYears: { school_year: string }[]
|
||||||
selectedYear: string | null
|
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 = {
|
export type ContactSubmitResponse = {
|
||||||
|
|||||||
+55
-9
@@ -6,6 +6,13 @@ import { apiFetch } from './http'
|
|||||||
|
|
||||||
const BASE = '/api/v1/whatsapp'
|
const BASE = '/api/v1/whatsapp'
|
||||||
|
|
||||||
|
function unwrapData<T>(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 = {
|
export type WhatsappSectionRow = {
|
||||||
class_section_id?: number
|
class_section_id?: number
|
||||||
id?: number
|
id?: number
|
||||||
@@ -16,6 +23,9 @@ export type WhatsappSectionRow = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type WhatsappLinkRow = {
|
export type WhatsappLinkRow = {
|
||||||
|
id?: number
|
||||||
|
class_section_id?: number
|
||||||
|
class_section_name?: string
|
||||||
invite_link?: string | null
|
invite_link?: string | null
|
||||||
active?: number | boolean
|
active?: number | boolean
|
||||||
}
|
}
|
||||||
@@ -79,8 +89,29 @@ export async function fetchWhatsappManageLinks(params?: {
|
|||||||
const qs = new URLSearchParams()
|
const qs = new URLSearchParams()
|
||||||
if (params?.school_year) qs.set('school_year', params.school_year)
|
if (params?.school_year) qs.set('school_year', params.school_year)
|
||||||
if (params?.semester) qs.set('semester', params.semester)
|
if (params?.semester) qs.set('semester', params.semester)
|
||||||
|
if (!qs.has('per_page')) qs.set('per_page', '200')
|
||||||
const suffix = qs.toString() ? `?${qs}` : ''
|
const suffix = qs.toString() ? `?${qs}` : ''
|
||||||
return apiFetch(`${BASE}/links${suffix}`)
|
const body = await apiFetch<WhatsappManageLinksResponse & {
|
||||||
|
data?: WhatsappManageLinksResponse & { links?: WhatsappLinkRow[] }
|
||||||
|
links?: WhatsappLinkRow[]
|
||||||
|
class_sections?: ParentContactsByClassSection[]
|
||||||
|
}>(`${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: {
|
export async function saveWhatsappLink(payload: {
|
||||||
@@ -89,11 +120,11 @@ export async function saveWhatsappLink(payload: {
|
|||||||
invite_link: string
|
invite_link: string
|
||||||
active?: boolean
|
active?: boolean
|
||||||
}): Promise<{ ok?: boolean; message?: string }> {
|
}): Promise<{ ok?: boolean; message?: string }> {
|
||||||
return apiFetch(`${BASE}/links`, {
|
return unwrapData(await apiFetch(`${BASE}/links`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
})
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendWhatsappInvites(payload: {
|
export async function sendWhatsappInvites(payload: {
|
||||||
@@ -101,11 +132,11 @@ export async function sendWhatsappInvites(payload: {
|
|||||||
class_section_id?: number | null
|
class_section_id?: number | null
|
||||||
parent_ids?: number[]
|
parent_ids?: number[]
|
||||||
}): Promise<{ ok?: boolean; message?: string }> {
|
}): Promise<{ ok?: boolean; message?: string }> {
|
||||||
return apiFetch(`${BASE}/invites/send`, {
|
return unwrapData(await apiFetch(`${BASE}/invites/send`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
})
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchWhatsappParentContacts(params?: {
|
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?.school_year) qs.set('school_year', params.school_year)
|
||||||
if (params?.semester) qs.set('semester', params.semester)
|
if (params?.semester) qs.set('semester', params.semester)
|
||||||
const suffix = qs.toString() ? `?${qs}` : ''
|
const suffix = qs.toString() ? `?${qs}` : ''
|
||||||
return apiFetch(`${BASE}/parent-contacts${suffix}`)
|
const body = await apiFetch<WhatsappParentContactsResponse & { data?: WhatsappParentContactsResponse }>(`${BASE}/parent-contacts${suffix}`)
|
||||||
|
return unwrapData(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchWhatsappParentContactsByClass(): Promise<WhatsappParentContactsByClassResponse> {
|
export async function fetchWhatsappParentContactsByClass(params?: {
|
||||||
return apiFetch(`${BASE}/parent-contacts-by-class`)
|
school_year?: string
|
||||||
|
semester?: string
|
||||||
|
}): Promise<WhatsappParentContactsByClassResponse> {
|
||||||
|
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: {
|
export async function updateWhatsappMembership(payload: {
|
||||||
@@ -132,9 +177,10 @@ export async function updateWhatsappMembership(payload: {
|
|||||||
primary_member?: string
|
primary_member?: string
|
||||||
second_member?: string
|
second_member?: string
|
||||||
}): Promise<{ status?: string; message?: string }> {
|
}): Promise<{ status?: string; message?: string }> {
|
||||||
return apiFetch(`${BASE}/membership`, {
|
const body = await apiFetch<{ status?: boolean; message?: string; data?: unknown }>(`${BASE}/membership`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
})
|
})
|
||||||
|
return { status: body.status ? 'ok' : undefined, message: body.message }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState, type FormEvent } from 'react'
|
import { useEffect, useMemo, useState, type FormEvent } from 'react'
|
||||||
import { ParentParityShell } from './ParentParityShell'
|
import { ParentParityShell } from './ParentParityShell'
|
||||||
import {
|
import {
|
||||||
fetchAttendanceReportForm,
|
fetchAttendanceReportForm,
|
||||||
@@ -7,9 +7,10 @@ import {
|
|||||||
fetchParentInvoices,
|
fetchParentInvoices,
|
||||||
fetchParentProfile,
|
fetchParentProfile,
|
||||||
fetchParentRegistrationOverview,
|
fetchParentRegistrationOverview,
|
||||||
submitParentAttendanceReport,
|
submitParentAttendanceReport,
|
||||||
submitContactMessage,
|
submitContactMessage,
|
||||||
} from '../../api/session'
|
updateParentEnrollments,
|
||||||
|
} from '../../api/session'
|
||||||
import type {
|
import type {
|
||||||
ParentAttendanceReportFormResponse,
|
ParentAttendanceReportFormResponse,
|
||||||
ParentEnrollmentStudent,
|
ParentEnrollmentStudent,
|
||||||
@@ -149,87 +150,335 @@ export function ParentContactPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `Views/parent/enroll_classes.php` — GET/POST /api/v1/parents/enrollments */
|
/** `Views/parent/enroll_classes.php` — GET/POST /api/v1/parents/enrollments */
|
||||||
export function ParentEnrollClassesPage() {
|
export function ParentEnrollClassesPage() {
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [message, setMessage] = useState<string | null>(null)
|
||||||
const [schoolYears, setSchoolYears] = useState<{ school_year: string }[]>([])
|
const [loading, setLoading] = useState(true)
|
||||||
const [year, setYear] = useState<string | null>(null)
|
const [saving, setSaving] = useState(false)
|
||||||
const [students, setStudents] = useState<ParentEnrollmentStudent[]>([])
|
const [schoolYears, setSchoolYears] = useState<{ school_year: string }[]>([])
|
||||||
|
const [year, setYear] = useState<string | null>(null)
|
||||||
useEffect(() => {
|
const [dates, setDates] = useState<{
|
||||||
let cancelled = false
|
withdrawalDeadline?: string | null
|
||||||
;(async () => {
|
lastDayOfRegistration?: string | null
|
||||||
setLoading(true)
|
schoolStartDate?: string | null
|
||||||
try {
|
}>({})
|
||||||
const data = await fetchParentEnrollments(null)
|
const [students, setStudents] = useState<ParentEnrollmentStudent[]>([])
|
||||||
if (cancelled) return
|
const [actions, setActions] = useState<Record<number, 'enroll' | 'withdraw'>>({})
|
||||||
setSchoolYears(data.schoolYears ?? [])
|
|
||||||
setYear(data.selectedYear ?? null)
|
useEffect(() => {
|
||||||
setStudents(data.students ?? [])
|
let cancelled = false
|
||||||
setError(null)
|
;(async () => {
|
||||||
} catch (e) {
|
setLoading(true)
|
||||||
if (!cancelled) setError(e instanceof Error ? e.message : 'Failed to load enrollments.')
|
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 {
|
} finally {
|
||||||
if (!cancelled) setLoading(false)
|
if (!cancelled) setLoading(false)
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
const counts = useMemo(() => {
|
||||||
<ParentParityShell
|
let enrollable = 0
|
||||||
title="Enroll in classes"
|
let withdrawable = 0
|
||||||
ciViewFile="enroll_classes.php"
|
for (const student of students) {
|
||||||
legacyCiRoutes={['/parent/enroll_classes']}
|
if (canEnroll(student)) enrollable += 1
|
||||||
>
|
if (canWithdraw(student)) withdrawable += 1
|
||||||
{error ? <div className="alert alert-warning small">{error}</div> : null}
|
}
|
||||||
{loading ? (
|
return { enrollable, withdrawable }
|
||||||
<p className="text-muted small">Loading…</p>
|
}, [students])
|
||||||
) : (
|
|
||||||
<>
|
async function loadYear(nextYear: string | null) {
|
||||||
<p className="small text-muted mb-2">
|
setLoading(true)
|
||||||
School year: <code>{year ?? '—'}</code>. Updates use{' '}
|
setError(null)
|
||||||
<code>POST /api/v1/parents/enrollments</code> with enroll / withdraw payloads.
|
setMessage(null)
|
||||||
</p>
|
try {
|
||||||
<div className="table-responsive">
|
const data = await fetchParentEnrollments(nextYear)
|
||||||
<table className="table table-sm table-bordered">
|
setSchoolYears(data.schoolYears ?? [])
|
||||||
<thead>
|
setYear(data.selectedYear ?? nextYear)
|
||||||
<tr>
|
setDates({
|
||||||
<th>Student</th>
|
withdrawalDeadline: data.withdrawalDeadline,
|
||||||
<th>Class</th>
|
lastDayOfRegistration: data.lastDayOfRegistration,
|
||||||
</tr>
|
schoolStartDate: data.schoolStartDate,
|
||||||
</thead>
|
})
|
||||||
<tbody>
|
setStudents(data.students ?? [])
|
||||||
{students.length === 0 ? (
|
setActions({})
|
||||||
<tr>
|
} catch (e) {
|
||||||
<td colSpan={2} className="text-muted">
|
setError(e instanceof Error ? e.message : 'Failed to load enrollments.')
|
||||||
No students for this overview.
|
} finally {
|
||||||
</td>
|
setLoading(false)
|
||||||
</tr>
|
}
|
||||||
) : (
|
}
|
||||||
students.map((s) => (
|
|
||||||
<tr key={s.id}>
|
function setAction(studentId: number, action: 'enroll' | 'withdraw' | null) {
|
||||||
<td>
|
setActions((current) => {
|
||||||
{s.firstname} {s.lastname}
|
const next = { ...current }
|
||||||
</td>
|
if (action) next[studentId] = action
|
||||||
<td>{s.class_section ?? '—'}</td>
|
else delete next[studentId]
|
||||||
</tr>
|
return next
|
||||||
))
|
})
|
||||||
)}
|
}
|
||||||
</tbody>
|
|
||||||
</table>
|
async function onSubmit(e: FormEvent) {
|
||||||
</div>
|
e.preventDefault()
|
||||||
{schoolYears.length > 1 ? (
|
setSaving(true)
|
||||||
<p className="small mb-0">Additional years in response: {schoolYears.length}</p>
|
setError(null)
|
||||||
) : null}
|
setMessage(null)
|
||||||
</>
|
try {
|
||||||
)}
|
const enroll = Object.entries(actions)
|
||||||
</ParentParityShell>
|
.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 (
|
||||||
|
<ParentParityShell
|
||||||
|
title="Enroll in classes"
|
||||||
|
ciViewFile="enroll_classes.php"
|
||||||
|
legacyCiRoutes={['/parent/enroll_classes']}
|
||||||
|
>
|
||||||
|
{message ? <div className="alert alert-success small">{message}</div> : null}
|
||||||
|
{error ? <div className="alert alert-warning small">{error}</div> : null}
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-muted small">Loading…</p>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={onSubmit}>
|
||||||
|
<div className="row g-3 align-items-end mb-3">
|
||||||
|
<div className="col-md-4">
|
||||||
|
<label className="form-label" htmlFor="enrollment_school_year">
|
||||||
|
School Year
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="enrollment_school_year"
|
||||||
|
className="form-select"
|
||||||
|
value={year ?? ''}
|
||||||
|
onChange={(e) => void loadYear(e.target.value || null)}
|
||||||
|
>
|
||||||
|
{year && !schoolYears.some((item) => item.school_year === year) ? (
|
||||||
|
<option value={year}>{year}</option>
|
||||||
|
) : null}
|
||||||
|
{schoolYears.map((item) => (
|
||||||
|
<option key={item.school_year} value={item.school_year}>
|
||||||
|
{item.school_year}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-8">
|
||||||
|
<div className="d-flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline-success"
|
||||||
|
onClick={() => {
|
||||||
|
const next: Record<number, 'enroll' | 'withdraw'> = { ...actions }
|
||||||
|
students.forEach((student) => {
|
||||||
|
if (canEnroll(student)) next[student.id] = 'enroll'
|
||||||
|
})
|
||||||
|
setActions(next)
|
||||||
|
}}
|
||||||
|
disabled={counts.enrollable === 0 || saving}
|
||||||
|
>
|
||||||
|
Enroll all available
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline-danger"
|
||||||
|
onClick={() => {
|
||||||
|
const next: Record<number, 'enroll' | 'withdraw'> = { ...actions }
|
||||||
|
students.forEach((student) => {
|
||||||
|
if (canWithdraw(student)) next[student.id] = 'withdraw'
|
||||||
|
})
|
||||||
|
setActions(next)
|
||||||
|
}}
|
||||||
|
disabled={counts.withdrawable === 0 || saving}
|
||||||
|
>
|
||||||
|
Withdraw all eligible
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline-secondary"
|
||||||
|
onClick={() => setActions({})}
|
||||||
|
disabled={Object.keys(actions).length === 0 || saving}
|
||||||
|
>
|
||||||
|
Reset choices
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row g-3 mb-3">
|
||||||
|
<div className="col-md-4">
|
||||||
|
<div className="border rounded p-3 h-100">
|
||||||
|
<div className="small text-muted">Last day of registration</div>
|
||||||
|
<div className="fw-semibold">{formatDate(dates.lastDayOfRegistration)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-4">
|
||||||
|
<div className="border rounded p-3 h-100">
|
||||||
|
<div className="small text-muted">Withdrawal deadline</div>
|
||||||
|
<div className="fw-semibold">{formatDate(dates.withdrawalDeadline)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-4">
|
||||||
|
<div className="border rounded p-3 h-100">
|
||||||
|
<div className="small text-muted">School start date</div>
|
||||||
|
<div className="fw-semibold">{formatDate(dates.schoolStartDate)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-sm table-bordered align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Student</th>
|
||||||
|
<th>Class</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th className="text-center">Enroll</th>
|
||||||
|
<th className="text-center">Withdraw</th>
|
||||||
|
<th>Choice</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{students.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="text-muted">
|
||||||
|
No students for this overview.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
students.map((student) => {
|
||||||
|
const selected = actions[student.id] ?? null
|
||||||
|
const enrollEnabled = canEnroll(student)
|
||||||
|
const withdrawEnabled = canWithdraw(student)
|
||||||
|
return (
|
||||||
|
<tr key={student.id}>
|
||||||
|
<td>
|
||||||
|
<div className="fw-semibold">
|
||||||
|
{student.firstname} {student.lastname}
|
||||||
|
</div>
|
||||||
|
<div className="small text-muted">
|
||||||
|
{student.registration_grade ? `Grade ${student.registration_grade}` : 'Grade not set'}
|
||||||
|
{student.gender ? ` · ${student.gender}` : ''}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{student.class_section ?? 'Class not Assigned'}</td>
|
||||||
|
<td>{statusBadge(student.enrollment_status ?? student.admission_status)}</td>
|
||||||
|
<td className="text-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-sm ${selected === 'enroll' ? 'btn-success' : 'btn-outline-success'}`}
|
||||||
|
onClick={() => setAction(student.id, selected === 'enroll' ? null : 'enroll')}
|
||||||
|
disabled={!enrollEnabled || saving}
|
||||||
|
>
|
||||||
|
Enroll
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="text-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-sm ${selected === 'withdraw' ? 'btn-danger' : 'btn-outline-danger'}`}
|
||||||
|
onClick={() => setAction(student.id, selected === 'withdraw' ? null : 'withdraw')}
|
||||||
|
disabled={!withdrawEnabled || saving}
|
||||||
|
>
|
||||||
|
Withdraw
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td>{choiceBadge(selected)}</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="d-flex flex-wrap gap-2 justify-content-end mt-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline-secondary"
|
||||||
|
onClick={() => void loadYear(year)}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn btn-success" disabled={saving || Object.keys(actions).length === 0}>
|
||||||
|
{saving ? 'Saving…' : 'Submit changes'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</ParentParityShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <span className={`badge ${className}`}>{value}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
function choiceBadge(action: 'enroll' | 'withdraw' | null) {
|
||||||
|
if (action === 'enroll') return <span className="badge bg-success">Enroll requested</span>
|
||||||
|
if (action === 'withdraw') return <span className="badge bg-danger">Withdraw requested</span>
|
||||||
|
return <span className="text-muted small">No change</span>
|
||||||
|
}
|
||||||
|
|
||||||
/** `Views/parent/payment_view.php` — GET /api/v1/parents/invoices */
|
/** `Views/parent/payment_view.php` — GET /api/v1/parents/invoices */
|
||||||
export function ParentPaymentInvoicesPage() {
|
export function ParentPaymentInvoicesPage() {
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ export function AdminPrintRequestsPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-link btn-sm p-0"
|
className="btn btn-link btn-sm p-0"
|
||||||
onClick={() => openPrintRequestFile(encodeURIComponent(fp))}
|
onClick={() => openPrintRequestFile(id as number | string)}
|
||||||
>
|
>
|
||||||
{fp}
|
{fp}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user