import { useEffect, useMemo, useState, type FormEvent } from 'react' import { Link, useSearchParams } from 'react-router-dom' import { CiFlashMessages, type CiFlashMessage } from '../../components/CiPartials' import { SchoolYearSelector, SchoolYearStatusBadge } from '../../components/schoolYear' import { isReadOnlySchoolYear } from '../../lib/schoolYearSelection' import { ApiHttpError } from '../../api/http' import { closeSchoolYear, createSchoolYear, fetchSchoolYearParentBalances, fetchSchoolYearPromotionPreview, fetchSchoolYearSummary, fetchSchoolYears, previewSchoolYearClose, reopenSchoolYear, updateSchoolYear, validateSchoolYearClose, type CloseSchoolYearPayload, type SchoolYearClosePreview, type SchoolYearCloseResult, type SchoolYearCloseValidation, type SchoolYearParentBalancePreview, type SchoolYearPromotionPreview, type SchoolYearRecord, type SchoolYearSummary, } from '../../api/schoolYears' type YearFormState = { name: string start_date: string end_date: string } type CloseFormState = { name: string start_date: string end_date: string confirmation: string transfer_unpaid_balances: boolean } function emptyYearForm(): YearFormState { return { name: '', start_date: '', end_date: '' } } function emptyCloseForm(): CloseFormState { return { name: '', start_date: '', end_date: '', confirmation: '', transfer_unpaid_balances: true, } } function normalizeApiError(error: unknown, fallback: string): string { if (error instanceof ApiHttpError) return error.message || fallback if (error instanceof Error) return error.message return fallback } function toDisplayDate(value: string | null | undefined): string { if (!value) return '—' const iso = value.length > 10 ? value.slice(0, 10) : value const parsed = new Date(`${iso}T00:00:00`) if (Number.isNaN(parsed.getTime())) return value return parsed.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric', }) } function formatMoney(value: number | null | undefined): string { return Number(value ?? 0).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2, }) } function suggestNextName(name: string): string { const fullRange = name.match(/^(\d{4})\s*-\s*(\d{4})$/) if (fullRange) { return `${Number(fullRange[1]) + 1}-${Number(fullRange[2]) + 1}` } const shortRange = name.match(/^(\d{4})\s*-\s*(\d{2})$/) if (shortRange) { const nextStart = Number(shortRange[1]) + 1 const nextEnd = (Number(shortRange[2]) + 1).toString().padStart(2, '0') return `${nextStart}-${nextEnd}` } return '' } function shiftSchoolYearDate(value: string): string { const parts = value.split('-') if (parts.length !== 3) return '' return `${Number(parts[0]) + 1}-${parts[1]}-${parts[2]}` } function defaultDraftFor(activeYear: SchoolYearRecord | null): YearFormState { if (!activeYear) return emptyYearForm() return { name: suggestNextName(activeYear.name), start_date: shiftSchoolYearDate(activeYear.start_date), end_date: shiftSchoolYearDate(activeYear.end_date), } } function closePayloadFrom(state: CloseFormState): CloseSchoolYearPayload { return { new_school_year: { name: state.name.trim(), start_date: state.start_date, end_date: state.end_date, }, transfer_unpaid_balances: state.transfer_unpaid_balances, confirmation: state.confirmation.trim(), } } export function SchoolYearsManagementPage() { const [searchParams, setSearchParams] = useSearchParams() const [years, setYears] = useState([]) const [loading, setLoading] = useState(true) const [loadingMessage, setLoadingMessage] = useState('Loading school years…') const [error, setError] = useState(null) const [messages, setMessages] = useState([]) const [summary, setSummary] = useState(null) const [promotionPreview, setPromotionPreview] = useState(null) const [parentBalances, setParentBalances] = useState(null) const [closeValidation, setCloseValidation] = useState(null) const [closePreview, setClosePreview] = useState(null) const [closeResult, setCloseResult] = useState(null) const [createForm, setCreateForm] = useState(emptyYearForm()) const [editForm, setEditForm] = useState(emptyYearForm()) const [closeForm, setCloseForm] = useState(emptyCloseForm()) const [busyCreate, setBusyCreate] = useState(false) const [busyEdit, setBusyEdit] = useState(false) const [busyOpenId, setBusyOpenId] = useState(null) const [busyCloseAction, setBusyCloseAction] = useState<'validate' | 'preview' | 'close' | null>(null) const selectedYearName = searchParams.get('school_year')?.trim() || null const activeYear = useMemo( () => years.find((year) => Boolean(year.is_current)) ?? null, [years], ) const selectedYear = useMemo(() => { if (years.length === 0) return null if (selectedYearName) { return years.find((year) => year.name === selectedYearName) ?? null } return activeYear ?? years[0] ?? null }, [activeYear, selectedYearName, years]) async function refreshYears(preferredYearName?: string | null) { setLoading(true) setLoadingMessage('Loading school years…') setError(null) try { const list = await fetchSchoolYears() setYears(list) const nextSelectedId = preferredYearName ?? (selectedYearName && list.some((year) => year.name === selectedYearName) ? selectedYearName : list.find((year) => year.is_current)?.name ?? list[0]?.name ?? null) if (nextSelectedId != null) { setSearchParams((current) => { const next = new URLSearchParams(current) next.delete('year') next.delete('school_year_id') next.set('school_year', nextSelectedId) return next }, { replace: true }) } const nextActive = list.find((year) => year.is_current) ?? null const draftDefaults = defaultDraftFor(nextActive) setCreateForm((current) => current.name || current.start_date || current.end_date ? current : draftDefaults, ) if (nextActive) { setCloseForm((current) => current.name || current.start_date || current.end_date ? current : { ...current, ...draftDefaults, }, ) } else { setCloseForm(emptyCloseForm()) } } catch (loadError) { setYears([]) setSummary(null) setPromotionPreview(null) setParentBalances(null) setCloseValidation(null) setClosePreview(null) setCloseResult(null) setError(normalizeApiError(loadError, 'Unable to load school years.')) } finally { setLoading(false) } } useEffect(() => { void refreshYears() // search params are intentionally handled by explicit selection logic // eslint-disable-next-line react-hooks/exhaustive-deps }, []) useEffect(() => { if (!selectedYear) { setSummary(null) setEditForm(emptyYearForm()) setPromotionPreview(null) setParentBalances(null) return } setEditForm({ name: selectedYear.name, start_date: selectedYear.start_date, end_date: selectedYear.end_date, }) let cancelled = false setSummary(null) setLoadingMessage(`Loading ${selectedYear.name} summary…`) ;(async () => { try { const nextSummary = await fetchSchoolYearSummary(selectedYear.name) if (!cancelled) { setSummary(nextSummary) } } catch (loadError) { if (!cancelled) { setError(normalizeApiError(loadError, 'Unable to load school year summary.')) } } })() return () => { cancelled = true } }, [selectedYear]) useEffect(() => { const defaults = defaultDraftFor(activeYear) if (!activeYear) { setCloseForm(emptyCloseForm()) return } setCloseForm((current) => ({ ...current, name: current.name || defaults.name, start_date: current.start_date || defaults.start_date, end_date: current.end_date || defaults.end_date, })) }, [activeYear]) function pushMessage(type: CiFlashMessage['type'], message: string) { setMessages([{ type, message }]) } function selectYear(yearName: string) { setSearchParams((current) => { const next = new URLSearchParams(current) next.delete('year') next.delete('school_year_id') next.set('school_year', yearName) return next }) setPromotionPreview(null) setParentBalances(null) setCloseResult(null) } async function handleCreate(event: FormEvent) { event.preventDefault() setBusyCreate(true) setError(null) try { const created = await createSchoolYear(createForm) pushMessage('success', `Draft school year ${created.name} created.`) setCreateForm(emptyYearForm()) await refreshYears(created.name) } catch (saveError) { setError(normalizeApiError(saveError, 'Unable to create school year.')) } finally { setBusyCreate(false) } } async function handleEdit(event: FormEvent) { event.preventDefault() if (!selectedYear) return setBusyEdit(true) setError(null) try { const updated = await updateSchoolYear(selectedYear.name, editForm) pushMessage('success', `School year ${updated.name} updated.`) await refreshYears(updated.name) } catch (saveError) { setError(normalizeApiError(saveError, 'Unable to update school year.')) } finally { setBusyEdit(false) } } async function handleReopen(year: SchoolYearRecord) { setBusyOpenId(year.id) setError(null) try { const reopened = await reopenSchoolYear(year.name) pushMessage('success', `School year ${reopened.name} is now active.`) setCloseValidation(null) setClosePreview(null) await refreshYears(reopened.name) } catch (actionError) { setError(normalizeApiError(actionError, 'Unable to open school year.')) } finally { setBusyOpenId(null) } } async function loadPromotionPreview() { if (!selectedYear) return setLoadingMessage(`Loading ${selectedYear.name} promotion preview…`) setError(null) try { const preview = await fetchSchoolYearPromotionPreview( selectedYear.name, suggestNextName(selectedYear.name), ) setPromotionPreview(preview) } catch (loadError) { setError(normalizeApiError(loadError, 'Unable to load promotion preview.')) } } async function loadParentBalances() { if (!selectedYear) return setLoadingMessage(`Loading ${selectedYear.name} parent balances…`) setError(null) try { const balances = await fetchSchoolYearParentBalances( selectedYear.name, suggestNextName(selectedYear.name), ) setParentBalances(balances) } catch (loadError) { setError(normalizeApiError(loadError, 'Unable to load parent balance preview.')) } } async function handleValidateClose() { if (!selectedYear?.is_current) return setBusyCloseAction('validate') setError(null) try { const validation = await validateSchoolYearClose(selectedYear.name, closePayloadFrom(closeForm)) setCloseValidation(validation) setClosePreview(null) setCloseResult(null) } catch (actionError) { setError(normalizeApiError(actionError, 'Unable to validate school year closure.')) } finally { setBusyCloseAction(null) } } async function handlePreviewClose() { if (!selectedYear?.is_current) return setBusyCloseAction('preview') setError(null) try { const preview = await previewSchoolYearClose(selectedYear.name, closePayloadFrom(closeForm)) setCloseValidation(preview.validation) setClosePreview(preview) setCloseResult(null) } catch (actionError) { setError(normalizeApiError(actionError, 'Unable to preview school year closure.')) } finally { setBusyCloseAction(null) } } async function handleClose(event: FormEvent) { event.preventDefault() if (!selectedYear?.is_current) return setBusyCloseAction('close') setError(null) try { const result = await closeSchoolYear(selectedYear.name, closePayloadFrom(closeForm)) pushMessage( 'success', `Closed ${result.closed_school_year.name} and opened ${result.new_school_year.name}.`, ) setCloseValidation(null) setClosePreview(null) setCloseResult(result) setPromotionPreview(null) setParentBalances(null) setCloseForm(emptyCloseForm()) await refreshYears(result.new_school_year.name) } catch (actionError) { setError(normalizeApiError(actionError, 'Unable to close school year.')) } finally { setBusyCloseAction(null) } } const summaryCards = summary?.totals const promotionRows = promotionPreview?.rows ?? [] const balanceRows = parentBalances?.rows ?? [] const closePromotionRows = closePreview?.promotion_preview.rows ?? [] const closeBalanceRows = closePreview?.parent_balances.rows ?? [] const selectedYearReadOnly = isReadOnlySchoolYear(selectedYear) const selectedYearIsActive = Boolean(selectedYear?.is_current) const requiredConfirmation = selectedYearIsActive && selectedYear ? `CLOSE ${selectedYear.name}` : '' const confirmationMatches = !selectedYearIsActive || closeForm.confirmation.trim() === requiredConfirmation return (

School Years Management

Control draft years, metadata updates, reopening, validation, promotion preview, parent balance carryover, and year closure through the matching Laravel API routes.

Uses /api/v1/school-years with JWT Authorization: Bearer.
{error ?
{error}
: null}

School Years

{loading && years.length === 0 ? (

{loadingMessage}

) : years.length === 0 ? (

No school years found.

) : (
{years.map((year) => (
{toDisplayDate(year.start_date)} to {toDisplayDate(year.end_date)}
Summary {!year.is_current ? ( ) : ( Current )}
))}
)}

Create Draft Year

void handleCreate(event)}>
setCreateForm((current) => ({ ...current, name: event.target.value })) } required />
setCreateForm((current) => ({ ...current, start_date: event.target.value })) } required />
setCreateForm((current) => ({ ...current, end_date: event.target.value })) } required />

