This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -27,4 +27,9 @@ class Feature extends BaseConfig
|
||||
* Use improved new auto routing instead of the default legacy version.
|
||||
*/
|
||||
public bool $autoRoutesImproved = false;
|
||||
|
||||
/**
|
||||
* Show the global school-year selector in authenticated admin layouts.
|
||||
*/
|
||||
public bool $globalSchoolYearSelector = true;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ class Filters extends BaseConfig
|
||||
'permission' => \App\Filters\PermissionFilter::class,
|
||||
'timezone' => \App\Filters\TimezoneFilter::class,
|
||||
'schoolYear' => \App\Filters\RequireSchoolYearFilter::class,
|
||||
'schoolYearWritable'=> \App\Filters\SchoolYearWritableFilter::class,
|
||||
];
|
||||
|
||||
|
||||
|
||||
@@ -47,6 +47,8 @@ $routes->setAutoRoute(false);
|
||||
* --------------------------------------------------------------------
|
||||
*/
|
||||
$routes->post('api/proofread', 'ProofreadController::check', ['filter' => 'auth']);
|
||||
$routes->post('school-year/select', 'View\SchoolYearSelectionController::select', ['filter' => 'auth']);
|
||||
$routes->post('school-year/reset', 'View\SchoolYearSelectionController::reset', ['filter' => 'auth']);
|
||||
|
||||
/*
|
||||
* --------------------------------------------------------------------
|
||||
|
||||
+24
-1
@@ -188,7 +188,29 @@ class Services extends BaseService
|
||||
}
|
||||
|
||||
return new \App\Services\SchoolYearContextService(
|
||||
model(\App\Models\SchoolYearModel::class)
|
||||
model(\App\Models\SchoolYearModel::class),
|
||||
static::schoolYearAccessPolicy()
|
||||
);
|
||||
}
|
||||
|
||||
public static function schoolYearAccessPolicy(bool $getShared = true): \App\Services\SchoolYearAccessPolicy
|
||||
{
|
||||
if ($getShared) {
|
||||
return static::getSharedInstance('schoolYearAccessPolicy');
|
||||
}
|
||||
|
||||
return new \App\Services\SchoolYearAccessPolicy();
|
||||
}
|
||||
|
||||
public static function schoolYearViewData(bool $getShared = true): \App\Services\SchoolYearViewDataService
|
||||
{
|
||||
if ($getShared) {
|
||||
return static::getSharedInstance('schoolYearViewData');
|
||||
}
|
||||
|
||||
return new \App\Services\SchoolYearViewDataService(
|
||||
static::schoolYearContext(),
|
||||
static::schoolYearAccessPolicy()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -238,6 +260,7 @@ class Services extends BaseService
|
||||
model(\App\Models\SchoolYearModel::class),
|
||||
model(\App\Models\SchoolYearClosingBatchModel::class),
|
||||
model(\App\Models\SchoolYearClosingItemModel::class),
|
||||
model(\App\Models\ConfigurationModel::class),
|
||||
static::schoolYearManagement(),
|
||||
\Config\Database::connect()
|
||||
);
|
||||
|
||||
@@ -54,6 +54,11 @@ class CompetitionWinnersController extends BaseController
|
||||
public function index()
|
||||
{
|
||||
$competitionModel = new CompetitionModel();
|
||||
$schoolYear = $this->currentCompetitionSchoolYear();
|
||||
|
||||
if ($schoolYear !== '') {
|
||||
$competitionModel->where('school_year', $schoolYear);
|
||||
}
|
||||
|
||||
$competitions = $competitionModel
|
||||
->orderBy('id', 'DESC')
|
||||
@@ -62,12 +67,13 @@ class CompetitionWinnersController extends BaseController
|
||||
return view('admin/competition_winners/index', [
|
||||
'competitions' => $competitions,
|
||||
'sectionMap' => $this->getClassSectionMap(),
|
||||
'schoolYear' => $schoolYear,
|
||||
]);
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$schoolYear = $this->configModel->getConfig('school_year');
|
||||
$schoolYear = $this->currentCompetitionSchoolYear();
|
||||
$classCounts = $this->getClassStudentCounts($schoolYear);
|
||||
$classSections = $this->classSectionModel
|
||||
->orderBy('class_section_name', 'ASC')
|
||||
@@ -1088,6 +1094,11 @@ class CompetitionWinnersController extends BaseController
|
||||
return $questionCounts;
|
||||
}
|
||||
|
||||
private function currentCompetitionSchoolYear(): string
|
||||
{
|
||||
return $this->currentSchoolYearName((string) ($this->configModel->getConfig('school_year') ?? ''));
|
||||
}
|
||||
|
||||
private function getClassSectionMap(): array
|
||||
{
|
||||
$sections = $this->classSectionModel
|
||||
|
||||
@@ -39,7 +39,13 @@ class AdminProgressController extends BaseController
|
||||
|
||||
public function index()
|
||||
{
|
||||
$schoolYear = trim((string) ($this->request->getGet('school_year') ?? ''));
|
||||
if ($schoolYear === '') {
|
||||
$schoolYear = $this->currentSchoolYearName((string) ($this->configModel->getConfig('school_year') ?? ''));
|
||||
}
|
||||
|
||||
$filters = [
|
||||
'school_year' => $schoolYear,
|
||||
'from' => (string) $this->request->getGet('from'),
|
||||
'to' => (string) $this->request->getGet('to'),
|
||||
'class_section_id' => (string) $this->request->getGet('class_section_id'),
|
||||
@@ -51,6 +57,8 @@ class AdminProgressController extends BaseController
|
||||
->join('classSection cs', 'cs.class_section_id = class_progress_reports.class_section_id', 'left')
|
||||
->join('users u', 'u.id = class_progress_reports.teacher_id', 'left');
|
||||
|
||||
$this->applyProgressSchoolYearScope($builder, $schoolYear);
|
||||
|
||||
if ($filters['from']) {
|
||||
$builder->where('week_start >=', $filters['from']);
|
||||
}
|
||||
@@ -91,16 +99,17 @@ class AdminProgressController extends BaseController
|
||||
$reportGroupsBySection[$sectionId] = $groups;
|
||||
}
|
||||
|
||||
$classSections = $this->classSectionModel->getClassSections();
|
||||
$studentCounts = $this->studentClassModel->getStudentCountsBySection();
|
||||
$filteredSections = array_values(array_filter($classSections, function ($section) use ($studentCounts) {
|
||||
$classSections = $this->classSectionsForYear($schoolYear, $rows);
|
||||
$studentCounts = $this->studentClassModel->getStudentCountsBySection($schoolYear !== '' ? $schoolYear : null);
|
||||
$filteredSections = array_values(array_filter($classSections, function ($section) use ($studentCounts, $reportGroupsBySection) {
|
||||
$sectionId = (int) ($section['class_section_id'] ?? 0);
|
||||
return isset($studentCounts[$sectionId]) && $studentCounts[$sectionId] > 0;
|
||||
return (isset($studentCounts[$sectionId]) && $studentCounts[$sectionId] > 0)
|
||||
|| isset($reportGroupsBySection[$sectionId]);
|
||||
}));
|
||||
|
||||
$filterStart = $this->normalizeDate($filters['from'] ?? '');
|
||||
$filterEnd = $this->normalizeDate($filters['to'] ?? '');
|
||||
[$dateList, $noSchoolDays, $totalPassedDays, $passedDatesSet] = $this->buildSemesterDates();
|
||||
[$dateList, $noSchoolDays, $totalPassedDays, $passedDatesSet] = $this->buildSemesterDates($schoolYear);
|
||||
[$expectedDays, $activeDatesSet] = $this->resolveExpectedDays(
|
||||
$dateList,
|
||||
$noSchoolDays,
|
||||
@@ -132,6 +141,8 @@ class AdminProgressController extends BaseController
|
||||
return view('admin/class_progress_list', [
|
||||
'reportGroupsBySection' => $reportGroupsBySection,
|
||||
'filters' => $filters,
|
||||
'schoolYear' => $schoolYear,
|
||||
'schoolYears' => $this->availableSchoolYears($schoolYear),
|
||||
'classSections' => $filteredSections,
|
||||
'statusOptions' => ClassProgressController::STATUS_OPTIONS,
|
||||
'subjectSections' => ClassProgressController::SUBJECT_SECTIONS,
|
||||
@@ -142,6 +153,162 @@ class AdminProgressController extends BaseController
|
||||
]);
|
||||
}
|
||||
|
||||
private function classSectionsForYear(string $schoolYear, array $reportRows = []): array
|
||||
{
|
||||
$db = db_connect();
|
||||
|
||||
if ($schoolYear !== '' && $db->fieldExists('school_year', 'classSection')) {
|
||||
$sections = $db->table('classSection')
|
||||
->select('classSection.class_section_id, classSection.class_section_name, classes.class_name')
|
||||
->join('classes', 'classSection.class_id = classes.id', 'left')
|
||||
->where('classSection.school_year', $schoolYear)
|
||||
->orderBy('classSection.class_section_name', 'ASC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
} else {
|
||||
$sections = $this->classSectionModel->getClassSections();
|
||||
}
|
||||
|
||||
$sectionsById = [];
|
||||
foreach ($sections as $section) {
|
||||
$sectionId = (int) ($section['class_section_id'] ?? 0);
|
||||
if ($sectionId > 0) {
|
||||
$sectionsById[$sectionId] = $section;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($reportRows as $row) {
|
||||
$sectionId = (int) ($row['class_section_id'] ?? 0);
|
||||
if ($sectionId <= 0 || isset($sectionsById[$sectionId])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sectionsById[$sectionId] = [
|
||||
'class_section_id' => $sectionId,
|
||||
'class_section_name' => (string) ($row['class_section_name'] ?? ('Section #' . $sectionId)),
|
||||
'class_name' => '',
|
||||
];
|
||||
}
|
||||
|
||||
uasort($sectionsById, static function (array $a, array $b): int {
|
||||
return strnatcasecmp((string) ($a['class_section_name'] ?? ''), (string) ($b['class_section_name'] ?? ''));
|
||||
});
|
||||
|
||||
return array_values($sectionsById);
|
||||
}
|
||||
|
||||
private function applyProgressSchoolYearScope($builder, string $schoolYear): void
|
||||
{
|
||||
if ($schoolYear === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$db = db_connect();
|
||||
$hasReportYear = $db->fieldExists('school_year', 'class_progress_reports');
|
||||
$hasSectionYear = $db->fieldExists('school_year', 'classSection');
|
||||
[$rangeStart, $rangeEnd] = $this->semesterRangeService->getSchoolYearRange($schoolYear);
|
||||
|
||||
if ($hasReportYear) {
|
||||
if ($this->hasAnyExplicitProgressSchoolYearRows()) {
|
||||
$builder->where('class_progress_reports.school_year', $schoolYear);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($rangeStart !== '' && $rangeEnd !== '') {
|
||||
$builder
|
||||
->groupStart()
|
||||
->groupStart()
|
||||
->where('class_progress_reports.school_year IS NULL', null, false)
|
||||
->orWhere('class_progress_reports.school_year', '')
|
||||
->groupEnd()
|
||||
->where('class_progress_reports.week_start >=', $rangeStart)
|
||||
->where('class_progress_reports.week_start <=', $rangeEnd)
|
||||
->groupEnd();
|
||||
return;
|
||||
}
|
||||
|
||||
$builder->where('class_progress_reports.school_year', $schoolYear);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($hasSectionYear) {
|
||||
$builder->where('cs.school_year', $schoolYear);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($rangeStart !== '' && $rangeEnd !== '') {
|
||||
$builder
|
||||
->where('class_progress_reports.week_start >=', $rangeStart)
|
||||
->where('class_progress_reports.week_start <=', $rangeEnd);
|
||||
}
|
||||
}
|
||||
|
||||
private function hasAnyExplicitProgressSchoolYearRows(): bool
|
||||
{
|
||||
try {
|
||||
return db_connect()
|
||||
->table('class_progress_reports')
|
||||
->where('school_year IS NOT NULL', null, false)
|
||||
->where('school_year !=', '')
|
||||
->limit(1)
|
||||
->get()
|
||||
->getRowArray() !== null;
|
||||
} catch (\Throwable $e) {
|
||||
log_message('warning', 'Unable to check explicit progress school-year rows: {message}', [
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private function availableSchoolYears(string $selectedYear): array
|
||||
{
|
||||
$years = [];
|
||||
$addYear = static function (mixed $value) use (&$years): void {
|
||||
$year = trim((string) $value);
|
||||
if ($year !== '' && ! in_array($year, $years, true)) {
|
||||
$years[] = $year;
|
||||
}
|
||||
};
|
||||
|
||||
$addYear($selectedYear);
|
||||
$addYear($this->currentSchoolYearName((string) ($this->configModel->getConfig('school_year') ?? '')));
|
||||
|
||||
$db = db_connect();
|
||||
foreach ([
|
||||
['table' => 'school_years', 'column' => 'name'],
|
||||
['table' => 'class_progress_reports', 'column' => 'school_year'],
|
||||
['table' => 'classSection', 'column' => 'school_year'],
|
||||
['table' => 'student_class', 'column' => 'school_year'],
|
||||
] as $source) {
|
||||
try {
|
||||
if (! $db->tableExists($source['table']) || ! $db->fieldExists($source['column'], $source['table'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rows = $db->table($source['table'])
|
||||
->select($source['column'])
|
||||
->distinct()
|
||||
->where($source['column'] . ' IS NOT NULL', null, false)
|
||||
->orderBy($source['column'], 'DESC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$addYear($row[$source['column']] ?? '');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
log_message('warning', 'Unable to load school-year options for admin progress: {message}', [
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
rsort($years, SORT_NATURAL);
|
||||
|
||||
return $years;
|
||||
}
|
||||
|
||||
public function view($id)
|
||||
{
|
||||
$row = $this->reportModel
|
||||
@@ -277,11 +444,15 @@ class AdminProgressController extends BaseController
|
||||
return checkdate($m, $d, $y) ? $value : '';
|
||||
}
|
||||
|
||||
protected function buildSemesterDates(): array
|
||||
protected function buildSemesterDates(?string $schoolYear = null): array
|
||||
{
|
||||
$schoolYear = (string) ($this->configModel->getConfig('school_year') ?? '');
|
||||
$schoolYear = trim((string) ($schoolYear ?? ''));
|
||||
if ($schoolYear === '') {
|
||||
$schoolYear = $this->currentSchoolYearName((string) ($this->configModel->getConfig('school_year') ?? ''));
|
||||
}
|
||||
|
||||
$semester = (string) ($this->configModel->getConfig('semester') ?? '');
|
||||
$schoolYearForRange = $schoolYear !== '' ? $schoolYear : (string) ($this->configModel->getConfig('school_year') ?? '');
|
||||
$schoolYearForRange = $schoolYear !== '' ? $schoolYear : $this->currentSchoolYearName((string) ($this->configModel->getConfig('school_year') ?? ''));
|
||||
[$rangeStart, $rangeEnd] = $this->semesterRangeService->getSchoolYearRange($schoolYearForRange);
|
||||
$semesterNorm = $this->semesterRangeService->normalizeSemester($semester);
|
||||
if ($semesterNorm !== '' && $schoolYearForRange !== '') {
|
||||
|
||||
@@ -12,9 +12,12 @@ class SchoolYearClosingController extends BaseController
|
||||
{
|
||||
try {
|
||||
$targetId = $this->normalizeInt($this->request->getGet('target_school_year_id'));
|
||||
$preview = service('schoolYearClosing')->preview($id, $targetId);
|
||||
$promotionTable = $this->promotionTablePayload($preview['promotion']['rows'] ?? []);
|
||||
|
||||
return view('school_years/closing_preview', [
|
||||
'preview' => service('schoolYearClosing')->preview($id, $targetId),
|
||||
'preview' => $preview,
|
||||
'promotionTable' => $promotionTable,
|
||||
'latestBatch' => service('schoolYearClosing')->latestBatch($id),
|
||||
'schoolYears' => (new SchoolYearModel())->orderBy('name', 'DESC')->findAll(),
|
||||
]);
|
||||
@@ -32,7 +35,7 @@ class SchoolYearClosingController extends BaseController
|
||||
}
|
||||
|
||||
service('schoolYearClosing')->start($id, $targetId, $this->userId());
|
||||
return redirect()->to('/administrator/school-years/' . $id . '/closing/preview')->with('success', 'Closing started.');
|
||||
return redirect()->to('/administrator/school-years/' . $id . '/closing/preview')->with('success', 'End-year process started.');
|
||||
} catch (Throwable $e) {
|
||||
return redirect()->back()->with('error', $e->getMessage());
|
||||
}
|
||||
@@ -42,7 +45,7 @@ class SchoolYearClosingController extends BaseController
|
||||
{
|
||||
try {
|
||||
service('schoolYearClosing')->execute($id, $this->userId());
|
||||
return redirect()->to('/administrator/school-years/' . $id . '/closing/preview')->with('success', 'Carry-forward batch executed.');
|
||||
return redirect()->to('/administrator/school-years/' . $id . '/closing/preview')->with('success', 'Carry-forward confirmed.');
|
||||
} catch (Throwable $e) {
|
||||
return redirect()->back()->with('error', $e->getMessage());
|
||||
}
|
||||
@@ -52,7 +55,7 @@ class SchoolYearClosingController extends BaseController
|
||||
{
|
||||
try {
|
||||
service('schoolYearClosing')->complete($id, $this->userId());
|
||||
return redirect()->to('/administrator/school-years')->with('success', 'School year closed.');
|
||||
return redirect()->to('/administrator/school-years')->with('success', 'School year ended and closed.');
|
||||
} catch (Throwable $e) {
|
||||
return redirect()->back()->with('error', $e->getMessage());
|
||||
}
|
||||
@@ -62,7 +65,7 @@ class SchoolYearClosingController extends BaseController
|
||||
{
|
||||
try {
|
||||
service('schoolYearClosing')->cancel($id, $this->userId());
|
||||
return redirect()->to('/administrator/school-years')->with('success', 'Closing cancelled.');
|
||||
return redirect()->to('/administrator/school-years')->with('success', 'End-year process cancelled.');
|
||||
} catch (Throwable $e) {
|
||||
return redirect()->back()->with('error', $e->getMessage());
|
||||
}
|
||||
@@ -73,6 +76,89 @@ class SchoolYearClosingController extends BaseController
|
||||
return is_numeric($value) && (int) $value > 0 ? (int) $value : null;
|
||||
}
|
||||
|
||||
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);
|
||||
$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);
|
||||
if ($comparison === 0 && $sort !== 'class') {
|
||||
$comparison = $this->comparePromotionRows($a, $b, 'class');
|
||||
}
|
||||
if ($comparison === 0 && $sort !== 'student') {
|
||||
$comparison = $this->comparePromotionRows($a, $b, 'student');
|
||||
}
|
||||
if ($comparison === 0) {
|
||||
$comparison = ((int) ($a['student_id'] ?? 0)) <=> ((int) ($b['student_id'] ?? 0));
|
||||
}
|
||||
|
||||
return $order === 'desc' ? -$comparison : $comparison;
|
||||
});
|
||||
|
||||
$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),
|
||||
'sort' => $sort,
|
||||
'order' => $order,
|
||||
'page' => $page,
|
||||
'perPage' => $perPage,
|
||||
'total' => $total,
|
||||
'pageCount' => $pageCount,
|
||||
'allowedPerPage' => $allowedPerPage,
|
||||
'from' => $total === 0 ? 0 : $offset + 1,
|
||||
'to' => min($offset + $perPage, $total),
|
||||
];
|
||||
}
|
||||
|
||||
private function comparePromotionRows(array $a, array $b, string $sort): int
|
||||
{
|
||||
$aValue = $this->promotionSortValue($a, $sort);
|
||||
$bValue = $this->promotionSortValue($b, $sort);
|
||||
|
||||
if ($sort === 'year_score') {
|
||||
if ($aValue === null && $bValue === null) {
|
||||
return 0;
|
||||
}
|
||||
if ($aValue === null) {
|
||||
return 1;
|
||||
}
|
||||
if ($bValue === null) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return $aValue <=> $bValue;
|
||||
}
|
||||
|
||||
return strnatcasecmp((string) $aValue, (string) $bValue);
|
||||
}
|
||||
|
||||
private function promotionSortValue(array $row, string $sort): mixed
|
||||
{
|
||||
return match ($sort) {
|
||||
'student' => (string) ($row['student_name'] ?? ''),
|
||||
'school_id' => (string) ($row['school_id'] ?? ''),
|
||||
'class' => (string) ($row['class_section_name'] ?? ''),
|
||||
'year_score' => is_numeric($row['year_score'] ?? null) ? (float) $row['year_score'] : null,
|
||||
'decision' => (string) ($row['decision'] ?? ''),
|
||||
'source' => (string) ($row['source'] ?? ''),
|
||||
'queue' => (string) ($row['queue_status'] ?? ''),
|
||||
'target' => trim((string) ($row['target_section'] ?? '') . ' ' . (string) ($row['target_class'] ?? '')),
|
||||
'status' => (string) ($row['status'] ?? ''),
|
||||
default => '',
|
||||
};
|
||||
}
|
||||
|
||||
private function userId(): ?int
|
||||
{
|
||||
$id = session('user_id') ?? session('id');
|
||||
|
||||
@@ -51,6 +51,8 @@ class SchoolYearController extends BaseController
|
||||
'closingYear' => $closingYear,
|
||||
'archivedCount' => $archivedCount,
|
||||
'latestTransitions' => service('schoolYearManagement')->latestTransitionByYear(),
|
||||
'yearVerification' => $this->verificationByYear($schoolYears),
|
||||
'schoolYearNamesById' => array_column($schoolYears, 'name', 'id'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -124,4 +126,91 @@ class SchoolYearController extends BaseController
|
||||
|
||||
return is_numeric($id) ? (int) $id : null;
|
||||
}
|
||||
|
||||
private function verificationByYear(array $schoolYears): array
|
||||
{
|
||||
$db = db_connect();
|
||||
$verification = [];
|
||||
|
||||
$hasStudentClass = $db->tableExists('student_class');
|
||||
$hasStudentDecisions = $db->tableExists('student_decisions');
|
||||
$hasPromotionQueue = $db->tableExists('promotion_queue');
|
||||
$hasInvoices = $db->tableExists('invoices');
|
||||
$hasClosingBatches = $db->tableExists('school_year_closing_batches');
|
||||
|
||||
foreach ($schoolYears as $year) {
|
||||
$id = (int) ($year['id'] ?? 0);
|
||||
$name = (string) ($year['name'] ?? '');
|
||||
if ($id <= 0 || $name === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$summary = [
|
||||
'students' => 0,
|
||||
'decisions' => 0,
|
||||
'promoted' => 0,
|
||||
'queued' => 0,
|
||||
'carry_forward_balance' => 0.0,
|
||||
'positive_balance' => 0.0,
|
||||
'credit_balance' => 0.0,
|
||||
'closing_status' => '',
|
||||
];
|
||||
|
||||
if ($hasStudentClass && $db->fieldExists('school_year', 'student_class')) {
|
||||
$row = $db->table('student_class')
|
||||
->select('COUNT(DISTINCT student_id) AS total', false)
|
||||
->where('school_year', $name)
|
||||
->get()
|
||||
->getRowArray();
|
||||
$summary['students'] = (int) ($row['total'] ?? 0);
|
||||
}
|
||||
|
||||
if ($hasStudentDecisions && $db->fieldExists('school_year', 'student_decisions')) {
|
||||
$row = $db->table('student_decisions')
|
||||
->select('COUNT(DISTINCT student_id) AS decisions', false)
|
||||
->select("COUNT(DISTINCT CASE WHEN LOWER(decision) = 'pass' THEN student_id END) AS promoted", false)
|
||||
->where('school_year', $name)
|
||||
->get()
|
||||
->getRowArray();
|
||||
$summary['decisions'] = (int) ($row['decisions'] ?? 0);
|
||||
$summary['promoted'] = (int) ($row['promoted'] ?? 0);
|
||||
}
|
||||
|
||||
if ($hasPromotionQueue && $db->fieldExists('school_year_from', 'promotion_queue')) {
|
||||
$row = $db->table('promotion_queue')
|
||||
->select('COUNT(DISTINCT student_id) AS total', false)
|
||||
->where('school_year_from', $name)
|
||||
->get()
|
||||
->getRowArray();
|
||||
$summary['queued'] = (int) ($row['total'] ?? 0);
|
||||
}
|
||||
|
||||
if ($hasInvoices && $db->fieldExists('school_year', 'invoices')) {
|
||||
$row = $db->table('invoices')
|
||||
->select('COALESCE(SUM(balance), 0) AS carry_forward_balance')
|
||||
->select('COALESCE(SUM(CASE WHEN balance > 0 THEN balance ELSE 0 END), 0) AS positive_balance', false)
|
||||
->select('COALESCE(SUM(CASE WHEN balance < 0 THEN ABS(balance) ELSE 0 END), 0) AS credit_balance', false)
|
||||
->where('school_year', $name)
|
||||
->get()
|
||||
->getRowArray();
|
||||
$summary['carry_forward_balance'] = round((float) ($row['carry_forward_balance'] ?? 0), 2);
|
||||
$summary['positive_balance'] = round((float) ($row['positive_balance'] ?? 0), 2);
|
||||
$summary['credit_balance'] = round((float) ($row['credit_balance'] ?? 0), 2);
|
||||
}
|
||||
|
||||
if ($hasClosingBatches) {
|
||||
$batch = $db->table('school_year_closing_batches')
|
||||
->select('status')
|
||||
->where('source_school_year_id', $id)
|
||||
->orderBy('id', 'DESC')
|
||||
->get(1)
|
||||
->getRowArray();
|
||||
$summary['closing_status'] = (string) ($batch['status'] ?? '');
|
||||
}
|
||||
|
||||
$verification[$id] = $summary;
|
||||
}
|
||||
|
||||
return $verification;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use CodeIgniter\HTTP\RequestInterface;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use App\Services\ApiClient;
|
||||
use App\Exceptions\SchoolYear\SchoolYearConfigurationException;
|
||||
use App\Support\SchoolYear\SchoolYearContext;
|
||||
use Throwable;
|
||||
|
||||
@@ -60,6 +61,8 @@ abstract class BaseController extends Controller
|
||||
// Preload any models, libraries, etc, here.
|
||||
// E.g.: $this->session = \Config\Services::session();
|
||||
session();
|
||||
$this->syncSchoolYearPropertyFromContext();
|
||||
$this->injectSchoolYearViewData();
|
||||
|
||||
// Shared API client available to all controllers
|
||||
$this->api = service('apiClient');
|
||||
@@ -104,6 +107,23 @@ abstract class BaseController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
protected function currentSchoolYearName(?string $fallback = null): string
|
||||
{
|
||||
try {
|
||||
return $this->resolveSchoolYearContext()->yearName();
|
||||
} catch (Throwable $e) {
|
||||
if ($fallback !== null && $fallback !== '') {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
try {
|
||||
return (string) ((new \App\Models\ConfigurationModel())->getConfig('school_year') ?? '');
|
||||
} catch (Throwable) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected function assertSchoolYearWritable(
|
||||
SchoolYearContext $context,
|
||||
bool $allowDraftForAdmin = false,
|
||||
@@ -111,6 +131,85 @@ abstract class BaseController extends Controller
|
||||
): void {
|
||||
service('schoolYearWriteGuard')->assertWritable($context, $allowDraftForAdmin, $isAdmin);
|
||||
}
|
||||
|
||||
private function syncSchoolYearPropertyFromContext(): void
|
||||
{
|
||||
if (! property_exists($this, 'schoolYear')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->request instanceof IncomingRequest || is_cli()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! session()->get('user_id')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->schoolYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
|
||||
} catch (Throwable $e) {
|
||||
log_message('warning', 'Unable to sync controller school-year property: {message}', [
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function injectSchoolYearViewData(): void
|
||||
{
|
||||
if (! config('Feature')->globalSchoolYearSelector) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->request instanceof IncomingRequest || is_cli()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! session()->get('user_id')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->shouldShowSchoolYearSelector()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$accept = strtolower($this->request->getHeaderLine('Accept'));
|
||||
if ($this->request->isAJAX() || str_contains($accept, 'application/json')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
service('renderer')->setData(service('schoolYearViewData')->forCurrentRequest($this->request));
|
||||
} catch (SchoolYearConfigurationException $e) {
|
||||
throw $e;
|
||||
} catch (Throwable $e) {
|
||||
log_message('warning', 'Unable to inject school-year view data: {message}', [
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function shouldShowSchoolYearSelector(): bool
|
||||
{
|
||||
$roles = (array) session()->get('roles');
|
||||
$singleRole = session()->get('role');
|
||||
if ($singleRole !== null && $singleRole !== '') {
|
||||
$roles[] = $singleRole;
|
||||
}
|
||||
|
||||
$roles = array_map(
|
||||
static fn ($role): string => strtolower(trim((string) $role)),
|
||||
$roles
|
||||
);
|
||||
|
||||
return (bool) array_intersect($roles, [
|
||||
'admin',
|
||||
'administrator',
|
||||
'administrative staff',
|
||||
'principal',
|
||||
'vice principal',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
|
||||
@@ -31,7 +31,7 @@ class ParentReportCardController extends BaseController
|
||||
return redirect()->back()->with('error', 'Unable to retrieve student data. Please contact support.');
|
||||
}
|
||||
|
||||
$schoolYear = trim((string) ($this->request->getGet('school_year') ?? $this->configModel->getConfig('school_year') ?? ''));
|
||||
$schoolYear = trim((string) ($this->request->getGet('school_year') ?? $this->currentSchoolYearName()));
|
||||
$semester = trim((string) ($this->request->getGet('semester') ?? $this->configModel->getConfig('semester') ?? ''));
|
||||
|
||||
$builder = $this->db->table('students s')
|
||||
@@ -82,7 +82,7 @@ class ParentReportCardController extends BaseController
|
||||
throw new PageNotFoundException('Student not found.');
|
||||
}
|
||||
|
||||
$schoolYear = trim((string) ($this->request->getGet('school_year') ?? $this->configModel->getConfig('school_year') ?? ''));
|
||||
$schoolYear = trim((string) ($this->request->getGet('school_year') ?? $this->currentSchoolYearName()));
|
||||
$semester = trim((string) ($this->request->getGet('semester') ?? $this->configModel->getConfig('semester') ?? ''));
|
||||
|
||||
$this->touchAcknowledgement($parentId, (int) $studentId, $schoolYear, $semester, [
|
||||
@@ -121,7 +121,7 @@ class ParentReportCardController extends BaseController
|
||||
return redirect()->back()->with('error', 'Please type your full name to sign.');
|
||||
}
|
||||
|
||||
$schoolYear = trim((string) ($this->configModel->getConfig('school_year') ?? ''));
|
||||
$schoolYear = trim((string) $this->currentSchoolYearName());
|
||||
$semester = trim((string) ($this->configModel->getConfig('semester') ?? ''));
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$this->touchAcknowledgement($parentId, (int) $studentId, $schoolYear, $semester, [
|
||||
|
||||
@@ -699,7 +699,10 @@ class AdministratorController extends BaseController
|
||||
public function teacherSubmissionsReport()
|
||||
{
|
||||
$semester = (string)($this->configModel->getConfig('semester') ?? $this->semester ?? '');
|
||||
$schoolYear = (string)($this->configModel->getConfig('school_year') ?? $this->schoolYear ?? '');
|
||||
$schoolYear = trim((string) ($this->request->getGet('school_year') ?? ''));
|
||||
if ($schoolYear === '') {
|
||||
$schoolYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
|
||||
}
|
||||
$semesterResolver = new SemesterRangeService($this->configModel);
|
||||
$semesterNorm = $semesterResolver->normalizeSemester($semester);
|
||||
$semesterFilter = $semesterNorm !== '' ? $semesterNorm : $semester;
|
||||
@@ -1099,9 +1102,9 @@ class AdministratorController extends BaseController
|
||||
}
|
||||
|
||||
$semesterResolver = new SemesterRangeService($this->configModel);
|
||||
$schoolYear = (string)($this->configModel->getConfig('school_year') ?? '');
|
||||
$schoolYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
|
||||
$semester = (string)($this->configModel->getConfig('semester') ?? '');
|
||||
$schoolYearForRange = $schoolYear !== '' ? $schoolYear : (string)($this->configModel->getConfig('school_year') ?? '');
|
||||
$schoolYearForRange = $schoolYear !== '' ? $schoolYear : $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
|
||||
[$rangeStart, $rangeEnd] = $semesterResolver->getSchoolYearRange($schoolYearForRange);
|
||||
$semesterNorm = $semesterResolver->normalizeSemester($semester);
|
||||
if ($semesterNorm !== '' && $schoolYearForRange !== '') {
|
||||
@@ -2295,22 +2298,10 @@ class AdministratorController extends BaseController
|
||||
public function showEnrollmentWithdrawalPage()
|
||||
{
|
||||
try {
|
||||
$selectedYear = trim((string)($this->request->getGet('schoolYear') ?? ''));
|
||||
if ($selectedYear === '') {
|
||||
$selectedYear = (string)$this->schoolYear;
|
||||
}
|
||||
$schoolYears = $this->availableSchoolYears();
|
||||
$selectedYear = $this->selectedEnrollmentSchoolYear($schoolYears);
|
||||
|
||||
// Distinct school years from enrollments (for selector)
|
||||
$yearsRows = $this->db->table('enrollments')
|
||||
->distinct()
|
||||
->select('school_year')
|
||||
->orderBy('school_year', 'DESC')
|
||||
->get()->getResultArray();
|
||||
$schoolYears = array_values(array_filter(array_map(static function ($r) {
|
||||
return isset($r['school_year']) ? (string)$r['school_year'] : null;
|
||||
}, $yearsRows)));
|
||||
|
||||
$students = $this->studentModel->getStudentsWithClassAndEnrollment();
|
||||
$students = $this->studentModel->getStudentsWithClassAndEnrollment($selectedYear);
|
||||
|
||||
$selectedStartYear = $this->getSchoolYearStartYear((string)$selectedYear);
|
||||
$removedPriorIds = [];
|
||||
@@ -2326,6 +2317,7 @@ class AdministratorController extends BaseController
|
||||
}
|
||||
}
|
||||
}
|
||||
$returningStudentIds = $this->priorYearStudentIds($selectedYear);
|
||||
|
||||
foreach ($students as &$s) {
|
||||
// ===== Ensure IDs needed by the modal =====
|
||||
@@ -2360,6 +2352,9 @@ class AdministratorController extends BaseController
|
||||
|
||||
// ===== New-student flags =====
|
||||
$s['is_new'] = (int) ($s['is_new'] ?? 0);
|
||||
if (isset($returningStudentIds[$s['student_id']])) {
|
||||
$s['is_new'] = 0;
|
||||
}
|
||||
$s['new_student'] = $s['is_new'] === 1 ? 'Yes' : 'No';
|
||||
|
||||
// ===== Admission override =====
|
||||
@@ -2369,6 +2364,9 @@ class AdministratorController extends BaseController
|
||||
$s['enrollment_status'] = $statusForYear;
|
||||
} elseif (($s['admission_status'] ?? null) === 'denied') {
|
||||
$s['enrollment_status'] = 'denied';
|
||||
} else {
|
||||
$s['enrollment_status'] = 'admission under review';
|
||||
$s['admission_status'] = 'pending';
|
||||
}
|
||||
|
||||
// ===== Class section name for the selected year =====
|
||||
@@ -2396,22 +2394,7 @@ class AdministratorController extends BaseController
|
||||
return strcasecmp($pa, $pb);
|
||||
});
|
||||
|
||||
// ===== Provide classes for the modal select (like studentClassAssignment) =====
|
||||
// Filter to current term; loosen if your table doesn’t store these fields.
|
||||
$classes = $this->classSectionModel
|
||||
->select('id, class_section_id, class_section_name, school_year, semester')
|
||||
->where('school_year', (string)$selectedYear)
|
||||
->where('semester', (string)$this->semester)
|
||||
->orderBy('class_section_name', 'ASC')
|
||||
->findAll();
|
||||
|
||||
// Fallback so the dropdown never shows empty if filters are too strict
|
||||
if (empty($classes)) {
|
||||
$classes = $this->classSectionModel
|
||||
->select('id, class_section_id, class_section_name')
|
||||
->orderBy('class_section_name', 'ASC')
|
||||
->findAll();
|
||||
}
|
||||
$classes = $this->enrollmentClassOptions((string)$selectedYear);
|
||||
|
||||
return view('enroll_withdraw/enrollment_withdrawal', [
|
||||
'students' => $students,
|
||||
@@ -2432,22 +2415,10 @@ class AdministratorController extends BaseController
|
||||
public function enrollmentWithdrawalData()
|
||||
{
|
||||
try {
|
||||
$selectedYear = trim((string)($this->request->getGet('schoolYear') ?? ''));
|
||||
if ($selectedYear === '') {
|
||||
$selectedYear = (string)$this->schoolYear;
|
||||
}
|
||||
$schoolYears = $this->availableSchoolYears();
|
||||
$selectedYear = $this->selectedEnrollmentSchoolYear($schoolYears);
|
||||
|
||||
// Distinct school years
|
||||
$yearsRows = $this->db->table('enrollments')
|
||||
->distinct()
|
||||
->select('school_year')
|
||||
->orderBy('school_year', 'DESC')
|
||||
->get()->getResultArray();
|
||||
$schoolYears = array_values(array_filter(array_map(static function ($r) {
|
||||
return isset($r['school_year']) ? (string)$r['school_year'] : null;
|
||||
}, $yearsRows)));
|
||||
|
||||
$students = $this->studentModel->getStudentsWithClassAndEnrollment();
|
||||
$students = $this->studentModel->getStudentsWithClassAndEnrollment($selectedYear);
|
||||
|
||||
$selectedStartYear = $this->getSchoolYearStartYear((string)$selectedYear);
|
||||
$removedPriorIds = [];
|
||||
@@ -2463,6 +2434,7 @@ class AdministratorController extends BaseController
|
||||
}
|
||||
}
|
||||
}
|
||||
$returningStudentIds = $this->priorYearStudentIds($selectedYear);
|
||||
|
||||
foreach ($students as &$s) {
|
||||
$s['student_id'] = (int)($s['id'] ?? 0);
|
||||
@@ -2485,6 +2457,9 @@ class AdministratorController extends BaseController
|
||||
$s['parent_sort'] = trim(($pl !== '' ? $pl : $pf) . ' ' . $pf) ?: 'ZZZ Unknown Parent';
|
||||
|
||||
$s['is_new'] = (int) ($s['is_new'] ?? 0);
|
||||
if (isset($returningStudentIds[$s['student_id']])) {
|
||||
$s['is_new'] = 0;
|
||||
}
|
||||
$s['new_student'] = $s['is_new'] === 1 ? 'Yes' : 'No';
|
||||
|
||||
$statusForYear = $this->enrollmentModel->getEnrollmentStatus((int)$s['student_id'], $selectedYear);
|
||||
@@ -2492,6 +2467,9 @@ class AdministratorController extends BaseController
|
||||
$s['enrollment_status'] = $statusForYear;
|
||||
} elseif (($s['admission_status'] ?? null) === 'denied') {
|
||||
$s['enrollment_status'] = 'denied';
|
||||
} else {
|
||||
$s['enrollment_status'] = 'admission under review';
|
||||
$s['admission_status'] = 'pending';
|
||||
}
|
||||
|
||||
$className = $this->studentClassModel->getClassSectionsByStudentId((int)$s['student_id'], $selectedYear);
|
||||
@@ -2516,18 +2494,7 @@ class AdministratorController extends BaseController
|
||||
return strcasecmp($pa, $pb);
|
||||
});
|
||||
|
||||
$classes = $this->classSectionModel
|
||||
->select('id, class_section_id, class_section_name, school_year, semester')
|
||||
->where('school_year', (string)$selectedYear)
|
||||
->where('semester', (string)$this->semester)
|
||||
->orderBy('class_section_name', 'ASC')
|
||||
->findAll();
|
||||
if (empty($classes)) {
|
||||
$classes = $this->classSectionModel
|
||||
->select('id, class_section_id, class_section_name')
|
||||
->orderBy('class_section_name', 'ASC')
|
||||
->findAll();
|
||||
}
|
||||
$classes = $this->enrollmentClassOptions((string)$selectedYear);
|
||||
|
||||
return $this->response->setJSON([
|
||||
'students' => $students,
|
||||
@@ -2543,6 +2510,138 @@ class AdministratorController extends BaseController
|
||||
}
|
||||
}
|
||||
|
||||
private function availableSchoolYears(): array
|
||||
{
|
||||
$years = [];
|
||||
$addYear = static function (mixed $value) use (&$years): void {
|
||||
$year = trim((string) $value);
|
||||
if ($year !== '' && !in_array($year, $years, true)) {
|
||||
$years[] = $year;
|
||||
}
|
||||
};
|
||||
|
||||
$addYear($this->schoolYear ?? null);
|
||||
|
||||
if ($this->db->tableExists('school_years')) {
|
||||
$rows = $this->db->table('school_years')
|
||||
->select('name')
|
||||
->orderBy('name', 'DESC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$addYear($row['name'] ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->db->tableExists('enrollments')) {
|
||||
$rows = $this->db->table('enrollments')
|
||||
->distinct()
|
||||
->select('school_year')
|
||||
->where('school_year IS NOT NULL', null, false)
|
||||
->orderBy('school_year', 'DESC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$addYear($row['school_year'] ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
usort($years, static fn(string $a, string $b): int => strnatcasecmp($b, $a));
|
||||
|
||||
$activeYear = trim((string) ($this->schoolYear ?? ''));
|
||||
if ($activeYear !== '') {
|
||||
$years = array_values(array_filter($years, static fn(string $year): bool => $year !== $activeYear));
|
||||
array_unshift($years, $activeYear);
|
||||
}
|
||||
|
||||
return $years;
|
||||
}
|
||||
|
||||
private function selectedEnrollmentSchoolYear(array $schoolYears): string
|
||||
{
|
||||
$requestedYear = trim((string) ($this->request->getGet('schoolYear') ?? ''));
|
||||
if ($requestedYear !== '' && in_array($requestedYear, $schoolYears, true)) {
|
||||
return $requestedYear;
|
||||
}
|
||||
|
||||
$activeYear = trim((string) ($this->schoolYear ?? ''));
|
||||
if ($activeYear !== '') {
|
||||
return $activeYear;
|
||||
}
|
||||
|
||||
return (string) ($schoolYears[0] ?? '');
|
||||
}
|
||||
|
||||
private function enrollmentClassOptions(string $selectedYear): array
|
||||
{
|
||||
$select = ['id', 'class_section_id', 'class_section_name'];
|
||||
$hasSchoolYear = $this->db->fieldExists('school_year', 'classSection');
|
||||
$hasSemester = $this->db->fieldExists('semester', 'classSection');
|
||||
|
||||
if ($hasSchoolYear) {
|
||||
$select[] = 'school_year';
|
||||
}
|
||||
if ($hasSemester) {
|
||||
$select[] = 'semester';
|
||||
}
|
||||
|
||||
$query = $this->classSectionModel
|
||||
->select(implode(', ', $select))
|
||||
->orderBy('class_section_name', 'ASC');
|
||||
|
||||
if ($hasSchoolYear && $selectedYear !== '') {
|
||||
$query->where('school_year', $selectedYear);
|
||||
}
|
||||
if ($hasSemester) {
|
||||
$query->where('semester', (string)$this->semester);
|
||||
}
|
||||
|
||||
$classes = $query->findAll();
|
||||
|
||||
if (! empty($classes) || (! $hasSchoolYear && ! $hasSemester)) {
|
||||
return $classes;
|
||||
}
|
||||
|
||||
return $this->classSectionModel
|
||||
->select('id, class_section_id, class_section_name')
|
||||
->orderBy('class_section_name', 'ASC')
|
||||
->findAll();
|
||||
}
|
||||
|
||||
private function priorYearStudentIds(string $selectedYear): array
|
||||
{
|
||||
$selectedStartYear = $this->getSchoolYearStartYear($selectedYear);
|
||||
if ($selectedStartYear === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$studentIds = [];
|
||||
foreach (['enrollments', 'student_class'] as $table) {
|
||||
if (! $this->db->tableExists($table) || ! $this->db->fieldExists('school_year', $table)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rows = $this->db->table($table)
|
||||
->select('student_id, school_year')
|
||||
->where('student_id IS NOT NULL', null, false)
|
||||
->where('school_year IS NOT NULL', null, false)
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$rowStartYear = $this->getSchoolYearStartYear((string) ($row['school_year'] ?? ''));
|
||||
$studentId = (int) ($row['student_id'] ?? 0);
|
||||
if ($studentId > 0 && $rowStartYear !== null && $rowStartYear < $selectedStartYear) {
|
||||
$studentIds[$studentId] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $studentIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show only newly registered students, with contact modal support.
|
||||
*
|
||||
|
||||
@@ -46,7 +46,7 @@ class AssignmentController extends BaseController
|
||||
|
||||
// Apply school year filter (default to current config) but avoid semester filtering so the full year is visible
|
||||
$selectedSemester = (string)($this->request->getGet('semester') ?? $this->semester ?? '');
|
||||
$year = (string)($this->request->getGet('school_year') ?? $this->schoolYear ?? '');
|
||||
$year = (string)($this->schoolYear ?? '');
|
||||
|
||||
$tcQ = $this->teacherClassModel;
|
||||
if ($year !== '') {
|
||||
@@ -189,8 +189,8 @@ class AssignmentController extends BaseController
|
||||
} catch (\Throwable $e) {
|
||||
// ignore fallback below
|
||||
}
|
||||
if (empty($schoolYearsList) && $this->schoolYear !== null && $this->schoolYear !== '') {
|
||||
$schoolYearsList[] = (string)$this->schoolYear;
|
||||
if ($this->schoolYear !== null && $this->schoolYear !== '' && !in_array((string)$this->schoolYear, $schoolYearsList, true)) {
|
||||
array_unshift($schoolYearsList, (string)$this->schoolYear);
|
||||
}
|
||||
|
||||
// Sort sections
|
||||
|
||||
@@ -64,11 +64,20 @@ class AttendanceController extends Controller
|
||||
$this->userRoleModel = new UserRoleModel();
|
||||
$this->semesterScoreService = service('semesterScoreService');
|
||||
$this->semester = $this->configModel->getConfig('semester');
|
||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||
$this->schoolYear = $this->currentSchoolYearName();
|
||||
$this->enableAttendance = $this->configModel->getConfig('enable_attendance');
|
||||
$this->semesterRangeService = new SemesterRangeService($this->configModel);
|
||||
}
|
||||
|
||||
private function currentSchoolYearName(): string
|
||||
{
|
||||
try {
|
||||
return service('schoolYearContext')->resolve(service('request'))->yearName();
|
||||
} catch (\Throwable $e) {
|
||||
return (string) ($this->configModel->getConfig('school_year') ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* GET: filter + grid for a single date/section.
|
||||
|
||||
@@ -70,28 +70,6 @@ class AttendanceTrackingController extends BaseController
|
||||
->findAll();
|
||||
$debugInfo['class_students'] = count($classStudents);
|
||||
|
||||
// Fallback: if the configured school_year has no class assignments, look up the latest term
|
||||
if (empty($classStudents)) {
|
||||
$latestTerm = $this->db->table('student_class')
|
||||
->select('school_year, semester')
|
||||
->orderBy('id', 'DESC')
|
||||
->limit(1)
|
||||
->get()
|
||||
->getRowArray();
|
||||
|
||||
if ($latestTerm && !empty($latestTerm['school_year'])) {
|
||||
$schoolYear = (string)$latestTerm['school_year'];
|
||||
if (!empty($latestTerm['semester'])) {
|
||||
$semester = (string)$latestTerm['semester'];
|
||||
}
|
||||
|
||||
$classStudents = $this->studentClassModel
|
||||
->active()
|
||||
->where('student_class.school_year', $schoolYear)
|
||||
->findAll();
|
||||
}
|
||||
}
|
||||
|
||||
// Gather candidate student identifiers (numeric IDs and code-like IDs)
|
||||
$studentIds = [];
|
||||
$studentCodes = [];
|
||||
|
||||
@@ -62,7 +62,7 @@ class EventController extends ResourceController
|
||||
$this->parentModel = new ParentModel();
|
||||
$this->emailService = new EmailService();
|
||||
|
||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||
$this->schoolYear = $this->currentSchoolYearName();
|
||||
$this->semester = $this->configModel->getConfig('semester');
|
||||
$this->categories = [
|
||||
'workshops',
|
||||
@@ -88,6 +88,15 @@ class EventController extends ResourceController
|
||||
return $this->eventChargesHasCreatedBy;
|
||||
}
|
||||
|
||||
private function currentSchoolYearName(): string
|
||||
{
|
||||
try {
|
||||
return service('schoolYearContext')->resolve(service('request'))->yearName();
|
||||
} catch (\Throwable $e) {
|
||||
return (string) ($this->configModel->getConfig('school_year') ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
private function eventChargesSupportsWaiverSigned(): bool
|
||||
{
|
||||
if ($this->eventChargesHasWaiverSigned !== null) {
|
||||
|
||||
@@ -21,6 +21,60 @@ class FinancialController extends BaseController
|
||||
return $this->request->isAJAX() || str_contains($accept, 'application/json');
|
||||
}
|
||||
|
||||
private function selectedSchoolYear(): string
|
||||
{
|
||||
$requested = trim((string) ($this->request->getGet('school_year') ?? ''));
|
||||
if ($requested !== '') {
|
||||
return $requested;
|
||||
}
|
||||
|
||||
return $this->currentSchoolYearName();
|
||||
}
|
||||
|
||||
private function financialSchoolYearOptions(?string $selectedYear = null): array
|
||||
{
|
||||
$schoolYears = [];
|
||||
$addYear = static function (mixed $value) use (&$schoolYears): void {
|
||||
$year = trim((string) $value);
|
||||
if ($year !== '' && ! in_array($year, $schoolYears, true)) {
|
||||
$schoolYears[] = $year;
|
||||
}
|
||||
};
|
||||
|
||||
$addYear($selectedYear);
|
||||
$addYear($this->currentSchoolYearName());
|
||||
|
||||
try {
|
||||
$db = \Config\Database::connect();
|
||||
|
||||
if ($db->tableExists('school_years')) {
|
||||
$rows = $db->table('school_years')
|
||||
->select('name')
|
||||
->orderBy('name', 'DESC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
foreach ($rows as $row) {
|
||||
$addYear($row['name'] ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
if ($db->tableExists('invoices') && $db->fieldExists('school_year', 'invoices')) {
|
||||
$rows = $db->table('invoices')
|
||||
->select('DISTINCT school_year', false)
|
||||
->where('school_year IS NOT NULL', null, false)
|
||||
->orderBy('school_year', 'DESC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
foreach ($rows as $row) {
|
||||
$addYear($row['school_year'] ?? '');
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
|
||||
return $schoolYears;
|
||||
}
|
||||
|
||||
public function financialReport()
|
||||
{
|
||||
$invoiceModel = new InvoiceModel();
|
||||
@@ -32,7 +86,7 @@ public function financialReport()
|
||||
|
||||
$dateFrom = $this->request->getGet('date_from');
|
||||
$dateTo = $this->request->getGet('date_to');
|
||||
$schoolYear = $this->request->getGet('school_year');
|
||||
$schoolYear = $this->selectedSchoolYear();
|
||||
|
||||
// === Apply filters to models (invoice/refund/discount/expense/reimb) ===
|
||||
if ($schoolYear) {
|
||||
@@ -235,22 +289,7 @@ public function financialReport()
|
||||
->findAll();
|
||||
|
||||
// --- School year options (for detailed view & JSON) ---
|
||||
$schoolYears = [];
|
||||
try {
|
||||
$db = \Config\Database::connect();
|
||||
$rows = $db->table('invoices')
|
||||
->select('DISTINCT school_year', false)
|
||||
->where('school_year IS NOT NULL', null, false)
|
||||
->orderBy('school_year', 'DESC')
|
||||
->get()->getResultArray();
|
||||
foreach ($rows as $r) {
|
||||
$val = (string)($r['school_year'] ?? '');
|
||||
if ($val !== '' && !in_array($val, $schoolYears, true)) $schoolYears[] = $val;
|
||||
}
|
||||
} catch (\Throwable $e) {}
|
||||
if (empty($schoolYears) && !empty($schoolYear)) {
|
||||
$schoolYears[] = (string)$schoolYear;
|
||||
}
|
||||
$schoolYears = $this->financialSchoolYearOptions($schoolYear);
|
||||
|
||||
$eventFeesTotal = $this->getEventFeesTotal($schoolYear, $dateFrom, $dateTo);
|
||||
|
||||
@@ -298,28 +337,11 @@ public function financialReport()
|
||||
{
|
||||
$dateFrom = $this->request->getGet('date_from');
|
||||
$dateTo = $this->request->getGet('date_to');
|
||||
$schoolYear = $this->request->getGet('school_year');
|
||||
$schoolYear = $this->selectedSchoolYear();
|
||||
|
||||
$data = $this->getFinancialSummary($dateFrom, $dateTo, $schoolYear);
|
||||
|
||||
// Build school year options from invoices table (fallback to configured year)
|
||||
$schoolYears = [];
|
||||
try {
|
||||
$db = \Config\Database::connect();
|
||||
$rows = $db->table('invoices')
|
||||
->select('DISTINCT school_year', false)
|
||||
->where('school_year IS NOT NULL', null, false)
|
||||
->orderBy('school_year', 'DESC')
|
||||
->get()->getResultArray();
|
||||
foreach ($rows as $r) {
|
||||
$val = (string)($r['school_year'] ?? '');
|
||||
if ($val !== '' && !in_array($val, $schoolYears, true)) $schoolYears[] = $val;
|
||||
}
|
||||
} catch (\Throwable $e) {}
|
||||
if (empty($schoolYears) && !empty($data['schoolYear'])) {
|
||||
$schoolYears[] = (string)$data['schoolYear'];
|
||||
}
|
||||
$data['schoolYears'] = $schoolYears;
|
||||
$data['schoolYears'] = $this->financialSchoolYearOptions((string)($data['schoolYear'] ?? $schoolYear));
|
||||
if ($this->wantsJson() || strtolower((string)($this->request->getGet('format') ?? '')) === 'json') {
|
||||
return $this->response->setJSON(['ok' => true] + $data + [
|
||||
'csrf_token' => csrf_token(),
|
||||
@@ -524,7 +546,7 @@ public function financialReport()
|
||||
private function getFinancialSummaryDetailSections(?string $dateFrom = null, ?string $dateTo = null, ?string $schoolYear = null): array
|
||||
{
|
||||
$configModel = new \App\Models\ConfigurationModel();
|
||||
$schoolYear = $schoolYear ?: $configModel->getConfig('school_year');
|
||||
$schoolYear = $schoolYear ?: $this->currentSchoolYearName();
|
||||
|
||||
$derivedDateFrom = null;
|
||||
$derivedDateTo = null;
|
||||
@@ -1612,7 +1634,7 @@ public function financialReport()
|
||||
$additionalModel = new \App\Models\AdditionalChargeModel();
|
||||
|
||||
// Allow override via parameter; fallback to configured year
|
||||
$schoolYear = $schoolYear ?: $configModel->getConfig('school_year');
|
||||
$schoolYear = $schoolYear ?: $this->currentSchoolYearName();
|
||||
$derivedDateFrom = null;
|
||||
$derivedDateTo = null;
|
||||
if (!empty($schoolYear) && empty($dateFrom) && empty($dateTo)) {
|
||||
@@ -2068,7 +2090,7 @@ public function financialReport()
|
||||
$configModel = new \App\Models\ConfigurationModel();
|
||||
$schoolYear = trim((string)($this->request->getGet('school_year') ?? ''));
|
||||
if ($schoolYear === '') {
|
||||
$schoolYear = (string) ($configModel->getConfig('school_year') ?? date('Y'));
|
||||
$schoolYear = $this->currentSchoolYearName((string) ($configModel->getConfig('school_year') ?? date('Y')));
|
||||
}
|
||||
|
||||
// Build school year options from invoices
|
||||
|
||||
@@ -35,8 +35,11 @@ class FlagController extends Controller
|
||||
$grades = [];
|
||||
|
||||
// Retrieve flags and class sections that currently have active students
|
||||
$flags = $currentFlagModel->findAll();
|
||||
$schoolYear = $configModel->getConfig('school_year');
|
||||
if ((string)$schoolYear !== '' && $this->db->fieldExists('school_year', 'current_flag')) {
|
||||
$currentFlagModel->where('school_year', (string)$schoolYear);
|
||||
}
|
||||
$flags = $currentFlagModel->findAll();
|
||||
$studentCounts = $studentClassModel->getStudentCountsBySection($schoolYear);
|
||||
$classSections = [];
|
||||
|
||||
|
||||
@@ -67,17 +67,17 @@ class InvoiceController extends ResourceController
|
||||
$this->chargesModel = new EventChargesModel();
|
||||
$this->discountUsageModel = new DiscountUsageModel();
|
||||
$this->refundModel = new RefundModel();
|
||||
$this->db = \Config\Database::connect();
|
||||
$this->request = \Config\Services::request();
|
||||
|
||||
$this->gradeFee = $this->configModel->getConfig('grade_fee');
|
||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||
$this->schoolYear = $this->currentSchoolYearName();
|
||||
$this->semester = $this->configModel->getConfig('semester');
|
||||
$this->dueDate = $this->configModel->getConfig('due_date');
|
||||
$this->firstStudentFee = (float) ($this->configModel->getConfig('first_student_fee') ?? 350);
|
||||
$this->secondStudentFee = (float) ($this->configModel->getConfig('second_student_fee') ?? 200);
|
||||
$this->youthFee = (float) ($this->configModel->getConfig('youth_fee') ?? 200);
|
||||
$this->refundDeadline = date('Y-m-d', strtotime($this->configModel->getConfig('refund_deadline')));
|
||||
$this->db = \Config\Database::connect();
|
||||
$this->request = \Config\Services::request();
|
||||
}
|
||||
|
||||
public function index($schoolYear = null)
|
||||
@@ -213,6 +213,15 @@ class InvoiceController extends ResourceController
|
||||
]);
|
||||
}
|
||||
|
||||
private function currentSchoolYearName(): string
|
||||
{
|
||||
try {
|
||||
return service('schoolYearContext')->resolve($this->request)->yearName();
|
||||
} catch (\Throwable $e) {
|
||||
return (string) ($this->configModel->getConfig('school_year') ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API: Invoice management composite data (used by invoice_management view)
|
||||
* Returns the same structure previously rendered server-side in index().
|
||||
@@ -221,7 +230,7 @@ class InvoiceController extends ResourceController
|
||||
{
|
||||
$schoolYear = trim((string)($this->request->getGet('schoolYear') ?? $this->request->getGet('year') ?? ''));
|
||||
if ($schoolYear === '') {
|
||||
$schoolYear = $this->schoolYear;
|
||||
$schoolYear = $this->currentSchoolYearName();
|
||||
}
|
||||
|
||||
$invoiceData = [];
|
||||
|
||||
@@ -21,7 +21,7 @@ class LateSlipLogsController extends BaseController
|
||||
{
|
||||
$req = $this->request;
|
||||
|
||||
$defaultYear = (string) ($this->configModel->getConfig('school_year') ?? '');
|
||||
$defaultYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
|
||||
$defaultSem = (string) ($this->configModel->getConfig('semester') ?? '');
|
||||
$schoolYear = trim((string) ($req->getGet('school_year') ?? $defaultYear));
|
||||
$semester = trim((string) ($req->getGet('semester') ?? $defaultSem));
|
||||
|
||||
@@ -149,6 +149,11 @@ class NotificationsController extends BaseController
|
||||
$targetGroup = null;
|
||||
}
|
||||
|
||||
$schoolYear = (string) ((new \App\Models\ConfigurationModel())->getConfig('school_year') ?? '');
|
||||
if ($schoolYear !== '' && db_connect()->fieldExists('school_year', 'notifications')) {
|
||||
$this->notificationModel->where('school_year', $schoolYear);
|
||||
}
|
||||
|
||||
$rows = $this->notificationModel->getActiveNotifications($targetGroup);
|
||||
if (!is_array($rows)) {
|
||||
$rows = [];
|
||||
|
||||
@@ -164,8 +164,7 @@ class ParentController extends BaseController
|
||||
return redirect()->back()->with('error', 'Parent session not found.');
|
||||
}
|
||||
|
||||
// Get current school year from config
|
||||
$currentSchoolYear = $this->configModel->getConfig('school_year');
|
||||
$currentSchoolYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
|
||||
|
||||
// Get selected school year (no semester filter on parent view)
|
||||
$selectedYear = $this->request->getVar('school_year') ?? $currentSchoolYear;
|
||||
@@ -1784,7 +1783,7 @@ $existing = $this->studentModel
|
||||
|
||||
public function parentEventPage()
|
||||
{
|
||||
$schoolYear = session()->get('school_year');
|
||||
$schoolYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
|
||||
$semester = session()->get('semester');
|
||||
$parentId = session()->get('user_id');
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ class PaymentController extends ResourceController
|
||||
$this->enrollmentModel = new EnrollmentModel();
|
||||
$this->studentModel = new StudentModel();
|
||||
$this->studentClassModel = new StudentClassModel();
|
||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||
$this->schoolYear = $this->currentSchoolYearName();
|
||||
$this->semester = $this->configModel->getConfig('semester'); //installment_date
|
||||
$this->installmentDate = $this->configModel->getConfig('installment_date');
|
||||
$this->discountUsageModel = new DiscountUsageModel();
|
||||
@@ -137,11 +137,20 @@ class PaymentController extends ResourceController
|
||||
|
||||
private function getSelectedPaymentHistoryYear(): ?string
|
||||
{
|
||||
$selectedYear = $this->request->getGet('school_year') ?? $this->schoolYear;
|
||||
$selectedYear = $this->request->getGet('school_year') ?? $this->currentSchoolYearName();
|
||||
|
||||
return $selectedYear !== '' ? $selectedYear : null;
|
||||
}
|
||||
|
||||
private function currentSchoolYearName(): string
|
||||
{
|
||||
try {
|
||||
return service('schoolYearContext')->resolve($this->request)->yearName();
|
||||
} catch (\Throwable $e) {
|
||||
return (string) ($this->configModel->getConfig('school_year') ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
// View: Create a new payment plan
|
||||
public function create()
|
||||
{
|
||||
@@ -204,7 +213,7 @@ class PaymentController extends ResourceController
|
||||
public function eventChargesShow()
|
||||
{
|
||||
$parents = $this->userModel->getParents();
|
||||
$school_Year = $this->request->getGet('school_year') ?? $this->schoolYear;
|
||||
$school_Year = $this->request->getGet('school_year') ?? $this->currentSchoolYearName();
|
||||
$seme_ster = $this->request->getGet('semester') ?? $this->semester;
|
||||
|
||||
// Join with users and students for full info
|
||||
|
||||
@@ -1235,6 +1235,10 @@ public function updateBatchAssignment()
|
||||
$status = $this->request->getGet('status') ?: null;
|
||||
$filterYear = $this->request->getGet('school_year') ?: $this->schoolYear;
|
||||
$userId = $this->request->getGet('user_id') ?: null;
|
||||
$hasBatchSchoolYear = $this->db->fieldExists('school_year', 'reimbursement_batches');
|
||||
$hasExpenseSchoolYear = $this->db->fieldExists('school_year', 'expenses');
|
||||
$hasExpenseSemester = $this->db->fieldExists('semester', 'expenses');
|
||||
$hasReimbursementSchoolYear = $this->db->fieldExists('school_year', 'reimbursements');
|
||||
|
||||
$filters = [
|
||||
'school_year' => $filterYear,
|
||||
@@ -1251,13 +1255,11 @@ public function updateBatchAssignment()
|
||||
// Build closed batch summaries/details (all closed batches)
|
||||
$batchSummaries = [];
|
||||
$batchDetails = [];
|
||||
$batchQuery = $this->db->table('reimbursement_batches b')
|
||||
->select("
|
||||
$batchSelect = "
|
||||
b.id AS batch_id,
|
||||
b.title AS batch_title,
|
||||
b.yearly_batch_number,
|
||||
b.closed_at,
|
||||
b.school_year AS batch_school_year,
|
||||
e.id AS expense_id,
|
||||
e.amount AS expense_amount,
|
||||
e.description,
|
||||
@@ -1268,7 +1270,13 @@ public function updateBatchAssignment()
|
||||
u.firstname AS purchaser_firstname,
|
||||
u.lastname AS purchaser_lastname,
|
||||
r.amount AS reimb_amount
|
||||
")
|
||||
";
|
||||
if ($hasBatchSchoolYear) {
|
||||
$batchSelect .= ', b.school_year AS batch_school_year';
|
||||
}
|
||||
|
||||
$batchQuery = $this->db->table('reimbursement_batches b')
|
||||
->select($batchSelect)
|
||||
->join('reimbursement_batch_items bi', 'bi.batch_id = b.id', 'inner')
|
||||
->join('expenses e', 'e.id = bi.expense_id', 'inner')
|
||||
->join('users u', 'u.id = e.purchased_by', 'left')
|
||||
@@ -1277,10 +1285,16 @@ public function updateBatchAssignment()
|
||||
->where('bi.unassigned_at IS NULL', null, false);
|
||||
|
||||
if (!empty($filterYear)) {
|
||||
if ($hasBatchSchoolYear && $hasExpenseSchoolYear) {
|
||||
$batchQuery->groupStart()
|
||||
->where('b.school_year', $filterYear)
|
||||
->orWhere('e.school_year', $filterYear)
|
||||
->groupEnd();
|
||||
} elseif ($hasBatchSchoolYear) {
|
||||
$batchQuery->where('b.school_year', $filterYear);
|
||||
} elseif ($hasExpenseSchoolYear) {
|
||||
$batchQuery->where('e.school_year', $filterYear);
|
||||
}
|
||||
}
|
||||
if (!empty($userId)) {
|
||||
$batchQuery->where('e.purchased_by', $userId);
|
||||
@@ -1401,27 +1415,33 @@ public function updateBatchAssignment()
|
||||
'items' => [],
|
||||
'checks' => [],
|
||||
];
|
||||
$donationQuery = $this->db->table('expenses e')
|
||||
->select('
|
||||
$donationSelect = '
|
||||
e.id AS expense_id,
|
||||
e.amount AS expense_amount,
|
||||
e.description,
|
||||
e.retailor,
|
||||
e.receipt_path AS expense_receipt,
|
||||
e.purchased_by,
|
||||
e.school_year,
|
||||
e.semester,
|
||||
e.status,
|
||||
u.firstname AS purchaser_firstname,
|
||||
u.lastname AS purchaser_lastname
|
||||
')
|
||||
';
|
||||
if ($hasExpenseSchoolYear) {
|
||||
$donationSelect .= ', e.school_year';
|
||||
}
|
||||
if ($hasExpenseSemester) {
|
||||
$donationSelect .= ', e.semester';
|
||||
}
|
||||
|
||||
$donationQuery = $this->db->table('expenses e')
|
||||
->select($donationSelect)
|
||||
->join('users u', 'u.id = e.purchased_by', 'left')
|
||||
->where('e.category', 'Donation');
|
||||
|
||||
if (!empty($filterYear)) {
|
||||
if (!empty($filterYear) && $hasExpenseSchoolYear) {
|
||||
$donationQuery->where('e.school_year', $filterYear);
|
||||
}
|
||||
if (!empty($semester)) {
|
||||
if (!empty($semester) && $hasExpenseSemester) {
|
||||
$donationQuery->where('e.semester', $semester);
|
||||
}
|
||||
if (!empty($userId)) {
|
||||
@@ -1488,6 +1508,8 @@ public function updateBatchAssignment()
|
||||
|
||||
$users = $this->recipientOptions();
|
||||
|
||||
$schoolYears = [];
|
||||
if ($hasReimbursementSchoolYear) {
|
||||
$years = $this->db->table('reimbursements')
|
||||
->select('school_year')
|
||||
->distinct()
|
||||
@@ -1495,6 +1517,16 @@ public function updateBatchAssignment()
|
||||
->get()
|
||||
->getResultArray();
|
||||
$schoolYears = array_column($years, 'school_year');
|
||||
} elseif ($hasExpenseSchoolYear) {
|
||||
$years = $this->db->table('expenses')
|
||||
->select('school_year')
|
||||
->distinct()
|
||||
->where('school_year IS NOT NULL', null, false)
|
||||
->orderBy('school_year', 'DESC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
$schoolYears = array_column($years, 'school_year');
|
||||
}
|
||||
|
||||
// Add school years from fallback expenses (closed batches without reimbursements)
|
||||
if (!empty($fallbackRows)) {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use CodeIgniter\HTTP\RedirectResponse;
|
||||
|
||||
final class SchoolYearSelectionController extends BaseController
|
||||
{
|
||||
public function select(): RedirectResponse
|
||||
{
|
||||
$schoolYearId = (int) ($this->request->getPost('school_year_id') ?? 0);
|
||||
$returnTo = (string) ($this->request->getPost('return_to') ?? '');
|
||||
|
||||
if ($schoolYearId <= 0) {
|
||||
return redirect()->to($this->safeReturnTo($returnTo))
|
||||
->with('error', 'Please select a valid school year.');
|
||||
}
|
||||
|
||||
try {
|
||||
service('schoolYearContext')->select($schoolYearId);
|
||||
return redirect()->to($this->safeReturnTo($returnTo));
|
||||
} catch (\Throwable $e) {
|
||||
log_message('warning', 'School-year selection failed: {message}', [
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return redirect()->to($this->safeReturnTo($returnTo))
|
||||
->with('error', 'The selected school year is not available.');
|
||||
}
|
||||
}
|
||||
|
||||
public function reset(): RedirectResponse
|
||||
{
|
||||
service('schoolYearContext')->clearSelection();
|
||||
|
||||
return redirect()->to($this->safeReturnTo((string) ($this->request->getPost('return_to') ?? '')));
|
||||
}
|
||||
|
||||
private function safeReturnTo(string $returnTo): string
|
||||
{
|
||||
$fallback = $this->dashboardRoute();
|
||||
$returnTo = trim($returnTo);
|
||||
|
||||
if ($returnTo === '') {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
$parts = parse_url($returnTo);
|
||||
if ($parts === false) {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
if (isset($parts['host']) && strcasecmp((string) $parts['host'], (string) $this->request->getUri()->getHost()) !== 0) {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
$path = '/' . ltrim((string) ($parts['path'] ?? ''), '/');
|
||||
if ($path === '/' || str_starts_with($path, '//')) {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
$query = [];
|
||||
if (! empty($parts['query'])) {
|
||||
parse_str((string) $parts['query'], $query);
|
||||
foreach (['school_year_id', 'school_year', 'schoolYear', 'year'] as $key) {
|
||||
unset($query[$key]);
|
||||
}
|
||||
}
|
||||
|
||||
$cleanQuery = http_build_query($query);
|
||||
|
||||
return $path . ($cleanQuery !== '' ? '?' . $cleanQuery : '');
|
||||
}
|
||||
|
||||
private function dashboardRoute(): string
|
||||
{
|
||||
try {
|
||||
return service('roleService')->bestDashboardRouteFor((array) session()->get('roles'));
|
||||
} catch (\Throwable) {
|
||||
return '/landing_page/guest_dashboard';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,11 @@
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Models\ConfigurationModel;
|
||||
use App\Models\ClassSectionModel;
|
||||
|
||||
use CodeIgniter\Controller;
|
||||
|
||||
class ScorePredictor extends Controller
|
||||
class ScorePredictor extends BaseController
|
||||
{
|
||||
protected $db;
|
||||
protected $configModel;
|
||||
@@ -60,17 +59,30 @@ class ScorePredictor extends Controller
|
||||
public function combinedReport()
|
||||
{
|
||||
$request = service('request');
|
||||
$currentSchoolYear = $this->schoolYear;
|
||||
$currentSchoolYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
|
||||
|
||||
$selectedYear = $request->getVar('school_year') ?? $currentSchoolYear;
|
||||
$classSectionId = $request->getVar('class_section_id');
|
||||
$yearEsc = $this->db->escape($selectedYear);
|
||||
$acceptedEnrollmentStatuses = ['payment pending', 'enrolled'];
|
||||
// Only show class sections that have at least one student in the selected year
|
||||
$classSections = $this->db->table('classSection cs')
|
||||
->select('cs.*')
|
||||
->distinct()
|
||||
->join('student_class sc', 'sc.class_section_id = cs.class_section_id', 'inner')
|
||||
->join('enrollments e', 'e.student_id = sc.student_id AND e.school_year = ' . $yearEsc, 'inner')
|
||||
->where('sc.school_year', $selectedYear)
|
||||
->whereIn('e.enrollment_status', $acceptedEnrollmentStatuses)
|
||||
->where('e.admission_status', 'accepted')
|
||||
->groupStart()
|
||||
->where('e.is_withdrawn', 0)
|
||||
->orWhere('e.is_withdrawn', null)
|
||||
->groupEnd()
|
||||
->groupStart()
|
||||
->where('sc.is_event_only', 0)
|
||||
->orWhere('sc.is_event_only', null)
|
||||
->groupEnd()
|
||||
->notLike('cs.class_section_name', 'KG', 'after')
|
||||
->groupBy('cs.id')
|
||||
->orderBy('cs.class_section_name', 'ASC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
@@ -85,12 +97,23 @@ class ScorePredictor extends Controller
|
||||
MAX(spring.semester_score) as spring_score');
|
||||
// Reduce duplication from restored students while keeping a stable class section.
|
||||
$builder->select('MAX(sc.class_section_id) as class_section_id');
|
||||
$yearEsc = $this->db->escape($selectedYear);
|
||||
$builder->join('student_class sc', 'sc.student_id = s.id AND sc.school_year = ' . $yearEsc, 'left');
|
||||
$builder->join('student_class sc', 'sc.student_id = s.id AND sc.school_year = ' . $yearEsc, 'inner');
|
||||
$builder->join('enrollments e', 'e.student_id = s.id AND e.school_year = ' . $yearEsc, 'inner');
|
||||
$builder->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left');
|
||||
$builder->join('semester_scores fall', 'fall.student_id = s.id AND fall.semester = "fall" AND fall.school_year = "' . $selectedYear . '"', 'left');
|
||||
$builder->join('semester_scores spring', 'spring.student_id = s.id AND spring.semester = "spring" AND spring.school_year = "' . $selectedYear . '"', 'left');
|
||||
$builder->join('semester_scores fall', 'fall.student_id = s.id AND fall.semester = "fall" AND fall.school_year = ' . $yearEsc, 'left');
|
||||
$builder->join('semester_scores spring', 'spring.student_id = s.id AND spring.semester = "spring" AND spring.school_year = ' . $yearEsc, 'left');
|
||||
$builder->where('s.is_active', 1);
|
||||
$builder->where('sc.class_section_id IS NOT NULL', null, false);
|
||||
$builder->whereIn('e.enrollment_status', $acceptedEnrollmentStatuses);
|
||||
$builder->where('e.admission_status', 'accepted');
|
||||
$builder->groupStart()
|
||||
->where('e.is_withdrawn', 0)
|
||||
->orWhere('e.is_withdrawn', null)
|
||||
->groupEnd();
|
||||
$builder->groupStart()
|
||||
->where('sc.is_event_only', 0)
|
||||
->orWhere('sc.is_event_only', null)
|
||||
->groupEnd();
|
||||
$builder->groupStart()
|
||||
->where('cs.class_section_name IS NULL')
|
||||
->orNotLike('cs.class_section_name', 'KG', 'after')
|
||||
@@ -106,11 +129,22 @@ class ScorePredictor extends Controller
|
||||
// Stats for spring
|
||||
$springStatsQuery = $this->db->table('semester_scores')
|
||||
->select('AVG(semester_scores.semester_score) as mean, STDDEV(semester_scores.semester_score) as std')
|
||||
->join('student_class', 'student_class.student_id = semester_scores.student_id')
|
||||
->join('student_class', 'student_class.student_id = semester_scores.student_id AND student_class.school_year = ' . $yearEsc)
|
||||
->join('enrollments e', 'e.student_id = semester_scores.student_id AND e.school_year = ' . $yearEsc, 'inner')
|
||||
->join('classSection cs', 'cs.class_section_id = student_class.class_section_id', 'left')
|
||||
->where('semester_scores.semester', 'spring')
|
||||
->where('semester_scores.school_year', $selectedYear)
|
||||
->where('student_class.school_year', $selectedYear)
|
||||
->where('student_class.class_section_id IS NOT NULL', null, false)
|
||||
->whereIn('e.enrollment_status', $acceptedEnrollmentStatuses)
|
||||
->where('e.admission_status', 'accepted')
|
||||
->groupStart()
|
||||
->where('e.is_withdrawn', 0)
|
||||
->orWhere('e.is_withdrawn', null)
|
||||
->groupEnd()
|
||||
->groupStart()
|
||||
->where('student_class.is_event_only', 0)
|
||||
->orWhere('student_class.is_event_only', null)
|
||||
->groupEnd()
|
||||
->groupStart()
|
||||
->where('cs.class_section_name IS NULL')
|
||||
->orNotLike('cs.class_section_name', 'KG', 'after')
|
||||
|
||||
@@ -386,21 +386,8 @@ class StudentController extends BaseController
|
||||
{
|
||||
$schoolYear = (string)($this->schoolYear ?? '');
|
||||
|
||||
$activeStudents = $this->studentModel
|
||||
->select('id, school_id, firstname, lastname, gender, age')
|
||||
->where('is_active', 1)
|
||||
->orderBy('lastname', 'ASC')
|
||||
->orderBy('firstname', 'ASC')
|
||||
->findAll();
|
||||
|
||||
$removedStudents = $this->studentModel
|
||||
->select('id, school_id, firstname, lastname, gender, age')
|
||||
->where('is_active', 0)
|
||||
->orderBy('lastname', 'ASC')
|
||||
->orderBy('firstname', 'ASC')
|
||||
->findAll();
|
||||
|
||||
$classMap = [];
|
||||
$activeYearStudentIds = [];
|
||||
$classQuery = $this->db->table('student_class sc')
|
||||
->select('sc.student_id, cs.class_section_name')
|
||||
->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left');
|
||||
@@ -411,11 +398,35 @@ class StudentController extends BaseController
|
||||
|
||||
foreach ($classRows as $row) {
|
||||
$sid = (int)($row['student_id'] ?? 0);
|
||||
if ($sid > 0) {
|
||||
$activeYearStudentIds[$sid] = true;
|
||||
}
|
||||
|
||||
$name = trim((string)($row['class_section_name'] ?? ''));
|
||||
if ($sid <= 0 || $name === '') continue;
|
||||
$classMap[$sid][] = $name;
|
||||
}
|
||||
|
||||
$students = $this->studentModel
|
||||
->select('id, school_id, firstname, lastname, gender, age, is_active')
|
||||
->orderBy('lastname', 'ASC')
|
||||
->orderBy('firstname', 'ASC')
|
||||
->findAll();
|
||||
|
||||
$activeStudents = [];
|
||||
$removedStudents = [];
|
||||
foreach ($students as $student) {
|
||||
$studentId = (int)($student['id'] ?? 0);
|
||||
$isGloballyActive = (int)($student['is_active'] ?? 0) === 1;
|
||||
$hasSelectedYearClass = $studentId > 0 && isset($activeYearStudentIds[$studentId]);
|
||||
|
||||
if ($isGloballyActive && $hasSelectedYearClass) {
|
||||
$activeStudents[] = $student;
|
||||
} else {
|
||||
$removedStudents[] = $student;
|
||||
}
|
||||
}
|
||||
|
||||
$attachClassNames = static function (array $students) use ($classMap): array {
|
||||
foreach ($students as &$student) {
|
||||
$sid = (int)($student['id'] ?? 0);
|
||||
@@ -432,6 +443,7 @@ class StudentController extends BaseController
|
||||
'active_students' => $attachClassNames($activeStudents),
|
||||
'removed_students' => $attachClassNames($removedStudents),
|
||||
'school_year' => $schoolYear,
|
||||
'active_school_year' => (string)($this->schoolYear ?? ''),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1671,8 +1683,7 @@ class StudentController extends BaseController
|
||||
->findAll();
|
||||
}
|
||||
} elseif (in_array($role, ['teacher', 'teacher_assistant', 'teacher_dashboard'], true)) {
|
||||
$configModel = new \App\Models\ConfigurationModel();
|
||||
$schoolYear = session()->get('school_year') ?? $configModel->getConfig('school_year');
|
||||
$schoolYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
|
||||
$classSectionId = (int)(session()->get('class_section_id') ?? 0);
|
||||
|
||||
if ($classSectionId > 0 && !empty($schoolYear)) {
|
||||
|
||||
@@ -20,7 +20,7 @@ class TrophyController extends BaseController
|
||||
{
|
||||
$db = \Config\Database::connect();
|
||||
|
||||
$currentYear = $this->configModel->getConfig('school_year') ?? '';
|
||||
$currentYear = $this->currentSchoolYearName();
|
||||
$selectedYear = $this->request->getGet('school_year') ?? $currentYear;
|
||||
|
||||
$percentile = (float) ($this->request->getGet('percentile') ?? 75);
|
||||
@@ -166,7 +166,7 @@ class TrophyController extends BaseController
|
||||
{
|
||||
$db = \Config\Database::connect();
|
||||
|
||||
$currentYear = $this->configModel->getConfig('school_year') ?? '';
|
||||
$currentYear = $this->currentSchoolYearName();
|
||||
$selectedYear = $this->request->getGet('school_year') ?? $currentYear;
|
||||
$percentile = (float) ($this->request->getGet('percentile') ?? 75);
|
||||
$percentile = max(1.0, min(99.0, $percentile));
|
||||
@@ -283,7 +283,7 @@ class TrophyController extends BaseController
|
||||
{
|
||||
$db = \Config\Database::connect();
|
||||
|
||||
$currentYear = $this->configModel->getConfig('school_year') ?? '';
|
||||
$currentYear = $this->currentSchoolYearName();
|
||||
$selectedYear = $this->request->getGet('school_year') ?? $currentYear;
|
||||
$percentile = (float) ($this->request->getGet('percentile') ?? 75);
|
||||
$percentile = max(1.0, min(99.0, $percentile));
|
||||
|
||||
@@ -961,12 +961,18 @@ class UserController extends BaseController
|
||||
$perPage = max(1, min($perPage, 200));
|
||||
$page = max(1, $page);
|
||||
|
||||
$totalActivities = (int) $this->loginActivityModel->countAll();
|
||||
$activities = $this->loginActivityModel
|
||||
$schoolYear = (string) ((new \App\Models\ConfigurationModel())->getConfig('school_year') ?? '');
|
||||
$activityModel = $this->loginActivityModel;
|
||||
if ($schoolYear !== '' && db_connect()->fieldExists('school_year', 'login_activity')) {
|
||||
$activityModel->where('school_year', $schoolYear);
|
||||
}
|
||||
|
||||
$totalActivities = (int) $activityModel->countAllResults(false);
|
||||
$activities = $activityModel
|
||||
->orderBy('login_time', 'DESC')
|
||||
->paginate($perPage, 'loginActivity', $page) ?? [];
|
||||
|
||||
$pager = $this->loginActivityModel->pager;
|
||||
$pager = $activityModel->pager;
|
||||
$currentPage = $pager ? (int) $pager->getCurrentPage('loginActivity') : $page;
|
||||
$pageCount = $pager ? (int) $pager->getPageCount('loginActivity') : (int) max(1, ceil($totalActivities / $perPage));
|
||||
|
||||
|
||||
@@ -103,24 +103,14 @@ class WhatsappController extends BaseController
|
||||
$sectionName = $row['class_section_name'] ?? ('Section ' . $sectionId);
|
||||
}
|
||||
|
||||
$existing = $this->linkModel->where([
|
||||
'class_section_id' => $sectionId,
|
||||
'school_year' => $this->schoolYear,
|
||||
])->first();
|
||||
|
||||
$payload = [
|
||||
'class_section_id' => $sectionId,
|
||||
'class_section_name' => $sectionName,
|
||||
'school_year' => $this->schoolYear,
|
||||
'invite_link' => trim($inviteLink),
|
||||
'active' => $active,
|
||||
];
|
||||
|
||||
if ($existing) {
|
||||
$this->linkModel->update($existing['id'], $payload);
|
||||
} else {
|
||||
$this->linkModel->insert($payload);
|
||||
}
|
||||
$this->linkModel->upsertLinkForSection(
|
||||
$sectionId,
|
||||
$sectionName,
|
||||
(string) $this->schoolYear,
|
||||
'',
|
||||
$inviteLink,
|
||||
$active === 1
|
||||
);
|
||||
|
||||
return redirect()->back()->with('success', 'WhatsApp group link saved.');
|
||||
}
|
||||
|
||||
@@ -13,6 +13,11 @@ class WinnersController extends BaseController
|
||||
public function competitionIndex()
|
||||
{
|
||||
$competitionModel = new CompetitionModel();
|
||||
$schoolYear = $this->currentSchoolYearName();
|
||||
|
||||
if ($schoolYear !== '') {
|
||||
$competitionModel->where('school_year', $schoolYear);
|
||||
}
|
||||
|
||||
$competitions = $competitionModel
|
||||
->where('is_published', 1)
|
||||
@@ -21,6 +26,7 @@ class WinnersController extends BaseController
|
||||
|
||||
return view('winners/competitions/index', [
|
||||
'competitions' => $competitions,
|
||||
'schoolYear' => $schoolYear,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -31,7 +37,14 @@ class WinnersController extends BaseController
|
||||
$classSectionModel = new ClassSectionModel();
|
||||
$classWinnerModel = new CompetitionClassWinnerModel();
|
||||
|
||||
$competition = $competitionModel->where('is_published', 1)->find($id);
|
||||
$schoolYear = $this->currentSchoolYearName();
|
||||
|
||||
$competitionModel->where('is_published', 1);
|
||||
if ($schoolYear !== '') {
|
||||
$competitionModel->where('school_year', $schoolYear);
|
||||
}
|
||||
|
||||
$competition = $competitionModel->find($id);
|
||||
if (!$competition) {
|
||||
return redirect()->to('/winners/competitions');
|
||||
}
|
||||
@@ -81,6 +94,7 @@ class WinnersController extends BaseController
|
||||
'winnersByClass' => $winnersByClass,
|
||||
'sectionMap' => $sectionMap,
|
||||
'questionCounts' => $questionCounts,
|
||||
'schoolYear' => $schoolYear,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exceptions\SchoolYear;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class InvalidSchoolYearSelectionException extends RuntimeException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exceptions\SchoolYear;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class SchoolYearConfigurationException extends RuntimeException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exceptions\SchoolYear;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class SchoolYearNotFoundException extends RuntimeException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exceptions\SchoolYear;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class SchoolYearWriteConflictException extends RuntimeException
|
||||
{
|
||||
}
|
||||
@@ -6,6 +6,9 @@ use CodeIgniter\Filters\FilterInterface;
|
||||
use CodeIgniter\HTTP\IncomingRequest;
|
||||
use CodeIgniter\HTTP\RequestInterface;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
use App\Exceptions\SchoolYear\InvalidSchoolYearSelectionException;
|
||||
use App\Exceptions\SchoolYear\SchoolYearConfigurationException;
|
||||
use App\Exceptions\SchoolYear\SchoolYearNotFoundException;
|
||||
use Throwable;
|
||||
|
||||
final class RequireSchoolYearFilter implements FilterInterface
|
||||
@@ -24,18 +27,47 @@ final class RequireSchoolYearFilter implements FilterInterface
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
$status = $this->statusCodeFor($e);
|
||||
|
||||
if ($request->isAJAX() || str_contains(strtolower($request->getHeaderLine('Accept')), 'application/json')) {
|
||||
return service('response')
|
||||
->setStatusCode(400)
|
||||
->setStatusCode($status)
|
||||
->setJSON([
|
||||
'status' => 400,
|
||||
'status' => $status,
|
||||
'error' => 'Invalid school year',
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return service('response')
|
||||
->setStatusCode($status)
|
||||
->setBody(view('errors/school_year', [
|
||||
'message' => $status >= 500
|
||||
? 'School-year configuration is invalid. Please contact an administrator.'
|
||||
: $e->getMessage(),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
private function statusCodeFor(Throwable $e): int
|
||||
{
|
||||
if ($e instanceof InvalidSchoolYearSelectionException) {
|
||||
return 400;
|
||||
}
|
||||
|
||||
if ($e instanceof SchoolYearNotFoundException) {
|
||||
return 404;
|
||||
}
|
||||
|
||||
if ($e instanceof SchoolYearConfigurationException) {
|
||||
return 500;
|
||||
}
|
||||
|
||||
return 400;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filters;
|
||||
|
||||
use App\Exceptions\SchoolYear\SchoolYearWriteConflictException;
|
||||
use CodeIgniter\Filters\FilterInterface;
|
||||
use CodeIgniter\HTTP\IncomingRequest;
|
||||
use CodeIgniter\HTTP\RequestInterface;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
|
||||
final class SchoolYearWritableFilter implements FilterInterface
|
||||
{
|
||||
public function before(RequestInterface $request, $arguments = null)
|
||||
{
|
||||
if (! $request instanceof IncomingRequest) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
service('schoolYearWriteGuard')->assertWritable(
|
||||
service('schoolYearContext')->resolve($request)
|
||||
);
|
||||
|
||||
return null;
|
||||
} catch (SchoolYearWriteConflictException $e) {
|
||||
log_message('warning', 'Historical school-year write blocked: {message}', [
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
if ($request->isAJAX() || str_contains(strtolower($request->getHeaderLine('Accept')), 'application/json')) {
|
||||
return service('response')
|
||||
->setStatusCode(409)
|
||||
->setJSON([
|
||||
'status' => 409,
|
||||
'error' => 'Read-only school year',
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->back()->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,16 @@ if (!function_exists('getSemester')) {
|
||||
|
||||
if (!function_exists('getSchoolYear')) {
|
||||
function getSchoolYear() {
|
||||
if (! is_cli() && session()->get('user_id')) {
|
||||
try {
|
||||
return service('schoolYearContext')->resolve(service('request'))->yearName();
|
||||
} catch (\Throwable $e) {
|
||||
log_message('warning', 'Falling back to configured school year: {message}', [
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$configModel = new \App\Models\ConfigurationModel();
|
||||
return $configModel->getConfig('school_year');
|
||||
}
|
||||
|
||||
@@ -77,6 +77,13 @@ class ConfigurationModel extends Model
|
||||
public function getConfig($key)
|
||||
{
|
||||
$key = (string) $key;
|
||||
if ($key === 'school_year') {
|
||||
$activeSchoolYear = $this->activeSchoolYearName();
|
||||
if ($activeSchoolYear !== null) {
|
||||
return $activeSchoolYear;
|
||||
}
|
||||
}
|
||||
|
||||
if ($key === 'semester') {
|
||||
try {
|
||||
$semester = (new \App\Services\SemesterRangeService($this))->getSemesterForDate();
|
||||
@@ -90,4 +97,26 @@ class ConfigurationModel extends Model
|
||||
|
||||
return $this->getConfigValueByKey($key);
|
||||
}
|
||||
|
||||
private function activeSchoolYearName(): ?string
|
||||
{
|
||||
try {
|
||||
if (! $this->db->tableExists('school_years')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$row = $this->db->table('school_years')
|
||||
->select('name')
|
||||
->where('status', 'active')
|
||||
->orderBy('id', 'DESC')
|
||||
->get(1)
|
||||
->getRowArray();
|
||||
|
||||
$name = trim((string) ($row['name'] ?? ''));
|
||||
|
||||
return $name !== '' ? $name : null;
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,23 +39,59 @@ class ExpenseModel extends Model
|
||||
{
|
||||
$db = \Config\Database::connect();
|
||||
$builder = $db->table('expenses');
|
||||
$builder->select('expenses.*,
|
||||
r.id AS reimb_id,
|
||||
r.amount AS reimb_amount, r.reimbursement_method, r.check_number,
|
||||
r.receipt_path AS reimb_receipt, r.status AS reimb_status,
|
||||
r.school_year, r.semester, r.created_at AS reimb_created_at,
|
||||
r.reimbursed_to AS reimb_recipient_id,
|
||||
reimb.firstname AS reimb_firstname, reimb.lastname AS reimb_lastname,
|
||||
approver.firstname AS approver_firstname, approver.lastname AS approver_lastname');
|
||||
$hasReimbursementSchoolYear = $db->fieldExists('school_year', 'reimbursements');
|
||||
$hasReimbursementSemester = $db->fieldExists('semester', 'reimbursements');
|
||||
$hasExpenseSchoolYear = $db->fieldExists('school_year', 'expenses');
|
||||
$hasExpenseSemester = $db->fieldExists('semester', 'expenses');
|
||||
|
||||
$select = [
|
||||
'expenses.*',
|
||||
'r.id AS reimb_id',
|
||||
'r.amount AS reimb_amount',
|
||||
'r.reimbursement_method',
|
||||
'r.check_number',
|
||||
'r.receipt_path AS reimb_receipt',
|
||||
'r.status AS reimb_status',
|
||||
'r.created_at AS reimb_created_at',
|
||||
'r.reimbursed_to AS reimb_recipient_id',
|
||||
'reimb.firstname AS reimb_firstname',
|
||||
'reimb.lastname AS reimb_lastname',
|
||||
'approver.firstname AS approver_firstname',
|
||||
'approver.lastname AS approver_lastname',
|
||||
];
|
||||
|
||||
if ($hasReimbursementSchoolYear) {
|
||||
$select[] = 'r.school_year';
|
||||
} elseif ($hasExpenseSchoolYear) {
|
||||
$select[] = 'expenses.school_year AS school_year';
|
||||
}
|
||||
|
||||
if ($hasReimbursementSemester) {
|
||||
$select[] = 'r.semester';
|
||||
} elseif ($hasExpenseSemester) {
|
||||
$select[] = 'expenses.semester AS semester';
|
||||
} else {
|
||||
$select[] = 'NULL AS semester';
|
||||
}
|
||||
|
||||
$builder->select(implode(",\n", $select), false);
|
||||
$builder->join('reimbursements r', 'r.expense_id = expenses.id', 'inner');
|
||||
$builder->join('users reimb', 'reimb.id = r.reimbursed_to', 'left');
|
||||
$builder->join('users approver', 'approver.id = r.approved_by', 'left');
|
||||
|
||||
if (!empty($filters['school_year'])) {
|
||||
if ($hasReimbursementSchoolYear) {
|
||||
$builder->where('r.school_year', $filters['school_year']);
|
||||
} elseif ($hasExpenseSchoolYear) {
|
||||
$builder->where('expenses.school_year', $filters['school_year']);
|
||||
}
|
||||
}
|
||||
if (!empty($filters['semester'])) {
|
||||
if ($hasReimbursementSemester) {
|
||||
$builder->where('r.semester', $filters['semester']);
|
||||
} elseif ($hasExpenseSemester) {
|
||||
$builder->where('expenses.semester', $filters['semester']);
|
||||
}
|
||||
}
|
||||
if (!empty($filters['status'])) {
|
||||
$builder->where('r.status', $filters['status']);
|
||||
|
||||
@@ -40,8 +40,14 @@ class SchoolYearModel extends Model
|
||||
|
||||
public function active(): ?array
|
||||
{
|
||||
return $this->where('status', 'active')
|
||||
$rows = $this->where('status', 'active')
|
||||
->orderBy('id', 'DESC')
|
||||
->first();
|
||||
->findAll(2);
|
||||
|
||||
if (count($rows) !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $rows[0];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,8 +341,17 @@ class StudentModel extends Model
|
||||
return array_column($results, 'id');
|
||||
}
|
||||
|
||||
public function getStudentsWithClassAndEnrollment()
|
||||
public function getStudentsWithClassAndEnrollment(?string $schoolYear = null)
|
||||
{
|
||||
$schoolYear = trim((string) $schoolYear);
|
||||
$studentClassJoin = 'student_class.student_id = students.id';
|
||||
$enrollmentJoin = 'enrollments.student_id = students.id';
|
||||
|
||||
if ($schoolYear !== '') {
|
||||
$studentClassJoin .= ' AND student_class.school_year = ' . $this->db->escape($schoolYear);
|
||||
$enrollmentJoin .= ' AND enrollments.school_year = ' . $this->db->escape($schoolYear);
|
||||
}
|
||||
|
||||
return $this->select('
|
||||
students.*,
|
||||
student_class.class_section_id,
|
||||
@@ -351,8 +360,8 @@ class StudentModel extends Model
|
||||
u.firstname AS parent_firstname,
|
||||
u.lastname AS parent_lastname
|
||||
')
|
||||
->join('student_class', 'student_class.student_id = students.id', 'left')
|
||||
->join('enrollments', 'enrollments.student_id = students.id', 'left')
|
||||
->join('student_class', $studentClassJoin, 'left')
|
||||
->join('enrollments', $enrollmentJoin, 'left')
|
||||
->join('users u', 'u.id = students.parent_id', 'left')
|
||||
// keep your original grouping as-is so behavior doesn't change
|
||||
->groupBy('students.id, student_class.class_section_id, enrollments.enrollment_status, enrollments.admission_status')
|
||||
|
||||
@@ -16,6 +16,17 @@ class WhatsappGroupLinkModel extends Model
|
||||
];
|
||||
protected $useTimestamps = true; // requires created_at / updated_at columns
|
||||
|
||||
private ?bool $hasSchoolYearColumn = null;
|
||||
|
||||
private function hasSchoolYearColumn(): bool
|
||||
{
|
||||
if ($this->hasSchoolYearColumn === null) {
|
||||
$this->hasSchoolYearColumn = $this->db->fieldExists('school_year', $this->table);
|
||||
}
|
||||
|
||||
return $this->hasSchoolYearColumn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the link for a specific section in a specific term.
|
||||
*
|
||||
@@ -36,8 +47,11 @@ class WhatsappGroupLinkModel extends Model
|
||||
$sem = trim($sem);
|
||||
|
||||
$b = $this->asArray()
|
||||
->where('class_section_id', $sectionId)
|
||||
->where('school_year', $year);
|
||||
->where('class_section_id', $sectionId);
|
||||
|
||||
if ($this->hasSchoolYearColumn()) {
|
||||
$b = $b->where('school_year', $year);
|
||||
}
|
||||
|
||||
if ($onlyActive) {
|
||||
$b = $b->where('active', 1);
|
||||
@@ -69,8 +83,11 @@ class WhatsappGroupLinkModel extends Model
|
||||
$year = trim($year);
|
||||
$sem = trim($sem);
|
||||
|
||||
$b = $this->asArray()
|
||||
->where('school_year', $year);
|
||||
$b = $this->asArray();
|
||||
|
||||
if ($this->hasSchoolYearColumn()) {
|
||||
$b = $b->where('school_year', $year);
|
||||
}
|
||||
|
||||
if ($onlyActive === true) {
|
||||
$b = $b->where('active', 1);
|
||||
@@ -98,16 +115,23 @@ class WhatsappGroupLinkModel extends Model
|
||||
$payload = [
|
||||
'class_section_id' => $sectionId,
|
||||
'class_section_name' => $sectionName,
|
||||
'school_year' => trim($year),
|
||||
'invite_link' => trim($inviteLink),
|
||||
'active' => $active ? 1 : 0,
|
||||
];
|
||||
|
||||
if ($this->hasSchoolYearColumn()) {
|
||||
$payload['school_year'] = trim($year);
|
||||
}
|
||||
|
||||
// Try to find existing row (exact term)
|
||||
$existing = $this->asArray()
|
||||
->where('class_section_id', $sectionId)
|
||||
->where('school_year', $payload['school_year'])
|
||||
->first();
|
||||
->where('class_section_id', $sectionId);
|
||||
|
||||
if ($this->hasSchoolYearColumn()) {
|
||||
$existing = $existing->where('school_year', $payload['school_year']);
|
||||
}
|
||||
|
||||
$existing = $existing->first();
|
||||
|
||||
if ($existing) {
|
||||
$this->update((int)$existing['id'], $payload);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
final class SchoolYearAccessPolicy
|
||||
{
|
||||
public function canView(?int $userId, array $year): bool
|
||||
{
|
||||
if (($year['status'] ?? '') !== 'draft') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->canViewDraft($userId);
|
||||
}
|
||||
|
||||
public function canSelect(?int $userId, array $year): bool
|
||||
{
|
||||
return $this->canView($userId, $year);
|
||||
}
|
||||
|
||||
public function canViewDraft(?int $userId): bool
|
||||
{
|
||||
if ($userId === null || $userId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$roles = array_map(
|
||||
static fn ($role): string => strtolower(trim((string) $role)),
|
||||
(array) session()->get('roles')
|
||||
);
|
||||
|
||||
return (bool) array_intersect($roles, [
|
||||
'admin',
|
||||
'administrator',
|
||||
'administrative staff',
|
||||
'principal',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace App\Services;
|
||||
use App\Models\SchoolYearClosingBatchModel;
|
||||
use App\Models\SchoolYearClosingItemModel;
|
||||
use App\Models\SchoolYearModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
use App\Support\SchoolYear\SchoolYearStatus;
|
||||
use CodeIgniter\Database\BaseConnection;
|
||||
use InvalidArgumentException;
|
||||
@@ -16,6 +17,7 @@ final class SchoolYearClosingService
|
||||
private readonly SchoolYearModel $schoolYearModel,
|
||||
private readonly SchoolYearClosingBatchModel $batchModel,
|
||||
private readonly SchoolYearClosingItemModel $itemModel,
|
||||
private readonly ConfigurationModel $configurationModel,
|
||||
private readonly SchoolYearManagementService $managementService,
|
||||
private readonly BaseConnection $db,
|
||||
) {
|
||||
@@ -50,6 +52,29 @@ final class SchoolYearClosingService
|
||||
$findings[] = $finding;
|
||||
}
|
||||
|
||||
$promotion = $this->promotionPreview($sourceName, $target !== null ? (string) ($target['name'] ?? '') : null);
|
||||
if (($promotion['summary']['missing_decision'] ?? 0) > 0) {
|
||||
$findings[] = $this->finding(
|
||||
'blocking',
|
||||
'Students missing promotion decisions',
|
||||
$promotion['summary']['missing_decision'] . ' active student(s) do not have a saved promotion decision for this school year.'
|
||||
);
|
||||
}
|
||||
if (($promotion['summary']['pending_decision'] ?? 0) > 0) {
|
||||
$findings[] = $this->finding(
|
||||
'blocking',
|
||||
'Students with pending promotion decisions',
|
||||
$promotion['summary']['pending_decision'] . ' active student(s) still have pending promotion decisions.'
|
||||
);
|
||||
}
|
||||
if (($promotion['summary']['missing_queue'] ?? 0) > 0) {
|
||||
$findings[] = $this->finding(
|
||||
'warning',
|
||||
'Passed students missing promotion queue',
|
||||
$promotion['summary']['missing_queue'] . ' passed student(s) are not queued for the target school year. This will not block closing.'
|
||||
);
|
||||
}
|
||||
|
||||
if ($this->countMissingSchoolYearRows('invoices') > 0) {
|
||||
$findings[] = $this->finding('blocking', 'Invoices missing school year', 'Some invoice records are not assigned to a school year.');
|
||||
}
|
||||
@@ -63,6 +88,7 @@ final class SchoolYearClosingService
|
||||
'target' => $target,
|
||||
'overview' => $overview,
|
||||
'finance' => $finance,
|
||||
'promotion' => $promotion,
|
||||
'findings' => $findings,
|
||||
'blockers' => $blockers,
|
||||
'warnings' => $warnings,
|
||||
@@ -183,6 +209,12 @@ final class SchoolYearClosingService
|
||||
throw new InvalidArgumentException('All closing batch items must complete before the year can be closed.');
|
||||
}
|
||||
|
||||
$targetYearId = (int) ($batch['target_school_year_id'] ?? 0);
|
||||
$target = $targetYearId > 0 ? $this->schoolYearModel->find($targetYearId) : null;
|
||||
if ($target === null) {
|
||||
throw new InvalidArgumentException('A target school year is required before completing closing.');
|
||||
}
|
||||
|
||||
$this->db->transStart();
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$this->batchModel->update((int) $batch['id'], [
|
||||
@@ -195,8 +227,25 @@ final class SchoolYearClosingService
|
||||
'closed_at' => $now,
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
$this->schoolYearModel->update($targetYearId, [
|
||||
'status' => SchoolYearStatus::ACTIVE,
|
||||
'previous_school_year_id' => $sourceYearId,
|
||||
'activated_at' => $target['activated_at'] ?? $now,
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
$this->schoolYearModel->update($sourceYearId, [
|
||||
'next_school_year_id' => $targetYearId,
|
||||
]);
|
||||
$targetName = (string) ($target['name'] ?? '');
|
||||
$this->configurationModel->setConfigValueByKey('school_year', $targetName);
|
||||
$this->syncActiveYearSession($targetName);
|
||||
$this->managementService->log($sourceYearId, SchoolYearStatus::CLOSING, SchoolYearStatus::CLOSED, 'closing_complete', $userId, [
|
||||
'closing_batch_id' => (int) $batch['id'],
|
||||
'activated_school_year_id' => $targetYearId,
|
||||
]);
|
||||
$this->managementService->log($targetYearId, (string) ($target['status'] ?? SchoolYearStatus::DRAFT), SchoolYearStatus::ACTIVE, 'activate_after_closing', $userId, [
|
||||
'closed_school_year_id' => $sourceYearId,
|
||||
'closing_batch_id' => (int) $batch['id'],
|
||||
]);
|
||||
$this->db->transComplete();
|
||||
|
||||
@@ -322,11 +371,21 @@ final class SchoolYearClosingService
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = $this->db->table('invoices i')
|
||||
$builder = $this->db->table('invoices i')
|
||||
->select('i.parent_id AS family_id')
|
||||
->select('COALESCE(SUM(i.balance), 0) AS source_balance')
|
||||
->where('i.school_year', $schoolYear)
|
||||
->groupBy('i.parent_id')
|
||||
->where('i.school_year', $schoolYear);
|
||||
|
||||
if ($this->db->tableExists('users')) {
|
||||
$builder
|
||||
->select('u.firstname AS parent_firstname, u.lastname AS parent_lastname, u.email AS parent_email')
|
||||
->join('users u', 'u.id = i.parent_id', 'left')
|
||||
->groupBy('i.parent_id, u.firstname, u.lastname, u.email');
|
||||
} else {
|
||||
$builder->groupBy('i.parent_id');
|
||||
}
|
||||
|
||||
$rows = $builder
|
||||
->having('source_balance !=', 0)
|
||||
->orderBy('i.parent_id', 'ASC')
|
||||
->get()
|
||||
@@ -338,6 +397,8 @@ final class SchoolYearClosingService
|
||||
return [
|
||||
'family_id' => (int) $row['family_id'],
|
||||
'family' => 'Family #' . (int) $row['family_id'],
|
||||
'parent' => trim((string) ($row['parent_firstname'] ?? '') . ' ' . (string) ($row['parent_lastname'] ?? '')) ?: 'Parent #' . (int) $row['family_id'],
|
||||
'parent_email' => (string) ($row['parent_email'] ?? ''),
|
||||
'source_balance' => $balance,
|
||||
'credit_amount' => $balance < 0 ? abs($balance) : 0.0,
|
||||
'adjustment_amount' => 0.0,
|
||||
@@ -346,6 +407,315 @@ final class SchoolYearClosingService
|
||||
}, $rows);
|
||||
}
|
||||
|
||||
private function promotionPreview(string $schoolYear, ?string $targetSchoolYear): array
|
||||
{
|
||||
$summary = [
|
||||
'total_students' => 0,
|
||||
'with_decision' => 0,
|
||||
'missing_decision' => 0,
|
||||
'pending_decision' => 0,
|
||||
'pass' => 0,
|
||||
'other_decision' => 0,
|
||||
'queued' => 0,
|
||||
'assigned' => 0,
|
||||
'applied' => 0,
|
||||
'missing_queue' => 0,
|
||||
];
|
||||
|
||||
if (! $this->db->tableExists('student_class') || ! $this->db->tableExists('students')) {
|
||||
return [
|
||||
'summary' => $summary,
|
||||
'rows' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$hasEventOnly = $this->db->fieldExists('is_event_only', 'student_class');
|
||||
$hasActive = $this->db->fieldExists('is_active', 'students');
|
||||
$hasDob = $this->db->fieldExists('dob', 'students');
|
||||
$hasRegistrationGrade = $this->db->fieldExists('registration_grade', 'students');
|
||||
|
||||
$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')
|
||||
->join('students s', 's.id = sc.student_id', 'inner')
|
||||
->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left')
|
||||
->where('sc.school_year', $schoolYear)
|
||||
->where('sc.class_section_id IS NOT NULL', null, false);
|
||||
|
||||
if ($hasDob) {
|
||||
$builder->select('s.dob');
|
||||
}
|
||||
|
||||
if ($hasRegistrationGrade) {
|
||||
$builder->select('s.registration_grade');
|
||||
}
|
||||
|
||||
if ($hasEventOnly) {
|
||||
$builder->groupStart()
|
||||
->where('sc.is_event_only', 0)
|
||||
->orWhere('sc.is_event_only', null)
|
||||
->groupEnd();
|
||||
}
|
||||
|
||||
if ($hasActive) {
|
||||
$builder->where('s.is_active', 1);
|
||||
}
|
||||
|
||||
$assignmentRows = $builder
|
||||
->orderBy('sc.student_id', 'ASC')
|
||||
->orderBy('sc.updated_at', 'DESC')
|
||||
->orderBy('sc.created_at', 'DESC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
$students = [];
|
||||
foreach ($assignmentRows as $row) {
|
||||
$studentId = (int) ($row['student_id'] ?? 0);
|
||||
if ($studentId <= 0 || isset($students[$studentId])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$students[$studentId] = [
|
||||
'student_id' => $studentId,
|
||||
'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_section_name' => (string) ($row['class_section_name'] ?? ''),
|
||||
'dob' => (string) ($row['dob'] ?? ''),
|
||||
'registration_grade' => (string) ($row['registration_grade'] ?? ''),
|
||||
'auto_kg_pass' => false,
|
||||
'year_score' => null,
|
||||
'decision' => '',
|
||||
'source' => 'missing',
|
||||
'notes' => '',
|
||||
'status' => 'missing',
|
||||
'queue_status' => '',
|
||||
'target_class' => '',
|
||||
'target_section' => '',
|
||||
];
|
||||
}
|
||||
|
||||
if ($students === []) {
|
||||
return [
|
||||
'summary' => $summary,
|
||||
'rows' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$decisions = $this->promotionDecisionRows(array_keys($students), $schoolYear);
|
||||
$queueRows = $this->promotionQueueRows(array_keys($students), $schoolYear, $targetSchoolYear);
|
||||
foreach ($students as $studentId => &$student) {
|
||||
$decision = $decisions[$studentId] ?? null;
|
||||
if ($decision !== null) {
|
||||
$student['year_score'] = $decision['year_score'];
|
||||
$student['decision'] = $decision['decision'];
|
||||
$student['source'] = $decision['source'];
|
||||
$student['notes'] = $decision['notes'];
|
||||
$student['status'] = $decision['status'];
|
||||
if (($decision['class_section_name'] ?? '') !== '') {
|
||||
$student['class_section_name'] = $decision['class_section_name'];
|
||||
}
|
||||
}
|
||||
|
||||
if ($decision === null && $this->isKgStudent($student)) {
|
||||
$kgAgeStatus = $this->kgAgeStatusByTargetYearCutoff((string) $student['dob'], $targetSchoolYear);
|
||||
if ($kgAgeStatus === 'pass') {
|
||||
$student['decision'] = 'Pass';
|
||||
$student['source'] = 'automatic_kg_age';
|
||||
$student['notes'] = 'Auto-pass KG student: age 6 or older by Dec 31 of the next school year start year.';
|
||||
$student['status'] = 'decided';
|
||||
$student['auto_kg_pass'] = true;
|
||||
} elseif ($kgAgeStatus === 'keep_kg') {
|
||||
$student['decision'] = 'Keep KG';
|
||||
$student['source'] = 'automatic_kg_age';
|
||||
$student['notes'] = 'Auto-keep KG student: younger than 6 by Dec 31 of the next school year start year.';
|
||||
$student['status'] = 'decided';
|
||||
}
|
||||
}
|
||||
|
||||
$queue = $queueRows[$studentId] ?? null;
|
||||
if ($queue !== null) {
|
||||
$student['queue_status'] = $queue['status'];
|
||||
$student['target_class'] = $queue['target_class'];
|
||||
$student['target_section'] = $queue['target_section'];
|
||||
}
|
||||
|
||||
$summary['total_students']++;
|
||||
if ($student['status'] === 'missing') {
|
||||
$summary['missing_decision']++;
|
||||
} elseif ($student['status'] === 'pending') {
|
||||
$summary['pending_decision']++;
|
||||
} else {
|
||||
$summary['with_decision']++;
|
||||
if (strcasecmp((string) $student['decision'], 'Pass') === 0) {
|
||||
$summary['pass']++;
|
||||
if ($queue === null && $student['auto_kg_pass'] !== true) {
|
||||
$summary['missing_queue']++;
|
||||
}
|
||||
} else {
|
||||
$summary['other_decision']++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($queue !== null && isset($summary[$queue['status']])) {
|
||||
$summary[$queue['status']]++;
|
||||
}
|
||||
}
|
||||
unset($student);
|
||||
|
||||
$rows = array_values($students);
|
||||
usort($rows, static function (array $a, array $b): int {
|
||||
return [$a['class_section_name'], $a['student_name'], $a['student_id']]
|
||||
<=> [$b['class_section_name'], $b['student_name'], $b['student_id']];
|
||||
});
|
||||
|
||||
return [
|
||||
'summary' => $summary,
|
||||
'rows' => $rows,
|
||||
];
|
||||
}
|
||||
|
||||
private function isKgStudent(array $student): bool
|
||||
{
|
||||
foreach (['class_section_name', 'registration_grade'] as $field) {
|
||||
$value = strtoupper(trim((string) ($student[$field] ?? '')));
|
||||
if ($value === 'KG' || str_starts_with($value, 'KG-') || str_contains($value, 'KINDERGARTEN')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function kgAgeStatusByTargetYearCutoff(string $dob, ?string $targetSchoolYear): string
|
||||
{
|
||||
$dob = trim($dob);
|
||||
if ($dob === '' || $targetSchoolYear === null || ! preg_match('/^(\d{4})-\d{4}$/', $targetSchoolYear, $matches)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
$birthDate = new \DateTimeImmutable($dob);
|
||||
$cutoff = new \DateTimeImmutable($matches[1] . '-12-31');
|
||||
} catch (\Throwable) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if ($birthDate > $cutoff) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$age = $birthDate->diff($cutoff)->y;
|
||||
if ($age >= 6) {
|
||||
return 'pass';
|
||||
}
|
||||
|
||||
return 'keep_kg';
|
||||
}
|
||||
|
||||
private function promotionDecisionRows(array $studentIds, string $schoolYear): array
|
||||
{
|
||||
if (! $this->db->tableExists('student_decisions')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$select = ['student_id', 'class_section_name', 'decision', 'source', 'notes'];
|
||||
$scoreField = null;
|
||||
if ($this->db->fieldExists('year_score', 'student_decisions')) {
|
||||
$scoreField = 'year_score';
|
||||
} elseif ($this->db->fieldExists('semester_score', 'student_decisions')) {
|
||||
$scoreField = 'semester_score';
|
||||
}
|
||||
|
||||
if ($scoreField !== null) {
|
||||
$select[] = $scoreField . ' AS year_score';
|
||||
}
|
||||
|
||||
$rows = $this->db->table('student_decisions')
|
||||
->select($select)
|
||||
->where('school_year', $schoolYear)
|
||||
->whereIn('student_id', $studentIds)
|
||||
->orderBy('updated_at', 'DESC')
|
||||
->orderBy('id', 'DESC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
$decisions = [];
|
||||
foreach ($rows as $row) {
|
||||
$studentId = (int) ($row['student_id'] ?? 0);
|
||||
if ($studentId <= 0 || isset($decisions[$studentId])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$decision = trim((string) ($row['decision'] ?? ''));
|
||||
$source = trim((string) ($row['source'] ?? ''));
|
||||
$status = $decision === '' || $source === 'pending' ? 'pending' : 'decided';
|
||||
|
||||
$decisions[$studentId] = [
|
||||
'class_section_name' => (string) ($row['class_section_name'] ?? ''),
|
||||
'year_score' => is_numeric($row['year_score'] ?? null) ? round((float) $row['year_score'], 2) : null,
|
||||
'decision' => $decision,
|
||||
'source' => $source !== '' ? $source : ($status === 'pending' ? 'pending' : 'manual'),
|
||||
'notes' => (string) ($row['notes'] ?? ''),
|
||||
'status' => $status,
|
||||
];
|
||||
}
|
||||
|
||||
return $decisions;
|
||||
}
|
||||
|
||||
private function promotionQueueRows(array $studentIds, string $sourceSchoolYear, ?string $targetSchoolYear): array
|
||||
{
|
||||
if (
|
||||
$targetSchoolYear === null
|
||||
|| $targetSchoolYear === ''
|
||||
|| ! $this->db->tableExists('promotion_queue')
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$builder = $this->db->table('promotion_queue pq')
|
||||
->select('pq.student_id, pq.to_class_id, pq.to_class_section_id, pq.status')
|
||||
->select('c.class_name AS target_class')
|
||||
->select('cs.class_section_name AS target_section')
|
||||
->join('classes c', 'c.id = pq.to_class_id', 'left')
|
||||
->join('classSection cs', 'cs.class_section_id = pq.to_class_section_id', 'left')
|
||||
->where('pq.school_year_from', $sourceSchoolYear)
|
||||
->where('pq.school_year_to', $targetSchoolYear)
|
||||
->whereIn('pq.student_id', $studentIds)
|
||||
->orderBy('pq.updated_at', 'DESC')
|
||||
->orderBy('pq.id', 'DESC');
|
||||
|
||||
$rows = $builder->get()->getResultArray();
|
||||
|
||||
$queueRows = [];
|
||||
foreach ($rows as $row) {
|
||||
$studentId = (int) ($row['student_id'] ?? 0);
|
||||
if ($studentId <= 0 || isset($queueRows[$studentId])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$targetClass = trim((string) ($row['target_class'] ?? ''));
|
||||
if ($targetClass === '' && (int) ($row['to_class_id'] ?? 0) > 0) {
|
||||
$targetClass = 'Class #' . (int) $row['to_class_id'];
|
||||
}
|
||||
|
||||
$targetSection = trim((string) ($row['target_section'] ?? ''));
|
||||
if ($targetSection === '' && (int) ($row['to_class_section_id'] ?? 0) > 0) {
|
||||
$targetSection = 'Section #' . (int) $row['to_class_section_id'];
|
||||
}
|
||||
|
||||
$queueRows[$studentId] = [
|
||||
'status' => (string) ($row['status'] ?? ''),
|
||||
'target_class' => $targetClass,
|
||||
'target_section' => $targetSection,
|
||||
];
|
||||
}
|
||||
|
||||
return $queueRows;
|
||||
}
|
||||
|
||||
private function countBySchoolYear(string $table, string $schoolYear, string $distinctField): int
|
||||
{
|
||||
if (! $this->db->tableExists($table) || ! $this->db->fieldExists('school_year', $table)) {
|
||||
@@ -423,8 +793,20 @@ final class SchoolYearClosingService
|
||||
'source_id' => $preview['source']['id'] ?? null,
|
||||
'target_id' => $preview['target']['id'] ?? null,
|
||||
'finance' => $preview['finance'],
|
||||
'promotion' => $preview['promotion'],
|
||||
'carry_forward' => $preview['carry_forward'],
|
||||
'blockers' => $preview['blockers'],
|
||||
], JSON_UNESCAPED_SLASHES));
|
||||
}
|
||||
|
||||
private function syncActiveYearSession(string $schoolYearName): void
|
||||
{
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||
return;
|
||||
}
|
||||
|
||||
session()->set('school_year', $schoolYearName);
|
||||
session()->remove('selected_school_year_id');
|
||||
session()->remove('selected_school_year');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,15 +2,19 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Exceptions\SchoolYear\InvalidSchoolYearSelectionException;
|
||||
use App\Exceptions\SchoolYear\SchoolYearConfigurationException;
|
||||
use App\Exceptions\SchoolYear\SchoolYearNotFoundException;
|
||||
use App\Models\SchoolYearModel;
|
||||
use App\Support\SchoolYear\SchoolYearStatus;
|
||||
use App\Support\SchoolYear\SchoolYearContext;
|
||||
use CodeIgniter\HTTP\IncomingRequest;
|
||||
use RuntimeException;
|
||||
|
||||
final class SchoolYearContextService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SchoolYearModel $schoolYearModel,
|
||||
private readonly SchoolYearAccessPolicy $accessPolicy = new SchoolYearAccessPolicy(),
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -18,29 +22,28 @@ final class SchoolYearContextService
|
||||
IncomingRequest $request,
|
||||
?int $routeSchoolYearId = null
|
||||
): SchoolYearContext {
|
||||
$requestedId = $routeSchoolYearId
|
||||
?? $this->normalizeInt($request->getGet('school_year_id'));
|
||||
|
||||
$requestedName = trim((string) $request->getGet('school_year'));
|
||||
$requestedId = $routeSchoolYearId ?? $this->normalizeInt($request->getGet('school_year_id'));
|
||||
$requestedName = $this->legacyYearName($request);
|
||||
$userId = (int) (session()->get('user_id') ?? 0);
|
||||
|
||||
if ($requestedId !== null && $requestedName !== '') {
|
||||
$row = $this->schoolYearModel->find($requestedId);
|
||||
|
||||
if ($row === null || (string) ($row['name'] ?? '') !== $requestedName) {
|
||||
throw new RuntimeException('Selected school-year parameters conflict.');
|
||||
throw new InvalidSchoolYearSelectionException('Selected school-year parameters conflict.');
|
||||
}
|
||||
|
||||
return $this->fromRow($row, true);
|
||||
return $this->authorizedContext($row, $userId, true);
|
||||
}
|
||||
|
||||
if ($requestedId !== null) {
|
||||
$row = $this->schoolYearModel->find($requestedId);
|
||||
|
||||
if ($row === null) {
|
||||
throw new RuntimeException('Selected school year was not found.');
|
||||
throw new SchoolYearNotFoundException('Selected school year was not found.');
|
||||
}
|
||||
|
||||
return $this->fromRow($row, true);
|
||||
return $this->authorizedContext($row, $userId, true);
|
||||
}
|
||||
|
||||
if ($requestedName !== '') {
|
||||
@@ -49,10 +52,10 @@ final class SchoolYearContextService
|
||||
->first();
|
||||
|
||||
if ($row === null) {
|
||||
throw new RuntimeException('Selected school year was not found.');
|
||||
throw new SchoolYearNotFoundException('Selected school year was not found.');
|
||||
}
|
||||
|
||||
return $this->fromRow($row, true);
|
||||
return $this->authorizedContext($row, $userId, true);
|
||||
}
|
||||
|
||||
$sessionYearId = session('selected_school_year_id');
|
||||
@@ -60,15 +63,73 @@ final class SchoolYearContextService
|
||||
if (is_numeric($sessionYearId)) {
|
||||
$row = $this->schoolYearModel->find((int) $sessionYearId);
|
||||
|
||||
if ($row !== null) {
|
||||
return $this->fromRow($row, false);
|
||||
}
|
||||
if ($row !== null && $this->isSelectable($row, $userId)) {
|
||||
return $this->fromRow($row, true);
|
||||
}
|
||||
|
||||
$this->clearSelection();
|
||||
}
|
||||
|
||||
return $this->active();
|
||||
}
|
||||
|
||||
public function selectableYears(bool $includeDraft = false): array
|
||||
{
|
||||
$builder = $this->schoolYearModel
|
||||
->orderBy('name', 'DESC')
|
||||
->orderBy('id', 'DESC');
|
||||
|
||||
if (! $includeDraft) {
|
||||
$builder->where('status !=', SchoolYearStatus::DRAFT);
|
||||
}
|
||||
|
||||
$userId = (int) (session()->get('user_id') ?? 0);
|
||||
|
||||
return array_values(array_filter(
|
||||
$builder->findAll(),
|
||||
fn (array $row): bool => $this->isSelectable($row, $userId)
|
||||
));
|
||||
}
|
||||
|
||||
public function select(int $schoolYearId): SchoolYearContext
|
||||
{
|
||||
$row = $this->schoolYearModel->find($schoolYearId);
|
||||
|
||||
if ($row === null) {
|
||||
throw new SchoolYearNotFoundException('Selected school year was not found.');
|
||||
}
|
||||
|
||||
$userId = (int) (session()->get('user_id') ?? 0);
|
||||
$context = $this->authorizedContext($row, $userId, true);
|
||||
$previousSelection = session('selected_school_year_id');
|
||||
|
||||
if ($context->isActive()) {
|
||||
$this->clearSelection();
|
||||
$context = $this->fromRow($row, false);
|
||||
} else {
|
||||
session()->set('selected_school_year_id', $context->id());
|
||||
session()->remove('selected_school_year');
|
||||
}
|
||||
|
||||
if ((string) $previousSelection !== (string) session('selected_school_year_id')) {
|
||||
$this->clearDependentSessionState();
|
||||
}
|
||||
|
||||
return $context;
|
||||
}
|
||||
|
||||
public function clearSelection(): void
|
||||
{
|
||||
session()->remove('selected_school_year_id');
|
||||
session()->remove('selected_school_year');
|
||||
}
|
||||
|
||||
public function active(): SchoolYearContext
|
||||
{
|
||||
$active = $this->schoolYearModel->active();
|
||||
|
||||
if ($active === null) {
|
||||
throw new RuntimeException('No active school year is configured.');
|
||||
throw new SchoolYearConfigurationException('Exactly one active school year must be configured.');
|
||||
}
|
||||
|
||||
return $this->fromRow($active, false);
|
||||
@@ -83,12 +144,61 @@ final class SchoolYearContextService
|
||||
return (int) $value;
|
||||
}
|
||||
|
||||
private function legacyYearName(IncomingRequest $request): string
|
||||
{
|
||||
$names = [];
|
||||
|
||||
foreach (['school_year', 'schoolYear', 'year'] as $key) {
|
||||
$value = trim((string) ($request->getGet($key) ?? ''));
|
||||
if ($value !== '') {
|
||||
$names[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
if (count(array_unique($names)) > 1) {
|
||||
throw new InvalidSchoolYearSelectionException('Conflicting school-year parameters were provided.');
|
||||
}
|
||||
|
||||
if (isset($names['schoolYear']) || isset($names['year'])) {
|
||||
log_message('notice', 'Legacy school-year request alias used: {aliases}', [
|
||||
'aliases' => implode(',', array_keys($names)),
|
||||
]);
|
||||
}
|
||||
|
||||
return $names === [] ? '' : (string) reset($names);
|
||||
}
|
||||
|
||||
private function authorizedContext(array $row, int $userId, bool $explicit): SchoolYearContext
|
||||
{
|
||||
if (! $this->isSelectable($row, $userId)) {
|
||||
throw new SchoolYearNotFoundException('Selected school year was not found.');
|
||||
}
|
||||
|
||||
return $this->fromRow($row, $explicit);
|
||||
}
|
||||
|
||||
private function isSelectable(array $row, int $userId): bool
|
||||
{
|
||||
return $this->accessPolicy->canSelect($userId > 0 ? $userId : null, $row);
|
||||
}
|
||||
|
||||
private function clearDependentSessionState(): void
|
||||
{
|
||||
session()->remove([
|
||||
'class_section_id',
|
||||
'semester',
|
||||
'active_semester',
|
||||
'teacher_scores_selected_semester',
|
||||
'grading_selected_semester',
|
||||
]);
|
||||
}
|
||||
|
||||
private function fromRow(array $row, bool $explicit): SchoolYearContext
|
||||
{
|
||||
return new SchoolYearContext(
|
||||
id: (int) $row['id'],
|
||||
yearName: (string) $row['name'],
|
||||
status: (string) $row['status'],
|
||||
status: strtolower((string) $row['status']),
|
||||
explicitSelection: $explicit,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -105,6 +105,7 @@ final class SchoolYearManagementService
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
$this->configurationModel->setConfigValueByKey('school_year', (string) $year['name']);
|
||||
$this->syncActiveYearSession((string) $year['name']);
|
||||
$this->log($id, $from, SchoolYearStatus::ACTIVE, 'activate', $userId);
|
||||
|
||||
$this->db->transComplete();
|
||||
@@ -310,4 +311,15 @@ final class SchoolYearManagementService
|
||||
|
||||
return is_string($first) && $first !== '' ? $first : $fallback;
|
||||
}
|
||||
|
||||
private function syncActiveYearSession(string $schoolYearName): void
|
||||
{
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||
return;
|
||||
}
|
||||
|
||||
session()->set('school_year', $schoolYearName);
|
||||
session()->remove('selected_school_year_id');
|
||||
session()->remove('selected_school_year');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use CodeIgniter\HTTP\IncomingRequest;
|
||||
|
||||
final class SchoolYearViewDataService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SchoolYearContextService $contextService,
|
||||
private readonly SchoolYearAccessPolicy $accessPolicy,
|
||||
) {
|
||||
}
|
||||
|
||||
public function forCurrentRequest(IncomingRequest $request): array
|
||||
{
|
||||
$context = $this->contextService->resolve($request);
|
||||
$userId = (int) (session()->get('user_id') ?? 0);
|
||||
|
||||
return [
|
||||
'schoolYearContext' => $context,
|
||||
'schoolYearOptions' => $this->contextService->selectableYears(
|
||||
$this->accessPolicy->canViewDraft($userId)
|
||||
),
|
||||
'schoolYearReadonly' => $context->isReadonly(),
|
||||
'schoolYearSelectorEnabled' => config('Feature')->globalSchoolYearSelector,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Exceptions\SchoolYear\SchoolYearWriteConflictException;
|
||||
use App\Support\SchoolYear\SchoolYearContext;
|
||||
use RuntimeException;
|
||||
|
||||
final class SchoolYearWriteGuard
|
||||
{
|
||||
@@ -12,14 +12,14 @@ final class SchoolYearWriteGuard
|
||||
bool $allowDraftForAdmin = false,
|
||||
bool $isAdmin = false
|
||||
): void {
|
||||
if ($context->status() === 'active') {
|
||||
if (strtolower($context->status()) === 'active') {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($context->status() === 'draft' && $allowDraftForAdmin && $isAdmin) {
|
||||
if (strtolower($context->status()) === 'draft' && $allowDraftForAdmin && $isAdmin) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new RuntimeException('The selected school year is read-only.');
|
||||
throw new SchoolYearWriteConflictException('The selected school year is read-only.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,17 +24,22 @@ final class SchoolYearContext
|
||||
|
||||
public function status(): string
|
||||
{
|
||||
return $this->status;
|
||||
return strtolower($this->status);
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->status === 'active';
|
||||
return $this->status() === 'active';
|
||||
}
|
||||
|
||||
public function isReadonly(): bool
|
||||
{
|
||||
return in_array($this->status, ['closed', 'archived'], true);
|
||||
return ! $this->isActive();
|
||||
}
|
||||
|
||||
public function isHistorical(): bool
|
||||
{
|
||||
return ! $this->isActive();
|
||||
}
|
||||
|
||||
public function isExplicitSelection(): bool
|
||||
@@ -47,7 +52,7 @@ final class SchoolYearContext
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->yearName,
|
||||
'status' => $this->status,
|
||||
'status' => $this->status(),
|
||||
'readonly' => $this->isReadonly(),
|
||||
'explicitSelection' => $this->explicitSelection,
|
||||
];
|
||||
|
||||
@@ -32,6 +32,11 @@ final class SchoolYearStatus
|
||||
}
|
||||
|
||||
public static function isReadonly(string $status): bool
|
||||
{
|
||||
return self::isLifecycleMetadataLocked($status);
|
||||
}
|
||||
|
||||
public static function isLifecycleMetadataLocked(string $status): bool
|
||||
{
|
||||
return in_array($status, [self::CLOSED, self::ARCHIVED], true);
|
||||
}
|
||||
|
||||
@@ -61,22 +61,6 @@ $decisionBadge = [
|
||||
<i class="bi bi-award me-2"></i>Generate Certificates
|
||||
</h2>
|
||||
|
||||
<!-- School year filter -->
|
||||
<div class="d-flex justify-content-end mb-3">
|
||||
<form method="get" action="<?= site_url('administrator/certificates') ?>" class="d-flex gap-2 align-items-center">
|
||||
<label class="form-label mb-0 me-1 text-muted small">School Year</label>
|
||||
<input type="text"
|
||||
name="school_year"
|
||||
class="form-control form-control-sm"
|
||||
style="width:130px;"
|
||||
value="<?= esc($schoolYear) ?>"
|
||||
placeholder="e.g. 2024-2025">
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-arrow-repeat me-1"></i>Reload
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<?php if (session()->getFlashdata('error')): ?>
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<?= esc(session()->getFlashdata('error')) ?>
|
||||
|
||||
@@ -30,8 +30,15 @@
|
||||
$lowProgressSectionIds = $lowProgressSectionIds ?? [];
|
||||
$lowProgressQuery = implode(',', $lowProgressSectionIds);
|
||||
$lowProgressUrl = base_url('administrator/teacher-submissions');
|
||||
$lowProgressParams = [];
|
||||
if (!empty($filters['school_year'])) {
|
||||
$lowProgressParams['school_year'] = $filters['school_year'];
|
||||
}
|
||||
if ($lowProgressQuery !== '') {
|
||||
$lowProgressUrl .= '?low_progress_sections=' . rawurlencode($lowProgressQuery);
|
||||
$lowProgressParams['low_progress_sections'] = $lowProgressQuery;
|
||||
}
|
||||
if (!empty($lowProgressParams)) {
|
||||
$lowProgressUrl .= '?' . http_build_query($lowProgressParams);
|
||||
}
|
||||
?>
|
||||
<a
|
||||
@@ -48,6 +55,16 @@
|
||||
<form class="card shadow-sm mb-3" method="get" action="<?= base_url('admin/progress') ?>">
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">School year</label>
|
||||
<select name="school_year" class="form-select">
|
||||
<?php foreach (($schoolYears ?? []) as $year): ?>
|
||||
<option value="<?= esc($year) ?>" <?= (string)($filters['school_year'] ?? $schoolYear ?? '') === (string)$year ? 'selected' : '' ?>>
|
||||
<?= esc($year) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">From</label>
|
||||
<input type="date" name="from" class="form-control" value="<?= esc($filters['from'] ?? '') ?>">
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
|
||||
<div class="container mt-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h3 class="mb-0">Competition Winners</h3>
|
||||
<?php if (!empty($schoolYear)): ?>
|
||||
<div class="text-muted small">School Year: <?= esc($schoolYear) ?></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<a class="btn btn-primary" href="/admin/competition-winners/create">+ New Competition</a>
|
||||
</div>
|
||||
|
||||
@@ -19,6 +24,7 @@
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Title</th>
|
||||
<th>School Year</th>
|
||||
<th>Class Section</th>
|
||||
<th>Winners Rule</th>
|
||||
<th>Published</th>
|
||||
@@ -36,6 +42,7 @@
|
||||
<tr>
|
||||
<td><?= esc($c['id']) ?></td>
|
||||
<td><?= esc($c['title']) ?></td>
|
||||
<td><?= esc($c['school_year'] ?? '') ?></td>
|
||||
<td><?= esc($sectionName) ?></td>
|
||||
<td>Tiered per class</td>
|
||||
<td><?= $c['is_published'] ? 'Yes' : 'No' ?></td>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
$activeStudents = $active_students ?? [];
|
||||
$removedStudents = $removed_students ?? [];
|
||||
$schoolYear = $school_year ?? '';
|
||||
$activeSchoolYear = $active_school_year ?? '';
|
||||
?>
|
||||
|
||||
<?= $this->extend('layout/management_layout') ?>
|
||||
@@ -12,8 +13,8 @@ $schoolYear = $school_year ?? '';
|
||||
<div class="content">
|
||||
<h2 class="text-center mt-4 mb-2">Student Removal</h2>
|
||||
<p class="text-center text-muted mb-4">
|
||||
Removed students keep their data but do not appear in classes or attendance.
|
||||
<?= $schoolYear !== '' ? 'Current year: ' . esc($schoolYear) : '' ?>
|
||||
Active school year: <?= esc($schoolYear !== '' ? $schoolYear : $activeSchoolYear) ?>.
|
||||
Active students have a class assignment in the active school year. Removed students are inactive or have no active-year assignment.
|
||||
</p>
|
||||
<?= $this->include('partials/flash_messages') ?>
|
||||
|
||||
|
||||
@@ -1,32 +1,20 @@
|
||||
<?= $this->extend('layout/management_layout') ?>
|
||||
<?= $this->section('content') ?>
|
||||
<div class="container-fluid">
|
||||
<div class="wrapper">
|
||||
<div class="content"></div>
|
||||
|
||||
<h2 class="text-center mt-4 mb-3">Class Preparation</h2>
|
||||
|
||||
<!-- School Year Selector + Calculate Button -->
|
||||
<form method="get" action="<?= site_url('class-prep') ?>" class="mb-4 d-flex align-items-center gap-2 flex-wrap">
|
||||
<label for="school_year" class="form-label mb-0">School Year:</label>
|
||||
<select name="school_year" id="school_year" class="form-select form-select-sm w-auto">
|
||||
<?php for ($y = date('Y'); $y >= 2020; $y--): ?>
|
||||
<?php $sy = $y . '-' . ($y + 1); ?>
|
||||
<option value="<?= $sy ?>" <?= $schoolYear === $sy ? 'selected' : '' ?>><?= $sy ?></option>
|
||||
<?php endfor; ?>
|
||||
</select>
|
||||
<?php $semVal = (string)($semester ?? ($_GET['semester'] ?? '')); ?>
|
||||
<label for="semester" class="form-label mb-0">Semester:</label>
|
||||
<select name="semester" id="semester" class="form-select form-select-sm w-auto">
|
||||
<option value="">—</option>
|
||||
<option value="Fall" <?= (strcasecmp($semVal,'Fall')===0?'selected':'') ?>>Fall</option>
|
||||
<option value="Spring" <?= (strcasecmp($semVal,'Spring')===0?'selected':'') ?>>Spring</option>
|
||||
</select>
|
||||
<button class="btn btn-sm btn-primary">Calculate Items</button>
|
||||
</form>
|
||||
<?php $semVal = (string)($semester ?? ($_GET['semester'] ?? '')); ?>
|
||||
|
||||
<!-- Class Cards -->
|
||||
<div class="row g-3">
|
||||
<!-- Recalculate using the current school year and semester -->
|
||||
<form method="get" action="<?= site_url('class-prep') ?>" class="mb-4">
|
||||
<input type="hidden" name="school_year" value="<?= esc($schoolYear) ?>">
|
||||
<?php if ($semVal !== ''): ?>
|
||||
<input type="hidden" name="semester" value="<?= esc($semVal) ?>">
|
||||
<?php endif; ?>
|
||||
<button type="submit" class="btn btn-sm btn-primary">Calculate Items</button>
|
||||
</form>
|
||||
|
||||
<!-- Class Cards -->
|
||||
<div class="row g-3">
|
||||
<?php foreach ($prepResults as $prep): ?>
|
||||
<?php $adjMap = $prep['adjustments'] ?? []; ?>
|
||||
<div class="col-12 col-lg-6">
|
||||
@@ -111,15 +99,14 @@
|
||||
onclick="window.open(this.href, '_blank'); return false;">
|
||||
Print
|
||||
</a>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Totals Section -->
|
||||
<div class="mt-5">
|
||||
<!-- Totals Section -->
|
||||
<div class="mt-5">
|
||||
<h5 class="mb-3">📦 Totals for All Used Items</h5>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-bordered align-middle no-mgmt-sticky">
|
||||
@@ -164,8 +151,6 @@
|
||||
<?php else: ?>
|
||||
<div class="alert alert-success mt-3">✅ All items are available in stock.</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?= $this->endSection() ?>
|
||||
|
||||
@@ -204,10 +189,9 @@
|
||||
function adjustQty(btn, delta) {
|
||||
const input = btn.parentElement.querySelector('input[type="number"]');
|
||||
const curr = parseInt(input.value, 10) || 0;
|
||||
input.value = curr + delta; // clamp if you want: input.value = Math.max(0, curr + delta);
|
||||
input.value = curr + delta;
|
||||
}
|
||||
|
||||
// Force print links to open in a new tab even if other handlers intercept
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelectorAll('.btn-print-class').forEach(function(el) {
|
||||
el.addEventListener('click', function(ev) {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?= $this->extend('layout/main') ?>
|
||||
|
||||
<?= $this->section('content') ?>
|
||||
<div class="alert alert-danger" role="alert">
|
||||
<h1 class="h5 mb-2">School-year context unavailable</h1>
|
||||
<p class="mb-0"><?= esc($message ?? 'The selected school year is not available.') ?></p>
|
||||
</div>
|
||||
<?= $this->endSection() ?>
|
||||
@@ -6,16 +6,6 @@
|
||||
<div class="content"></div>
|
||||
<h2 class="text-center mt-4 mb-3">Invoice Management</h2>
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="row g-2 align-items-center justify-content-center mb-3">
|
||||
<div class="col-auto">
|
||||
<label for="schoolYearSelect" class="col-form-label">School year</label>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<select id="schoolYearSelect" class="form-select form-select-sm" style="min-width: 180px;"></select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table for displaying invoices (client-hydrated) -->
|
||||
<table id="invoicesTable" class="table table-striped table-hover table-bordered w-100">
|
||||
<thead class="thead-dark">
|
||||
@@ -155,16 +145,20 @@
|
||||
const $tbl = $('#invoicesTable');
|
||||
let dt = null;
|
||||
const $year = document.getElementById('schoolYearSelect');
|
||||
const selectedSchoolYear = () => $year ? $year.value : '';
|
||||
const syncSchoolYearSelect = (resp) => {
|
||||
if (!$year) return;
|
||||
const years = Array.isArray(resp.schoolYears) ? resp.schoolYears : [];
|
||||
const selected = resp.schoolYear || (years[0] || '');
|
||||
$year.innerHTML = years.map(y => `<option value="${esc(y)}" ${y===selected?'selected':''}>${esc(y)}</option>`).join('');
|
||||
};
|
||||
// Global fallback handler for inline onclick
|
||||
window.__genInvoice = async function(btn){
|
||||
try {
|
||||
btn.disabled = true;
|
||||
await generateInvoice(btn.getAttribute('data-parent-id'));
|
||||
const resp = await loadInvoices();
|
||||
// Populate year select
|
||||
const years = Array.isArray(resp.schoolYears) ? resp.schoolYears : [];
|
||||
const selected = resp.schoolYear || (years[0] || '');
|
||||
$year.innerHTML = years.map(y => `<option value="${esc(y)}" ${y===selected?'selected':''}>${esc(y)}</option>`).join('');
|
||||
const resp = await loadInvoices(selectedSchoolYear());
|
||||
syncSchoolYearSelect(resp);
|
||||
|
||||
const data = (resp.invoices || []).map(r => {
|
||||
const ts = Date.parse(r.invoice_date || new Date().toISOString());
|
||||
@@ -194,10 +188,7 @@
|
||||
}
|
||||
try {
|
||||
const resp = await loadInvoices();
|
||||
// Populate year select on first load
|
||||
const years = Array.isArray(resp.schoolYears) ? resp.schoolYears : [];
|
||||
const selected = resp.schoolYear || (years[0] || '');
|
||||
$year.innerHTML = years.map(y => `<option value="${esc(y)}" ${y===selected?'selected':''}>${esc(y)}</option>`).join('');
|
||||
syncSchoolYearSelect(resp);
|
||||
|
||||
const data = (resp.invoices || []).map(r => {
|
||||
const ts = Date.parse(r.invoice_date || new Date().toISOString());
|
||||
@@ -240,7 +231,7 @@
|
||||
try {
|
||||
await generateInvoice(btn.getAttribute('data-parent-id'));
|
||||
// Refresh table minimally: reload data
|
||||
const resp = await loadInvoices($year.value);
|
||||
const resp = await loadInvoices(selectedSchoolYear());
|
||||
const data = (resp.invoices || []).map(r => {
|
||||
const ts = Date.parse(r.invoice_date || new Date().toISOString());
|
||||
const genBtn = `<button type=\"button\" class=\"btn btn-primary btn-sm gen-invoice\" data-parent-id=\"${esc(r.parent_id)}\" onclick=\"return window.__genInvoice && window.__genInvoice(this)\">Generate Invoice</button>`;
|
||||
@@ -273,7 +264,7 @@
|
||||
try {
|
||||
this.disabled = true;
|
||||
await generateInvoice(this.getAttribute('data-parent-id'));
|
||||
const resp = await loadInvoices($year.value);
|
||||
const resp = await loadInvoices(selectedSchoolYear());
|
||||
const data = (resp.invoices || []).map(r => {
|
||||
const ts = Date.parse(r.invoice_date || new Date().toISOString());
|
||||
const genBtn = `<button type=\"button\" class=\"btn btn-primary btn-sm gen-invoice\" data-parent-id=\"${esc(r.parent_id)}\" onclick=\"return window.__genInvoice && window.__genInvoice(this)\">Generate Invoice</button>`;
|
||||
@@ -312,6 +303,7 @@
|
||||
});
|
||||
|
||||
// Year change
|
||||
if ($year) {
|
||||
$year.addEventListener('change', async () => {
|
||||
try {
|
||||
const resp = await loadInvoices($year.value);
|
||||
@@ -340,6 +332,7 @@
|
||||
showToast('Failed to load ' + $year.value, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<?= $this->endSection() ?>
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
<body>
|
||||
<!-- Navbar -->
|
||||
<?php include(__DIR__ . '/../partials/navbar.php'); ?>
|
||||
<?= view('partials/school_year_selector') ?>
|
||||
|
||||
<div class="container my-4">
|
||||
<?= $this->renderSection('content') ?>
|
||||
|
||||
@@ -165,6 +165,7 @@ html, body { overflow-x: hidden; }
|
||||
|
||||
<body data-app-menu-mode="<?= esc($appMenuMode) ?>">
|
||||
<?php include(__DIR__ . '/../partials/navbar.php'); ?>
|
||||
<?= view('partials/school_year_selector') ?>
|
||||
|
||||
<?php
|
||||
$uri = service('uri');
|
||||
@@ -188,7 +189,11 @@ html, body { overflow-x: hidden; }
|
||||
if ($isTeacher) {
|
||||
$cfg = new \App\Models\ConfigurationModel();
|
||||
$tcModel = new \App\Models\TeacherClassModel();
|
||||
try {
|
||||
$sy = service('schoolYearContext')->resolve(service('request'))->yearName();
|
||||
} catch (\Throwable $e) {
|
||||
$sy = (string)($cfg->getConfig('school_year') ?? '');
|
||||
}
|
||||
$sem = (string)($cfg->getConfig('semester') ?? '');
|
||||
$uid = (int)(session()->get('user_id') ?? 0);
|
||||
if ($uid) {
|
||||
|
||||
@@ -287,6 +287,20 @@
|
||||
<!-- Header & Navbar -->
|
||||
<?php include(__DIR__ . '/../partials/header_back.php'); ?>
|
||||
<?php include(__DIR__ . '/../partials/navbar_dynamic.php'); ?>
|
||||
<?php
|
||||
$managementSchoolYearSelectorData = [];
|
||||
if (!isset($schoolYearContext, $schoolYearOptions) && session()->get('user_id')) {
|
||||
try {
|
||||
$managementSchoolYearSelectorData = service('schoolYearViewData')->forCurrentRequest(service('request'));
|
||||
$managementSchoolYearSelectorData['schoolYearSelectorEnabled'] = true;
|
||||
} catch (\Throwable $e) {
|
||||
log_message('warning', 'Unable to load management school-year selector: {message}', [
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
?>
|
||||
<?= view('partials/school_year_selector', $managementSchoolYearSelectorData) ?>
|
||||
|
||||
<!-- Main Wrapper -->
|
||||
<div class="wrapper">
|
||||
|
||||
@@ -20,6 +20,8 @@ if ($currentYear === '' || $currentSem === '') {
|
||||
} catch (\Throwable $e) { /* ignore */ }
|
||||
}
|
||||
|
||||
$showSchoolYearControl = $showSchoolYearControl ?? true;
|
||||
|
||||
$selectedYear = (string)($schoolYear ?? ($_GET['school_year'] ?? ''));
|
||||
if ($selectedYear === '') { $selectedYear = $currentYear; }
|
||||
|
||||
@@ -32,6 +34,7 @@ $classSectionVal = (string)($classSectionId ?? ($_GET['class_section_id'] ?? '')
|
||||
<?php if ($classSectionVal !== ''): ?>
|
||||
<input type="hidden" name="class_section_id" value="<?= esc($classSectionVal) ?>">
|
||||
<?php endif; ?>
|
||||
<?php if ($showSchoolYearControl): ?>
|
||||
<div class="col-auto"><label class="form-label mb-0">School year</label></div>
|
||||
<div class="col-auto">
|
||||
<?php $years = isset($schoolYears) && is_array($schoolYears) ? $schoolYears : []; ?>
|
||||
@@ -44,11 +47,12 @@ $classSectionVal = (string)($classSectionId ?? ($_GET['class_section_id'] ?? '')
|
||||
<?php if ($selectedYear !== ''): ?>
|
||||
<option value="<?= esc($selectedYear) ?>" selected><?= esc($selectedYear) ?></option>
|
||||
<?php else: ?>
|
||||
<option value="">—</option>
|
||||
<option value="">-</option>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</select>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="col-auto"><label class="form-label mb-0">Semester</label></div>
|
||||
<div class="col-auto">
|
||||
|
||||
@@ -60,7 +60,11 @@ switch ($role) {
|
||||
}
|
||||
} else {
|
||||
$configModel = new \App\Models\ConfigurationModel();
|
||||
try {
|
||||
$schoolYear = service('schoolYearContext')->resolve(service('request'))->yearName();
|
||||
} catch (\Throwable $e) {
|
||||
$schoolYear = session()->get('school_year') ?? $configModel->getConfig('school_year');
|
||||
}
|
||||
$classSectionId = (int)($class_section_id ?? session()->get('class_section_id') ?? 0);
|
||||
if ($classSectionId > 0 && !empty($schoolYear)) {
|
||||
$scoreCardStudents = $studentModel->getByClassAndYear($classSectionId, $schoolYear);
|
||||
@@ -267,7 +271,11 @@ switch ($role) {
|
||||
if (!isset($activeEventCount) && ($role ?? '') === 'parent') {
|
||||
$configModel = new \App\Models\ConfigurationModel();
|
||||
$eventModel = new \App\Models\EventModel();
|
||||
try {
|
||||
$year = service('schoolYearContext')->resolve(service('request'))->yearName();
|
||||
} catch (\Throwable $e) {
|
||||
$year = $sess->get('school_year') ?? $configModel->getConfig('school_year');
|
||||
}
|
||||
$semester = $sess->get('semester') ?? $configModel->getConfig('semester');
|
||||
$activeEventCount = count($eventModel->getActiveEvents($year, $semester) ?? []);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php if (!empty($schoolYearSelectorEnabled) && isset($schoolYearContext, $schoolYearOptions)): ?>
|
||||
<?php
|
||||
$query = service('request')->getUri()->getQuery();
|
||||
$returnTo = current_url() . ($query !== '' ? '?' . $query : '');
|
||||
?>
|
||||
<div class="school-year-context-bar border-bottom bg-light py-2">
|
||||
<div class="container-fluid px-3">
|
||||
<form method="post" action="<?= site_url('school-year/select') ?>" class="d-flex align-items-center gap-2 flex-wrap">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="return_to" value="<?= esc($returnTo) ?>">
|
||||
|
||||
<label for="global-school-year" class="form-label mb-0 fw-semibold">
|
||||
School year
|
||||
</label>
|
||||
|
||||
<select
|
||||
id="global-school-year"
|
||||
name="school_year_id"
|
||||
class="form-select form-select-sm w-auto"
|
||||
onchange="this.form.submit()"
|
||||
aria-label="Selected school year"
|
||||
>
|
||||
<?php foreach ($schoolYearOptions as $year): ?>
|
||||
<option
|
||||
value="<?= (int) $year['id'] ?>"
|
||||
<?= (int) $year['id'] === $schoolYearContext->id() ? 'selected' : '' ?>
|
||||
>
|
||||
<?= esc($year['name']) ?> - <?= esc(ucfirst((string) $year['status'])) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
|
||||
<?php if ($schoolYearContext->isReadonly()): ?>
|
||||
<span class="btn btn-warning btn-sm disabled" aria-disabled="true">
|
||||
<i class="bi bi-lock-fill"></i> READ-ONLY
|
||||
</span>
|
||||
<?php else: ?>
|
||||
<span class="badge text-bg-success">ACTIVE</span>
|
||||
<?php endif; ?>
|
||||
|
||||
<span class="text-muted small"><?= esc($schoolYearContext->yearName()) ?></span>
|
||||
</form>
|
||||
<?php if ($schoolYearContext->isExplicitSelection()): ?>
|
||||
<form method="post" action="<?= site_url('school-year/reset') ?>" class="d-inline-block mt-2 mt-sm-0 ms-sm-2">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="return_to" value="<?= esc($returnTo) ?>">
|
||||
<button type="submit" class="btn btn-outline-secondary btn-sm">
|
||||
Active year
|
||||
</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
@@ -1,3 +1,35 @@
|
||||
|
||||
Yesterday 11:12 PM
|
||||
write sql querry to update school_year = 2025-2026
|
||||
|
||||
UPDATE your_table_name
|
||||
SET school_year = '2025-2026'
|
||||
WHERE your_condition;
|
||||
To update every row:
|
||||
|
||||
UPDATE your_table_name
|
||||
SET school_year = '2025-2026';
|
||||
Use the second version only if you genuinely intend to rewrite the entire table, because databases are remarkably obedient when humans forget WHERE.
|
||||
|
||||
|
||||
Today 12:49 AM
|
||||
Screenshot 2026-07-14 at 12.49.07 AM.png
|
||||
|
||||
Pasted code(6).php
|
||||
File
|
||||
remove the image content from the view
|
||||
|
||||
Removed the School year / Semester filter from the view by deleting:
|
||||
|
||||
<?= $this->include('partials/academic_filter') ?>
|
||||
Pasted_code_6_without_academic_filter.php
|
||||
|
||||
|
||||
Library
|
||||
/
|
||||
Pasted_code_6_without_academic_filter.php
|
||||
|
||||
|
||||
<?= $this->extend('layout/management_layout') ?>
|
||||
<?= $this->section('content') ?>
|
||||
<?php
|
||||
@@ -28,8 +60,6 @@ $formatRole = function (?string $role): string {
|
||||
<h2 class="text-dark mb-3" style="font-family: Arial, sans-serif;">Generate Staff Badges</h2>
|
||||
</div>
|
||||
|
||||
<?= $this->include('partials/academic_filter') ?>
|
||||
|
||||
<div class="d-flex flex-column flex-md-row justify-content-md-end align-items-md-center gap-2">
|
||||
<!-- Submit button associates to form below -->
|
||||
<button type="submit" class="btn btn-success mt-3" form="badgeForm">Generate Badges</button>
|
||||
@@ -38,9 +68,6 @@ $formatRole = function (?string $role): string {
|
||||
<!-- Staff selection -->
|
||||
<form method="post" action="<?= base_url('badge') ?>" target="_blank" id="badgeForm">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="school_year" value="<?= esc($selectedYear ?? '') ?>">
|
||||
<!-- hidden container to preserve selections and their data (guaranteed inside form) -->
|
||||
<div id="selectedHiddenInputs" style="display:none"></div>
|
||||
|
||||
<table id="staffTable" class="table table-bordered table-striped align-middle w-100">
|
||||
<thead class="table-dark">
|
||||
@@ -278,11 +305,356 @@ $formatRole = function (?string $role): string {
|
||||
async function refreshCsrf() {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (selectedYear) params.append('school_year', selectedYear);
|
||||
const resp = await fetch('<?= base_url('api/printables/badges/status') ?>' + (params.toString() ? ('?' + params.toString()) : ''), {
|
||||
method: 'GET',
|
||||
headers: { 'Accept': 'application/json' }
|
||||
});
|
||||
|
||||
if (!resp.ok) return;
|
||||
const json = await resp.json();
|
||||
if (json && json.csrf_token && json.csrf_hash && csrfInput && json.csrf_token === csrfName) {
|
||||
csrfInput.value = json.csrf_hash;
|
||||
}
|
||||
} catch (e) {
|
||||
// No-op: if refresh fails we'll still attempt submit; server may accept existing token
|
||||
}
|
||||
}
|
||||
|
||||
form.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
if (selected.size === 0) {
|
||||
alert('Please select at least one staff member to generate badges.');
|
||||
return;
|
||||
}
|
||||
syncHiddenInputs();
|
||||
// CSRF is excluded for this endpoint now, but keeping refresh is safe
|
||||
try { await refreshCsrf(); } catch (_) {}
|
||||
// Native submit so browser handles PDF in a new tab
|
||||
form.submit();
|
||||
// Auto-clear selections 5 seconds after generating badges
|
||||
clearTimerId = setTimeout(function() { clearSelectionsNow(); clearTimerId = null; }, 5000);
|
||||
});
|
||||
|
||||
// --- Fetch and render print status ---
|
||||
const selectedYear = <?= json_encode($selectedYear ?? '') ?>;
|
||||
|
||||
function fetchPrintStatus() {
|
||||
const ids = Object.keys(rowMeta);
|
||||
if (ids.length === 0) return;
|
||||
const params = new URLSearchParams();
|
||||
ids.forEach(id => params.append('user_ids[]', id));
|
||||
if (selectedYear) params.append('school_year', selectedYear);
|
||||
|
||||
fetch('<?= base_url('api/printables/badges/status') ?>?' + params.toString(), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(r => r.ok ? r.json() : Promise.reject())
|
||||
.then(json => {
|
||||
if (!json || !json.ok || !json.data) return;
|
||||
const data = json.data;
|
||||
Object.keys(rowMeta).forEach(id => {
|
||||
const info = data[id];
|
||||
const badge = document.querySelector(`.prints-badge[data-user-id="${id}"]`);
|
||||
const tr = document.querySelector(`#staffTable tbody tr[data-user-id="${id}"]`);
|
||||
if (!badge || !tr) return;
|
||||
const count = info ? (info.count || 0) : 0;
|
||||
badge.textContent = count > 0 ? `Printed ${count}` : '—';
|
||||
badge.className = 'badge prints-badge ' + (count > 0 ? 'bg-success' : 'bg-secondary');
|
||||
tr.classList.toggle('table-success', count > 0);
|
||||
});
|
||||
// Refresh CSRF for subsequent submissions (so no page reload is needed)
|
||||
if (json.csrf_token && json.csrf_hash && csrfInput && json.csrf_token === csrfName) {
|
||||
csrfInput.value = json.csrf_hash;
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
fetchPrintStatus();
|
||||
window.addEventListener('focus', fetchPrintStatus);
|
||||
});
|
||||
</script>
|
||||
<?= $this->endSection() ?>
|
||||
Library
|
||||
/
|
||||
Pasted_code_6_without_academic_filter.php
|
||||
|
||||
|
||||
<?= $this->extend('layout/management_layout') ?>
|
||||
<?= $this->section('content') ?>
|
||||
<?php
|
||||
// Role formatter for display + posting
|
||||
$formatRole = function (?string $role): string {
|
||||
$role = (string)$role;
|
||||
$role = str_replace(['-', '_'], ' ', $role);
|
||||
$role = preg_replace('/\s+/', ' ', trim($role));
|
||||
if ($role === '') return '';
|
||||
$out = [];
|
||||
foreach (explode(' ', $role) as $w) {
|
||||
if ($w === '') continue;
|
||||
if (preg_match('/^[A-Za-z]{1,3}$/', $w)) {
|
||||
$out[] = strtoupper($w); // TA, PTA, HR, KG...
|
||||
} else {
|
||||
$out[] = ucfirst(strtolower($w)); // Teacher, Assistant, Admin...
|
||||
}
|
||||
}
|
||||
return implode(' ', $out);
|
||||
};
|
||||
?>
|
||||
<div class="container-fluid">
|
||||
<div class="wrapper">
|
||||
<div class="content">
|
||||
|
||||
<div class="text-center mx-auto mb-5 wow" data-wow-delay="0.1s" style="max-width: 600px;">
|
||||
<br>
|
||||
<h2 class="text-dark mb-3" style="font-family: Arial, sans-serif;">Generate Staff Badges</h2>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-column flex-md-row justify-content-md-end align-items-md-center gap-2">
|
||||
<!-- Submit button associates to form below -->
|
||||
<button type="submit" class="btn btn-success mt-3" form="badgeForm">Generate Badges</button>
|
||||
<br>
|
||||
</div>
|
||||
<!-- Staff selection -->
|
||||
<form method="post" action="<?= base_url('badge') ?>" target="_blank" id="badgeForm">
|
||||
<?= csrf_field() ?>
|
||||
|
||||
<table id="staffTable" class="table table-bordered table-striped align-middle w-100">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th style="width: 70px;">#</th>
|
||||
<th scope="col">Firstname</th>
|
||||
<th scope="col">Lastname</th>
|
||||
<th scope="col">Role</th>
|
||||
<th scope="col">Teacher Class Section</th>
|
||||
<th scope="col" style="width: 140px;">Badge Prints</th>
|
||||
<th scope="col" style="width: 150px;">
|
||||
<div class="form-check m-0">
|
||||
<input class="form-check-input" type="checkbox" id="select-all">
|
||||
<label class="form-check-label" for="select-all">Select All (page)</label>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (!empty($users)): ?>
|
||||
<?php $order = 1; ?>
|
||||
<?php foreach ($users as $user): ?>
|
||||
<?php
|
||||
// Pick a representative role (first of CSV or role_name/active_role)
|
||||
$roleRaw = $user['active_role'] ?? $user['role_name'] ?? ($user['roles'] ?? '');
|
||||
if (strpos((string)$roleRaw, ',') !== false) {
|
||||
$parts = array_filter(array_map('trim', explode(',', (string)$roleRaw)));
|
||||
$roleRaw = $parts[0] ?? '';
|
||||
}
|
||||
//$roleLabel = $roleRaw !== '' ? $formatRole($roleRaw) : '-';
|
||||
$roleLabel = $user['role_name'] ?? ($user['roles'] ?? '-'); // both are pre-formatted by $formatRole
|
||||
|
||||
// Normalize user id key for checkbox
|
||||
$uid = $user['user_id'] ?? $user['id'] ?? ($user['users.id'] ?? null);
|
||||
|
||||
// Class section name (optional)
|
||||
$className = !empty($user['class_section_name']) ? (string)$user['class_section_name'] : '';
|
||||
?>
|
||||
<tr
|
||||
data-user-id="<?= esc($uid ?? '') ?>"
|
||||
data-role-label="<?= esc($roleLabel) ?>"
|
||||
data-class-name="<?= esc($className) ?>">
|
||||
<td style="text-align: center;"><?= esc($order++) ?></td>
|
||||
<td><?= esc($user['firstname'] ?? '') ?></td>
|
||||
<td><?= esc($user['lastname'] ?? '') ?></td>
|
||||
<td><?= esc($roleLabel) ?></td>
|
||||
<td><?= $className !== '' ? esc($className) : '-' ?></td>
|
||||
<td><span class="badge bg-secondary prints-badge" data-user-id="<?= esc($uid ?? '') ?>">—</span></td>
|
||||
<td>
|
||||
<?php if ($uid !== null): ?>
|
||||
<div class="form-check m-0">
|
||||
<input type="checkbox" name="user_ids[]" value="<?= esc($uid) ?>" class="form-check-input user-checkbox" id="chk-<?= esc($uid) ?>">
|
||||
<label class="form-check-label" for="chk-<?= esc($uid) ?>">Select</label>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<span class="text-muted">N/A</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php else: ?>
|
||||
<tr>
|
||||
<td colspan="7" class="text-center text-muted">No staff found for the selected year.</td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</form>
|
||||
<br>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?= $this->endSection() ?>
|
||||
|
||||
<?= $this->section('scripts') ?>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Track selected user IDs across paging/filtering/sorting
|
||||
const selected = new Set();
|
||||
const formEl = document.getElementById('badgeForm');
|
||||
let clearTimerId = null; // pending auto-clear timer, if any
|
||||
let hiddenContainer = document.getElementById('selectedHiddenInputs');
|
||||
// Ensure hidden container exists inside the form (some earlier layouts had it outside)
|
||||
if (!hiddenContainer || !formEl.contains(hiddenContainer)) {
|
||||
hiddenContainer = document.createElement('div');
|
||||
hiddenContainer.id = 'selectedHiddenInputs';
|
||||
hiddenContainer.style.display = 'none';
|
||||
formEl.appendChild(hiddenContainer);
|
||||
}
|
||||
const selectAll = document.getElementById('select-all');
|
||||
const csrfName = <?= json_encode(csrf_token()) ?>;
|
||||
const csrfInput = document.querySelector(`input[name='${csrfName}']`);
|
||||
|
||||
// Build a meta map (userId -> { role, className }) from the DOM BEFORE DataTables paginates
|
||||
const rowMeta = {};
|
||||
document.querySelectorAll('#staffTable tbody tr').forEach(tr => {
|
||||
const id = tr.getAttribute('data-user-id');
|
||||
if (!id) return;
|
||||
rowMeta[id] = {
|
||||
role: tr.getAttribute('data-role-label') || '',
|
||||
className: tr.getAttribute('data-class-name') || ''
|
||||
};
|
||||
});
|
||||
|
||||
const table = $('#staffTable').DataTable({
|
||||
stateSave: true,
|
||||
pageLength: 100,
|
||||
lengthMenu: [10, 25, 50, 100],
|
||||
order: [
|
||||
[1, 'asc'],
|
||||
[2, 'asc']
|
||||
], // Firstname, Lastname
|
||||
columnDefs: [{
|
||||
targets: 0,
|
||||
searchable: false
|
||||
}, // row #
|
||||
{
|
||||
targets: 6,
|
||||
orderable: false,
|
||||
searchable: false
|
||||
} // checkbox column
|
||||
]
|
||||
});
|
||||
|
||||
// Helper: keep hidden inputs in sync so selections submit even if not on current page
|
||||
function syncHiddenInputs() {
|
||||
hiddenContainer.innerHTML = '';
|
||||
selected.forEach(function(id) {
|
||||
const meta = rowMeta[id] || {
|
||||
role: '',
|
||||
className: ''
|
||||
};
|
||||
// user_ids[]
|
||||
const i1 = document.createElement('input');
|
||||
i1.type = 'hidden';
|
||||
i1.name = 'user_ids[]';
|
||||
i1.value = id;
|
||||
hiddenContainer.appendChild(i1);
|
||||
// roles[ID]
|
||||
const i2 = document.createElement('input');
|
||||
i2.type = 'hidden';
|
||||
i2.name = `roles[${id}]`;
|
||||
i2.value = meta.role;
|
||||
hiddenContainer.appendChild(i2);
|
||||
// classes[ID]
|
||||
const i3 = document.createElement('input');
|
||||
i3.type = 'hidden';
|
||||
i3.name = `classes[${id}]`;
|
||||
i3.value = meta.className;
|
||||
hiddenContainer.appendChild(i3);
|
||||
});
|
||||
}
|
||||
|
||||
// Helper: refresh checkboxes on current page based on Set
|
||||
function syncPageCheckboxes() {
|
||||
const pageNodes = table.rows({
|
||||
page: 'current'
|
||||
}).nodes().to$();
|
||||
let allChecked = true;
|
||||
let anyChecked = false;
|
||||
pageNodes.each(function() {
|
||||
const row = this;
|
||||
const userId = row.getAttribute('data-user-id');
|
||||
const cb = row.querySelector('.user-checkbox');
|
||||
if (!cb || !userId) return;
|
||||
cb.checked = selected.has(userId);
|
||||
if (!cb.checked) allChecked = false;
|
||||
if (cb.checked) anyChecked = true;
|
||||
});
|
||||
// Update header select-all for current page
|
||||
selectAll.checked = allChecked && pageNodes.length > 0;
|
||||
selectAll.indeterminate = !allChecked && anyChecked;
|
||||
}
|
||||
|
||||
// Helper: clear all selections and reset the current page UI
|
||||
function clearSelectionsNow() {
|
||||
selected.clear();
|
||||
// Uncheck visible checkboxes on current page
|
||||
table.rows({ page: 'current' }).every(function () {
|
||||
const row = this.node();
|
||||
const cb = row.querySelector('.user-checkbox');
|
||||
if (cb) cb.checked = false;
|
||||
});
|
||||
// Reset select-all and hidden inputs
|
||||
selectAll.checked = false;
|
||||
selectAll.indeterminate = false;
|
||||
syncHiddenInputs();
|
||||
syncPageCheckboxes();
|
||||
}
|
||||
|
||||
// On draw (paging/sort/search), just refresh checkbox UI states
|
||||
table.on('draw', function() {
|
||||
syncPageCheckboxes();
|
||||
});
|
||||
|
||||
// Delegate checkbox change handling once at the table level (works across redraws)
|
||||
document.getElementById('staffTable').addEventListener('change', function (e) {
|
||||
const target = e.target;
|
||||
if (!target || !target.classList.contains('user-checkbox')) return;
|
||||
const tr = target.closest('tr[data-user-id]');
|
||||
const userId = tr ? tr.getAttribute('data-user-id') : null;
|
||||
if (!userId) return;
|
||||
if (target.checked) selected.add(userId); else selected.delete(userId);
|
||||
syncHiddenInputs();
|
||||
syncPageCheckboxes();
|
||||
// If user interacts, cancel any pending auto-clear to avoid wiping new selections
|
||||
if (clearTimerId !== null) { clearTimeout(clearTimerId); clearTimerId = null; }
|
||||
});
|
||||
|
||||
// Initial sync on first load
|
||||
table.draw(false);
|
||||
|
||||
// Header "Select All (page)" toggler
|
||||
selectAll.addEventListener('change', function() {
|
||||
const check = this.checked;
|
||||
table.rows({
|
||||
page: 'current'
|
||||
}).every(function() {
|
||||
const row = this.node();
|
||||
const userId = row.getAttribute('data-user-id');
|
||||
const cb = row.querySelector('.user-checkbox');
|
||||
if (!cb || !userId) return;
|
||||
cb.checked = check;
|
||||
if (check) selected.add(userId);
|
||||
else selected.delete(userId);
|
||||
});
|
||||
syncHiddenInputs();
|
||||
syncPageCheckboxes();
|
||||
if (clearTimerId !== null) { clearTimeout(clearTimerId); clearTimerId = null; }
|
||||
});
|
||||
|
||||
// Before submit, ensure CSRF is fresh and hidden inputs include all selections
|
||||
const form = document.getElementById('badgeForm');
|
||||
async function refreshCsrf() {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (!resp.ok) return;
|
||||
const json = await resp.json();
|
||||
if (json && json.csrf_token && json.csrf_hash && csrfInput && json.csrf_token === csrfName) {
|
||||
|
||||
@@ -6,12 +6,48 @@
|
||||
$target = $preview['target'] ?? null;
|
||||
$overview = $preview['overview'] ?? [];
|
||||
$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];
|
||||
$blockers = $preview['blockers'] ?? [];
|
||||
$warnings = $preview['warnings'] ?? [];
|
||||
$carryForward = $preview['carry_forward'] ?? [];
|
||||
$status = (string) ($source['status'] ?? '');
|
||||
$batchStatus = (string) ($latestBatch['status'] ?? '');
|
||||
$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 !== '');
|
||||
|
||||
return site_url('administrator/school-years/' . (int) ($source['id'] ?? 0) . '/closing/preview?' . http_build_query($query));
|
||||
};
|
||||
$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)') : '';
|
||||
|
||||
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>';
|
||||
};
|
||||
?>
|
||||
|
||||
<?php if (session()->getFlashdata('success')): ?>
|
||||
@@ -23,21 +59,30 @@
|
||||
<div class="container-fluid py-3">
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
|
||||
<div>
|
||||
<h2 class="mb-1">Closing Preview: <?= esc($source['name'] ?? '') ?></h2>
|
||||
<h2 class="mb-1">End-Year Checklist: <?= esc($source['name'] ?? '') ?></h2>
|
||||
<div class="text-muted">
|
||||
Status: <span class="fw-semibold"><?= esc(ucfirst($status)) ?></span>
|
||||
<?php if ($target): ?>
|
||||
<span class="mx-2">Target: <span class="fw-semibold"><?= esc($target['name'] ?? '') ?></span></span>
|
||||
<span class="mx-2">Next year: <span class="fw-semibold"><?= esc($target['name'] ?? '') ?></span></span>
|
||||
<?php endif; ?>
|
||||
<span>Generated: <?= esc($preview['generated_at'] ?? '') ?></span>
|
||||
</div>
|
||||
</div>
|
||||
<a class="btn btn-outline-secondary" href="<?= site_url('administrator/school-years') ?>">Back to Management</a>
|
||||
<a class="btn btn-outline-secondary" href="<?= site_url('administrator/school-years') ?>">Back to School Years</a>
|
||||
</div>
|
||||
|
||||
<div class="border rounded bg-white p-3 mb-4">
|
||||
<h5 class="mb-2">How to End This Year</h5>
|
||||
<ol class="mb-0">
|
||||
<li>Clear all blocking issues below.</li>
|
||||
<li>Start the end-year process to lock the checklist.</li>
|
||||
<li>Confirm carry-forward balances, then mark this school year closed.</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<form class="row g-2 align-items-end mb-4" method="get" action="<?= site_url('administrator/school-years/' . (int) ($source['id'] ?? 0) . '/closing/preview') ?>">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="target_school_year_id">Target school year</label>
|
||||
<label class="form-label" for="target_school_year_id">Next school year</label>
|
||||
<select class="form-select" id="target_school_year_id" name="target_school_year_id">
|
||||
<option value="">Auto-select next draft year</option>
|
||||
<?php foreach (($schoolYears ?? []) as $year): ?>
|
||||
@@ -50,7 +95,7 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<button class="btn btn-primary" type="submit">Refresh Preview</button>
|
||||
<button class="btn btn-primary" type="submit">Refresh Checklist</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -130,11 +175,11 @@
|
||||
</div>
|
||||
<div class="col-lg-7">
|
||||
<div class="border rounded bg-white p-3 h-100">
|
||||
<h5>Closing Progress</h5>
|
||||
<h5>End-Year Progress</h5>
|
||||
<ol class="mb-0">
|
||||
<li>Preview generated</li>
|
||||
<li>Checklist generated</li>
|
||||
<li class="<?= $blockers === [] ? '' : 'text-muted' ?>">Validation <?= $blockers === [] ? 'passed' : 'has blockers' ?></li>
|
||||
<li class="<?= $batchStatus !== '' ? '' : 'text-muted' ?>">Closing batch <?= $batchStatus !== '' ? esc($batchStatus) : 'not created' ?></li>
|
||||
<li class="<?= $batchStatus !== '' ? '' : 'text-muted' ?>">End-year process <?= $batchStatus !== '' ? esc($batchStatus) : 'not started' ?></li>
|
||||
<li class="<?= in_array($batchStatus, ['executed', 'completed'], true) ? '' : 'text-muted' ?>">Carry-forward completed</li>
|
||||
<li class="<?= $status === 'closed' ? '' : 'text-muted' ?>">Year closed</li>
|
||||
</ol>
|
||||
@@ -142,13 +187,186 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border rounded bg-white p-3 mb-4">
|
||||
<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>
|
||||
<div class="text-muted small">Promotion decisions must be generated and resolved before this school year can end.</div>
|
||||
</div>
|
||||
<a class="btn btn-outline-primary btn-sm" href="<?= site_url('grading/decisions?' . http_build_query(['school_year' => $source['name'] ?? ''])) ?>">
|
||||
Manage Promotion Decisions
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-lg-2 col-md-3 col-6">
|
||||
<div class="border rounded p-2 h-100">
|
||||
<div class="text-muted small">Students</div>
|
||||
<div class="fw-semibold"><?= esc((string) ($promotionSummary['total_students'] ?? 0)) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-2 col-md-3 col-6">
|
||||
<div class="border rounded p-2 h-100">
|
||||
<div class="text-muted small">Decided</div>
|
||||
<div class="fw-semibold"><?= esc((string) ($promotionSummary['with_decision'] ?? 0)) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-2 col-md-3 col-6">
|
||||
<div class="border rounded p-2 h-100">
|
||||
<div class="text-muted small">Pass</div>
|
||||
<div class="fw-semibold"><?= esc((string) ($promotionSummary['pass'] ?? 0)) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-2 col-md-3 col-6">
|
||||
<div class="border rounded p-2 h-100">
|
||||
<div class="text-muted small">Other</div>
|
||||
<div class="fw-semibold"><?= esc((string) ($promotionSummary['other_decision'] ?? 0)) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-2 col-md-3 col-6">
|
||||
<div class="border rounded p-2 h-100">
|
||||
<div class="text-muted small">Queued</div>
|
||||
<div class="fw-semibold"><?= esc((string) ($promotionSummary['queued'] ?? 0)) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-2 col-md-3 col-6">
|
||||
<div class="border rounded p-2 h-100">
|
||||
<div class="text-muted small">Assigned</div>
|
||||
<div class="fw-semibold"><?= esc((string) ($promotionSummary['assigned'] ?? 0)) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-2 col-md-3 col-6">
|
||||
<div class="border rounded p-2 h-100">
|
||||
<div class="text-muted small">Applied</div>
|
||||
<div class="fw-semibold"><?= esc((string) ($promotionSummary['applied'] ?? 0)) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-2 col-md-3 col-6">
|
||||
<div class="border rounded p-2 h-100">
|
||||
<div class="text-muted small">Pending</div>
|
||||
<div class="fw-semibold text-danger"><?= esc((string) ($promotionSummary['pending_decision'] ?? 0)) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-2 col-md-3 col-6">
|
||||
<div class="border rounded p-2 h-100">
|
||||
<div class="text-muted small">Missing</div>
|
||||
<div class="fw-semibold text-danger"><?= esc((string) ($promotionSummary['missing_decision'] ?? 0)) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-2 col-md-3 col-6">
|
||||
<div class="border rounded p-2 h-100">
|
||||
<div class="text-muted small">Missing Queue</div>
|
||||
<div class="fw-semibold text-danger"><?= esc((string) ($promotionSummary['missing_queue'] ?? 0)) ?></div>
|
||||
</div>
|
||||
</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>
|
||||
<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>
|
||||
</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';
|
||||
}
|
||||
$targetLabel = trim((string) ($row['target_section'] ?? ''));
|
||||
if ($targetLabel === '') {
|
||||
$targetLabel = trim((string) ($row['target_class'] ?? ''));
|
||||
}
|
||||
?>
|
||||
<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><?= 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>
|
||||
</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">
|
||||
<h5>Carry-Forward Families</h5>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle">
|
||||
<table class="table table-sm align-middle no-mgmt-sticky" data-no-mgmt-sticky>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Family</th>
|
||||
<th>Parent</th>
|
||||
<th class="text-end">Source Balance</th>
|
||||
<th class="text-end">Credits</th>
|
||||
<th class="text-end">Adjustments</th>
|
||||
@@ -157,11 +375,17 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if ($carryForward === []): ?>
|
||||
<tr><td colspan="5" class="text-muted">No carry-forward balances found.</td></tr>
|
||||
<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>
|
||||
<td>
|
||||
<div><?= esc($row['parent'] ?? ('Parent #' . (int) ($row['family_id'] ?? 0))) ?></div>
|
||||
<?php if (! empty($row['parent_email'])): ?>
|
||||
<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>
|
||||
@@ -178,28 +402,37 @@
|
||||
<form action="<?= site_url('administrator/school-years/' . (int) $source['id'] . '/closing/start') ?>" method="post">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="target_school_year_id" value="<?= (int) $target['id'] ?>">
|
||||
<button class="btn btn-warning" type="submit">Begin Closing</button>
|
||||
<button class="btn btn-warning" type="submit">Close Year</button>
|
||||
</form>
|
||||
<?php elseif ($status === 'active'): ?>
|
||||
<button class="btn btn-warning" type="button" disabled>Close Year</button>
|
||||
<span class="align-self-center text-muted small">
|
||||
<?php if (! $target): ?>
|
||||
Create or select the next school year first.
|
||||
<?php elseif ($blockers !== []): ?>
|
||||
Resolve the blocking issues above first.
|
||||
<?php endif; ?>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($status === 'closing' && $batchStatus === 'started'): ?>
|
||||
<form action="<?= site_url('administrator/school-years/' . (int) $source['id'] . '/closing/execute') ?>" method="post">
|
||||
<?= csrf_field() ?>
|
||||
<button class="btn btn-primary" type="submit">Execute Carry-Forward</button>
|
||||
<button class="btn btn-primary" type="submit">Confirm Carry-Forward</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($status === 'closing' && $batchStatus === 'executed'): ?>
|
||||
<form action="<?= site_url('administrator/school-years/' . (int) $source['id'] . '/closing/complete') ?>" method="post">
|
||||
<?= csrf_field() ?>
|
||||
<button class="btn btn-success" type="submit">Complete Closing</button>
|
||||
<button class="btn btn-success" type="submit">Close Year</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($status === 'closing' && ! in_array($batchStatus, ['executed', 'completed'], true)): ?>
|
||||
<form action="<?= site_url('administrator/school-years/' . (int) $source['id'] . '/closing/cancel') ?>" method="post">
|
||||
<?= csrf_field() ?>
|
||||
<button class="btn btn-outline-secondary" type="submit">Cancel Closing</button>
|
||||
<button class="btn btn-outline-secondary" type="submit">Cancel End-Year Process</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,12 @@
|
||||
'archived' => 'bg-dark',
|
||||
];
|
||||
$formatDate = static fn ($value): string => $value ? esc((string) $value) : 'Not set';
|
||||
$money = static fn ($value): string => '$' . number_format((float) $value, 2);
|
||||
$activeId = (int) ($activeYear['id'] ?? 0);
|
||||
$nextDraftId = (int) ($nextDraftYear['id'] ?? 0);
|
||||
$closingPreviewUrl = $activeId > 0
|
||||
? site_url('administrator/school-years/' . $activeId . '/closing/preview' . ($nextDraftId > 0 ? '?' . http_build_query(['target_school_year_id' => $nextDraftId]) : ''))
|
||||
: '';
|
||||
?>
|
||||
|
||||
<?php if (session()->getFlashdata('success')): ?>
|
||||
@@ -19,46 +25,53 @@
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="container-fluid py-3">
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
|
||||
<div class="mb-3">
|
||||
<div>
|
||||
<h2 class="mb-1">School Year Management</h2>
|
||||
<div class="text-muted">
|
||||
Active year:
|
||||
<?php if (! empty($activeYear)): ?>
|
||||
<span class="badge bg-success"><?= esc($activeYear['name']) ?></span>
|
||||
<?php else: ?>
|
||||
<span class="badge bg-danger">None</span>
|
||||
<?php endif; ?>
|
||||
<h2 class="mb-1">School Years</h2>
|
||||
<div class="text-muted">End the current school year after its checklist is clear, then start the next year.</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-success" type="button" data-bs-toggle="collapse" data-bs-target="#schoolYearCreateForm">
|
||||
Add School Year
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="border rounded bg-white p-3 h-100">
|
||||
<div class="text-muted small">Active Year</div>
|
||||
<div class="fs-5 fw-semibold"><?= esc($activeYear['name'] ?? 'None') ?></div>
|
||||
<div class="border rounded bg-white p-3 mb-4">
|
||||
<div class="d-flex flex-wrap justify-content-between gap-3">
|
||||
<div>
|
||||
<h5 class="mb-2">End Current School Year</h5>
|
||||
<div class="d-flex flex-wrap gap-2 mb-2">
|
||||
<span class="badge <?= $activeYear ? 'bg-success' : 'bg-secondary' ?>">Current: <?= esc($activeYear['name'] ?? 'None') ?></span>
|
||||
<span class="badge <?= $nextDraftYear ? 'bg-info text-dark' : 'bg-secondary' ?>">Next: <?= esc($nextDraftYear['name'] ?? 'Not created') ?></span>
|
||||
<?php if ($closingYear): ?>
|
||||
<span class="badge bg-warning text-dark">Ending: <?= esc($closingYear['name']) ?></span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php if ($closingYear): ?>
|
||||
<div class="text-muted">Finish the end-year checklist for <?= esc($closingYear['name']) ?> before starting another year.</div>
|
||||
<?php elseif ($activeYear && $nextDraftYear): ?>
|
||||
<div class="text-muted">Review promotions and balances for <?= esc($activeYear['name']) ?>. When all blockers are resolved, end it and start <?= esc($nextDraftYear['name']) ?>.</div>
|
||||
<?php elseif ($activeYear): ?>
|
||||
<div class="text-muted">Create the next school year first. It will stay as a draft until the current year is closed.</div>
|
||||
<?php elseif ($nextDraftYear): ?>
|
||||
<div class="text-muted">No year is active. Start <?= esc($nextDraftYear['name']) ?> when you are ready.</div>
|
||||
<?php else: ?>
|
||||
<div class="text-muted">Create a school year draft to begin.</div>
|
||||
<?php endif; ?>
|
||||
<ol class="small text-muted ps-3 mt-2 mb-0">
|
||||
<li>Create the next year as a draft.</li>
|
||||
<li>Review the end-year checklist for the current year.</li>
|
||||
<li>End the current year, then start the next year.</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="border rounded bg-white p-3 h-100">
|
||||
<div class="text-muted small">Next Draft</div>
|
||||
<div class="fs-5 fw-semibold"><?= esc($nextDraftYear['name'] ?? 'None') ?></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="border rounded bg-white p-3 h-100">
|
||||
<div class="text-muted small">Currently Closing</div>
|
||||
<div class="fs-5 fw-semibold"><?= esc($closingYear['name'] ?? 'None') ?></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="border rounded bg-white p-3 h-100">
|
||||
<div class="text-muted small">Archived Years</div>
|
||||
<div class="fs-5 fw-semibold"><?= esc((string) ($archivedCount ?? 0)) ?></div>
|
||||
<div class="d-flex align-items-start">
|
||||
<?php if ($closingYear): ?>
|
||||
<a class="btn btn-primary" href="<?= site_url('administrator/school-years/' . (int) $closingYear['id'] . '/closing/preview') ?>">Finish End-Year Checklist</a>
|
||||
<?php elseif ($activeYear && $nextDraftYear): ?>
|
||||
<a class="btn btn-primary" href="<?= esc($closingPreviewUrl, 'attr') ?>">Close Year</a>
|
||||
<?php elseif ($activeYear): ?>
|
||||
<button class="btn btn-success" type="button" data-bs-toggle="collapse" data-bs-target="#schoolYearCreateForm">Create Next Year</button>
|
||||
<?php elseif ($nextDraftYear): ?>
|
||||
<button class="btn btn-success" type="button" data-bs-toggle="modal" data-bs-target="#activateSchoolYearModal<?= $nextDraftId ?>">Start <?= esc($nextDraftYear['name']) ?></button>
|
||||
<?php else: ?>
|
||||
<button class="btn btn-success" type="button" data-bs-toggle="collapse" data-bs-target="#schoolYearCreateForm">Create School Year</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -71,6 +84,15 @@
|
||||
<label class="form-label" for="new_school_year_name">School Year</label>
|
||||
<input class="form-control" id="new_school_year_name" name="name" placeholder="2026-2027" required pattern="\d{4}-\d{4}">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label" for="new_previous_school_year_id">Previous Year</label>
|
||||
<select class="form-select" id="new_previous_school_year_id" name="previous_school_year_id">
|
||||
<option value="">None</option>
|
||||
<?php foreach (($schoolYears ?? []) as $existingYear): ?>
|
||||
<option value="<?= (int) ($existingYear['id'] ?? 0) ?>"><?= esc($existingYear['name'] ?? '') ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label" for="new_school_year_starts_on">Starts On</label>
|
||||
<input class="form-control" id="new_school_year_starts_on" name="starts_on" type="date">
|
||||
@@ -106,10 +128,8 @@
|
||||
<th>School Year</th>
|
||||
<th>Dates</th>
|
||||
<th>Status</th>
|
||||
<th>Students</th>
|
||||
<th>Families</th>
|
||||
<th>Financial Balance</th>
|
||||
<th>Last Transition</th>
|
||||
<th>Verify</th>
|
||||
<th>Last Activity</th>
|
||||
<th>Updated</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
@@ -121,6 +141,8 @@
|
||||
$status = (string) ($year['status'] ?? 'draft');
|
||||
$readonly = in_array($status, ['closed', 'archived'], true);
|
||||
$transition = $latestTransitions[$id] ?? null;
|
||||
$verify = $yearVerification[$id] ?? [];
|
||||
$previousYearName = $schoolYearNamesById[(int) ($year['previous_school_year_id'] ?? 0)] ?? null;
|
||||
?>
|
||||
<tr>
|
||||
<td class="fw-semibold"><?= esc($year['name'] ?? '') ?></td>
|
||||
@@ -133,9 +155,13 @@
|
||||
<?= esc(ucfirst($status)) ?>
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-muted">-</td>
|
||||
<td class="text-muted">-</td>
|
||||
<td class="text-muted">Preview</td>
|
||||
<td class="small">
|
||||
<div>Previous: <span class="fw-semibold"><?= esc($previousYearName ?? 'Not linked') ?></span></div>
|
||||
<div>Students: <span class="fw-semibold"><?= esc((string) ($verify['students'] ?? 0)) ?></span></div>
|
||||
<div>Promoted: <span class="fw-semibold"><?= esc((string) ($verify['promoted'] ?? 0)) ?></span> / Decisions: <?= esc((string) ($verify['decisions'] ?? 0)) ?></div>
|
||||
<div>Queued: <span class="fw-semibold"><?= esc((string) ($verify['queued'] ?? 0)) ?></span></div>
|
||||
<div>Carry-forward: <span class="fw-semibold"><?= esc($money($verify['carry_forward_balance'] ?? 0)) ?></span></div>
|
||||
</td>
|
||||
<td>
|
||||
<?php if ($transition): ?>
|
||||
<div><?= esc($transition['action'] ?? '') ?></div>
|
||||
@@ -159,11 +185,13 @@
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
<?php if ($status === 'draft'): ?>
|
||||
<?php if (empty($activeYear) && empty($closingYear)): ?>
|
||||
<li>
|
||||
<button class="dropdown-item" type="button" data-bs-toggle="modal" data-bs-target="#activateSchoolYearModal<?= $id ?>">
|
||||
Activate
|
||||
Start this year
|
||||
</button>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<form action="<?= site_url('administrator/school-years/' . $id . '/delete-draft') ?>" method="post" onsubmit="return confirm('Delete this unused draft school year?');">
|
||||
@@ -173,21 +201,21 @@
|
||||
</li>
|
||||
<?php elseif ($status === 'active'): ?>
|
||||
<li>
|
||||
<a class="dropdown-item" href="<?= site_url('administrator/school-years/' . $id . '/closing/preview') ?>">Preview closing</a>
|
||||
<a class="dropdown-item" href="<?= site_url('administrator/school-years/' . $id . '/closing/preview' . ($nextDraftId > 0 ? '?' . http_build_query(['target_school_year_id' => $nextDraftId]) : '')) ?>">Close year</a>
|
||||
</li>
|
||||
<?php elseif ($status === 'closing'): ?>
|
||||
<li>
|
||||
<a class="dropdown-item" href="<?= site_url('administrator/school-years/' . $id . '/closing/preview') ?>">View closing</a>
|
||||
<a class="dropdown-item" href="<?= site_url('administrator/school-years/' . $id . '/closing/preview') ?>">Finish end-year checklist</a>
|
||||
</li>
|
||||
<li>
|
||||
<form action="<?= site_url('administrator/school-years/' . $id . '/closing/cancel') ?>" method="post" onsubmit="return confirm('Cancel closing for this school year?');">
|
||||
<form action="<?= site_url('administrator/school-years/' . $id . '/closing/cancel') ?>" method="post" onsubmit="return confirm('Cancel the end-year process for this school year?');">
|
||||
<?= csrf_field() ?>
|
||||
<button class="dropdown-item" type="submit">Cancel closing</button>
|
||||
<button class="dropdown-item" type="submit">Cancel end-year process</button>
|
||||
</form>
|
||||
</li>
|
||||
<?php elseif ($status === 'closed'): ?>
|
||||
<li>
|
||||
<a class="dropdown-item" href="<?= site_url('administrator/school-years/' . $id . '/closing/preview') ?>">View closing report</a>
|
||||
<a class="dropdown-item" href="<?= site_url('administrator/school-years/' . $id . '/closing/preview') ?>">View end-year report</a>
|
||||
</li>
|
||||
<li>
|
||||
<button class="dropdown-item" type="button" data-bs-toggle="modal" data-bs-target="#reopenSchoolYearModal<?= $id ?>">
|
||||
@@ -220,6 +248,8 @@
|
||||
$id = (int) ($year['id'] ?? 0);
|
||||
$status = (string) ($year['status'] ?? 'draft');
|
||||
$readonly = in_array($status, ['closed', 'archived', 'closing'], true);
|
||||
$verify = $yearVerification[$id] ?? [];
|
||||
$previousYearName = $schoolYearNamesById[(int) ($year['previous_school_year_id'] ?? 0)] ?? null;
|
||||
?>
|
||||
<?php if (! $readonly): ?>
|
||||
<div class="modal fade" id="editSchoolYearModal<?= $id ?>" tabindex="-1" aria-hidden="true">
|
||||
@@ -231,6 +261,47 @@
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="border rounded bg-light p-3 mb-3">
|
||||
<h6 class="mb-2">Current Verification Data</h6>
|
||||
<div class="row g-2 small">
|
||||
<div class="col-md-4">
|
||||
<div class="text-muted">Previous year</div>
|
||||
<div class="fw-semibold"><?= esc($previousYearName ?? 'Not linked') ?></div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="text-muted">Students in year</div>
|
||||
<div class="fw-semibold"><?= esc((string) ($verify['students'] ?? 0)) ?></div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="text-muted">Promotion decisions</div>
|
||||
<div class="fw-semibold"><?= esc((string) ($verify['decisions'] ?? 0)) ?></div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="text-muted">Promoted students</div>
|
||||
<div class="fw-semibold"><?= esc((string) ($verify['promoted'] ?? 0)) ?></div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="text-muted">Queued for next year</div>
|
||||
<div class="fw-semibold"><?= esc((string) ($verify['queued'] ?? 0)) ?></div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="text-muted">Carry-forward balance</div>
|
||||
<div class="fw-semibold"><?= esc($money($verify['carry_forward_balance'] ?? 0)) ?></div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="text-muted">Positive balances</div>
|
||||
<div class="fw-semibold"><?= esc($money($verify['positive_balance'] ?? 0)) ?></div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="text-muted">Credits</div>
|
||||
<div class="fw-semibold"><?= esc($money($verify['credit_balance'] ?? 0)) ?></div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="text-muted">End-year status</div>
|
||||
<div class="fw-semibold"><?= esc(($verify['closing_status'] ?? '') !== '' ? ucfirst((string) $verify['closing_status']) : 'Not started') ?></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label" for="school_year_name_<?= $id ?>">School Year</label>
|
||||
@@ -245,6 +316,19 @@
|
||||
<input class="form-control" id="school_year_ends_on_<?= $id ?>" name="ends_on" type="date" value="<?= esc($year['ends_on'] ?? '') ?>">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="previous_school_year_id_<?= $id ?>">Previous Year</label>
|
||||
<select class="form-select" id="previous_school_year_id_<?= $id ?>" name="previous_school_year_id">
|
||||
<option value="">None</option>
|
||||
<?php foreach (($schoolYears ?? []) as $existingYear): ?>
|
||||
<?php if ((int) ($existingYear['id'] ?? 0) !== $id): ?>
|
||||
<option value="<?= (int) ($existingYear['id'] ?? 0) ?>" <?= (int) ($year['previous_school_year_id'] ?? 0) === (int) ($existingYear['id'] ?? 0) ? 'selected' : '' ?>>
|
||||
<?= esc($existingYear['name'] ?? '') ?>
|
||||
</option>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="registration_starts_on_<?= $id ?>">Registration Starts</label>
|
||||
@@ -273,24 +357,24 @@
|
||||
<form class="modal-content" action="<?= site_url('administrator/school-years/' . $id . '/activate') ?>" method="post">
|
||||
<?= csrf_field() ?>
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Activate <?= esc($year['name'] ?? '') ?></h5>
|
||||
<h5 class="modal-title">Start <?= esc($year['name'] ?? '') ?></h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="mb-2">The selected school year will become active.</p>
|
||||
<p class="mb-2">This school year will become the active year.</p>
|
||||
<?php if (! empty($activeYear)): ?>
|
||||
<p class="mb-3">Current active year <strong><?= esc($activeYear['name']) ?></strong> will move to <strong>closing</strong>.</p>
|
||||
<p class="mb-3">Current active year <strong><?= esc($activeYear['name']) ?></strong> will move to <strong>end-year review</strong>.</p>
|
||||
<?php endif; ?>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" value="1" name="confirm_activation" id="confirm_activation_<?= $id ?>" required>
|
||||
<label class="form-check-label" for="confirm_activation_<?= $id ?>">
|
||||
I understand activation does not complete closing for the previous year.
|
||||
I understand this starts the selected year.
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-success">Activate</button>
|
||||
<button type="submit" class="btn btn-success">Start Year</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -101,9 +101,9 @@ function orderNum($v)
|
||||
?>
|
||||
|
||||
<div class="mb-4 text-center">
|
||||
<p class="text-muted small mb-1">Active Students</p>
|
||||
<p class="text-muted small mb-1">Enrolled Students</p>
|
||||
<div class="display-4 fw-semibold"><?= (int)$totalStudents ?></div>
|
||||
<p class="text-muted small mb-0">Reflects only currently active students, so removals update the count instantly.</p>
|
||||
<p class="text-muted small mb-0">Reflects accepted enrollments for the selected school year.</p>
|
||||
</div>
|
||||
|
||||
<form method="get" class="row g-3 mb-4 align-items-end">
|
||||
@@ -132,8 +132,8 @@ function orderNum($v)
|
||||
<div class="col-lg-4">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-header py-2 d-flex justify-content-between align-items-center">
|
||||
<strong>Active Student Snapshot</strong>
|
||||
<span class="badge bg-primary"><?= (int)$totalStudents ?> active</span>
|
||||
<strong>Enrolled Student Snapshot</strong>
|
||||
<span class="badge bg-primary"><?= (int)$totalStudents ?> enrolled</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-flex flex-wrap gap-3">
|
||||
|
||||
@@ -66,7 +66,11 @@
|
||||
if ($userId) {
|
||||
$configModel = new \App\Models\ConfigurationModel();
|
||||
$teacherClassModel = new \App\Models\TeacherClassModel();
|
||||
try {
|
||||
$sy = service('schoolYearContext')->resolve(service('request'))->yearName();
|
||||
} catch (\Throwable $e) {
|
||||
$sy = $configModel->getConfig('school_year');
|
||||
}
|
||||
$sem = $configModel->getConfig('semester');
|
||||
$classOptions = $teacherClassModel->getClassAssignmentsByUserId((int)$userId, (string)$sy, (string)$sem);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
|
||||
<div class="container mt-4">
|
||||
<h3>Competition Winners</h3>
|
||||
<p class="text-muted">Published competition winner announcements.</p>
|
||||
<p class="text-muted">
|
||||
Published competition winner announcements
|
||||
<?php if (!empty($schoolYear)): ?>
|
||||
for <?= esc($schoolYear) ?>
|
||||
<?php endif; ?>.
|
||||
</p>
|
||||
|
||||
<div class="list-group">
|
||||
<?php foreach ($competitions as $c): ?>
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
<h3><?= esc($competition['title']) ?></h3>
|
||||
<div class="text-muted mb-3">
|
||||
Published: <?= esc($competition['published_at'] ?? '') ?>
|
||||
<?php if (!empty($schoolYear)): ?>
|
||||
<span class="ms-2">School Year: <?= esc($schoolYear) ?></span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if (empty($winnersByClass)): ?>
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\App\Services;
|
||||
|
||||
use App\Models\SchoolYearModel;
|
||||
use App\Services\SchoolYearContextService;
|
||||
use CodeIgniter\HTTP\URI;
|
||||
use CodeIgniter\HTTP\UserAgent;
|
||||
use CodeIgniter\Test\CIUnitTestCase;
|
||||
use CodeIgniter\Test\Mock\MockIncomingRequest;
|
||||
use Config\App;
|
||||
|
||||
final class SchoolYearContextRequest extends MockIncomingRequest
|
||||
{
|
||||
public function __construct(private readonly array $gets = [])
|
||||
{
|
||||
parent::__construct(config(App::class), new URI('https://test.alrahmaisgl.org'), 'php://input', new UserAgent());
|
||||
}
|
||||
|
||||
public function getGet($key = null, $filter = null, $default = null)
|
||||
{
|
||||
if ($key === null) {
|
||||
return $this->gets;
|
||||
}
|
||||
|
||||
return $this->gets[$key] ?? $default;
|
||||
}
|
||||
}
|
||||
|
||||
final class SchoolYearContextFakeModel extends SchoolYearModel
|
||||
{
|
||||
public function __construct(private readonly array $rows)
|
||||
{
|
||||
}
|
||||
|
||||
public function find($id = null)
|
||||
{
|
||||
return $this->rows[(int) $id] ?? null;
|
||||
}
|
||||
|
||||
public function active(): ?array
|
||||
{
|
||||
foreach ($this->rows as $row) {
|
||||
if (($row['status'] ?? '') === 'active') {
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
final class SchoolYearContextServiceTest extends CIUnitTestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
if (method_exists(session(), 'isStarted') && ! session()->isStarted()) {
|
||||
session()->start();
|
||||
}
|
||||
}
|
||||
|
||||
public function testClosedSessionSelectionIsRetainedAsExplicitReadonlyContext(): void
|
||||
{
|
||||
session()->set('selected_school_year_id', 1);
|
||||
|
||||
$service = new SchoolYearContextService(new SchoolYearContextFakeModel([
|
||||
1 => ['id' => 1, 'name' => '2025-2026', 'status' => 'closed'],
|
||||
2 => ['id' => 2, 'name' => '2026-2027', 'status' => 'active'],
|
||||
]));
|
||||
|
||||
$context = $service->resolve(new SchoolYearContextRequest());
|
||||
|
||||
$this->assertSame(1, $context->id());
|
||||
$this->assertSame('2025-2026', $context->yearName());
|
||||
$this->assertTrue($context->isReadonly());
|
||||
$this->assertTrue($context->isExplicitSelection());
|
||||
$this->assertSame(1, session()->get('selected_school_year_id'));
|
||||
}
|
||||
|
||||
public function testInvalidSessionSelectionFallsBackToActiveYearAndClearsSession(): void
|
||||
{
|
||||
session()->set('selected_school_year_id', 99);
|
||||
|
||||
$service = new SchoolYearContextService(new SchoolYearContextFakeModel([
|
||||
2 => ['id' => 2, 'name' => '2026-2027', 'status' => 'active'],
|
||||
]));
|
||||
|
||||
$context = $service->resolve(new SchoolYearContextRequest());
|
||||
|
||||
$this->assertSame(2, $context->id());
|
||||
$this->assertSame('2026-2027', $context->yearName());
|
||||
$this->assertNull(session()->get('selected_school_year_id'));
|
||||
}
|
||||
|
||||
public function testSelectingActiveYearClearsHistoricalOverride(): void
|
||||
{
|
||||
session()->set('selected_school_year_id', 1);
|
||||
session()->set('class_section_id', 44);
|
||||
session()->set('semester', 'Fall');
|
||||
|
||||
$service = new SchoolYearContextService(new SchoolYearContextFakeModel([
|
||||
1 => ['id' => 1, 'name' => '2025-2026', 'status' => 'closed'],
|
||||
2 => ['id' => 2, 'name' => '2026-2027', 'status' => 'Active'],
|
||||
]));
|
||||
|
||||
$context = $service->select(2);
|
||||
|
||||
$this->assertSame(2, $context->id());
|
||||
$this->assertTrue($context->isActive());
|
||||
$this->assertFalse($context->isExplicitSelection());
|
||||
$this->assertNull(session()->get('selected_school_year_id'));
|
||||
$this->assertNull(session()->get('class_section_id'));
|
||||
$this->assertNull(session()->get('semester'));
|
||||
}
|
||||
}
|
||||
@@ -17,11 +17,13 @@ final class SchoolYearContextTest extends CIUnitTestCase
|
||||
$this->assertSame('2025-2026', $context->yearName());
|
||||
}
|
||||
|
||||
public function testClosedAndArchivedContextsAreReadonly(): void
|
||||
public function testNonActiveContextsAreReadonly(): void
|
||||
{
|
||||
$this->assertTrue((new SchoolYearContext(5, '2026-2027', 'draft'))->isReadonly());
|
||||
$this->assertTrue((new SchoolYearContext(6, '2026-2027', 'closing'))->isReadonly());
|
||||
$this->assertTrue((new SchoolYearContext(2, '2023-2024', 'closed'))->isReadonly());
|
||||
$this->assertTrue((new SchoolYearContext(1, '2022-2023', 'archived'))->isReadonly());
|
||||
$this->assertFalse((new SchoolYearContext(5, '2026-2027', 'draft'))->isReadonly());
|
||||
$this->assertTrue((new SchoolYearContext(1, '2022-2023', 'archived'))->isHistorical());
|
||||
}
|
||||
|
||||
public function testArrayRepresentationIncludesReadonlyState(): void
|
||||
|
||||
Reference in New Issue
Block a user