@@ -14,11 +14,13 @@ class SchoolYearClosingController extends BaseController
|
||||
$targetId = $this->normalizeInt($this->request->getGet('target_school_year_id'));
|
||||
$preview = service('schoolYearClosing')->preview($id, $targetId);
|
||||
$promotionTable = $this->promotionTablePayload($preview['promotion']['rows'] ?? []);
|
||||
$carryForwardTable = $this->carryForwardTablePayload($preview['carry_forward'] ?? []);
|
||||
$latestBatch = service('schoolYearClosing')->latestBatch($id);
|
||||
|
||||
return view('school_years/closing_preview', [
|
||||
'preview' => $preview,
|
||||
'promotionTable' => $promotionTable,
|
||||
'carryForwardTable' => $carryForwardTable,
|
||||
'latestBatch' => $latestBatch,
|
||||
'missingCarryForwardInvoices' => $this->missingCarryForwardInvoiceCount($latestBatch),
|
||||
'schoolYears' => (new SchoolYearModel())->orderBy('name', 'DESC')->findAll(),
|
||||
@@ -118,13 +120,10 @@ class SchoolYearClosingController extends BaseController
|
||||
private function promotionTablePayload(array $rows): array
|
||||
{
|
||||
$allowedSorts = ['student', 'school_id', 'class', 'year_score', 'decision', 'source', 'queue', 'target', 'status'];
|
||||
$sort = (string) ($this->request->getGet('sort') ?? 'class');
|
||||
$sort = in_array($sort, $allowedSorts, true) ? $sort : 'class';
|
||||
$order = strtolower((string) ($this->request->getGet('order') ?? 'asc')) === 'desc' ? 'desc' : 'asc';
|
||||
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
|
||||
$perPage = (int) ($this->request->getGet('per_page') ?? 25);
|
||||
$sort = 'class';
|
||||
$order = 'asc';
|
||||
$perPage = 25;
|
||||
$allowedPerPage = [10, 25, 50, 100];
|
||||
$perPage = in_array($perPage, $allowedPerPage, true) ? $perPage : 25;
|
||||
|
||||
usort($rows, function (array $a, array $b) use ($sort, $order): int {
|
||||
$comparison = $this->comparePromotionRows($a, $b, $sort);
|
||||
@@ -142,21 +141,18 @@ class SchoolYearClosingController extends BaseController
|
||||
});
|
||||
|
||||
$total = count($rows);
|
||||
$pageCount = max(1, (int) ceil($total / $perPage));
|
||||
$page = min($page, $pageCount);
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
return [
|
||||
'rows' => array_slice($rows, $offset, $perPage),
|
||||
'rows' => $rows,
|
||||
'sort' => $sort,
|
||||
'order' => $order,
|
||||
'page' => $page,
|
||||
'page' => 1,
|
||||
'perPage' => $perPage,
|
||||
'total' => $total,
|
||||
'pageCount' => $pageCount,
|
||||
'pageCount' => 1,
|
||||
'allowedPerPage' => $allowedPerPage,
|
||||
'from' => $total === 0 ? 0 : $offset + 1,
|
||||
'to' => min($offset + $perPage, $total),
|
||||
'from' => $total === 0 ? 0 : 1,
|
||||
'to' => $total,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -198,6 +194,58 @@ class SchoolYearClosingController extends BaseController
|
||||
};
|
||||
}
|
||||
|
||||
private function carryForwardTablePayload(array $rows): array
|
||||
{
|
||||
$allowedSorts = ['family', 'parent', 'source_balance', 'credits', 'adjustments', 'net'];
|
||||
$sort = (string) ($this->request->getGet('cf_sort') ?? 'family');
|
||||
$sort = in_array($sort, $allowedSorts, true) ? $sort : 'family';
|
||||
$order = strtolower((string) ($this->request->getGet('cf_order') ?? 'asc')) === 'desc' ? 'desc' : 'asc';
|
||||
|
||||
usort($rows, function (array $a, array $b) use ($sort, $order): int {
|
||||
$comparison = $this->compareCarryForwardRows($a, $b, $sort);
|
||||
if ($comparison === 0 && $sort !== 'family') {
|
||||
$comparison = $this->compareCarryForwardRows($a, $b, 'family');
|
||||
}
|
||||
if ($comparison === 0) {
|
||||
$comparison = ((int) ($a['family_id'] ?? 0)) <=> ((int) ($b['family_id'] ?? 0));
|
||||
}
|
||||
|
||||
return $order === 'desc' ? -$comparison : $comparison;
|
||||
});
|
||||
|
||||
return [
|
||||
'rows' => $rows,
|
||||
'sort' => $sort,
|
||||
'order' => $order,
|
||||
];
|
||||
}
|
||||
|
||||
private function compareCarryForwardRows(array $a, array $b, string $sort): int
|
||||
{
|
||||
$numericSorts = ['source_balance', 'credits', 'adjustments', 'net'];
|
||||
$aValue = $this->carryForwardSortValue($a, $sort);
|
||||
$bValue = $this->carryForwardSortValue($b, $sort);
|
||||
|
||||
if (in_array($sort, $numericSorts, true)) {
|
||||
return (float) $aValue <=> (float) $bValue;
|
||||
}
|
||||
|
||||
return strnatcasecmp((string) $aValue, (string) $bValue);
|
||||
}
|
||||
|
||||
private function carryForwardSortValue(array $row, string $sort): mixed
|
||||
{
|
||||
return match ($sort) {
|
||||
'family' => (string) ($row['family'] ?? ''),
|
||||
'parent' => trim((string) ($row['parent'] ?? '') . ' ' . (string) ($row['parent_email'] ?? '')),
|
||||
'source_balance' => (float) ($row['source_balance'] ?? 0),
|
||||
'credits' => (float) ($row['credit_amount'] ?? 0),
|
||||
'adjustments' => (float) ($row['adjustment_amount'] ?? 0),
|
||||
'net' => (float) ($row['carry_forward_amount'] ?? 0),
|
||||
default => '',
|
||||
};
|
||||
}
|
||||
|
||||
private function userId(): ?int
|
||||
{
|
||||
$id = session('user_id') ?? session('id');
|
||||
|
||||
@@ -31,6 +31,7 @@ use App\Models\GradingLockModel;
|
||||
use App\Models\BelowSixtyDecisionModel;
|
||||
use App\Models\StudentDecisionModel;
|
||||
use App\Services\NavbarService;
|
||||
use App\Support\Enrollment\DeliberationDecision;
|
||||
|
||||
//use App\Models\ScoreModel;
|
||||
|
||||
@@ -3399,13 +3400,16 @@ public function allDecisions()
|
||||
's.firstname',
|
||||
's.lastname',
|
||||
's.gender',
|
||||
's.dob',
|
||||
'ss.class_section_id',
|
||||
'cs.class_section_name',
|
||||
'c.class_name',
|
||||
'LOWER(TRIM(ss.semester)) AS sem_key',
|
||||
'ss.semester_score',
|
||||
])
|
||||
->join('students s', 's.id = ss.student_id', 'inner')
|
||||
->join('classSection cs', 'cs.class_section_id = ss.class_section_id', 'left')
|
||||
->join('classes c', 'c.id = cs.class_id', 'left')
|
||||
->where('s.is_active', 1)
|
||||
->where('ss.school_year', $schoolYear)
|
||||
->whereIn('LOWER(TRIM(ss.semester))', ['fall', 'spring'])
|
||||
@@ -3432,7 +3436,9 @@ public function allDecisions()
|
||||
'firstname' => $sr['firstname'] ?? '',
|
||||
'lastname' => $sr['lastname'] ?? '',
|
||||
'gender' => $sr['gender'] ?? '',
|
||||
'dob' => $sr['dob'] ?? '',
|
||||
'class_section_id' => (int)($sr['class_section_id'] ?? 0),
|
||||
'class_name' => $sr['class_name'] ?? '',
|
||||
'class_section_name' => $sr['class_section_name'] ?? '',
|
||||
'fall_score' => null,
|
||||
'spring_score' => null,
|
||||
@@ -3513,18 +3519,30 @@ public function allDecisions()
|
||||
$notes = '';
|
||||
}
|
||||
|
||||
$currentClassSectionName = trim((string)($info['class_section_name'] ?? ''));
|
||||
if ($currentClassSectionName === '' && isset($savedMap[$sid])) {
|
||||
$currentClassSectionName = trim((string)($savedMap[$sid]['class_section_name'] ?? ''));
|
||||
}
|
||||
$currentClassName = trim((string)($info['class_name'] ?? ''));
|
||||
if ($currentClassName === '') {
|
||||
$currentClassName = $this->classOnlyLabel($currentClassSectionName);
|
||||
}
|
||||
|
||||
$rows[] = [
|
||||
'student_id' => $sid,
|
||||
'school_id' => $info['school_id'],
|
||||
'firstname' => $info['firstname'],
|
||||
'lastname' => $info['lastname'],
|
||||
'gender' => $info['gender'] ?? '',
|
||||
'dob' => $info['dob'] ?? '',
|
||||
'class_section_id' => (int)($info['class_section_id'] ?? 0),
|
||||
'class_section_name' => $info['class_section_name'],
|
||||
'class_name' => $currentClassName,
|
||||
'class_section_name' => $currentClassSectionName,
|
||||
'fall_score' => $fall,
|
||||
'spring_score' => $spring,
|
||||
'year_score' => $yearScore,
|
||||
'decision' => $decision,
|
||||
'next_year_placement' => $this->nextYearPlacementLabel($decision, $currentClassName, $currentClassSectionName, (string)($info['dob'] ?? ''), $schoolYear),
|
||||
'source' => $source,
|
||||
'notes' => $notes,
|
||||
'saved' => isset($savedMap[$sid]),
|
||||
@@ -3741,6 +3759,76 @@ public function generateAllDecisions()
|
||||
->with('status', "Decisions generated for {$savedCount} students.");
|
||||
}
|
||||
|
||||
private function nextYearPlacementLabel(
|
||||
?string $decision,
|
||||
string $currentClassName,
|
||||
string $currentClassSectionName,
|
||||
string $dob,
|
||||
string $schoolYear
|
||||
): string
|
||||
{
|
||||
$normalizedDecision = DeliberationDecision::normalize($decision);
|
||||
$classLabel = $this->classOnlyLabel($currentClassName !== '' ? $currentClassName : $currentClassSectionName);
|
||||
|
||||
if ($this->isKgClass($classLabel)) {
|
||||
$kgPlacement = $this->kgPlacementByComingSeptember($dob, $schoolYear);
|
||||
if ($kgPlacement !== '') {
|
||||
return $kgPlacement;
|
||||
}
|
||||
}
|
||||
|
||||
if ($normalizedDecision === DeliberationDecision::REPEAT_CLASS) {
|
||||
return $classLabel;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private function classOnlyLabel(string $className): string
|
||||
{
|
||||
return trim((string) preg_replace('/-.+$/', '', $className));
|
||||
}
|
||||
|
||||
private function isKgClass(string $className): bool
|
||||
{
|
||||
$value = strtoupper(trim($className));
|
||||
|
||||
return preg_match('/(^|[^A-Z0-9])KG([^A-Z0-9]|$)/', $value) === 1 || str_contains($value, 'KINDERGARTEN');
|
||||
}
|
||||
|
||||
private function kgPlacementByComingSeptember(string $dob, string $schoolYear): string
|
||||
{
|
||||
$dob = trim($dob);
|
||||
if ($dob === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
$birthDate = new \DateTimeImmutable($dob);
|
||||
$cutoff = $this->comingSeptemberFirstCutoff($schoolYear);
|
||||
} catch (\Throwable) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if ($birthDate > $cutoff) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $birthDate->diff($cutoff)->y >= 6 ? '1' : 'KG';
|
||||
}
|
||||
|
||||
private function comingSeptemberFirstCutoff(string $schoolYear): \DateTimeImmutable
|
||||
{
|
||||
if (preg_match('/^(\d{4})-\d{4}$/', $schoolYear, $matches) === 1) {
|
||||
return new \DateTimeImmutable(((int) $matches[1] + 1) . '-09-01');
|
||||
}
|
||||
|
||||
$today = new \DateTimeImmutable('today');
|
||||
$cutoff = new \DateTimeImmutable($today->format('Y') . '-09-01');
|
||||
|
||||
return $today <= $cutoff ? $cutoff : $cutoff->modify('+1 year');
|
||||
}
|
||||
|
||||
private function calculateTrophyThreshold(array $scores, float $percentile = 75.0): array
|
||||
{
|
||||
$scores = array_values(array_filter(
|
||||
|
||||
@@ -595,9 +595,10 @@ final class SchoolYearClosingService
|
||||
$builder = $this->db->table('student_class sc')
|
||||
->select('sc.student_id, sc.class_section_id, sc.created_at, sc.updated_at')
|
||||
->select('s.school_id, s.firstname, s.lastname')
|
||||
->select('cs.class_section_name')
|
||||
->select('cs.class_section_name, cs.class_id, c.class_name')
|
||||
->join('students s', 's.id = sc.student_id', 'inner')
|
||||
->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left')
|
||||
->join('classes c', 'c.id = cs.class_id', 'left')
|
||||
->where('sc.school_year', $schoolYear)
|
||||
->where('sc.class_section_id IS NOT NULL', null, false);
|
||||
|
||||
@@ -639,6 +640,8 @@ final class SchoolYearClosingService
|
||||
'school_id' => (string) ($row['school_id'] ?? ''),
|
||||
'student_name' => trim((string) ($row['firstname'] ?? '') . ' ' . (string) ($row['lastname'] ?? '')),
|
||||
'class_section_id' => (int) ($row['class_section_id'] ?? 0),
|
||||
'class_id' => (int) ($row['class_id'] ?? 0),
|
||||
'class_name' => (string) ($row['class_name'] ?? ''),
|
||||
'class_section_name' => (string) ($row['class_section_name'] ?? ''),
|
||||
'dob' => (string) ($row['dob'] ?? ''),
|
||||
'registration_grade' => (string) ($row['registration_grade'] ?? ''),
|
||||
@@ -703,6 +706,14 @@ final class SchoolYearClosingService
|
||||
$student['target_section'] = $queue['target_section'];
|
||||
}
|
||||
|
||||
if ($this->isKgStudent($student)) {
|
||||
$kgPlacement = $this->kgPlacementLabelByTargetYearStartCutoff((string) ($student['dob'] ?? ''), $targetSchoolYear);
|
||||
if ($kgPlacement !== '') {
|
||||
$student['target_section'] = '';
|
||||
$student['target_class'] = $kgPlacement;
|
||||
}
|
||||
}
|
||||
|
||||
$summary['total_students']++;
|
||||
if ($student['status'] === 'missing') {
|
||||
$summary['missing_decision']++;
|
||||
@@ -712,10 +723,22 @@ final class SchoolYearClosingService
|
||||
$summary['with_decision']++;
|
||||
if (($student['normalized_decision'] ?? null) === DeliberationDecision::PASSED) {
|
||||
$summary['pass']++;
|
||||
if ($student['target_class'] === '') {
|
||||
$student['target_class'] = $this->nextClassLabelForPromotionPreview(
|
||||
(string) ($student['class_name'] ?: $student['class_section_name']),
|
||||
$targetSchoolYear
|
||||
);
|
||||
}
|
||||
if ($queue === null && $student['auto_kg_pass'] !== true) {
|
||||
$summary['missing_queue']++;
|
||||
}
|
||||
} else {
|
||||
if (($student['normalized_decision'] ?? null) === DeliberationDecision::REPEAT_CLASS) {
|
||||
$student['target_section'] = '';
|
||||
$student['target_class'] = $student['target_class'] !== ''
|
||||
? $student['target_class']
|
||||
: $this->classOnlyLabel((string) ($student['class_name'] ?: $student['class_section_name']));
|
||||
}
|
||||
$summary['other_decision']++;
|
||||
}
|
||||
}
|
||||
@@ -740,9 +763,9 @@ final class SchoolYearClosingService
|
||||
|
||||
private function isKgStudent(array $student): bool
|
||||
{
|
||||
foreach (['class_section_name', 'registration_grade'] as $field) {
|
||||
foreach (['class_name', 'class_section_name'] as $field) {
|
||||
$value = strtoupper(trim((string) ($student[$field] ?? '')));
|
||||
if ($value === 'KG' || str_starts_with($value, 'KG-') || str_contains($value, 'KINDERGARTEN')) {
|
||||
if (preg_match('/(^|[^A-Z0-9])KG([^A-Z0-9]|$)/', $value) === 1 || str_contains($value, 'KINDERGARTEN')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -753,13 +776,13 @@ final class SchoolYearClosingService
|
||||
private function kgAgeStatusByTargetYearStartCutoff(string $dob, ?string $targetSchoolYear): string
|
||||
{
|
||||
$dob = trim($dob);
|
||||
if ($dob === '' || $targetSchoolYear === null || ! preg_match('/^(\d{4})-\d{4}$/', $targetSchoolYear, $matches)) {
|
||||
if ($dob === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
$birthDate = new \DateTimeImmutable($dob);
|
||||
$cutoff = new \DateTimeImmutable($matches[1] . '-09-01');
|
||||
$cutoff = $this->septemberFirstCutoff($targetSchoolYear);
|
||||
} catch (\Throwable) {
|
||||
return '';
|
||||
}
|
||||
@@ -776,6 +799,27 @@ final class SchoolYearClosingService
|
||||
return 'keep_kg';
|
||||
}
|
||||
|
||||
private function kgPlacementLabelByTargetYearStartCutoff(string $dob, ?string $targetSchoolYear): string
|
||||
{
|
||||
return match ($this->kgAgeStatusByTargetYearStartCutoff($dob, $targetSchoolYear)) {
|
||||
'pass' => '1',
|
||||
'keep_kg' => 'KG',
|
||||
default => '',
|
||||
};
|
||||
}
|
||||
|
||||
private function septemberFirstCutoff(?string $targetSchoolYear): \DateTimeImmutable
|
||||
{
|
||||
if ($targetSchoolYear !== null && preg_match('/^(\d{4})-\d{4}$/', $targetSchoolYear, $matches) === 1) {
|
||||
return new \DateTimeImmutable($matches[1] . '-09-01');
|
||||
}
|
||||
|
||||
$today = new \DateTimeImmutable('today');
|
||||
$cutoff = new \DateTimeImmutable($today->format('Y') . '-09-01');
|
||||
|
||||
return $today <= $cutoff ? $cutoff : $cutoff->modify('+1 year');
|
||||
}
|
||||
|
||||
private function promotionDecisionRows(array $studentIds, string $schoolYear): array
|
||||
{
|
||||
if (! $this->db->tableExists('student_decisions')) {
|
||||
@@ -880,6 +924,83 @@ final class SchoolYearClosingService
|
||||
return $queueRows;
|
||||
}
|
||||
|
||||
private function nextClassLabelForPromotionPreview(string $sourceClassName, ?string $targetSchoolYear): string
|
||||
{
|
||||
$base = $this->classBaseName($sourceClassName);
|
||||
$target = match (true) {
|
||||
$base === 'KG' || str_contains($base, 'KINDERGARTEN') => '1',
|
||||
ctype_digit($base) => (string) ((int) $base + 1),
|
||||
$base === 'YOUTH' => 'YOUTH',
|
||||
default => '',
|
||||
};
|
||||
|
||||
if ($target === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if ($targetSchoolYear !== null && $targetSchoolYear !== '' && $this->db->tableExists('classes')) {
|
||||
$targetClass = $this->classByNameForPromotionPreview($target, $targetSchoolYear);
|
||||
if ($targetClass !== null) {
|
||||
return (string) ($targetClass['class_name'] ?? $target);
|
||||
}
|
||||
|
||||
if (ctype_digit($base) && (int) $base >= 9) {
|
||||
$youthClass = $this->classByNameForPromotionPreview('YOUTH', $targetSchoolYear);
|
||||
if ($youthClass !== null) {
|
||||
return (string) ($youthClass['class_name'] ?? 'YOUTH');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $target;
|
||||
}
|
||||
|
||||
private function classOnlyLabel(string $className): string
|
||||
{
|
||||
return trim((string) preg_replace('/-.+$/', '', $className));
|
||||
}
|
||||
|
||||
private function classBaseName(string $className): string
|
||||
{
|
||||
$base = strtoupper(trim((string) preg_replace('/-.+$/', '', $className)));
|
||||
$base = preg_replace('/\b(CLASS|GRADE)\b/i', '', $base) ?? $base;
|
||||
$base = trim(preg_replace('/\s+/', ' ', $base) ?? $base);
|
||||
|
||||
if (str_contains($base, 'KINDERGARTEN')) {
|
||||
return 'KG';
|
||||
}
|
||||
|
||||
if (preg_match('/(^|[^A-Z0-9])KG([^A-Z0-9]|$)/', $base) === 1) {
|
||||
return 'KG';
|
||||
}
|
||||
|
||||
if (preg_match('/\d+/', $base, $matches) === 1) {
|
||||
return (string) (int) $matches[0];
|
||||
}
|
||||
|
||||
return $base;
|
||||
}
|
||||
|
||||
private function classByNameForPromotionPreview(string $className, string $schoolYear): ?array
|
||||
{
|
||||
$builder = $this->db->table('classes')->where('UPPER(class_name)', strtoupper($className));
|
||||
if ($this->db->fieldExists('school_year', 'classes')) {
|
||||
$builder->where('school_year', $schoolYear);
|
||||
}
|
||||
|
||||
$row = $builder->orderBy('id', 'DESC')->limit(1)->get()->getRowArray();
|
||||
if ($row !== null || ! $this->db->fieldExists('school_year', 'classes')) {
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
return $this->db->table('classes')
|
||||
->where('UPPER(class_name)', strtoupper($className))
|
||||
->orderBy('id', 'DESC')
|
||||
->limit(1)
|
||||
->get()
|
||||
->getRowArray() ?: null;
|
||||
}
|
||||
|
||||
private function countBySchoolYear(string $table, string $schoolYear, string $distinctField): int
|
||||
{
|
||||
if (! $this->db->tableExists($table) || ! $this->db->fieldExists('school_year', $table)) {
|
||||
|
||||
@@ -214,6 +214,7 @@
|
||||
<th class="text-center">Spring Score</th>
|
||||
<th class="text-center">Year Score</th>
|
||||
<th class="text-center">Decision</th>
|
||||
<th>Next year placement</th>
|
||||
<th class="text-center">Source</th>
|
||||
<th>Notes</th>
|
||||
</tr>
|
||||
@@ -251,6 +252,7 @@
|
||||
<span class="text-muted small">—</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><?= esc((string)($row['next_year_placement'] ?? '') ?: '—') ?></td>
|
||||
<td class="text-center">
|
||||
<span class="badge <?= esc($srcCls) ?>"><?= esc($srcLabel) ?></span>
|
||||
</td>
|
||||
@@ -312,7 +314,7 @@
|
||||
order: [[1, 'asc'], [0, 'asc']],
|
||||
pageLength: 100,
|
||||
lengthMenu: [25, 50, 100, 200],
|
||||
columnDefs: [{ orderable: false, targets: [7] }]
|
||||
columnDefs: [{ orderable: false, targets: [8] }]
|
||||
});
|
||||
} catch (_) {}
|
||||
});
|
||||
|
||||
@@ -8,17 +8,8 @@
|
||||
$finance = $preview['finance'] ?? [];
|
||||
$promotion = $preview['promotion'] ?? ['summary' => [], 'rows' => []];
|
||||
$promotionSummary = $promotion['summary'] ?? [];
|
||||
$promotionTable = $promotionTable ?? [];
|
||||
$promotionRows = $promotionTable['rows'] ?? ($promotion['rows'] ?? []);
|
||||
$promotionSort = (string) ($promotionTable['sort'] ?? 'class');
|
||||
$promotionOrder = (string) ($promotionTable['order'] ?? 'asc');
|
||||
$promotionPage = (int) ($promotionTable['page'] ?? 1);
|
||||
$promotionPerPage = (int) ($promotionTable['perPage'] ?? 25);
|
||||
$promotionTotal = (int) ($promotionTable['total'] ?? count($promotionRows));
|
||||
$promotionPageCount = (int) ($promotionTable['pageCount'] ?? 1);
|
||||
$promotionFrom = (int) ($promotionTable['from'] ?? ($promotionTotal === 0 ? 0 : 1));
|
||||
$promotionTo = (int) ($promotionTable['to'] ?? count($promotionRows));
|
||||
$promotionPerPageOptions = $promotionTable['allowedPerPage'] ?? [10, 25, 50, 100];
|
||||
$promotionRows = $promotion['rows'] ?? [];
|
||||
$promotionTotal = count($promotionRows);
|
||||
$blockers = $preview['blockers'] ?? [];
|
||||
$warnings = $preview['warnings'] ?? [];
|
||||
$carryForward = $preview['carry_forward'] ?? [];
|
||||
@@ -27,27 +18,61 @@
|
||||
$missingCarryForwardInvoices = (int) ($missingCarryForwardInvoices ?? 0);
|
||||
$money = static fn ($value): string => '$' . number_format((float) $value, 2);
|
||||
$score = static fn ($value): string => is_numeric($value) ? number_format((float) $value, 2) : '-';
|
||||
$promotionUrl = static function (array $overrides = []) use ($source, $target, $promotionSort, $promotionOrder, $promotionPage, $promotionPerPage): string {
|
||||
$query = array_merge([
|
||||
'target_school_year_id' => $target['id'] ?? null,
|
||||
'sort' => $promotionSort,
|
||||
'order' => $promotionOrder,
|
||||
'page' => $promotionPage,
|
||||
'per_page' => $promotionPerPage,
|
||||
], $overrides);
|
||||
$query = array_filter($query, static fn ($value): bool => $value !== null && $value !== '');
|
||||
$ageBySchoolYearSecondSeptember = static function (array $row) use ($source): string {
|
||||
$dob = trim((string) ($row['dob'] ?? ''));
|
||||
$schoolYear = (string) ($source['name'] ?? '');
|
||||
if ($dob === '' || preg_match('/^\d{4}-(\d{4})$/', $schoolYear, $matches) !== 1) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return site_url('administrator/school-years/' . (int) ($source['id'] ?? 0) . '/closing/preview?' . http_build_query($query));
|
||||
try {
|
||||
$birthDate = new DateTimeImmutable($dob);
|
||||
$cutoff = new DateTimeImmutable($matches[1] . '-09-01');
|
||||
} catch (Throwable) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if ($birthDate > $cutoff) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return (string) $birthDate->diff($cutoff)->y;
|
||||
};
|
||||
$sortLink = static function (string $key, string $label, string $class = '') use ($promotionSort, $promotionOrder, $promotionUrl): string {
|
||||
$nextOrder = $promotionSort === $key && $promotionOrder === 'asc' ? 'desc' : 'asc';
|
||||
$indicator = $promotionSort === $key ? ($promotionOrder === 'asc' ? ' (asc)' : ' (desc)') : '';
|
||||
$kgPlacementFallback = static function (array $row) use ($target): string {
|
||||
$classText = strtoupper(trim(
|
||||
(string) ($row['class_name'] ?? '') . ' '
|
||||
. (string) ($row['class_section_name'] ?? '')
|
||||
));
|
||||
if (preg_match('/(^|[^A-Z0-9])KG([^A-Z0-9]|$)/', $classText) !== 1 && ! str_contains($classText, 'KINDERGARTEN')) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return '<a class="link-dark text-decoration-none ' . esc($class, 'attr') . '" href="' . esc($promotionUrl([
|
||||
'sort' => $key,
|
||||
'order' => $nextOrder,
|
||||
'page' => 1,
|
||||
]), 'attr') . '">' . esc($label . $indicator) . '</a>';
|
||||
$dob = trim((string) ($row['dob'] ?? ''));
|
||||
if ($dob === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
$targetName = (string) ($target['name'] ?? '');
|
||||
if (preg_match('/^(\d{4})-\d{4}$/', $targetName, $matches) === 1) {
|
||||
$cutoff = new DateTimeImmutable($matches[1] . '-09-01');
|
||||
} else {
|
||||
$today = new DateTimeImmutable('today');
|
||||
$cutoff = new DateTimeImmutable($today->format('Y') . '-09-01');
|
||||
if ($today > $cutoff) {
|
||||
$cutoff = $cutoff->modify('+1 year');
|
||||
}
|
||||
}
|
||||
$birthDate = new DateTimeImmutable($dob);
|
||||
} catch (Throwable) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if ($birthDate > $cutoff) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $birthDate->diff($cutoff)->y >= 6 ? '1' : 'KG';
|
||||
};
|
||||
?>
|
||||
|
||||
@@ -188,7 +213,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border rounded bg-white p-3 mb-4">
|
||||
<div class="border rounded bg-white p-3 mb-4" id="promotion-table">
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
|
||||
<div>
|
||||
<h5 class="mb-1">Student Promotion Preview</h5>
|
||||
@@ -232,52 +257,26 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-2">
|
||||
<div class="text-muted small">
|
||||
Showing <?= esc((string) $promotionFrom) ?>-<?= esc((string) $promotionTo) ?> of <?= esc((string) $promotionTotal) ?> students
|
||||
</div>
|
||||
<form class="d-flex align-items-center gap-2" method="get" action="<?= site_url('administrator/school-years/' . (int) ($source['id'] ?? 0) . '/closing/preview') ?>">
|
||||
<?php if ($target): ?>
|
||||
<input type="hidden" name="target_school_year_id" value="<?= (int) $target['id'] ?>">
|
||||
<?php endif; ?>
|
||||
<input type="hidden" name="sort" value="<?= esc($promotionSort, 'attr') ?>">
|
||||
<input type="hidden" name="order" value="<?= esc($promotionOrder, 'attr') ?>">
|
||||
<input type="hidden" name="page" value="1">
|
||||
<label class="form-label small text-muted mb-0" for="promotion_per_page">Rows</label>
|
||||
<select class="form-select form-select-sm w-auto" id="promotion_per_page" name="per_page" onchange="this.form.submit()">
|
||||
<?php foreach ($promotionPerPageOptions as $option): ?>
|
||||
<option value="<?= (int) $option ?>" <?= (int) $option === $promotionPerPage ? 'selected' : '' ?>>
|
||||
<?= esc((string) $option) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle no-mgmt-sticky" data-no-mgmt-sticky>
|
||||
<table id="promotionPreviewTable" class="table table-sm align-middle no-mgmt-sticky" data-no-mgmt-sticky>
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?= $sortLink('student', 'Student') ?></th>
|
||||
<th><?= $sortLink('school_id', 'School ID') ?></th>
|
||||
<th><?= $sortLink('class', 'Class') ?></th>
|
||||
<th class="text-end"><?= $sortLink('year_score', 'Year Score') ?></th>
|
||||
<th><?= $sortLink('decision', 'Decision') ?></th>
|
||||
<th><?= $sortLink('source', 'Source') ?></th>
|
||||
<th><?= $sortLink('queue', 'Queue') ?></th>
|
||||
<th><?= $sortLink('target', 'Next Year Placement') ?></th>
|
||||
<th><?= $sortLink('status', 'Status') ?></th>
|
||||
<th>Student</th>
|
||||
<th>School ID</th>
|
||||
<th>Class</th>
|
||||
<th class="text-end">Age Sep 1</th>
|
||||
<th class="text-end">Year Score</th>
|
||||
<th>Decision</th>
|
||||
<th>Source</th>
|
||||
<th>Next Year Placement</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if ($promotionTotal === 0): ?>
|
||||
<tr><td colspan="9" class="text-muted">No active class assignments found for this school year.</td></tr>
|
||||
<?php endif; ?>
|
||||
<?php foreach ($promotionRows as $row): ?>
|
||||
<?php
|
||||
$rowStatus = (string) ($row['status'] ?? 'missing');
|
||||
$statusClass = $rowStatus === 'decided' ? 'success' : 'danger';
|
||||
$queueStatus = (string) ($row['queue_status'] ?? '');
|
||||
$sourceLabel = (string) ($row['source'] ?? '');
|
||||
if ($sourceLabel === 'automatic_kg_age') {
|
||||
$sourceLabel = 'Auto KG age';
|
||||
@@ -286,54 +285,32 @@
|
||||
if ($targetLabel === '') {
|
||||
$targetLabel = trim((string) ($row['target_class'] ?? ''));
|
||||
}
|
||||
if ($targetLabel === '') {
|
||||
$targetLabel = $kgPlacementFallback($row);
|
||||
}
|
||||
$ageSepOne = $ageBySchoolYearSecondSeptember($row);
|
||||
?>
|
||||
<tr>
|
||||
<td><?= esc($row['student_name'] ?? ('Student #' . (int) ($row['student_id'] ?? 0))) ?></td>
|
||||
<td><?= esc($row['school_id'] ?? '') ?></td>
|
||||
<td><?= esc($row['class_section_name'] ?? '') ?></td>
|
||||
<td class="text-end"><?= esc($score($row['year_score'] ?? null)) ?></td>
|
||||
<td class="text-end" data-order="<?= esc($ageSepOne, 'attr') ?>"><?= esc($ageSepOne !== '' ? $ageSepOne : '-') ?></td>
|
||||
<td class="text-end" data-order="<?= esc(is_numeric($row['year_score'] ?? null) ? (string) $row['year_score'] : '', 'attr') ?>"><?= esc($score($row['year_score'] ?? null)) ?></td>
|
||||
<td><?= esc(($row['decision'] ?? '') !== '' ? $row['decision'] : '-') ?></td>
|
||||
<td><?= esc($sourceLabel) ?></td>
|
||||
<td><?= esc($queueStatus !== '' ? ucfirst($queueStatus) : '-') ?></td>
|
||||
<td><?= esc($targetLabel !== '' ? $targetLabel : '-') ?></td>
|
||||
<td><span class="badge bg-<?= esc($statusClass) ?>"><?= esc(ucfirst($rowStatus)) ?></span></td>
|
||||
<td data-order="<?= esc($targetLabel, 'attr') ?>"><?= esc($targetLabel !== '' ? $targetLabel : '-') ?></td>
|
||||
<td data-order="<?= esc($rowStatus, 'attr') ?>"><span class="badge bg-<?= esc($statusClass) ?>"><?= esc(ucfirst($rowStatus)) ?></span></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<?php if ($promotionPageCount > 1): ?>
|
||||
<?php
|
||||
$pageStart = max(1, $promotionPage - 2);
|
||||
$pageEnd = min($promotionPageCount, $promotionPage + 2);
|
||||
if ($pageEnd - $pageStart < 4) {
|
||||
$pageStart = max(1, $pageEnd - 4);
|
||||
$pageEnd = min($promotionPageCount, $pageStart + 4);
|
||||
}
|
||||
?>
|
||||
<nav aria-label="Student promotion pages">
|
||||
<ul class="pagination pagination-sm justify-content-end mb-0">
|
||||
<li class="page-item <?= $promotionPage <= 1 ? 'disabled' : '' ?>">
|
||||
<a class="page-link" href="<?= esc($promotionUrl(['page' => max(1, $promotionPage - 1)]), 'attr') ?>">Previous</a>
|
||||
</li>
|
||||
<?php for ($pageNumber = $pageStart; $pageNumber <= $pageEnd; $pageNumber++): ?>
|
||||
<li class="page-item <?= $pageNumber === $promotionPage ? 'active' : '' ?>">
|
||||
<a class="page-link" href="<?= esc($promotionUrl(['page' => $pageNumber]), 'attr') ?>"><?= esc((string) $pageNumber) ?></a>
|
||||
</li>
|
||||
<?php endfor; ?>
|
||||
<li class="page-item <?= $promotionPage >= $promotionPageCount ? 'disabled' : '' ?>">
|
||||
<a class="page-link" href="<?= esc($promotionUrl(['page' => min($promotionPageCount, $promotionPage + 1)]), 'attr') ?>">Next</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="border rounded bg-white p-3 mb-4">
|
||||
<div class="border rounded bg-white p-3 mb-4" id="carry-forward-table">
|
||||
<h5>Carry-Forward Families</h5>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle no-mgmt-sticky" data-no-mgmt-sticky>
|
||||
<table id="carryForwardFamiliesTable" class="table table-sm align-middle no-mgmt-sticky" data-no-mgmt-sticky>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Family</th>
|
||||
@@ -345,9 +322,6 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if ($carryForward === []): ?>
|
||||
<tr><td colspan="6" class="text-muted">No carry-forward balances found.</td></tr>
|
||||
<?php endif; ?>
|
||||
<?php foreach ($carryForward as $row): ?>
|
||||
<tr>
|
||||
<td><?= esc($row['family']) ?></td>
|
||||
@@ -357,10 +331,10 @@
|
||||
<div class="text-muted small"><?= esc($row['parent_email']) ?></div>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="text-end"><?= esc($money($row['source_balance'])) ?></td>
|
||||
<td class="text-end"><?= esc($money($row['credit_amount'])) ?></td>
|
||||
<td class="text-end"><?= esc($money($row['adjustment_amount'])) ?></td>
|
||||
<td class="text-end"><?= esc($money($row['carry_forward_amount'])) ?></td>
|
||||
<td class="text-end" data-order="<?= esc((string) (float) ($row['source_balance'] ?? 0), 'attr') ?>"><?= esc($money($row['source_balance'])) ?></td>
|
||||
<td class="text-end" data-order="<?= esc((string) (float) ($row['credit_amount'] ?? 0), 'attr') ?>"><?= esc($money($row['credit_amount'])) ?></td>
|
||||
<td class="text-end" data-order="<?= esc((string) (float) ($row['adjustment_amount'] ?? 0), 'attr') ?>"><?= esc($money($row['adjustment_amount'])) ?></td>
|
||||
<td class="text-end" data-order="<?= esc((string) (float) ($row['carry_forward_amount'] ?? 0), 'attr') ?>"><?= esc($money($row['carry_forward_amount'])) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
@@ -421,3 +395,60 @@
|
||||
</div>
|
||||
|
||||
<?= $this->endSection() ?>
|
||||
|
||||
<?= $this->section('scripts') ?>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
if (!window.jQuery || !window.jQuery.fn || !window.jQuery.fn.DataTable) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Object.keys(window.localStorage || {}).forEach(function (key) {
|
||||
if (key.indexOf('DataTables_promotionPreviewTable_') === 0 || key.indexOf('DataTables_carryForwardFamiliesTable_') === 0) {
|
||||
window.localStorage.removeItem(key);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
// Ignore storage restrictions; DataTables can still initialize without saved state.
|
||||
}
|
||||
|
||||
var baseOptions = {
|
||||
paging: true,
|
||||
pageLength: 25,
|
||||
lengthMenu: [[25, 50, 100, 200, -1], [25, 50, 100, 200, 'All']],
|
||||
pagingType: 'full_numbers',
|
||||
stateSave: false,
|
||||
autoWidth: false,
|
||||
dom: "<'row mb-2'<'col-sm-12 col-md-6'l><'col-sm-12 col-md-6'f>>" +
|
||||
"t" +
|
||||
"<'row mt-2'<'col-sm-12 col-md-5'i><'col-sm-12 col-md-7'p>>",
|
||||
language: {
|
||||
lengthMenu: 'Show _MENU_ entries',
|
||||
paginate: {
|
||||
first: 'First',
|
||||
previous: 'Previous',
|
||||
next: 'Next',
|
||||
last: 'Last'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.jQuery('#promotionPreviewTable').DataTable(Object.assign({}, baseOptions, {
|
||||
order: [[2, 'asc'], [0, 'asc']],
|
||||
language: {
|
||||
lengthMenu: 'Show _MENU_ entries',
|
||||
emptyTable: 'No active class assignments found for this school year.'
|
||||
}
|
||||
}));
|
||||
|
||||
window.jQuery('#carryForwardFamiliesTable').DataTable(Object.assign({}, baseOptions, {
|
||||
order: [[0, 'asc']],
|
||||
language: {
|
||||
lengthMenu: 'Show _MENU_ entries',
|
||||
emptyTable: 'No carry-forward balances found.'
|
||||
}
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
<?= $this->endSection() ?>
|
||||
|
||||
Reference in New Issue
Block a user