apply the school year concept
Tests / PHPUnit (push) Failing after 1m19s

This commit is contained in:
root
2026-07-14 00:59:00 -04:00
parent 504c3bc9f9
commit feb1b29a32
73 changed files with 4288 additions and 620 deletions
File diff suppressed because it is too large Load Diff
+5
View File
@@ -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;
}
+1
View File
@@ -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,
];
+2
View File
@@ -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
View File
@@ -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
+179 -8
View File
@@ -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;
}
}
+99
View File
@@ -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, [
+160 -61
View File
@@ -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 doesnt 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
+10 -1
View File
@@ -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 = [];
+10 -1
View File
@@ -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) {
+61 -39
View File
@@ -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
+4 -1
View File
@@ -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 = [];
+13 -4
View File
@@ -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 = [];
+2 -3
View File
@@ -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');
+12 -3
View File
@@ -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';
}
}
}
+45 -11
View File
@@ -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')
+27 -16
View File
@@ -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)) {
+3 -3
View File
@@ -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));
+9 -3
View File
@@ -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));
+8 -18
View File
@@ -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.');
}
+15 -1
View File
@@ -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
{
}
+34 -2
View File
@@ -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;
}
}
+48
View File
@@ -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;
}
}
+10
View File
@@ -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');
}
+29
View File
@@ -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;
}
}
}
+44 -8
View File
@@ -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']);
+8 -2
View File
@@ -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];
}
}
+12 -3
View File
@@ -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')
+32 -8
View File
@@ -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);
+39
View File
@@ -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',
]);
}
}
+385 -3
View File
@@ -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');
}
}
+126 -16
View File
@@ -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,
];
}
}
+4 -4
View File
@@ -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.');
}
}
+9 -4
View File
@@ -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);
}
-16
View File
@@ -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')) ?>
+18 -1
View File
@@ -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>
+3 -2
View File
@@ -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') ?>
+9 -25
View File
@@ -1,28 +1,16 @@
<?= $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>
<!-- 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 -->
@@ -111,7 +99,6 @@
onclick="window.open(this.href, '_blank'); return false;">
Print
</a>
</div>
</div>
</div>
@@ -165,8 +152,6 @@
<div class="alert alert-success mt-3">✅ All items are available in stock.</div>
<?php endif; ?>
</div>
</div>
</div>
<?= $this->endSection() ?>
<?= $this->section('scripts') ?>
@@ -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) {
+8
View File
@@ -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() ?>
+1
View File
@@ -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') ?>
+5
View File
@@ -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) {
+14
View File
@@ -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">
+5 -1
View File
@@ -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">
+8
View File
@@ -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; ?>
+382 -10
View File
@@ -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.07AM.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) {
+247 -14
View File
@@ -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>
+136 -52
View File
@@ -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">
+4
View File
@@ -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);
}
+6 -1
View File
@@ -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): ?>
+3
View File
@@ -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