add school year and security fix
Web Client CI/CD / Lint (ESLint + TypeScript) (push) Successful in 28s
Web Client CI/CD / Build (tsc + Vite) (push) Successful in 23s
Web Client CI/CD / Deploy to shared hosting (push) Successful in 33s

This commit is contained in:
root
2026-07-06 00:55:02 -04:00
parent 8cacd0874f
commit b74ddca8b1
27 changed files with 1258 additions and 39 deletions
@@ -1,6 +1,8 @@
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,
@@ -15,6 +17,7 @@ import {
validateSchoolYearClose,
type CloseSchoolYearPayload,
type SchoolYearClosePreview,
type SchoolYearCloseResult,
type SchoolYearCloseValidation,
type SchoolYearParentBalancePreview,
type SchoolYearPromotionPreview,
@@ -75,19 +78,6 @@ function formatMoney(value: number | null | undefined): string {
})
}
function statusBadge(status: string): string {
switch (status) {
case 'active':
return 'success'
case 'closed':
return 'danger'
case 'draft':
return 'warning'
default:
return 'secondary'
}
}
function suggestNextName(name: string): string {
const fullRange = name.match(/^(\d{4})\s*-\s*(\d{4})$/)
if (fullRange) {
@@ -143,6 +133,7 @@ export function SchoolYearsManagementPage() {
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())
@@ -213,6 +204,7 @@ export function SchoolYearsManagementPage() {
setParentBalances(null)
setCloseValidation(null)
setClosePreview(null)
setCloseResult(null)
setError(normalizeApiError(loadError, 'Unable to load school years.'))
} finally {
setLoading(false)
@@ -289,6 +281,7 @@ export function SchoolYearsManagementPage() {
})
setPromotionPreview(null)
setParentBalances(null)
setCloseResult(null)
}
async function handleCreate(event: FormEvent) {
@@ -377,6 +370,7 @@ export function SchoolYearsManagementPage() {
const validation = await validateSchoolYearClose(activeYear.id, closePayloadFrom(closeForm))
setCloseValidation(validation)
setClosePreview(null)
setCloseResult(null)
} catch (actionError) {
setError(normalizeApiError(actionError, 'Unable to validate school year closure.'))
} finally {
@@ -392,6 +386,7 @@ export function SchoolYearsManagementPage() {
const preview = await previewSchoolYearClose(activeYear.id, closePayloadFrom(closeForm))
setCloseValidation(preview.validation)
setClosePreview(preview)
setCloseResult(null)
} catch (actionError) {
setError(normalizeApiError(actionError, 'Unable to preview school year closure.'))
} finally {
@@ -412,6 +407,7 @@ export function SchoolYearsManagementPage() {
)
setCloseValidation(null)
setClosePreview(null)
setCloseResult(result)
setPromotionPreview(null)
setParentBalances(null)
setCloseForm(emptyCloseForm())
@@ -428,6 +424,9 @@ export function SchoolYearsManagementPage() {
const balanceRows = parentBalances?.rows ?? []
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
return (
<div className="container-fluid py-4">
@@ -453,8 +452,11 @@ export function SchoolYearsManagementPage() {
parent balance carryover, and year closure through the matching Laravel API routes.
</p>
</div>
<div className="text-muted small">
Uses <code>/api/v1/school-years</code> with JWT <code>Authorization: Bearer</code>.
<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>
@@ -490,9 +492,7 @@ export function SchoolYearsManagementPage() {
{toDisplayDate(year.start_date)} to {toDisplayDate(year.end_date)}
</div>
</div>
<span className={`badge text-bg-${statusBadge(year.status)}`}>
{year.status.toUpperCase()}
</span>
<SchoolYearStatusBadge status={year.status} />
</div>
<div className="d-flex flex-wrap gap-2">
<button
@@ -502,6 +502,12 @@ export function SchoolYearsManagementPage() {
>
Inspect
</button>
<Link
className="btn btn-sm btn-outline-secondary"
to={`/app/admin/school-years/${year.id}/summary`}
>
Summary
</Link>
{!year.is_current ? (
<button
type="button"
@@ -604,9 +610,7 @@ export function SchoolYearsManagementPage() {
</div>
</div>
{selectedYear ? (
<span className={`badge text-bg-${statusBadge(selectedYear.status)}`}>
{selectedYear.status.toUpperCase()}
</span>
<SchoolYearStatusBadge status={selectedYear.status} />
) : null}
</div>
@@ -645,9 +649,28 @@ export function SchoolYearsManagementPage() {
</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/${selectedYear.id}/summary`}>
Open summary page
</Link>
<Link className="btn btn-outline-secondary btn-sm" to={`/app/admin/school-years/${selectedYear.id}/reports/closing`}>
Closing report
</Link>
<Link className="btn btn-outline-secondary btn-sm" to={`/app/admin/school-years/${selectedYear.id}/reports/balance-transfers`}>
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>
) : (
@@ -660,6 +683,7 @@ export function SchoolYearsManagementPage() {
id="edit_school_year_name"
className="form-control"
value={editForm.name}
disabled={selectedYearReadOnly}
onChange={(event) =>
setEditForm((current) => ({ ...current, name: event.target.value }))
}
@@ -676,6 +700,7 @@ export function SchoolYearsManagementPage() {
className="form-control"
type="date"
value={editForm.start_date}
disabled={selectedYearReadOnly}
onChange={(event) =>
setEditForm((current) => ({ ...current, start_date: event.target.value }))
}
@@ -691,6 +716,7 @@ export function SchoolYearsManagementPage() {
className="form-control"
type="date"
value={editForm.end_date}
disabled={selectedYearReadOnly}
onChange={(event) =>
setEditForm((current) => ({ ...current, end_date: event.target.value }))
}
@@ -702,7 +728,7 @@ export function SchoolYearsManagementPage() {
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}>
<button type="submit" className="btn btn-primary mt-3" disabled={busyEdit || selectedYearReadOnly}>
{busyEdit ? 'Saving…' : 'Save changes'}
</button>
</form>
@@ -859,6 +885,21 @@ export function SchoolYearsManagementPage() {
{!activeYear ? (
<p className="text-muted mb-0">No active school year is available to close.</p>
) : (
<>
<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">
@@ -922,9 +963,12 @@ export function SchoolYearsManagementPage() {
confirmation: event.target.value,
}))
}
placeholder={`CLOSE ${activeYear.name}`}
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">
@@ -964,11 +1008,16 @@ export function SchoolYearsManagementPage() {
>
{busyCloseAction === 'preview' ? 'Previewing…' : 'Preview'}
</button>
<button type="submit" className="btn btn-danger" disabled={busyCloseAction === 'close'}>
<button
type="submit"
className="btn btn-danger"
disabled={busyCloseAction === 'close' || !confirmationMatches}
>
{busyCloseAction === 'close' ? 'Closing…' : `Close ${activeYear.name}`}
</button>
</div>
</form>
</>
)}
{closeValidation ? (
@@ -1007,6 +1056,18 @@ export function SchoolYearsManagementPage() {
</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">