Files
2026-06-07 00:52:01 -04:00

1586 lines
62 KiB
PHP

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>School Years Management</title>
<style>
:root {
--bg: #f4efe7;
--bg-strong: #ece4d5;
--panel: rgba(255, 252, 247, 0.94);
--panel-alt: rgba(246, 238, 225, 0.96);
--line: rgba(54, 41, 26, 0.12);
--line-strong: rgba(32, 87, 68, 0.24);
--ink: #1d2629;
--muted: #677176;
--accent: #1d6a55;
--accent-deep: #114838;
--warning: #a86511;
--danger: #9d3131;
--danger-soft: #f7dfdf;
--success-soft: #dcefe6;
--warning-soft: #f8eed8;
--shadow: 0 26px 56px rgba(33, 26, 18, 0.12);
--radius: 26px;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
color: var(--ink);
font-family: Georgia, "Times New Roman", serif;
background:
radial-gradient(circle at top left, rgba(29, 106, 85, 0.17), transparent 28%),
radial-gradient(circle at top right, rgba(168, 101, 17, 0.13), transparent 24%),
linear-gradient(180deg, #f8f2ea 0%, #efe7da 100%);
}
button,
input,
a {
font: inherit;
}
a {
color: inherit;
}
.page {
max-width: 1600px;
margin: 0 auto;
padding: 24px 18px 42px;
display: grid;
grid-template-columns: minmax(250px, 290px) minmax(0, 1fr);
gap: 20px;
}
.panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
backdrop-filter: blur(12px);
}
.sidebar {
padding: 22px 18px;
align-self: start;
position: sticky;
top: 18px;
}
.sidebar-label {
margin: 0 0 16px;
font-size: 0.8rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--accent);
}
.nav-title {
margin: 0 0 16px;
font-size: 1.6rem;
line-height: 1;
}
.nav-list {
display: grid;
gap: 8px;
}
.nav-item {
padding: 10px 12px;
border-radius: 16px;
color: var(--muted);
text-decoration: none;
border: 1px solid transparent;
}
.nav-item.active {
color: var(--accent-deep);
background: rgba(29, 106, 85, 0.11);
border-color: rgba(29, 106, 85, 0.18);
font-weight: 700;
}
.workspace {
display: grid;
gap: 18px;
}
.hero {
padding: 26px 28px;
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(270px, 0.8fr);
gap: 18px;
}
.breadcrumbs {
margin: 0 0 10px;
font-size: 0.84rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
}
h1,
h2,
h3 {
margin: 0;
line-height: 1;
}
h1 {
font-size: clamp(2rem, 4vw, 3.3rem);
margin-bottom: 14px;
}
.hero-copy {
margin: 0;
max-width: 56rem;
color: var(--muted);
line-height: 1.55;
}
.hero-side {
display: grid;
gap: 12px;
align-content: start;
}
.hero-card {
padding: 16px 18px;
border-radius: 18px;
background: var(--panel-alt);
border: 1px solid rgba(54, 41, 26, 0.08);
}
.hero-card strong {
display: block;
font-size: 1rem;
margin-bottom: 6px;
}
.button-row,
.card-actions,
.form-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.button,
button {
appearance: none;
border: 0;
cursor: pointer;
text-decoration: none;
border-radius: 999px;
padding: 11px 16px;
display: inline-flex;
align-items: center;
justify-content: center;
transition: transform 120ms ease, opacity 120ms ease;
}
.button:hover,
button:hover {
transform: translateY(-1px);
}
.button:disabled,
button:disabled {
opacity: 0.6;
cursor: wait;
transform: none;
}
.button-primary {
background: var(--accent);
color: #fff;
}
.button-secondary {
background: #e9dcc5;
color: var(--ink);
}
.button-ghost {
background: transparent;
border: 1px solid rgba(54, 41, 26, 0.18);
color: var(--ink);
}
.button-danger {
background: var(--danger);
color: #fff;
}
.button-link {
background: #fff;
border: 1px solid rgba(54, 41, 26, 0.12);
}
.notice-stack {
display: grid;
gap: 10px;
}
.notice {
border-radius: 18px;
padding: 14px 16px;
border: 1px solid rgba(54, 41, 26, 0.08);
}
.notice.success {
background: var(--success-soft);
color: var(--accent-deep);
}
.notice.warning {
background: var(--warning-soft);
color: #815112;
}
.notice.error {
background: var(--danger-soft);
color: var(--danger);
}
.stats-grid,
.summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
}
.stat-card,
.summary-card {
padding: 18px;
border-radius: 20px;
background: var(--panel-alt);
border: 1px solid rgba(54, 41, 26, 0.08);
}
.stat-card strong,
.summary-card strong {
display: block;
font-size: 1.8rem;
margin-bottom: 6px;
}
.content-grid {
display: grid;
grid-template-columns: minmax(320px, 0.86fr) minmax(0, 1.14fr);
gap: 18px;
}
.column {
display: grid;
gap: 18px;
align-content: start;
}
.section {
padding: 22px;
}
.section-header {
display: flex;
justify-content: space-between;
gap: 14px;
align-items: flex-start;
margin-bottom: 16px;
}
.section-copy {
margin-top: 8px;
color: var(--muted);
line-height: 1.48;
}
.status-pill {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 7px 12px;
font-size: 0.75rem;
letter-spacing: 0.12em;
text-transform: uppercase;
background: #d8eee4;
color: var(--accent-deep);
}
.status-pill.draft {
background: #f1e5d0;
color: #7d4c0f;
}
.status-pill.closed,
.status-pill.blocked {
background: #f4dddd;
color: var(--danger);
}
.year-list,
.preview-stack {
display: grid;
gap: 12px;
}
.year-card {
padding: 18px;
border: 1px solid var(--line);
border-radius: 20px;
background: rgba(255, 255, 255, 0.8);
}
.year-card.selected {
border-color: var(--line-strong);
box-shadow: inset 0 0 0 1px rgba(29, 106, 85, 0.1);
}
.year-meta {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: flex-start;
}
.year-dates,
.microcopy,
.empty-state {
color: var(--muted);
line-height: 1.45;
}
.year-dates {
margin-top: 10px;
font-size: 0.94rem;
}
form {
display: grid;
gap: 14px;
}
.field-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
label {
display: grid;
gap: 6px;
color: var(--muted);
font-size: 0.92rem;
}
input[type="text"],
input[type="date"] {
width: 100%;
border-radius: 14px;
border: 1px solid rgba(54, 41, 26, 0.18);
padding: 12px 14px;
background: rgba(255, 255, 255, 0.94);
color: var(--ink);
}
input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: var(--accent);
}
.checkbox {
display: flex;
gap: 10px;
align-items: center;
color: var(--ink);
}
.mono {
font-family: "SFMono-Regular", ui-monospace, monospace;
}
.info-strip {
padding: 14px 16px;
border-radius: 18px;
background: var(--panel-alt);
border: 1px dashed rgba(54, 41, 26, 0.18);
color: var(--muted);
}
.table-scroll {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.93rem;
}
th,
td {
text-align: left;
padding: 10px 8px;
border-bottom: 1px solid rgba(54, 41, 26, 0.08);
vertical-align: top;
}
th {
color: var(--muted);
font-size: 0.76rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.split {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.loading {
color: var(--muted);
font-style: italic;
}
@media (max-width: 1240px) {
.page,
.content-grid,
.hero {
grid-template-columns: 1fr;
}
.sidebar {
position: static;
}
}
@media (max-width: 760px) {
.stats-grid,
.summary-grid,
.field-grid,
.split {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div class="page">
<aside class="panel sidebar">
<p class="sidebar-label">Management</p>
<h2 class="nav-title">Navigation</h2>
<nav class="nav-list" aria-label="Administrator navigation">
<a class="nav-item" href="#">Home</a>
<a class="nav-item" href="#">CI views</a>
<a class="nav-item" href="#">Attendance Management</a>
<a class="nav-item" href="#">Classes</a>
<a class="nav-item" href="#">Communication</a>
<a class="nav-item" href="#">Competition</a>
<a class="nav-item" href="#">Configuration</a>
<a class="nav-item" href="#">Curriculum</a>
<a class="nav-item" href="#">Event Management</a>
<a class="nav-item" href="#">Exams</a>
<a class="nav-item" href="#">Family</a>
<a class="nav-item" href="#">Financial</a>
<a class="nav-item" href="#">Inventory</a>
<a class="nav-item" href="#">Notification Management</a>
<a class="nav-item" href="#">Parents</a>
<a class="nav-item" href="#">Printables</a>
<a class="nav-item" href="#">Roles</a>
<a class="nav-item active" href="/app/administrator/school-years">School Years Management</a>
<a class="nav-item" href="#">Score Management</a>
<a class="nav-item" href="#">Staffing</a>
<a class="nav-item" href="#">Student-Affairs</a>
<a class="nav-item" href="#">TimeOff Request</a>
<a class="nav-item" href="#">Users</a>
</nav>
</aside>
<main class="workspace">
<section class="panel hero">
<div>
<p class="breadcrumbs">Home / administrator / school-years</p>
<h1>School Years Management</h1>
<p class="hero-copy">
Replace the placeholder with a real control surface for creating draft years, editing metadata,
reopening a year, validating closure, previewing promotions and family balances, and closing the
active year through the same JWT-protected Laravel API used by the rest of the admin app.
</p>
</div>
<div class="hero-side">
<div class="hero-card">
<strong>SPA path</strong>
<span class="mono">/app/administrator/school-years</span>
</div>
<div class="hero-card">
<strong>API auth</strong>
<span>JWT Authorization: Bearer</span>
</div>
<div class="button-row">
<a class="button button-link" href="{{ $pageConfig['docs_url'] }}" target="_blank" rel="noreferrer">Open API docs</a>
</div>
</div>
</section>
<div id="notice-stack" class="notice-stack"></div>
<section id="stats-grid" class="stats-grid"></section>
<div class="content-grid">
<div class="column">
<section class="panel section">
<div class="section-header">
<div>
<h2>School Years</h2>
<div class="section-copy">
Select a year to inspect it. Opening or reopening a year switches the live configuration
and leaves the rest visible as historical records.
</div>
</div>
</div>
<div id="year-list" class="year-list"></div>
</section>
<section class="panel section">
<div class="section-header">
<div>
<h2>Create Draft Year</h2>
<div class="section-copy">Create the next year ahead of activation so closure can target a prepared record.</div>
</div>
</div>
<form id="create-form">
<div class="field-grid">
<label>
Name
<input type="text" name="name" required>
</label>
<label>
Start date
<input type="date" name="start_date" required>
</label>
</div>
<label>
End date
<input type="date" name="end_date" required>
</label>
<div class="form-actions">
<button type="submit" class="button-primary">Create draft year</button>
</div>
</form>
</section>
</div>
<div class="column">
<section class="panel section">
<div id="selected-summary"></div>
</section>
<section class="panel section">
<div id="selected-edit"></div>
</section>
<section class="panel section">
<div id="selected-inspection"></div>
</section>
<section class="panel section">
<div id="close-workflow"></div>
</section>
</div>
</div>
</main>
</div>
<script>
window.schoolYearPage = @json($pageConfig);
</script>
<script>
(function () {
const config = window.schoolYearPage || {};
const state = {
schoolYears: [],
activeYear: null,
selectedYearId: Number(config.selected_year_id || 0) || null,
selectedSummary: null,
selectedPromotionPreview: null,
selectedParentBalances: null,
closeDraft: null,
closeValidation: null,
closePreview: null,
notices: [],
};
const elements = {
noticeStack: document.getElementById('notice-stack'),
statsGrid: document.getElementById('stats-grid'),
yearList: document.getElementById('year-list'),
createForm: document.getElementById('create-form'),
selectedSummary: document.getElementById('selected-summary'),
selectedEdit: document.getElementById('selected-edit'),
selectedInspection: document.getElementById('selected-inspection'),
closeWorkflow: document.getElementById('close-workflow'),
};
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function formatMoney(value) {
const number = Number(value || 0);
return number.toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
function formatDate(value) {
if (!value) {
return '-';
}
const parsed = new Date(value + 'T00:00:00');
if (Number.isNaN(parsed.getTime())) {
return value;
}
return parsed.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
function statusLabel(value) {
return String(value || '').toUpperCase();
}
function templateUrl(template, id) {
return String(template || '').replace('__ID__', String(id));
}
function withQuery(url, params) {
const target = new URL(url, window.location.origin);
Object.entries(params || {}).forEach(([key, value]) => {
if (value !== null && value !== undefined && value !== '') {
target.searchParams.set(key, value);
}
});
return target.toString();
}
function defaultDraft() {
const activeYear = state.activeYear;
if (!activeYear) {
return Object.assign({
name: '',
start_date: '',
end_date: '',
}, config.default_draft || {});
}
const suggestedName = suggestNextName(activeYear.name) || (config.default_draft || {}).name || '';
const suggestedStart = shiftYear(activeYear.start_date) || (config.default_draft || {}).start_date || '';
const suggestedEnd = shiftYear(activeYear.end_date) || (config.default_draft || {}).end_date || '';
return {
name: suggestedName,
start_date: suggestedStart,
end_date: suggestedEnd,
};
}
function suggestNextName(name) {
const fullRange = String(name || '').match(/^(\d{4})\s*-\s*(\d{4})$/);
if (fullRange) {
return `${Number(fullRange[1]) + 1}-${Number(fullRange[2]) + 1}`;
}
const shortRange = String(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 shiftYear(dateValue) {
if (!dateValue) {
return '';
}
const parts = String(dateValue).split('-');
if (parts.length !== 3) {
return '';
}
return `${Number(parts[0]) + 1}-${parts[1]}-${parts[2]}`;
}
function selectedYear() {
return state.schoolYears.find((year) => Number(year.id) === Number(state.selectedYearId)) || null;
}
function pushNotice(type, message) {
state.notices = [{ type, message }, ...state.notices].slice(0, 4);
renderNotices();
}
function renderNotices() {
if (!state.notices.length) {
elements.noticeStack.innerHTML = '';
return;
}
elements.noticeStack.innerHTML = state.notices.map((notice) => `
<div class="notice ${escapeHtml(notice.type)}">${escapeHtml(notice.message)}</div>
`).join('');
}
function syncSelectedYearInUrl() {
const url = new URL(window.location.href);
if (state.selectedYearId) {
url.searchParams.set('year', String(state.selectedYearId));
} else {
url.searchParams.delete('year');
}
window.history.replaceState({}, '', url.toString());
}
async function apiRequest(url, options) {
const requestOptions = Object.assign({ method: 'GET' }, options || {});
const headers = Object.assign({
'Accept': 'application/json',
'Authorization': `Bearer ${config.jwt}`,
}, requestOptions.headers || {});
if (requestOptions.body && !(requestOptions.body instanceof FormData)) {
headers['Content-Type'] = 'application/json';
requestOptions.body = JSON.stringify(requestOptions.body);
}
const response = await fetch(url, Object.assign({}, requestOptions, { headers }));
const raw = await response.text();
let json = {};
try {
json = raw ? JSON.parse(raw) : {};
} catch (error) {
json = { message: raw || 'Request failed.' };
}
if (!response.ok || json.ok === false || json.status === false) {
const messages = json.errors
? Object.values(json.errors).flat().map(String)
: [json.message || `Request failed with status ${response.status}.`];
const failure = new Error(messages.join(' '));
failure.messages = messages;
failure.status = response.status;
throw failure;
}
return json;
}
function setFormBusy(form, isBusy) {
if (!form) {
return;
}
form.querySelectorAll('button, input').forEach((field) => {
field.disabled = isBusy;
});
}
function renderStats() {
const activeName = state.activeYear ? state.activeYear.name : 'None';
const closedCount = state.schoolYears.filter((year) => year.status === 'closed').length;
const draftCount = state.schoolYears.filter((year) => year.status === 'draft').length;
elements.statsGrid.innerHTML = `
<div class="stat-card panel">
<strong>${state.schoolYears.length}</strong>
Configured school years
</div>
<div class="stat-card panel">
<strong>${escapeHtml(activeName)}</strong>
Active year
</div>
<div class="stat-card panel">
<strong>${closedCount}</strong>
Closed years
</div>
<div class="stat-card panel">
<strong>${draftCount}</strong>
Draft years
</div>
`;
}
function renderYearList() {
if (!state.schoolYears.length) {
elements.yearList.innerHTML = '<div class="empty-state">No school years exist yet.</div>';
return;
}
elements.yearList.innerHTML = state.schoolYears.map((year) => {
const isSelected = Number(year.id) === Number(state.selectedYearId);
const isCurrent = Boolean(year.is_current);
const openLabel = year.status === 'draft' ? 'Open draft' : 'Reopen';
return `
<article class="year-card ${isSelected ? 'selected' : ''}">
<div class="year-meta">
<div>
<h3>${escapeHtml(year.name)}</h3>
<div class="year-dates">
${escapeHtml(formatDate(year.start_date))} to ${escapeHtml(formatDate(year.end_date))}
${year.closed_at ? `<br>Closed ${escapeHtml(year.closed_at)}` : ''}
</div>
</div>
<span class="status-pill ${escapeHtml(year.status)}">${escapeHtml(statusLabel(year.status))}</span>
</div>
<div class="card-actions">
<button class="button-secondary" type="button" data-select-year="${year.id}">Inspect</button>
${isCurrent ? '<span class="status-pill">Current</span>' : `<button class="button-primary" type="button" data-open-year="${year.id}">${escapeHtml(openLabel)}</button>`}
</div>
</article>
`;
}).join('');
}
function renderCreateForm() {
const defaults = defaultDraft();
const form = elements.createForm;
if (!form) {
return;
}
form.elements.name.value = defaults.name || '';
form.elements.start_date.value = defaults.start_date || '';
form.elements.end_date.value = defaults.end_date || '';
}
function renderSelectedSummary() {
const year = selectedYear();
if (!year) {
elements.selectedSummary.innerHTML = `
<div class="section-header">
<div>
<h2>Year Summary</h2>
<div class="section-copy">Select a school year from the list to view operational totals.</div>
</div>
</div>
<div class="empty-state">No school year is selected.</div>
`;
return;
}
const summary = state.selectedSummary;
const totals = summary ? summary.totals : null;
elements.selectedSummary.innerHTML = `
<div class="section-header">
<div>
<h2>${escapeHtml(year.name)}</h2>
<div class="section-copy">
Inspect the selected year before switching it live or closing it. Summary data is loaded from
<span class="mono">GET /api/v1/school-years/${escapeHtml(year.id)}/summary</span>.
</div>
</div>
<span class="status-pill ${escapeHtml(year.status)}">${escapeHtml(statusLabel(year.status))}</span>
</div>
<div class="info-strip">
${escapeHtml(formatDate(year.start_date))} to ${escapeHtml(formatDate(year.end_date))}
${year.is_current ? ' | Current configuration year' : ''}
</div>
${totals ? `
<div class="summary-grid" style="margin-top:16px;">
<div class="summary-card">
<strong>${totals.students_considered}</strong>
Students considered
</div>
<div class="summary-card">
<strong>${totals.students_blocked}</strong>
Students blocked
</div>
<div class="summary-card">
<strong>${totals.parents_with_balances}</strong>
Parents with carryover
</div>
<div class="summary-card">
<strong>${formatMoney(totals.net_balance_to_transfer)}</strong>
Net balance impact
</div>
</div>
` : '<div class="loading" style="margin-top:16px;">Loading summary…</div>'}
`;
}
function renderSelectedEdit() {
const year = selectedYear();
if (!year) {
elements.selectedEdit.innerHTML = `
<div class="section-header">
<div>
<h2>Edit Year</h2>
<div class="section-copy">Metadata editing appears once a year is selected.</div>
</div>
</div>
<div class="empty-state">Select a school year to edit its dates or name.</div>
`;
return;
}
elements.selectedEdit.innerHTML = `
<div class="section-header">
<div>
<h2>Edit Selected Year</h2>
<div class="section-copy">
Updates use <span class="mono">PATCH /api/v1/school-years/${escapeHtml(year.id)}</span>.
Safe renames are allowed only while historical operational references do not depend on the old label.
</div>
</div>
</div>
<form id="edit-form" data-school-year-id="${year.id}">
<div class="field-grid">
<label>
Year name
<input type="text" name="name" value="${escapeHtml(year.name)}" required>
</label>
<label>
Start date
<input type="date" name="start_date" value="${escapeHtml(year.start_date)}" required>
</label>
</div>
<label>
End date
<input type="date" name="end_date" value="${escapeHtml(year.end_date)}" required>
</label>
<div class="form-actions">
<button type="submit" class="button-ghost">Save changes</button>
</div>
</form>
`;
}
function renderInspection() {
const year = selectedYear();
if (!year) {
elements.selectedInspection.innerHTML = `
<div class="section-header">
<div>
<h2>Promotion And Balance Inspection</h2>
<div class="section-copy">Use the selected year to inspect promotions and parent balances.</div>
</div>
</div>
<div class="empty-state">Nothing to inspect until a school year is selected.</div>
`;
return;
}
const promotion = state.selectedPromotionPreview;
const balances = state.selectedParentBalances;
const promotionRows = (promotion?.rows || []).slice(0, 8).map((row) => `
<tr>
<td>${escapeHtml(row.student_name)}</td>
<td>${escapeHtml(row.current_grade || '-')}</td>
<td>${escapeHtml(row.promotion_action)}</td>
<td>${escapeHtml(row.target_grade || '-')}</td>
<td>${escapeHtml((row.warnings || []).join('; ') || '-')}</td>
</tr>
`).join('');
const balanceRows = (balances?.rows || []).slice(0, 8).map((row) => `
<tr>
<td>${escapeHtml(row.parent_name || `Parent #${row.parent_id}`)}</td>
<td>${escapeHtml((row.student_names || []).join(', ') || '-')}</td>
<td>${formatMoney(row.old_unpaid_balance)}</td>
<td>${formatMoney(row.credit_balance)}</td>
<td>${formatMoney(row.amount_to_transfer)}</td>
</tr>
`).join('');
elements.selectedInspection.innerHTML = `
<div class="section-header">
<div>
<h2>Promotion And Balance Inspection</h2>
<div class="section-copy">
Pull read-only previews from the selected year's standalone endpoints before deciding whether the year is ready to close.
</div>
</div>
</div>
<div class="button-row" style="margin-bottom:16px;">
<button type="button" class="button-secondary" data-load-preview="promotion">Load promotion preview</button>
<button type="button" class="button-secondary" data-load-preview="balances">Load parent balances</button>
</div>
<div class="split">
<div class="preview-stack">
<div class="info-strip">
<strong>Promotion preview</strong><br>
${promotion ? `${promotion.summary.total_students} students scanned, ${promotion.summary.hold} hold rows.` : 'Not loaded yet.'}
</div>
${promotion ? `
<div class="table-scroll">
<table>
<thead>
<tr>
<th>Student</th>
<th>Current</th>
<th>Action</th>
<th>Target</th>
<th>Warnings</th>
</tr>
</thead>
<tbody>${promotionRows || '<tr><td colspan="5">No students found.</td></tr>'}</tbody>
</table>
</div>
` : '<div class="empty-state">Use the button above to fetch the promotion preview.</div>'}
</div>
<div class="preview-stack">
<div class="info-strip">
<strong>Parent balance preview</strong><br>
${balances ? `${balances.summary.parents_with_non_zero_balance} parents with balances, net ${formatMoney(balances.summary.net_balance_to_transfer)}.` : 'Not loaded yet.'}
</div>
${balances ? `
<div class="table-scroll">
<table>
<thead>
<tr>
<th>Parent</th>
<th>Students</th>
<th>Old due</th>
<th>Credit</th>
<th>Transfer</th>
</tr>
</thead>
<tbody>${balanceRows || '<tr><td colspan="5">No balances found.</td></tr>'}</tbody>
</table>
</div>
` : '<div class="empty-state">Use the button above to fetch parent balances.</div>'}
</div>
</div>
`;
}
function renderValidation(validation) {
if (!validation) {
return '';
}
const errors = (validation.errors || []).map((message) => `<li>${escapeHtml(message)}</li>`).join('');
const warnings = (validation.warnings || []).map((message) => `<li>${escapeHtml(message)}</li>`).join('');
return `
<div class="preview-stack" style="margin-top:16px;">
<div class="section-header">
<div>
<h3>Closure Validation</h3>
<div class="section-copy">
${validation.can_close ? 'Validation passed. Review the preview and confirmation text before closing.' : 'Closure is blocked until the listed issues are resolved.'}
</div>
</div>
<span class="status-pill ${validation.can_close ? '' : 'blocked'}">${validation.can_close ? 'Ready' : 'Blocked'}</span>
</div>
<div class="summary-grid">
<div class="summary-card">
<strong>${validation.promotion_summary.total_students ?? 0}</strong>
Students scanned
</div>
<div class="summary-card">
<strong>${validation.promotion_summary.hold ?? 0}</strong>
Missing decisions
</div>
<div class="summary-card">
<strong>${validation.financial_summary.parents_with_non_zero_balance ?? 0}</strong>
Parents with balances
</div>
<div class="summary-card">
<strong>${formatMoney(validation.financial_summary.net_balance_to_transfer ?? 0)}</strong>
Net transfer
</div>
</div>
${errors ? `<div class="notice error"><strong>Errors</strong><ul>${errors}</ul></div>` : ''}
${warnings ? `<div class="notice warning"><strong>Warnings</strong><ul>${warnings}</ul></div>` : ''}
</div>
`;
}
function renderClosePreview(preview) {
if (!preview) {
return '';
}
const promotionRows = (preview.promotion_preview.rows || []).slice(0, 8).map((row) => `
<tr>
<td>${escapeHtml(row.student_name)}</td>
<td>${escapeHtml(row.current_grade || '-')}</td>
<td>${escapeHtml(row.promotion_action)}</td>
<td>${escapeHtml(row.target_grade || '-')}</td>
<td>${escapeHtml((row.warnings || []).join('; ') || '-')}</td>
</tr>
`).join('');
const balanceRows = (preview.parent_balances.rows || []).slice(0, 8).map((row) => `
<tr>
<td>${escapeHtml(row.parent_name || `Parent #${row.parent_id}`)}</td>
<td>${escapeHtml((row.student_names || []).join(', ') || '-')}</td>
<td>${formatMoney(row.old_unpaid_balance)}</td>
<td>${formatMoney(row.credit_balance)}</td>
<td>${formatMoney(row.amount_to_transfer)}</td>
</tr>
`).join('');
return `
<div class="split" style="margin-top:16px;">
<div class="preview-stack">
<div class="info-strip">
<strong>Promotion preview</strong><br>
${preview.promotion_preview.summary.total_students} students scanned, ${preview.promotion_preview.summary.hold} hold rows.
</div>
<div class="table-scroll">
<table>
<thead>
<tr>
<th>Student</th>
<th>Current</th>
<th>Action</th>
<th>Target</th>
<th>Warnings</th>
</tr>
</thead>
<tbody>${promotionRows || '<tr><td colspan="5">No students found.</td></tr>'}</tbody>
</table>
</div>
</div>
<div class="preview-stack">
<div class="info-strip">
<strong>Parent balance preview</strong><br>
${preview.parent_balances.summary.parents_with_non_zero_balance} parents with balances, net ${formatMoney(preview.parent_balances.summary.net_balance_to_transfer)}.
</div>
<div class="table-scroll">
<table>
<thead>
<tr>
<th>Parent</th>
<th>Students</th>
<th>Old due</th>
<th>Credit</th>
<th>Transfer</th>
</tr>
</thead>
<tbody>${balanceRows || '<tr><td colspan="5">No balances found.</td></tr>'}</tbody>
</table>
</div>
</div>
</div>
`;
}
function renderCloseWorkflow() {
if (!state.activeYear) {
elements.closeWorkflow.innerHTML = `
<div class="section-header">
<div>
<h2>Close Active Year</h2>
<div class="section-copy">An active school year is required before closure tooling becomes available.</div>
</div>
</div>
<div class="empty-state">There is no active school year right now.</div>
`;
return;
}
const defaults = state.closeDraft || Object.assign({
confirmation: '',
transfer_unpaid_balances: true,
}, defaultDraft());
elements.closeWorkflow.innerHTML = `
<div class="section-header">
<div>
<h2>Close ${escapeHtml(state.activeYear.name)}</h2>
<div class="section-copy">
Validate, preview, and close the active year using the same transaction-backed API endpoints exposed under
<span class="mono">/api/v1/school-years</span>.
</div>
</div>
<span class="status-pill">${escapeHtml(statusLabel(state.activeYear.status))}</span>
</div>
<form id="close-form" data-school-year-id="${state.activeYear.id}">
<div class="field-grid">
<label>
New year name
<input type="text" name="new_name" value="${escapeHtml(defaults.name)}" required>
</label>
<label>
New year start date
<input type="date" name="new_start_date" value="${escapeHtml(defaults.start_date)}" required>
</label>
</div>
<div class="field-grid">
<label>
New year end date
<input type="date" name="new_end_date" value="${escapeHtml(defaults.end_date)}" required>
</label>
<label>
Confirmation
<input class="mono" type="text" name="confirmation" value="${escapeHtml(defaults.confirmation || '')}" placeholder="CLOSE ${escapeHtml(state.activeYear.name)}" required>
</label>
</div>
<label class="checkbox">
<input type="checkbox" name="transfer_unpaid_balances" value="1" ${defaults.transfer_unpaid_balances === false ? '' : 'checked'}>
Transfer unpaid balances into the new year
</label>
<div class="form-actions">
<button type="button" class="button-secondary" data-close-action="validate">Validate</button>
<button type="button" class="button-ghost" data-close-action="preview">Preview</button>
<button type="submit" class="button-danger">Close year</button>
</div>
</form>
${renderValidation(state.closeValidation)}
${renderClosePreview(state.closePreview)}
`;
}
function renderAll() {
renderNotices();
renderStats();
renderYearList();
renderCreateForm();
renderSelectedSummary();
renderSelectedEdit();
renderInspection();
renderCloseWorkflow();
}
async function loadSchoolYears(preferredId) {
const response = await apiRequest(config.api.index);
state.schoolYears = response.data || [];
state.activeYear = state.schoolYears.find((year) => Boolean(year.is_current)) || null;
if (!state.schoolYears.length) {
state.selectedYearId = null;
state.selectedSummary = null;
state.selectedPromotionPreview = null;
state.selectedParentBalances = null;
state.closeValidation = null;
state.closePreview = null;
renderAll();
return;
}
const requestedId = Number(preferredId || state.selectedYearId || 0);
const hasRequested = state.schoolYears.some((year) => Number(year.id) === requestedId);
state.selectedYearId = hasRequested
? requestedId
: Number(state.activeYear?.id || state.schoolYears[0].id);
state.selectedPromotionPreview = null;
state.selectedParentBalances = null;
state.closeDraft = null;
syncSelectedYearInUrl();
renderAll();
await loadSelectedSummary();
}
async function loadSelectedSummary() {
const year = selectedYear();
if (!year) {
state.selectedSummary = null;
renderSelectedSummary();
renderSelectedEdit();
renderInspection();
return;
}
state.selectedSummary = null;
renderSelectedSummary();
const response = await apiRequest(templateUrl(config.api.summary, year.id));
state.selectedSummary = response.data || null;
renderSelectedSummary();
}
async function loadSelectedPreview(type) {
const year = selectedYear();
if (!year) {
return;
}
if (type === 'promotion') {
state.selectedPromotionPreview = null;
} else {
state.selectedParentBalances = null;
}
renderInspection();
const nextName = suggestNextName(year.name) || '';
const url = type === 'promotion'
? withQuery(templateUrl(config.api.promotion_preview, year.id), { to_school_year: nextName })
: withQuery(templateUrl(config.api.parent_balances, year.id), { to_school_year: nextName });
const response = await apiRequest(url);
if (type === 'promotion') {
state.selectedPromotionPreview = response.data || null;
} else {
state.selectedParentBalances = response.data || null;
}
renderInspection();
}
function createPayloadFromForm(form) {
const data = new FormData(form);
return {
name: String(data.get('name') || '').trim(),
start_date: String(data.get('start_date') || '').trim(),
end_date: String(data.get('end_date') || '').trim(),
};
}
function closePayloadFromForm(form, includeConfirmation) {
const data = new FormData(form);
const payload = {
new_school_year: {
name: String(data.get('new_name') || '').trim(),
start_date: String(data.get('new_start_date') || '').trim(),
end_date: String(data.get('new_end_date') || '').trim(),
},
transfer_unpaid_balances: data.get('transfer_unpaid_balances') === '1',
};
if (includeConfirmation) {
payload.confirmation = String(data.get('confirmation') || '').trim();
}
state.closeDraft = {
name: payload.new_school_year.name,
start_date: payload.new_school_year.start_date,
end_date: payload.new_school_year.end_date,
confirmation: String(data.get('confirmation') || '').trim(),
transfer_unpaid_balances: payload.transfer_unpaid_balances,
};
return payload;
}
async function handleCreate(form) {
setFormBusy(form, true);
try {
const response = await apiRequest(config.api.store, {
method: 'POST',
body: createPayloadFromForm(form),
});
pushNotice('success', `Draft school year ${response.data.name} created.`);
await loadSchoolYears(response.data.id);
} finally {
setFormBusy(form, false);
}
}
async function handleEdit(form) {
const yearId = Number(form.getAttribute('data-school-year-id') || 0);
setFormBusy(form, true);
try {
const response = await apiRequest(templateUrl(config.api.update, yearId), {
method: 'PATCH',
body: createPayloadFromForm(form),
});
pushNotice('success', `School year ${response.data.name} updated.`);
await loadSchoolYears(response.data.id);
} finally {
setFormBusy(form, false);
}
}
async function handleOpenYear(yearId, button) {
button.disabled = true;
try {
const response = await apiRequest(templateUrl(config.api.reopen, yearId), {
method: 'POST',
});
pushNotice('success', `School year ${response.data.name} is now active.`);
state.closeDraft = null;
state.closeValidation = null;
state.closePreview = null;
await loadSchoolYears(response.data.id);
} finally {
button.disabled = false;
}
}
async function handleCloseAction(action, button) {
const form = document.getElementById('close-form');
if (!form) {
return;
}
const yearId = Number(form.getAttribute('data-school-year-id') || 0);
button.disabled = true;
try {
if (action === 'validate') {
const response = await apiRequest(templateUrl(config.api.validate_close, yearId), {
method: 'POST',
body: closePayloadFromForm(form, false),
});
state.closeValidation = response.validation || null;
state.closePreview = null;
renderCloseWorkflow();
return;
}
const response = await apiRequest(templateUrl(config.api.preview_close, yearId), {
method: 'POST',
body: closePayloadFromForm(form, false),
});
state.closeValidation = response.data?.validation || null;
state.closePreview = response.data || null;
renderCloseWorkflow();
} finally {
button.disabled = false;
}
}
async function handleCloseSubmit(form) {
const yearId = Number(form.getAttribute('data-school-year-id') || 0);
setFormBusy(form, true);
try {
const response = await apiRequest(templateUrl(config.api.close, yearId), {
method: 'POST',
body: closePayloadFromForm(form, true),
});
const nextYear = response.data?.new_school_year;
pushNotice('success', `Closed ${response.data.closed_school_year.name} and opened ${nextYear.name}.`);
state.closeDraft = null;
state.closeValidation = null;
state.closePreview = null;
await loadSchoolYears(nextYear.id);
} finally {
setFormBusy(form, false);
}
}
document.addEventListener('click', async function (event) {
const selectButton = event.target.closest('[data-select-year]');
if (selectButton) {
state.selectedYearId = Number(selectButton.getAttribute('data-select-year'));
state.selectedPromotionPreview = null;
state.selectedParentBalances = null;
syncSelectedYearInUrl();
renderAll();
try {
await loadSelectedSummary();
} catch (error) {
pushNotice('error', error.message);
}
return;
}
const openButton = event.target.closest('[data-open-year]');
if (openButton) {
try {
await handleOpenYear(Number(openButton.getAttribute('data-open-year')), openButton);
} catch (error) {
pushNotice('error', error.message);
}
return;
}
const previewButton = event.target.closest('[data-load-preview]');
if (previewButton) {
previewButton.disabled = true;
try {
await loadSelectedPreview(previewButton.getAttribute('data-load-preview'));
} catch (error) {
pushNotice('error', error.message);
} finally {
previewButton.disabled = false;
}
return;
}
const closeActionButton = event.target.closest('[data-close-action]');
if (closeActionButton) {
try {
await handleCloseAction(closeActionButton.getAttribute('data-close-action'), closeActionButton);
} catch (error) {
pushNotice('error', error.message);
}
}
});
document.addEventListener('submit', async function (event) {
const form = event.target;
if (!(form instanceof HTMLFormElement)) {
return;
}
if (!['create-form', 'edit-form', 'close-form'].includes(form.id)) {
return;
}
event.preventDefault();
try {
if (form.id === 'create-form') {
await handleCreate(form);
return;
}
if (form.id === 'edit-form') {
await handleEdit(form);
return;
}
await handleCloseSubmit(form);
} catch (error) {
pushNotice('error', error.message);
}
});
async function init() {
elements.statsGrid.innerHTML = '<div class="loading">Loading school years…</div>';
elements.yearList.innerHTML = '<div class="loading">Loading school years…</div>';
elements.selectedSummary.innerHTML = '<div class="loading">Loading summary…</div>';
elements.selectedEdit.innerHTML = '<div class="loading">Loading editor…</div>';
elements.selectedInspection.innerHTML = '<div class="loading">Loading inspection tools…</div>';
elements.closeWorkflow.innerHTML = '<div class="loading">Loading closure workflow…</div>';
renderCreateForm();
try {
await loadSchoolYears(state.selectedYearId);
} catch (error) {
pushNotice('error', error.message);
renderAll();
}
}
init();
})();
</script>
</body>
</html>