diff --git a/src/App.tsx b/src/App.tsx index 097593f..738911f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1170,13 +1170,13 @@ export default function App() { } /> } /> } /> - } /> + } /> } /> } /> } /> diff --git a/src/api/http.ts b/src/api/http.ts index 9e95e05..bfdc3d7 100644 --- a/src/api/http.ts +++ b/src/api/http.ts @@ -1,5 +1,5 @@ import { apiUrl } from '../lib/apiOrigin' -import { selectedSchoolYearHeaders } from '../lib/schoolYearSelection' +import { selectedSchoolYearHeaders, withSelectedSchoolYearUrl } from '../lib/schoolYearSelection' const TOKEN_KEY = 'alrahma_api_token' @@ -39,6 +39,10 @@ export function applyStoredSchoolYearHeaders(headers: Headers): Headers { return headers } +export function withStoredSchoolYearUrl(url: string): string { + return withSelectedSchoolYearUrl(url) +} + export class ApiHttpError extends Error { readonly status: number readonly body: unknown @@ -73,7 +77,8 @@ export async function apiFetch( applyStoredSchoolYearHeaders(headers) } - const url = apiUrl(path) + const method = String(init.method ?? 'GET').toUpperCase() + const url = apiUrl(method === 'GET' ? withStoredSchoolYearUrl(path) : path) let res: Response diff --git a/src/api/parentProfiles.ts b/src/api/parentProfiles.ts new file mode 100644 index 0000000..99d8560 --- /dev/null +++ b/src/api/parentProfiles.ts @@ -0,0 +1,70 @@ +import { apiFetch } from './http' + +export type ParentProfileSummary = { + id: number + firstname?: string | null + lastname?: string | null + email?: string | null + cellphone?: string | null + is_active?: boolean + families_count?: number + selected_year_students_count?: number + balance?: number + positive_unpaid_balance?: number + credit_balance?: number + primary_family?: { id?: number; household_name?: string | null } | null +} + +export type ParentProfileDetail = { + school_year: string + read_only: boolean + parent: Record | null + families: Record[] + students: Record[] + emergency_contacts: Record[] + invoices: Record[] + payments: Record[] + finance_summary: Record +} + +type Envelope = { data?: T } + +function unwrap(response: T | Envelope): T { + if (response && typeof response === 'object' && 'data' in response) { + return (response as Envelope).data as T + } + return response as T +} + +export async function fetchParentProfiles(params: { + schoolYear: string + q?: string + page?: number + perPage?: number +}): Promise<{ + school_year: string + read_only: boolean + parents: ParentProfileSummary[] + pagination: { page: number; per_page: number; total: number } +}> { + const query = new URLSearchParams() + query.set('school_year', params.schoolYear) + if (params.q?.trim()) query.set('q', params.q.trim()) + if (params.page) query.set('page', String(params.page)) + if (params.perPage) query.set('per_page', String(params.perPage)) + return unwrap(await apiFetch(`/api/v1/parent-profiles?${query.toString()}`)) +} + +export async function fetchParentProfile(parentId: number, schoolYear: string): Promise { + const query = new URLSearchParams({ school_year: schoolYear }) + return unwrap(await apiFetch(`/api/v1/parent-profiles/${parentId}?${query.toString()}`)) +} + +export async function updateParentProfile(parentId: number, schoolYear: string, payload: Record): Promise { + const query = new URLSearchParams({ school_year: schoolYear }) + return unwrap(await apiFetch(`/api/v1/parent-profiles/${parentId}?${query.toString()}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...payload, school_year: schoolYear }), + })) +} diff --git a/src/api/schoolYears.ts b/src/api/schoolYears.ts index c52d457..41a0655 100644 --- a/src/api/schoolYears.ts +++ b/src/api/schoolYears.ts @@ -20,7 +20,11 @@ export type SchoolYearSummary = { students_considered: number students_blocked: number parents_with_balances: number + parents_with_positive_balance?: number net_balance_to_transfer: number + net_balance_impact?: number + total_positive_unpaid_balance?: number + total_credit_balance?: number } } @@ -57,19 +61,29 @@ export type SchoolYearParentBalanceRow = { parent_name?: string | null student_names?: string[] old_unpaid_balance: number + positive_unpaid_balance?: number credit_balance: number + net_balance?: number amount_to_transfer: number + positive_invoice_ids?: number[] + credit_invoice_ids?: number[] + source_invoice_ids?: number[] existing_transfer_conflict?: boolean } export type SchoolYearParentBalancePreview = { + from_school_year?: string + to_school_year?: string rows: SchoolYearParentBalanceRow[] summary: { parents_with_non_zero_balance: number + parents_with_positive_balance?: number parents_with_credit_balance: number total_old_unpaid_balance: number + total_positive_unpaid_balance?: number total_credit_balance: number net_balance_to_transfer: number + net_balance_impact?: number existing_transfer_conflicts: number } } @@ -111,7 +125,9 @@ export type SchoolYearClosingReport = { parents_with_unpaid_balances?: number total_unpaid_balance_transferred?: number parents_with_credit_balances?: number + total_credit_carried_or_reported?: number total_credit_transferred?: number + net_balance_impact?: number } warnings?: string[] } @@ -251,14 +267,17 @@ export async function createSchoolYear(payload: SaveSchoolYearPayload): Promise< } export async function updateSchoolYear( - schoolYearId: number, + schoolYear: string, payload: SaveSchoolYearPayload, ): Promise { - const response = await apiFetch(`/api/v1/school-years/${schoolYearId}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }) + const response = await apiFetch( + `/api/v1/school-years/selected${selectedYearQuery(schoolYear)}`, + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: selectedYearBody(payload, schoolYear), + }, + ) if (!response.data) { throw new Error('School year update returned no record.') @@ -267,8 +286,23 @@ export async function updateSchoolYear( return response.data } -export async function fetchSchoolYearSummary(schoolYearId: number): Promise { - const response = await apiFetch(`/api/v1/school-years/${schoolYearId}/summary`) +function selectedYearQuery(schoolYear: string, extras?: Record): string { + const query = new URLSearchParams() + query.set('school_year', schoolYear) + for (const [key, value] of Object.entries(extras ?? {})) { + if (value) query.set(key, value) + } + return `?${query.toString()}` +} + +function selectedYearBody(payload: object, schoolYear: string): string { + return JSON.stringify({ ...payload, school_year: schoolYear }) +} + +export async function fetchSchoolYearSummary(schoolYear: string): Promise { + const response = await apiFetch( + `/api/v1/school-years/summary${selectedYearQuery(schoolYear)}`, + ) if (!response.data) { throw new Error('School year summary returned no data.') } @@ -276,15 +310,15 @@ export async function fetchSchoolYearSummary(schoolYearId: number): Promise { const response = await apiFetch( - `/api/v1/school-years/${schoolYearId}/validate-close`, + `/api/v1/school-years/validate-close${selectedYearQuery(schoolYear)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), + body: selectedYearBody(payload, schoolYear), }, ) @@ -296,15 +330,15 @@ export async function validateSchoolYearClose( } export async function previewSchoolYearClose( - schoolYearId: number, + schoolYear: string, payload: CloseSchoolYearPayload, ): Promise { const response = await apiFetch( - `/api/v1/school-years/${schoolYearId}/preview-close`, + `/api/v1/school-years/preview-close${selectedYearQuery(schoolYear)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), + body: selectedYearBody(payload, schoolYear), }, ) @@ -316,13 +350,13 @@ export async function previewSchoolYearClose( } export async function closeSchoolYear( - schoolYearId: number, + schoolYear: string, payload: CloseSchoolYearPayload, ): Promise { - const response = await apiFetch(`/api/v1/school-years/${schoolYearId}/close`, { + const response = await apiFetch(`/api/v1/school-years/close${selectedYearQuery(schoolYear)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), + body: selectedYearBody(payload, schoolYear), }) if (!response.data) { @@ -332,10 +366,11 @@ export async function closeSchoolYear( return response.data } -export async function reopenSchoolYear(schoolYearId: number): Promise { - const response = await apiFetch(`/api/v1/school-years/${schoolYearId}/reopen`, { +export async function reopenSchoolYear(schoolYear: string): Promise { + const response = await apiFetch(`/api/v1/school-years/reopen${selectedYearQuery(schoolYear)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ school_year: schoolYear }), }) if (!response.data) { @@ -346,12 +381,11 @@ export async function reopenSchoolYear(schoolYearId: number): Promise { - const query = toSchoolYear ? `?to_school_year=${encodeURIComponent(toSchoolYear)}` : '' const response = await apiFetch( - `/api/v1/school-years/${schoolYearId}/parent-balances${query}`, + `/api/v1/school-years/parent-balances${selectedYearQuery(schoolYear, { to_school_year: toSchoolYear })}`, ) if (!response.data) { @@ -362,12 +396,11 @@ export async function fetchSchoolYearParentBalances( } export async function fetchSchoolYearPromotionPreview( - schoolYearId: number, + schoolYear: string, toSchoolYear?: string, ): Promise { - const query = toSchoolYear ? `?to_school_year=${encodeURIComponent(toSchoolYear)}` : '' const response = await apiFetch( - `/api/v1/school-years/${schoolYearId}/promotion-preview${query}`, + `/api/v1/school-years/promotion-preview${selectedYearQuery(schoolYear, { to_school_year: toSchoolYear })}`, ) if (!response.data) { @@ -379,10 +412,10 @@ export async function fetchSchoolYearPromotionPreview( export async function fetchSchoolYearClosingReport( - schoolYearId: number, + schoolYear: string, ): Promise { const response = await apiFetch( - `/api/v1/school-years/${schoolYearId}/reports/closing`, + `/api/v1/school-years/reports/closing${selectedYearQuery(schoolYear)}`, ) if (!response.data) { @@ -393,10 +426,10 @@ export async function fetchSchoolYearClosingReport( } export async function fetchSchoolYearBalanceTransferReport( - schoolYearId: number, + schoolYear: string, ): Promise { const response = await apiFetch( - `/api/v1/school-years/${schoolYearId}/parent-balances`, + `/api/v1/school-years/parent-balances${selectedYearQuery(schoolYear)}`, ) if (!response.data) { @@ -407,11 +440,11 @@ export async function fetchSchoolYearBalanceTransferReport( } export async function fetchParentStatement(params: { - schoolYearId?: number | null + schoolYear?: string | null parentId?: number | null }): Promise { const query = new URLSearchParams() - if (params.schoolYearId != null) query.set('school_year_id', String(params.schoolYearId)) + if (params.schoolYear) query.set('school_year', params.schoolYear) if (params.parentId != null) query.set('parent_id', String(params.parentId)) const response = await apiFetch( diff --git a/src/api/session.ts b/src/api/session.ts index 0419253..db0c9ec 100644 --- a/src/api/session.ts +++ b/src/api/session.ts @@ -1722,10 +1722,15 @@ export async function sendFamilyComposeEmail(payload: { html: string return_url?: string }): Promise<{ ok?: boolean; message?: string }> { - return apiFetch(`/api/v1/family-admin/compose-email/send`, { + return apiFetch(`/api/v1/family-admin/compose-email`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), + body: JSON.stringify({ + recipient: payload.to.trim(), + subject: payload.subject.trim(), + html_message: payload.html, + return_url: payload.return_url, + }), }) } diff --git a/src/api/staff.ts b/src/api/staff.ts index 2f73b29..fd11f86 100644 --- a/src/api/staff.ts +++ b/src/api/staff.ts @@ -1,10 +1,9 @@ /** - * Staff directory (legacy CI `staff/*`). - * Expected Laravel routes under `/api/v1/administrator/staff`. + * Staff directory. */ import { apiFetch } from './http' -const BASE = '/api/v1/administrator/staff' +const BASE = '/api/v1/staff' function unwrapData(body: T | { data?: T }): T { if (body && typeof body === 'object' && 'data' in body && (body as { data?: T }).data != null) { @@ -24,6 +23,7 @@ export type StaffListRow = { active_role?: string | null class_section?: string | null verification_issue?: boolean + status?: string | null } export type StaffIndexResponse = { @@ -32,12 +32,16 @@ export type StaffIndexResponse = { school_year?: string | null semester?: string | null schoolYears?: string[] + meta?: unknown } -export type RoleOption = { id: number; name: string } +export type RoleOption = { id: number; name: string; label?: string } export type StaffFormOptionsResponse = { roles?: RoleOption[] + statuses?: Array<{ value: string; label: string }> + school_year?: string | null + is_read_only_year?: boolean } export type StaffMemberResponse = { @@ -59,28 +63,45 @@ export type StaffMemberResponse = { export async function fetchStaffIndex(params?: { school_year?: string semester?: string + role?: string + status?: string + search?: string + page?: number + per_page?: number }): Promise { const qs = new URLSearchParams() if (params?.school_year) qs.set('school_year', params.school_year) if (params?.semester) qs.set('semester', params.semester) + if (params?.role) qs.set('role', params.role) + if (params?.status) qs.set('status', params.status) + if (params?.search) qs.set('search', params.search) + if (params?.page) qs.set('page', String(params.page)) + if (params?.per_page) qs.set('per_page', String(params.per_page)) const suffix = qs.toString() ? `?${qs}` : '' return unwrapData(await apiFetch(`${BASE}${suffix}`)) } -export async function fetchStaffFormOptions(): Promise { - return unwrapData(await apiFetch(`${BASE}/form-options`)) +export async function fetchStaffFormOptions(params?: { school_year?: string }): Promise { + const qs = new URLSearchParams() + if (params?.school_year) qs.set('school_year', params.school_year) + return unwrapData(await apiFetch(`${BASE}/form-options${qs.toString() ? `?${qs}` : ''}`)) } -export async function fetchStaffMember(id: number): Promise { - return unwrapData(await apiFetch(`${BASE}/${id}`)) +export async function fetchStaffMember(id: number, params?: { school_year?: string }): Promise { + const qs = new URLSearchParams() + if (params?.school_year) qs.set('school_year', params.school_year) + return unwrapData(await apiFetch(`${BASE}/${id}${qs.toString() ? `?${qs}` : ''}`)) } export async function createStaff(payload: { firstname: string lastname: string email: string + phone?: string password: string role_id: number + status?: string + school_year: string }): Promise<{ ok?: boolean; message?: string }> { return apiFetch(`${BASE}`, { method: 'POST', @@ -91,10 +112,10 @@ export async function createStaff(payload: { export async function updateStaff( id: number, - payload: { firstname: string; lastname: string; email: string; phone?: string }, + payload: { firstname: string; lastname: string; email: string; phone?: string; role_id?: number; status?: string; school_year: string }, ): Promise<{ ok?: boolean; message?: string }> { return apiFetch(`${BASE}/${id}`, { - method: 'PUT', + method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }) diff --git a/src/components/schoolYear/SchoolYearSelector.tsx b/src/components/schoolYear/SchoolYearSelector.tsx index 2bc4a6e..3819241 100644 --- a/src/components/schoolYear/SchoolYearSelector.tsx +++ b/src/components/schoolYear/SchoolYearSelector.tsx @@ -9,7 +9,7 @@ export function SchoolYearSelector({ compact = false }: { compact?: boolean }) { selectedSchoolYear, isLoadingSchoolYears, schoolYearError, - setSelectedSchoolYearId, + setSelectedSchoolYearName, } = useSchoolYear() const selectId = useId() @@ -29,18 +29,17 @@ export function SchoolYearSelector({ compact = false }: { compact?: boolean }) { setQuery(event.target.value)} placeholder="Search parents, email, phone, student" /> + + + + {error ?
{error}
: null}
-
Guardians
-
- {loading ?
Loading guardians…
: guardians.map((guardian) => ( - ))}
@@ -3539,21 +3617,87 @@ export function AdminParentProfilePage() {
-
Families
+
+ {fullName || 'Parent Detail'} + {detailLoading ? Loading... : null} +
- {families.length === 0 ?

Select a guardian to view family details.

: families.map((family) => ( -
-
{String(family.household_name ?? 'Household')}
-
{String(family.address_line1 ?? '')} {String(family.city ?? '')} {String(family.state ?? '')}
-
Phone: {String(family.primary_phone ?? '—')}
-
Students
-
    - {Array.isArray(family.students) && family.students.length > 0 ? family.students.map((student, idx) => ( -
  • {`${String(student.firstname ?? '')} ${String(student.lastname ?? '')}`.trim()} {student.class_section_name ? `(${String(student.class_section_name)})` : ''}
  • - )) :
  • No students linked.
  • } -
+ {!detail ?

Select a parent to view selected-year details.

: null} + {detail && parent ? ( +
+
+ {['firstname', 'lastname', 'email', 'cellphone', 'address_street', 'city', 'state', 'zip'].map((field) => ( +
+ + +
+ ))} +
+ +
+
+ +
+
Balance
{money(detail.finance_summary.balance)}
+
Unpaid
{money(detail.finance_summary.positive_unpaid_balance)}
+
Credit
{money(detail.finance_summary.credit_balance)}
+
+ +
+
Students
+ {detail.students.length === 0 ?

No students for this school year.

: ( + + + {detail.students.map((student) => ( + + + + + ))} + +
{`${String(student.firstname ?? '')} ${String(student.lastname ?? '')}`.trim()}{String(student.class_section_name ?? student.grade ?? '')}
+ )} +
+ +
+
Families
+ {detail.families.length === 0 ?

No selected-year families.

: detail.families.map((family) => ( +
+ {String(family.household_name ?? 'Household')} +
{String(family.address_line1 ?? '')} {String(family.city ?? '')} {String(family.state ?? '')}
+
+ ))} +
+ +
+
Emergency Contacts
+ {detail.emergency_contacts.length === 0 ?

No selected-year emergency contacts.

: detail.emergency_contacts.map((contact) => ( +
+ {String(contact.emergency_contact_name ?? '')} +
{String(contact.relation ?? '')} {String(contact.cellphone ?? '')} {String(contact.email ?? '')}
+
+ ))} +
+ +
+
Invoices
+ {detail.invoices.length === 0 ?

No selected-year invoices.

: ( + + + + {detail.invoices.map((invoice) => ( + + + + + + ))} + +
InvoiceStatusBalance
{String(invoice.invoice_number ?? invoice.id ?? '')}{String(invoice.status ?? '')}{money(invoice.balance)}
+ )} +
- ))} + ) : null}
diff --git a/src/pages/administrator/SchoolYearDetailPage.tsx b/src/pages/administrator/SchoolYearDetailPage.tsx index c1ff877..9aef03b 100644 --- a/src/pages/administrator/SchoolYearDetailPage.tsx +++ b/src/pages/administrator/SchoolYearDetailPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from 'react' -import { Link, useParams } from 'react-router-dom' +import { Link, useSearchParams } from 'react-router-dom' import { ApiHttpError } from '../../api/http' import { fetchSchoolYearParentBalances, @@ -25,19 +25,17 @@ function formatMoney(value: number | null | undefined): string { } export function SchoolYearDetailPage() { - const { yearId } = useParams() - const id = Number(yearId) + const [searchParams] = useSearchParams() + const schoolYear = searchParams.get('school_year')?.trim() ?? '' const [summary, setSummary] = useState(null) const [promotionPreview, setPromotionPreview] = useState(null) const [parentBalances, setParentBalances] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) - const validId = Number.isFinite(id) && id > 0 - useEffect(() => { - if (!validId) { - setError('Invalid school year id.') + if (!schoolYear) { + setError('Missing school_year query parameter.') setLoading(false) return } @@ -49,9 +47,9 @@ export function SchoolYearDetailPage() { ;(async () => { try { const [nextSummary, nextPromotion, nextBalances] = await Promise.all([ - fetchSchoolYearSummary(id), - fetchSchoolYearPromotionPreview(id), - fetchSchoolYearParentBalances(id), + fetchSchoolYearSummary(schoolYear), + fetchSchoolYearPromotionPreview(schoolYear), + fetchSchoolYearParentBalances(schoolYear), ]) if (!cancelled) { setSummary(nextSummary) @@ -68,7 +66,7 @@ export function SchoolYearDetailPage() { return () => { cancelled = true } - }, [id, validId]) + }, [schoolYear]) const totals = summary?.totals const promotion = promotionPreview?.summary @@ -116,14 +114,14 @@ export function SchoolYearDetailPage() {
-
Parents with balances
-
{totals?.parents_with_balances ?? 0}
+
Unpaid carryover parents
+
{totals?.parents_with_positive_balance ?? totals?.parents_with_balances ?? 0}
Net transfer impact
-
${formatMoney(totals?.net_balance_to_transfer)}
+
${formatMoney(totals?.net_balance_impact ?? totals?.net_balance_to_transfer)}
@@ -148,20 +146,23 @@ export function SchoolYearDetailPage() {

Parent balance preview

- {balances?.parents_with_non_zero_balance ?? 0} parents with non-zero balances, - net ${formatMoney(balances?.net_balance_to_transfer)}. + {balances?.parents_with_positive_balance ?? 0} parents with unpaid balances, + {balances?.parents_with_credit_balance ?? 0} parents with credits, + net ${formatMoney(balances?.net_balance_impact ?? balances?.net_balance_to_transfer)}.
- + {rows.length === 0 ? ( - + ) : rows.map((row) => ( - + + + ))} diff --git a/src/pages/administrator/SchoolYearReportsPage.tsx b/src/pages/administrator/SchoolYearReportsPage.tsx index d5b8131..bbc169f 100644 --- a/src/pages/administrator/SchoolYearReportsPage.tsx +++ b/src/pages/administrator/SchoolYearReportsPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from 'react' -import { Link, useParams } from 'react-router-dom' +import { Link, useSearchParams } from 'react-router-dom' import { ApiHttpError } from '../../api/http' import { fetchSchoolYearBalanceTransferReport, @@ -56,7 +56,8 @@ function ClosingReportView({ report }: { report: SchoolYearClosingReport }) {
Graduated
{totals.students_graduated ?? 0}
Parents with unpaid balances
{totals.parents_with_unpaid_balances ?? 0}
Unpaid transferred
${formatMoney(totals.total_unpaid_balance_transferred)}
-
Credit transferred
${formatMoney(totals.total_credit_transferred)}
+
Credits carried or reported
${formatMoney(totals.total_credit_carried_or_reported ?? totals.total_credit_transferred)}
+
Net balance impact
${formatMoney(totals.net_balance_impact)}
{report.warnings?.length ? ( @@ -125,8 +126,8 @@ function BalanceTransfersView({ report }: { report: SchoolYearBalanceTransferRep } export function SchoolYearReportsPage({ reportType }: { reportType: ReportType }) { - const { yearId } = useParams() - const id = Number(yearId) + const [searchParams] = useSearchParams() + const schoolYear = searchParams.get('school_year')?.trim() ?? '' const [closingReport, setClosingReport] = useState(null) const [balanceReport, setBalanceReport] = useState(null) const [loading, setLoading] = useState(true) @@ -138,8 +139,8 @@ export function SchoolYearReportsPage({ reportType }: { reportType: ReportType } ) useEffect(() => { - if (!Number.isFinite(id) || id <= 0) { - setError('Invalid school year id.') + if (!schoolYear) { + setError('Missing school_year query parameter.') setLoading(false) return } @@ -153,10 +154,10 @@ export function SchoolYearReportsPage({ reportType }: { reportType: ReportType } ;(async () => { try { if (reportType === 'closing') { - const report = await fetchSchoolYearClosingReport(id) + const report = await fetchSchoolYearClosingReport(schoolYear) if (!cancelled) setClosingReport(report) } else { - const report = await fetchSchoolYearBalanceTransferReport(id) + const report = await fetchSchoolYearBalanceTransferReport(schoolYear) if (!cancelled) setBalanceReport(report) } } catch (loadError) { @@ -169,7 +170,7 @@ export function SchoolYearReportsPage({ reportType }: { reportType: ReportType } return () => { cancelled = true } - }, [id, reportType, title]) + }, [reportType, schoolYear, title]) return (
@@ -189,7 +190,7 @@ export function SchoolYearReportsPage({ reportType }: { reportType: ReportType } This screen only displays records returned by those routes.

- + Back to summary diff --git a/src/pages/administrator/SchoolYearsManagementPage.tsx b/src/pages/administrator/SchoolYearsManagementPage.tsx index e9e1bfc..43d0f5e 100644 --- a/src/pages/administrator/SchoolYearsManagementPage.tsx +++ b/src/pages/administrator/SchoolYearsManagementPage.tsx @@ -141,7 +141,7 @@ export function SchoolYearsManagementPage() { const [busyEdit, setBusyEdit] = useState(false) const [busyOpenId, setBusyOpenId] = useState(null) const [busyCloseAction, setBusyCloseAction] = useState<'validate' | 'preview' | 'close' | null>(null) - const selectedYearId = Number(searchParams.get('year') ?? 0) || null + const selectedYearName = searchParams.get('school_year')?.trim() || null const activeYear = useMemo( () => years.find((year) => Boolean(year.is_current)) ?? null, @@ -150,13 +150,13 @@ export function SchoolYearsManagementPage() { const selectedYear = useMemo(() => { if (years.length === 0) return null - if (selectedYearId != null) { - return years.find((year) => year.id === selectedYearId) ?? null + if (selectedYearName) { + return years.find((year) => year.name === selectedYearName) ?? null } return activeYear ?? years[0] ?? null - }, [activeYear, selectedYearId, years]) + }, [activeYear, selectedYearName, years]) - async function refreshYears(preferredYearId?: number | null) { + async function refreshYears(preferredYearName?: string | null) { setLoading(true) setLoadingMessage('Loading school years…') setError(null) @@ -166,15 +166,17 @@ export function SchoolYearsManagementPage() { setYears(list) const nextSelectedId = - preferredYearId ?? - (selectedYearId != null && list.some((year) => year.id === selectedYearId) - ? selectedYearId - : list.find((year) => year.is_current)?.id ?? list[0]?.id ?? null) + preferredYearName ?? + (selectedYearName && list.some((year) => year.name === selectedYearName) + ? selectedYearName + : list.find((year) => year.is_current)?.name ?? list[0]?.name ?? null) if (nextSelectedId != null) { setSearchParams((current) => { const next = new URLSearchParams(current) - next.set('year', String(nextSelectedId)) + next.delete('year') + next.delete('school_year_id') + next.set('school_year', nextSelectedId) return next }, { replace: true }) } @@ -238,7 +240,7 @@ export function SchoolYearsManagementPage() { ;(async () => { try { - const nextSummary = await fetchSchoolYearSummary(selectedYear.id) + const nextSummary = await fetchSchoolYearSummary(selectedYear.name) if (!cancelled) { setSummary(nextSummary) } @@ -273,10 +275,12 @@ export function SchoolYearsManagementPage() { setMessages([{ type, message }]) } - function selectYear(yearId: number) { + function selectYear(yearName: string) { setSearchParams((current) => { const next = new URLSearchParams(current) - next.set('year', String(yearId)) + next.delete('year') + next.delete('school_year_id') + next.set('school_year', yearName) return next }) setPromotionPreview(null) @@ -292,7 +296,7 @@ export function SchoolYearsManagementPage() { const created = await createSchoolYear(createForm) pushMessage('success', `Draft school year ${created.name} created.`) setCreateForm(emptyYearForm()) - await refreshYears(created.id) + await refreshYears(created.name) } catch (saveError) { setError(normalizeApiError(saveError, 'Unable to create school year.')) } finally { @@ -306,9 +310,9 @@ export function SchoolYearsManagementPage() { setBusyEdit(true) setError(null) try { - const updated = await updateSchoolYear(selectedYear.id, editForm) + const updated = await updateSchoolYear(selectedYear.name, editForm) pushMessage('success', `School year ${updated.name} updated.`) - await refreshYears(updated.id) + await refreshYears(updated.name) } catch (saveError) { setError(normalizeApiError(saveError, 'Unable to update school year.')) } finally { @@ -320,11 +324,11 @@ export function SchoolYearsManagementPage() { setBusyOpenId(year.id) setError(null) try { - const reopened = await reopenSchoolYear(year.id) + const reopened = await reopenSchoolYear(year.name) pushMessage('success', `School year ${reopened.name} is now active.`) setCloseValidation(null) setClosePreview(null) - await refreshYears(reopened.id) + await refreshYears(reopened.name) } catch (actionError) { setError(normalizeApiError(actionError, 'Unable to open school year.')) } finally { @@ -338,7 +342,7 @@ export function SchoolYearsManagementPage() { setError(null) try { const preview = await fetchSchoolYearPromotionPreview( - selectedYear.id, + selectedYear.name, suggestNextName(selectedYear.name), ) setPromotionPreview(preview) @@ -353,7 +357,7 @@ export function SchoolYearsManagementPage() { setError(null) try { const balances = await fetchSchoolYearParentBalances( - selectedYear.id, + selectedYear.name, suggestNextName(selectedYear.name), ) setParentBalances(balances) @@ -363,11 +367,11 @@ export function SchoolYearsManagementPage() { } async function handleValidateClose() { - if (!activeYear) return + if (!selectedYear?.is_current) return setBusyCloseAction('validate') setError(null) try { - const validation = await validateSchoolYearClose(activeYear.id, closePayloadFrom(closeForm)) + const validation = await validateSchoolYearClose(selectedYear.name, closePayloadFrom(closeForm)) setCloseValidation(validation) setClosePreview(null) setCloseResult(null) @@ -379,11 +383,11 @@ export function SchoolYearsManagementPage() { } async function handlePreviewClose() { - if (!activeYear) return + if (!selectedYear?.is_current) return setBusyCloseAction('preview') setError(null) try { - const preview = await previewSchoolYearClose(activeYear.id, closePayloadFrom(closeForm)) + const preview = await previewSchoolYearClose(selectedYear.name, closePayloadFrom(closeForm)) setCloseValidation(preview.validation) setClosePreview(preview) setCloseResult(null) @@ -396,11 +400,11 @@ export function SchoolYearsManagementPage() { async function handleClose(event: FormEvent) { event.preventDefault() - if (!activeYear) return + if (!selectedYear?.is_current) return setBusyCloseAction('close') setError(null) try { - const result = await closeSchoolYear(activeYear.id, closePayloadFrom(closeForm)) + const result = await closeSchoolYear(selectedYear.name, closePayloadFrom(closeForm)) pushMessage( 'success', `Closed ${result.closed_school_year.name} and opened ${result.new_school_year.name}.`, @@ -411,7 +415,7 @@ export function SchoolYearsManagementPage() { setPromotionPreview(null) setParentBalances(null) setCloseForm(emptyCloseForm()) - await refreshYears(result.new_school_year.id) + await refreshYears(result.new_school_year.name) } catch (actionError) { setError(normalizeApiError(actionError, 'Unable to close school year.')) } finally { @@ -425,8 +429,9 @@ export function SchoolYearsManagementPage() { const closePromotionRows = closePreview?.promotion_preview.rows ?? [] const closeBalanceRows = closePreview?.parent_balances.rows ?? [] const selectedYearReadOnly = isReadOnlySchoolYear(selectedYear) - const requiredConfirmation = activeYear ? `CLOSE ${activeYear.name}` : '' - const confirmationMatches = !activeYear || closeForm.confirmation.trim() === requiredConfirmation + const selectedYearIsActive = Boolean(selectedYear?.is_current) + const requiredConfirmation = selectedYearIsActive && selectedYear ? `CLOSE ${selectedYear.name}` : '' + const confirmationMatches = !selectedYearIsActive || closeForm.confirmation.trim() === requiredConfirmation return (
@@ -484,7 +489,7 @@ export function SchoolYearsManagementPage() { @@ -498,13 +503,13 @@ export function SchoolYearsManagementPage() { Summary @@ -634,14 +639,14 @@ export function SchoolYearsManagementPage() {
-
Parents with balances
-
{summaryCards.parents_with_balances}
+
Unpaid carryover parents
+
{summaryCards.parents_with_positive_balance ?? summaryCards.parents_with_balances}
Net balance impact
-
${formatMoney(summaryCards.net_balance_to_transfer)}
+
${formatMoney(summaryCards.net_balance_impact ?? summaryCards.net_balance_to_transfer)}
@@ -651,13 +656,13 @@ export function SchoolYearsManagementPage() { {selectedYear ? (
- + Open summary page - + Closing report - + Balance transfer report
@@ -828,8 +833,9 @@ export function SchoolYearsManagementPage() { ) : ( <>
- {parentBalances.summary.parents_with_non_zero_balance} parents with - balances, net ${formatMoney(parentBalances.summary.net_balance_to_transfer)}. + Source year: {parentBalances.from_school_year}; target year: {parentBalances.to_school_year}.{' '} + 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)}.
ParentStudentsTransfer
ParentStudentsUnpaidCreditsNet
No balance rows found.
No balance rows found.
{row.parent_name || `Parent #${row.parent_id}`} {row.student_names?.join(', ') || '—'}${formatMoney(row.amount_to_transfer)}${formatMoney(row.positive_unpaid_balance ?? row.amount_to_transfer)}${formatMoney(row.credit_balance)}${formatMoney(row.net_balance)}
@@ -837,13 +843,17 @@ export function SchoolYearsManagementPage() { - + + + + + {balanceRows.length === 0 ? ( - @@ -852,7 +862,11 @@ export function SchoolYearsManagementPage() { - + + + + + )) )} @@ -871,19 +885,23 @@ export function SchoolYearsManagementPage() {
-

Close Active Year

+

Close Selected Active Year

- Validate, preview, and close the current year through the transactional - school-year closure endpoints. + Validate, preview, and close the inspected school year through the transactional + school-year closure endpoints. Only the current active year can be closed.
- {activeYear ? ( - {activeYear.name} + {selectedYearIsActive && selectedYear ? ( + {selectedYear.name} ) : null}
- {!activeYear ? ( -

No active school year is available to close.

+ {!selectedYear ? ( +

Select a school year to close it.

+ ) : !selectedYearIsActive ? ( +
+ Only the current active school year can be closed. You are inspecting {selectedYear.name}, which is {selectedYear.status}. +
) : ( <>
@@ -995,7 +1013,7 @@ export function SchoolYearsManagementPage() {
@@ -1033,10 +1051,10 @@ export function SchoolYearsManagementPage() { Missing decisions: {closeValidation.promotion_summary.hold}
- Parents with balances: {closeValidation.financial_summary.parents_with_non_zero_balance} + Unpaid carryover parents: {closeValidation.financial_summary.parents_with_positive_balance ?? closeValidation.financial_summary.parents_with_non_zero_balance}
- Net transfer: ${formatMoney(closeValidation.financial_summary.net_balance_to_transfer)} + Net impact: ${formatMoney(closeValidation.financial_summary.net_balance_impact ?? closeValidation.financial_summary.net_balance_to_transfer)}
{closeValidation.errors.length > 0 ? ( @@ -1099,13 +1117,19 @@ export function SchoolYearsManagementPage() {

Closure balance preview

+
+ Source year: {closePreview.parent_balances.from_school_year}; target year: {closePreview.parent_balances.to_school_year}. Transfer mode: unpaid balances only. +
Parent StudentsTransferUnpaidCreditsNetSourcesConflict
+ No rows found.
{row.parent_name || `Parent #${row.parent_id}`} {row.student_names?.join(', ') || '—'}${formatMoney(row.amount_to_transfer)}${formatMoney(row.positive_unpaid_balance ?? row.old_unpaid_balance)}${formatMoney(row.credit_balance)}${formatMoney(row.net_balance ?? row.amount_to_transfer)}{(row.positive_invoice_ids ?? row.source_invoice_ids ?? []).join(', ') || '—'}{row.existing_transfer_conflict ? Conflict : '—'}
- + + + + @@ -1113,7 +1137,10 @@ export function SchoolYearsManagementPage() { - + + + + ))} diff --git a/src/pages/parent/ParentStatementPage.tsx b/src/pages/parent/ParentStatementPage.tsx index 92dc573..2ce6bad 100644 --- a/src/pages/parent/ParentStatementPage.tsx +++ b/src/pages/parent/ParentStatementPage.tsx @@ -18,13 +18,13 @@ function formatMoney(value: number | null | undefined): string { } export function ParentStatementPage() { - const { selectedSchoolYearId, selectedSchoolYearName } = useSchoolYear() + const { selectedSchoolYearName } = useSchoolYear() const [statement, setStatement] = useState(null) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) useEffect(() => { - if (selectedSchoolYearId == null) return + if (!selectedSchoolYearName) return let cancelled = false setLoading(true) @@ -32,7 +32,7 @@ export function ParentStatementPage() { ;(async () => { try { - const nextStatement = await fetchParentStatement({ schoolYearId: selectedSchoolYearId }) + const nextStatement = await fetchParentStatement({ schoolYear: selectedSchoolYearName }) if (!cancelled) setStatement(nextStatement) } catch (loadError) { if (!cancelled) setError(normalizeApiError(loadError, 'Unable to load parent statement.')) @@ -44,7 +44,7 @@ export function ParentStatementPage() { return () => { cancelled = true } - }, [selectedSchoolYearId]) + }, [selectedSchoolYearName]) const lines = useMemo(() => statement?.lines ?? [], [statement]) diff --git a/src/pages/staff/StaffCreatePage.tsx b/src/pages/staff/StaffCreatePage.tsx index 4e43f3e..6dcfc0a 100644 --- a/src/pages/staff/StaffCreatePage.tsx +++ b/src/pages/staff/StaffCreatePage.tsx @@ -1,22 +1,27 @@ import { type FormEvent, useEffect, useState } from 'react' import { useNavigate } from 'react-router-dom' import { createStaff, fetchStaffFormOptions, type RoleOption } from '../../api/staff' +import { ReadOnlyBanner, SchoolYearSelector } from '../../components/schoolYear' +import { useSchoolYear } from '../../context/SchoolYearContext' export function StaffCreatePage() { const navigate = useNavigate() + const { selectedSchoolYearName, isReadOnlyYear } = useSchoolYear() const [roles, setRoles] = useState([]) const [firstname, setFirstname] = useState('') const [lastname, setLastname] = useState('') const [email, setEmail] = useState('') + const [phone, setPhone] = useState('') const [password, setPassword] = useState('') const [roleId, setRoleId] = useState(0) + const [status, setStatus] = useState('active') const [error, setError] = useState(null) useEffect(() => { let cancelled = false ;(async () => { try { - const data = await fetchStaffFormOptions() + const data = await fetchStaffFormOptions({ school_year: selectedSchoolYearName ?? undefined }) if (cancelled) return const list = data.roles ?? [] setRoles(list) @@ -28,17 +33,21 @@ export function StaffCreatePage() { return () => { cancelled = true } - }, []) + }, [selectedSchoolYearName]) async function onSubmit(e: FormEvent) { e.preventDefault() + if (!selectedSchoolYearName) { + setError('Please select a school year.') + return + } if (!roleId) { setError('Please select a role.') return } try { - await createStaff({ firstname, lastname, email, password, role_id: roleId }) - navigate('/app/staff') + await createStaff({ firstname, lastname, email, phone, password, role_id: roleId, status, school_year: selectedSchoolYearName }) + navigate(`/app/staff?school_year=${encodeURIComponent(selectedSchoolYearName)}`) } catch (err) { setError(err instanceof Error ? err.message : 'Unable to create staff.') } @@ -47,6 +56,11 @@ export function StaffCreatePage() { return (

Add Staff Member

+
+
School year: {selectedSchoolYearName ?? '—'}
+ +
+ {error ?
{error}
: null}
void onSubmit(e)}>
@@ -89,6 +103,10 @@ export function StaffCreatePage() { required />
+
+ + setPhone(e.target.value)} /> +
setStatus(e.target.value)}> + + + +
+ diff --git a/src/pages/staff/StaffEditPage.tsx b/src/pages/staff/StaffEditPage.tsx index c21ec7d..1f5fd49 100644 --- a/src/pages/staff/StaffEditPage.tsx +++ b/src/pages/staff/StaffEditPage.tsx @@ -1,10 +1,13 @@ import { type FormEvent, useEffect, useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' import { fetchStaffMember, updateStaff } from '../../api/staff' +import { ReadOnlyBanner, SchoolYearSelector } from '../../components/schoolYear' +import { useSchoolYear } from '../../context/SchoolYearContext' export function StaffEditPage() { const { staffId } = useParams() const navigate = useNavigate() + const { selectedSchoolYearName, isReadOnlyYear } = useSchoolYear() const id = Number(staffId) const [firstname, setFirstname] = useState('') const [lastname, setLastname] = useState('') @@ -17,7 +20,7 @@ export function StaffEditPage() { let cancelled = false ;(async () => { try { - const data = await fetchStaffMember(id) + const data = await fetchStaffMember(id, { school_year: selectedSchoolYearName ?? undefined }) if (cancelled) return const u = (data.user ?? data.staff ?? {}) as Record setFirstname(String(u.firstname ?? '')) @@ -32,14 +35,18 @@ export function StaffEditPage() { return () => { cancelled = true } - }, [id]) + }, [id, selectedSchoolYearName]) async function onSubmit(e: FormEvent) { e.preventDefault() if (!id) return try { - await updateStaff(id, { firstname, lastname, email, phone }) - navigate('/app/staff') + if (!selectedSchoolYearName) { + setError('Please select a school year.') + return + } + await updateStaff(id, { firstname, lastname, email, phone, school_year: selectedSchoolYearName }) + navigate(`/app/staff?school_year=${encodeURIComponent(selectedSchoolYearName)}`) } catch (err) { setError(err instanceof Error ? err.message : 'Unable to update staff.') } @@ -48,6 +55,11 @@ export function StaffEditPage() { return (

Edit Staff Member

+
+
School year: {selectedSchoolYearName ?? '—'}
+ +
+ {error ?
{error}
: null}
void onSubmit(e)}>
@@ -91,7 +103,7 @@ export function StaffEditPage() { placeholder="Enter phone number" />
- diff --git a/src/pages/staff/StaffIndexPage.tsx b/src/pages/staff/StaffIndexPage.tsx index 7b63ae8..4e163b6 100644 --- a/src/pages/staff/StaffIndexPage.tsx +++ b/src/pages/staff/StaffIndexPage.tsx @@ -1,51 +1,112 @@ import { useCallback, useEffect, useState } from 'react' import { Link, useSearchParams } from 'react-router-dom' -import { CiAcademicFilter } from '../../components/CiPartials' import { fetchStaffIndex, type StaffListRow } from '../../api/staff' +import { ReadOnlyBanner, SchoolYearSelector } from '../../components/schoolYear' +import { useSchoolYear } from '../../context/SchoolYearContext' export function StaffIndexPage() { const [searchParams, setSearchParams] = useSearchParams() + const { selectedSchoolYearName, isReadOnlyYear } = useSchoolYear() const [rows, setRows] = useState([]) const [issuesCount, setIssuesCount] = useState(0) const [schoolYear, setSchoolYear] = useState(null) - const [schoolYears, setSchoolYears] = useState([]) const [semester, setSemester] = useState(null) + const [search, setSearch] = useState(searchParams.get('search') ?? '') + const [status, setStatus] = useState(searchParams.get('status') ?? '') + const [role, setRole] = useState(searchParams.get('role') ?? '') const [error, setError] = useState(null) const load = useCallback(async () => { + const sy = selectedSchoolYearName ?? searchParams.get('school_year') ?? undefined try { - const sy = searchParams.get('school_year') ?? undefined const sem = searchParams.get('semester') ?? undefined - const data = await fetchStaffIndex({ school_year: sy, semester: sem }) + const data = await fetchStaffIndex({ + school_year: sy, + semester: sem, + search: searchParams.get('search') ?? undefined, + status: searchParams.get('status') ?? undefined, + role: searchParams.get('role') ?? undefined, + }) setRows(data.staff ?? []) setIssuesCount(Number(data.issues_count ?? 0)) setSchoolYear(data.school_year ?? null) setSemester(data.semester ?? null) - setSchoolYears(data.schoolYears ?? []) setError(null) } catch (e) { setError(e instanceof Error ? e.message : 'Unable to load staff.') } - }, [searchParams]) + }, [searchParams, selectedSchoolYearName]) + + useEffect(() => { + if (!selectedSchoolYearName) return + setSearchParams((current) => { + const next = new URLSearchParams(current) + next.delete('school_year_id') + next.delete('year') + next.set('school_year', selectedSchoolYearName) + return next.toString() === current.toString() ? current : next + }, { replace: true }) + }, [selectedSchoolYearName, setSearchParams]) useEffect(() => { void load() }, [load]) + function applyFilters() { + const next = new URLSearchParams() + if (selectedSchoolYearName) next.set('school_year', selectedSchoolYearName) + if (semester) next.set('semester', semester) + if (search.trim()) next.set('search', search.trim()) + if (status) next.set('status', status) + if (role) next.set('role', role) + setSearchParams(next) + } + + const selectedYear = selectedSchoolYearName ?? schoolYear ?? '' + const selectedQuery = selectedYear ? `?school_year=${encodeURIComponent(selectedYear)}` : '' + return (
-

Staff List

- 0 ? schoolYears : schoolYear ? [schoolYear] : []} - selectedYear={searchParams.get('school_year') ?? schoolYear ?? undefined} - selectedSemester={searchParams.get('semester') ?? semester ?? undefined} - onApply={({ schoolYear: y, semester: sem }) => { - const next = new URLSearchParams() - if (y) next.set('school_year', y) - if (sem) next.set('semester', sem) - setSearchParams(next) - }} - /> +
+
+

Staff List

+
School year: {selectedYear || '—'}
+
+
+ + {!isReadOnlyYear ? ( + Add Staff + ) : null} +
+
+ + + +
+
+ + setSearch(event.target.value)} /> +
+
+ + setSemester(event.target.value)} placeholder="Fall" /> +
+
+ + setRole(event.target.value)} /> +
+
+ + +
+
+ +
+
{issuesCount > 0 ? (
@@ -83,7 +144,7 @@ export function StaffIndexPage() {
Parent StudentsTransferUnpaidCreditsNetConflict
{row.parent_name || `Parent #${row.parent_id}`} {row.student_names?.join(', ') || '—'}${formatMoney(row.amount_to_transfer)}${formatMoney(row.positive_unpaid_balance ?? row.old_unpaid_balance)}${formatMoney(row.credit_balance)}${formatMoney(row.net_balance ?? row.amount_to_transfer)}{row.existing_transfer_conflict ? Conflict : '—'}
{s.active_role ?? ''} {s.class_section ?? ''} - + Edit