From b895c06dc146da0b124000a40f41fb735ea9bba9 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 9 Jul 2026 13:56:26 -0400 Subject: [PATCH] fix parent pages and teachers and admin --- src/App.tsx | 6 - src/api/printables.ts | 79 +++- src/api/schoolYears.ts | 2 +- src/api/session.ts | 8 + src/api/types.ts | 14 + src/layout/MainLayout.tsx | 2 - .../SchoolYearsManagementPage.tsx | 158 ++++++- .../classProgress/ClassProgressListPage.tsx | 37 +- src/pages/parent/ParentStatementPage.tsx | 111 ----- src/pages/parent/WiredParentScreens.tsx | 446 ++++++++++++++---- src/pages/payment/ManualPayPage.tsx | 44 +- .../printables/ReportCardManagementPage.tsx | 27 +- .../TeacherClassProgressHistoryPage.tsx | 34 +- .../TeacherClassProgressSubmitPage.tsx | 96 +--- src/pages/teacher/TeacherRestPages.tsx | 34 +- 15 files changed, 726 insertions(+), 372 deletions(-) delete mode 100644 src/pages/parent/ParentStatementPage.tsx diff --git a/src/App.tsx b/src/App.tsx index 738911f..2a574e6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1003,11 +1003,6 @@ const ParentPaymentInvoicesPage = lazy(() => default: m.ParentPaymentInvoicesPage, })), ) -const ParentStatementPage = lazy(() => - import('./pages/parent/ParentStatementPage').then((m) => ({ - default: m.ParentStatementPage, - })), -) const ParentRegisterStudentPage = lazy(() => import('./pages/parent/WiredParentScreens').then((m) => ({ default: m.ParentRegisterStudentPage, @@ -1539,7 +1534,6 @@ export default function App() { } /> } /> } /> - } /> } /> } /> } /> diff --git a/src/api/printables.ts b/src/api/printables.ts index 3099c9b..85ff36a 100644 --- a/src/api/printables.ts +++ b/src/api/printables.ts @@ -222,8 +222,7 @@ export async function fetchReportCardCompleteness(params: { } -/** Build absolute API URL for iframe / window.open (report PDF HTML). */ -export function reportCardStudentUrl( +function reportCardStudentPath( studentId: number | string, opts: { school_year: string; semester?: string; download?: boolean; report_date?: string }, ): string { @@ -233,10 +232,10 @@ export function reportCardStudentUrl( if (opts.download) qs.set('download', '1') if (opts.report_date) qs.set('report_date', opts.report_date) qs.set('cb', String(Date.now())) - return apiUrl(`/api/v1/reports/report-cards/students/${encodeURIComponent(String(studentId))}?${qs}`) + return `/api/v1/reports/report-cards/students/${encodeURIComponent(String(studentId))}?${qs}` } -export function reportCardClassUrl( +function reportCardClassPath( classSectionId: number | string, opts: { school_year: string; semester?: string; download?: boolean; report_date?: string }, ): string { @@ -246,7 +245,22 @@ export function reportCardClassUrl( if (opts.download) qs.set('download', '1') if (opts.report_date) qs.set('report_date', opts.report_date) qs.set('cb', String(Date.now())) - return apiUrl(`/api/v1/reports/report-cards/classes/${encodeURIComponent(String(classSectionId))}?${qs}`) + return `/api/v1/reports/report-cards/classes/${encodeURIComponent(String(classSectionId))}?${qs}` +} + +/** Build absolute API URL for iframe / window.open (report PDF HTML). */ +export function reportCardStudentUrl( + studentId: number | string, + opts: { school_year: string; semester?: string; download?: boolean; report_date?: string }, +): string { + return apiUrl(reportCardStudentPath(studentId, opts)) +} + +export function reportCardClassUrl( + classSectionId: number | string, + opts: { school_year: string; semester?: string; download?: boolean; report_date?: string }, +): string { + return apiUrl(reportCardClassPath(classSectionId, opts)) } /** HTML document for iframe preview (Bearer auth; unlike raw iframe src). */ @@ -288,15 +302,58 @@ export async function fetchReportCardClassHtml( return res.text() } -/** Opens PDF download in new tab (authenticated GET). */ -export async function openReportCardDownload(url: string): Promise { +async function fetchReportCardPdf(path: string): Promise { const headers = new Headers({ Accept: 'application/pdf,*/*' }) const token = getStoredToken() + if (!token) { + throw new ApiHttpError('Your session has expired. Please sign in again.', 401, null) + } if (token) headers.set('Authorization', `Bearer ${token}`) applyStoredSchoolYearHeaders(headers) - const res = await fetch(url, { headers }) - if (!res.ok) throw new ApiHttpError(`HTTP ${res.status}`, res.status, null) + const res = await fetch(apiUrl(path), { headers }) + if (!res.ok) { + const errBody: unknown = await res.json().catch(() => null) + const msg = + typeof errBody === 'object' && errBody !== null && 'message' in errBody + ? String((errBody as { message?: unknown }).message ?? '') + : '' + throw new ApiHttpError(msg || `HTTP ${res.status}`, res.status, errBody) + } const raw = await res.blob() - const blob = new Blob([await raw.arrayBuffer()], { type: 'application/pdf' }) - window.open(URL.createObjectURL(blob), '_blank', 'noopener') + return new Blob([await raw.arrayBuffer()], { type: 'application/pdf' }) +} + +function openBlob(blob: Blob): void { + const objectUrl = URL.createObjectURL(blob) + window.open(objectUrl, '_blank', 'noopener') + window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000) +} + +/** Opens PDF download in new tab (authenticated GET). */ +export async function openReportCardStudentDownload( + studentId: number | string, + opts: { school_year: string; semester?: string; report_date?: string }, +): Promise { + openBlob( + await fetchReportCardPdf( + reportCardStudentPath(studentId, { + ...opts, + download: true, + }), + ), + ) +} + +export async function openReportCardClassDownload( + classSectionId: number | string, + opts: { school_year: string; semester?: string; report_date?: string }, +): Promise { + openBlob( + await fetchReportCardPdf( + reportCardClassPath(classSectionId, { + ...opts, + download: true, + }), + ), + ) } diff --git a/src/api/schoolYears.ts b/src/api/schoolYears.ts index 41a0655..f2eb6ee 100644 --- a/src/api/schoolYears.ts +++ b/src/api/schoolYears.ts @@ -448,7 +448,7 @@ export async function fetchParentStatement(params: { if (params.parentId != null) query.set('parent_id', String(params.parentId)) const response = await apiFetch( - `/api/v1/parent/statements${query.toString() ? `?${query}` : ''}`, + `/api/v1/parents/statements${query.toString() ? `?${query}` : ''}`, ) if (!response.data) { diff --git a/src/api/session.ts b/src/api/session.ts index db0c9ec..938dd10 100644 --- a/src/api/session.ts +++ b/src/api/session.ts @@ -1974,6 +1974,8 @@ export async function fetchClassProgressGroups(params?: { weekStart?: string | null weekEnd?: string | null subject?: string | null + schoolYear?: string | null + semester?: string | null page?: number perPage?: number }): Promise { @@ -1984,6 +1986,8 @@ export async function fetchClassProgressGroups(params?: { if (params?.weekStart) query.set('week_start', params.weekStart) if (params?.weekEnd) query.set('week_end', params.weekEnd) if (params?.subject) query.set('subject', params.subject) + if (params?.schoolYear) query.set('school_year', params.schoolYear) + if (params?.semester) query.set('semester', params.semester) if (params?.page) query.set('page', String(params.page)) if (params?.perPage) query.set('per_page', String(params.perPage)) return apiFetch(`/api/v1/class-progress?${query.toString()}`) @@ -1996,10 +2000,14 @@ export async function fetchClassProgressDetail(id: number): Promise { const query = new URLSearchParams() if (params?.classId) query.set('class_id', String(params.classId)) if (params?.sundayCount) query.set('sunday_count', String(params.sundayCount)) + 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/class-progress/meta${qs}`) } diff --git a/src/api/types.ts b/src/api/types.ts index ee2efd6..25a8d3c 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -349,9 +349,23 @@ export type ParentProfileResponse = { export type ParentInvoiceRow = { id: number invoice_number?: string | null + total_amount?: number + paid_amount?: number balance?: number status?: string | null due_date?: string | null + last_paid_amount?: number + last_payment_date?: string | null + payments?: Array<{ + id: number + invoice_id?: number + paid_amount?: number + balance?: number + payment_method?: string | null + payment_date?: string | null + transaction_id?: string | null + status?: string | null + }> } export type ParentInvoicesResponse = { diff --git a/src/layout/MainLayout.tsx b/src/layout/MainLayout.tsx index d6a510c..6eb91ac 100644 --- a/src/layout/MainLayout.tsx +++ b/src/layout/MainLayout.tsx @@ -14,7 +14,6 @@ const parentNavItems = [ { to: '/app/parent/enroll-classes', icon: 'bi bi-pencil-square', label: 'Enroll in Classes' }, { to: '/app/parent/events', icon: 'bi bi-bell', label: 'Events' }, { to: '/app/parent/payment', icon: 'bi bi-credit-card', label: 'Invoice' }, - { to: '/app/parent/statements', icon: 'bi bi-receipt', label: 'Statements' }, { to: '/app/parent/register-student', icon: 'bi bi-person-plus', label: 'Register Students' }, { to: '/app/parent/report-attendance', icon: 'bi bi-megaphone', label: 'Report Absence' }, { to: '/app/parent/report-cards', icon: 'bi bi-file-earmark-text', label: 'Report Cards' }, @@ -171,7 +170,6 @@ export function MainLayout() {
Al Rahma Sunday School logo - Al Rahma Sunday School + Page {page} of {totalPages} + +
+ + ) +} + export function SchoolYearsManagementPage() { const [searchParams, setSearchParams] = useSearchParams() const [years, setYears] = useState([]) @@ -141,6 +221,14 @@ export function SchoolYearsManagementPage() { const [busyEdit, setBusyEdit] = useState(false) const [busyOpenId, setBusyOpenId] = useState(null) const [busyCloseAction, setBusyCloseAction] = useState<'validate' | 'preview' | 'close' | null>(null) + const [promotionPage, setPromotionPage] = useState(1) + const [promotionPageSize, setPromotionPageSize] = useState(10) + const [balancePage, setBalancePage] = useState(1) + const [balancePageSize, setBalancePageSize] = useState(10) + const [closePromotionPage, setClosePromotionPage] = useState(1) + const [closePromotionPageSize, setClosePromotionPageSize] = useState(10) + const [closeBalancePage, setCloseBalancePage] = useState(1) + const [closeBalancePageSize, setCloseBalancePageSize] = useState(10) const selectedYearName = searchParams.get('school_year')?.trim() || null const activeYear = useMemo( @@ -346,6 +434,7 @@ export function SchoolYearsManagementPage() { suggestNextName(selectedYear.name), ) setPromotionPreview(preview) + setPromotionPage(1) } catch (loadError) { setError(normalizeApiError(loadError, 'Unable to load promotion preview.')) } @@ -361,6 +450,7 @@ export function SchoolYearsManagementPage() { suggestNextName(selectedYear.name), ) setParentBalances(balances) + setBalancePage(1) } catch (loadError) { setError(normalizeApiError(loadError, 'Unable to load parent balance preview.')) } @@ -428,6 +518,10 @@ export function SchoolYearsManagementPage() { const balanceRows = parentBalances?.rows ?? [] const closePromotionRows = closePreview?.promotion_preview.rows ?? [] const closeBalanceRows = closePreview?.parent_balances.rows ?? [] + const pagedPromotionRows = paginateRows(promotionRows, promotionPage, promotionPageSize) + const pagedBalanceRows = paginateRows(balanceRows, balancePage, balancePageSize) + const pagedClosePromotionRows = paginateRows(closePromotionRows, closePromotionPage, closePromotionPageSize) + const pagedCloseBalanceRows = paginateRows(closeBalanceRows, closeBalancePage, closeBalancePageSize) const selectedYearReadOnly = isReadOnlySchoolYear(selectedYear) const selectedYearIsActive = Boolean(selectedYear?.is_current) const requiredConfirmation = selectedYearIsActive && selectedYear ? `CLOSE ${selectedYear.name}` : '' @@ -783,6 +877,20 @@ export function SchoolYearsManagementPage() { {promotionPreview.summary.total_students} students scanned,{' '} {promotionPreview.summary.hold} hold rows. + { + setPromotionPageSize(pageSize) + setPromotionPage(1) + }} + />
@@ -801,7 +909,7 @@ export function SchoolYearsManagementPage() { ) : ( - promotionRows.slice(0, 8).map((row) => ( + pagedPromotionRows.rows.map((row) => (
{row.student_name}
@@ -837,6 +945,20 @@ export function SchoolYearsManagementPage() { Unpaid ${formatMoney(parentBalances.summary.total_positive_unpaid_balance ?? parentBalances.summary.total_old_unpaid_balance)},{' '} credits ${formatMoney(parentBalances.summary.total_credit_balance)}, net ${formatMoney(parentBalances.summary.net_balance_impact ?? parentBalances.summary.net_balance_to_transfer)}. + { + setBalancePageSize(pageSize) + setBalancePage(1) + }} + />
@@ -858,7 +980,7 @@ export function SchoolYearsManagementPage() { ) : ( - balanceRows.slice(0, 8).map((row) => ( + pagedBalanceRows.rows.map((row) => ( @@ -1092,6 +1214,20 @@ export function SchoolYearsManagementPage() {

Closure promotion preview

+ { + setClosePromotionPageSize(pageSize) + setClosePromotionPage(1) + }} + />
{row.parent_name || `Parent #${row.parent_id}`} {row.student_names?.join(', ') || '—'}
@@ -1102,7 +1238,7 @@ export function SchoolYearsManagementPage() { - {closePromotionRows.slice(0, 8).map((row) => ( + {pagedClosePromotionRows.rows.map((row) => ( @@ -1120,6 +1256,20 @@ export function SchoolYearsManagementPage() {
Source year: {closePreview.parent_balances.from_school_year}; target year: {closePreview.parent_balances.to_school_year}. Transfer mode: unpaid balances only.
+ { + setCloseBalancePageSize(pageSize) + setCloseBalancePage(1) + }} + />
{row.student_name} {row.promotion_action}
@@ -1133,7 +1283,7 @@ export function SchoolYearsManagementPage() { - {closeBalanceRows.slice(0, 8).map((row) => ( + {pagedCloseBalanceRows.rows.map((row) => ( diff --git a/src/pages/classProgress/ClassProgressListPage.tsx b/src/pages/classProgress/ClassProgressListPage.tsx index ea8e8df..c0f9951 100644 --- a/src/pages/classProgress/ClassProgressListPage.tsx +++ b/src/pages/classProgress/ClassProgressListPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from 'react' -import { Link, useNavigate } from 'react-router-dom' +import { Link, useNavigate, useSearchParams } from 'react-router-dom' import { fetchClassProgressGroups, fetchClassProgressMeta, @@ -20,6 +20,9 @@ export function ClassProgressListPage() { const [items, setItems] = useState([]) const [error, setError] = useState(null) const navigate = useNavigate() + const [searchParams] = useSearchParams() + const selectedSchoolYear = searchParams.get('school_year')?.trim() || null + const selectedSemester = searchParams.get('semester')?.trim() || null /** Server may later expose `low_progress_section_ids`; until then keep empty (CI disables button). */ const lowProgressSectionIds: number[] = [] @@ -33,10 +36,12 @@ export function ClassProgressListPage() { async function load() { try { const [meta, sections, groups] = await Promise.all([ - fetchClassProgressMeta(), - fetchClassSections({ perPage: 200, withStudents: true }), + fetchClassProgressMeta({ schoolYear: selectedSchoolYear, semester: selectedSemester }), + fetchClassSections({ perPage: 200, withStudents: true, schoolYear: selectedSchoolYear, semester: selectedSemester }), fetchClassProgressGroups({ classSectionId: filters.classSectionId ? Number(filters.classSectionId) : null, + schoolYear: selectedSchoolYear, + semester: selectedSemester, status: filters.status || null, weekStart: filters.weekStart || null, weekEnd: filters.weekEnd || null, @@ -55,7 +60,7 @@ export function ClassProgressListPage() { useEffect(() => { void load() - }, []) + }, [selectedSchoolYear, selectedSemester]) const groupsBySection = useMemo(() => { const m = new Map() @@ -69,6 +74,26 @@ export function ClassProgressListPage() { return m }, [items]) + const displaySections = useMemo(() => { + const byId = new Map() + for (const section of classSections) { + const id = Number(section.class_section_id ?? 0) + if (id > 0) byId.set(id, section) + } + for (const item of items) { + const id = Number(item.class_section_id ?? 0) + if (id > 0 && !byId.has(id)) { + byId.set(id, { + class_section_id: id, + class_section_name: item.class_section_name ?? `Section ${id}`, + }) + } + } + return Array.from(byId.values()).sort((a, b) => + String(a.class_section_name ?? '').localeCompare(String(b.class_section_name ?? '')), + ) + }, [classSections, items]) + const subjectCount = Object.keys(subjectSections).length function teacherLabelForGroup(group: ClassProgressGroupRow): string { @@ -218,11 +243,11 @@ export function ClassProgressListPage() {
- {classSections.length === 0 ? ( + {displaySections.length === 0 ? (
No class sections found.
) : (
- {classSections.map((cs) => { + {displaySections.map((cs) => { const sectionId = Number(cs.class_section_id ?? 0) const sectionName = cs.class_section_name ?? 'Unknown Section' const sectionGroups = groupsBySection.get(sectionId) ?? [] diff --git a/src/pages/parent/ParentStatementPage.tsx b/src/pages/parent/ParentStatementPage.tsx deleted file mode 100644 index 2ce6bad..0000000 --- a/src/pages/parent/ParentStatementPage.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { useEffect, useMemo, useState } from 'react' -import { ApiHttpError } from '../../api/http' -import { fetchParentStatement, type ParentStatement } from '../../api/schoolYears' -import { SchoolYearSelector } from '../../components/schoolYear' -import { useSchoolYear } from '../../context/SchoolYearContext' - -function normalizeApiError(error: unknown, fallback: string): string { - if (error instanceof ApiHttpError) return error.message || fallback - if (error instanceof Error) return error.message - return fallback -} - -function formatMoney(value: number | null | undefined): string { - return Number(value ?? 0).toLocaleString(undefined, { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }) -} - -export function ParentStatementPage() { - const { selectedSchoolYearName } = useSchoolYear() - const [statement, setStatement] = useState(null) - const [loading, setLoading] = useState(false) - const [error, setError] = useState(null) - - useEffect(() => { - if (!selectedSchoolYearName) return - - let cancelled = false - setLoading(true) - setError(null) - - ;(async () => { - try { - const nextStatement = await fetchParentStatement({ schoolYear: selectedSchoolYearName }) - if (!cancelled) setStatement(nextStatement) - } catch (loadError) { - if (!cancelled) setError(normalizeApiError(loadError, 'Unable to load parent statement.')) - } finally { - if (!cancelled) setLoading(false) - } - })() - - return () => { - cancelled = true - } - }, [selectedSchoolYearName]) - - const lines = useMemo(() => statement?.lines ?? [], [statement]) - - return ( -
-
-
-

Parent Statement

-

- Opening balances from closed years are shown separately from new-year charges so they are not double-counted. -

-
- -
- - {error ?
{error}
: null} - {loading ?

Loading statement…

: null} - -
-
-
-
-

{statement?.parent_name ?? 'Parent account'}

-
School year: {selectedSchoolYearName ?? '—'}
-
-
-
Current balance
-
${formatMoney(statement?.current_balance)}
-
-
- -
- Opening balance from prior year: ${formatMoney(statement?.opening_balance)}. The old-balance invoice is the collectible item; this opening-balance label is audit context, not a second charge. -
- -
-
{row.parent_name || `Parent #${row.parent_id}`} {row.student_names?.join(', ') || '—'}
- - - - - - - - - - {lines.length === 0 ? ( - - ) : lines.map((line, index) => ( - - - - - - - ))} - -
DescriptionSource yearInvoiceAmount
No statement lines found.
{line.label}{line.source_school_year ?? '—'}{line.invoice_id ?? '—'}${formatMoney(line.amount)}
-
- - - - ) -} diff --git a/src/pages/parent/WiredParentScreens.tsx b/src/pages/parent/WiredParentScreens.tsx index f283f13..19ec7e1 100644 --- a/src/pages/parent/WiredParentScreens.tsx +++ b/src/pages/parent/WiredParentScreens.tsx @@ -1,24 +1,27 @@ import { useEffect, useMemo, useState, type FormEvent } from 'react' +import { Link } from 'react-router-dom' import { ParentParityShell } from './ParentParityShell' -import { - fetchAttendanceReportForm, - fetchParentEnrollments, - fetchParentEventsOverview, - fetchParentInvoices, +import { + fetchAttendanceReportForm, + fetchParentEnrollments, + fetchParentEventsOverview, + fetchParentInvoices, fetchParentProfile, fetchParentRegistrationOverview, submitParentAttendanceReport, submitContactMessage, updateParentEnrollments, } from '../../api/session' -import type { - ParentAttendanceReportFormResponse, - ParentEnrollmentStudent, - ParentEventsOverviewResponse, - ParentInvoiceRow, - ParentRegistrationOverviewResponse, -} from '../../api/types' -import { useAuth } from '../../auth/AuthProvider' +import { fetchParentStatement, type ParentStatement } from '../../api/schoolYears' +import type { + ParentAttendanceReportFormResponse, + ParentEnrollmentStudent, + ParentEventsOverviewResponse, + ParentInvoiceRow, + ParentRegistrationOverviewResponse, +} from '../../api/types' +import { useAuth } from '../../auth/AuthProvider' +import { useSchoolYear } from '../../context/SchoolYearContext' /** `Views/parent/contact.php` — POST /api/v1/contact */ export function ParentContactPage() { @@ -479,81 +482,356 @@ function choiceBadge(action: 'enroll' | 'withdraw' | null) { if (action === 'withdraw') return Withdraw requested return No change } - -/** `Views/parent/payment_view.php` — GET /api/v1/parents/invoices */ -export function ParentPaymentInvoicesPage() { - const [error, setError] = useState(null) - const [loading, setLoading] = useState(true) - const [rows, setRows] = useState([]) - - useEffect(() => { - let cancelled = false - ;(async () => { - setLoading(true) - try { - const data = await fetchParentInvoices(null) - if (cancelled) return - setRows(data.invoices ?? []) - setError(null) - } catch (e) { - if (!cancelled) setError(e instanceof Error ? e.message : 'Failed to load invoices.') + +function formatCurrency(value: number | null | undefined): string { + return Number(value ?? 0).toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }) +} + +function parseCurrency(value: number | string | null | undefined): number { + if (typeof value === 'number' && Number.isFinite(value)) return value + if (typeof value !== 'string') return 0 + const parsed = Number(value.replace(/[$,]/g, '')) + return Number.isFinite(parsed) ? parsed : 0 +} + +function invoiceStatusClass(status?: string | null): string { + const normalized = String(status ?? '').trim().toLowerCase() + if (normalized === 'paid') return 'bg-success-subtle text-success-emphasis border border-success-subtle' + if (normalized.includes('partial')) return 'bg-warning-subtle text-warning-emphasis border border-warning-subtle' + if (normalized === 'pending' || normalized === 'unpaid') return 'bg-danger-subtle text-danger-emphasis border border-danger-subtle' + return 'bg-secondary-subtle text-secondary-emphasis border border-secondary-subtle' +} + +function paymentTone(balance: number): string { + if (balance > 0) return 'text-danger' + if (balance < 0) return 'text-success' + return 'text-success' +} + +function formatDateLabel(value?: string | null): string { + if (!value) return 'No due date' + const date = new Date(`${value}T00:00:00`) + if (Number.isNaN(date.getTime())) return value + return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) +} + +/** `Views/parent/payment_view.php` — GET /api/v1/parents/invoices */ +export function ParentPaymentInvoicesPage() { + const { selectedSchoolYearName } = useSchoolYear() + const [error, setError] = useState(null) + const [statementError, setStatementError] = useState(null) + const [loading, setLoading] = useState(true) + const [statementLoading, setStatementLoading] = useState(false) + const [rows, setRows] = useState([]) + const [statement, setStatement] = useState(null) + + useEffect(() => { + if (!selectedSchoolYearName) return + + let cancelled = false + ;(async () => { + setLoading(true) + try { + const data = await fetchParentInvoices(selectedSchoolYearName) + if (cancelled) return + setRows(data.invoices ?? []) + setError(null) + } catch (e) { + if (!cancelled) setError(e instanceof Error ? e.message : 'Failed to load invoices.') } finally { if (!cancelled) setLoading(false) } })() - return () => { - cancelled = true - } - }, []) - - return ( - - {error ?
{error}
: null} - {loading ? ( -

Loading…

- ) : ( -
- - - - - - - - - - - - {rows.length === 0 ? ( - - - - ) : ( - rows.map((inv) => ( - - - - - - - - )) - )} - -
#InvoiceBalanceStatusDue
- No invoices. -
{inv.id}{inv.invoice_number ?? '—'} - {typeof inv.balance === 'number' ? inv.balance.toFixed(2) : '—'} - {inv.status ?? '—'}{inv.due_date ?? '—'}
-
- )} -
- ) -} + return () => { + cancelled = true + } + }, [selectedSchoolYearName]) + + useEffect(() => { + if (!selectedSchoolYearName) return + + let cancelled = false + ;(async () => { + setStatementLoading(true) + setStatement(null) + try { + const data = await fetchParentStatement({ schoolYear: selectedSchoolYearName }) + if (cancelled) return + setStatement(data) + setStatementError(null) + } catch (e) { + if (!cancelled) { + setStatement(null) + setStatementError(e instanceof Error ? e.message : 'Failed to load statement.') + } + } finally { + if (!cancelled) setStatementLoading(false) + } + })() + return () => { + cancelled = true + } + }, [selectedSchoolYearName]) + + const statementLines = useMemo(() => statement?.lines ?? [], [statement]) + const totalBalance = useMemo( + () => rows.reduce((sum, inv) => sum + parseCurrency(inv.balance), 0), + [rows], + ) + const totalPaid = useMemo( + () => rows.reduce((sum, inv) => sum + parseCurrency(inv.paid_amount), 0), + [rows], + ) + const openInvoices = useMemo( + () => rows.filter((inv) => parseCurrency(inv.balance) > 0), + [rows], + ) + const paidInvoices = rows.length - openInvoices.length + const nextDueInvoice = useMemo(() => { + return [...openInvoices] + .filter((inv) => inv.due_date) + .sort((a, b) => String(a.due_date).localeCompare(String(b.due_date)))[0] + }, [openInvoices]) + const statementTotal = useMemo( + () => statementLines.reduce((sum, line) => sum + parseCurrency(line.amount), 0), + [statementLines], + ) + + return ( + + {error ?
{error}
: null} + +
+
+
Family account
+

Payment overview

+

+ {selectedSchoolYearName ? `School year ${selectedSchoolYearName}` : 'School year not selected'} +

+
+ + Make a payment + +
+ +
+
+
+
Current balance
+
+ ${formatCurrency(statement?.current_balance ?? totalBalance)} +
+
Across all visible invoices and statement items
+
+
+
+
+
Invoices
+
{rows.length}
+
+ {openInvoices.length} open, {paidInvoices} paid · ${formatCurrency(totalPaid)} received +
+
+
+
+
+
Next due date
+
{formatDateLabel(nextDueInvoice?.due_date)}
+
+ {nextDueInvoice ? nextDueInvoice.invoice_number ?? `Invoice #${nextDueInvoice.id}` : 'No open due dates'} +
+
+
+
+ +
+
+
+

Invoices

+

Review balances and payment status for each invoice.

+
+ ${formatCurrency(totalBalance)} visible balance +
+ + {loading ? ( +
Loading invoices…
+ ) : rows.length === 0 ? ( +
+
No invoices found
+
There are no invoices for this school year.
+
+ ) : ( +
+ {rows.map((inv) => { + const balance = parseCurrency(inv.balance) + const totalAmount = parseCurrency(inv.total_amount) + const paidAmount = parseCurrency(inv.paid_amount) + const invoiceLabel = inv.invoice_number ?? `Invoice #${inv.id}` + const payments = inv.payments ?? [] + return ( +
+
+
+
Invoice
+
{invoiceLabel}
+
Record #{inv.id}
+
+
+
Invoice total
+
${formatCurrency(totalAmount)}
+
+
+
Paid
+
${formatCurrency(paidAmount)}
+
+
+
Balance
+
${formatCurrency(balance)}
+
+
+
Due
+
{formatDateLabel(inv.due_date)}
+
+
+ + {inv.status ?? 'Unknown'} + +
+
+ {balance > 0 ? ( + + Pay invoice + + ) : ( + Paid + )} +
+
+
+
Payments from payment records
+ {payments.length === 0 ? ( +
No posted payments found for this invoice.
+ ) : ( +
+ + + + + + + + + + + + {payments.map((payment) => ( + + + + + + + + ))} + +
DateAmountMethodStatusTransaction
{payment.payment_date ? formatDateLabel(payment.payment_date) : 'No payment date'}${formatCurrency(payment.paid_amount)}{payment.payment_method ?? '—'}{payment.status ?? '—'}{payment.transaction_id ?? '—'}
+
+ )} +
+
+ ) + })} +
+ )} +
+ +
+
+
+

Parent statement

+

+ Opening balances from closed years are shown separately from new-year charges so they are not double-counted. +

+
+
+
Statement total
+
${formatCurrency(statementTotal)}
+
+
+ + {statementError ?
{statementError}
: null} + {statementLoading ? ( +
Loading statement…
+ ) : ( + <> +
+
+
+
Opening balance from prior year
+
${formatCurrency(statement?.opening_balance)}
+
+ The old-balance invoice is the collectible item. This label is audit context, not a second charge. +
+
+
+
+
+
Current balance
+
+ ${formatCurrency(statement?.current_balance)} +
+
Includes current statement activity for this account.
+
+
+
+ +
+ + + + + + + + + + + {statementLines.length === 0 ? ( + + + + ) : ( + statementLines.map((line, index) => ( + + + + + + + )) + )} + +
DescriptionSource yearInvoiceAmount
+ No statement lines found. +
{line.label}{line.source_school_year ?? '—'}{line.invoice_id ?? '—'}${formatCurrency(line.amount)}
+
+ + )} +
+
+ ) +} /** `Views/parent/event_participation.php` — GET/POST /api/v1/parents/events … */ export function ParentEventsPage() { diff --git a/src/pages/payment/ManualPayPage.tsx b/src/pages/payment/ManualPayPage.tsx index 5edae89..70749cb 100644 --- a/src/pages/payment/ManualPayPage.tsx +++ b/src/pages/payment/ManualPayPage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Link, useNavigate, useSearchParams } from 'react-router-dom' import { ApiHttpError } from '../../api/http' import { @@ -49,23 +49,32 @@ export function ManualPayPage() { const [previewBlobUrl, setPreviewBlobUrl] = useState(null) const [showAddModal, setShowAddModal] = useState(false) const [editPaymentId, setEditPaymentId] = useState(null) + const loadSeq = useRef(0) const activeTerm = termFromUrl const load = useCallback( (term: string) => { + const requestId = ++loadSeq.current if (!term.trim()) { setData(null) + setLoading(false) return } setLoading(true) setError(null) fetchManualPayPage({ search_term: term.trim() }) - .then(setData) - .catch((e: unknown) => - setError(e instanceof ApiHttpError ? e.message : 'Could not load manual pay data.'), - ) - .finally(() => setLoading(false)) + .then((payload) => { + if (requestId === loadSeq.current) setData(payload) + }) + .catch((e: unknown) => { + if (requestId === loadSeq.current) { + setError(e instanceof ApiHttpError ? e.message : 'Could not load manual pay data.') + } + }) + .finally(() => { + if (requestId === loadSeq.current) setLoading(false) + }) }, [], ) @@ -103,6 +112,21 @@ export function ManualPayPage() { navigate({ pathname: '/app/administrator/payment/manual-pay', search: `?search_term=${encodeURIComponent(q)}` }) } + function clearSearch() { + loadSeq.current += 1 + setSearchInput('') + setData(null) + setError(null) + setSuggestions([]) + setSuggestOpen(false) + setLoading(false) + setShowAddModal(false) + setEditPaymentId(null) + if (previewBlobUrl) URL.revokeObjectURL(previewBlobUrl) + setPreviewBlobUrl(null) + navigate({ pathname: '/app/administrator/payment/manual-pay', search: '' }) + } + function pickSuggestion(text: string) { setSearchInput(text) setSuggestOpen(false) @@ -183,6 +207,14 @@ export function ManualPayPage() { + {suggestOpen && suggestions.length > 0 ? (
diff --git a/src/pages/printables/ReportCardManagementPage.tsx b/src/pages/printables/ReportCardManagementPage.tsx index 508ea8a..0a88ec4 100644 --- a/src/pages/printables/ReportCardManagementPage.tsx +++ b/src/pages/printables/ReportCardManagementPage.tsx @@ -6,7 +6,8 @@ import { fetchReportCardCompleteness, fetchReportCardMeta, fetchReportCardStudentHtml, - openReportCardDownload, + openReportCardClassDownload, + openReportCardStudentDownload, reportCardClassUrl, reportCardStudentUrl, type ReportCardMeta, @@ -118,14 +119,12 @@ export function ReportCardManagementPage() { async function downloadStudent() { if (!studentId || !schoolYear) return - const url = reportCardStudentUrl(studentId, { - school_year: schoolYear, - semester, - download: true, - report_date: reportDate || undefined, - }) try { - await openReportCardDownload(url) + await openReportCardStudentDownload(studentId, { + school_year: schoolYear, + semester, + report_date: reportDate || undefined, + }) } catch (e: unknown) { alert(e instanceof ApiHttpError ? e.message : 'Download failed.') } @@ -133,14 +132,12 @@ export function ReportCardManagementPage() { async function downloadClass() { if (!classId || !schoolYear) return - const url = reportCardClassUrl(classId, { - school_year: schoolYear, - semester, - download: true, - report_date: reportDate || undefined, - }) try { - await openReportCardDownload(url) + await openReportCardClassDownload(classId, { + school_year: schoolYear, + semester, + report_date: reportDate || undefined, + }) } catch (e: unknown) { alert(e instanceof ApiHttpError ? e.message : 'Download failed.') } diff --git a/src/pages/teacher/TeacherClassProgressHistoryPage.tsx b/src/pages/teacher/TeacherClassProgressHistoryPage.tsx index ee719c1..e5f023f 100644 --- a/src/pages/teacher/TeacherClassProgressHistoryPage.tsx +++ b/src/pages/teacher/TeacherClassProgressHistoryPage.tsx @@ -70,6 +70,7 @@ function buildHistoryQueryString(qs: string): string { if (v === '' || v == null) emptyKeys.push(k) }) emptyKeys.forEach((k) => p.delete(k)) + p.delete('class_section_id') if (!p.has('per_page')) p.set('per_page', '100') if (!p.has('sort_by')) p.set('sort_by', 'week_start') if (!p.has('sort_dir')) p.set('sort_dir', 'desc') @@ -106,7 +107,6 @@ export function TeacherClassProgressHistoryPage() { const [draftWeekStart, setDraftWeekStart] = useState(() => search.get('week_start') ?? '') const [draftWeekEnd, setDraftWeekEnd] = useState(() => search.get('week_end') ?? '') - const [draftSection, setDraftSection] = useState(() => search.get('class_section_id') ?? '') const [draftStatus, setDraftStatus] = useState(() => search.get('status') ?? '') const [draftSortDir, setDraftSortDir] = useState(() => search.get('sort_dir') || 'desc') @@ -114,22 +114,10 @@ export function TeacherClassProgressHistoryPage() { const p = new URLSearchParams(buildHistoryQueryString(qs)) setDraftWeekStart(p.get('week_start') ?? '') setDraftWeekEnd(p.get('week_end') ?? '') - setDraftSection(p.get('class_section_id') ?? '') setDraftStatus(p.get('status') ?? '') setDraftSortDir(p.get('sort_dir') || 'desc') }, [qs]) - const sectionOptions = useMemo(() => { - const m = new Map() - for (const r of items) { - const id = Number(r.class_section_id ?? 0) - if (!id) continue - const name = String(r.class_section_name ?? '').trim() || `Section ${id}` - if (!m.has(id)) m.set(id, name) - } - return [...m.entries()].sort((a, b) => a[0] - b[0]) - }, [items]) - function applyFilters(e: FormEvent) { e.preventDefault() const next = new URLSearchParams() @@ -138,7 +126,6 @@ export function TeacherClassProgressHistoryPage() { next.set('sort_dir', draftSortDir === 'asc' ? 'asc' : 'desc') if (draftWeekStart.trim()) next.set('week_start', draftWeekStart.trim()) if (draftWeekEnd.trim()) next.set('week_end', draftWeekEnd.trim()) - if (draftSection.trim()) next.set('class_section_id', draftSection.trim()) if (draftStatus.trim()) next.set('status', draftStatus.trim()) next.delete('page') setSearch(next, { replace: true }) @@ -147,7 +134,6 @@ export function TeacherClassProgressHistoryPage() { function resetFilters() { setDraftWeekStart('') setDraftWeekEnd('') - setDraftSection('') setDraftStatus('') const next = new URLSearchParams() next.set('per_page', '100') @@ -220,24 +206,6 @@ export function TeacherClassProgressHistoryPage() { onChange={(e) => setDraftWeekEnd(e.target.value)} />
-
- - -
) : null} - {classes.length > 0 && classIds.length > 1 ? ( -
- Class: - {classIds.map((id) => { - const label = - classes.find((c) => Number(c.class_id) === id)?.class_name?.trim() || `Class ${id}` - const active = resolvedClassId === id - return ( - - {label} - - ) - })} -
- ) : null} - {submitOk ? (
{submitOk} @@ -382,29 +335,6 @@ export function TeacherClassProgressSubmitPage() { ))}
-
- - -
@@ -671,7 +601,7 @@ export function TeacherClassProgressSubmitPage() { {!canSubmit ? ( - Choose section, week, Islamic unit/chapter, and material covered for Islamic Studies. + Choose week, Islamic unit/chapter, and material covered for Islamic Studies. ) : null} diff --git a/src/pages/teacher/TeacherRestPages.tsx b/src/pages/teacher/TeacherRestPages.tsx index 3b5c104..8bcc85d 100644 --- a/src/pages/teacher/TeacherRestPages.tsx +++ b/src/pages/teacher/TeacherRestPages.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState, type FormEvent } from 'react' -import { Link, useParams, useSearchParams } from 'react-router-dom' +import { Link, Navigate, useParams, useSearchParams } from 'react-router-dom' import { fetchAdministratorCalendarEvents, openProtectedApiFile } from '../../api/session' import type { ExamDraftRow, SchoolCalendarEventRow } from '../../api/types' import { TeacherShell } from './TeacherShell' @@ -128,8 +128,18 @@ type FrontendMeResponse = { const EXAM_DRAFT_MAX_BYTES = 12 * 1024 * 1024 const EXAM_DRAFT_ALLOWED_EXTENSIONS = ['doc', 'docx'] -function teacherExamFilePath(kind: 'teacher' | 'final', filename?: string | null) { - return filename ? `/api/v1/files/exams/${kind}/${encodeURIComponent(filename)}` : null +function teacherExamStoredFileName(filename?: string | null): string | null { + const value = String(filename ?? '').trim() + return /\.(docx?|pdf)$/i.test(value) ? value : null +} + +function teacherExamFilePath(kind: 'teacher' | 'final', filename?: string | null, draftId?: number | null) { + const stored = teacherExamStoredFileName(filename) + if (stored) return `/api/v1/files/exams/${kind}/${encodeURIComponent(stored)}` + if (kind === 'final' && draftId && draftId > 0) { + return `/api/v1/files/exams/final/${encodeURIComponent(String(draftId))}` + } + return null } type TeacherAbsenceRow = { @@ -332,7 +342,9 @@ export function TeacherCompetitionScoresIndexPage() {
{dateLabel} {isLocked ? 'Yes' : 'No'} - {isLocked ? ( + {compId <= 0 ? ( + Unavailable + ) : isLocked ? ( Locked ) : ( Enter scores @@ -353,10 +365,12 @@ export function TeacherCompetitionScoresIndexPage() { /** `teacher/competition_scores/scores.php` */ export function TeacherCompetitionScoresDetailPage() { const { competitionId } = useParams() - const path = - competitionId && competitionId !== 'undefined' - ? `/competition-scores/${competitionId}` - : '/competition-scores/0' + const id = Number(competitionId ?? 0) + if (!Number.isInteger(id) || id <= 0) { + return + } + + const path = `/competition-scores/${id}` return } @@ -1279,7 +1293,7 @@ export function TeacherExamDraftsPage() { ) : ( (payload?.drafts ?? []).map((draft) => { const teacherFile = teacherExamFilePath('teacher', draft.teacher_file) - const finalFile = teacherExamFilePath('final', draft.final_pdf_file ?? draft.final_file) + const finalFile = teacherExamFilePath('final', draft.final_pdf_file ?? draft.final_file, draft.id) return (
@@ -1340,7 +1354,7 @@ export function TeacherExamDraftsPage() { ) : (
{(payload?.legacy_exams ?? []).map((draft) => { - const finalFile = teacherExamFilePath('final', draft.final_pdf_file ?? draft.final_file) + const finalFile = teacherExamFilePath('final', draft.final_pdf_file ?? draft.final_file, draft.id) return (