This commit is contained in:
@@ -0,0 +1,336 @@
|
||||
<?= $this->extend('layout/management_layout') ?>
|
||||
<?= $this->section('content') ?>
|
||||
|
||||
<div class="container-fluid py-4">
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<div>
|
||||
<h2 class="mb-1">Enrollment Administration</h2>
|
||||
<div class="text-muted">Review school-year transition flags, exceptions, and placement follow-up.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (session()->getFlashdata('success')): ?>
|
||||
<div class="alert alert-success"><?= esc(session()->getFlashdata('success')) ?></div>
|
||||
<?php endif; ?>
|
||||
<?php if (session()->getFlashdata('error')): ?>
|
||||
<div class="alert alert-danger"><?= esc(session()->getFlashdata('error')) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="border rounded p-3 mb-3 bg-light">
|
||||
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2">
|
||||
<div>
|
||||
<div class="fw-semibold">Registration Email Launch</div>
|
||||
<?php if (!empty($launchState['approved'])): ?>
|
||||
<div class="text-success small">Approved at <?= esc(local_datetime($launchState['approved_at'], 'm-d-Y H:i')) ?></div>
|
||||
<?php elseif (!empty($launchState['missing'])): ?>
|
||||
<div class="text-danger small">Missing: <?= esc(implode(', ', $launchState['missing'])) ?></div>
|
||||
<?php else: ?>
|
||||
<div class="text-muted small">Configuration is ready for launch approval.</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2 justify-content-end">
|
||||
<?php if (!empty($previewParentId)): ?>
|
||||
<a class="btn btn-outline-secondary" target="_blank" href="<?= site_url('administrator/enrollment-admin/email-preview?school_year=' . rawurlencode((string) $schoolYear) . '&parent_id=' . (int) $previewParentId) ?>">Preview Email</a>
|
||||
<?php endif; ?>
|
||||
<form method="post" action="<?= site_url('administrator/enrollment-admin/approve-launch') ?>">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="school_year" value="<?= esc($schoolYear ?? '') ?>">
|
||||
<button type="submit" class="btn btn-success" <?= !empty($launchState['missing']) ? 'disabled' : '' ?>>Approve Launch</button>
|
||||
</form>
|
||||
<form method="post" action="<?= site_url('administrator/enrollment-admin/send-registration-emails') ?>" onsubmit="return confirm('Send real registration emails to parents for <?= esc((string) ($schoolYear ?? ''), 'js') ?>?');">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="school_year" value="<?= esc($schoolYear ?? '') ?>">
|
||||
<div class="form-check form-check-inline mb-0">
|
||||
<input class="form-check-input" type="checkbox" id="force_resend" name="force_resend" value="1">
|
||||
<label class="form-check-label small" for="force_resend">Resend sent</label>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-danger" <?= empty($launchState['approved']) || !empty($launchState['missing']) || empty($emailExamples) ? 'disabled' : '' ?>>Send Real Emails</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 class="mb-3">Decision Email Examples</h4>
|
||||
<div class="table-responsive mb-4">
|
||||
<table class="table table-sm table-bordered align-middle" data-no-mgmt-sticky>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Parent</th>
|
||||
<th>Recipient</th>
|
||||
<th>Students</th>
|
||||
<th>Subject</th>
|
||||
<th>Delivery</th>
|
||||
<th>Example</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (!empty($emailExamples)): ?>
|
||||
<?php foreach ($emailExamples as $example): ?>
|
||||
<?php
|
||||
$deliveryStatus = (string) ($example['delivery_status'] ?? 'not sent');
|
||||
$badgeClass = match ($deliveryStatus) {
|
||||
'sent' => 'bg-success',
|
||||
'failed' => 'bg-danger',
|
||||
'generated' => 'bg-info text-dark',
|
||||
default => 'bg-secondary',
|
||||
};
|
||||
?>
|
||||
<tr>
|
||||
<td><?= esc($example['parent_name'] ?? '') ?></td>
|
||||
<td><?= esc(implode(', ', $example['recipients'] ?? [])) ?></td>
|
||||
<td><?= esc(implode(', ', $example['student_names'] ?? [])) ?></td>
|
||||
<td><?= esc($example['subject'] ?? '') ?></td>
|
||||
<td>
|
||||
<span class="badge <?= esc($badgeClass) ?>"><?= esc($deliveryStatus) ?></span>
|
||||
<?php if (!empty($example['sent_at'])): ?>
|
||||
<div class="small text-muted"><?= esc(local_datetime($example['sent_at'], 'm-d-Y H:i')) ?></div>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($example['failure_reason'])): ?>
|
||||
<div class="small text-danger"><?= esc($example['failure_reason']) ?></div>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<a class="btn btn-sm btn-outline-secondary" target="_blank" href="<?= site_url('administrator/enrollment-admin/email-preview?school_year=' . rawurlencode((string) $schoolYear) . '&parent_id=' . (int) ($example['parent_user_id'] ?? 0)) ?>">View Example</a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php else: ?>
|
||||
<tr>
|
||||
<td colspan="6" class="text-center text-muted">No parent decision email examples found for this school year.</td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<form method="get" action="<?= site_url('administrator/enrollment-admin') ?>" class="row g-2 align-items-end mb-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label" for="school_year">School Year</label>
|
||||
<select class="form-select" id="school_year" name="school_year">
|
||||
<?php foreach (($schoolYears ?? []) as $year): ?>
|
||||
<?php $name = (string) ($year['name'] ?? ''); ?>
|
||||
<option value="<?= esc($name) ?>" <?= $name === ($schoolYear ?? '') ? 'selected' : '' ?>><?= esc($name) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label" for="status">Status</label>
|
||||
<select class="form-select" id="status" name="status">
|
||||
<?php foreach (['open' => 'Open', 'resolved' => 'Resolved'] as $value => $label): ?>
|
||||
<option value="<?= esc($value) ?>" <?= $value === ($status ?? '') ? 'selected' : '' ?>><?= esc($label) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label" for="flag_type">Flag Type</label>
|
||||
<select class="form-select" id="flag_type" name="flag_type">
|
||||
<option value="">All flag types</option>
|
||||
<?php foreach (($flagTypes ?? []) as $type): ?>
|
||||
<option value="<?= esc($type) ?>" <?= $type === ($flagType ?? '') ? 'selected' : '' ?>><?= esc($type) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label" for="assigned_to">Assigned To</label>
|
||||
<select class="form-select" id="assigned_to" name="assigned_to">
|
||||
<option value="">Anyone</option>
|
||||
<?php foreach (($admins ?? []) as $admin): ?>
|
||||
<?php
|
||||
$adminId = (int) ($admin['id'] ?? 0);
|
||||
$adminName = trim((string) ($admin['firstname'] ?? '') . ' ' . (string) ($admin['lastname'] ?? '')) ?: 'User #' . $adminId;
|
||||
?>
|
||||
<option value="<?= $adminId ?>" <?= (string) $adminId === (string) ($assignedTo ?? '') ? 'selected' : '' ?>><?= esc($adminName) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button type="submit" class="btn btn-primary w-100">Filter</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<h4 class="mb-3">Enrollment Follow-up</h4>
|
||||
<div class="table-responsive mb-4">
|
||||
<table class="table table-bordered table-striped align-middle" data-no-mgmt-sticky>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Student</th>
|
||||
<th>School ID</th>
|
||||
<th>Enrollment Status</th>
|
||||
<th>Decision</th>
|
||||
<th>Placement</th>
|
||||
<th>Class</th>
|
||||
<th>Exception / Reason</th>
|
||||
<th>Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (!empty($enrollmentFollowups)): ?>
|
||||
<?php foreach ($enrollmentFollowups as $row): ?>
|
||||
<?php
|
||||
$statusValue = (string) ($row['enrollment_status'] ?? '');
|
||||
$placementValue = (string) ($row['placement_status'] ?? '');
|
||||
$decisionValue = (string) ($row['deliberation_decision'] ?? '');
|
||||
$statusClass = match (strtolower($statusValue)) {
|
||||
'review & decision' => 'bg-warning text-dark',
|
||||
'waitlist' => 'bg-info text-dark',
|
||||
'denied' => 'bg-danger',
|
||||
default => 'bg-secondary',
|
||||
};
|
||||
?>
|
||||
<tr>
|
||||
<td><?= esc($row['student_name'] ?? '') ?></td>
|
||||
<td><?= esc($row['school_id'] ?? '') ?></td>
|
||||
<td><span class="badge <?= esc($statusClass) ?>"><?= esc($statusValue) ?></span></td>
|
||||
<td><?= esc($decisionValue !== '' ? $decisionValue : 'Pending') ?></td>
|
||||
<td><?= esc($placementValue !== '' ? $placementValue : 'Pending') ?></td>
|
||||
<td><?= esc($row['class_section_name'] ?? '') ?></td>
|
||||
<td>
|
||||
<?php if (!empty($row['exception_required'])): ?>
|
||||
<span class="badge bg-warning text-dark">Exception</span>
|
||||
<?php endif; ?>
|
||||
<div class="small"><?= esc($row['exception_reason'] ?? '') ?></div>
|
||||
</td>
|
||||
<td><?= esc(!empty($row['updated_at']) ? local_datetime($row['updated_at'], 'm-d-Y H:i') : '') ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php else: ?>
|
||||
<tr>
|
||||
<td colspan="8" class="text-center text-muted">No enrollment records need follow-up for this school year.</td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h4 class="mb-3">Enrollment Flags</h4>
|
||||
<div class="table-responsive mb-4">
|
||||
<table class="table table-bordered table-striped align-middle" data-no-mgmt-sticky>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Student</th>
|
||||
<th>School ID</th>
|
||||
<th>Flag Type</th>
|
||||
<th>Priority</th>
|
||||
<th>Details</th>
|
||||
<th>Assigned</th>
|
||||
<th>Created</th>
|
||||
<th style="min-width: 280px;">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (!empty($flags)): ?>
|
||||
<?php foreach ($flags as $flag): ?>
|
||||
<?php
|
||||
$flagId = (int) ($flag['id'] ?? 0);
|
||||
$flagTypeValue = (string) ($flag['flag_type'] ?? '');
|
||||
$details = is_array($flag['details'] ?? null) ? $flag['details'] : [];
|
||||
?>
|
||||
<tr>
|
||||
<td><?= esc($flag['student_name'] ?? '') ?></td>
|
||||
<td><?= esc($flag['school_id'] ?? '') ?></td>
|
||||
<td><span class="badge bg-secondary"><?= esc($flagTypeValue) ?></span></td>
|
||||
<td><?= esc($flag['priority'] ?? 'normal') ?></td>
|
||||
<td>
|
||||
<?php if ($details !== []): ?>
|
||||
<?php foreach ($details as $key => $value): ?>
|
||||
<div><span class="text-muted"><?= esc((string) $key) ?>:</span> <?= esc(is_scalar($value) ? (string) $value : json_encode($value)) ?></div>
|
||||
<?php endforeach; ?>
|
||||
<?php else: ?>
|
||||
<span class="text-muted">No details</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><?= esc($flag['assignee_name'] ?: '') ?></td>
|
||||
<td><?= esc(!empty($flag['created_at']) ? local_datetime($flag['created_at'], 'm-d-Y H:i') : '') ?></td>
|
||||
<td>
|
||||
<?php if (($flag['status'] ?? '') === 'open'): ?>
|
||||
<?php if ($flagTypeValue === 'CLASS_REASSIGNMENT_REQUIRED'): ?>
|
||||
<form method="post" action="<?= site_url('administrator/enrollment-admin/flags/' . $flagId . '/assign-class') ?>" class="mb-2">
|
||||
<?= csrf_field() ?>
|
||||
<select class="form-select form-select-sm mb-2" name="class_section_id" required>
|
||||
<option value="">Select class section</option>
|
||||
<?php foreach (($classSections ?? []) as $section): ?>
|
||||
<option value="<?= (int) ($section['class_section_id'] ?? 0) ?>"><?= esc($section['class_section_name'] ?? '') ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<input class="form-control form-control-sm mb-2" name="resolution_notes" placeholder="Reason / notes" required>
|
||||
<button class="btn btn-sm btn-primary" type="submit">Assign Class</button>
|
||||
</form>
|
||||
<?php elseif ($flagTypeValue === 'PENDING_MAKE_UP_EXAM_PROMOTION'): ?>
|
||||
<form method="post" action="<?= site_url('administrator/enrollment-admin/flags/' . $flagId . '/makeup-promotion') ?>" class="mb-2">
|
||||
<?= csrf_field() ?>
|
||||
<select class="form-select form-select-sm mb-2" name="exam_result" required>
|
||||
<option value="">Exam result</option>
|
||||
<option value="passed">Passed</option>
|
||||
<option value="failed">Failed</option>
|
||||
</select>
|
||||
<select class="form-select form-select-sm mb-2" name="class_section_id">
|
||||
<option value="">Promoted class section if passed</option>
|
||||
<?php foreach (($classSections ?? []) as $section): ?>
|
||||
<option value="<?= (int) ($section['class_section_id'] ?? 0) ?>"><?= esc($section['class_section_name'] ?? '') ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<input class="form-control form-control-sm mb-2" name="resolution_notes" placeholder="Resolution notes" required>
|
||||
<button class="btn btn-sm btn-primary" type="submit">Resolve Exam</button>
|
||||
</form>
|
||||
<?php elseif (in_array($flagTypeValue, ['AGE_EXCEPTION_REQUIRED', 'LATE_REGISTRATION_EXCEPTION', 'FINANCIAL_REVIEW_REQUIRED', 'CLASS_CAPACITY_EXCEPTION_REQUIRED'], true)): ?>
|
||||
<form method="post" action="<?= site_url('administrator/enrollment-admin/flags/' . $flagId . '/approve-exception') ?>" class="mb-2">
|
||||
<?= csrf_field() ?>
|
||||
<input class="form-control form-control-sm mb-2" name="reason" placeholder="Approval reason" required>
|
||||
<button class="btn btn-sm btn-warning" type="submit">Approve Exception</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" action="<?= site_url('administrator/enrollment-admin/flags/' . $flagId . '/resolve') ?>">
|
||||
<?= csrf_field() ?>
|
||||
<input class="form-control form-control-sm mb-2" name="resolution_notes" placeholder="Resolution notes" required>
|
||||
<button class="btn btn-sm btn-outline-secondary" type="submit">Resolve Manually</button>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<div class="text-muted small"><?= esc($flag['resolution_notes'] ?? '') ?></div>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php else: ?>
|
||||
<tr>
|
||||
<td colspan="8" class="text-center text-muted">No formal enrollment flags found. Check Enrollment Follow-up above for status-based items.</td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h4 class="mb-3">Recent Enrollment Audit</h4>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-bordered align-middle" data-no-mgmt-sticky>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>When</th>
|
||||
<th>Student</th>
|
||||
<th>Action</th>
|
||||
<th>Administrator</th>
|
||||
<th>Reason</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (!empty($auditRows)): ?>
|
||||
<?php foreach ($auditRows as $row): ?>
|
||||
<tr>
|
||||
<td><?= esc(!empty($row['created_at']) ? local_datetime($row['created_at'], 'm-d-Y H:i') : '') ?></td>
|
||||
<td><?= esc($row['student_name'] ?? '') ?></td>
|
||||
<td><?= esc($row['action'] ?? '') ?></td>
|
||||
<td><?= esc($row['performed_by_name'] ?? '') ?></td>
|
||||
<td><?= esc($row['reason'] ?? '') ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php else: ?>
|
||||
<tr>
|
||||
<td colspan="5" class="text-center text-muted">No enrollment audit records found. Audit rows appear after actions are completed from this dashboard or through the transition service.</td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?= $this->endSection() ?>
|
||||
@@ -0,0 +1,30 @@
|
||||
<?= $this->extend('layout/management_layout') ?>
|
||||
<?= $this->section('content') ?>
|
||||
|
||||
<style>
|
||||
.enrollment-email-preview-frame .email-container {
|
||||
max-width: 1280px;
|
||||
width: min(1280px, 100%);
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="container-fluid py-4">
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<div>
|
||||
<h2 class="mb-1">Registration Email Preview</h2>
|
||||
<div class="text-muted"><?= esc($schoolYear ?? '') ?> · Parent #<?= esc((string) ($parentId ?? '')) ?></div>
|
||||
</div>
|
||||
<a class="btn btn-secondary" href="<?= site_url('administrator/enrollment-admin?school_year=' . rawurlencode((string) ($schoolYear ?? ''))) ?>">Back</a>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Subject</label>
|
||||
<input class="form-control" value="<?= esc($subject ?? '') ?>" readonly>
|
||||
</div>
|
||||
|
||||
<div class="border rounded bg-white p-3 enrollment-email-preview-frame">
|
||||
<?= $body ?? '' ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?= $this->endSection() ?>
|
||||
@@ -3,30 +3,44 @@
|
||||
<?= $this->section('styles') ?>
|
||||
<style>
|
||||
#classesTable {
|
||||
table-layout: fixed;
|
||||
table-layout: auto;
|
||||
}
|
||||
#classesTable th:nth-child(1),
|
||||
#classesTable td:nth-child(1) {
|
||||
width: 10rem;
|
||||
width: 1%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
#classesTable th:nth-child(2),
|
||||
#classesTable td:nth-child(2),
|
||||
#classesTable td:nth-child(2) {
|
||||
width: 1%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
#classesTable th:nth-child(3),
|
||||
#classesTable td:nth-child(3) {
|
||||
width: 6rem;
|
||||
}
|
||||
#classesTable th:nth-child(4),
|
||||
#classesTable td:nth-child(4) {
|
||||
width: 10rem;
|
||||
min-width: 34rem;
|
||||
}
|
||||
.distribution-cell {
|
||||
min-width: 28rem;
|
||||
min-width: 34rem;
|
||||
}
|
||||
.distribution-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
|
||||
gap: .5rem;
|
||||
}
|
||||
.distribution-class-roster {
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: .375rem;
|
||||
background: #f8f9fa;
|
||||
padding: .5rem;
|
||||
margin-bottom: .5rem;
|
||||
}
|
||||
.distribution-class-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: .5rem;
|
||||
margin-bottom: .35rem;
|
||||
}
|
||||
.distribution-section {
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: .375rem;
|
||||
@@ -53,14 +67,78 @@
|
||||
max-height: 8.5rem;
|
||||
overflow: auto;
|
||||
margin: 0;
|
||||
padding-left: 1.1rem;
|
||||
padding-left: 0;
|
||||
list-style: none;
|
||||
counter-reset: distribution-student;
|
||||
font-size: .8125rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.distribution-students li {
|
||||
counter-increment: distribution-student;
|
||||
}
|
||||
.distribution-class-roster .distribution-students {
|
||||
max-height: 14rem;
|
||||
}
|
||||
.distribution-students-wrap {
|
||||
min-width: 0;
|
||||
}
|
||||
.distribution-students-header {
|
||||
display: grid;
|
||||
grid-template-columns: 2.25rem minmax(12rem, 1fr) 3.5rem 5rem minmax(8rem, 10rem) minmax(12rem, 16rem);
|
||||
gap: .5rem;
|
||||
padding: 0 .25rem .2rem 0;
|
||||
font-size: .72rem;
|
||||
font-weight: 700;
|
||||
color: #6c757d;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.distribution-students-header span:nth-child(3) {
|
||||
text-align: end;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.distribution-student-row {
|
||||
display: grid;
|
||||
grid-template-columns: 2.25rem minmax(12rem, 1fr) 3.5rem 5rem minmax(8rem, 10rem) minmax(12rem, 16rem);
|
||||
gap: .5rem;
|
||||
align-items: center;
|
||||
margin-bottom: .25rem;
|
||||
}
|
||||
.student-row-number {
|
||||
color: #6c757d;
|
||||
text-align: end;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.student-row-number::before {
|
||||
content: counter(distribution-student) ". ";
|
||||
}
|
||||
.student-age-cell {
|
||||
text-align: end;
|
||||
color: #495057;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.student-gender-cell {
|
||||
color: #495057;
|
||||
}
|
||||
.student-last-year-cell {
|
||||
color: #495057;
|
||||
min-width: 0;
|
||||
}
|
||||
.student-assignment-cell {
|
||||
min-width: 0;
|
||||
}
|
||||
.student-assignment-cell .form-select {
|
||||
display: inline-block;
|
||||
width: fit-content;
|
||||
min-width: 5rem;
|
||||
max-width: 100%;
|
||||
}
|
||||
.distribution-empty {
|
||||
color: #6c757d;
|
||||
font-size: .875rem;
|
||||
}
|
||||
.distribution-search-summary {
|
||||
min-height: 1.25rem;
|
||||
}
|
||||
</style>
|
||||
<?= $this->endSection() ?>
|
||||
|
||||
@@ -104,7 +182,7 @@
|
||||
$name = trim((string)($c['class_section_name'] ?? ''));
|
||||
$isBase = ($name !== '' && strpos($name, '-') === false);
|
||||
$lowerName = strtolower($name);
|
||||
$isStandard = ($lowerName === 'kg' || $lowerName === 'youth' || (ctype_digit($lowerName) && (int)$lowerName >= 1 && (int)$lowerName <= 9));
|
||||
$isStandard = ($lowerName === 'kg' || $lowerName === 'youth' || (ctype_digit($lowerName) && (int)$lowerName >= 1 && (int)$lowerName <= 10));
|
||||
if (!$isBase || $isStandard) continue;
|
||||
?>
|
||||
<option value="<?= (int)($c['class_id'] ?? 0) ?>"><?= esc($name) ?></option>
|
||||
@@ -119,6 +197,15 @@
|
||||
<div id="includedClasses" class="d-flex flex-wrap gap-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 align-items-end mb-3">
|
||||
<div class="col-md-4">
|
||||
<label for="studentSearch" class="form-label">Search Students</label>
|
||||
<input type="search" id="studentSearch" class="form-control" placeholder="Search by student name..." autocomplete="off" />
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<div id="studentSearchSummary" class="small text-muted distribution-search-summary"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-striped align-middle no-mgmt-sticky" id="classesTable">
|
||||
@@ -126,8 +213,6 @@
|
||||
<tr id="tblHeader">
|
||||
<th>Class</th>
|
||||
<th>Total</th>
|
||||
<th>Sections</th>
|
||||
<th>Actions</th>
|
||||
<th>Distribution</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -151,6 +236,15 @@
|
||||
const selectedYear = '<?= esc($selectedYear ?? '') ?>';
|
||||
const totalsBaseUrl = '<?= site_url('administrator/sections/promotion-totals') ?>';
|
||||
const distUrl = '<?= site_url('administrator/sections/auto-distribute') ?>';
|
||||
const updateDraftUrl = '<?= site_url('administrator/sections/distribution-draft/update') ?>';
|
||||
const updateCandidateUrl = '<?= site_url('administrator/sections/distribution-candidate/update') ?>';
|
||||
const classSections = <?= json_encode(array_values(array_map(static function ($c) {
|
||||
return [
|
||||
'class_id' => (int)($c['class_id'] ?? 0),
|
||||
'class_section_id' => (int)($c['class_section_id'] ?? 0),
|
||||
'class_section_name' => (string)($c['class_section_name'] ?? ''),
|
||||
];
|
||||
}, $classes ?? [])), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
|
||||
|
||||
const tblBody = document.getElementById('tblBody');
|
||||
const sectionCountInput = document.getElementById('sectionCount');
|
||||
@@ -161,9 +255,13 @@
|
||||
const additionalClassSelect = document.getElementById('additionalClassSelect');
|
||||
const addClassBtn = document.getElementById('addClassBtn');
|
||||
const includedClassesEl = document.getElementById('includedClasses');
|
||||
const studentSearchInput = document.getElementById('studentSearch');
|
||||
const studentSearchSummary = document.getElementById('studentSearchSummary');
|
||||
|
||||
let rowIndexByClassId = {}; // mapping to locate rows
|
||||
let includedClassIds = [];
|
||||
const baseClasses = buildBaseClassOptions();
|
||||
const sectionsByClassId = buildSectionsByClassId();
|
||||
|
||||
function totalsUrl() {
|
||||
const params = new URLSearchParams();
|
||||
@@ -172,9 +270,111 @@
|
||||
return totalsBaseUrl + '?' + params.toString();
|
||||
}
|
||||
|
||||
function selectedSectionCount() {
|
||||
const count = parseInt(sectionCountInput.value || '0', 10);
|
||||
return count > 0 ? count : '';
|
||||
function buildBaseClassOptions() {
|
||||
const seen = {};
|
||||
return classSections
|
||||
.filter(row => row.class_id > 0 && row.class_section_name && row.class_section_name.indexOf('-') < 0)
|
||||
.filter(function(row){
|
||||
if (seen[row.class_id]) return false;
|
||||
seen[row.class_id] = true;
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => String(a.class_section_name).localeCompare(String(b.class_section_name), undefined, { numeric: true }));
|
||||
}
|
||||
|
||||
function buildSectionsByClassId() {
|
||||
const out = {};
|
||||
classSections.forEach(function(row){
|
||||
if (!row.class_id || !row.class_section_id || !row.class_section_name || row.class_section_name.indexOf('-') < 0) return;
|
||||
if (!out[row.class_id]) out[row.class_id] = [];
|
||||
out[row.class_id].push({
|
||||
id: row.class_section_id,
|
||||
name: row.class_section_name
|
||||
});
|
||||
});
|
||||
Object.keys(out).forEach(function(classId){
|
||||
out[classId].sort((a, b) => String(a.name).localeCompare(String(b.name), undefined, { numeric: true }));
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function classNameById(classId) {
|
||||
const row = baseClasses.find(c => c.class_id === parseInt(classId || '0', 10));
|
||||
return row ? row.class_section_name : ('Class #' + classId);
|
||||
}
|
||||
|
||||
function isStandardBaseClassName(name) {
|
||||
const normalized = String(name || '').trim().toLowerCase();
|
||||
if (normalized === 'kg' || normalized === 'youth') return true;
|
||||
if (!/^\d+$/.test(normalized)) return false;
|
||||
const n = parseInt(normalized, 10);
|
||||
return n >= 1 && n <= 10;
|
||||
}
|
||||
|
||||
function ensureIncludedClassVisible(classId) {
|
||||
classId = parseInt(classId || '0', 10);
|
||||
if (!classId) return;
|
||||
const className = classNameById(classId);
|
||||
if (isStandardBaseClassName(className)) return;
|
||||
if (includedClassIds.indexOf(classId) < 0) {
|
||||
includedClassIds.push(classId);
|
||||
renderIncludedClasses();
|
||||
}
|
||||
}
|
||||
|
||||
function populateDestinationSelect(select, selectedSectionId, selectedClassId) {
|
||||
select.innerHTML = '';
|
||||
selectedClassId = parseInt(selectedClassId || '0', 10);
|
||||
let resolvedClassId = 0;
|
||||
|
||||
baseClasses.forEach(function(baseClass){
|
||||
const classOption = document.createElement('option');
|
||||
classOption.value = String(baseClass.class_section_id);
|
||||
classOption.dataset.classId = String(baseClass.class_id);
|
||||
classOption.textContent = baseClass.class_section_name;
|
||||
if (baseClass.class_section_id === parseInt(selectedSectionId || '0', 10) || (!parseInt(selectedSectionId || '0', 10) && selectedClassId === baseClass.class_id)) {
|
||||
classOption.selected = true;
|
||||
resolvedClassId = baseClass.class_id;
|
||||
}
|
||||
select.appendChild(classOption);
|
||||
|
||||
const sections = sectionsByClassId[String(baseClass.class_id)] || [];
|
||||
sections.forEach(function(section){
|
||||
const opt = document.createElement('option');
|
||||
opt.value = String(section.id);
|
||||
opt.dataset.classId = String(baseClass.class_id);
|
||||
opt.textContent = section.name;
|
||||
if (section.id === parseInt(selectedSectionId || '0', 10)) {
|
||||
opt.selected = true;
|
||||
resolvedClassId = baseClass.class_id;
|
||||
}
|
||||
select.appendChild(opt);
|
||||
});
|
||||
});
|
||||
|
||||
const selectedOption = select.options[select.selectedIndex] || null;
|
||||
if (!resolvedClassId && selectedOption) {
|
||||
resolvedClassId = parseInt(selectedOption.dataset.classId || '0', 10);
|
||||
}
|
||||
|
||||
return {
|
||||
classId: resolvedClassId,
|
||||
sectionId: selectedOption ? parseInt(selectedOption.value || '0', 10) : 0
|
||||
};
|
||||
}
|
||||
|
||||
function selectedDestination(select) {
|
||||
const selectedOption = select && select.options ? select.options[select.selectedIndex] : null;
|
||||
return {
|
||||
classId: selectedOption ? parseInt(selectedOption.dataset.classId || '0', 10) : 0,
|
||||
sectionId: selectedOption ? parseInt(selectedOption.value || '0', 10) : 0
|
||||
};
|
||||
}
|
||||
|
||||
function assignmentLabel(assignment) {
|
||||
if (assignment && assignment.class_section_name) return assignment.class_section_name;
|
||||
const classId = parseInt((assignment && assignment.class_id) || '0', 10);
|
||||
return classId > 0 ? classNameById(classId) : '-';
|
||||
}
|
||||
|
||||
function buildInitialTable(rows) {
|
||||
@@ -196,25 +396,16 @@
|
||||
tdTotal.textContent = r.total;
|
||||
tr.appendChild(tdTotal);
|
||||
|
||||
const tdNeed = document.createElement('td');
|
||||
tdNeed.className = 'text-end need-cell';
|
||||
tdNeed.textContent = selectedSectionCount();
|
||||
tr.appendChild(tdNeed);
|
||||
|
||||
const tdAct = document.createElement('td');
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'btn btn-sm btn-primary';
|
||||
btn.textContent = 'Generate Sections';
|
||||
btn.addEventListener('click', function(){ runDistribution(r.class_section_id, r.class_section_name); });
|
||||
tdAct.appendChild(btn);
|
||||
tr.appendChild(tdAct);
|
||||
|
||||
const tdResults = document.createElement('td');
|
||||
tdResults.className = 'distribution-cell';
|
||||
const empty = document.createElement('span');
|
||||
empty.className = 'distribution-empty';
|
||||
empty.textContent = 'Not generated';
|
||||
tdResults.appendChild(empty);
|
||||
if (Array.isArray(r.students) && r.students.length) {
|
||||
tdResults.appendChild(renderClassRoster(tr, r.students));
|
||||
} else {
|
||||
const empty = document.createElement('span');
|
||||
empty.className = 'distribution-empty';
|
||||
empty.textContent = 'No students';
|
||||
tdResults.appendChild(empty);
|
||||
}
|
||||
tr.appendChild(tdResults);
|
||||
|
||||
tblBody.appendChild(tr);
|
||||
@@ -223,16 +414,10 @@
|
||||
|
||||
rows.forEach(function(r){
|
||||
if (Array.isArray(r.sections) && r.sections.length) {
|
||||
renderSectionsForRow(r.class_section_id, r.sections);
|
||||
renderSectionsForRow(r.class_section_id, r.sections, r.students || []);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateNeeds() {
|
||||
document.querySelectorAll('#tblBody tr').forEach(function(tr){
|
||||
const needCell = tr.querySelector('.need-cell');
|
||||
if (needCell) needCell.textContent = selectedSectionCount();
|
||||
});
|
||||
applyStudentSearch();
|
||||
}
|
||||
|
||||
function runDistribution(baseSectionId, baseName) {
|
||||
@@ -273,12 +458,12 @@
|
||||
|
||||
msgEl.textContent = res && res.message ? res.message : 'Completed.';
|
||||
|
||||
renderSectionsForRow(baseSectionId, res.sections);
|
||||
renderSectionsForRow(baseSectionId, res.sections, []);
|
||||
})
|
||||
.catch(() => { msgEl.textContent = 'Failed to distribute. Please try again.'; });
|
||||
}
|
||||
|
||||
function renderSectionsForRow(baseSectionId, sections) {
|
||||
function renderSectionsForRow(baseSectionId, sections, unassignedAssignments) {
|
||||
const tr = Array.from(document.querySelectorAll('#tblBody tr')).find(function(_tr){
|
||||
return String(_tr.dataset.classSectionId || '') === String(baseSectionId || '');
|
||||
});
|
||||
@@ -288,11 +473,54 @@
|
||||
if (!td) return;
|
||||
td.innerHTML = '';
|
||||
|
||||
const unassigned = Array.isArray(unassignedAssignments) ? unassignedAssignments : (tr._unassignedAssignments || []);
|
||||
tr._unassignedAssignments = unassigned;
|
||||
const allSections = Array.isArray(sections) ? sections.slice() : [];
|
||||
const baseSections = allSections.filter(function(section){
|
||||
const name = String(section.class_section_name || '');
|
||||
return name === String(tr.dataset.className || '') || name.indexOf('-') < 0;
|
||||
});
|
||||
const visibleSections = allSections.filter(function(section){
|
||||
return baseSections.indexOf(section) < 0;
|
||||
});
|
||||
const baseAssignments = assignmentsFromSections(baseSections);
|
||||
const rosterAssignments = unassigned.concat(baseAssignments);
|
||||
if (rosterAssignments.length) {
|
||||
td.appendChild(renderClassRoster(tr, rosterAssignments));
|
||||
}
|
||||
|
||||
const grid = document.createElement('div');
|
||||
grid.className = 'distribution-grid';
|
||||
const allAssignments = assignmentsFromSections(visibleSections);
|
||||
|
||||
sections.forEach(function(s){
|
||||
const studentNames = Array.isArray(s.student_names) ? s.student_names : [];
|
||||
if (!visibleSections.length && !rosterAssignments.length) {
|
||||
td.appendChild(renderClassRoster(tr, allAssignments));
|
||||
}
|
||||
|
||||
visibleSections.forEach(function(s){
|
||||
const studentAssignments = Array.isArray(s.student_assignments) && s.student_assignments.length
|
||||
? s.student_assignments.map(function(assignment){
|
||||
return {
|
||||
...assignment,
|
||||
class_id: assignment.class_id || s.class_id,
|
||||
class_section_id: assignment.class_section_id || s.class_section_id,
|
||||
class_section_name: assignment.class_section_name || s.class_section_name || ''
|
||||
};
|
||||
})
|
||||
: (Array.isArray(s.student_names) ? s.student_names : []).map(function(studentName){
|
||||
return {
|
||||
draft_id: 0,
|
||||
student_id: 0,
|
||||
student_name: studentName,
|
||||
age_at_reference: null,
|
||||
gender: '',
|
||||
last_year_class_section: '',
|
||||
class_id: s.class_id,
|
||||
class_section_id: s.class_section_id,
|
||||
class_section_name: s.class_section_name || ''
|
||||
};
|
||||
});
|
||||
const studentNames = studentAssignments.map(a => a.student_name).filter(Boolean);
|
||||
const block = document.createElement('div');
|
||||
block.className = 'distribution-section';
|
||||
|
||||
@@ -327,14 +555,7 @@
|
||||
if (meta.children.length) block.appendChild(meta);
|
||||
|
||||
if (studentNames.length) {
|
||||
const list = document.createElement('ol');
|
||||
list.className = 'distribution-students';
|
||||
studentNames.forEach(function(studentName){
|
||||
const item = document.createElement('li');
|
||||
item.textContent = studentName;
|
||||
list.appendChild(item);
|
||||
});
|
||||
block.appendChild(list);
|
||||
block.appendChild(renderAssignmentList(studentAssignments, true));
|
||||
} else {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'distribution-empty';
|
||||
@@ -346,6 +567,344 @@
|
||||
});
|
||||
|
||||
td.appendChild(grid);
|
||||
renderAddSectionButton(td, tr, baseSectionId, visibleSections);
|
||||
applyStudentSearch();
|
||||
}
|
||||
|
||||
function assignmentsFromSections(sections) {
|
||||
const out = [];
|
||||
const seenDrafts = {};
|
||||
const seenFallback = {};
|
||||
|
||||
sections.forEach(function(section){
|
||||
const assignments = Array.isArray(section.student_assignments) && section.student_assignments.length
|
||||
? section.student_assignments
|
||||
: (Array.isArray(section.student_names) ? section.student_names : []).map(function(studentName, idx){
|
||||
return {
|
||||
draft_id: 0,
|
||||
student_id: 0,
|
||||
student_name: studentName,
|
||||
age_at_reference: null,
|
||||
gender: '',
|
||||
last_year_class_section: '',
|
||||
class_id: section.class_id,
|
||||
class_section_id: section.class_section_id,
|
||||
class_section_name: section.class_section_name,
|
||||
fallback_key: String(section.class_section_id || '') + ':' + idx + ':' + String(studentName || '')
|
||||
};
|
||||
});
|
||||
|
||||
assignments.forEach(function(assignment){
|
||||
const draftId = parseInt(assignment.draft_id || '0', 10);
|
||||
if (draftId > 0) {
|
||||
if (seenDrafts[draftId]) return;
|
||||
seenDrafts[draftId] = true;
|
||||
} else {
|
||||
const key = assignment.fallback_key || String(assignment.class_section_id || section.class_section_id || '') + ':' + String(assignment.student_name || '');
|
||||
if (seenFallback[key]) return;
|
||||
seenFallback[key] = true;
|
||||
}
|
||||
|
||||
out.push({
|
||||
draft_id: draftId,
|
||||
student_id: parseInt(assignment.student_id || '0', 10),
|
||||
student_name: assignment.student_name || 'Student',
|
||||
age_at_reference: assignment.age_at_reference ?? null,
|
||||
gender: assignment.gender || '',
|
||||
last_year_class_section: assignment.last_year_class_section || '',
|
||||
class_id: parseInt(assignment.class_id || section.class_id || '0', 10),
|
||||
class_section_id: parseInt(assignment.class_section_id || section.class_section_id || '0', 10),
|
||||
class_section_name: assignment.class_section_name || section.class_section_name || ''
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
out.sort((a, b) => String(a.student_name).localeCompare(String(b.student_name), undefined, { numeric: true }));
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderClassRoster(tr, assignments) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'distribution-class-roster';
|
||||
|
||||
const title = document.createElement('div');
|
||||
title.className = 'distribution-class-title';
|
||||
|
||||
const name = document.createElement('div');
|
||||
name.className = 'fw-semibold';
|
||||
name.textContent = 'Students in ' + (tr.dataset.className || 'class');
|
||||
title.appendChild(name);
|
||||
|
||||
const count = document.createElement('div');
|
||||
count.className = 'badge bg-secondary';
|
||||
count.textContent = assignments.length + ' students';
|
||||
title.appendChild(count);
|
||||
wrap.appendChild(title);
|
||||
|
||||
if (!assignments.length) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'distribution-empty';
|
||||
empty.textContent = 'No students assigned';
|
||||
wrap.appendChild(empty);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
wrap.appendChild(renderAssignmentList(assignments, true));
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function renderAssignmentList(assignments, editable) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'distribution-students-wrap';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'distribution-students-header';
|
||||
['', 'Student', 'Age', 'Gender', 'Last Year', 'Assignment'].forEach(function(label){
|
||||
const cell = document.createElement('span');
|
||||
cell.textContent = label;
|
||||
header.appendChild(cell);
|
||||
});
|
||||
wrap.appendChild(header);
|
||||
|
||||
const list = document.createElement('ol');
|
||||
list.className = 'distribution-students';
|
||||
|
||||
const sortedAssignments = Array.isArray(assignments)
|
||||
? assignments.slice().sort(function(a, b){
|
||||
return String(a.student_name || '').localeCompare(String(b.student_name || ''), undefined, { numeric: true });
|
||||
})
|
||||
: [];
|
||||
|
||||
sortedAssignments.forEach(function(assignment){
|
||||
const item = document.createElement('li');
|
||||
item.dataset.searchText = [
|
||||
assignment.student_name || '',
|
||||
assignment.age_at_reference ?? '',
|
||||
assignment.gender || '',
|
||||
assignment.last_year_class_section || '',
|
||||
assignmentLabel(assignment)
|
||||
].join(' ').toLowerCase();
|
||||
const row = document.createElement('div');
|
||||
row.className = 'distribution-student-row';
|
||||
|
||||
const rowNumber = document.createElement('span');
|
||||
rowNumber.className = 'student-row-number';
|
||||
row.appendChild(rowNumber);
|
||||
|
||||
const studentLabel = document.createElement('span');
|
||||
studentLabel.textContent = assignment.student_name || 'Student';
|
||||
row.appendChild(studentLabel);
|
||||
|
||||
const ageCell = document.createElement('span');
|
||||
ageCell.className = 'student-age-cell';
|
||||
ageCell.textContent = assignment.age_at_reference === null || assignment.age_at_reference === undefined || assignment.age_at_reference === ''
|
||||
? '-'
|
||||
: assignment.age_at_reference;
|
||||
row.appendChild(ageCell);
|
||||
|
||||
const genderCell = document.createElement('span');
|
||||
genderCell.className = 'student-gender-cell';
|
||||
genderCell.textContent = assignment.gender || '-';
|
||||
row.appendChild(genderCell);
|
||||
|
||||
const lastYearCell = document.createElement('span');
|
||||
lastYearCell.className = 'student-last-year-cell';
|
||||
lastYearCell.textContent = assignment.last_year_class_section || '-';
|
||||
row.appendChild(lastYearCell);
|
||||
|
||||
const assignmentCell = document.createElement('span');
|
||||
assignmentCell.className = 'student-assignment-cell';
|
||||
|
||||
const draftId = parseInt(assignment.draft_id || '0', 10);
|
||||
const studentId = parseInt(assignment.student_id || '0', 10);
|
||||
if (editable && (draftId > 0 || studentId > 0) && baseClasses.length) {
|
||||
const destinationSelect = document.createElement('select');
|
||||
destinationSelect.className = 'form-select form-select-sm';
|
||||
destinationSelect.setAttribute('aria-label', 'Change class and section for ' + (assignment.student_name || 'student'));
|
||||
populateDestinationSelect(
|
||||
destinationSelect,
|
||||
parseInt(assignment.class_section_id || '0', 10),
|
||||
parseInt(assignment.class_id || '0', 10)
|
||||
);
|
||||
destinationSelect.dataset.previousValue = destinationSelect.value;
|
||||
|
||||
destinationSelect.addEventListener('change', function(){
|
||||
const target = selectedDestination(destinationSelect);
|
||||
if (draftId > 0) {
|
||||
updateDraftAssignment(draftId, target.classId, target.sectionId, destinationSelect);
|
||||
} else {
|
||||
updateCandidateAssignment(studentId, target.classId, target.sectionId, destinationSelect);
|
||||
}
|
||||
});
|
||||
|
||||
assignmentCell.appendChild(destinationSelect);
|
||||
} else {
|
||||
assignmentCell.textContent = assignmentLabel(assignment);
|
||||
}
|
||||
row.appendChild(assignmentCell);
|
||||
|
||||
item.appendChild(row);
|
||||
list.appendChild(item);
|
||||
});
|
||||
|
||||
wrap.appendChild(list);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function applyStudentSearch() {
|
||||
const query = String((studentSearchInput && studentSearchInput.value) || '').trim().toLowerCase();
|
||||
let totalStudents = 0;
|
||||
let visibleStudents = 0;
|
||||
|
||||
document.querySelectorAll('.distribution-students li').forEach(function(item){
|
||||
totalStudents++;
|
||||
const matches = !query || String(item.dataset.searchText || '').indexOf(query) >= 0;
|
||||
item.hidden = !matches;
|
||||
if (matches) visibleStudents++;
|
||||
});
|
||||
|
||||
document.querySelectorAll('.distribution-section, .distribution-class-roster').forEach(function(block){
|
||||
const items = Array.from(block.querySelectorAll('.distribution-students li'));
|
||||
block.hidden = !!query && items.length > 0 && !items.some(item => !item.hidden);
|
||||
});
|
||||
|
||||
document.querySelectorAll('#tblBody tr').forEach(function(tr){
|
||||
const items = Array.from(tr.querySelectorAll('.distribution-students li'));
|
||||
tr.hidden = !!query && items.length > 0 && !items.some(item => !item.hidden);
|
||||
});
|
||||
|
||||
if (studentSearchSummary) {
|
||||
studentSearchSummary.textContent = query
|
||||
? visibleStudents + ' of ' + totalStudents + ' students shown'
|
||||
: '';
|
||||
}
|
||||
}
|
||||
|
||||
function renderAddSectionButton(container, tr, baseSectionId, visibleSections) {
|
||||
const baseClassId = parseInt(tr.dataset.classId || '0', 10);
|
||||
const allSections = sectionsByClassId[String(baseClassId)] || [];
|
||||
const visibleSectionIds = visibleSections.map(section => parseInt(section.class_section_id || '0', 10));
|
||||
const nextSection = allSections.find(section => visibleSectionIds.indexOf(section.id) < 0);
|
||||
if (!nextSection) return;
|
||||
|
||||
const addWrap = document.createElement('div');
|
||||
addWrap.className = 'mt-2';
|
||||
|
||||
const addBtn = document.createElement('button');
|
||||
addBtn.type = 'button';
|
||||
addBtn.className = 'btn btn-sm btn-outline-secondary';
|
||||
addBtn.textContent = 'Add Section';
|
||||
addBtn.addEventListener('click', function(){
|
||||
const nextVisibleSections = visibleSections.concat([{
|
||||
class_id: baseClassId,
|
||||
class_section_id: nextSection.id,
|
||||
class_section_name: nextSection.name,
|
||||
total: 0,
|
||||
student_names: [],
|
||||
student_assignments: []
|
||||
}]);
|
||||
renderSectionsForRow(baseSectionId, nextVisibleSections, tr._unassignedAssignments || []);
|
||||
});
|
||||
|
||||
addWrap.appendChild(addBtn);
|
||||
container.appendChild(addWrap);
|
||||
}
|
||||
|
||||
function updateDraftAssignment(draftId, classId, classSectionId, selectEl) {
|
||||
if (!draftId || !classSectionId) {
|
||||
msgEl.textContent = 'Select a valid target section.';
|
||||
return;
|
||||
}
|
||||
|
||||
const previousValue = selectEl ? selectEl.dataset.previousValue || selectEl.defaultValue || '' : '';
|
||||
if (selectEl) selectEl.disabled = true;
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('draft_id', String(draftId));
|
||||
fd.append('class_section_id', String(classSectionId));
|
||||
|
||||
const csrfNameEl = document.getElementById('csrfName');
|
||||
const csrfValueEl= document.getElementById('csrfValue');
|
||||
if (csrfNameEl && csrfValueEl) {
|
||||
fd.append(csrfNameEl.value, csrfValueEl.value);
|
||||
}
|
||||
|
||||
msgEl.textContent = 'Updating draft assignment...';
|
||||
fetch(updateDraftUrl, { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest' }, body: fd })
|
||||
.then(r => r.json())
|
||||
.then(res => {
|
||||
if (res && res.csrfTokenName && res.csrfHash) {
|
||||
if (csrfNameEl) csrfNameEl.value = res.csrfTokenName;
|
||||
if (csrfValueEl) csrfValueEl.value = res.csrfHash;
|
||||
}
|
||||
|
||||
if (!res || !res.ok) {
|
||||
if (selectEl && previousValue) selectEl.value = previousValue;
|
||||
msgEl.textContent = res && res.message ? res.message : 'Draft assignment could not be updated.';
|
||||
return;
|
||||
}
|
||||
|
||||
msgEl.textContent = res.message || 'Draft assignment updated.';
|
||||
if (selectEl) selectEl.dataset.previousValue = String(classSectionId);
|
||||
ensureIncludedClassVisible(classId);
|
||||
loadTotals();
|
||||
})
|
||||
.catch(() => {
|
||||
if (selectEl && previousValue) selectEl.value = previousValue;
|
||||
msgEl.textContent = 'Draft assignment could not be updated.';
|
||||
})
|
||||
.finally(() => {
|
||||
if (selectEl) selectEl.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
function updateCandidateAssignment(studentId, classId, classSectionId, selectEl) {
|
||||
if (!studentId || !classSectionId) {
|
||||
msgEl.textContent = 'Select a valid student and target assignment.';
|
||||
return;
|
||||
}
|
||||
|
||||
const previousValue = selectEl ? selectEl.dataset.previousValue || selectEl.defaultValue || '' : '';
|
||||
if (selectEl) selectEl.disabled = true;
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('student_id', String(studentId));
|
||||
fd.append('class_section_id', String(classSectionId));
|
||||
fd.append('school_year', selectedYear);
|
||||
|
||||
const csrfNameEl = document.getElementById('csrfName');
|
||||
const csrfValueEl= document.getElementById('csrfValue');
|
||||
if (csrfNameEl && csrfValueEl) {
|
||||
fd.append(csrfNameEl.value, csrfValueEl.value);
|
||||
}
|
||||
|
||||
msgEl.textContent = 'Saving candidate assignment...';
|
||||
fetch(updateCandidateUrl, { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest' }, body: fd })
|
||||
.then(r => r.json())
|
||||
.then(res => {
|
||||
if (res && res.csrfTokenName && res.csrfHash) {
|
||||
if (csrfNameEl) csrfNameEl.value = res.csrfTokenName;
|
||||
if (csrfValueEl) csrfValueEl.value = res.csrfHash;
|
||||
}
|
||||
|
||||
if (!res || !res.ok) {
|
||||
if (selectEl && previousValue) selectEl.value = previousValue;
|
||||
msgEl.textContent = res && res.message ? res.message : 'Candidate assignment could not be saved.';
|
||||
return;
|
||||
}
|
||||
|
||||
msgEl.textContent = res.message || 'Candidate assignment saved.';
|
||||
if (selectEl) selectEl.dataset.previousValue = String(classSectionId);
|
||||
ensureIncludedClassVisible(classId);
|
||||
loadTotals();
|
||||
})
|
||||
.catch(() => {
|
||||
if (selectEl && previousValue) selectEl.value = previousValue;
|
||||
msgEl.textContent = 'Candidate assignment could not be saved.';
|
||||
})
|
||||
.finally(() => {
|
||||
if (selectEl) selectEl.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
function loadTotals() {
|
||||
@@ -355,7 +914,6 @@
|
||||
.then(res => {
|
||||
if (!res || !res.ok) { msgEl.textContent = res && res.message ? res.message : 'Failed to load totals.'; return; }
|
||||
buildInitialTable(res.rows || []);
|
||||
updateNeeds();
|
||||
msgEl.textContent = '';
|
||||
})
|
||||
.catch(() => { msgEl.textContent = 'Failed to load totals.'; });
|
||||
@@ -402,6 +960,9 @@
|
||||
});
|
||||
|
||||
refreshBtn.addEventListener('click', function(){ loadTotals(); });
|
||||
if (studentSearchInput) {
|
||||
studentSearchInput.addEventListener('input', applyStudentSearch);
|
||||
}
|
||||
document.getElementById('generateAllBtn').addEventListener('click', async function(){
|
||||
const sectionCount = parseInt(sectionCountInput.value || '0', 10);
|
||||
const minStudents = parseInt(minInput.value || '0', 10);
|
||||
@@ -421,8 +982,6 @@
|
||||
}
|
||||
msgEl.textContent = 'All distributions completed.';
|
||||
});
|
||||
sectionCountInput.addEventListener('input', function(){ updateNeeds(); });
|
||||
|
||||
loadTotals();
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -64,6 +64,7 @@ $allergyOptions = $allergyOptions ?? [
|
||||
];
|
||||
|
||||
$gradeOptions = $gradeOptions ?? ['KG', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', 'Youth'];
|
||||
$selectedYear = trim((string)($selectedYear ?? ''));
|
||||
?>
|
||||
|
||||
<?= $this->extend('layout/management_layout') ?>
|
||||
@@ -279,8 +280,8 @@ $gradeOptions = $gradeOptions ?? ['KG', '1', '2', '3', '4', '5', '6', '7', '8',
|
||||
<!-- age (readonly) -->
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Age</label>
|
||||
<input type="number" name="age" class="form-control js-age" value="<?= (int)($student['age'] ?? 0) ?>" min="0" step="1" readonly>
|
||||
<div class="form-text">Auto-calculated from DOB.</div>
|
||||
<input type="number" name="age" class="form-control js-age" value="<?= esc((string)($student['age'] ?? '')) ?>" min="0" step="1" readonly>
|
||||
<div class="form-text">Auto-calculated from DOB as of Sep 1 of the school year.</div>
|
||||
</div>
|
||||
|
||||
<!-- registration_grade -->
|
||||
@@ -589,32 +590,43 @@ $gradeOptions = $gradeOptions ?? ['KG', '1', '2', '3', '4', '5', '6', '7', '8',
|
||||
// Bind all multi-selects present on the page (all modals rendered server-side)
|
||||
document.querySelectorAll('select.js-multi').forEach(bindSelect);
|
||||
|
||||
const fallbackSchoolYear = <?= json_encode($selectedYear, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
|
||||
|
||||
function ageAsOfSchoolYearStart(dobValue, schoolYearValue) {
|
||||
if (!dobValue) return '';
|
||||
const yearMatch = String(schoolYearValue || fallbackSchoolYear || '').match(/^(\d{4})/);
|
||||
if (!yearMatch) return '';
|
||||
|
||||
const birthParts = dobValue.split('-').map(Number);
|
||||
if (birthParts.length !== 3 || birthParts.some(Number.isNaN)) return '';
|
||||
|
||||
const dob = new Date(birthParts[0], birthParts[1] - 1, birthParts[2]);
|
||||
const cutoff = new Date(Number(yearMatch[1]), 8, 1);
|
||||
if (Number.isNaN(dob.getTime()) || dob > cutoff) return '';
|
||||
|
||||
let age = cutoff.getFullYear() - dob.getFullYear();
|
||||
const monthDelta = cutoff.getMonth() - dob.getMonth();
|
||||
if (monthDelta < 0 || (monthDelta === 0 && cutoff.getDate() < dob.getDate())) age--;
|
||||
return age >= 0 ? String(age) : '';
|
||||
}
|
||||
|
||||
// Modal lifecycle: wire age + (re)bind selects inside shown modal
|
||||
document.querySelectorAll('.modal').forEach((modal) => {
|
||||
modal.addEventListener('shown.bs.modal', () => {
|
||||
// Age from DOB
|
||||
const dob = modal.querySelector('.js-dob');
|
||||
const age = modal.querySelector('.js-age');
|
||||
const schoolYear = modal.querySelector('input[name="school_year"]');
|
||||
if (dob && age) {
|
||||
const update = () => {
|
||||
if (!dob.value) {
|
||||
age.value = '';
|
||||
return;
|
||||
}
|
||||
const d = new Date(dob.value + 'T00:00:00');
|
||||
if (isNaN(d.getTime())) {
|
||||
age.value = '';
|
||||
return;
|
||||
}
|
||||
const t = new Date();
|
||||
let a = t.getFullYear() - d.getFullYear();
|
||||
const m = t.getMonth() - d.getMonth();
|
||||
if (m < 0 || (m === 0 && t.getDate() < d.getDate())) a--;
|
||||
age.value = a >= 0 ? a : '';
|
||||
age.value = ageAsOfSchoolYearStart(dob.value, schoolYear ? schoolYear.value : '');
|
||||
};
|
||||
update();
|
||||
dob.addEventListener('change', update);
|
||||
dob.addEventListener('input', update);
|
||||
if (schoolYear) {
|
||||
schoolYear.addEventListener('change', update);
|
||||
schoolYear.addEventListener('input', update);
|
||||
}
|
||||
}
|
||||
|
||||
modal.querySelectorAll('select.js-multi').forEach(bindSelect);
|
||||
|
||||
Reference in New Issue
Block a user