add school year and security fix
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user