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

This commit is contained in:
root
2026-07-07 01:52:37 -04:00
parent b510f534fd
commit 4b78aafa65
18 changed files with 664 additions and 256 deletions
@@ -141,7 +141,7 @@ export function SchoolYearsManagementPage() {
const [busyEdit, setBusyEdit] = useState(false)
const [busyOpenId, setBusyOpenId] = useState<number | 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(
() => years.find((year) => Boolean(year.is_current)) ?? null,
@@ -150,13 +150,13 @@ export function SchoolYearsManagementPage() {
const selectedYear = useMemo(() => {
if (years.length === 0) return null
if (selectedYearId != null) {
return years.find((year) => year.id === selectedYearId) ?? null
if (selectedYearName) {
return years.find((year) => year.name === selectedYearName) ?? null
}
return activeYear ?? years[0] ?? null
}, [activeYear, selectedYearId, years])
}, [activeYear, selectedYearName, years])
async function refreshYears(preferredYearId?: number | null) {
async function refreshYears(preferredYearName?: string | null) {
setLoading(true)
setLoadingMessage('Loading school years…')
setError(null)
@@ -166,15 +166,17 @@ export function SchoolYearsManagementPage() {
setYears(list)
const nextSelectedId =
preferredYearId ??
(selectedYearId != null && list.some((year) => year.id === selectedYearId)
? selectedYearId
: list.find((year) => year.is_current)?.id ?? list[0]?.id ?? null)
preferredYearName ??
(selectedYearName && list.some((year) => year.name === selectedYearName)
? selectedYearName
: list.find((year) => year.is_current)?.name ?? list[0]?.name ?? null)
if (nextSelectedId != null) {
setSearchParams((current) => {
const next = new URLSearchParams(current)
next.set('year', String(nextSelectedId))
next.delete('year')
next.delete('school_year_id')
next.set('school_year', nextSelectedId)
return next
}, { replace: true })
}
@@ -238,7 +240,7 @@ export function SchoolYearsManagementPage() {
;(async () => {
try {
const nextSummary = await fetchSchoolYearSummary(selectedYear.id)
const nextSummary = await fetchSchoolYearSummary(selectedYear.name)
if (!cancelled) {
setSummary(nextSummary)
}
@@ -273,10 +275,12 @@ export function SchoolYearsManagementPage() {
setMessages([{ type, message }])
}
function selectYear(yearId: number) {
function selectYear(yearName: string) {
setSearchParams((current) => {
const next = new URLSearchParams(current)
next.set('year', String(yearId))
next.delete('year')
next.delete('school_year_id')
next.set('school_year', yearName)
return next
})
setPromotionPreview(null)
@@ -292,7 +296,7 @@ export function SchoolYearsManagementPage() {
const created = await createSchoolYear(createForm)
pushMessage('success', `Draft school year ${created.name} created.`)
setCreateForm(emptyYearForm())
await refreshYears(created.id)
await refreshYears(created.name)
} catch (saveError) {
setError(normalizeApiError(saveError, 'Unable to create school year.'))
} finally {
@@ -306,9 +310,9 @@ export function SchoolYearsManagementPage() {
setBusyEdit(true)
setError(null)
try {
const updated = await updateSchoolYear(selectedYear.id, editForm)
const updated = await updateSchoolYear(selectedYear.name, editForm)
pushMessage('success', `School year ${updated.name} updated.`)
await refreshYears(updated.id)
await refreshYears(updated.name)
} catch (saveError) {
setError(normalizeApiError(saveError, 'Unable to update school year.'))
} finally {
@@ -320,11 +324,11 @@ export function SchoolYearsManagementPage() {
setBusyOpenId(year.id)
setError(null)
try {
const reopened = await reopenSchoolYear(year.id)
const reopened = await reopenSchoolYear(year.name)
pushMessage('success', `School year ${reopened.name} is now active.`)
setCloseValidation(null)
setClosePreview(null)
await refreshYears(reopened.id)
await refreshYears(reopened.name)
} catch (actionError) {
setError(normalizeApiError(actionError, 'Unable to open school year.'))
} finally {
@@ -338,7 +342,7 @@ export function SchoolYearsManagementPage() {
setError(null)
try {
const preview = await fetchSchoolYearPromotionPreview(
selectedYear.id,
selectedYear.name,
suggestNextName(selectedYear.name),
)
setPromotionPreview(preview)
@@ -353,7 +357,7 @@ export function SchoolYearsManagementPage() {
setError(null)
try {
const balances = await fetchSchoolYearParentBalances(
selectedYear.id,
selectedYear.name,
suggestNextName(selectedYear.name),
)
setParentBalances(balances)
@@ -363,11 +367,11 @@ export function SchoolYearsManagementPage() {
}
async function handleValidateClose() {
if (!activeYear) return
if (!selectedYear?.is_current) return
setBusyCloseAction('validate')
setError(null)
try {
const validation = await validateSchoolYearClose(activeYear.id, closePayloadFrom(closeForm))
const validation = await validateSchoolYearClose(selectedYear.name, closePayloadFrom(closeForm))
setCloseValidation(validation)
setClosePreview(null)
setCloseResult(null)
@@ -379,11 +383,11 @@ export function SchoolYearsManagementPage() {
}
async function handlePreviewClose() {
if (!activeYear) return
if (!selectedYear?.is_current) return
setBusyCloseAction('preview')
setError(null)
try {
const preview = await previewSchoolYearClose(activeYear.id, closePayloadFrom(closeForm))
const preview = await previewSchoolYearClose(selectedYear.name, closePayloadFrom(closeForm))
setCloseValidation(preview.validation)
setClosePreview(preview)
setCloseResult(null)
@@ -396,11 +400,11 @@ export function SchoolYearsManagementPage() {
async function handleClose(event: FormEvent) {
event.preventDefault()
if (!activeYear) return
if (!selectedYear?.is_current) return
setBusyCloseAction('close')
setError(null)
try {
const result = await closeSchoolYear(activeYear.id, closePayloadFrom(closeForm))
const result = await closeSchoolYear(selectedYear.name, closePayloadFrom(closeForm))
pushMessage(
'success',
`Closed ${result.closed_school_year.name} and opened ${result.new_school_year.name}.`,
@@ -411,7 +415,7 @@ export function SchoolYearsManagementPage() {
setPromotionPreview(null)
setParentBalances(null)
setCloseForm(emptyCloseForm())
await refreshYears(result.new_school_year.id)
await refreshYears(result.new_school_year.name)
} catch (actionError) {
setError(normalizeApiError(actionError, 'Unable to close school year.'))
} finally {
@@ -425,8 +429,9 @@ export function SchoolYearsManagementPage() {
const closePromotionRows = closePreview?.promotion_preview.rows ?? []
const closeBalanceRows = closePreview?.parent_balances.rows ?? []
const selectedYearReadOnly = isReadOnlySchoolYear(selectedYear)
const requiredConfirmation = activeYear ? `CLOSE ${activeYear.name}` : ''
const confirmationMatches = !activeYear || closeForm.confirmation.trim() === requiredConfirmation
const selectedYearIsActive = Boolean(selectedYear?.is_current)
const requiredConfirmation = selectedYearIsActive && selectedYear ? `CLOSE ${selectedYear.name}` : ''
const confirmationMatches = !selectedYearIsActive || closeForm.confirmation.trim() === requiredConfirmation
return (
<div className="container-fluid py-4">
@@ -484,7 +489,7 @@ export function SchoolYearsManagementPage() {
<button
type="button"
className="btn btn-link p-0 fw-semibold text-decoration-none"
onClick={() => selectYear(year.id)}
onClick={() => selectYear(year.name)}
>
{year.name}
</button>
@@ -498,13 +503,13 @@ export function SchoolYearsManagementPage() {
<button
type="button"
className="btn btn-sm btn-outline-primary"
onClick={() => selectYear(year.id)}
onClick={() => selectYear(year.name)}
>
Inspect
</button>
<Link
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
</Link>
@@ -634,14 +639,14 @@ export function SchoolYearsManagementPage() {
</div>
<div className="col-md-6 col-xl-3">
<div className="border rounded p-3 h-100">
<div className="small text-muted text-uppercase">Parents with balances</div>
<div className="display-6">{summaryCards.parents_with_balances}</div>
<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_to_transfer)}</div>
<div className="display-6">${formatMoney(summaryCards.net_balance_impact ?? summaryCards.net_balance_to_transfer)}</div>
</div>
</div>
</div>
@@ -651,13 +656,13 @@ export function SchoolYearsManagementPage() {
{selectedYear ? (
<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
</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
</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
</Link>
</div>
@@ -828,8 +833,9 @@ export function SchoolYearsManagementPage() {
) : (
<>
<div className="small text-muted mb-3">
{parentBalances.summary.parents_with_non_zero_balance} parents with
balances, net ${formatMoney(parentBalances.summary.net_balance_to_transfer)}.
Source year: {parentBalances.from_school_year}; target year: {parentBalances.to_school_year}.{' '}
Unpaid ${formatMoney(parentBalances.summary.total_positive_unpaid_balance ?? parentBalances.summary.total_old_unpaid_balance)},{' '}
credits ${formatMoney(parentBalances.summary.total_credit_balance)}, net ${formatMoney(parentBalances.summary.net_balance_impact ?? parentBalances.summary.net_balance_to_transfer)}.
</div>
<div className="table-responsive">
<table className="table table-sm align-middle mb-0">
@@ -837,13 +843,17 @@ export function SchoolYearsManagementPage() {
<tr>
<th>Parent</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>
</thead>
<tbody>
{balanceRows.length === 0 ? (
<tr>
<td colSpan={3} className="text-muted">
<td colSpan={7} className="text-muted">
No rows found.
</td>
</tr>
@@ -852,7 +862,11 @@ export function SchoolYearsManagementPage() {
<tr key={row.parent_id}>
<td>{row.parent_name || `Parent #${row.parent_id}`}</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>
))
)}
@@ -871,19 +885,23 @@ export function SchoolYearsManagementPage() {
<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 Active Year</h2>
<h2 className="h5 mb-1">Close Selected Active Year</h2>
<div className="text-muted small">
Validate, preview, and close the current year through the transactional
school-year closure endpoints.
Validate, preview, and close the inspected school year through the transactional
school-year closure endpoints. Only the current active year can be closed.
</div>
</div>
{activeYear ? (
<span className="badge text-bg-success">{activeYear.name}</span>
{selectedYearIsActive && selectedYear ? (
<span className="badge text-bg-success">{selectedYear.name}</span>
) : null}
</div>
{!activeYear ? (
<p className="text-muted mb-0">No active school year is available to close.</p>
{!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">
@@ -995,7 +1013,7 @@ export function SchoolYearsManagementPage() {
<button
type="button"
className="btn btn-outline-primary"
disabled={busyCloseAction === 'validate'}
disabled={busyCloseAction === 'validate' || !selectedYearIsActive}
onClick={() => void handleValidateClose()}
>
{busyCloseAction === 'validate' ? 'Validating…' : 'Validate'}
@@ -1003,7 +1021,7 @@ export function SchoolYearsManagementPage() {
<button
type="button"
className="btn btn-outline-secondary"
disabled={busyCloseAction === 'preview'}
disabled={busyCloseAction === 'preview' || !selectedYearIsActive}
onClick={() => void handlePreviewClose()}
>
{busyCloseAction === 'preview' ? 'Previewing…' : 'Preview'}
@@ -1011,9 +1029,9 @@ export function SchoolYearsManagementPage() {
<button
type="submit"
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>
</div>
</form>
@@ -1033,10 +1051,10 @@ export function SchoolYearsManagementPage() {
Missing decisions: <strong>{closeValidation.promotion_summary.hold}</strong>
</div>
<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 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>
{closeValidation.errors.length > 0 ? (
@@ -1099,13 +1117,19 @@ export function SchoolYearsManagementPage() {
<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>Transfer</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>
@@ -1113,7 +1137,10 @@ export function SchoolYearsManagementPage() {
<tr key={`balance-${row.parent_id}`}>
<td>{row.parent_name || `Parent #${row.parent_id}`}</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>
))}
</tbody>