{selectedYear?.name ?? 'Year summary'}

Review the selected year before updating metadata, reopening, or closing.
{selectedYear ? ( ) : null}
{!selectedYear ? (

Select a school year to inspect it.

) : !summaryCards ? (

Loading summary…

) : (
Students considered
{summaryCards.students_considered}
Students blocked
{summaryCards.students_blocked}
Unpaid carryover parents
{summaryCards.parents_with_positive_balance ?? summaryCards.parents_with_balances}
Net balance impact
${formatMoney(summaryCards.net_balance_impact ?? summaryCards.net_balance_to_transfer)}
)}
{selectedYear ? (
Open summary page Closing report Balance transfer report
) : null}

Edit Selected Year

{selectedYearReadOnly ? (
This year is closed or archived. Metadata editing is disabled here; backend guards must reject the write anyway.
) : null} {!selectedYear ? (

Select a school year to edit its metadata.

) : (
void handleEdit(event)}>
setEditForm((current) => ({ ...current, name: event.target.value })) } required />
setEditForm((current) => ({ ...current, start_date: event.target.value })) } required />
setEditForm((current) => ({ ...current, end_date: event.target.value })) } required />
Safe renames depend on whether finance and enrollment records already reference the existing year label.
)}

Promotion And Balance Inspection

