Files
alrahma_web_client/src/pages/administrator/SchoolYearsManagementPage.tsx
T
root 4b78aafa65
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Successful in 29s
Web Client CI/CD / Build (tsc + Vite) (push) Successful in 30s
Web Client CI/CD / Deploy to shared hosting (push) Successful in 33s
fix apply the plan docs/alrahma_api_fix_plan_school_year_only_v7
2026-07-07 01:52:37 -04:00

1161 lines
48 KiB
TypeScript

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<SchoolYearRecord[]>([])
const [loading, setLoading] = useState(true)
const [loadingMessage, setLoadingMessage] = useState('Loading school years…')
const [error, setError] = useState<string | null>(null)
const [messages, setMessages] = useState<CiFlashMessage[]>([])
const [summary, setSummary] = useState<SchoolYearSummary | null>(null)
const [promotionPreview, setPromotionPreview] = useState<SchoolYearPromotionPreview | null>(null)
const [parentBalances, setParentBalances] = useState<SchoolYearParentBalancePreview | null>(null)
const [closeValidation, setCloseValidation] = useState<SchoolYearCloseValidation | null>(null)
const [closePreview, setClosePreview] = useState<SchoolYearClosePreview | null>(null)
const [closeResult, setCloseResult] = useState<SchoolYearCloseResult | null>(null)
const [createForm, setCreateForm] = useState<YearFormState>(emptyYearForm())
const [editForm, setEditForm] = useState<YearFormState>(emptyYearForm())
const [closeForm, setCloseForm] = useState<CloseFormState>(emptyCloseForm())
const [busyCreate, setBusyCreate] = useState(false)
const [busyEdit, setBusyEdit] = useState(false)
const [busyOpenId, setBusyOpenId] = useState<number | null>(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 (
<div className="container-fluid py-4">
<nav aria-label="breadcrumb">
<ol className="breadcrumb">
<li className="breadcrumb-item">
<Link to="/app/home">Home</Link>
</li>
<li className="breadcrumb-item active" aria-current="page">
administrator
</li>
<li className="breadcrumb-item active" aria-current="page">
school-years
</li>
</ol>
</nav>
<div className="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
<div>
<h1 className="h3 mb-1">School Years Management</h1>
<p className="text-muted mb-0">
Control draft years, metadata updates, reopening, validation, promotion preview,
parent balance carryover, and year closure through the matching Laravel API routes.
</p>
</div>
<div className="d-flex flex-column align-items-end gap-2">
<SchoolYearSelector />
<div className="text-muted small">
Uses <code>/api/v1/school-years</code> with JWT <code>Authorization: Bearer</code>.
</div>
</div>
</div>
<CiFlashMessages messages={messages} />
{error ? <div className="alert alert-danger">{error}</div> : null}
<div className="row g-4">
<div className="col-xl-4">
<div className="card shadow-sm mb-4">
<div className="card-body">
<h2 className="h5 mb-3">School Years</h2>
{loading && years.length === 0 ? (
<p className="text-muted mb-0">{loadingMessage}</p>
) : years.length === 0 ? (
<p className="text-muted mb-0">No school years found.</p>
) : (
<div className="d-grid gap-3">
{years.map((year) => (
<div
key={year.id}
className={`border rounded p-3 ${selectedYear?.id === year.id ? 'border-primary bg-light' : 'bg-white'}`}
>
<div className="d-flex justify-content-between gap-2 align-items-start mb-2">
<div>
<button
type="button"
className="btn btn-link p-0 fw-semibold text-decoration-none"
onClick={() => selectYear(year.name)}
>
{year.name}
</button>
<div className="small text-muted">
{toDisplayDate(year.start_date)} to {toDisplayDate(year.end_date)}
</div>
</div>
<SchoolYearStatusBadge status={year.status} />
</div>
<div className="d-flex flex-wrap gap-2">
<button
type="button"
className="btn btn-sm btn-outline-primary"
onClick={() => selectYear(year.name)}
>
Inspect
</button>
<Link
className="btn btn-sm btn-outline-secondary"
to={`/app/admin/school-years/summary?school_year=${encodeURIComponent(year.name)}`}
>
Summary
</Link>
{!year.is_current ? (
<button
type="button"
className="btn btn-sm btn-primary"
disabled={busyOpenId === year.id}
onClick={() => void handleReopen(year)}
>
{busyOpenId === year.id
? 'Opening…'
: year.status === 'draft'
? 'Open draft'
: 'Reopen'}
</button>
) : (
<span className="badge text-bg-success align-self-center">Current</span>
)}
</div>
</div>
))}
</div>
)}
</div>
</div>
<div className="card shadow-sm">
<div className="card-body">
<div className="d-flex justify-content-between align-items-center mb-3">
<h2 className="h5 mb-0">Create Draft Year</h2>
<button
type="button"
className="btn btn-sm btn-outline-secondary"
onClick={() => setCreateForm(defaultDraftFor(activeYear))}
>
Suggest next
</button>
</div>
<form onSubmit={(event) => void handleCreate(event)}>
<div className="mb-3">
<label className="form-label" htmlFor="create_school_year_name">
Name
</label>
<input
id="create_school_year_name"
className="form-control"
value={createForm.name}
onChange={(event) =>
setCreateForm((current) => ({ ...current, name: event.target.value }))
}
required
/>
</div>
<div className="row g-3">
<div className="col-md-6">
<label className="form-label" htmlFor="create_school_year_start">
Start date
</label>
<input
id="create_school_year_start"
className="form-control"
type="date"
value={createForm.start_date}
onChange={(event) =>
setCreateForm((current) => ({ ...current, start_date: event.target.value }))
}
required
/>
</div>
<div className="col-md-6">
<label className="form-label" htmlFor="create_school_year_end">
End date
</label>
<input
id="create_school_year_end"
className="form-control"
type="date"
value={createForm.end_date}
onChange={(event) =>
setCreateForm((current) => ({ ...current, end_date: event.target.value }))
}
required
/>
</div>
</div>
<button type="submit" className="btn btn-success mt-3" disabled={busyCreate}>
{busyCreate ? 'Creating…' : 'Create draft year'}
</button>
</form>
</div>
</div>
</div>
<div className="col-xl-8">
<div className="card shadow-sm mb-4">
<div className="card-body">
<div className="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-3">
<div>
<h2 className="h5 mb-1">{selectedYear?.name ?? 'Year summary'}</h2>
<div className="text-muted small">
Review the selected year before updating metadata, reopening, or closing.
</div>
</div>
{selectedYear ? (
<SchoolYearStatusBadge status={selectedYear.status} />
) : null}
</div>
{!selectedYear ? (
<p className="text-muted mb-0">Select a school year to inspect it.</p>
) : !summaryCards ? (
<p className="text-muted mb-0">Loading summary</p>
) : (
<div className="row g-3">
<div className="col-md-6 col-xl-3">
<div className="border rounded p-3 h-100">
<div className="small text-muted text-uppercase">Students considered</div>
<div className="display-6">{summaryCards.students_considered}</div>
</div>
</div>
<div className="col-md-6 col-xl-3">
<div className="border rounded p-3 h-100">
<div className="small text-muted text-uppercase">Students blocked</div>
<div className="display-6">{summaryCards.students_blocked}</div>
</div>
</div>
<div className="col-md-6 col-xl-3">
<div className="border rounded p-3 h-100">
<div className="small text-muted text-uppercase">Unpaid carryover parents</div>
<div className="display-6">{summaryCards.parents_with_positive_balance ?? summaryCards.parents_with_balances}</div>
</div>
</div>
<div className="col-md-6 col-xl-3">
<div className="border rounded p-3 h-100">
<div className="small text-muted text-uppercase">Net balance impact</div>
<div className="display-6">${formatMoney(summaryCards.net_balance_impact ?? summaryCards.net_balance_to_transfer)}</div>
</div>
</div>
</div>
)}
</div>
</div>
{selectedYear ? (
<div className="d-flex flex-wrap gap-2 mb-4">
<Link className="btn btn-outline-secondary btn-sm" to={`/app/admin/school-years/summary?school_year=${encodeURIComponent(selectedYear.name)}`}>
Open summary page
</Link>
<Link className="btn btn-outline-secondary btn-sm" to={`/app/admin/school-years/reports/closing?school_year=${encodeURIComponent(selectedYear.name)}`}>
Closing report
</Link>
<Link className="btn btn-outline-secondary btn-sm" to={`/app/admin/school-years/reports/balance-transfers?school_year=${encodeURIComponent(selectedYear.name)}`}>
Balance transfer report
</Link>
</div>
) : null}
<div className="card shadow-sm mb-4">
<div className="card-body">
<h2 className="h5 mb-3">Edit Selected Year</h2>
{selectedYearReadOnly ? (
<div className="alert alert-warning small">
This year is closed or archived. Metadata editing is disabled here; backend guards must reject the write anyway.
</div>
) : null}
{!selectedYear ? (
<p className="text-muted mb-0">Select a school year to edit its metadata.</p>
) : (
<form onSubmit={(event) => void handleEdit(event)}>
<div className="mb-3">
<label className="form-label" htmlFor="edit_school_year_name">
Name
</label>
<input
id="edit_school_year_name"
className="form-control"
value={editForm.name}
disabled={selectedYearReadOnly}
onChange={(event) =>
setEditForm((current) => ({ ...current, name: event.target.value }))
}
required
/>
</div>
<div className="row g-3">
<div className="col-md-6">
<label className="form-label" htmlFor="edit_school_year_start">
Start date
</label>
<input
id="edit_school_year_start"
className="form-control"
type="date"
value={editForm.start_date}
disabled={selectedYearReadOnly}
onChange={(event) =>
setEditForm((current) => ({ ...current, start_date: event.target.value }))
}
required
/>
</div>
<div className="col-md-6">
<label className="form-label" htmlFor="edit_school_year_end">
End date
</label>
<input
id="edit_school_year_end"
className="form-control"
type="date"
value={editForm.end_date}
disabled={selectedYearReadOnly}
onChange={(event) =>
setEditForm((current) => ({ ...current, end_date: event.target.value }))
}
required
/>
</div>
</div>
<div className="form-text mt-2">
Safe renames depend on whether finance and enrollment records already reference
the existing year label.
</div>
<button type="submit" className="btn btn-primary mt-3" disabled={busyEdit || selectedYearReadOnly}>
{busyEdit ? 'Saving…' : 'Save changes'}
</button>
</form>
)}
</div>
</div>
<div className="card shadow-sm mb-4">
<div className="card-body">
<div className="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-3">
<div>
<h2 className="h5 mb-1">Promotion And Balance Inspection</h2>
<div className="text-muted small">
Read-only previews for the selected year using the dedicated promotion and
parent-balance endpoints.
</div>
</div>
<div className="d-flex gap-2">
<button
type="button"
className="btn btn-outline-primary btn-sm"
disabled={!selectedYear}
onClick={() => void loadPromotionPreview()}
>
Load promotion preview
</button>
<button
type="button"
className="btn btn-outline-primary btn-sm"
disabled={!selectedYear}
onClick={() => void loadParentBalances()}
>
Load parent balances
</button>
</div>
</div>
<div className="row g-4">
<div className="col-lg-6">
<div className="border rounded p-3 h-100">
<h3 className="h6">Promotion preview</h3>
{!promotionPreview ? (
<p className="text-muted small mb-0">Not loaded yet.</p>
) : (
<>
<div className="small text-muted mb-3">
{promotionPreview.summary.total_students} students scanned,{' '}
{promotionPreview.summary.hold} hold rows.
</div>
<div className="table-responsive">
<table className="table table-sm align-middle mb-0">
<thead>
<tr>
<th>Student</th>
<th>Current</th>
<th>Action</th>
<th>Target</th>
</tr>
</thead>
<tbody>
{promotionRows.length === 0 ? (
<tr>
<td colSpan={4} className="text-muted">
No rows found.
</td>
</tr>
) : (
promotionRows.slice(0, 8).map((row) => (
<tr key={row.student_id}>
<td>
<div>{row.student_name}</div>
{row.warnings?.length ? (
<div className="small text-warning">
{row.warnings.join('; ')}
</div>
) : null}
</td>
<td>{row.current_grade ?? '—'}</td>
<td>{row.promotion_action}</td>
<td>{row.target_grade ?? '—'}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</>
)}
</div>
</div>
<div className="col-lg-6">
<div className="border rounded p-3 h-100">
<h3 className="h6">Parent balance preview</h3>
{!parentBalances ? (
<p className="text-muted small mb-0">Not loaded yet.</p>
) : (
<>
<div className="small text-muted mb-3">
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)}.
</div>
<div className="table-responsive">
<table className="table table-sm align-middle mb-0">
<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>
<th>Sources</th>
<th>Conflict</th>
</tr>
</thead>
<tbody>
{balanceRows.length === 0 ? (
<tr>
<td colSpan={7} className="text-muted">
No rows found.
</td>
</tr>
) : (
balanceRows.slice(0, 8).map((row) => (
<tr key={row.parent_id}>
<td>{row.parent_name || `Parent #${row.parent_id}`}</td>
<td>{row.student_names?.join(', ') || '—'}</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>
))
)}
</tbody>
</table>
</div>
</>
)}
</div>
</div>
</div>
</div>
</div>
<div className="card shadow-sm">
<div className="card-body">
<div className="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-3">
<div>
<h2 className="h5 mb-1">Close Selected Active Year</h2>
<div className="text-muted small">
Validate, preview, and close the inspected school year through the transactional
school-year closure endpoints. Only the current active year can be closed.
</div>
</div>
{selectedYearIsActive && selectedYear ? (
<span className="badge text-bg-success">{selectedYear.name}</span>
) : null}
</div>
{!selectedYear ? (
<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">
{[
'1. New Year Details',
'2. Academic Promotion Preview',
'3. Financial Balance Preview',
'4. Validation Results',
'5. Confirmation',
'6. Completion Report',
].map((step) => (
<div className="col-md-4" key={step}>
<div className="border rounded px-2 py-1 bg-light">{step}</div>
</div>
))}
</div>
<form onSubmit={(event) => void handleClose(event)}>
<div className="row g-3">
<div className="col-md-4">
<label className="form-label" htmlFor="close_school_year_name">
New year name
</label>
<input
id="close_school_year_name"
className="form-control"
value={closeForm.name}
onChange={(event) =>
setCloseForm((current) => ({ ...current, name: event.target.value }))
}
required
/>
</div>
<div className="col-md-4">
<label className="form-label" htmlFor="close_school_year_start">
New year start date
</label>
<input
id="close_school_year_start"
className="form-control"
type="date"
value={closeForm.start_date}
onChange={(event) =>
setCloseForm((current) => ({ ...current, start_date: event.target.value }))
}
required
/>
</div>
<div className="col-md-4">
<label className="form-label" htmlFor="close_school_year_end">
New year end date
</label>
<input
id="close_school_year_end"
className="form-control"
type="date"
value={closeForm.end_date}
onChange={(event) =>
setCloseForm((current) => ({ ...current, end_date: event.target.value }))
}
required
/>
</div>
</div>
<div className="row g-3 mt-0">
<div className="col-md-8">
<label className="form-label" htmlFor="close_school_year_confirmation">
Confirmation text
</label>
<input
id="close_school_year_confirmation"
className="form-control"
value={closeForm.confirmation}
onChange={(event) =>
setCloseForm((current) => ({
...current,
confirmation: event.target.value,
}))
}
placeholder={requiredConfirmation}
required
/>
<div className={confirmationMatches ? 'form-text' : 'form-text text-danger'}>
Type exactly <code>{requiredConfirmation}</code>. Button-click roulette is not a control.
</div>
</div>
<div className="col-md-4 d-flex align-items-end">
<div className="form-check mb-2">
<input
id="close_school_year_balances"
type="checkbox"
className="form-check-input"
checked={closeForm.transfer_unpaid_balances}
onChange={(event) =>
setCloseForm((current) => ({
...current,
transfer_unpaid_balances: event.target.checked,
}))
}
/>
<label className="form-check-label" htmlFor="close_school_year_balances">
Transfer unpaid balances
</label>
</div>
</div>
</div>
<div className="d-flex flex-wrap gap-2 mt-3">
<button
type="button"
className="btn btn-outline-primary"
disabled={busyCloseAction === 'validate' || !selectedYearIsActive}
onClick={() => void handleValidateClose()}
>
{busyCloseAction === 'validate' ? 'Validating…' : 'Validate'}
</button>
<button
type="button"
className="btn btn-outline-secondary"
disabled={busyCloseAction === 'preview' || !selectedYearIsActive}
onClick={() => void handlePreviewClose()}
>
{busyCloseAction === 'preview' ? 'Previewing…' : 'Preview'}
</button>
<button
type="submit"
className="btn btn-danger"
disabled={busyCloseAction === 'close' || !confirmationMatches || !selectedYearIsActive}
>
{busyCloseAction === 'close' ? 'Closing…' : `Close ${selectedYear.name}`}
</button>
</div>
</form>
</>
)}
{closeValidation ? (
<div className={`alert mt-4 ${closeValidation.can_close ? 'alert-success' : 'alert-warning'}`}>
<div className="fw-semibold mb-2">
{closeValidation.can_close ? 'Closure validation passed.' : 'Closure is currently blocked.'}
</div>
<div className="row g-3 small">
<div className="col-md-3">
Students scanned: <strong>{closeValidation.promotion_summary.total_students}</strong>
</div>
<div className="col-md-3">
Missing decisions: <strong>{closeValidation.promotion_summary.hold}</strong>
</div>
<div className="col-md-3">
Unpaid carryover parents: <strong>{closeValidation.financial_summary.parents_with_positive_balance ?? closeValidation.financial_summary.parents_with_non_zero_balance}</strong>
</div>
<div className="col-md-3">
Net impact: <strong>${formatMoney(closeValidation.financial_summary.net_balance_impact ?? closeValidation.financial_summary.net_balance_to_transfer)}</strong>
</div>
</div>
{closeValidation.errors.length > 0 ? (
<ul className="mb-0 mt-3">
{closeValidation.errors.map((message) => (
<li key={message}>{message}</li>
))}
</ul>
) : null}
{closeValidation.warnings.length > 0 ? (
<ul className="mb-0 mt-3">
{closeValidation.warnings.map((message) => (
<li key={message}>{message}</li>
))}
</ul>
) : null}
</div>
) : null}
{closeResult ? (
<div className="alert alert-success mt-4">
<div className="fw-semibold mb-2">Completion report</div>
<div className="row g-3 small">
<div className="col-md-6">Closed year: <strong>{closeResult.closed_school_year.name}</strong></div>
<div className="col-md-6">New active year: <strong>{closeResult.new_school_year.name}</strong></div>
<div className="col-md-6">Promotions: <strong>{Object.values(closeResult.promotion_counts ?? {}).reduce((sum, value) => sum + Number(value || 0), 0)}</strong></div>
<div className="col-md-6">Balance transfer rows: <strong>{Object.values(closeResult.balance_counts ?? {}).reduce((sum, value) => sum + Number(value || 0), 0)}</strong></div>
</div>
</div>
) : null}
{closePreview ? (
<div className="mt-4">
<div className="row g-4">
<div className="col-lg-6">
<div className="border rounded p-3 h-100">
<h3 className="h6">Closure promotion preview</h3>
<div className="table-responsive">
<table className="table table-sm align-middle mb-0">
<thead>
<tr>
<th>Student</th>
<th>Action</th>
<th>Target</th>
</tr>
</thead>
<tbody>
{closePromotionRows.slice(0, 8).map((row) => (
<tr key={`close-${row.student_id}`}>
<td>{row.student_name}</td>
<td>{row.promotion_action}</td>
<td>{row.target_grade ?? '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
<div className="col-lg-6">
<div className="border rounded p-3 h-100">
<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">
<table className="table table-sm align-middle mb-0">
<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>
<th>Conflict</th>
</tr>
</thead>
<tbody>
{closeBalanceRows.slice(0, 8).map((row) => (
<tr key={`balance-${row.parent_id}`}>
<td>{row.parent_name || `Parent #${row.parent_id}`}</td>
<td>{row.student_names?.join(', ') || '—'}</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>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
) : null}
</div>
</div>
</div>
</div>
</div>
)
}