fix apply the plan docs/alrahma_api_fix_plan_school_year_only_v7
This commit is contained in:
+3
-3
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
}))
|
||||||
|
}
|
||||||
+62
-29
@@ -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>(
|
||||||
|
`/api/v1/school-years/selected${selectedYearQuery(schoolYear)}`,
|
||||||
|
{
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload),
|
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
@@ -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
@@ -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}` : ''}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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])
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)) : <li className="text-muted">No students linked.</li>}
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
<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>
|
||||||
|
) : 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>
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user