Read-only previews for the selected year using the dedicated promotion and parent-balance endpoints.

Promotion preview

{!promotionPreview ? (

Not loaded yet.

) : ( <>
{promotionPreview.summary.total_students} students scanned,{' '} {promotionPreview.summary.hold} hold rows.
{promotionRows.length === 0 ? ( ) : ( promotionRows.slice(0, 8).map((row) => ( )) )}
Student Current Action Target
No rows found.
{row.student_name}
{row.warnings?.length ? (
{row.warnings.join('; ')}
) : null}
{row.current_grade ?? '—'} {row.promotion_action} {row.target_grade ?? '—'}
)}

Parent balance preview

{!parentBalances ? (

Not loaded yet.

) : ( <>
Source year: {parentBalances.from_school_year}; target year: {parentBalances.to_school_year}.{' '} Unpaid ${formatMoney(parentBalances.summary.total_positive_unpaid_balance ?? parentBalances.summary.total_old_unpaid_balance)},{' '} credits ${formatMoney(parentBalances.summary.total_credit_balance)}, net ${formatMoney(parentBalances.summary.net_balance_impact ?? parentBalances.summary.net_balance_to_transfer)}.
{balanceRows.length === 0 ? ( ) : ( balanceRows.slice(0, 8).map((row) => ( )) )}
Parent Students Unpaid Credits Net Sources Conflict
No rows found.
{row.parent_name || `Parent #${row.parent_id}`} {row.student_names?.join(', ') || '—'} ${formatMoney(row.positive_unpaid_balance ?? row.old_unpaid_balance)} ${formatMoney(row.credit_balance)} ${formatMoney(row.net_balance ?? row.amount_to_transfer)} {(row.positive_invoice_ids ?? row.source_invoice_ids ?? []).join(', ') || '—'} {row.existing_transfer_conflict ? Conflict : '—'}
)}

