fix apply the plan docs/alrahma_api_fix_plan_school_year_only_v7
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Successful in 29s
Web Client CI/CD / Build (tsc + Vite) (push) Successful in 30s
Web Client CI/CD / Deploy to shared hosting (push) Successful in 33s

This commit is contained in:
root
2026-07-07 01:52:37 -04:00
parent b510f534fd
commit 4b78aafa65
18 changed files with 664 additions and 256 deletions
+3 -3
View File
@@ -1170,13 +1170,13 @@ export default function App() {
<Route path="administrator/school-years" element={<SchoolYearsManagementPage />} /> <Route path="administrator/school-years" element={<SchoolYearsManagementPage />} />
<Route path="admin/school-years" element={<SchoolYearsManagementPage />} /> <Route path="admin/school-years" element={<SchoolYearsManagementPage />} />
<Route path="admin/school-years/close" element={<SchoolYearsManagementPage />} /> <Route path="admin/school-years/close" element={<SchoolYearsManagementPage />} />
<Route path="admin/school-years/:yearId/summary" element={<SchoolYearDetailPage />} /> <Route path="admin/school-years/summary" element={<SchoolYearDetailPage />} />
<Route <Route
path="admin/school-years/:yearId/reports/closing" path="admin/school-years/reports/closing"
element={<SchoolYearClosingReportPage />} element={<SchoolYearClosingReportPage />}
/> />
<Route <Route
path="admin/school-years/:yearId/reports/balance-transfers" path="admin/school-years/reports/balance-transfers"
element={<SchoolYearBalanceTransfersReportPage />} element={<SchoolYearBalanceTransfersReportPage />}
/> />
<Route path="administrator/discounts" element={<DiscountListPage />} /> <Route path="administrator/discounts" element={<DiscountListPage />} />
+7 -2
View File
@@ -1,5 +1,5 @@
import { apiUrl } from '../lib/apiOrigin' import { apiUrl } from '../lib/apiOrigin'
import { selectedSchoolYearHeaders } from '../lib/schoolYearSelection' import { selectedSchoolYearHeaders, withSelectedSchoolYearUrl } from '../lib/schoolYearSelection'
const TOKEN_KEY = 'alrahma_api_token' const TOKEN_KEY = 'alrahma_api_token'
@@ -39,6 +39,10 @@ export function applyStoredSchoolYearHeaders(headers: Headers): Headers {
return headers return headers
} }
export function withStoredSchoolYearUrl(url: string): string {
return withSelectedSchoolYearUrl(url)
}
export class ApiHttpError extends Error { export class ApiHttpError extends Error {
readonly status: number readonly status: number
readonly body: unknown readonly body: unknown
@@ -73,7 +77,8 @@ export async function apiFetch<T>(
applyStoredSchoolYearHeaders(headers) applyStoredSchoolYearHeaders(headers)
} }
const url = apiUrl(path) const method = String(init.method ?? 'GET').toUpperCase()
const url = apiUrl(method === 'GET' ? withStoredSchoolYearUrl(path) : path)
let res: Response let res: Response
+70
View File
@@ -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<string, unknown> | null
families: Record<string, unknown>[]
students: Record<string, unknown>[]
emergency_contacts: Record<string, unknown>[]
invoices: Record<string, unknown>[]
payments: Record<string, unknown>[]
finance_summary: Record<string, unknown>
}
type Envelope<T> = { data?: T }
function unwrap<T>(response: T | Envelope<T>): T {
if (response && typeof response === 'object' && 'data' in response) {
return (response as Envelope<T>).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<ParentProfileDetail> {
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<string, unknown>): Promise<ParentProfileDetail> {
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 }),
}))
}
+64 -31
View File
@@ -20,7 +20,11 @@ export type SchoolYearSummary = {
students_considered: number students_considered: number
students_blocked: number students_blocked: number
parents_with_balances: number parents_with_balances: number
parents_with_positive_balance?: number
net_balance_to_transfer: 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 parent_name?: string | null
student_names?: string[] student_names?: string[]
old_unpaid_balance: number old_unpaid_balance: number
positive_unpaid_balance?: number
credit_balance: number credit_balance: number
net_balance?: number
amount_to_transfer: number amount_to_transfer: number
positive_invoice_ids?: number[]
credit_invoice_ids?: number[]
source_invoice_ids?: number[]
existing_transfer_conflict?: boolean existing_transfer_conflict?: boolean
} }
export type SchoolYearParentBalancePreview = { export type SchoolYearParentBalancePreview = {
from_school_year?: string
to_school_year?: string
rows: SchoolYearParentBalanceRow[] rows: SchoolYearParentBalanceRow[]
summary: { summary: {
parents_with_non_zero_balance: number parents_with_non_zero_balance: number
parents_with_positive_balance?: number
parents_with_credit_balance: number parents_with_credit_balance: number
total_old_unpaid_balance: number total_old_unpaid_balance: number
total_positive_unpaid_balance?: number
total_credit_balance: number total_credit_balance: number
net_balance_to_transfer: number net_balance_to_transfer: number
net_balance_impact?: number
existing_transfer_conflicts: number existing_transfer_conflicts: number
} }
} }
@@ -111,7 +125,9 @@ export type SchoolYearClosingReport = {
parents_with_unpaid_balances?: number parents_with_unpaid_balances?: number
total_unpaid_balance_transferred?: number total_unpaid_balance_transferred?: number
parents_with_credit_balances?: number parents_with_credit_balances?: number
total_credit_carried_or_reported?: number
total_credit_transferred?: number total_credit_transferred?: number
net_balance_impact?: number
} }
warnings?: string[] warnings?: string[]
} }
@@ -251,14 +267,17 @@ export async function createSchoolYear(payload: SaveSchoolYearPayload): Promise<
} }
export async function updateSchoolYear( export async function updateSchoolYear(
schoolYearId: number, schoolYear: string,
payload: SaveSchoolYearPayload, payload: SaveSchoolYearPayload,
): Promise<SchoolYearRecord> { ): Promise<SchoolYearRecord> {
const response = await apiFetch<ApiRecordResponse>(`/api/v1/school-years/${schoolYearId}`, { const response = await apiFetch<ApiRecordResponse>(
method: 'PATCH', `/api/v1/school-years/selected${selectedYearQuery(schoolYear)}`,
headers: { 'Content-Type': 'application/json' }, {
body: JSON.stringify(payload), method: 'PATCH',
}) headers: { 'Content-Type': 'application/json' },
body: selectedYearBody(payload, schoolYear),
},
)
if (!response.data) { if (!response.data) {
throw new Error('School year update returned no record.') throw new Error('School year update returned no record.')
@@ -267,8 +286,23 @@ export async function updateSchoolYear(
return response.data return response.data
} }
export async function fetchSchoolYearSummary(schoolYearId: number): Promise<SchoolYearSummary> { function selectedYearQuery(schoolYear: string, extras?: Record<string, string | undefined>): string {
const response = await apiFetch<ApiSummaryResponse>(`/api/v1/school-years/${schoolYearId}/summary`) 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<SchoolYearSummary> {
const response = await apiFetch<ApiSummaryResponse>(
`/api/v1/school-years/summary${selectedYearQuery(schoolYear)}`,
)
if (!response.data) { if (!response.data) {
throw new Error('School year summary returned no data.') throw new Error('School year summary returned no data.')
} }
@@ -276,15 +310,15 @@ export async function fetchSchoolYearSummary(schoolYearId: number): Promise<Scho
} }
export async function validateSchoolYearClose( export async function validateSchoolYearClose(
schoolYearId: number, schoolYear: string,
payload: CloseSchoolYearPayload, payload: CloseSchoolYearPayload,
): Promise<SchoolYearCloseValidation> { ): Promise<SchoolYearCloseValidation> {
const response = await apiFetch<ApiValidationResponse>( const response = await apiFetch<ApiValidationResponse>(
`/api/v1/school-years/${schoolYearId}/validate-close`, `/api/v1/school-years/validate-close${selectedYearQuery(schoolYear)}`,
{ {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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( export async function previewSchoolYearClose(
schoolYearId: number, schoolYear: string,
payload: CloseSchoolYearPayload, payload: CloseSchoolYearPayload,
): Promise<SchoolYearClosePreview> { ): Promise<SchoolYearClosePreview> {
const response = await apiFetch<ApiPreviewResponse>( const response = await apiFetch<ApiPreviewResponse>(
`/api/v1/school-years/${schoolYearId}/preview-close`, `/api/v1/school-years/preview-close${selectedYearQuery(schoolYear)}`,
{ {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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( export async function closeSchoolYear(
schoolYearId: number, schoolYear: string,
payload: CloseSchoolYearPayload, payload: CloseSchoolYearPayload,
): Promise<SchoolYearCloseResult> { ): Promise<SchoolYearCloseResult> {
const response = await apiFetch<ApiCloseResponse>(`/api/v1/school-years/${schoolYearId}/close`, { const response = await apiFetch<ApiCloseResponse>(`/api/v1/school-years/close${selectedYearQuery(schoolYear)}`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: selectedYearBody(payload, schoolYear),
}) })
if (!response.data) { if (!response.data) {
@@ -332,10 +366,11 @@ export async function closeSchoolYear(
return response.data return response.data
} }
export async function reopenSchoolYear(schoolYearId: number): Promise<SchoolYearRecord> { export async function reopenSchoolYear(schoolYear: string): Promise<SchoolYearRecord> {
const response = await apiFetch<ApiRecordResponse>(`/api/v1/school-years/${schoolYearId}/reopen`, { const response = await apiFetch<ApiRecordResponse>(`/api/v1/school-years/reopen${selectedYearQuery(schoolYear)}`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ school_year: schoolYear }),
}) })
if (!response.data) { if (!response.data) {
@@ -346,12 +381,11 @@ export async function reopenSchoolYear(schoolYearId: number): Promise<SchoolYear
} }
export async function fetchSchoolYearParentBalances( export async function fetchSchoolYearParentBalances(
schoolYearId: number, schoolYear: string,
toSchoolYear?: string, toSchoolYear?: string,
): Promise<SchoolYearParentBalancePreview> { ): Promise<SchoolYearParentBalancePreview> {
const query = toSchoolYear ? `?to_school_year=${encodeURIComponent(toSchoolYear)}` : ''
const response = await apiFetch<ApiParentBalancesResponse>( const response = await apiFetch<ApiParentBalancesResponse>(
`/api/v1/school-years/${schoolYearId}/parent-balances${query}`, `/api/v1/school-years/parent-balances${selectedYearQuery(schoolYear, { to_school_year: toSchoolYear })}`,
) )
if (!response.data) { if (!response.data) {
@@ -362,12 +396,11 @@ export async function fetchSchoolYearParentBalances(
} }
export async function fetchSchoolYearPromotionPreview( export async function fetchSchoolYearPromotionPreview(
schoolYearId: number, schoolYear: string,
toSchoolYear?: string, toSchoolYear?: string,
): Promise<SchoolYearPromotionPreview> { ): Promise<SchoolYearPromotionPreview> {
const query = toSchoolYear ? `?to_school_year=${encodeURIComponent(toSchoolYear)}` : ''
const response = await apiFetch<ApiPromotionPreviewResponse>( const response = await apiFetch<ApiPromotionPreviewResponse>(
`/api/v1/school-years/${schoolYearId}/promotion-preview${query}`, `/api/v1/school-years/promotion-preview${selectedYearQuery(schoolYear, { to_school_year: toSchoolYear })}`,
) )
if (!response.data) { if (!response.data) {
@@ -379,10 +412,10 @@ export async function fetchSchoolYearPromotionPreview(
export async function fetchSchoolYearClosingReport( export async function fetchSchoolYearClosingReport(
schoolYearId: number, schoolYear: string,
): Promise<SchoolYearClosingReport> { ): Promise<SchoolYearClosingReport> {
const response = await apiFetch<ApiClosingReportResponse>( const response = await apiFetch<ApiClosingReportResponse>(
`/api/v1/school-years/${schoolYearId}/reports/closing`, `/api/v1/school-years/reports/closing${selectedYearQuery(schoolYear)}`,
) )
if (!response.data) { if (!response.data) {
@@ -393,10 +426,10 @@ export async function fetchSchoolYearClosingReport(
} }
export async function fetchSchoolYearBalanceTransferReport( export async function fetchSchoolYearBalanceTransferReport(
schoolYearId: number, schoolYear: string,
): Promise<SchoolYearBalanceTransferReport> { ): Promise<SchoolYearBalanceTransferReport> {
const response = await apiFetch<ApiBalanceTransferReportResponse>( const response = await apiFetch<ApiBalanceTransferReportResponse>(
`/api/v1/school-years/${schoolYearId}/parent-balances`, `/api/v1/school-years/parent-balances${selectedYearQuery(schoolYear)}`,
) )
if (!response.data) { if (!response.data) {
@@ -407,11 +440,11 @@ export async function fetchSchoolYearBalanceTransferReport(
} }
export async function fetchParentStatement(params: { export async function fetchParentStatement(params: {
schoolYearId?: number | null schoolYear?: string | null
parentId?: number | null parentId?: number | null
}): Promise<ParentStatement> { }): Promise<ParentStatement> {
const query = new URLSearchParams() 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)) if (params.parentId != null) query.set('parent_id', String(params.parentId))
const response = await apiFetch<ApiParentStatementResponse>( const response = await apiFetch<ApiParentStatementResponse>(
+7 -2
View File
@@ -1722,10 +1722,15 @@ export async function sendFamilyComposeEmail(payload: {
html: string html: string
return_url?: string return_url?: string
}): Promise<{ ok?: boolean; message?: 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', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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,
}),
}) })
} }
+31 -10
View File
@@ -1,10 +1,9 @@
/** /**
* Staff directory (legacy CI `staff/*`). * Staff directory.
* Expected Laravel routes under `/api/v1/administrator/staff`.
*/ */
import { apiFetch } from './http' import { apiFetch } from './http'
const BASE = '/api/v1/administrator/staff' const BASE = '/api/v1/staff'
function unwrapData<T>(body: T | { data?: T }): T { function unwrapData<T>(body: T | { data?: T }): T {
if (body && typeof body === 'object' && 'data' in body && (body as { data?: T }).data != null) { 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 active_role?: string | null
class_section?: string | null class_section?: string | null
verification_issue?: boolean verification_issue?: boolean
status?: string | null
} }
export type StaffIndexResponse = { export type StaffIndexResponse = {
@@ -32,12 +32,16 @@ export type StaffIndexResponse = {
school_year?: string | null school_year?: string | null
semester?: string | null semester?: string | null
schoolYears?: string[] schoolYears?: string[]
meta?: unknown
} }
export type RoleOption = { id: number; name: string } export type RoleOption = { id: number; name: string; label?: string }
export type StaffFormOptionsResponse = { export type StaffFormOptionsResponse = {
roles?: RoleOption[] roles?: RoleOption[]
statuses?: Array<{ value: string; label: string }>
school_year?: string | null
is_read_only_year?: boolean
} }
export type StaffMemberResponse = { export type StaffMemberResponse = {
@@ -59,28 +63,45 @@ export type StaffMemberResponse = {
export async function fetchStaffIndex(params?: { export async function fetchStaffIndex(params?: {
school_year?: string school_year?: string
semester?: string semester?: string
role?: string
status?: string
search?: string
page?: number
per_page?: number
}): Promise<StaffIndexResponse> { }): Promise<StaffIndexResponse> {
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 (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}` : '' const suffix = qs.toString() ? `?${qs}` : ''
return unwrapData(await apiFetch(`${BASE}${suffix}`)) return unwrapData(await apiFetch(`${BASE}${suffix}`))
} }
export async function fetchStaffFormOptions(): Promise<StaffFormOptionsResponse> { export async function fetchStaffFormOptions(params?: { school_year?: string }): Promise<StaffFormOptionsResponse> {
return unwrapData(await apiFetch(`${BASE}/form-options`)) 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<StaffMemberResponse> { export async function fetchStaffMember(id: number, params?: { school_year?: string }): Promise<StaffMemberResponse> {
return unwrapData(await apiFetch(`${BASE}/${id}`)) 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: { export async function createStaff(payload: {
firstname: string firstname: string
lastname: string lastname: string
email: string email: string
phone?: string
password: string password: string
role_id: number role_id: number
status?: string
school_year: string
}): Promise<{ ok?: boolean; message?: string }> { }): Promise<{ ok?: boolean; message?: string }> {
return apiFetch(`${BASE}`, { return apiFetch(`${BASE}`, {
method: 'POST', method: 'POST',
@@ -91,10 +112,10 @@ export async function createStaff(payload: {
export async function updateStaff( export async function updateStaff(
id: number, 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 }> { ): Promise<{ ok?: boolean; message?: string }> {
return apiFetch(`${BASE}/${id}`, { return apiFetch(`${BASE}/${id}`, {
method: 'PUT', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
}) })
@@ -9,7 +9,7 @@ export function SchoolYearSelector({ compact = false }: { compact?: boolean }) {
selectedSchoolYear, selectedSchoolYear,
isLoadingSchoolYears, isLoadingSchoolYears,
schoolYearError, schoolYearError,
setSelectedSchoolYearId, setSelectedSchoolYearName,
} = useSchoolYear() } = useSchoolYear()
const selectId = useId() const selectId = useId()
@@ -29,18 +29,17 @@ export function SchoolYearSelector({ compact = false }: { compact?: boolean }) {
<select <select
id={selectId} id={selectId}
className="form-select form-select-sm" className="form-select form-select-sm"
value={selectedSchoolYear?.id ?? ''} value={selectedSchoolYear?.name ?? ''}
disabled={isLoadingSchoolYears || schoolYears.length === 0} disabled={isLoadingSchoolYears || schoolYears.length === 0}
onChange={(event) => { onChange={(event) => {
const id = Number(event.target.value) setSelectedSchoolYearName(event.target.value || null)
setSelectedSchoolYearId(Number.isFinite(id) && id > 0 ? id : null)
}} }}
> >
{isLoadingSchoolYears && schoolYears.length === 0 ? ( {isLoadingSchoolYears && schoolYears.length === 0 ? (
<option value="">Loading</option> <option value="">Loading</option>
) : null} ) : null}
{schoolYears.map((year) => ( {schoolYears.map((year) => (
<option key={year.id} value={year.id}> <option key={year.name} value={year.name}>
{year.name} {year.name}
{isCurrentSchoolYear(year) ? ' Current' : ''} {isCurrentSchoolYear(year) ? ' Current' : ''}
{year.status && !isCurrentSchoolYear(year) ? ` ${year.status}` : ''} {year.status && !isCurrentSchoolYear(year) ? ` ${year.status}` : ''}
+13 -29
View File
@@ -12,7 +12,6 @@ import { fetchSchoolYears, type SchoolYearRecord } from '../api/schoolYears'
import { useAuth } from '../auth/AuthProvider' import { useAuth } from '../auth/AuthProvider'
import { import {
clearSelectedSchoolYear, clearSelectedSchoolYear,
getStoredSelectedSchoolYearId,
getStoredSelectedSchoolYearName, getStoredSelectedSchoolYearName,
isCurrentSchoolYear, isCurrentSchoolYear,
isReadOnlySchoolYear, isReadOnlySchoolYear,
@@ -22,24 +21,17 @@ import {
type SchoolYearContextValue = { type SchoolYearContextValue = {
schoolYears: SchoolYearRecord[] schoolYears: SchoolYearRecord[]
selectedSchoolYear: SchoolYearRecord | null selectedSchoolYear: SchoolYearRecord | null
selectedSchoolYearId: number | null
selectedSchoolYearName: string | null selectedSchoolYearName: string | null
activeSchoolYear: SchoolYearRecord | null activeSchoolYear: SchoolYearRecord | null
isLoadingSchoolYears: boolean isLoadingSchoolYears: boolean
schoolYearError: string | null schoolYearError: string | null
isReadOnlyYear: boolean isReadOnlyYear: boolean
setSelectedSchoolYearId: (id: number | null) => void setSelectedSchoolYearName: (name: string | null) => void
refreshSchoolYears: () => Promise<void> refreshSchoolYears: () => Promise<void>
} }
const SchoolYearContext = createContext<SchoolYearContextValue | null>(null) const SchoolYearContext = createContext<SchoolYearContextValue | null>(null)
function yearIdFromSearchParams(searchParams: URLSearchParams): number | null {
const raw = searchParams.get('school_year_id') ?? searchParams.get('year') ?? ''
const id = Number(raw)
return Number.isFinite(id) && id > 0 ? id : null
}
function yearNameFromSearchParams(searchParams: URLSearchParams): string | null { function yearNameFromSearchParams(searchParams: URLSearchParams): string | null {
return ( return (
searchParams.get('school_year')?.trim() || searchParams.get('school_year')?.trim() ||
@@ -51,24 +43,16 @@ function yearNameFromSearchParams(searchParams: URLSearchParams): string | null
function resolveSelectedYear(params: { function resolveSelectedYear(params: {
years: SchoolYearRecord[] years: SchoolYearRecord[]
searchParams: URLSearchParams searchParams: URLSearchParams
requestedId: number | null requestedName: string | null
}): SchoolYearRecord | null { }): SchoolYearRecord | null {
const { years, searchParams, requestedId } = params const { years, searchParams, requestedName } = params
if (years.length === 0) return null if (years.length === 0) return null
const urlId = yearIdFromSearchParams(searchParams)
const storedId = getStoredSelectedSchoolYearId()
const urlName = yearNameFromSearchParams(searchParams) const urlName = yearNameFromSearchParams(searchParams)
const storedName = getStoredSelectedSchoolYearName() const storedName = getStoredSelectedSchoolYearName()
const byRequestedId = requestedId != null ? years.find((year) => year.id === requestedId) : null const byRequestedName = requestedName ? years.find((year) => year.name === requestedName) : null
if (byRequestedId) return byRequestedId if (byRequestedName) return byRequestedName
const byUrlId = urlId != null ? years.find((year) => year.id === urlId) : null
if (byUrlId) return byUrlId
const byStoredId = storedId != null ? years.find((year) => year.id === storedId) : null
if (byStoredId) return byStoredId
const byUrlName = urlName ? years.find((year) => year.name === urlName) : null const byUrlName = urlName ? years.find((year) => year.name === urlName) : null
if (byUrlName) return byUrlName if (byUrlName) return byUrlName
@@ -83,7 +67,7 @@ export function SchoolYearProvider({ children }: { children: ReactNode }) {
const { token } = useAuth() const { token } = useAuth()
const [searchParams, setSearchParams] = useSearchParams() const [searchParams, setSearchParams] = useSearchParams()
const [schoolYears, setSchoolYears] = useState<SchoolYearRecord[]>([]) const [schoolYears, setSchoolYears] = useState<SchoolYearRecord[]>([])
const [requestedYearId, setRequestedYearId] = useState<number | null>(null) const [requestedYearName, setRequestedYearName] = useState<string | null>(null)
const [isLoadingSchoolYears, setIsLoadingSchoolYears] = useState(false) const [isLoadingSchoolYears, setIsLoadingSchoolYears] = useState(false)
const [schoolYearError, setSchoolYearError] = useState<string | null>(null) const [schoolYearError, setSchoolYearError] = useState<string | null>(null)
@@ -118,9 +102,9 @@ export function SchoolYearProvider({ children }: { children: ReactNode }) {
resolveSelectedYear({ resolveSelectedYear({
years: schoolYears, years: schoolYears,
searchParams, searchParams,
requestedId: requestedYearId, requestedName: requestedYearName,
}), }),
[requestedYearId, schoolYears, searchParams], [requestedYearName, schoolYears, searchParams],
) )
const activeSchoolYear = useMemo( const activeSchoolYear = useMemo(
@@ -135,14 +119,15 @@ export function SchoolYearProvider({ children }: { children: ReactNode }) {
setSearchParams((current) => { setSearchParams((current) => {
const next = new URLSearchParams(current) const next = new URLSearchParams(current)
const currentId = next.get('school_year_id')
const currentName = next.get('school_year') const currentName = next.get('school_year')
if (currentId === String(selectedSchoolYear.id) && currentName === selectedSchoolYear.name) { next.delete('school_year_id')
next.delete('year')
if (currentName === selectedSchoolYear.name && next.toString() === current.toString()) {
return current return current
} }
next.set('school_year_id', String(selectedSchoolYear.id))
next.set('school_year', selectedSchoolYear.name) next.set('school_year', selectedSchoolYear.name)
return next return next
}, { replace: true }) }, { replace: true })
@@ -151,13 +136,12 @@ export function SchoolYearProvider({ children }: { children: ReactNode }) {
const value = useMemo<SchoolYearContextValue>(() => ({ const value = useMemo<SchoolYearContextValue>(() => ({
schoolYears, schoolYears,
selectedSchoolYear, selectedSchoolYear,
selectedSchoolYearId: selectedSchoolYear?.id ?? null,
selectedSchoolYearName: selectedSchoolYear?.name ?? null, selectedSchoolYearName: selectedSchoolYear?.name ?? null,
activeSchoolYear, activeSchoolYear,
isLoadingSchoolYears, isLoadingSchoolYears,
schoolYearError, schoolYearError,
isReadOnlyYear: isReadOnlySchoolYear(selectedSchoolYear), isReadOnlyYear: isReadOnlySchoolYear(selectedSchoolYear),
setSelectedSchoolYearId: setRequestedYearId, setSelectedSchoolYearName: setRequestedYearName,
refreshSchoolYears, refreshSchoolYears,
}), [ }), [
activeSchoolYear, activeSchoolYear,
+27
View File
@@ -0,0 +1,27 @@
import { useMemo } from 'react'
import { useSchoolYear } from '../context/SchoolYearContext'
export function useSelectedSchoolYearParams() {
const { selectedSchoolYear, selectedSchoolYearName, isReadOnlyYear } = useSchoolYear()
return useMemo(() => {
const schoolYear = selectedSchoolYearName
const query = new URLSearchParams()
const headers: Record<string, string> = {}
if (schoolYear) {
query.set('school_year', schoolYear)
headers['X-School-Year'] = schoolYear
}
const queryString = query.toString()
return {
schoolYear,
query,
queryString,
headers,
isReadOnly: selectedSchoolYear ? isReadOnlyYear : false,
}
}, [isReadOnlyYear, selectedSchoolYear, selectedSchoolYearName])
}
+15 -22
View File
@@ -1,6 +1,5 @@
import type { SchoolYearRecord } from '../api/schoolYears' import type { SchoolYearRecord } from '../api/schoolYears'
export const SELECTED_SCHOOL_YEAR_ID_STORAGE_KEY = 'alrahma_selected_school_year_id'
export const SELECTED_SCHOOL_YEAR_NAME_STORAGE_KEY = 'alrahma_selected_school_year_name' export const SELECTED_SCHOOL_YEAR_NAME_STORAGE_KEY = 'alrahma_selected_school_year_name'
export const SELECTED_SCHOOL_YEAR_STATUS_STORAGE_KEY = 'alrahma_selected_school_year_status' export const SELECTED_SCHOOL_YEAR_STATUS_STORAGE_KEY = 'alrahma_selected_school_year_status'
@@ -25,13 +24,6 @@ export function isReadOnlySchoolYear(year: SchoolYearRecord | null | undefined):
return year ? isReadOnlySchoolYearStatus(year.status) : false return year ? isReadOnlySchoolYearStatus(year.status) : false
} }
export function getStoredSelectedSchoolYearId(): number | null {
const storage = safeLocalStorage()
const raw = storage?.getItem(SELECTED_SCHOOL_YEAR_ID_STORAGE_KEY) ?? ''
const id = Number(raw)
return Number.isFinite(id) && id > 0 ? id : null
}
export function getStoredSelectedSchoolYearName(): string | null { export function getStoredSelectedSchoolYearName(): string | null {
const storage = safeLocalStorage() const storage = safeLocalStorage()
const value = storage?.getItem(SELECTED_SCHOOL_YEAR_NAME_STORAGE_KEY)?.trim() ?? '' const value = storage?.getItem(SELECTED_SCHOOL_YEAR_NAME_STORAGE_KEY)?.trim() ?? ''
@@ -49,13 +41,11 @@ export function storeSelectedSchoolYear(year: SchoolYearRecord | null): void {
if (!storage) return if (!storage) return
if (!year) { if (!year) {
storage.removeItem(SELECTED_SCHOOL_YEAR_ID_STORAGE_KEY)
storage.removeItem(SELECTED_SCHOOL_YEAR_NAME_STORAGE_KEY) storage.removeItem(SELECTED_SCHOOL_YEAR_NAME_STORAGE_KEY)
storage.removeItem(SELECTED_SCHOOL_YEAR_STATUS_STORAGE_KEY) storage.removeItem(SELECTED_SCHOOL_YEAR_STATUS_STORAGE_KEY)
return return
} }
storage.setItem(SELECTED_SCHOOL_YEAR_ID_STORAGE_KEY, String(year.id))
storage.setItem(SELECTED_SCHOOL_YEAR_NAME_STORAGE_KEY, year.name) storage.setItem(SELECTED_SCHOOL_YEAR_NAME_STORAGE_KEY, year.name)
storage.setItem(SELECTED_SCHOOL_YEAR_STATUS_STORAGE_KEY, year.status) storage.setItem(SELECTED_SCHOOL_YEAR_STATUS_STORAGE_KEY, year.status)
} }
@@ -65,19 +55,12 @@ export function clearSelectedSchoolYear(): void {
} }
export function selectedSchoolYearHeaders(): Record<string, string> { export function selectedSchoolYearHeaders(): Record<string, string> {
const id = getStoredSelectedSchoolYearId()
const name = getStoredSelectedSchoolYearName() const name = getStoredSelectedSchoolYearName()
const status = getStoredSelectedSchoolYearStatus() const status = getStoredSelectedSchoolYearStatus()
const headers: Record<string, string> = {} const headers: Record<string, string> = {}
if (id != null) {
headers['X-School-Year-Id'] = String(id)
headers['X-Selected-School-Year-Id'] = String(id)
}
if (name) { if (name) {
headers['X-School-Year'] = name headers['X-School-Year'] = name
headers['X-Selected-School-Year'] = name
} }
if (status) { if (status) {
@@ -88,16 +71,11 @@ export function selectedSchoolYearHeaders(): Record<string, string> {
} }
export function buildSchoolYearQuery(params: { export function buildSchoolYearQuery(params: {
schoolYearId?: number | null
schoolYearName?: string | null schoolYearName?: string | null
existing?: string | URLSearchParams existing?: string | URLSearchParams
}): string { }): string {
const query = new URLSearchParams(params.existing) const query = new URLSearchParams(params.existing)
if (params.schoolYearId != null) {
query.set('school_year_id', String(params.schoolYearId))
}
const name = params.schoolYearName?.trim() const name = params.schoolYearName?.trim()
if (name) { if (name) {
query.set('school_year', name) query.set('school_year', name)
@@ -106,3 +84,18 @@ export function buildSchoolYearQuery(params: {
const value = query.toString() const value = query.toString()
return value ? `?${value}` : '' return value ? `?${value}` : ''
} }
export function withSelectedSchoolYearUrl(url: string): string {
const name = getStoredSelectedSchoolYearName()
if (!name) return url
const [base, hash = ''] = url.split('#', 2)
const [path, rawQuery = ''] = base.split('?', 2)
const query = new URLSearchParams(rawQuery)
query.delete('school_year_id')
query.delete('year')
query.set('school_year', name)
const next = `${path}?${query.toString()}`
return hash ? `${next}#${hash}` : next
}
+176 -32
View File
@@ -2,7 +2,10 @@ import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from 're
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom' import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import './AdminDailyAttendance.css' import './AdminDailyAttendance.css'
import { ApiHttpError } from '../api/http' import { ApiHttpError } from '../api/http'
import { fetchParentProfile, fetchParentProfiles, updateParentProfile, type ParentProfileDetail, type ParentProfileSummary } from '../api/parentProfiles'
import { CiAcademicFilter } from '../components/CiPartials' import { CiAcademicFilter } from '../components/CiPartials'
import { ReadOnlyBanner, SchoolYearSelector } from '../components/schoolYear'
import { useSchoolYear } from '../context/SchoolYearContext'
import { useSchoolYearOptions } from '../hooks/useSchoolYearOptions' import { useSchoolYearOptions } from '../hooks/useSchoolYearOptions'
import { import {
assignTeacherClass, assignTeacherClass,
@@ -25,7 +28,6 @@ import {
fetchClassAssignmentOverview, fetchClassAssignmentOverview,
fetchClassSections, fetchClassSections,
fetchExamDraftAdminData, fetchExamDraftAdminData,
fetchFamilyAdminIndex,
fetchGradingOverview, fetchGradingOverview,
fetchIpBans, fetchIpBans,
fetchLateSlipLogs, fetchLateSlipLogs,
@@ -3487,19 +3489,28 @@ export function AdminPrintNotificationAdminsPage() {
} }
export function AdminParentProfilePage() { export function AdminParentProfilePage() {
const [guardians, setGuardians] = useState<NotificationAdminRow[]>([]) const { selectedSchoolYearName, isReadOnlyYear } = useSchoolYear()
const [selectedGuardianId, setSelectedGuardianId] = useState<number | null>(null) const [searchParams, setSearchParams] = useSearchParams()
const [families, setFamilies] = useState<Array<Record<string, unknown>>>([]) const [query, setQuery] = useState(searchParams.get('q') ?? '')
const [parents, setParents] = useState<ParentProfileSummary[]>([])
const [selectedParentId, setSelectedParentId] = useState<number | null>(Number(searchParams.get('parent_id')) || null)
const [detail, setDetail] = useState<ParentProfileDetail | null>(null)
const [pagination, setPagination] = useState({ page: Number(searchParams.get('page')) || 1, per_page: 25, total: 0 })
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [detailLoading, setDetailLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
if (!selectedSchoolYearName) return
;(async () => { ;(async () => {
setLoading(true) setLoading(true)
try { try {
const data = await fetchFamilyAdminIndex() const page = Number(searchParams.get('page')) || 1
setGuardians(data.data?.searchGuardians ?? []) const q = searchParams.get('q') ?? ''
setFamilies((data.data?.families as Array<Record<string, unknown>>) ?? []) const data = await fetchParentProfiles({ schoolYear: selectedSchoolYearName, q, page, perPage: 25 })
setParents(data.parents ?? [])
setPagination(data.pagination ?? { page, per_page: 25, total: 0 })
setQuery(q)
setError(null) setError(null)
} catch (e) { } catch (e) {
setError(e instanceof ApiHttpError ? e.message : e instanceof Error ? e.message : 'Unable to load parent profiles.') setError(e instanceof ApiHttpError ? e.message : e instanceof Error ? e.message : 'Unable to load parent profiles.')
@@ -3507,31 +3518,98 @@ export function AdminParentProfilePage() {
setLoading(false) setLoading(false)
} }
})() })()
}, []) }, [searchParams, selectedSchoolYearName])
async function loadGuardian(guardianId: number) { useEffect(() => {
setSelectedGuardianId(guardianId) if (!selectedSchoolYearName || !selectedParentId) {
setDetail(null)
return
}
;(async () => {
setDetailLoading(true)
try {
setDetail(await fetchParentProfile(selectedParentId, selectedSchoolYearName))
setError(null)
} catch (e) {
setError(e instanceof ApiHttpError ? e.message : e instanceof Error ? e.message : 'Unable to load parent profile.')
} finally {
setDetailLoading(false)
}
})()
}, [selectedParentId, selectedSchoolYearName])
function applySearch(event: FormEvent) {
event.preventDefault()
if (!selectedSchoolYearName) return
const next = new URLSearchParams(searchParams)
next.set('school_year', selectedSchoolYearName)
next.set('page', '1')
if (query.trim()) next.set('q', query.trim())
else next.delete('q')
setSearchParams(next)
}
function selectParent(parentId: number) {
if (!selectedSchoolYearName) return
setSelectedParentId(parentId)
const next = new URLSearchParams(searchParams)
next.set('school_year', selectedSchoolYearName)
next.set('parent_id', String(parentId))
setSearchParams(next)
}
function money(value: unknown): string {
return Number(value ?? 0).toLocaleString(undefined, { style: 'currency', currency: 'USD' })
}
async function saveParentProfile(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!detail?.parent || !selectedParentId || isReadOnlyYear || !selectedSchoolYearName) return
setError(null) setError(null)
try { try {
const data = await fetchFamilyAdminIndex({ guardianId }) const form = new FormData(event.currentTarget)
setFamilies((data.data?.families as Array<Record<string, unknown>>) ?? []) const payload = Object.fromEntries(form.entries())
const updated = await updateParentProfile(selectedParentId, selectedSchoolYearName, payload)
setDetail(updated)
} catch (e) { } catch (e) {
setError(e instanceof ApiHttpError ? e.message : e instanceof Error ? e.message : 'Unable to load families.') setError(e instanceof ApiHttpError ? e.message : e instanceof Error ? e.message : 'Unable to update parent profile.')
} }
} }
const parent = detail?.parent ?? null
const fullName = parent ? `${String(parent.firstname ?? '')} ${String(parent.lastname ?? '')}`.trim() : ''
return ( return (
<AdminParityShell title="Parent Profiles"> <AdminParityShell title="Parent Profiles">
<div className="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
<div className="d-flex align-items-center gap-2">
<SchoolYearSelector compact />
{selectedSchoolYearName ? <span className="badge bg-secondary">{selectedSchoolYearName}</span> : null}
</div>
<form className="d-flex gap-2" onSubmit={applySearch}>
<input className="form-control" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search parents, email, phone, student" />
<button type="submit" className="btn btn-primary">Search</button>
</form>
</div>
<ReadOnlyBanner className="mb-3" />
{error ? <div className="alert alert-danger">{error}</div> : null} {error ? <div className="alert alert-danger">{error}</div> : null}
<div className="row g-3"> <div className="row g-3">
<div className="col-lg-4"> <div className="col-lg-4">
<div className="card"> <div className="card">
<div className="card-header">Guardians</div> <div className="card-header d-flex justify-content-between">
<div className="list-group list-group-flush" style={{ maxHeight: 520, overflow: 'auto' }}> <span>Parents</span>
{loading ? <div className="list-group-item text-muted">Loading guardians</div> : guardians.map((guardian) => ( <span className="text-muted small">{pagination.total} found</span>
<button key={guardian.id} type="button" className={`list-group-item list-group-item-action ${selectedGuardianId === guardian.id ? 'active' : ''}`} onClick={() => void loadGuardian(guardian.id)}> </div>
<div>{`${guardian.firstname ?? ''} ${guardian.lastname ?? ''}`.trim()}</div> <div className="list-group list-group-flush" style={{ maxHeight: 640, overflow: 'auto' }}>
<div className="small">{guardian.email ?? ''}</div> {loading ? <div className="list-group-item text-muted">Loading parents...</div> : null}
{!loading && parents.length === 0 ? <div className="list-group-item text-muted">No selected-year parents found.</div> : null}
{parents.map((parentRow) => (
<button key={parentRow.id} type="button" className={`list-group-item list-group-item-action ${selectedParentId === parentRow.id ? 'active' : ''}`} onClick={() => selectParent(parentRow.id)}>
<div className="fw-semibold">{`${parentRow.firstname ?? ''} ${parentRow.lastname ?? ''}`.trim() || `Parent #${parentRow.id}`}</div>
<div className="small">{parentRow.email ?? ''}</div>
<div className="small text-muted">
Students: {parentRow.selected_year_students_count ?? 0} | Balance: {money(parentRow.balance)}
</div>
</button> </button>
))} ))}
</div> </div>
@@ -3539,21 +3617,87 @@ export function AdminParentProfilePage() {
</div> </div>
<div className="col-lg-8"> <div className="col-lg-8">
<div className="card"> <div className="card">
<div className="card-header">Families</div> <div className="card-header d-flex justify-content-between">
<span>{fullName || 'Parent Detail'}</span>
{detailLoading ? <span className="text-muted small">Loading...</span> : null}
</div>
<div className="card-body"> <div className="card-body">
{families.length === 0 ? <p className="text-muted mb-0">Select a guardian to view family details.</p> : families.map((family) => ( {!detail ? <p className="text-muted mb-0">Select a parent to view selected-year details.</p> : null}
<div key={String(family.id ?? '')} className="border rounded p-3 mb-3"> {detail && parent ? (
<h5 className="mb-1">{String(family.household_name ?? 'Household')}</h5> <div className="d-grid gap-3">
<div className="small text-muted mb-2">{String(family.address_line1 ?? '')} {String(family.city ?? '')} {String(family.state ?? '')}</div> <form className="row g-2" onSubmit={saveParentProfile}>
<div className="mb-2"><strong>Phone:</strong> {String(family.primary_phone ?? '—')}</div> {['firstname', 'lastname', 'email', 'cellphone', 'address_street', 'city', 'state', 'zip'].map((field) => (
<div><strong>Students</strong></div> <div className={field === 'address_street' ? 'col-12' : 'col-md-6'} key={field}>
<ul className="mb-0"> <label className="form-label text-capitalize">{field.replace('_', ' ')}</label>
{Array.isArray(family.students) && family.students.length > 0 ? family.students.map((student, idx) => ( <input className="form-control" name={field} defaultValue={String(parent[field] ?? '')} disabled={isReadOnlyYear} />
<li key={idx}>{`${String(student.firstname ?? '')} ${String(student.lastname ?? '')}`.trim()} {student.class_section_name ? `(${String(student.class_section_name)})` : ''}</li> </div>
)) : <li className="text-muted">No students linked.</li>} ))}
</ul> <div className="col-12">
<button type="submit" className="btn btn-primary" disabled={isReadOnlyYear}>Save Profile</button>
</div>
</form>
<div className="row g-2">
<div className="col-md-4"><div className="border rounded p-2"><div className="small text-muted">Balance</div><strong>{money(detail.finance_summary.balance)}</strong></div></div>
<div className="col-md-4"><div className="border rounded p-2"><div className="small text-muted">Unpaid</div><strong>{money(detail.finance_summary.positive_unpaid_balance)}</strong></div></div>
<div className="col-md-4"><div className="border rounded p-2"><div className="small text-muted">Credit</div><strong>{money(detail.finance_summary.credit_balance)}</strong></div></div>
</div>
<section>
<h5>Students</h5>
{detail.students.length === 0 ? <p className="text-muted">No students for this school year.</p> : (
<table className="table table-sm align-middle">
<tbody>
{detail.students.map((student) => (
<tr key={String(student.id)}>
<td>{`${String(student.firstname ?? '')} ${String(student.lastname ?? '')}`.trim()}</td>
<td>{String(student.class_section_name ?? student.grade ?? '')}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
<section>
<h5>Families</h5>
{detail.families.length === 0 ? <p className="text-muted">No selected-year families.</p> : detail.families.map((family) => (
<div key={String(family.id)} className="border rounded p-2 mb-2">
<strong>{String(family.household_name ?? 'Household')}</strong>
<div className="small text-muted">{String(family.address_line1 ?? '')} {String(family.city ?? '')} {String(family.state ?? '')}</div>
</div>
))}
</section>
<section>
<h5>Emergency Contacts</h5>
{detail.emergency_contacts.length === 0 ? <p className="text-muted">No selected-year emergency contacts.</p> : detail.emergency_contacts.map((contact) => (
<div key={String(contact.id)} className="border rounded p-2 mb-2">
<strong>{String(contact.emergency_contact_name ?? '')}</strong>
<div className="small text-muted">{String(contact.relation ?? '')} {String(contact.cellphone ?? '')} {String(contact.email ?? '')}</div>
</div>
))}
</section>
<section>
<h5>Invoices</h5>
{detail.invoices.length === 0 ? <p className="text-muted">No selected-year invoices.</p> : (
<table className="table table-sm align-middle">
<thead><tr><th>Invoice</th><th>Status</th><th className="text-end">Balance</th></tr></thead>
<tbody>
{detail.invoices.map((invoice) => (
<tr key={String(invoice.id)}>
<td>{String(invoice.invoice_number ?? invoice.id ?? '')}</td>
<td>{String(invoice.status ?? '')}</td>
<td className="text-end">{money(invoice.balance)}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
</div> </div>
))} ) : null}
</div> </div>
</div> </div>
</div> </div>
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react' 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 { ApiHttpError } from '../../api/http'
import { import {
fetchSchoolYearParentBalances, fetchSchoolYearParentBalances,
@@ -25,19 +25,17 @@ function formatMoney(value: number | null | undefined): string {
} }
export function SchoolYearDetailPage() { export function SchoolYearDetailPage() {
const { yearId } = useParams() const [searchParams] = useSearchParams()
const id = Number(yearId) const schoolYear = searchParams.get('school_year')?.trim() ?? ''
const [summary, setSummary] = useState<SchoolYearSummary | null>(null) const [summary, setSummary] = useState<SchoolYearSummary | null>(null)
const [promotionPreview, setPromotionPreview] = useState<SchoolYearPromotionPreview | null>(null) const [promotionPreview, setPromotionPreview] = useState<SchoolYearPromotionPreview | null>(null)
const [parentBalances, setParentBalances] = useState<SchoolYearParentBalancePreview | null>(null) const [parentBalances, setParentBalances] = useState<SchoolYearParentBalancePreview | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const validId = Number.isFinite(id) && id > 0
useEffect(() => { useEffect(() => {
if (!validId) { if (!schoolYear) {
setError('Invalid school year id.') setError('Missing school_year query parameter.')
setLoading(false) setLoading(false)
return return
} }
@@ -49,9 +47,9 @@ export function SchoolYearDetailPage() {
;(async () => { ;(async () => {
try { try {
const [nextSummary, nextPromotion, nextBalances] = await Promise.all([ const [nextSummary, nextPromotion, nextBalances] = await Promise.all([
fetchSchoolYearSummary(id), fetchSchoolYearSummary(schoolYear),
fetchSchoolYearPromotionPreview(id), fetchSchoolYearPromotionPreview(schoolYear),
fetchSchoolYearParentBalances(id), fetchSchoolYearParentBalances(schoolYear),
]) ])
if (!cancelled) { if (!cancelled) {
setSummary(nextSummary) setSummary(nextSummary)
@@ -68,7 +66,7 @@ export function SchoolYearDetailPage() {
return () => { return () => {
cancelled = true cancelled = true
} }
}, [id, validId]) }, [schoolYear])
const totals = summary?.totals const totals = summary?.totals
const promotion = promotionPreview?.summary const promotion = promotionPreview?.summary
@@ -116,14 +114,14 @@ export function SchoolYearDetailPage() {
</div> </div>
<div className="col-md-6 col-xl-3"> <div className="col-md-6 col-xl-3">
<div className="card shadow-sm h-100"><div className="card-body"> <div className="card shadow-sm h-100"><div className="card-body">
<div className="small text-muted text-uppercase">Parents with balances</div> <div className="small text-muted text-uppercase">Unpaid carryover parents</div>
<div className="display-6">{totals?.parents_with_balances ?? 0}</div> <div className="display-6">{totals?.parents_with_positive_balance ?? totals?.parents_with_balances ?? 0}</div>
</div></div> </div></div>
</div> </div>
<div className="col-md-6 col-xl-3"> <div className="col-md-6 col-xl-3">
<div className="card shadow-sm h-100"><div className="card-body"> <div className="card shadow-sm h-100"><div className="card-body">
<div className="small text-muted text-uppercase">Net transfer impact</div> <div className="small text-muted text-uppercase">Net transfer impact</div>
<div className="display-6">${formatMoney(totals?.net_balance_to_transfer)}</div> <div className="display-6">${formatMoney(totals?.net_balance_impact ?? totals?.net_balance_to_transfer)}</div>
</div></div> </div></div>
</div> </div>
</div> </div>
@@ -148,20 +146,23 @@ export function SchoolYearDetailPage() {
<div className="card-body"> <div className="card-body">
<h2 className="h5">Parent balance preview</h2> <h2 className="h5">Parent balance preview</h2>
<div className="small text-muted mb-3"> <div className="small text-muted mb-3">
{balances?.parents_with_non_zero_balance ?? 0} parents with non-zero balances, {balances?.parents_with_positive_balance ?? 0} parents with unpaid balances,
net ${formatMoney(balances?.net_balance_to_transfer)}. {balances?.parents_with_credit_balance ?? 0} parents with credits,
net ${formatMoney(balances?.net_balance_impact ?? balances?.net_balance_to_transfer)}.
</div> </div>
<div className="table-responsive"> <div className="table-responsive">
<table className="table table-sm align-middle mb-0"> <table className="table table-sm align-middle mb-0">
<thead><tr><th>Parent</th><th>Students</th><th className="text-end">Transfer</th></tr></thead> <thead><tr><th>Parent</th><th>Students</th><th className="text-end">Unpaid</th><th className="text-end">Credits</th><th className="text-end">Net</th></tr></thead>
<tbody> <tbody>
{rows.length === 0 ? ( {rows.length === 0 ? (
<tr><td colSpan={3} className="text-muted">No balance rows found.</td></tr> <tr><td colSpan={5} className="text-muted">No balance rows found.</td></tr>
) : rows.map((row) => ( ) : rows.map((row) => (
<tr key={row.parent_id}> <tr key={row.parent_id}>
<td>{row.parent_name || `Parent #${row.parent_id}`}</td> <td>{row.parent_name || `Parent #${row.parent_id}`}</td>
<td>{row.student_names?.join(', ') || '—'}</td> <td>{row.student_names?.join(', ') || '—'}</td>
<td className="text-end">${formatMoney(row.amount_to_transfer)}</td> <td className="text-end">${formatMoney(row.positive_unpaid_balance ?? row.amount_to_transfer)}</td>
<td className="text-end">${formatMoney(row.credit_balance)}</td>
<td className="text-end">${formatMoney(row.net_balance)}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react' 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 { ApiHttpError } from '../../api/http'
import { import {
fetchSchoolYearBalanceTransferReport, fetchSchoolYearBalanceTransferReport,
@@ -56,7 +56,8 @@ function ClosingReportView({ report }: { report: SchoolYearClosingReport }) {
<div className="col-md-4"><div className="border rounded p-3 h-100"><div className="small text-muted">Graduated</div><div className="h4 mb-0">{totals.students_graduated ?? 0}</div></div></div> <div className="col-md-4"><div className="border rounded p-3 h-100"><div className="small text-muted">Graduated</div><div className="h4 mb-0">{totals.students_graduated ?? 0}</div></div></div>
<div className="col-md-4"><div className="border rounded p-3 h-100"><div className="small text-muted">Parents with unpaid balances</div><div className="h4 mb-0">{totals.parents_with_unpaid_balances ?? 0}</div></div></div> <div className="col-md-4"><div className="border rounded p-3 h-100"><div className="small text-muted">Parents with unpaid balances</div><div className="h4 mb-0">{totals.parents_with_unpaid_balances ?? 0}</div></div></div>
<div className="col-md-4"><div className="border rounded p-3 h-100"><div className="small text-muted">Unpaid transferred</div><div className="h4 mb-0">${formatMoney(totals.total_unpaid_balance_transferred)}</div></div></div> <div className="col-md-4"><div className="border rounded p-3 h-100"><div className="small text-muted">Unpaid transferred</div><div className="h4 mb-0">${formatMoney(totals.total_unpaid_balance_transferred)}</div></div></div>
<div className="col-md-4"><div className="border rounded p-3 h-100"><div className="small text-muted">Credit transferred</div><div className="h4 mb-0">${formatMoney(totals.total_credit_transferred)}</div></div></div> <div className="col-md-4"><div className="border rounded p-3 h-100"><div className="small text-muted">Credits carried or reported</div><div className="h4 mb-0">${formatMoney(totals.total_credit_carried_or_reported ?? totals.total_credit_transferred)}</div></div></div>
<div className="col-md-4"><div className="border rounded p-3 h-100"><div className="small text-muted">Net balance impact</div><div className="h4 mb-0">${formatMoney(totals.net_balance_impact)}</div></div></div>
</div> </div>
{report.warnings?.length ? ( {report.warnings?.length ? (
@@ -125,8 +126,8 @@ function BalanceTransfersView({ report }: { report: SchoolYearBalanceTransferRep
} }
export function SchoolYearReportsPage({ reportType }: { reportType: ReportType }) { export function SchoolYearReportsPage({ reportType }: { reportType: ReportType }) {
const { yearId } = useParams() const [searchParams] = useSearchParams()
const id = Number(yearId) const schoolYear = searchParams.get('school_year')?.trim() ?? ''
const [closingReport, setClosingReport] = useState<SchoolYearClosingReport | null>(null) const [closingReport, setClosingReport] = useState<SchoolYearClosingReport | null>(null)
const [balanceReport, setBalanceReport] = useState<SchoolYearBalanceTransferReport | null>(null) const [balanceReport, setBalanceReport] = useState<SchoolYearBalanceTransferReport | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@@ -138,8 +139,8 @@ export function SchoolYearReportsPage({ reportType }: { reportType: ReportType }
) )
useEffect(() => { useEffect(() => {
if (!Number.isFinite(id) || id <= 0) { if (!schoolYear) {
setError('Invalid school year id.') setError('Missing school_year query parameter.')
setLoading(false) setLoading(false)
return return
} }
@@ -153,10 +154,10 @@ export function SchoolYearReportsPage({ reportType }: { reportType: ReportType }
;(async () => { ;(async () => {
try { try {
if (reportType === 'closing') { if (reportType === 'closing') {
const report = await fetchSchoolYearClosingReport(id) const report = await fetchSchoolYearClosingReport(schoolYear)
if (!cancelled) setClosingReport(report) if (!cancelled) setClosingReport(report)
} else { } else {
const report = await fetchSchoolYearBalanceTransferReport(id) const report = await fetchSchoolYearBalanceTransferReport(schoolYear)
if (!cancelled) setBalanceReport(report) if (!cancelled) setBalanceReport(report)
} }
} catch (loadError) { } catch (loadError) {
@@ -169,7 +170,7 @@ export function SchoolYearReportsPage({ reportType }: { reportType: ReportType }
return () => { return () => {
cancelled = true cancelled = true
} }
}, [id, reportType, title]) }, [reportType, schoolYear, title])
return ( return (
<div className="container-fluid py-4"> <div className="container-fluid py-4">
@@ -189,7 +190,7 @@ export function SchoolYearReportsPage({ reportType }: { reportType: ReportType }
This screen only displays records returned by those routes. This screen only displays records returned by those routes.
</p> </p>
</div> </div>
<Link className="btn btn-outline-secondary btn-sm" to={`/app/admin/school-years/${id}/summary`}> <Link className="btn btn-outline-secondary btn-sm" to={`/app/admin/school-years/summary?school_year=${encodeURIComponent(schoolYear)}`}>
Back to summary Back to summary
</Link> </Link>
</div> </div>
@@ -141,7 +141,7 @@ export function SchoolYearsManagementPage() {
const [busyEdit, setBusyEdit] = useState(false) const [busyEdit, setBusyEdit] = useState(false)
const [busyOpenId, setBusyOpenId] = useState<number | null>(null) const [busyOpenId, setBusyOpenId] = useState<number | null>(null)
const [busyCloseAction, setBusyCloseAction] = useState<'validate' | 'preview' | 'close' | null>(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( const activeYear = useMemo(
() => years.find((year) => Boolean(year.is_current)) ?? null, () => years.find((year) => Boolean(year.is_current)) ?? null,
@@ -150,13 +150,13 @@ export function SchoolYearsManagementPage() {
const selectedYear = useMemo(() => { const selectedYear = useMemo(() => {
if (years.length === 0) return null if (years.length === 0) return null
if (selectedYearId != null) { if (selectedYearName) {
return years.find((year) => year.id === selectedYearId) ?? null return years.find((year) => year.name === selectedYearName) ?? null
} }
return activeYear ?? years[0] ?? 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) setLoading(true)
setLoadingMessage('Loading school years…') setLoadingMessage('Loading school years…')
setError(null) setError(null)
@@ -166,15 +166,17 @@ export function SchoolYearsManagementPage() {
setYears(list) setYears(list)
const nextSelectedId = const nextSelectedId =
preferredYearId ?? preferredYearName ??
(selectedYearId != null && list.some((year) => year.id === selectedYearId) (selectedYearName && list.some((year) => year.name === selectedYearName)
? selectedYearId ? selectedYearName
: list.find((year) => year.is_current)?.id ?? list[0]?.id ?? null) : list.find((year) => year.is_current)?.name ?? list[0]?.name ?? null)
if (nextSelectedId != null) { if (nextSelectedId != null) {
setSearchParams((current) => { setSearchParams((current) => {
const next = new URLSearchParams(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 return next
}, { replace: true }) }, { replace: true })
} }
@@ -238,7 +240,7 @@ export function SchoolYearsManagementPage() {
;(async () => { ;(async () => {
try { try {
const nextSummary = await fetchSchoolYearSummary(selectedYear.id) const nextSummary = await fetchSchoolYearSummary(selectedYear.name)
if (!cancelled) { if (!cancelled) {
setSummary(nextSummary) setSummary(nextSummary)
} }
@@ -273,10 +275,12 @@ export function SchoolYearsManagementPage() {
setMessages([{ type, message }]) setMessages([{ type, message }])
} }
function selectYear(yearId: number) { function selectYear(yearName: string) {
setSearchParams((current) => { setSearchParams((current) => {
const next = new URLSearchParams(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 return next
}) })
setPromotionPreview(null) setPromotionPreview(null)
@@ -292,7 +296,7 @@ export function SchoolYearsManagementPage() {
const created = await createSchoolYear(createForm) const created = await createSchoolYear(createForm)
pushMessage('success', `Draft school year ${created.name} created.`) pushMessage('success', `Draft school year ${created.name} created.`)
setCreateForm(emptyYearForm()) setCreateForm(emptyYearForm())
await refreshYears(created.id) await refreshYears(created.name)
} catch (saveError) { } catch (saveError) {
setError(normalizeApiError(saveError, 'Unable to create school year.')) setError(normalizeApiError(saveError, 'Unable to create school year.'))
} finally { } finally {
@@ -306,9 +310,9 @@ export function SchoolYearsManagementPage() {
setBusyEdit(true) setBusyEdit(true)
setError(null) setError(null)
try { try {
const updated = await updateSchoolYear(selectedYear.id, editForm) const updated = await updateSchoolYear(selectedYear.name, editForm)
pushMessage('success', `School year ${updated.name} updated.`) pushMessage('success', `School year ${updated.name} updated.`)
await refreshYears(updated.id) await refreshYears(updated.name)
} catch (saveError) { } catch (saveError) {
setError(normalizeApiError(saveError, 'Unable to update school year.')) setError(normalizeApiError(saveError, 'Unable to update school year.'))
} finally { } finally {
@@ -320,11 +324,11 @@ export function SchoolYearsManagementPage() {
setBusyOpenId(year.id) setBusyOpenId(year.id)
setError(null) setError(null)
try { try {
const reopened = await reopenSchoolYear(year.id) const reopened = await reopenSchoolYear(year.name)
pushMessage('success', `School year ${reopened.name} is now active.`) pushMessage('success', `School year ${reopened.name} is now active.`)
setCloseValidation(null) setCloseValidation(null)
setClosePreview(null) setClosePreview(null)
await refreshYears(reopened.id) await refreshYears(reopened.name)
} catch (actionError) { } catch (actionError) {
setError(normalizeApiError(actionError, 'Unable to open school year.')) setError(normalizeApiError(actionError, 'Unable to open school year.'))
} finally { } finally {
@@ -338,7 +342,7 @@ export function SchoolYearsManagementPage() {
setError(null) setError(null)
try { try {
const preview = await fetchSchoolYearPromotionPreview( const preview = await fetchSchoolYearPromotionPreview(
selectedYear.id, selectedYear.name,
suggestNextName(selectedYear.name), suggestNextName(selectedYear.name),
) )
setPromotionPreview(preview) setPromotionPreview(preview)
@@ -353,7 +357,7 @@ export function SchoolYearsManagementPage() {
setError(null) setError(null)
try { try {
const balances = await fetchSchoolYearParentBalances( const balances = await fetchSchoolYearParentBalances(
selectedYear.id, selectedYear.name,
suggestNextName(selectedYear.name), suggestNextName(selectedYear.name),
) )
setParentBalances(balances) setParentBalances(balances)
@@ -363,11 +367,11 @@ export function SchoolYearsManagementPage() {
} }
async function handleValidateClose() { async function handleValidateClose() {
if (!activeYear) return if (!selectedYear?.is_current) return
setBusyCloseAction('validate') setBusyCloseAction('validate')
setError(null) setError(null)
try { try {
const validation = await validateSchoolYearClose(activeYear.id, closePayloadFrom(closeForm)) const validation = await validateSchoolYearClose(selectedYear.name, closePayloadFrom(closeForm))
setCloseValidation(validation) setCloseValidation(validation)
setClosePreview(null) setClosePreview(null)
setCloseResult(null) setCloseResult(null)
@@ -379,11 +383,11 @@ export function SchoolYearsManagementPage() {
} }
async function handlePreviewClose() { async function handlePreviewClose() {
if (!activeYear) return if (!selectedYear?.is_current) return
setBusyCloseAction('preview') setBusyCloseAction('preview')
setError(null) setError(null)
try { try {
const preview = await previewSchoolYearClose(activeYear.id, closePayloadFrom(closeForm)) const preview = await previewSchoolYearClose(selectedYear.name, closePayloadFrom(closeForm))
setCloseValidation(preview.validation) setCloseValidation(preview.validation)
setClosePreview(preview) setClosePreview(preview)
setCloseResult(null) setCloseResult(null)
@@ -396,11 +400,11 @@ export function SchoolYearsManagementPage() {
async function handleClose(event: FormEvent) { async function handleClose(event: FormEvent) {
event.preventDefault() event.preventDefault()
if (!activeYear) return if (!selectedYear?.is_current) return
setBusyCloseAction('close') setBusyCloseAction('close')
setError(null) setError(null)
try { try {
const result = await closeSchoolYear(activeYear.id, closePayloadFrom(closeForm)) const result = await closeSchoolYear(selectedYear.name, closePayloadFrom(closeForm))
pushMessage( pushMessage(
'success', 'success',
`Closed ${result.closed_school_year.name} and opened ${result.new_school_year.name}.`, `Closed ${result.closed_school_year.name} and opened ${result.new_school_year.name}.`,
@@ -411,7 +415,7 @@ export function SchoolYearsManagementPage() {
setPromotionPreview(null) setPromotionPreview(null)
setParentBalances(null) setParentBalances(null)
setCloseForm(emptyCloseForm()) setCloseForm(emptyCloseForm())
await refreshYears(result.new_school_year.id) await refreshYears(result.new_school_year.name)
} catch (actionError) { } catch (actionError) {
setError(normalizeApiError(actionError, 'Unable to close school year.')) setError(normalizeApiError(actionError, 'Unable to close school year.'))
} finally { } finally {
@@ -425,8 +429,9 @@ export function SchoolYearsManagementPage() {
const closePromotionRows = closePreview?.promotion_preview.rows ?? [] const closePromotionRows = closePreview?.promotion_preview.rows ?? []
const closeBalanceRows = closePreview?.parent_balances.rows ?? [] const closeBalanceRows = closePreview?.parent_balances.rows ?? []
const selectedYearReadOnly = isReadOnlySchoolYear(selectedYear) const selectedYearReadOnly = isReadOnlySchoolYear(selectedYear)
const requiredConfirmation = activeYear ? `CLOSE ${activeYear.name}` : '' const selectedYearIsActive = Boolean(selectedYear?.is_current)
const confirmationMatches = !activeYear || closeForm.confirmation.trim() === requiredConfirmation const requiredConfirmation = selectedYearIsActive && selectedYear ? `CLOSE ${selectedYear.name}` : ''
const confirmationMatches = !selectedYearIsActive || closeForm.confirmation.trim() === requiredConfirmation
return ( return (
<div className="container-fluid py-4"> <div className="container-fluid py-4">
@@ -484,7 +489,7 @@ export function SchoolYearsManagementPage() {
<button <button
type="button" type="button"
className="btn btn-link p-0 fw-semibold text-decoration-none" className="btn btn-link p-0 fw-semibold text-decoration-none"
onClick={() => selectYear(year.id)} onClick={() => selectYear(year.name)}
> >
{year.name} {year.name}
</button> </button>
@@ -498,13 +503,13 @@ export function SchoolYearsManagementPage() {
<button <button
type="button" type="button"
className="btn btn-sm btn-outline-primary" className="btn btn-sm btn-outline-primary"
onClick={() => selectYear(year.id)} onClick={() => selectYear(year.name)}
> >
Inspect Inspect
</button> </button>
<Link <Link
className="btn btn-sm btn-outline-secondary" className="btn btn-sm btn-outline-secondary"
to={`/app/admin/school-years/${year.id}/summary`} to={`/app/admin/school-years/summary?school_year=${encodeURIComponent(year.name)}`}
> >
Summary Summary
</Link> </Link>
@@ -634,14 +639,14 @@ export function SchoolYearsManagementPage() {
</div> </div>
<div className="col-md-6 col-xl-3"> <div className="col-md-6 col-xl-3">
<div className="border rounded p-3 h-100"> <div className="border rounded p-3 h-100">
<div className="small text-muted text-uppercase">Parents with balances</div> <div className="small text-muted text-uppercase">Unpaid carryover parents</div>
<div className="display-6">{summaryCards.parents_with_balances}</div> <div className="display-6">{summaryCards.parents_with_positive_balance ?? summaryCards.parents_with_balances}</div>
</div> </div>
</div> </div>
<div className="col-md-6 col-xl-3"> <div className="col-md-6 col-xl-3">
<div className="border rounded p-3 h-100"> <div className="border rounded p-3 h-100">
<div className="small text-muted text-uppercase">Net balance impact</div> <div className="small text-muted text-uppercase">Net balance impact</div>
<div className="display-6">${formatMoney(summaryCards.net_balance_to_transfer)}</div> <div className="display-6">${formatMoney(summaryCards.net_balance_impact ?? summaryCards.net_balance_to_transfer)}</div>
</div> </div>
</div> </div>
</div> </div>
@@ -651,13 +656,13 @@ export function SchoolYearsManagementPage() {
{selectedYear ? ( {selectedYear ? (
<div className="d-flex flex-wrap gap-2 mb-4"> <div className="d-flex flex-wrap gap-2 mb-4">
<Link className="btn btn-outline-secondary btn-sm" to={`/app/admin/school-years/${selectedYear.id}/summary`}> <Link className="btn btn-outline-secondary btn-sm" to={`/app/admin/school-years/summary?school_year=${encodeURIComponent(selectedYear.name)}`}>
Open summary page Open summary page
</Link> </Link>
<Link className="btn btn-outline-secondary btn-sm" to={`/app/admin/school-years/${selectedYear.id}/reports/closing`}> <Link className="btn btn-outline-secondary btn-sm" to={`/app/admin/school-years/reports/closing?school_year=${encodeURIComponent(selectedYear.name)}`}>
Closing report Closing report
</Link> </Link>
<Link className="btn btn-outline-secondary btn-sm" to={`/app/admin/school-years/${selectedYear.id}/reports/balance-transfers`}> <Link className="btn btn-outline-secondary btn-sm" to={`/app/admin/school-years/reports/balance-transfers?school_year=${encodeURIComponent(selectedYear.name)}`}>
Balance transfer report Balance transfer report
</Link> </Link>
</div> </div>
@@ -828,8 +833,9 @@ export function SchoolYearsManagementPage() {
) : ( ) : (
<> <>
<div className="small text-muted mb-3"> <div className="small text-muted mb-3">
{parentBalances.summary.parents_with_non_zero_balance} parents with Source year: {parentBalances.from_school_year}; target year: {parentBalances.to_school_year}.{' '}
balances, net ${formatMoney(parentBalances.summary.net_balance_to_transfer)}. 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)}.
</div> </div>
<div className="table-responsive"> <div className="table-responsive">
<table className="table table-sm align-middle mb-0"> <table className="table table-sm align-middle mb-0">
@@ -837,13 +843,17 @@ export function SchoolYearsManagementPage() {
<tr> <tr>
<th>Parent</th> <th>Parent</th>
<th>Students</th> <th>Students</th>
<th>Transfer</th> <th className="text-end">Unpaid</th>
<th className="text-end">Credits</th>
<th className="text-end">Net</th>
<th>Sources</th>
<th>Conflict</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{balanceRows.length === 0 ? ( {balanceRows.length === 0 ? (
<tr> <tr>
<td colSpan={3} className="text-muted"> <td colSpan={7} className="text-muted">
No rows found. No rows found.
</td> </td>
</tr> </tr>
@@ -852,7 +862,11 @@ export function SchoolYearsManagementPage() {
<tr key={row.parent_id}> <tr key={row.parent_id}>
<td>{row.parent_name || `Parent #${row.parent_id}`}</td> <td>{row.parent_name || `Parent #${row.parent_id}`}</td>
<td>{row.student_names?.join(', ') || '—'}</td> <td>{row.student_names?.join(', ') || '—'}</td>
<td>${formatMoney(row.amount_to_transfer)}</td> <td className="text-end">${formatMoney(row.positive_unpaid_balance ?? row.old_unpaid_balance)}</td>
<td className="text-end">${formatMoney(row.credit_balance)}</td>
<td className="text-end">${formatMoney(row.net_balance ?? row.amount_to_transfer)}</td>
<td>{(row.positive_invoice_ids ?? row.source_invoice_ids ?? []).join(', ') || '—'}</td>
<td>{row.existing_transfer_conflict ? <span className="badge text-bg-warning">Conflict</span> : '—'}</td>
</tr> </tr>
)) ))
)} )}
@@ -871,19 +885,23 @@ export function SchoolYearsManagementPage() {
<div className="card-body"> <div className="card-body">
<div className="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-3"> <div className="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-3">
<div> <div>
<h2 className="h5 mb-1">Close Active Year</h2> <h2 className="h5 mb-1">Close Selected Active Year</h2>
<div className="text-muted small"> <div className="text-muted small">
Validate, preview, and close the current year through the transactional Validate, preview, and close the inspected school year through the transactional
school-year closure endpoints. school-year closure endpoints. Only the current active year can be closed.
</div> </div>
</div> </div>
{activeYear ? ( {selectedYearIsActive && selectedYear ? (
<span className="badge text-bg-success">{activeYear.name}</span> <span className="badge text-bg-success">{selectedYear.name}</span>
) : null} ) : null}
</div> </div>
{!activeYear ? ( {!selectedYear ? (
<p className="text-muted mb-0">No active school year is available to close.</p> <p className="text-muted mb-0">Select a school year to close it.</p>
) : !selectedYearIsActive ? (
<div className="alert alert-info mb-0">
Only the current active school year can be closed. You are inspecting {selectedYear.name}, which is {selectedYear.status}.
</div>
) : ( ) : (
<> <>
<div className="row g-2 mb-3 small" aria-label="Close-year wizard steps"> <div className="row g-2 mb-3 small" aria-label="Close-year wizard steps">
@@ -995,7 +1013,7 @@ export function SchoolYearsManagementPage() {
<button <button
type="button" type="button"
className="btn btn-outline-primary" className="btn btn-outline-primary"
disabled={busyCloseAction === 'validate'} disabled={busyCloseAction === 'validate' || !selectedYearIsActive}
onClick={() => void handleValidateClose()} onClick={() => void handleValidateClose()}
> >
{busyCloseAction === 'validate' ? 'Validating…' : 'Validate'} {busyCloseAction === 'validate' ? 'Validating…' : 'Validate'}
@@ -1003,7 +1021,7 @@ export function SchoolYearsManagementPage() {
<button <button
type="button" type="button"
className="btn btn-outline-secondary" className="btn btn-outline-secondary"
disabled={busyCloseAction === 'preview'} disabled={busyCloseAction === 'preview' || !selectedYearIsActive}
onClick={() => void handlePreviewClose()} onClick={() => void handlePreviewClose()}
> >
{busyCloseAction === 'preview' ? 'Previewing…' : 'Preview'} {busyCloseAction === 'preview' ? 'Previewing…' : 'Preview'}
@@ -1011,9 +1029,9 @@ export function SchoolYearsManagementPage() {
<button <button
type="submit" type="submit"
className="btn btn-danger" className="btn btn-danger"
disabled={busyCloseAction === 'close' || !confirmationMatches} disabled={busyCloseAction === 'close' || !confirmationMatches || !selectedYearIsActive}
> >
{busyCloseAction === 'close' ? 'Closing…' : `Close ${activeYear.name}`} {busyCloseAction === 'close' ? 'Closing…' : `Close ${selectedYear.name}`}
</button> </button>
</div> </div>
</form> </form>
@@ -1033,10 +1051,10 @@ export function SchoolYearsManagementPage() {
Missing decisions: <strong>{closeValidation.promotion_summary.hold}</strong> Missing decisions: <strong>{closeValidation.promotion_summary.hold}</strong>
</div> </div>
<div className="col-md-3"> <div className="col-md-3">
Parents with balances: <strong>{closeValidation.financial_summary.parents_with_non_zero_balance}</strong> Unpaid carryover parents: <strong>{closeValidation.financial_summary.parents_with_positive_balance ?? closeValidation.financial_summary.parents_with_non_zero_balance}</strong>
</div> </div>
<div className="col-md-3"> <div className="col-md-3">
Net transfer: <strong>${formatMoney(closeValidation.financial_summary.net_balance_to_transfer)}</strong> Net impact: <strong>${formatMoney(closeValidation.financial_summary.net_balance_impact ?? closeValidation.financial_summary.net_balance_to_transfer)}</strong>
</div> </div>
</div> </div>
{closeValidation.errors.length > 0 ? ( {closeValidation.errors.length > 0 ? (
@@ -1099,13 +1117,19 @@ export function SchoolYearsManagementPage() {
<div className="col-lg-6"> <div className="col-lg-6">
<div className="border rounded p-3 h-100"> <div className="border rounded p-3 h-100">
<h3 className="h6">Closure balance preview</h3> <h3 className="h6">Closure balance preview</h3>
<div className="small text-muted mb-2">
Source year: {closePreview.parent_balances.from_school_year}; target year: {closePreview.parent_balances.to_school_year}. Transfer mode: unpaid balances only.
</div>
<div className="table-responsive"> <div className="table-responsive">
<table className="table table-sm align-middle mb-0"> <table className="table table-sm align-middle mb-0">
<thead> <thead>
<tr> <tr>
<th>Parent</th> <th>Parent</th>
<th>Students</th> <th>Students</th>
<th>Transfer</th> <th className="text-end">Unpaid</th>
<th className="text-end">Credits</th>
<th className="text-end">Net</th>
<th>Conflict</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -1113,7 +1137,10 @@ export function SchoolYearsManagementPage() {
<tr key={`balance-${row.parent_id}`}> <tr key={`balance-${row.parent_id}`}>
<td>{row.parent_name || `Parent #${row.parent_id}`}</td> <td>{row.parent_name || `Parent #${row.parent_id}`}</td>
<td>{row.student_names?.join(', ') || '—'}</td> <td>{row.student_names?.join(', ') || '—'}</td>
<td>${formatMoney(row.amount_to_transfer)}</td> <td className="text-end">${formatMoney(row.positive_unpaid_balance ?? row.old_unpaid_balance)}</td>
<td className="text-end">${formatMoney(row.credit_balance)}</td>
<td className="text-end">${formatMoney(row.net_balance ?? row.amount_to_transfer)}</td>
<td>{row.existing_transfer_conflict ? <span className="badge text-bg-warning">Conflict</span> : '—'}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
+4 -4
View File
@@ -18,13 +18,13 @@ function formatMoney(value: number | null | undefined): string {
} }
export function ParentStatementPage() { export function ParentStatementPage() {
const { selectedSchoolYearId, selectedSchoolYearName } = useSchoolYear() const { selectedSchoolYearName } = useSchoolYear()
const [statement, setStatement] = useState<ParentStatement | null>(null) const [statement, setStatement] = useState<ParentStatement | null>(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
if (selectedSchoolYearId == null) return if (!selectedSchoolYearName) return
let cancelled = false let cancelled = false
setLoading(true) setLoading(true)
@@ -32,7 +32,7 @@ export function ParentStatementPage() {
;(async () => { ;(async () => {
try { try {
const nextStatement = await fetchParentStatement({ schoolYearId: selectedSchoolYearId }) const nextStatement = await fetchParentStatement({ schoolYear: selectedSchoolYearName })
if (!cancelled) setStatement(nextStatement) if (!cancelled) setStatement(nextStatement)
} catch (loadError) { } catch (loadError) {
if (!cancelled) setError(normalizeApiError(loadError, 'Unable to load parent statement.')) if (!cancelled) setError(normalizeApiError(loadError, 'Unable to load parent statement.'))
@@ -44,7 +44,7 @@ export function ParentStatementPage() {
return () => { return () => {
cancelled = true cancelled = true
} }
}, [selectedSchoolYearId]) }, [selectedSchoolYearName])
const lines = useMemo(() => statement?.lines ?? [], [statement]) const lines = useMemo(() => statement?.lines ?? [], [statement])
+30 -5
View File
@@ -1,22 +1,27 @@
import { type FormEvent, useEffect, useState } from 'react' import { type FormEvent, useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { createStaff, fetchStaffFormOptions, type RoleOption } from '../../api/staff' import { createStaff, fetchStaffFormOptions, type RoleOption } from '../../api/staff'
import { ReadOnlyBanner, SchoolYearSelector } from '../../components/schoolYear'
import { useSchoolYear } from '../../context/SchoolYearContext'
export function StaffCreatePage() { export function StaffCreatePage() {
const navigate = useNavigate() const navigate = useNavigate()
const { selectedSchoolYearName, isReadOnlyYear } = useSchoolYear()
const [roles, setRoles] = useState<RoleOption[]>([]) const [roles, setRoles] = useState<RoleOption[]>([])
const [firstname, setFirstname] = useState('') const [firstname, setFirstname] = useState('')
const [lastname, setLastname] = useState('') const [lastname, setLastname] = useState('')
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [phone, setPhone] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [roleId, setRoleId] = useState<number>(0) const [roleId, setRoleId] = useState<number>(0)
const [status, setStatus] = useState('active')
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
;(async () => { ;(async () => {
try { try {
const data = await fetchStaffFormOptions() const data = await fetchStaffFormOptions({ school_year: selectedSchoolYearName ?? undefined })
if (cancelled) return if (cancelled) return
const list = data.roles ?? [] const list = data.roles ?? []
setRoles(list) setRoles(list)
@@ -28,17 +33,21 @@ export function StaffCreatePage() {
return () => { return () => {
cancelled = true cancelled = true
} }
}, []) }, [selectedSchoolYearName])
async function onSubmit(e: FormEvent) { async function onSubmit(e: FormEvent) {
e.preventDefault() e.preventDefault()
if (!selectedSchoolYearName) {
setError('Please select a school year.')
return
}
if (!roleId) { if (!roleId) {
setError('Please select a role.') setError('Please select a role.')
return return
} }
try { try {
await createStaff({ firstname, lastname, email, password, role_id: roleId }) await createStaff({ firstname, lastname, email, phone, password, role_id: roleId, status, school_year: selectedSchoolYearName })
navigate('/app/staff') navigate(`/app/staff?school_year=${encodeURIComponent(selectedSchoolYearName)}`)
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Unable to create staff.') setError(err instanceof Error ? err.message : 'Unable to create staff.')
} }
@@ -47,6 +56,11 @@ export function StaffCreatePage() {
return ( return (
<div className="container mt-4"> <div className="container mt-4">
<h2>Add Staff Member</h2> <h2>Add Staff Member</h2>
<div className="d-flex justify-content-between align-items-center mb-3">
<div className="text-muted">School year: {selectedSchoolYearName ?? '—'}</div>
<SchoolYearSelector compact />
</div>
<ReadOnlyBanner />
{error ? <div className="alert alert-danger">{error}</div> : null} {error ? <div className="alert alert-danger">{error}</div> : null}
<form onSubmit={(e) => void onSubmit(e)}> <form onSubmit={(e) => void onSubmit(e)}>
<div className="mb-3"> <div className="mb-3">
@@ -89,6 +103,10 @@ export function StaffCreatePage() {
required required
/> />
</div> </div>
<div className="mb-3">
<label className="form-label">Phone</label>
<input type="tel" className="form-control" value={phone} onChange={(e) => setPhone(e.target.value)} />
</div>
<div className="mb-3"> <div className="mb-3">
<label className="form-label">Role</label> <label className="form-label">Role</label>
<select <select
@@ -104,7 +122,14 @@ export function StaffCreatePage() {
))} ))}
</select> </select>
</div> </div>
<button type="submit" className="btn btn-success"> <div className="mb-3">
<label className="form-label">Status</label>
<select className="form-select" value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</div>
<button type="submit" className="btn btn-success" disabled={isReadOnlyYear || !selectedSchoolYearName}>
Create Create
</button> </button>
</form> </form>
+17 -5
View File
@@ -1,10 +1,13 @@
import { type FormEvent, useEffect, useState } from 'react' import { type FormEvent, useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { fetchStaffMember, updateStaff } from '../../api/staff' import { fetchStaffMember, updateStaff } from '../../api/staff'
import { ReadOnlyBanner, SchoolYearSelector } from '../../components/schoolYear'
import { useSchoolYear } from '../../context/SchoolYearContext'
export function StaffEditPage() { export function StaffEditPage() {
const { staffId } = useParams() const { staffId } = useParams()
const navigate = useNavigate() const navigate = useNavigate()
const { selectedSchoolYearName, isReadOnlyYear } = useSchoolYear()
const id = Number(staffId) const id = Number(staffId)
const [firstname, setFirstname] = useState('') const [firstname, setFirstname] = useState('')
const [lastname, setLastname] = useState('') const [lastname, setLastname] = useState('')
@@ -17,7 +20,7 @@ export function StaffEditPage() {
let cancelled = false let cancelled = false
;(async () => { ;(async () => {
try { try {
const data = await fetchStaffMember(id) const data = await fetchStaffMember(id, { school_year: selectedSchoolYearName ?? undefined })
if (cancelled) return if (cancelled) return
const u = (data.user ?? data.staff ?? {}) as Record<string, unknown> const u = (data.user ?? data.staff ?? {}) as Record<string, unknown>
setFirstname(String(u.firstname ?? '')) setFirstname(String(u.firstname ?? ''))
@@ -32,14 +35,18 @@ export function StaffEditPage() {
return () => { return () => {
cancelled = true cancelled = true
} }
}, [id]) }, [id, selectedSchoolYearName])
async function onSubmit(e: FormEvent) { async function onSubmit(e: FormEvent) {
e.preventDefault() e.preventDefault()
if (!id) return if (!id) return
try { try {
await updateStaff(id, { firstname, lastname, email, phone }) if (!selectedSchoolYearName) {
navigate('/app/staff') 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) { } catch (err) {
setError(err instanceof Error ? err.message : 'Unable to update staff.') setError(err instanceof Error ? err.message : 'Unable to update staff.')
} }
@@ -48,6 +55,11 @@ export function StaffEditPage() {
return ( return (
<div className="container mt-4"> <div className="container mt-4">
<h2>Edit Staff Member</h2> <h2>Edit Staff Member</h2>
<div className="d-flex justify-content-between align-items-center mb-3">
<div className="text-muted">School year: {selectedSchoolYearName ?? '—'}</div>
<SchoolYearSelector compact />
</div>
<ReadOnlyBanner />
{error ? <div className="alert alert-danger">{error}</div> : null} {error ? <div className="alert alert-danger">{error}</div> : null}
<form onSubmit={(e) => void onSubmit(e)}> <form onSubmit={(e) => void onSubmit(e)}>
<div className="mb-3"> <div className="mb-3">
@@ -91,7 +103,7 @@ export function StaffEditPage() {
placeholder="Enter phone number" placeholder="Enter phone number"
/> />
</div> </div>
<button type="submit" className="btn btn-primary"> <button type="submit" className="btn btn-primary" disabled={isReadOnlyYear || !selectedSchoolYearName}>
Update Update
</button> </button>
</form> </form>
+80 -19
View File
@@ -1,51 +1,112 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom' import { Link, useSearchParams } from 'react-router-dom'
import { CiAcademicFilter } from '../../components/CiPartials'
import { fetchStaffIndex, type StaffListRow } from '../../api/staff' import { fetchStaffIndex, type StaffListRow } from '../../api/staff'
import { ReadOnlyBanner, SchoolYearSelector } from '../../components/schoolYear'
import { useSchoolYear } from '../../context/SchoolYearContext'
export function StaffIndexPage() { export function StaffIndexPage() {
const [searchParams, setSearchParams] = useSearchParams() const [searchParams, setSearchParams] = useSearchParams()
const { selectedSchoolYearName, isReadOnlyYear } = useSchoolYear()
const [rows, setRows] = useState<StaffListRow[]>([]) const [rows, setRows] = useState<StaffListRow[]>([])
const [issuesCount, setIssuesCount] = useState(0) const [issuesCount, setIssuesCount] = useState(0)
const [schoolYear, setSchoolYear] = useState<string | null>(null) const [schoolYear, setSchoolYear] = useState<string | null>(null)
const [schoolYears, setSchoolYears] = useState<string[]>([])
const [semester, setSemester] = useState<string | null>(null) const [semester, setSemester] = useState<string | null>(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<string | null>(null) const [error, setError] = useState<string | null>(null)
const load = useCallback(async () => { const load = useCallback(async () => {
const sy = selectedSchoolYearName ?? searchParams.get('school_year') ?? undefined
try { try {
const sy = searchParams.get('school_year') ?? undefined
const sem = searchParams.get('semester') ?? 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 ?? []) setRows(data.staff ?? [])
setIssuesCount(Number(data.issues_count ?? 0)) setIssuesCount(Number(data.issues_count ?? 0))
setSchoolYear(data.school_year ?? null) setSchoolYear(data.school_year ?? null)
setSemester(data.semester ?? null) setSemester(data.semester ?? null)
setSchoolYears(data.schoolYears ?? [])
setError(null) setError(null)
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : 'Unable to load staff.') 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(() => { useEffect(() => {
void load() void load()
}, [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 ( return (
<div className="container-fluid mt-4"> <div className="container-fluid mt-4">
<h2 className="text-center mt-4 mb-3">Staff List</h2> <div className="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-3">
<CiAcademicFilter <div>
schoolYears={schoolYears.length > 0 ? schoolYears : schoolYear ? [schoolYear] : []} <h2 className="mb-1">Staff List</h2>
selectedYear={searchParams.get('school_year') ?? schoolYear ?? undefined} <div className="text-muted small">School year: {selectedYear || '—'}</div>
selectedSemester={searchParams.get('semester') ?? semester ?? undefined} </div>
onApply={({ schoolYear: y, semester: sem }) => { <div className="d-flex flex-wrap gap-2 align-items-center">
const next = new URLSearchParams() <SchoolYearSelector compact />
if (y) next.set('school_year', y) {!isReadOnlyYear ? (
if (sem) next.set('semester', sem) <Link className="btn btn-primary btn-sm" to={`/app/staff/create${selectedQuery}`}>Add Staff</Link>
setSearchParams(next) ) : null}
}} </div>
/> </div>
<ReadOnlyBanner />
<div className="row g-2 align-items-end mb-3">
<div className="col-md-4">
<label className="form-label">Search</label>
<input className="form-control" value={search} onChange={(event) => setSearch(event.target.value)} />
</div>
<div className="col-md-2">
<label className="form-label">Semester</label>
<input className="form-control" value={semester ?? ''} onChange={(event) => setSemester(event.target.value)} placeholder="Fall" />
</div>
<div className="col-md-2">
<label className="form-label">Role</label>
<input className="form-control" value={role} onChange={(event) => setRole(event.target.value)} />
</div>
<div className="col-md-2">
<label className="form-label">Status</label>
<select className="form-select" value={status} onChange={(event) => setStatus(event.target.value)}>
<option value="">Any</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</div>
<div className="col-md-2">
<button className="btn btn-outline-primary w-100" type="button" onClick={applyFilters}>Apply</button>
</div>
</div>
{issuesCount > 0 ? ( {issuesCount > 0 ? (
<div className="alert alert-warning" role="alert"> <div className="alert alert-warning" role="alert">
@@ -83,7 +144,7 @@ export function StaffIndexPage() {
<td>{s.active_role ?? ''}</td> <td>{s.active_role ?? ''}</td>
<td>{s.class_section ?? ''}</td> <td>{s.class_section ?? ''}</td>
<td> <td>
<Link className="btn btn-sm btn-warning" to={`/app/staff/edit/${s.id}`}> <Link className="btn btn-sm btn-warning" to={`/app/staff/edit/${s.id}${selectedQuery}`}>
Edit Edit
</Link> </Link>
</td> </td>