1586 lines
62 KiB
PHP
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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
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>
|