Close Selected Active Year

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

Select a school year to close it.

) : !selectedYearIsActive ? (
Only the current active school year can be closed. You are inspecting {selectedYear.name}, which is {selectedYear.status}.
) : ( <>
{[ '1. New Year Details', '2. Academic Promotion Preview', '3. Financial Balance Preview', '4. Validation Results', '5. Confirmation', '6. Completion Report', ].map((step) => (
{step}
))}
void handleClose(event)}>
setCloseForm((current) => ({ ...current, name: event.target.value })) } required />
setCloseForm((current) => ({ ...current, start_date: event.target.value })) } required />
setCloseForm((current) => ({ ...current, end_date: event.target.value })) } required />
setCloseForm((current) => ({ ...current, confirmation: event.target.value, })) } placeholder={requiredConfirmation} required />
Type exactly {requiredConfirmation}. Button-click roulette is not a control.
setCloseForm((current) => ({ ...current, transfer_unpaid_balances: event.target.checked, })) } />
)} {closeValidation ? (
{closeValidation.can_close ? 'Closure validation passed.' : 'Closure is currently blocked.'}
Students scanned: {closeValidation.promotion_summary.total_students}
Missing decisions: {closeValidation.promotion_summary.hold}
Unpaid carryover parents: {closeValidation.financial_summary.parents_with_positive_balance ?? closeValidation.financial_summary.parents_with_non_zero_balance}
Net impact: ${formatMoney(closeValidation.financial_summary.net_balance_impact ?? closeValidation.financial_summary.net_balance_to_transfer)}
{closeValidation.errors.length > 0 ? (
    {closeValidation.errors.map((message) => (
  • {message}
  • ))}
) : null} {closeValidation.warnings.length > 0 ? (
    {closeValidation.warnings.map((message) => (
  • {message}
  • ))}
) : null}
) : null} {closeResult ? (
Completion report
Closed year: {closeResult.closed_school_year.name}
New active year: {closeResult.new_school_year.name}
Promotions: {Object.values(closeResult.promotion_counts ?? {}).reduce((sum, value) => sum + Number(value || 0), 0)}
Balance transfer rows: {Object.values(closeResult.balance_counts ?? {}).reduce((sum, value) => sum + Number(value || 0), 0)}
) : null} {closePreview ? (

Closure promotion preview

{closePromotionRows.slice(0, 8).map((row) => ( ))}
Student Action Target
{row.student_name} {row.promotion_action} {row.target_grade ?? '—'}

Closure balance preview

Source year: {closePreview.parent_balances.from_school_year}; target year: {closePreview.parent_balances.to_school_year}. Transfer mode: unpaid balances only.
{closeBalanceRows.slice(0, 8).map((row) => ( ))}
Parent Students Unpaid Credits Net Conflict
{row.parent_name || `Parent #${row.parent_id}`} {row.student_names?.join(', ') || '—'} ${formatMoney(row.positive_unpaid_balance ?? row.old_unpaid_balance)} ${formatMoney(row.credit_balance)} ${formatMoney(row.net_balance ?? row.amount_to_transfer)} {row.existing_transfer_conflict ? Conflict : '—'}
) : null}
) }