This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -27,4 +27,9 @@ class Feature extends BaseConfig
|
|||||||
* Use improved new auto routing instead of the default legacy version.
|
* Use improved new auto routing instead of the default legacy version.
|
||||||
*/
|
*/
|
||||||
public bool $autoRoutesImproved = false;
|
public bool $autoRoutesImproved = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the global school-year selector in authenticated admin layouts.
|
||||||
|
*/
|
||||||
|
public bool $globalSchoolYearSelector = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ class Filters extends BaseConfig
|
|||||||
'permission' => \App\Filters\PermissionFilter::class,
|
'permission' => \App\Filters\PermissionFilter::class,
|
||||||
'timezone' => \App\Filters\TimezoneFilter::class,
|
'timezone' => \App\Filters\TimezoneFilter::class,
|
||||||
'schoolYear' => \App\Filters\RequireSchoolYearFilter::class,
|
'schoolYear' => \App\Filters\RequireSchoolYearFilter::class,
|
||||||
|
'schoolYearWritable'=> \App\Filters\SchoolYearWritableFilter::class,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ $routes->setAutoRoute(false);
|
|||||||
* --------------------------------------------------------------------
|
* --------------------------------------------------------------------
|
||||||
*/
|
*/
|
||||||
$routes->post('api/proofread', 'ProofreadController::check', ['filter' => 'auth']);
|
$routes->post('api/proofread', 'ProofreadController::check', ['filter' => 'auth']);
|
||||||
|
$routes->post('school-year/select', 'View\SchoolYearSelectionController::select', ['filter' => 'auth']);
|
||||||
|
$routes->post('school-year/reset', 'View\SchoolYearSelectionController::reset', ['filter' => 'auth']);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* --------------------------------------------------------------------
|
* --------------------------------------------------------------------
|
||||||
|
|||||||
+24
-1
@@ -188,7 +188,29 @@ class Services extends BaseService
|
|||||||
}
|
}
|
||||||
|
|
||||||
return new \App\Services\SchoolYearContextService(
|
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\SchoolYearModel::class),
|
||||||
model(\App\Models\SchoolYearClosingBatchModel::class),
|
model(\App\Models\SchoolYearClosingBatchModel::class),
|
||||||
model(\App\Models\SchoolYearClosingItemModel::class),
|
model(\App\Models\SchoolYearClosingItemModel::class),
|
||||||
|
model(\App\Models\ConfigurationModel::class),
|
||||||
static::schoolYearManagement(),
|
static::schoolYearManagement(),
|
||||||
\Config\Database::connect()
|
\Config\Database::connect()
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -54,6 +54,11 @@ class CompetitionWinnersController extends BaseController
|
|||||||
public function index()
|
public function index()
|
||||||
{
|
{
|
||||||
$competitionModel = new CompetitionModel();
|
$competitionModel = new CompetitionModel();
|
||||||
|
$schoolYear = $this->currentCompetitionSchoolYear();
|
||||||
|
|
||||||
|
if ($schoolYear !== '') {
|
||||||
|
$competitionModel->where('school_year', $schoolYear);
|
||||||
|
}
|
||||||
|
|
||||||
$competitions = $competitionModel
|
$competitions = $competitionModel
|
||||||
->orderBy('id', 'DESC')
|
->orderBy('id', 'DESC')
|
||||||
@@ -62,12 +67,13 @@ class CompetitionWinnersController extends BaseController
|
|||||||
return view('admin/competition_winners/index', [
|
return view('admin/competition_winners/index', [
|
||||||
'competitions' => $competitions,
|
'competitions' => $competitions,
|
||||||
'sectionMap' => $this->getClassSectionMap(),
|
'sectionMap' => $this->getClassSectionMap(),
|
||||||
|
'schoolYear' => $schoolYear,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create()
|
public function create()
|
||||||
{
|
{
|
||||||
$schoolYear = $this->configModel->getConfig('school_year');
|
$schoolYear = $this->currentCompetitionSchoolYear();
|
||||||
$classCounts = $this->getClassStudentCounts($schoolYear);
|
$classCounts = $this->getClassStudentCounts($schoolYear);
|
||||||
$classSections = $this->classSectionModel
|
$classSections = $this->classSectionModel
|
||||||
->orderBy('class_section_name', 'ASC')
|
->orderBy('class_section_name', 'ASC')
|
||||||
@@ -1088,6 +1094,11 @@ class CompetitionWinnersController extends BaseController
|
|||||||
return $questionCounts;
|
return $questionCounts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function currentCompetitionSchoolYear(): string
|
||||||
|
{
|
||||||
|
return $this->currentSchoolYearName((string) ($this->configModel->getConfig('school_year') ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
private function getClassSectionMap(): array
|
private function getClassSectionMap(): array
|
||||||
{
|
{
|
||||||
$sections = $this->classSectionModel
|
$sections = $this->classSectionModel
|
||||||
|
|||||||
@@ -39,7 +39,13 @@ class AdminProgressController extends BaseController
|
|||||||
|
|
||||||
public function index()
|
public function index()
|
||||||
{
|
{
|
||||||
|
$schoolYear = trim((string) ($this->request->getGet('school_year') ?? ''));
|
||||||
|
if ($schoolYear === '') {
|
||||||
|
$schoolYear = $this->currentSchoolYearName((string) ($this->configModel->getConfig('school_year') ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
$filters = [
|
$filters = [
|
||||||
|
'school_year' => $schoolYear,
|
||||||
'from' => (string) $this->request->getGet('from'),
|
'from' => (string) $this->request->getGet('from'),
|
||||||
'to' => (string) $this->request->getGet('to'),
|
'to' => (string) $this->request->getGet('to'),
|
||||||
'class_section_id' => (string) $this->request->getGet('class_section_id'),
|
'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('classSection cs', 'cs.class_section_id = class_progress_reports.class_section_id', 'left')
|
||||||
->join('users u', 'u.id = class_progress_reports.teacher_id', 'left');
|
->join('users u', 'u.id = class_progress_reports.teacher_id', 'left');
|
||||||
|
|
||||||
|
$this->applyProgressSchoolYearScope($builder, $schoolYear);
|
||||||
|
|
||||||
if ($filters['from']) {
|
if ($filters['from']) {
|
||||||
$builder->where('week_start >=', $filters['from']);
|
$builder->where('week_start >=', $filters['from']);
|
||||||
}
|
}
|
||||||
@@ -91,16 +99,17 @@ class AdminProgressController extends BaseController
|
|||||||
$reportGroupsBySection[$sectionId] = $groups;
|
$reportGroupsBySection[$sectionId] = $groups;
|
||||||
}
|
}
|
||||||
|
|
||||||
$classSections = $this->classSectionModel->getClassSections();
|
$classSections = $this->classSectionsForYear($schoolYear, $rows);
|
||||||
$studentCounts = $this->studentClassModel->getStudentCountsBySection();
|
$studentCounts = $this->studentClassModel->getStudentCountsBySection($schoolYear !== '' ? $schoolYear : null);
|
||||||
$filteredSections = array_values(array_filter($classSections, function ($section) use ($studentCounts) {
|
$filteredSections = array_values(array_filter($classSections, function ($section) use ($studentCounts, $reportGroupsBySection) {
|
||||||
$sectionId = (int) ($section['class_section_id'] ?? 0);
|
$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'] ?? '');
|
$filterStart = $this->normalizeDate($filters['from'] ?? '');
|
||||||
$filterEnd = $this->normalizeDate($filters['to'] ?? '');
|
$filterEnd = $this->normalizeDate($filters['to'] ?? '');
|
||||||
[$dateList, $noSchoolDays, $totalPassedDays, $passedDatesSet] = $this->buildSemesterDates();
|
[$dateList, $noSchoolDays, $totalPassedDays, $passedDatesSet] = $this->buildSemesterDates($schoolYear);
|
||||||
[$expectedDays, $activeDatesSet] = $this->resolveExpectedDays(
|
[$expectedDays, $activeDatesSet] = $this->resolveExpectedDays(
|
||||||
$dateList,
|
$dateList,
|
||||||
$noSchoolDays,
|
$noSchoolDays,
|
||||||
@@ -132,6 +141,8 @@ class AdminProgressController extends BaseController
|
|||||||
return view('admin/class_progress_list', [
|
return view('admin/class_progress_list', [
|
||||||
'reportGroupsBySection' => $reportGroupsBySection,
|
'reportGroupsBySection' => $reportGroupsBySection,
|
||||||
'filters' => $filters,
|
'filters' => $filters,
|
||||||
|
'schoolYear' => $schoolYear,
|
||||||
|
'schoolYears' => $this->availableSchoolYears($schoolYear),
|
||||||
'classSections' => $filteredSections,
|
'classSections' => $filteredSections,
|
||||||
'statusOptions' => ClassProgressController::STATUS_OPTIONS,
|
'statusOptions' => ClassProgressController::STATUS_OPTIONS,
|
||||||
'subjectSections' => ClassProgressController::SUBJECT_SECTIONS,
|
'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)
|
public function view($id)
|
||||||
{
|
{
|
||||||
$row = $this->reportModel
|
$row = $this->reportModel
|
||||||
@@ -277,11 +444,15 @@ class AdminProgressController extends BaseController
|
|||||||
return checkdate($m, $d, $y) ? $value : '';
|
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') ?? '');
|
$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);
|
[$rangeStart, $rangeEnd] = $this->semesterRangeService->getSchoolYearRange($schoolYearForRange);
|
||||||
$semesterNorm = $this->semesterRangeService->normalizeSemester($semester);
|
$semesterNorm = $this->semesterRangeService->normalizeSemester($semester);
|
||||||
if ($semesterNorm !== '' && $schoolYearForRange !== '') {
|
if ($semesterNorm !== '' && $schoolYearForRange !== '') {
|
||||||
|
|||||||
@@ -12,9 +12,12 @@ class SchoolYearClosingController extends BaseController
|
|||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$targetId = $this->normalizeInt($this->request->getGet('target_school_year_id'));
|
$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', [
|
return view('school_years/closing_preview', [
|
||||||
'preview' => service('schoolYearClosing')->preview($id, $targetId),
|
'preview' => $preview,
|
||||||
|
'promotionTable' => $promotionTable,
|
||||||
'latestBatch' => service('schoolYearClosing')->latestBatch($id),
|
'latestBatch' => service('schoolYearClosing')->latestBatch($id),
|
||||||
'schoolYears' => (new SchoolYearModel())->orderBy('name', 'DESC')->findAll(),
|
'schoolYears' => (new SchoolYearModel())->orderBy('name', 'DESC')->findAll(),
|
||||||
]);
|
]);
|
||||||
@@ -32,7 +35,7 @@ class SchoolYearClosingController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
service('schoolYearClosing')->start($id, $targetId, $this->userId());
|
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) {
|
} catch (Throwable $e) {
|
||||||
return redirect()->back()->with('error', $e->getMessage());
|
return redirect()->back()->with('error', $e->getMessage());
|
||||||
}
|
}
|
||||||
@@ -42,7 +45,7 @@ class SchoolYearClosingController extends BaseController
|
|||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
service('schoolYearClosing')->execute($id, $this->userId());
|
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) {
|
} catch (Throwable $e) {
|
||||||
return redirect()->back()->with('error', $e->getMessage());
|
return redirect()->back()->with('error', $e->getMessage());
|
||||||
}
|
}
|
||||||
@@ -52,7 +55,7 @@ class SchoolYearClosingController extends BaseController
|
|||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
service('schoolYearClosing')->complete($id, $this->userId());
|
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) {
|
} catch (Throwable $e) {
|
||||||
return redirect()->back()->with('error', $e->getMessage());
|
return redirect()->back()->with('error', $e->getMessage());
|
||||||
}
|
}
|
||||||
@@ -62,7 +65,7 @@ class SchoolYearClosingController extends BaseController
|
|||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
service('schoolYearClosing')->cancel($id, $this->userId());
|
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) {
|
} catch (Throwable $e) {
|
||||||
return redirect()->back()->with('error', $e->getMessage());
|
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;
|
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
|
private function userId(): ?int
|
||||||
{
|
{
|
||||||
$id = session('user_id') ?? session('id');
|
$id = session('user_id') ?? session('id');
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ class SchoolYearController extends BaseController
|
|||||||
'closingYear' => $closingYear,
|
'closingYear' => $closingYear,
|
||||||
'archivedCount' => $archivedCount,
|
'archivedCount' => $archivedCount,
|
||||||
'latestTransitions' => service('schoolYearManagement')->latestTransitionByYear(),
|
'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;
|
return is_numeric($id) ? (int) $id : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function verificationByYear(array $schoolYears): array
|
||||||
|
{
|
||||||
|
$db = db_connect();
|
||||||
|
$verification = [];
|
||||||
|
|
||||||
|
$hasStudentClass = $db->tableExists('student_class');
|
||||||
|
$hasStudentDecisions = $db->tableExists('student_decisions');
|
||||||
|
$hasPromotionQueue = $db->tableExists('promotion_queue');
|
||||||
|
$hasInvoices = $db->tableExists('invoices');
|
||||||
|
$hasClosingBatches = $db->tableExists('school_year_closing_batches');
|
||||||
|
|
||||||
|
foreach ($schoolYears as $year) {
|
||||||
|
$id = (int) ($year['id'] ?? 0);
|
||||||
|
$name = (string) ($year['name'] ?? '');
|
||||||
|
if ($id <= 0 || $name === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$summary = [
|
||||||
|
'students' => 0,
|
||||||
|
'decisions' => 0,
|
||||||
|
'promoted' => 0,
|
||||||
|
'queued' => 0,
|
||||||
|
'carry_forward_balance' => 0.0,
|
||||||
|
'positive_balance' => 0.0,
|
||||||
|
'credit_balance' => 0.0,
|
||||||
|
'closing_status' => '',
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($hasStudentClass && $db->fieldExists('school_year', 'student_class')) {
|
||||||
|
$row = $db->table('student_class')
|
||||||
|
->select('COUNT(DISTINCT student_id) AS total', false)
|
||||||
|
->where('school_year', $name)
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
$summary['students'] = (int) ($row['total'] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($hasStudentDecisions && $db->fieldExists('school_year', 'student_decisions')) {
|
||||||
|
$row = $db->table('student_decisions')
|
||||||
|
->select('COUNT(DISTINCT student_id) AS decisions', false)
|
||||||
|
->select("COUNT(DISTINCT CASE WHEN LOWER(decision) = 'pass' THEN student_id END) AS promoted", false)
|
||||||
|
->where('school_year', $name)
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
$summary['decisions'] = (int) ($row['decisions'] ?? 0);
|
||||||
|
$summary['promoted'] = (int) ($row['promoted'] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($hasPromotionQueue && $db->fieldExists('school_year_from', 'promotion_queue')) {
|
||||||
|
$row = $db->table('promotion_queue')
|
||||||
|
->select('COUNT(DISTINCT student_id) AS total', false)
|
||||||
|
->where('school_year_from', $name)
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
$summary['queued'] = (int) ($row['total'] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($hasInvoices && $db->fieldExists('school_year', 'invoices')) {
|
||||||
|
$row = $db->table('invoices')
|
||||||
|
->select('COALESCE(SUM(balance), 0) AS carry_forward_balance')
|
||||||
|
->select('COALESCE(SUM(CASE WHEN balance > 0 THEN balance ELSE 0 END), 0) AS positive_balance', false)
|
||||||
|
->select('COALESCE(SUM(CASE WHEN balance < 0 THEN ABS(balance) ELSE 0 END), 0) AS credit_balance', false)
|
||||||
|
->where('school_year', $name)
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
$summary['carry_forward_balance'] = round((float) ($row['carry_forward_balance'] ?? 0), 2);
|
||||||
|
$summary['positive_balance'] = round((float) ($row['positive_balance'] ?? 0), 2);
|
||||||
|
$summary['credit_balance'] = round((float) ($row['credit_balance'] ?? 0), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($hasClosingBatches) {
|
||||||
|
$batch = $db->table('school_year_closing_batches')
|
||||||
|
->select('status')
|
||||||
|
->where('source_school_year_id', $id)
|
||||||
|
->orderBy('id', 'DESC')
|
||||||
|
->get(1)
|
||||||
|
->getRowArray();
|
||||||
|
$summary['closing_status'] = (string) ($batch['status'] ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
$verification[$id] = $summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $verification;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use CodeIgniter\HTTP\RequestInterface;
|
|||||||
use CodeIgniter\HTTP\ResponseInterface;
|
use CodeIgniter\HTTP\ResponseInterface;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
use App\Services\ApiClient;
|
use App\Services\ApiClient;
|
||||||
|
use App\Exceptions\SchoolYear\SchoolYearConfigurationException;
|
||||||
use App\Support\SchoolYear\SchoolYearContext;
|
use App\Support\SchoolYear\SchoolYearContext;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
@@ -60,6 +61,8 @@ abstract class BaseController extends Controller
|
|||||||
// Preload any models, libraries, etc, here.
|
// Preload any models, libraries, etc, here.
|
||||||
// E.g.: $this->session = \Config\Services::session();
|
// E.g.: $this->session = \Config\Services::session();
|
||||||
session();
|
session();
|
||||||
|
$this->syncSchoolYearPropertyFromContext();
|
||||||
|
$this->injectSchoolYearViewData();
|
||||||
|
|
||||||
// Shared API client available to all controllers
|
// Shared API client available to all controllers
|
||||||
$this->api = service('apiClient');
|
$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(
|
protected function assertSchoolYearWritable(
|
||||||
SchoolYearContext $context,
|
SchoolYearContext $context,
|
||||||
bool $allowDraftForAdmin = false,
|
bool $allowDraftForAdmin = false,
|
||||||
@@ -111,6 +131,85 @@ abstract class BaseController extends Controller
|
|||||||
): void {
|
): void {
|
||||||
service('schoolYearWriteGuard')->assertWritable($context, $allowDraftForAdmin, $isAdmin);
|
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.');
|
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') ?? ''));
|
$semester = trim((string) ($this->request->getGet('semester') ?? $this->configModel->getConfig('semester') ?? ''));
|
||||||
|
|
||||||
$builder = $this->db->table('students s')
|
$builder = $this->db->table('students s')
|
||||||
@@ -82,7 +82,7 @@ class ParentReportCardController extends BaseController
|
|||||||
throw new PageNotFoundException('Student not found.');
|
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') ?? ''));
|
$semester = trim((string) ($this->request->getGet('semester') ?? $this->configModel->getConfig('semester') ?? ''));
|
||||||
|
|
||||||
$this->touchAcknowledgement($parentId, (int) $studentId, $schoolYear, $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.');
|
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') ?? ''));
|
$semester = trim((string) ($this->configModel->getConfig('semester') ?? ''));
|
||||||
$now = date('Y-m-d H:i:s');
|
$now = date('Y-m-d H:i:s');
|
||||||
$this->touchAcknowledgement($parentId, (int) $studentId, $schoolYear, $semester, [
|
$this->touchAcknowledgement($parentId, (int) $studentId, $schoolYear, $semester, [
|
||||||
|
|||||||
@@ -699,7 +699,10 @@ class AdministratorController extends BaseController
|
|||||||
public function teacherSubmissionsReport()
|
public function teacherSubmissionsReport()
|
||||||
{
|
{
|
||||||
$semester = (string)($this->configModel->getConfig('semester') ?? $this->semester ?? '');
|
$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);
|
$semesterResolver = new SemesterRangeService($this->configModel);
|
||||||
$semesterNorm = $semesterResolver->normalizeSemester($semester);
|
$semesterNorm = $semesterResolver->normalizeSemester($semester);
|
||||||
$semesterFilter = $semesterNorm !== '' ? $semesterNorm : $semester;
|
$semesterFilter = $semesterNorm !== '' ? $semesterNorm : $semester;
|
||||||
@@ -1099,9 +1102,9 @@ class AdministratorController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$semesterResolver = new SemesterRangeService($this->configModel);
|
$semesterResolver = new SemesterRangeService($this->configModel);
|
||||||
$schoolYear = (string)($this->configModel->getConfig('school_year') ?? '');
|
$schoolYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
|
||||||
$semester = (string)($this->configModel->getConfig('semester') ?? '');
|
$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);
|
[$rangeStart, $rangeEnd] = $semesterResolver->getSchoolYearRange($schoolYearForRange);
|
||||||
$semesterNorm = $semesterResolver->normalizeSemester($semester);
|
$semesterNorm = $semesterResolver->normalizeSemester($semester);
|
||||||
if ($semesterNorm !== '' && $schoolYearForRange !== '') {
|
if ($semesterNorm !== '' && $schoolYearForRange !== '') {
|
||||||
@@ -2295,22 +2298,10 @@ class AdministratorController extends BaseController
|
|||||||
public function showEnrollmentWithdrawalPage()
|
public function showEnrollmentWithdrawalPage()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$selectedYear = trim((string)($this->request->getGet('schoolYear') ?? ''));
|
$schoolYears = $this->availableSchoolYears();
|
||||||
if ($selectedYear === '') {
|
$selectedYear = $this->selectedEnrollmentSchoolYear($schoolYears);
|
||||||
$selectedYear = (string)$this->schoolYear;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Distinct school years from enrollments (for selector)
|
$students = $this->studentModel->getStudentsWithClassAndEnrollment($selectedYear);
|
||||||
$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();
|
|
||||||
|
|
||||||
$selectedStartYear = $this->getSchoolYearStartYear((string)$selectedYear);
|
$selectedStartYear = $this->getSchoolYearStartYear((string)$selectedYear);
|
||||||
$removedPriorIds = [];
|
$removedPriorIds = [];
|
||||||
@@ -2326,6 +2317,7 @@ class AdministratorController extends BaseController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
$returningStudentIds = $this->priorYearStudentIds($selectedYear);
|
||||||
|
|
||||||
foreach ($students as &$s) {
|
foreach ($students as &$s) {
|
||||||
// ===== Ensure IDs needed by the modal =====
|
// ===== Ensure IDs needed by the modal =====
|
||||||
@@ -2360,6 +2352,9 @@ class AdministratorController extends BaseController
|
|||||||
|
|
||||||
// ===== New-student flags =====
|
// ===== New-student flags =====
|
||||||
$s['is_new'] = (int) ($s['is_new'] ?? 0);
|
$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';
|
$s['new_student'] = $s['is_new'] === 1 ? 'Yes' : 'No';
|
||||||
|
|
||||||
// ===== Admission override =====
|
// ===== Admission override =====
|
||||||
@@ -2369,6 +2364,9 @@ class AdministratorController extends BaseController
|
|||||||
$s['enrollment_status'] = $statusForYear;
|
$s['enrollment_status'] = $statusForYear;
|
||||||
} elseif (($s['admission_status'] ?? null) === 'denied') {
|
} elseif (($s['admission_status'] ?? null) === 'denied') {
|
||||||
$s['enrollment_status'] = 'denied';
|
$s['enrollment_status'] = 'denied';
|
||||||
|
} else {
|
||||||
|
$s['enrollment_status'] = 'admission under review';
|
||||||
|
$s['admission_status'] = 'pending';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Class section name for the selected year =====
|
// ===== Class section name for the selected year =====
|
||||||
@@ -2396,22 +2394,7 @@ class AdministratorController extends BaseController
|
|||||||
return strcasecmp($pa, $pb);
|
return strcasecmp($pa, $pb);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ===== Provide classes for the modal select (like studentClassAssignment) =====
|
$classes = $this->enrollmentClassOptions((string)$selectedYear);
|
||||||
// Filter to current term; loosen if your table doesn’t store these fields.
|
|
||||||
$classes = $this->classSectionModel
|
|
||||||
->select('id, class_section_id, class_section_name, school_year, semester')
|
|
||||||
->where('school_year', (string)$selectedYear)
|
|
||||||
->where('semester', (string)$this->semester)
|
|
||||||
->orderBy('class_section_name', 'ASC')
|
|
||||||
->findAll();
|
|
||||||
|
|
||||||
// Fallback so the dropdown never shows empty if filters are too strict
|
|
||||||
if (empty($classes)) {
|
|
||||||
$classes = $this->classSectionModel
|
|
||||||
->select('id, class_section_id, class_section_name')
|
|
||||||
->orderBy('class_section_name', 'ASC')
|
|
||||||
->findAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
return view('enroll_withdraw/enrollment_withdrawal', [
|
return view('enroll_withdraw/enrollment_withdrawal', [
|
||||||
'students' => $students,
|
'students' => $students,
|
||||||
@@ -2432,22 +2415,10 @@ class AdministratorController extends BaseController
|
|||||||
public function enrollmentWithdrawalData()
|
public function enrollmentWithdrawalData()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$selectedYear = trim((string)($this->request->getGet('schoolYear') ?? ''));
|
$schoolYears = $this->availableSchoolYears();
|
||||||
if ($selectedYear === '') {
|
$selectedYear = $this->selectedEnrollmentSchoolYear($schoolYears);
|
||||||
$selectedYear = (string)$this->schoolYear;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Distinct school years
|
$students = $this->studentModel->getStudentsWithClassAndEnrollment($selectedYear);
|
||||||
$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();
|
|
||||||
|
|
||||||
$selectedStartYear = $this->getSchoolYearStartYear((string)$selectedYear);
|
$selectedStartYear = $this->getSchoolYearStartYear((string)$selectedYear);
|
||||||
$removedPriorIds = [];
|
$removedPriorIds = [];
|
||||||
@@ -2463,6 +2434,7 @@ class AdministratorController extends BaseController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
$returningStudentIds = $this->priorYearStudentIds($selectedYear);
|
||||||
|
|
||||||
foreach ($students as &$s) {
|
foreach ($students as &$s) {
|
||||||
$s['student_id'] = (int)($s['id'] ?? 0);
|
$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['parent_sort'] = trim(($pl !== '' ? $pl : $pf) . ' ' . $pf) ?: 'ZZZ Unknown Parent';
|
||||||
|
|
||||||
$s['is_new'] = (int) ($s['is_new'] ?? 0);
|
$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';
|
$s['new_student'] = $s['is_new'] === 1 ? 'Yes' : 'No';
|
||||||
|
|
||||||
$statusForYear = $this->enrollmentModel->getEnrollmentStatus((int)$s['student_id'], $selectedYear);
|
$statusForYear = $this->enrollmentModel->getEnrollmentStatus((int)$s['student_id'], $selectedYear);
|
||||||
@@ -2492,6 +2467,9 @@ class AdministratorController extends BaseController
|
|||||||
$s['enrollment_status'] = $statusForYear;
|
$s['enrollment_status'] = $statusForYear;
|
||||||
} elseif (($s['admission_status'] ?? null) === 'denied') {
|
} elseif (($s['admission_status'] ?? null) === 'denied') {
|
||||||
$s['enrollment_status'] = '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);
|
$className = $this->studentClassModel->getClassSectionsByStudentId((int)$s['student_id'], $selectedYear);
|
||||||
@@ -2516,18 +2494,7 @@ class AdministratorController extends BaseController
|
|||||||
return strcasecmp($pa, $pb);
|
return strcasecmp($pa, $pb);
|
||||||
});
|
});
|
||||||
|
|
||||||
$classes = $this->classSectionModel
|
$classes = $this->enrollmentClassOptions((string)$selectedYear);
|
||||||
->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();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->response->setJSON([
|
return $this->response->setJSON([
|
||||||
'students' => $students,
|
'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.
|
* 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
|
// 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 ?? '');
|
$selectedSemester = (string)($this->request->getGet('semester') ?? $this->semester ?? '');
|
||||||
$year = (string)($this->request->getGet('school_year') ?? $this->schoolYear ?? '');
|
$year = (string)($this->schoolYear ?? '');
|
||||||
|
|
||||||
$tcQ = $this->teacherClassModel;
|
$tcQ = $this->teacherClassModel;
|
||||||
if ($year !== '') {
|
if ($year !== '') {
|
||||||
@@ -172,11 +172,11 @@ class AssignmentController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$schoolYearsList = [];
|
$schoolYearsList = [];
|
||||||
try {
|
try {
|
||||||
$db = Database::connect();
|
$db = Database::connect();
|
||||||
$yearsQuery = $db->table('teacher_class')
|
$yearsQuery = $db->table('teacher_class')
|
||||||
->select('DISTINCT school_year', false)
|
->select('DISTINCT school_year', false)
|
||||||
->where('school_year IS NOT NULL', null, false)
|
->where('school_year IS NOT NULL', null, false)
|
||||||
->orderBy('school_year', 'DESC')
|
->orderBy('school_year', 'DESC')
|
||||||
->get()
|
->get()
|
||||||
->getResultArray();
|
->getResultArray();
|
||||||
@@ -189,8 +189,8 @@ class AssignmentController extends BaseController
|
|||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
// ignore fallback below
|
// ignore fallback below
|
||||||
}
|
}
|
||||||
if (empty($schoolYearsList) && $this->schoolYear !== null && $this->schoolYear !== '') {
|
if ($this->schoolYear !== null && $this->schoolYear !== '' && !in_array((string)$this->schoolYear, $schoolYearsList, true)) {
|
||||||
$schoolYearsList[] = (string)$this->schoolYear;
|
array_unshift($schoolYearsList, (string)$this->schoolYear);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort sections
|
// Sort sections
|
||||||
|
|||||||
@@ -64,11 +64,20 @@ class AttendanceController extends Controller
|
|||||||
$this->userRoleModel = new UserRoleModel();
|
$this->userRoleModel = new UserRoleModel();
|
||||||
$this->semesterScoreService = service('semesterScoreService');
|
$this->semesterScoreService = service('semesterScoreService');
|
||||||
$this->semester = $this->configModel->getConfig('semester');
|
$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->enableAttendance = $this->configModel->getConfig('enable_attendance');
|
||||||
$this->semesterRangeService = new SemesterRangeService($this->configModel);
|
$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.
|
* GET: filter + grid for a single date/section.
|
||||||
|
|||||||
@@ -70,28 +70,6 @@ class AttendanceTrackingController extends BaseController
|
|||||||
->findAll();
|
->findAll();
|
||||||
$debugInfo['class_students'] = count($classStudents);
|
$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)
|
// Gather candidate student identifiers (numeric IDs and code-like IDs)
|
||||||
$studentIds = [];
|
$studentIds = [];
|
||||||
$studentCodes = [];
|
$studentCodes = [];
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ class EventController extends ResourceController
|
|||||||
$this->parentModel = new ParentModel();
|
$this->parentModel = new ParentModel();
|
||||||
$this->emailService = new EmailService();
|
$this->emailService = new EmailService();
|
||||||
|
|
||||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
$this->schoolYear = $this->currentSchoolYearName();
|
||||||
$this->semester = $this->configModel->getConfig('semester');
|
$this->semester = $this->configModel->getConfig('semester');
|
||||||
$this->categories = [
|
$this->categories = [
|
||||||
'workshops',
|
'workshops',
|
||||||
@@ -88,6 +88,15 @@ class EventController extends ResourceController
|
|||||||
return $this->eventChargesHasCreatedBy;
|
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
|
private function eventChargesSupportsWaiverSigned(): bool
|
||||||
{
|
{
|
||||||
if ($this->eventChargesHasWaiverSigned !== null) {
|
if ($this->eventChargesHasWaiverSigned !== null) {
|
||||||
|
|||||||
@@ -21,6 +21,60 @@ class FinancialController extends BaseController
|
|||||||
return $this->request->isAJAX() || str_contains($accept, 'application/json');
|
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()
|
public function financialReport()
|
||||||
{
|
{
|
||||||
$invoiceModel = new InvoiceModel();
|
$invoiceModel = new InvoiceModel();
|
||||||
@@ -32,7 +86,7 @@ public function financialReport()
|
|||||||
|
|
||||||
$dateFrom = $this->request->getGet('date_from');
|
$dateFrom = $this->request->getGet('date_from');
|
||||||
$dateTo = $this->request->getGet('date_to');
|
$dateTo = $this->request->getGet('date_to');
|
||||||
$schoolYear = $this->request->getGet('school_year');
|
$schoolYear = $this->selectedSchoolYear();
|
||||||
|
|
||||||
// === Apply filters to models (invoice/refund/discount/expense/reimb) ===
|
// === Apply filters to models (invoice/refund/discount/expense/reimb) ===
|
||||||
if ($schoolYear) {
|
if ($schoolYear) {
|
||||||
@@ -235,22 +289,7 @@ public function financialReport()
|
|||||||
->findAll();
|
->findAll();
|
||||||
|
|
||||||
// --- School year options (for detailed view & JSON) ---
|
// --- School year options (for detailed view & JSON) ---
|
||||||
$schoolYears = [];
|
$schoolYears = $this->financialSchoolYearOptions($schoolYear);
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
$eventFeesTotal = $this->getEventFeesTotal($schoolYear, $dateFrom, $dateTo);
|
$eventFeesTotal = $this->getEventFeesTotal($schoolYear, $dateFrom, $dateTo);
|
||||||
|
|
||||||
@@ -298,28 +337,11 @@ public function financialReport()
|
|||||||
{
|
{
|
||||||
$dateFrom = $this->request->getGet('date_from');
|
$dateFrom = $this->request->getGet('date_from');
|
||||||
$dateTo = $this->request->getGet('date_to');
|
$dateTo = $this->request->getGet('date_to');
|
||||||
$schoolYear = $this->request->getGet('school_year');
|
$schoolYear = $this->selectedSchoolYear();
|
||||||
|
|
||||||
$data = $this->getFinancialSummary($dateFrom, $dateTo, $schoolYear);
|
$data = $this->getFinancialSummary($dateFrom, $dateTo, $schoolYear);
|
||||||
|
|
||||||
// Build school year options from invoices table (fallback to configured year)
|
$data['schoolYears'] = $this->financialSchoolYearOptions((string)($data['schoolYear'] ?? $schoolYear));
|
||||||
$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;
|
|
||||||
if ($this->wantsJson() || strtolower((string)($this->request->getGet('format') ?? '')) === 'json') {
|
if ($this->wantsJson() || strtolower((string)($this->request->getGet('format') ?? '')) === 'json') {
|
||||||
return $this->response->setJSON(['ok' => true] + $data + [
|
return $this->response->setJSON(['ok' => true] + $data + [
|
||||||
'csrf_token' => csrf_token(),
|
'csrf_token' => csrf_token(),
|
||||||
@@ -524,7 +546,7 @@ public function financialReport()
|
|||||||
private function getFinancialSummaryDetailSections(?string $dateFrom = null, ?string $dateTo = null, ?string $schoolYear = null): array
|
private function getFinancialSummaryDetailSections(?string $dateFrom = null, ?string $dateTo = null, ?string $schoolYear = null): array
|
||||||
{
|
{
|
||||||
$configModel = new \App\Models\ConfigurationModel();
|
$configModel = new \App\Models\ConfigurationModel();
|
||||||
$schoolYear = $schoolYear ?: $configModel->getConfig('school_year');
|
$schoolYear = $schoolYear ?: $this->currentSchoolYearName();
|
||||||
|
|
||||||
$derivedDateFrom = null;
|
$derivedDateFrom = null;
|
||||||
$derivedDateTo = null;
|
$derivedDateTo = null;
|
||||||
@@ -1612,7 +1634,7 @@ public function financialReport()
|
|||||||
$additionalModel = new \App\Models\AdditionalChargeModel();
|
$additionalModel = new \App\Models\AdditionalChargeModel();
|
||||||
|
|
||||||
// Allow override via parameter; fallback to configured year
|
// Allow override via parameter; fallback to configured year
|
||||||
$schoolYear = $schoolYear ?: $configModel->getConfig('school_year');
|
$schoolYear = $schoolYear ?: $this->currentSchoolYearName();
|
||||||
$derivedDateFrom = null;
|
$derivedDateFrom = null;
|
||||||
$derivedDateTo = null;
|
$derivedDateTo = null;
|
||||||
if (!empty($schoolYear) && empty($dateFrom) && empty($dateTo)) {
|
if (!empty($schoolYear) && empty($dateFrom) && empty($dateTo)) {
|
||||||
@@ -2068,7 +2090,7 @@ public function financialReport()
|
|||||||
$configModel = new \App\Models\ConfigurationModel();
|
$configModel = new \App\Models\ConfigurationModel();
|
||||||
$schoolYear = trim((string)($this->request->getGet('school_year') ?? ''));
|
$schoolYear = trim((string)($this->request->getGet('school_year') ?? ''));
|
||||||
if ($schoolYear === '') {
|
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
|
// Build school year options from invoices
|
||||||
|
|||||||
@@ -35,8 +35,11 @@ class FlagController extends Controller
|
|||||||
$grades = [];
|
$grades = [];
|
||||||
|
|
||||||
// Retrieve flags and class sections that currently have active students
|
// Retrieve flags and class sections that currently have active students
|
||||||
$flags = $currentFlagModel->findAll();
|
|
||||||
$schoolYear = $configModel->getConfig('school_year');
|
$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);
|
$studentCounts = $studentClassModel->getStudentCountsBySection($schoolYear);
|
||||||
$classSections = [];
|
$classSections = [];
|
||||||
|
|
||||||
|
|||||||
@@ -67,17 +67,17 @@ class InvoiceController extends ResourceController
|
|||||||
$this->chargesModel = new EventChargesModel();
|
$this->chargesModel = new EventChargesModel();
|
||||||
$this->discountUsageModel = new DiscountUsageModel();
|
$this->discountUsageModel = new DiscountUsageModel();
|
||||||
$this->refundModel = new RefundModel();
|
$this->refundModel = new RefundModel();
|
||||||
|
$this->db = \Config\Database::connect();
|
||||||
|
$this->request = \Config\Services::request();
|
||||||
|
|
||||||
$this->gradeFee = $this->configModel->getConfig('grade_fee');
|
$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->semester = $this->configModel->getConfig('semester');
|
||||||
$this->dueDate = $this->configModel->getConfig('due_date');
|
$this->dueDate = $this->configModel->getConfig('due_date');
|
||||||
$this->firstStudentFee = (float) ($this->configModel->getConfig('first_student_fee') ?? 350);
|
$this->firstStudentFee = (float) ($this->configModel->getConfig('first_student_fee') ?? 350);
|
||||||
$this->secondStudentFee = (float) ($this->configModel->getConfig('second_student_fee') ?? 200);
|
$this->secondStudentFee = (float) ($this->configModel->getConfig('second_student_fee') ?? 200);
|
||||||
$this->youthFee = (float) ($this->configModel->getConfig('youth_fee') ?? 200);
|
$this->youthFee = (float) ($this->configModel->getConfig('youth_fee') ?? 200);
|
||||||
$this->refundDeadline = date('Y-m-d', strtotime($this->configModel->getConfig('refund_deadline')));
|
$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)
|
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)
|
* API: Invoice management composite data (used by invoice_management view)
|
||||||
* Returns the same structure previously rendered server-side in index().
|
* 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') ?? ''));
|
$schoolYear = trim((string)($this->request->getGet('schoolYear') ?? $this->request->getGet('year') ?? ''));
|
||||||
if ($schoolYear === '') {
|
if ($schoolYear === '') {
|
||||||
$schoolYear = $this->schoolYear;
|
$schoolYear = $this->currentSchoolYearName();
|
||||||
}
|
}
|
||||||
|
|
||||||
$invoiceData = [];
|
$invoiceData = [];
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class LateSlipLogsController extends BaseController
|
|||||||
{
|
{
|
||||||
$req = $this->request;
|
$req = $this->request;
|
||||||
|
|
||||||
$defaultYear = (string) ($this->configModel->getConfig('school_year') ?? '');
|
$defaultYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
|
||||||
$defaultSem = (string) ($this->configModel->getConfig('semester') ?? '');
|
$defaultSem = (string) ($this->configModel->getConfig('semester') ?? '');
|
||||||
$schoolYear = trim((string) ($req->getGet('school_year') ?? $defaultYear));
|
$schoolYear = trim((string) ($req->getGet('school_year') ?? $defaultYear));
|
||||||
$semester = trim((string) ($req->getGet('semester') ?? $defaultSem));
|
$semester = trim((string) ($req->getGet('semester') ?? $defaultSem));
|
||||||
|
|||||||
@@ -149,6 +149,11 @@ class NotificationsController extends BaseController
|
|||||||
$targetGroup = null;
|
$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);
|
$rows = $this->notificationModel->getActiveNotifications($targetGroup);
|
||||||
if (!is_array($rows)) {
|
if (!is_array($rows)) {
|
||||||
$rows = [];
|
$rows = [];
|
||||||
|
|||||||
@@ -164,8 +164,7 @@ class ParentController extends BaseController
|
|||||||
return redirect()->back()->with('error', 'Parent session not found.');
|
return redirect()->back()->with('error', 'Parent session not found.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get current school year from config
|
$currentSchoolYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
|
||||||
$currentSchoolYear = $this->configModel->getConfig('school_year');
|
|
||||||
|
|
||||||
// Get selected school year (no semester filter on parent view)
|
// Get selected school year (no semester filter on parent view)
|
||||||
$selectedYear = $this->request->getVar('school_year') ?? $currentSchoolYear;
|
$selectedYear = $this->request->getVar('school_year') ?? $currentSchoolYear;
|
||||||
@@ -1784,7 +1783,7 @@ $existing = $this->studentModel
|
|||||||
|
|
||||||
public function parentEventPage()
|
public function parentEventPage()
|
||||||
{
|
{
|
||||||
$schoolYear = session()->get('school_year');
|
$schoolYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
|
||||||
$semester = session()->get('semester');
|
$semester = session()->get('semester');
|
||||||
$parentId = session()->get('user_id');
|
$parentId = session()->get('user_id');
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ class PaymentController extends ResourceController
|
|||||||
$this->enrollmentModel = new EnrollmentModel();
|
$this->enrollmentModel = new EnrollmentModel();
|
||||||
$this->studentModel = new StudentModel();
|
$this->studentModel = new StudentModel();
|
||||||
$this->studentClassModel = new StudentClassModel();
|
$this->studentClassModel = new StudentClassModel();
|
||||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
$this->schoolYear = $this->currentSchoolYearName();
|
||||||
$this->semester = $this->configModel->getConfig('semester'); //installment_date
|
$this->semester = $this->configModel->getConfig('semester'); //installment_date
|
||||||
$this->installmentDate = $this->configModel->getConfig('installment_date');
|
$this->installmentDate = $this->configModel->getConfig('installment_date');
|
||||||
$this->discountUsageModel = new DiscountUsageModel();
|
$this->discountUsageModel = new DiscountUsageModel();
|
||||||
@@ -137,11 +137,20 @@ class PaymentController extends ResourceController
|
|||||||
|
|
||||||
private function getSelectedPaymentHistoryYear(): ?string
|
private function getSelectedPaymentHistoryYear(): ?string
|
||||||
{
|
{
|
||||||
$selectedYear = $this->request->getGet('school_year') ?? $this->schoolYear;
|
$selectedYear = $this->request->getGet('school_year') ?? $this->currentSchoolYearName();
|
||||||
|
|
||||||
return $selectedYear !== '' ? $selectedYear : null;
|
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
|
// View: Create a new payment plan
|
||||||
public function create()
|
public function create()
|
||||||
{
|
{
|
||||||
@@ -204,7 +213,7 @@ class PaymentController extends ResourceController
|
|||||||
public function eventChargesShow()
|
public function eventChargesShow()
|
||||||
{
|
{
|
||||||
$parents = $this->userModel->getParents();
|
$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;
|
$seme_ster = $this->request->getGet('semester') ?? $this->semester;
|
||||||
|
|
||||||
// Join with users and students for full info
|
// Join with users and students for full info
|
||||||
|
|||||||
@@ -1235,6 +1235,10 @@ public function updateBatchAssignment()
|
|||||||
$status = $this->request->getGet('status') ?: null;
|
$status = $this->request->getGet('status') ?: null;
|
||||||
$filterYear = $this->request->getGet('school_year') ?: $this->schoolYear;
|
$filterYear = $this->request->getGet('school_year') ?: $this->schoolYear;
|
||||||
$userId = $this->request->getGet('user_id') ?: null;
|
$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 = [
|
$filters = [
|
||||||
'school_year' => $filterYear,
|
'school_year' => $filterYear,
|
||||||
@@ -1251,13 +1255,11 @@ public function updateBatchAssignment()
|
|||||||
// Build closed batch summaries/details (all closed batches)
|
// Build closed batch summaries/details (all closed batches)
|
||||||
$batchSummaries = [];
|
$batchSummaries = [];
|
||||||
$batchDetails = [];
|
$batchDetails = [];
|
||||||
$batchQuery = $this->db->table('reimbursement_batches b')
|
$batchSelect = "
|
||||||
->select("
|
|
||||||
b.id AS batch_id,
|
b.id AS batch_id,
|
||||||
b.title AS batch_title,
|
b.title AS batch_title,
|
||||||
b.yearly_batch_number,
|
b.yearly_batch_number,
|
||||||
b.closed_at,
|
b.closed_at,
|
||||||
b.school_year AS batch_school_year,
|
|
||||||
e.id AS expense_id,
|
e.id AS expense_id,
|
||||||
e.amount AS expense_amount,
|
e.amount AS expense_amount,
|
||||||
e.description,
|
e.description,
|
||||||
@@ -1268,7 +1270,13 @@ public function updateBatchAssignment()
|
|||||||
u.firstname AS purchaser_firstname,
|
u.firstname AS purchaser_firstname,
|
||||||
u.lastname AS purchaser_lastname,
|
u.lastname AS purchaser_lastname,
|
||||||
r.amount AS reimb_amount
|
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('reimbursement_batch_items bi', 'bi.batch_id = b.id', 'inner')
|
||||||
->join('expenses e', 'e.id = bi.expense_id', 'inner')
|
->join('expenses e', 'e.id = bi.expense_id', 'inner')
|
||||||
->join('users u', 'u.id = e.purchased_by', 'left')
|
->join('users u', 'u.id = e.purchased_by', 'left')
|
||||||
@@ -1277,10 +1285,16 @@ public function updateBatchAssignment()
|
|||||||
->where('bi.unassigned_at IS NULL', null, false);
|
->where('bi.unassigned_at IS NULL', null, false);
|
||||||
|
|
||||||
if (!empty($filterYear)) {
|
if (!empty($filterYear)) {
|
||||||
$batchQuery->groupStart()
|
if ($hasBatchSchoolYear && $hasExpenseSchoolYear) {
|
||||||
->where('b.school_year', $filterYear)
|
$batchQuery->groupStart()
|
||||||
->orWhere('e.school_year', $filterYear)
|
->where('b.school_year', $filterYear)
|
||||||
->groupEnd();
|
->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)) {
|
if (!empty($userId)) {
|
||||||
$batchQuery->where('e.purchased_by', $userId);
|
$batchQuery->where('e.purchased_by', $userId);
|
||||||
@@ -1401,27 +1415,33 @@ public function updateBatchAssignment()
|
|||||||
'items' => [],
|
'items' => [],
|
||||||
'checks' => [],
|
'checks' => [],
|
||||||
];
|
];
|
||||||
$donationQuery = $this->db->table('expenses e')
|
$donationSelect = '
|
||||||
->select('
|
|
||||||
e.id AS expense_id,
|
e.id AS expense_id,
|
||||||
e.amount AS expense_amount,
|
e.amount AS expense_amount,
|
||||||
e.description,
|
e.description,
|
||||||
e.retailor,
|
e.retailor,
|
||||||
e.receipt_path AS expense_receipt,
|
e.receipt_path AS expense_receipt,
|
||||||
e.purchased_by,
|
e.purchased_by,
|
||||||
e.school_year,
|
|
||||||
e.semester,
|
|
||||||
e.status,
|
e.status,
|
||||||
u.firstname AS purchaser_firstname,
|
u.firstname AS purchaser_firstname,
|
||||||
u.lastname AS purchaser_lastname
|
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')
|
->join('users u', 'u.id = e.purchased_by', 'left')
|
||||||
->where('e.category', 'Donation');
|
->where('e.category', 'Donation');
|
||||||
|
|
||||||
if (!empty($filterYear)) {
|
if (!empty($filterYear) && $hasExpenseSchoolYear) {
|
||||||
$donationQuery->where('e.school_year', $filterYear);
|
$donationQuery->where('e.school_year', $filterYear);
|
||||||
}
|
}
|
||||||
if (!empty($semester)) {
|
if (!empty($semester) && $hasExpenseSemester) {
|
||||||
$donationQuery->where('e.semester', $semester);
|
$donationQuery->where('e.semester', $semester);
|
||||||
}
|
}
|
||||||
if (!empty($userId)) {
|
if (!empty($userId)) {
|
||||||
@@ -1488,13 +1508,25 @@ public function updateBatchAssignment()
|
|||||||
|
|
||||||
$users = $this->recipientOptions();
|
$users = $this->recipientOptions();
|
||||||
|
|
||||||
$years = $this->db->table('reimbursements')
|
$schoolYears = [];
|
||||||
->select('school_year')
|
if ($hasReimbursementSchoolYear) {
|
||||||
->distinct()
|
$years = $this->db->table('reimbursements')
|
||||||
->orderBy('school_year', 'DESC')
|
->select('school_year')
|
||||||
->get()
|
->distinct()
|
||||||
->getResultArray();
|
->orderBy('school_year', 'DESC')
|
||||||
$schoolYears = array_column($years, 'school_year');
|
->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)
|
// Add school years from fallback expenses (closed batches without reimbursements)
|
||||||
if (!empty($fallbackRows)) {
|
if (!empty($fallbackRows)) {
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controllers\View;
|
||||||
|
|
||||||
|
use App\Controllers\BaseController;
|
||||||
|
use CodeIgniter\HTTP\RedirectResponse;
|
||||||
|
|
||||||
|
final class SchoolYearSelectionController extends BaseController
|
||||||
|
{
|
||||||
|
public function select(): RedirectResponse
|
||||||
|
{
|
||||||
|
$schoolYearId = (int) ($this->request->getPost('school_year_id') ?? 0);
|
||||||
|
$returnTo = (string) ($this->request->getPost('return_to') ?? '');
|
||||||
|
|
||||||
|
if ($schoolYearId <= 0) {
|
||||||
|
return redirect()->to($this->safeReturnTo($returnTo))
|
||||||
|
->with('error', 'Please select a valid school year.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
service('schoolYearContext')->select($schoolYearId);
|
||||||
|
return redirect()->to($this->safeReturnTo($returnTo));
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
log_message('warning', 'School-year selection failed: {message}', [
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()->to($this->safeReturnTo($returnTo))
|
||||||
|
->with('error', 'The selected school year is not available.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reset(): RedirectResponse
|
||||||
|
{
|
||||||
|
service('schoolYearContext')->clearSelection();
|
||||||
|
|
||||||
|
return redirect()->to($this->safeReturnTo((string) ($this->request->getPost('return_to') ?? '')));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function safeReturnTo(string $returnTo): string
|
||||||
|
{
|
||||||
|
$fallback = $this->dashboardRoute();
|
||||||
|
$returnTo = trim($returnTo);
|
||||||
|
|
||||||
|
if ($returnTo === '') {
|
||||||
|
return $fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = parse_url($returnTo);
|
||||||
|
if ($parts === false) {
|
||||||
|
return $fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($parts['host']) && strcasecmp((string) $parts['host'], (string) $this->request->getUri()->getHost()) !== 0) {
|
||||||
|
return $fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = '/' . ltrim((string) ($parts['path'] ?? ''), '/');
|
||||||
|
if ($path === '/' || str_starts_with($path, '//')) {
|
||||||
|
return $fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = [];
|
||||||
|
if (! empty($parts['query'])) {
|
||||||
|
parse_str((string) $parts['query'], $query);
|
||||||
|
foreach (['school_year_id', 'school_year', 'schoolYear', 'year'] as $key) {
|
||||||
|
unset($query[$key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$cleanQuery = http_build_query($query);
|
||||||
|
|
||||||
|
return $path . ($cleanQuery !== '' ? '?' . $cleanQuery : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function dashboardRoute(): string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return service('roleService')->bestDashboardRouteFor((array) session()->get('roles'));
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return '/landing_page/guest_dashboard';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,12 +2,11 @@
|
|||||||
|
|
||||||
namespace App\Controllers\View;
|
namespace App\Controllers\View;
|
||||||
|
|
||||||
|
use App\Controllers\BaseController;
|
||||||
use App\Models\ConfigurationModel;
|
use App\Models\ConfigurationModel;
|
||||||
use App\Models\ClassSectionModel;
|
use App\Models\ClassSectionModel;
|
||||||
|
|
||||||
use CodeIgniter\Controller;
|
class ScorePredictor extends BaseController
|
||||||
|
|
||||||
class ScorePredictor extends Controller
|
|
||||||
{
|
{
|
||||||
protected $db;
|
protected $db;
|
||||||
protected $configModel;
|
protected $configModel;
|
||||||
@@ -60,17 +59,30 @@ class ScorePredictor extends Controller
|
|||||||
public function combinedReport()
|
public function combinedReport()
|
||||||
{
|
{
|
||||||
$request = service('request');
|
$request = service('request');
|
||||||
$currentSchoolYear = $this->schoolYear;
|
$currentSchoolYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
|
||||||
|
|
||||||
$selectedYear = $request->getVar('school_year') ?? $currentSchoolYear;
|
$selectedYear = $request->getVar('school_year') ?? $currentSchoolYear;
|
||||||
$classSectionId = $request->getVar('class_section_id');
|
$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
|
// Only show class sections that have at least one student in the selected year
|
||||||
$classSections = $this->db->table('classSection cs')
|
$classSections = $this->db->table('classSection cs')
|
||||||
->select('cs.*')
|
->select('cs.*')
|
||||||
|
->distinct()
|
||||||
->join('student_class sc', 'sc.class_section_id = cs.class_section_id', 'inner')
|
->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)
|
->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')
|
->notLike('cs.class_section_name', 'KG', 'after')
|
||||||
->groupBy('cs.id')
|
|
||||||
->orderBy('cs.class_section_name', 'ASC')
|
->orderBy('cs.class_section_name', 'ASC')
|
||||||
->get()
|
->get()
|
||||||
->getResultArray();
|
->getResultArray();
|
||||||
@@ -85,12 +97,23 @@ class ScorePredictor extends Controller
|
|||||||
MAX(spring.semester_score) as spring_score');
|
MAX(spring.semester_score) as spring_score');
|
||||||
// Reduce duplication from restored students while keeping a stable class section.
|
// Reduce duplication from restored students while keeping a stable class section.
|
||||||
$builder->select('MAX(sc.class_section_id) as class_section_id');
|
$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, 'inner');
|
||||||
$builder->join('student_class sc', 'sc.student_id = s.id AND sc.school_year = ' . $yearEsc, 'left');
|
$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('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 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 = "' . $selectedYear . '"', '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('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()
|
$builder->groupStart()
|
||||||
->where('cs.class_section_name IS NULL')
|
->where('cs.class_section_name IS NULL')
|
||||||
->orNotLike('cs.class_section_name', 'KG', 'after')
|
->orNotLike('cs.class_section_name', 'KG', 'after')
|
||||||
@@ -106,11 +129,22 @@ class ScorePredictor extends Controller
|
|||||||
// Stats for spring
|
// Stats for spring
|
||||||
$springStatsQuery = $this->db->table('semester_scores')
|
$springStatsQuery = $this->db->table('semester_scores')
|
||||||
->select('AVG(semester_scores.semester_score) as mean, STDDEV(semester_scores.semester_score) as std')
|
->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')
|
->join('classSection cs', 'cs.class_section_id = student_class.class_section_id', 'left')
|
||||||
->where('semester_scores.semester', 'spring')
|
->where('semester_scores.semester', 'spring')
|
||||||
->where('semester_scores.school_year', $selectedYear)
|
->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()
|
->groupStart()
|
||||||
->where('cs.class_section_name IS NULL')
|
->where('cs.class_section_name IS NULL')
|
||||||
->orNotLike('cs.class_section_name', 'KG', 'after')
|
->orNotLike('cs.class_section_name', 'KG', 'after')
|
||||||
|
|||||||
@@ -386,21 +386,8 @@ class StudentController extends BaseController
|
|||||||
{
|
{
|
||||||
$schoolYear = (string)($this->schoolYear ?? '');
|
$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 = [];
|
$classMap = [];
|
||||||
|
$activeYearStudentIds = [];
|
||||||
$classQuery = $this->db->table('student_class sc')
|
$classQuery = $this->db->table('student_class sc')
|
||||||
->select('sc.student_id, cs.class_section_name')
|
->select('sc.student_id, cs.class_section_name')
|
||||||
->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left');
|
->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left');
|
||||||
@@ -411,11 +398,35 @@ class StudentController extends BaseController
|
|||||||
|
|
||||||
foreach ($classRows as $row) {
|
foreach ($classRows as $row) {
|
||||||
$sid = (int)($row['student_id'] ?? 0);
|
$sid = (int)($row['student_id'] ?? 0);
|
||||||
|
if ($sid > 0) {
|
||||||
|
$activeYearStudentIds[$sid] = true;
|
||||||
|
}
|
||||||
|
|
||||||
$name = trim((string)($row['class_section_name'] ?? ''));
|
$name = trim((string)($row['class_section_name'] ?? ''));
|
||||||
if ($sid <= 0 || $name === '') continue;
|
if ($sid <= 0 || $name === '') continue;
|
||||||
$classMap[$sid][] = $name;
|
$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 {
|
$attachClassNames = static function (array $students) use ($classMap): array {
|
||||||
foreach ($students as &$student) {
|
foreach ($students as &$student) {
|
||||||
$sid = (int)($student['id'] ?? 0);
|
$sid = (int)($student['id'] ?? 0);
|
||||||
@@ -432,6 +443,7 @@ class StudentController extends BaseController
|
|||||||
'active_students' => $attachClassNames($activeStudents),
|
'active_students' => $attachClassNames($activeStudents),
|
||||||
'removed_students' => $attachClassNames($removedStudents),
|
'removed_students' => $attachClassNames($removedStudents),
|
||||||
'school_year' => $schoolYear,
|
'school_year' => $schoolYear,
|
||||||
|
'active_school_year' => (string)($this->schoolYear ?? ''),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1671,8 +1683,7 @@ class StudentController extends BaseController
|
|||||||
->findAll();
|
->findAll();
|
||||||
}
|
}
|
||||||
} elseif (in_array($role, ['teacher', 'teacher_assistant', 'teacher_dashboard'], true)) {
|
} elseif (in_array($role, ['teacher', 'teacher_assistant', 'teacher_dashboard'], true)) {
|
||||||
$configModel = new \App\Models\ConfigurationModel();
|
$schoolYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
|
||||||
$schoolYear = session()->get('school_year') ?? $configModel->getConfig('school_year');
|
|
||||||
$classSectionId = (int)(session()->get('class_section_id') ?? 0);
|
$classSectionId = (int)(session()->get('class_section_id') ?? 0);
|
||||||
|
|
||||||
if ($classSectionId > 0 && !empty($schoolYear)) {
|
if ($classSectionId > 0 && !empty($schoolYear)) {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class TrophyController extends BaseController
|
|||||||
{
|
{
|
||||||
$db = \Config\Database::connect();
|
$db = \Config\Database::connect();
|
||||||
|
|
||||||
$currentYear = $this->configModel->getConfig('school_year') ?? '';
|
$currentYear = $this->currentSchoolYearName();
|
||||||
$selectedYear = $this->request->getGet('school_year') ?? $currentYear;
|
$selectedYear = $this->request->getGet('school_year') ?? $currentYear;
|
||||||
|
|
||||||
$percentile = (float) ($this->request->getGet('percentile') ?? 75);
|
$percentile = (float) ($this->request->getGet('percentile') ?? 75);
|
||||||
@@ -166,7 +166,7 @@ class TrophyController extends BaseController
|
|||||||
{
|
{
|
||||||
$db = \Config\Database::connect();
|
$db = \Config\Database::connect();
|
||||||
|
|
||||||
$currentYear = $this->configModel->getConfig('school_year') ?? '';
|
$currentYear = $this->currentSchoolYearName();
|
||||||
$selectedYear = $this->request->getGet('school_year') ?? $currentYear;
|
$selectedYear = $this->request->getGet('school_year') ?? $currentYear;
|
||||||
$percentile = (float) ($this->request->getGet('percentile') ?? 75);
|
$percentile = (float) ($this->request->getGet('percentile') ?? 75);
|
||||||
$percentile = max(1.0, min(99.0, $percentile));
|
$percentile = max(1.0, min(99.0, $percentile));
|
||||||
@@ -283,7 +283,7 @@ class TrophyController extends BaseController
|
|||||||
{
|
{
|
||||||
$db = \Config\Database::connect();
|
$db = \Config\Database::connect();
|
||||||
|
|
||||||
$currentYear = $this->configModel->getConfig('school_year') ?? '';
|
$currentYear = $this->currentSchoolYearName();
|
||||||
$selectedYear = $this->request->getGet('school_year') ?? $currentYear;
|
$selectedYear = $this->request->getGet('school_year') ?? $currentYear;
|
||||||
$percentile = (float) ($this->request->getGet('percentile') ?? 75);
|
$percentile = (float) ($this->request->getGet('percentile') ?? 75);
|
||||||
$percentile = max(1.0, min(99.0, $percentile));
|
$percentile = max(1.0, min(99.0, $percentile));
|
||||||
|
|||||||
@@ -961,12 +961,18 @@ class UserController extends BaseController
|
|||||||
$perPage = max(1, min($perPage, 200));
|
$perPage = max(1, min($perPage, 200));
|
||||||
$page = max(1, $page);
|
$page = max(1, $page);
|
||||||
|
|
||||||
$totalActivities = (int) $this->loginActivityModel->countAll();
|
$schoolYear = (string) ((new \App\Models\ConfigurationModel())->getConfig('school_year') ?? '');
|
||||||
$activities = $this->loginActivityModel
|
$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')
|
->orderBy('login_time', 'DESC')
|
||||||
->paginate($perPage, 'loginActivity', $page) ?? [];
|
->paginate($perPage, 'loginActivity', $page) ?? [];
|
||||||
|
|
||||||
$pager = $this->loginActivityModel->pager;
|
$pager = $activityModel->pager;
|
||||||
$currentPage = $pager ? (int) $pager->getCurrentPage('loginActivity') : $page;
|
$currentPage = $pager ? (int) $pager->getCurrentPage('loginActivity') : $page;
|
||||||
$pageCount = $pager ? (int) $pager->getPageCount('loginActivity') : (int) max(1, ceil($totalActivities / $perPage));
|
$pageCount = $pager ? (int) $pager->getPageCount('loginActivity') : (int) max(1, ceil($totalActivities / $perPage));
|
||||||
|
|
||||||
|
|||||||
@@ -103,24 +103,14 @@ class WhatsappController extends BaseController
|
|||||||
$sectionName = $row['class_section_name'] ?? ('Section ' . $sectionId);
|
$sectionName = $row['class_section_name'] ?? ('Section ' . $sectionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
$existing = $this->linkModel->where([
|
$this->linkModel->upsertLinkForSection(
|
||||||
'class_section_id' => $sectionId,
|
$sectionId,
|
||||||
'school_year' => $this->schoolYear,
|
$sectionName,
|
||||||
])->first();
|
(string) $this->schoolYear,
|
||||||
|
'',
|
||||||
$payload = [
|
$inviteLink,
|
||||||
'class_section_id' => $sectionId,
|
$active === 1
|
||||||
'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);
|
|
||||||
}
|
|
||||||
|
|
||||||
return redirect()->back()->with('success', 'WhatsApp group link saved.');
|
return redirect()->back()->with('success', 'WhatsApp group link saved.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ class WinnersController extends BaseController
|
|||||||
public function competitionIndex()
|
public function competitionIndex()
|
||||||
{
|
{
|
||||||
$competitionModel = new CompetitionModel();
|
$competitionModel = new CompetitionModel();
|
||||||
|
$schoolYear = $this->currentSchoolYearName();
|
||||||
|
|
||||||
|
if ($schoolYear !== '') {
|
||||||
|
$competitionModel->where('school_year', $schoolYear);
|
||||||
|
}
|
||||||
|
|
||||||
$competitions = $competitionModel
|
$competitions = $competitionModel
|
||||||
->where('is_published', 1)
|
->where('is_published', 1)
|
||||||
@@ -21,6 +26,7 @@ class WinnersController extends BaseController
|
|||||||
|
|
||||||
return view('winners/competitions/index', [
|
return view('winners/competitions/index', [
|
||||||
'competitions' => $competitions,
|
'competitions' => $competitions,
|
||||||
|
'schoolYear' => $schoolYear,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,7 +37,14 @@ class WinnersController extends BaseController
|
|||||||
$classSectionModel = new ClassSectionModel();
|
$classSectionModel = new ClassSectionModel();
|
||||||
$classWinnerModel = new CompetitionClassWinnerModel();
|
$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) {
|
if (!$competition) {
|
||||||
return redirect()->to('/winners/competitions');
|
return redirect()->to('/winners/competitions');
|
||||||
}
|
}
|
||||||
@@ -81,6 +94,7 @@ class WinnersController extends BaseController
|
|||||||
'winnersByClass' => $winnersByClass,
|
'winnersByClass' => $winnersByClass,
|
||||||
'sectionMap' => $sectionMap,
|
'sectionMap' => $sectionMap,
|
||||||
'questionCounts' => $questionCounts,
|
'questionCounts' => $questionCounts,
|
||||||
|
'schoolYear' => $schoolYear,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Exceptions\SchoolYear;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class InvalidSchoolYearSelectionException extends RuntimeException
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Exceptions\SchoolYear;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class SchoolYearConfigurationException extends RuntimeException
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Exceptions\SchoolYear;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class SchoolYearNotFoundException extends RuntimeException
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Exceptions\SchoolYear;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class SchoolYearWriteConflictException extends RuntimeException
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -6,6 +6,9 @@ use CodeIgniter\Filters\FilterInterface;
|
|||||||
use CodeIgniter\HTTP\IncomingRequest;
|
use CodeIgniter\HTTP\IncomingRequest;
|
||||||
use CodeIgniter\HTTP\RequestInterface;
|
use CodeIgniter\HTTP\RequestInterface;
|
||||||
use CodeIgniter\HTTP\ResponseInterface;
|
use CodeIgniter\HTTP\ResponseInterface;
|
||||||
|
use App\Exceptions\SchoolYear\InvalidSchoolYearSelectionException;
|
||||||
|
use App\Exceptions\SchoolYear\SchoolYearConfigurationException;
|
||||||
|
use App\Exceptions\SchoolYear\SchoolYearNotFoundException;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
final class RequireSchoolYearFilter implements FilterInterface
|
final class RequireSchoolYearFilter implements FilterInterface
|
||||||
@@ -24,13 +27,25 @@ final class RequireSchoolYearFilter implements FilterInterface
|
|||||||
'message' => $e->getMessage(),
|
'message' => $e->getMessage(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$status = $this->statusCodeFor($e);
|
||||||
|
|
||||||
|
if ($request->isAJAX() || str_contains(strtolower($request->getHeaderLine('Accept')), 'application/json')) {
|
||||||
|
return service('response')
|
||||||
|
->setStatusCode($status)
|
||||||
|
->setJSON([
|
||||||
|
'status' => $status,
|
||||||
|
'error' => 'Invalid school year',
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
return service('response')
|
return service('response')
|
||||||
->setStatusCode(400)
|
->setStatusCode($status)
|
||||||
->setJSON([
|
->setBody(view('errors/school_year', [
|
||||||
'status' => 400,
|
'message' => $status >= 500
|
||||||
'error' => 'Invalid school year',
|
? 'School-year configuration is invalid. Please contact an administrator.'
|
||||||
'message' => $e->getMessage(),
|
: $e->getMessage(),
|
||||||
]);
|
]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,4 +53,21 @@ final class RequireSchoolYearFilter implements FilterInterface
|
|||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function statusCodeFor(Throwable $e): int
|
||||||
|
{
|
||||||
|
if ($e instanceof InvalidSchoolYearSelectionException) {
|
||||||
|
return 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($e instanceof SchoolYearNotFoundException) {
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($e instanceof SchoolYearConfigurationException) {
|
||||||
|
return 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 400;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filters;
|
||||||
|
|
||||||
|
use App\Exceptions\SchoolYear\SchoolYearWriteConflictException;
|
||||||
|
use CodeIgniter\Filters\FilterInterface;
|
||||||
|
use CodeIgniter\HTTP\IncomingRequest;
|
||||||
|
use CodeIgniter\HTTP\RequestInterface;
|
||||||
|
use CodeIgniter\HTTP\ResponseInterface;
|
||||||
|
|
||||||
|
final class SchoolYearWritableFilter implements FilterInterface
|
||||||
|
{
|
||||||
|
public function before(RequestInterface $request, $arguments = null)
|
||||||
|
{
|
||||||
|
if (! $request instanceof IncomingRequest) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
service('schoolYearWriteGuard')->assertWritable(
|
||||||
|
service('schoolYearContext')->resolve($request)
|
||||||
|
);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (SchoolYearWriteConflictException $e) {
|
||||||
|
log_message('warning', 'Historical school-year write blocked: {message}', [
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($request->isAJAX() || str_contains(strtolower($request->getHeaderLine('Accept')), 'application/json')) {
|
||||||
|
return service('response')
|
||||||
|
->setStatusCode(409)
|
||||||
|
->setJSON([
|
||||||
|
'status' => 409,
|
||||||
|
'error' => 'Read-only school year',
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->back()->with('error', $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,16 @@ if (!function_exists('getSemester')) {
|
|||||||
|
|
||||||
if (!function_exists('getSchoolYear')) {
|
if (!function_exists('getSchoolYear')) {
|
||||||
function 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();
|
$configModel = new \App\Models\ConfigurationModel();
|
||||||
return $configModel->getConfig('school_year');
|
return $configModel->getConfig('school_year');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,6 +77,13 @@ class ConfigurationModel extends Model
|
|||||||
public function getConfig($key)
|
public function getConfig($key)
|
||||||
{
|
{
|
||||||
$key = (string) $key;
|
$key = (string) $key;
|
||||||
|
if ($key === 'school_year') {
|
||||||
|
$activeSchoolYear = $this->activeSchoolYearName();
|
||||||
|
if ($activeSchoolYear !== null) {
|
||||||
|
return $activeSchoolYear;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ($key === 'semester') {
|
if ($key === 'semester') {
|
||||||
try {
|
try {
|
||||||
$semester = (new \App\Services\SemesterRangeService($this))->getSemesterForDate();
|
$semester = (new \App\Services\SemesterRangeService($this))->getSemesterForDate();
|
||||||
@@ -90,4 +97,26 @@ class ConfigurationModel extends Model
|
|||||||
|
|
||||||
return $this->getConfigValueByKey($key);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+46
-10
@@ -39,23 +39,59 @@ class ExpenseModel extends Model
|
|||||||
{
|
{
|
||||||
$db = \Config\Database::connect();
|
$db = \Config\Database::connect();
|
||||||
$builder = $db->table('expenses');
|
$builder = $db->table('expenses');
|
||||||
$builder->select('expenses.*,
|
$hasReimbursementSchoolYear = $db->fieldExists('school_year', 'reimbursements');
|
||||||
r.id AS reimb_id,
|
$hasReimbursementSemester = $db->fieldExists('semester', 'reimbursements');
|
||||||
r.amount AS reimb_amount, r.reimbursement_method, r.check_number,
|
$hasExpenseSchoolYear = $db->fieldExists('school_year', 'expenses');
|
||||||
r.receipt_path AS reimb_receipt, r.status AS reimb_status,
|
$hasExpenseSemester = $db->fieldExists('semester', 'expenses');
|
||||||
r.school_year, r.semester, r.created_at AS reimb_created_at,
|
|
||||||
r.reimbursed_to AS reimb_recipient_id,
|
$select = [
|
||||||
reimb.firstname AS reimb_firstname, reimb.lastname AS reimb_lastname,
|
'expenses.*',
|
||||||
approver.firstname AS approver_firstname, approver.lastname AS approver_lastname');
|
'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('reimbursements r', 'r.expense_id = expenses.id', 'inner');
|
||||||
$builder->join('users reimb', 'reimb.id = r.reimbursed_to', 'left');
|
$builder->join('users reimb', 'reimb.id = r.reimbursed_to', 'left');
|
||||||
$builder->join('users approver', 'approver.id = r.approved_by', 'left');
|
$builder->join('users approver', 'approver.id = r.approved_by', 'left');
|
||||||
|
|
||||||
if (!empty($filters['school_year'])) {
|
if (!empty($filters['school_year'])) {
|
||||||
$builder->where('r.school_year', $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 (!empty($filters['semester'])) {
|
||||||
$builder->where('r.semester', $filters['semester']);
|
if ($hasReimbursementSemester) {
|
||||||
|
$builder->where('r.semester', $filters['semester']);
|
||||||
|
} elseif ($hasExpenseSemester) {
|
||||||
|
$builder->where('expenses.semester', $filters['semester']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!empty($filters['status'])) {
|
if (!empty($filters['status'])) {
|
||||||
$builder->where('r.status', $filters['status']);
|
$builder->where('r.status', $filters['status']);
|
||||||
|
|||||||
@@ -40,8 +40,14 @@ class SchoolYearModel extends Model
|
|||||||
|
|
||||||
public function active(): ?array
|
public function active(): ?array
|
||||||
{
|
{
|
||||||
return $this->where('status', 'active')
|
$rows = $this->where('status', 'active')
|
||||||
->orderBy('id', 'DESC')
|
->orderBy('id', 'DESC')
|
||||||
->first();
|
->findAll(2);
|
||||||
|
|
||||||
|
if (count($rows) !== 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rows[0];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -341,8 +341,17 @@ class StudentModel extends Model
|
|||||||
return array_column($results, 'id');
|
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('
|
return $this->select('
|
||||||
students.*,
|
students.*,
|
||||||
student_class.class_section_id,
|
student_class.class_section_id,
|
||||||
@@ -351,8 +360,8 @@ class StudentModel extends Model
|
|||||||
u.firstname AS parent_firstname,
|
u.firstname AS parent_firstname,
|
||||||
u.lastname AS parent_lastname
|
u.lastname AS parent_lastname
|
||||||
')
|
')
|
||||||
->join('student_class', 'student_class.student_id = students.id', 'left')
|
->join('student_class', $studentClassJoin, 'left')
|
||||||
->join('enrollments', 'enrollments.student_id = students.id', 'left')
|
->join('enrollments', $enrollmentJoin, 'left')
|
||||||
->join('users u', 'u.id = students.parent_id', 'left')
|
->join('users u', 'u.id = students.parent_id', 'left')
|
||||||
// keep your original grouping as-is so behavior doesn't change
|
// 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')
|
->groupBy('students.id, student_class.class_section_id, enrollments.enrollment_status, enrollments.admission_status')
|
||||||
|
|||||||
@@ -16,6 +16,17 @@ class WhatsappGroupLinkModel extends Model
|
|||||||
];
|
];
|
||||||
protected $useTimestamps = true; // requires created_at / updated_at columns
|
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.
|
* Get the link for a specific section in a specific term.
|
||||||
*
|
*
|
||||||
@@ -36,8 +47,11 @@ class WhatsappGroupLinkModel extends Model
|
|||||||
$sem = trim($sem);
|
$sem = trim($sem);
|
||||||
|
|
||||||
$b = $this->asArray()
|
$b = $this->asArray()
|
||||||
->where('class_section_id', $sectionId)
|
->where('class_section_id', $sectionId);
|
||||||
->where('school_year', $year);
|
|
||||||
|
if ($this->hasSchoolYearColumn()) {
|
||||||
|
$b = $b->where('school_year', $year);
|
||||||
|
}
|
||||||
|
|
||||||
if ($onlyActive) {
|
if ($onlyActive) {
|
||||||
$b = $b->where('active', 1);
|
$b = $b->where('active', 1);
|
||||||
@@ -69,8 +83,11 @@ class WhatsappGroupLinkModel extends Model
|
|||||||
$year = trim($year);
|
$year = trim($year);
|
||||||
$sem = trim($sem);
|
$sem = trim($sem);
|
||||||
|
|
||||||
$b = $this->asArray()
|
$b = $this->asArray();
|
||||||
->where('school_year', $year);
|
|
||||||
|
if ($this->hasSchoolYearColumn()) {
|
||||||
|
$b = $b->where('school_year', $year);
|
||||||
|
}
|
||||||
|
|
||||||
if ($onlyActive === true) {
|
if ($onlyActive === true) {
|
||||||
$b = $b->where('active', 1);
|
$b = $b->where('active', 1);
|
||||||
@@ -98,16 +115,23 @@ class WhatsappGroupLinkModel extends Model
|
|||||||
$payload = [
|
$payload = [
|
||||||
'class_section_id' => $sectionId,
|
'class_section_id' => $sectionId,
|
||||||
'class_section_name' => $sectionName,
|
'class_section_name' => $sectionName,
|
||||||
'school_year' => trim($year),
|
|
||||||
'invite_link' => trim($inviteLink),
|
'invite_link' => trim($inviteLink),
|
||||||
'active' => $active ? 1 : 0,
|
'active' => $active ? 1 : 0,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if ($this->hasSchoolYearColumn()) {
|
||||||
|
$payload['school_year'] = trim($year);
|
||||||
|
}
|
||||||
|
|
||||||
// Try to find existing row (exact term)
|
// Try to find existing row (exact term)
|
||||||
$existing = $this->asArray()
|
$existing = $this->asArray()
|
||||||
->where('class_section_id', $sectionId)
|
->where('class_section_id', $sectionId);
|
||||||
->where('school_year', $payload['school_year'])
|
|
||||||
->first();
|
if ($this->hasSchoolYearColumn()) {
|
||||||
|
$existing = $existing->where('school_year', $payload['school_year']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$existing = $existing->first();
|
||||||
|
|
||||||
if ($existing) {
|
if ($existing) {
|
||||||
$this->update((int)$existing['id'], $payload);
|
$this->update((int)$existing['id'], $payload);
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
final class SchoolYearAccessPolicy
|
||||||
|
{
|
||||||
|
public function canView(?int $userId, array $year): bool
|
||||||
|
{
|
||||||
|
if (($year['status'] ?? '') !== 'draft') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->canViewDraft($userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canSelect(?int $userId, array $year): bool
|
||||||
|
{
|
||||||
|
return $this->canView($userId, $year);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canViewDraft(?int $userId): bool
|
||||||
|
{
|
||||||
|
if ($userId === null || $userId <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$roles = array_map(
|
||||||
|
static fn ($role): string => strtolower(trim((string) $role)),
|
||||||
|
(array) session()->get('roles')
|
||||||
|
);
|
||||||
|
|
||||||
|
return (bool) array_intersect($roles, [
|
||||||
|
'admin',
|
||||||
|
'administrator',
|
||||||
|
'administrative staff',
|
||||||
|
'principal',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ namespace App\Services;
|
|||||||
use App\Models\SchoolYearClosingBatchModel;
|
use App\Models\SchoolYearClosingBatchModel;
|
||||||
use App\Models\SchoolYearClosingItemModel;
|
use App\Models\SchoolYearClosingItemModel;
|
||||||
use App\Models\SchoolYearModel;
|
use App\Models\SchoolYearModel;
|
||||||
|
use App\Models\ConfigurationModel;
|
||||||
use App\Support\SchoolYear\SchoolYearStatus;
|
use App\Support\SchoolYear\SchoolYearStatus;
|
||||||
use CodeIgniter\Database\BaseConnection;
|
use CodeIgniter\Database\BaseConnection;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
@@ -16,6 +17,7 @@ final class SchoolYearClosingService
|
|||||||
private readonly SchoolYearModel $schoolYearModel,
|
private readonly SchoolYearModel $schoolYearModel,
|
||||||
private readonly SchoolYearClosingBatchModel $batchModel,
|
private readonly SchoolYearClosingBatchModel $batchModel,
|
||||||
private readonly SchoolYearClosingItemModel $itemModel,
|
private readonly SchoolYearClosingItemModel $itemModel,
|
||||||
|
private readonly ConfigurationModel $configurationModel,
|
||||||
private readonly SchoolYearManagementService $managementService,
|
private readonly SchoolYearManagementService $managementService,
|
||||||
private readonly BaseConnection $db,
|
private readonly BaseConnection $db,
|
||||||
) {
|
) {
|
||||||
@@ -50,6 +52,29 @@ final class SchoolYearClosingService
|
|||||||
$findings[] = $finding;
|
$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) {
|
if ($this->countMissingSchoolYearRows('invoices') > 0) {
|
||||||
$findings[] = $this->finding('blocking', 'Invoices missing school year', 'Some invoice records are not assigned to a school year.');
|
$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,
|
'target' => $target,
|
||||||
'overview' => $overview,
|
'overview' => $overview,
|
||||||
'finance' => $finance,
|
'finance' => $finance,
|
||||||
|
'promotion' => $promotion,
|
||||||
'findings' => $findings,
|
'findings' => $findings,
|
||||||
'blockers' => $blockers,
|
'blockers' => $blockers,
|
||||||
'warnings' => $warnings,
|
'warnings' => $warnings,
|
||||||
@@ -183,6 +209,12 @@ final class SchoolYearClosingService
|
|||||||
throw new InvalidArgumentException('All closing batch items must complete before the year can be closed.');
|
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();
|
$this->db->transStart();
|
||||||
$now = date('Y-m-d H:i:s');
|
$now = date('Y-m-d H:i:s');
|
||||||
$this->batchModel->update((int) $batch['id'], [
|
$this->batchModel->update((int) $batch['id'], [
|
||||||
@@ -195,8 +227,25 @@ final class SchoolYearClosingService
|
|||||||
'closed_at' => $now,
|
'closed_at' => $now,
|
||||||
'updated_by' => $userId,
|
'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, [
|
$this->managementService->log($sourceYearId, SchoolYearStatus::CLOSING, SchoolYearStatus::CLOSED, 'closing_complete', $userId, [
|
||||||
'closing_batch_id' => (int) $batch['id'],
|
'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();
|
$this->db->transComplete();
|
||||||
|
|
||||||
@@ -322,11 +371,21 @@ final class SchoolYearClosingService
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$rows = $this->db->table('invoices i')
|
$builder = $this->db->table('invoices i')
|
||||||
->select('i.parent_id AS family_id')
|
->select('i.parent_id AS family_id')
|
||||||
->select('COALESCE(SUM(i.balance), 0) AS source_balance')
|
->select('COALESCE(SUM(i.balance), 0) AS source_balance')
|
||||||
->where('i.school_year', $schoolYear)
|
->where('i.school_year', $schoolYear);
|
||||||
->groupBy('i.parent_id')
|
|
||||||
|
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)
|
->having('source_balance !=', 0)
|
||||||
->orderBy('i.parent_id', 'ASC')
|
->orderBy('i.parent_id', 'ASC')
|
||||||
->get()
|
->get()
|
||||||
@@ -338,6 +397,8 @@ final class SchoolYearClosingService
|
|||||||
return [
|
return [
|
||||||
'family_id' => (int) $row['family_id'],
|
'family_id' => (int) $row['family_id'],
|
||||||
'family' => 'Family #' . (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,
|
'source_balance' => $balance,
|
||||||
'credit_amount' => $balance < 0 ? abs($balance) : 0.0,
|
'credit_amount' => $balance < 0 ? abs($balance) : 0.0,
|
||||||
'adjustment_amount' => 0.0,
|
'adjustment_amount' => 0.0,
|
||||||
@@ -346,6 +407,315 @@ final class SchoolYearClosingService
|
|||||||
}, $rows);
|
}, $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
|
private function countBySchoolYear(string $table, string $schoolYear, string $distinctField): int
|
||||||
{
|
{
|
||||||
if (! $this->db->tableExists($table) || ! $this->db->fieldExists('school_year', $table)) {
|
if (! $this->db->tableExists($table) || ! $this->db->fieldExists('school_year', $table)) {
|
||||||
@@ -423,8 +793,20 @@ final class SchoolYearClosingService
|
|||||||
'source_id' => $preview['source']['id'] ?? null,
|
'source_id' => $preview['source']['id'] ?? null,
|
||||||
'target_id' => $preview['target']['id'] ?? null,
|
'target_id' => $preview['target']['id'] ?? null,
|
||||||
'finance' => $preview['finance'],
|
'finance' => $preview['finance'],
|
||||||
|
'promotion' => $preview['promotion'],
|
||||||
'carry_forward' => $preview['carry_forward'],
|
'carry_forward' => $preview['carry_forward'],
|
||||||
'blockers' => $preview['blockers'],
|
'blockers' => $preview['blockers'],
|
||||||
], JSON_UNESCAPED_SLASHES));
|
], JSON_UNESCAPED_SLASHES));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function syncActiveYearSession(string $schoolYearName): void
|
||||||
|
{
|
||||||
|
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
session()->set('school_year', $schoolYearName);
|
||||||
|
session()->remove('selected_school_year_id');
|
||||||
|
session()->remove('selected_school_year');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,15 +2,19 @@
|
|||||||
|
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Exceptions\SchoolYear\InvalidSchoolYearSelectionException;
|
||||||
|
use App\Exceptions\SchoolYear\SchoolYearConfigurationException;
|
||||||
|
use App\Exceptions\SchoolYear\SchoolYearNotFoundException;
|
||||||
use App\Models\SchoolYearModel;
|
use App\Models\SchoolYearModel;
|
||||||
|
use App\Support\SchoolYear\SchoolYearStatus;
|
||||||
use App\Support\SchoolYear\SchoolYearContext;
|
use App\Support\SchoolYear\SchoolYearContext;
|
||||||
use CodeIgniter\HTTP\IncomingRequest;
|
use CodeIgniter\HTTP\IncomingRequest;
|
||||||
use RuntimeException;
|
|
||||||
|
|
||||||
final class SchoolYearContextService
|
final class SchoolYearContextService
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly SchoolYearModel $schoolYearModel,
|
private readonly SchoolYearModel $schoolYearModel,
|
||||||
|
private readonly SchoolYearAccessPolicy $accessPolicy = new SchoolYearAccessPolicy(),
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,29 +22,28 @@ final class SchoolYearContextService
|
|||||||
IncomingRequest $request,
|
IncomingRequest $request,
|
||||||
?int $routeSchoolYearId = null
|
?int $routeSchoolYearId = null
|
||||||
): SchoolYearContext {
|
): SchoolYearContext {
|
||||||
$requestedId = $routeSchoolYearId
|
$requestedId = $routeSchoolYearId ?? $this->normalizeInt($request->getGet('school_year_id'));
|
||||||
?? $this->normalizeInt($request->getGet('school_year_id'));
|
$requestedName = $this->legacyYearName($request);
|
||||||
|
$userId = (int) (session()->get('user_id') ?? 0);
|
||||||
$requestedName = trim((string) $request->getGet('school_year'));
|
|
||||||
|
|
||||||
if ($requestedId !== null && $requestedName !== '') {
|
if ($requestedId !== null && $requestedName !== '') {
|
||||||
$row = $this->schoolYearModel->find($requestedId);
|
$row = $this->schoolYearModel->find($requestedId);
|
||||||
|
|
||||||
if ($row === null || (string) ($row['name'] ?? '') !== $requestedName) {
|
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) {
|
if ($requestedId !== null) {
|
||||||
$row = $this->schoolYearModel->find($requestedId);
|
$row = $this->schoolYearModel->find($requestedId);
|
||||||
|
|
||||||
if ($row === null) {
|
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 !== '') {
|
if ($requestedName !== '') {
|
||||||
@@ -49,10 +52,10 @@ final class SchoolYearContextService
|
|||||||
->first();
|
->first();
|
||||||
|
|
||||||
if ($row === null) {
|
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');
|
$sessionYearId = session('selected_school_year_id');
|
||||||
@@ -60,15 +63,73 @@ final class SchoolYearContextService
|
|||||||
if (is_numeric($sessionYearId)) {
|
if (is_numeric($sessionYearId)) {
|
||||||
$row = $this->schoolYearModel->find((int) $sessionYearId);
|
$row = $this->schoolYearModel->find((int) $sessionYearId);
|
||||||
|
|
||||||
if ($row !== null) {
|
if ($row !== null && $this->isSelectable($row, $userId)) {
|
||||||
return $this->fromRow($row, false);
|
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();
|
$active = $this->schoolYearModel->active();
|
||||||
|
|
||||||
if ($active === null) {
|
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);
|
return $this->fromRow($active, false);
|
||||||
@@ -83,12 +144,61 @@ final class SchoolYearContextService
|
|||||||
return (int) $value;
|
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
|
private function fromRow(array $row, bool $explicit): SchoolYearContext
|
||||||
{
|
{
|
||||||
return new SchoolYearContext(
|
return new SchoolYearContext(
|
||||||
id: (int) $row['id'],
|
id: (int) $row['id'],
|
||||||
yearName: (string) $row['name'],
|
yearName: (string) $row['name'],
|
||||||
status: (string) $row['status'],
|
status: strtolower((string) $row['status']),
|
||||||
explicitSelection: $explicit,
|
explicitSelection: $explicit,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ final class SchoolYearManagementService
|
|||||||
'updated_by' => $userId,
|
'updated_by' => $userId,
|
||||||
]);
|
]);
|
||||||
$this->configurationModel->setConfigValueByKey('school_year', (string) $year['name']);
|
$this->configurationModel->setConfigValueByKey('school_year', (string) $year['name']);
|
||||||
|
$this->syncActiveYearSession((string) $year['name']);
|
||||||
$this->log($id, $from, SchoolYearStatus::ACTIVE, 'activate', $userId);
|
$this->log($id, $from, SchoolYearStatus::ACTIVE, 'activate', $userId);
|
||||||
|
|
||||||
$this->db->transComplete();
|
$this->db->transComplete();
|
||||||
@@ -310,4 +311,15 @@ final class SchoolYearManagementService
|
|||||||
|
|
||||||
return is_string($first) && $first !== '' ? $first : $fallback;
|
return is_string($first) && $first !== '' ? $first : $fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function syncActiveYearSession(string $schoolYearName): void
|
||||||
|
{
|
||||||
|
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
session()->set('school_year', $schoolYearName);
|
||||||
|
session()->remove('selected_school_year_id');
|
||||||
|
session()->remove('selected_school_year');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use CodeIgniter\HTTP\IncomingRequest;
|
||||||
|
|
||||||
|
final class SchoolYearViewDataService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly SchoolYearContextService $contextService,
|
||||||
|
private readonly SchoolYearAccessPolicy $accessPolicy,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forCurrentRequest(IncomingRequest $request): array
|
||||||
|
{
|
||||||
|
$context = $this->contextService->resolve($request);
|
||||||
|
$userId = (int) (session()->get('user_id') ?? 0);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'schoolYearContext' => $context,
|
||||||
|
'schoolYearOptions' => $this->contextService->selectableYears(
|
||||||
|
$this->accessPolicy->canViewDraft($userId)
|
||||||
|
),
|
||||||
|
'schoolYearReadonly' => $context->isReadonly(),
|
||||||
|
'schoolYearSelectorEnabled' => config('Feature')->globalSchoolYearSelector,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Exceptions\SchoolYear\SchoolYearWriteConflictException;
|
||||||
use App\Support\SchoolYear\SchoolYearContext;
|
use App\Support\SchoolYear\SchoolYearContext;
|
||||||
use RuntimeException;
|
|
||||||
|
|
||||||
final class SchoolYearWriteGuard
|
final class SchoolYearWriteGuard
|
||||||
{
|
{
|
||||||
@@ -12,14 +12,14 @@ final class SchoolYearWriteGuard
|
|||||||
bool $allowDraftForAdmin = false,
|
bool $allowDraftForAdmin = false,
|
||||||
bool $isAdmin = false
|
bool $isAdmin = false
|
||||||
): void {
|
): void {
|
||||||
if ($context->status() === 'active') {
|
if (strtolower($context->status()) === 'active') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($context->status() === 'draft' && $allowDraftForAdmin && $isAdmin) {
|
if (strtolower($context->status()) === 'draft' && $allowDraftForAdmin && $isAdmin) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new RuntimeException('The selected school year is read-only.');
|
throw new SchoolYearWriteConflictException('The selected school year is read-only.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,17 +24,22 @@ final class SchoolYearContext
|
|||||||
|
|
||||||
public function status(): string
|
public function status(): string
|
||||||
{
|
{
|
||||||
return $this->status;
|
return strtolower($this->status);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isActive(): bool
|
public function isActive(): bool
|
||||||
{
|
{
|
||||||
return $this->status === 'active';
|
return $this->status() === 'active';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isReadonly(): bool
|
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
|
public function isExplicitSelection(): bool
|
||||||
@@ -47,7 +52,7 @@ final class SchoolYearContext
|
|||||||
return [
|
return [
|
||||||
'id' => $this->id,
|
'id' => $this->id,
|
||||||
'name' => $this->yearName,
|
'name' => $this->yearName,
|
||||||
'status' => $this->status,
|
'status' => $this->status(),
|
||||||
'readonly' => $this->isReadonly(),
|
'readonly' => $this->isReadonly(),
|
||||||
'explicitSelection' => $this->explicitSelection,
|
'explicitSelection' => $this->explicitSelection,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ final class SchoolYearStatus
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static function isReadonly(string $status): bool
|
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);
|
return in_array($status, [self::CLOSED, self::ARCHIVED], true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,22 +61,6 @@ $decisionBadge = [
|
|||||||
<i class="bi bi-award me-2"></i>Generate Certificates
|
<i class="bi bi-award me-2"></i>Generate Certificates
|
||||||
</h2>
|
</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')): ?>
|
<?php if (session()->getFlashdata('error')): ?>
|
||||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||||
<?= esc(session()->getFlashdata('error')) ?>
|
<?= esc(session()->getFlashdata('error')) ?>
|
||||||
|
|||||||
@@ -30,8 +30,15 @@
|
|||||||
$lowProgressSectionIds = $lowProgressSectionIds ?? [];
|
$lowProgressSectionIds = $lowProgressSectionIds ?? [];
|
||||||
$lowProgressQuery = implode(',', $lowProgressSectionIds);
|
$lowProgressQuery = implode(',', $lowProgressSectionIds);
|
||||||
$lowProgressUrl = base_url('administrator/teacher-submissions');
|
$lowProgressUrl = base_url('administrator/teacher-submissions');
|
||||||
|
$lowProgressParams = [];
|
||||||
|
if (!empty($filters['school_year'])) {
|
||||||
|
$lowProgressParams['school_year'] = $filters['school_year'];
|
||||||
|
}
|
||||||
if ($lowProgressQuery !== '') {
|
if ($lowProgressQuery !== '') {
|
||||||
$lowProgressUrl .= '?low_progress_sections=' . rawurlencode($lowProgressQuery);
|
$lowProgressParams['low_progress_sections'] = $lowProgressQuery;
|
||||||
|
}
|
||||||
|
if (!empty($lowProgressParams)) {
|
||||||
|
$lowProgressUrl .= '?' . http_build_query($lowProgressParams);
|
||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
<a
|
<a
|
||||||
@@ -48,6 +55,16 @@
|
|||||||
<form class="card shadow-sm mb-3" method="get" action="<?= base_url('admin/progress') ?>">
|
<form class="card shadow-sm mb-3" method="get" action="<?= base_url('admin/progress') ?>">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="row g-3">
|
<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">
|
<div class="col-md-3">
|
||||||
<label class="form-label">From</label>
|
<label class="form-label">From</label>
|
||||||
<input type="date" name="from" class="form-control" value="<?= esc($filters['from'] ?? '') ?>">
|
<input type="date" name="from" class="form-control" value="<?= esc($filters['from'] ?? '') ?>">
|
||||||
|
|||||||
@@ -3,7 +3,12 @@
|
|||||||
|
|
||||||
<div class="container mt-4">
|
<div class="container mt-4">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<h3 class="mb-0">Competition Winners</h3>
|
<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>
|
<a class="btn btn-primary" href="/admin/competition-winners/create">+ New Competition</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -19,6 +24,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
<th>Title</th>
|
<th>Title</th>
|
||||||
|
<th>School Year</th>
|
||||||
<th>Class Section</th>
|
<th>Class Section</th>
|
||||||
<th>Winners Rule</th>
|
<th>Winners Rule</th>
|
||||||
<th>Published</th>
|
<th>Published</th>
|
||||||
@@ -36,6 +42,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td><?= esc($c['id']) ?></td>
|
<td><?= esc($c['id']) ?></td>
|
||||||
<td><?= esc($c['title']) ?></td>
|
<td><?= esc($c['title']) ?></td>
|
||||||
|
<td><?= esc($c['school_year'] ?? '') ?></td>
|
||||||
<td><?= esc($sectionName) ?></td>
|
<td><?= esc($sectionName) ?></td>
|
||||||
<td>Tiered per class</td>
|
<td>Tiered per class</td>
|
||||||
<td><?= $c['is_published'] ? 'Yes' : 'No' ?></td>
|
<td><?= $c['is_published'] ? 'Yes' : 'No' ?></td>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
$activeStudents = $active_students ?? [];
|
$activeStudents = $active_students ?? [];
|
||||||
$removedStudents = $removed_students ?? [];
|
$removedStudents = $removed_students ?? [];
|
||||||
$schoolYear = $school_year ?? '';
|
$schoolYear = $school_year ?? '';
|
||||||
|
$activeSchoolYear = $active_school_year ?? '';
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<?= $this->extend('layout/management_layout') ?>
|
<?= $this->extend('layout/management_layout') ?>
|
||||||
@@ -12,8 +13,8 @@ $schoolYear = $school_year ?? '';
|
|||||||
<div class="content">
|
<div class="content">
|
||||||
<h2 class="text-center mt-4 mb-2">Student Removal</h2>
|
<h2 class="text-center mt-4 mb-2">Student Removal</h2>
|
||||||
<p class="text-center text-muted mb-4">
|
<p class="text-center text-muted mb-4">
|
||||||
Removed students keep their data but do not appear in classes or attendance.
|
Active school year: <?= esc($schoolYear !== '' ? $schoolYear : $activeSchoolYear) ?>.
|
||||||
<?= $schoolYear !== '' ? 'Current year: ' . esc($schoolYear) : '' ?>
|
Active students have a class assignment in the active school year. Removed students are inactive or have no active-year assignment.
|
||||||
</p>
|
</p>
|
||||||
<?= $this->include('partials/flash_messages') ?>
|
<?= $this->include('partials/flash_messages') ?>
|
||||||
|
|
||||||
|
|||||||
+137
-153
@@ -1,171 +1,156 @@
|
|||||||
<?= $this->extend('layout/management_layout') ?>
|
<?= $this->extend('layout/management_layout') ?>
|
||||||
<?= $this->section('content') ?>
|
<?= $this->section('content') ?>
|
||||||
<div class="container-fluid">
|
<h2 class="text-center mt-4 mb-3">Class Preparation</h2>
|
||||||
<div class="wrapper">
|
|
||||||
<div class="content"></div>
|
|
||||||
|
|
||||||
<h2 class="text-center mt-4 mb-3">Class Preparation</h2>
|
<?php $semVal = (string)($semester ?? ($_GET['semester'] ?? '')); ?>
|
||||||
|
|
||||||
<!-- School Year Selector + Calculate Button -->
|
<!-- Recalculate using the current school year and semester -->
|
||||||
<form method="get" action="<?= site_url('class-prep') ?>" class="mb-4 d-flex align-items-center gap-2 flex-wrap">
|
<form method="get" action="<?= site_url('class-prep') ?>" class="mb-4">
|
||||||
<label for="school_year" class="form-label mb-0">School Year:</label>
|
<input type="hidden" name="school_year" value="<?= esc($schoolYear) ?>">
|
||||||
<select name="school_year" id="school_year" class="form-select form-select-sm w-auto">
|
<?php if ($semVal !== ''): ?>
|
||||||
<?php for ($y = date('Y'); $y >= 2020; $y--): ?>
|
<input type="hidden" name="semester" value="<?= esc($semVal) ?>">
|
||||||
<?php $sy = $y . '-' . ($y + 1); ?>
|
<?php endif; ?>
|
||||||
<option value="<?= $sy ?>" <?= $schoolYear === $sy ? 'selected' : '' ?>><?= $sy ?></option>
|
<button type="submit" class="btn btn-sm btn-primary">Calculate Items</button>
|
||||||
<?php endfor; ?>
|
</form>
|
||||||
</select>
|
|
||||||
<?php $semVal = (string)($semester ?? ($_GET['semester'] ?? '')); ?>
|
|
||||||
<label for="semester" class="form-label mb-0">Semester:</label>
|
|
||||||
<select name="semester" id="semester" class="form-select form-select-sm w-auto">
|
|
||||||
<option value="">—</option>
|
|
||||||
<option value="Fall" <?= (strcasecmp($semVal,'Fall')===0?'selected':'') ?>>Fall</option>
|
|
||||||
<option value="Spring" <?= (strcasecmp($semVal,'Spring')===0?'selected':'') ?>>Spring</option>
|
|
||||||
</select>
|
|
||||||
<button class="btn btn-sm btn-primary">Calculate Items</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<!-- Class Cards -->
|
<!-- Class Cards -->
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<?php foreach ($prepResults as $prep): ?>
|
<?php foreach ($prepResults as $prep): ?>
|
||||||
<?php $adjMap = $prep['adjustments'] ?? []; ?>
|
<?php $adjMap = $prep['adjustments'] ?? []; ?>
|
||||||
<div class="col-12 col-lg-6">
|
<div class="col-12 col-lg-6">
|
||||||
<div class="card shadow-sm border-0 h-100">
|
<div class="card shadow-sm border-0 h-100">
|
||||||
<div class="card-header d-flex justify-content-between align-items-center bg-light border-0">
|
<div class="card-header d-flex justify-content-between align-items-center bg-light border-0">
|
||||||
<div>
|
<div>
|
||||||
<div class="fw-semibold mb-1">
|
<div class="fw-semibold mb-1">
|
||||||
<?= esc($prep['class_section']) ?>
|
<?= esc($prep['class_section']) ?>
|
||||||
</div>
|
</div>
|
||||||
<?php if (!empty($prep['needs_print'])): ?>
|
<?php if (!empty($prep['needs_print'])): ?>
|
||||||
<span class="badge bg-danger">Updated since last print</span>
|
<span class="badge bg-danger">Updated since last print</span>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<span class="badge bg-success">Up-to-date</span>
|
<span class="badge bg-success">Up-to-date</span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php if (!empty($prep['last_printed_at'])): ?>
|
<?php if (!empty($prep['last_printed_at'])): ?>
|
||||||
<small class="text-muted ms-2">Last printed: <?= esc($prep['last_printed_at']) ?></small>
|
<small class="text-muted ms-2">Last printed: <?= esc($prep['last_printed_at']) ?></small>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
<div class="small text-muted text-end">
|
<div class="small text-muted text-end">
|
||||||
Students:<br>
|
Students:<br>
|
||||||
<span class="badge bg-secondary"><?= (int)$prep['student_count'] ?></span>
|
<span class="badge bg-secondary"><?= (int)$prep['student_count'] ?></span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3">
|
||||||
|
<!-- Prep Items -->
|
||||||
|
<div class="col-12 col-md-7">
|
||||||
|
<div class="fw-semibold mb-2">Prep Items</div>
|
||||||
|
<ul class="list-group list-group-flush small border rounded overflow-hidden">
|
||||||
|
<?php foreach (($prep['prep_items'] ?? []) as $item => $qty): ?>
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center <?= (int)$qty === 0 ? 'text-muted-50' : '' ?>">
|
||||||
|
<span><?= esc($item) ?></span>
|
||||||
|
<span class="badge <?= (int)$qty > 0 ? 'bg-primary' : 'bg-secondary' ?> rounded-pill">
|
||||||
|
<?= (int)$qty ?>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-body">
|
<!-- Adjustments -->
|
||||||
<div class="row g-3">
|
<div class="col-12 col-md-5">
|
||||||
<!-- Prep Items -->
|
<div class="fw-semibold mb-2">Adjustments (Tables)</div>
|
||||||
<div class="col-12 col-md-7">
|
<form method="post" action="<?= site_url('class-prep/save-adjustment') ?>" class="d-grid gap-2">
|
||||||
<div class="fw-semibold mb-2">Prep Items</div>
|
<?= csrf_field() ?>
|
||||||
<ul class="list-group list-group-flush small border rounded overflow-hidden">
|
<input type="hidden" name="class_section_id" value="<?= esc($prep['class_section_id']) ?>">
|
||||||
<?php foreach (($prep['prep_items'] ?? []) as $item => $qty): ?>
|
<input type="hidden" name="school_year" value="<?= esc($schoolYear) ?>">
|
||||||
<li class="list-group-item d-flex justify-content-between align-items-center <?= (int)$qty === 0 ? 'text-muted-50' : '' ?>">
|
|
||||||
<span><?= esc($item) ?></span>
|
|
||||||
<span class="badge <?= (int)$qty > 0 ? 'bg-primary' : 'bg-secondary' ?> rounded-pill">
|
|
||||||
<?= (int)$qty ?>
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Adjustments -->
|
<?php foreach ($adjMap as $item => $value): ?>
|
||||||
<div class="col-12 col-md-5">
|
<div>
|
||||||
<div class="fw-semibold mb-2">Adjustments (Tables)</div>
|
<label class="form-label mb-1 small text-muted d-block"><?= esc($item) ?></label>
|
||||||
<form method="post" action="<?= site_url('class-prep/save-adjustment') ?>" class="d-grid gap-2">
|
<div class="input-group input-group-sm">
|
||||||
<?= csrf_field() ?>
|
<button type="button" class="btn btn-outline-secondary" onclick="adjustQty(this, -1)" aria-label="Decrease <?= esc($item) ?>">−</button>
|
||||||
<input type="hidden" name="class_section_id" value="<?= esc($prep['class_section_id']) ?>">
|
<input type="number"
|
||||||
<input type="hidden" name="school_year" value="<?= esc($schoolYear) ?>">
|
class="form-control text-center"
|
||||||
|
name="adjustments[<?= esc($item) ?>]"
|
||||||
<?php foreach ($adjMap as $item => $value): ?>
|
value="<?= (int)$value ?>"
|
||||||
<div>
|
readonly>
|
||||||
<label class="form-label mb-1 small text-muted d-block"><?= esc($item) ?></label>
|
<button type="button" class="btn btn-outline-secondary" onclick="adjustQty(this, 1)" aria-label="Increase <?= esc($item) ?>">+</button>
|
||||||
<div class="input-group input-group-sm">
|
|
||||||
<button type="button" class="btn btn-outline-secondary" onclick="adjustQty(this, -1)" aria-label="Decrease <?= esc($item) ?>">−</button>
|
|
||||||
<input type="number"
|
|
||||||
class="form-control text-center"
|
|
||||||
name="adjustments[<?= esc($item) ?>]"
|
|
||||||
value="<?= (int)$value ?>"
|
|
||||||
readonly>
|
|
||||||
<button type="button" class="btn btn-outline-secondary" onclick="adjustQty(this, 1)" aria-label="Increase <?= esc($item) ?>">+</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
|
|
||||||
<div class="d-flex justify-content-end">
|
|
||||||
<button type="submit" class="btn btn-sm btn-primary">Save</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-end">
|
||||||
|
<button type="submit" class="btn btn-sm btn-primary">Save</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-footer bg-transparent border-0 d-flex justify-content-end">
|
|
||||||
<?php
|
|
||||||
$printUrl = site_url('class-prep/print/' . urlencode($prep['class_section_id']) . '/' . $schoolYear);
|
|
||||||
if ($semVal !== '') {
|
|
||||||
$printUrl .= '?semester=' . urlencode($semVal);
|
|
||||||
}
|
|
||||||
?>
|
|
||||||
<a href="<?= $printUrl ?>"
|
|
||||||
class="btn btn-lg btn-print-class <?= !empty($prep['needs_print']) ? 'btn-danger' : 'btn-success' ?>" target="_blank" rel="noopener noreferrer"
|
|
||||||
onclick="window.open(this.href, '_blank'); return false;">
|
|
||||||
Print
|
|
||||||
</a>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Totals Section -->
|
<div class="card-footer bg-transparent border-0 d-flex justify-content-end">
|
||||||
<div class="mt-5">
|
<?php
|
||||||
<h5 class="mb-3">📦 Totals for All Used Items</h5>
|
$printUrl = site_url('class-prep/print/' . urlencode($prep['class_section_id']) . '/' . $schoolYear);
|
||||||
<div class="table-responsive">
|
if ($semVal !== '') {
|
||||||
<table class="table table-sm table-bordered align-middle no-mgmt-sticky">
|
$printUrl .= '?semester=' . urlencode($semVal);
|
||||||
<thead class="table-light">
|
}
|
||||||
<tr>
|
?>
|
||||||
<th>Item</th>
|
<a href="<?= $printUrl ?>"
|
||||||
<th class="text-end">Total Needed</th>
|
class="btn btn-lg btn-print-class <?= !empty($prep['needs_print']) ? 'btn-danger' : 'btn-success' ?>" target="_blank" rel="noopener noreferrer"
|
||||||
<th class="text-end">In Inventory</th>
|
onclick="window.open(this.href, '_blank'); return false;">
|
||||||
<th class="text-end">Short By</th>
|
Print
|
||||||
</tr>
|
</a>
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<?php foreach ($totalNeeded as $item => $needed): ?>
|
|
||||||
<?php
|
|
||||||
$have = (int)($available[$item] ?? 0);
|
|
||||||
$short = max(0, (int)$needed - $have);
|
|
||||||
$rowCls = $short > 0 ? 'table-warning' : '';
|
|
||||||
?>
|
|
||||||
<tr class="<?= $rowCls ?>">
|
|
||||||
<td><?= esc($item) ?></td>
|
|
||||||
<td class="text-end"><?= (int)$needed ?></td>
|
|
||||||
<td class="text-end"><?= $have ?></td>
|
|
||||||
<td class="text-end <?= $short > 0 ? 'text-danger fw-bold' : 'text-success' ?>">
|
|
||||||
<?= $short ?>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Shortage Warning -->
|
|
||||||
<?php if (!empty($shortages)): ?>
|
|
||||||
<div class="alert alert-danger mt-3">
|
|
||||||
<strong>Shortages Detected</strong>
|
|
||||||
<ul class="mb-0">
|
|
||||||
<?php foreach ($shortages as $item => $short): ?>
|
|
||||||
<li><?= esc($item) ?>: short by <?= (int)$short ?> pcs</li>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
<?php else: ?>
|
</div>
|
||||||
<div class="alert alert-success mt-3">✅ All items are available in stock.</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Totals Section -->
|
||||||
|
<div class="mt-5">
|
||||||
|
<h5 class="mb-3">📦 Totals for All Used Items</h5>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-bordered align-middle no-mgmt-sticky">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Item</th>
|
||||||
|
<th class="text-end">Total Needed</th>
|
||||||
|
<th class="text-end">In Inventory</th>
|
||||||
|
<th class="text-end">Short By</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($totalNeeded as $item => $needed): ?>
|
||||||
|
<?php
|
||||||
|
$have = (int)($available[$item] ?? 0);
|
||||||
|
$short = max(0, (int)$needed - $have);
|
||||||
|
$rowCls = $short > 0 ? 'table-warning' : '';
|
||||||
|
?>
|
||||||
|
<tr class="<?= $rowCls ?>">
|
||||||
|
<td><?= esc($item) ?></td>
|
||||||
|
<td class="text-end"><?= (int)$needed ?></td>
|
||||||
|
<td class="text-end"><?= $have ?></td>
|
||||||
|
<td class="text-end <?= $short > 0 ? 'text-danger fw-bold' : 'text-success' ?>">
|
||||||
|
<?= $short ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Shortage Warning -->
|
||||||
|
<?php if (!empty($shortages)): ?>
|
||||||
|
<div class="alert alert-danger mt-3">
|
||||||
|
<strong>Shortages Detected</strong>
|
||||||
|
<ul class="mb-0">
|
||||||
|
<?php foreach ($shortages as $item => $short): ?>
|
||||||
|
<li><?= esc($item) ?>: short by <?= (int)$short ?> pcs</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="alert alert-success mt-3">✅ All items are available in stock.</div>
|
||||||
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
<?= $this->endSection() ?>
|
<?= $this->endSection() ?>
|
||||||
|
|
||||||
@@ -204,10 +189,9 @@
|
|||||||
function adjustQty(btn, delta) {
|
function adjustQty(btn, delta) {
|
||||||
const input = btn.parentElement.querySelector('input[type="number"]');
|
const input = btn.parentElement.querySelector('input[type="number"]');
|
||||||
const curr = parseInt(input.value, 10) || 0;
|
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.addEventListener('DOMContentLoaded', function() {
|
||||||
document.querySelectorAll('.btn-print-class').forEach(function(el) {
|
document.querySelectorAll('.btn-print-class').forEach(function(el) {
|
||||||
el.addEventListener('click', function(ev) {
|
el.addEventListener('click', function(ev) {
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?= $this->extend('layout/main') ?>
|
||||||
|
|
||||||
|
<?= $this->section('content') ?>
|
||||||
|
<div class="alert alert-danger" role="alert">
|
||||||
|
<h1 class="h5 mb-2">School-year context unavailable</h1>
|
||||||
|
<p class="mb-0"><?= esc($message ?? 'The selected school year is not available.') ?></p>
|
||||||
|
</div>
|
||||||
|
<?= $this->endSection() ?>
|
||||||
@@ -6,16 +6,6 @@
|
|||||||
<div class="content"></div>
|
<div class="content"></div>
|
||||||
<h2 class="text-center mt-4 mb-3">Invoice Management</h2>
|
<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 for displaying invoices (client-hydrated) -->
|
||||||
<table id="invoicesTable" class="table table-striped table-hover table-bordered w-100">
|
<table id="invoicesTable" class="table table-striped table-hover table-bordered w-100">
|
||||||
<thead class="thead-dark">
|
<thead class="thead-dark">
|
||||||
@@ -155,16 +145,20 @@
|
|||||||
const $tbl = $('#invoicesTable');
|
const $tbl = $('#invoicesTable');
|
||||||
let dt = null;
|
let dt = null;
|
||||||
const $year = document.getElementById('schoolYearSelect');
|
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
|
// Global fallback handler for inline onclick
|
||||||
window.__genInvoice = async function(btn){
|
window.__genInvoice = async function(btn){
|
||||||
try {
|
try {
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
await generateInvoice(btn.getAttribute('data-parent-id'));
|
await generateInvoice(btn.getAttribute('data-parent-id'));
|
||||||
const resp = await loadInvoices();
|
const resp = await loadInvoices(selectedSchoolYear());
|
||||||
// Populate year select
|
syncSchoolYearSelect(resp);
|
||||||
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 data = (resp.invoices || []).map(r => {
|
const data = (resp.invoices || []).map(r => {
|
||||||
const ts = Date.parse(r.invoice_date || new Date().toISOString());
|
const ts = Date.parse(r.invoice_date || new Date().toISOString());
|
||||||
@@ -194,10 +188,7 @@
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const resp = await loadInvoices();
|
const resp = await loadInvoices();
|
||||||
// Populate year select on first load
|
syncSchoolYearSelect(resp);
|
||||||
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 data = (resp.invoices || []).map(r => {
|
const data = (resp.invoices || []).map(r => {
|
||||||
const ts = Date.parse(r.invoice_date || new Date().toISOString());
|
const ts = Date.parse(r.invoice_date || new Date().toISOString());
|
||||||
@@ -240,7 +231,7 @@
|
|||||||
try {
|
try {
|
||||||
await generateInvoice(btn.getAttribute('data-parent-id'));
|
await generateInvoice(btn.getAttribute('data-parent-id'));
|
||||||
// Refresh table minimally: reload data
|
// Refresh table minimally: reload data
|
||||||
const resp = await loadInvoices($year.value);
|
const resp = await loadInvoices(selectedSchoolYear());
|
||||||
const data = (resp.invoices || []).map(r => {
|
const data = (resp.invoices || []).map(r => {
|
||||||
const ts = Date.parse(r.invoice_date || new Date().toISOString());
|
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>`;
|
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 {
|
try {
|
||||||
this.disabled = true;
|
this.disabled = true;
|
||||||
await generateInvoice(this.getAttribute('data-parent-id'));
|
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 data = (resp.invoices || []).map(r => {
|
||||||
const ts = Date.parse(r.invoice_date || new Date().toISOString());
|
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>`;
|
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,34 +303,36 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Year change
|
// Year change
|
||||||
$year.addEventListener('change', async () => {
|
if ($year) {
|
||||||
try {
|
$year.addEventListener('change', async () => {
|
||||||
const resp = await loadInvoices($year.value);
|
try {
|
||||||
const data = (resp.invoices || []).map(r => {
|
const resp = await loadInvoices($year.value);
|
||||||
const ts = Date.parse(r.invoice_date || new Date().toISOString());
|
const data = (resp.invoices || []).map(r => {
|
||||||
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>`;
|
const ts = Date.parse(r.invoice_date || new Date().toISOString());
|
||||||
const pdf = r.invoice_id ? `<a href=\"<?= base_url('invoice/pdf') ?>/${esc(r.invoice_id)}\" target=\"_blank\" class=\"btn btn-info btn-sm external-link\">View/Print PDF</a>` : '<span class=\"text-muted\">No invoice yet</span>';
|
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>`;
|
||||||
return [
|
const pdf = r.invoice_id ? `<a href=\"<?= base_url('invoice/pdf') ?>/${esc(r.invoice_id)}\" target=\"_blank\" class=\"btn btn-info btn-sm external-link\">View/Print PDF</a>` : '<span class=\"text-muted\">No invoice yet</span>';
|
||||||
esc(r.parent_name || ''),
|
return [
|
||||||
renderStudents(r.enrolledKids || []),
|
esc(r.parent_name || ''),
|
||||||
genBtn,
|
renderStudents(r.enrolledKids || []),
|
||||||
fmtMoney(r.invoice_amount),
|
genBtn,
|
||||||
fmtMoney(r.refund_amount),
|
fmtMoney(r.invoice_amount),
|
||||||
`<span data-order=\"${ts}\">${formatDateTime(ts)}</span>`,
|
fmtMoney(r.refund_amount),
|
||||||
pdf,
|
`<span data-order=\"${ts}\">${formatDateTime(ts)}</span>`,
|
||||||
];
|
pdf,
|
||||||
});
|
];
|
||||||
if ($.fn.DataTable.isDataTable($tbl)) {
|
});
|
||||||
const dti = $tbl.DataTable();
|
if ($.fn.DataTable.isDataTable($tbl)) {
|
||||||
dti.clear();
|
const dti = $tbl.DataTable();
|
||||||
dti.rows.add(data).draw(false);
|
dti.clear();
|
||||||
|
dti.rows.add(data).draw(false);
|
||||||
|
}
|
||||||
|
showToast('Loaded ' + $year.value);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
showToast('Failed to load ' + $year.value, false);
|
||||||
}
|
}
|
||||||
showToast('Loaded ' + $year.value);
|
});
|
||||||
} catch (e) {
|
}
|
||||||
console.error(e);
|
|
||||||
showToast('Failed to load ' + $year.value, false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<?= $this->endSection() ?>
|
<?= $this->endSection() ?>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<!-- Navbar -->
|
<!-- Navbar -->
|
||||||
<?php include(__DIR__ . '/../partials/navbar.php'); ?>
|
<?php include(__DIR__ . '/../partials/navbar.php'); ?>
|
||||||
|
<?= view('partials/school_year_selector') ?>
|
||||||
|
|
||||||
<div class="container my-4">
|
<div class="container my-4">
|
||||||
<?= $this->renderSection('content') ?>
|
<?= $this->renderSection('content') ?>
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ html, body { overflow-x: hidden; }
|
|||||||
|
|
||||||
<body data-app-menu-mode="<?= esc($appMenuMode) ?>">
|
<body data-app-menu-mode="<?= esc($appMenuMode) ?>">
|
||||||
<?php include(__DIR__ . '/../partials/navbar.php'); ?>
|
<?php include(__DIR__ . '/../partials/navbar.php'); ?>
|
||||||
|
<?= view('partials/school_year_selector') ?>
|
||||||
|
|
||||||
<?php
|
<?php
|
||||||
$uri = service('uri');
|
$uri = service('uri');
|
||||||
@@ -188,7 +189,11 @@ html, body { overflow-x: hidden; }
|
|||||||
if ($isTeacher) {
|
if ($isTeacher) {
|
||||||
$cfg = new \App\Models\ConfigurationModel();
|
$cfg = new \App\Models\ConfigurationModel();
|
||||||
$tcModel = new \App\Models\TeacherClassModel();
|
$tcModel = new \App\Models\TeacherClassModel();
|
||||||
$sy = (string)($cfg->getConfig('school_year') ?? '');
|
try {
|
||||||
|
$sy = service('schoolYearContext')->resolve(service('request'))->yearName();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$sy = (string)($cfg->getConfig('school_year') ?? '');
|
||||||
|
}
|
||||||
$sem = (string)($cfg->getConfig('semester') ?? '');
|
$sem = (string)($cfg->getConfig('semester') ?? '');
|
||||||
$uid = (int)(session()->get('user_id') ?? 0);
|
$uid = (int)(session()->get('user_id') ?? 0);
|
||||||
if ($uid) {
|
if ($uid) {
|
||||||
|
|||||||
@@ -287,6 +287,20 @@
|
|||||||
<!-- Header & Navbar -->
|
<!-- Header & Navbar -->
|
||||||
<?php include(__DIR__ . '/../partials/header_back.php'); ?>
|
<?php include(__DIR__ . '/../partials/header_back.php'); ?>
|
||||||
<?php include(__DIR__ . '/../partials/navbar_dynamic.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 -->
|
<!-- Main Wrapper -->
|
||||||
<div class="wrapper">
|
<div class="wrapper">
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ if ($currentYear === '' || $currentSem === '') {
|
|||||||
} catch (\Throwable $e) { /* ignore */ }
|
} catch (\Throwable $e) { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$showSchoolYearControl = $showSchoolYearControl ?? true;
|
||||||
|
|
||||||
$selectedYear = (string)($schoolYear ?? ($_GET['school_year'] ?? ''));
|
$selectedYear = (string)($schoolYear ?? ($_GET['school_year'] ?? ''));
|
||||||
if ($selectedYear === '') { $selectedYear = $currentYear; }
|
if ($selectedYear === '') { $selectedYear = $currentYear; }
|
||||||
|
|
||||||
@@ -32,23 +34,25 @@ $classSectionVal = (string)($classSectionId ?? ($_GET['class_section_id'] ?? '')
|
|||||||
<?php if ($classSectionVal !== ''): ?>
|
<?php if ($classSectionVal !== ''): ?>
|
||||||
<input type="hidden" name="class_section_id" value="<?= esc($classSectionVal) ?>">
|
<input type="hidden" name="class_section_id" value="<?= esc($classSectionVal) ?>">
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<div class="col-auto"><label class="form-label mb-0">School year</label></div>
|
<?php if ($showSchoolYearControl): ?>
|
||||||
<div class="col-auto">
|
<div class="col-auto"><label class="form-label mb-0">School year</label></div>
|
||||||
<?php $years = isset($schoolYears) && is_array($schoolYears) ? $schoolYears : []; ?>
|
<div class="col-auto">
|
||||||
<select name="school_year" class="form-select form-select-sm" style="min-width: 180px;">
|
<?php $years = isset($schoolYears) && is_array($schoolYears) ? $schoolYears : []; ?>
|
||||||
<?php if (!empty($years)): ?>
|
<select name="school_year" class="form-select form-select-sm" style="min-width: 180px;">
|
||||||
<?php foreach ($years as $y): $val = is_array($y) && isset($y['school_year']) ? (string)$y['school_year'] : (string)$y; ?>
|
<?php if (!empty($years)): ?>
|
||||||
<option value="<?= esc($val) ?>" <?= ($selectedYear === $val ? 'selected' : '') ?>><?= esc($val) ?></option>
|
<?php foreach ($years as $y): $val = is_array($y) && isset($y['school_year']) ? (string)$y['school_year'] : (string)$y; ?>
|
||||||
<?php endforeach; ?>
|
<option value="<?= esc($val) ?>" <?= ($selectedYear === $val ? 'selected' : '') ?>><?= esc($val) ?></option>
|
||||||
<?php else: ?>
|
<?php endforeach; ?>
|
||||||
<?php if ($selectedYear !== ''): ?>
|
|
||||||
<option value="<?= esc($selectedYear) ?>" selected><?= esc($selectedYear) ?></option>
|
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<option value="">—</option>
|
<?php if ($selectedYear !== ''): ?>
|
||||||
|
<option value="<?= esc($selectedYear) ?>" selected><?= esc($selectedYear) ?></option>
|
||||||
|
<?php else: ?>
|
||||||
|
<option value="">-</option>
|
||||||
|
<?php endif; ?>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php endif; ?>
|
</select>
|
||||||
</select>
|
</div>
|
||||||
</div>
|
<?php endif; ?>
|
||||||
|
|
||||||
<div class="col-auto"><label class="form-label mb-0">Semester</label></div>
|
<div class="col-auto"><label class="form-label mb-0">Semester</label></div>
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
|
|||||||
@@ -60,7 +60,11 @@ switch ($role) {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$configModel = new \App\Models\ConfigurationModel();
|
$configModel = new \App\Models\ConfigurationModel();
|
||||||
$schoolYear = session()->get('school_year') ?? $configModel->getConfig('school_year');
|
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);
|
$classSectionId = (int)($class_section_id ?? session()->get('class_section_id') ?? 0);
|
||||||
if ($classSectionId > 0 && !empty($schoolYear)) {
|
if ($classSectionId > 0 && !empty($schoolYear)) {
|
||||||
$scoreCardStudents = $studentModel->getByClassAndYear($classSectionId, $schoolYear);
|
$scoreCardStudents = $studentModel->getByClassAndYear($classSectionId, $schoolYear);
|
||||||
@@ -267,7 +271,11 @@ switch ($role) {
|
|||||||
if (!isset($activeEventCount) && ($role ?? '') === 'parent') {
|
if (!isset($activeEventCount) && ($role ?? '') === 'parent') {
|
||||||
$configModel = new \App\Models\ConfigurationModel();
|
$configModel = new \App\Models\ConfigurationModel();
|
||||||
$eventModel = new \App\Models\EventModel();
|
$eventModel = new \App\Models\EventModel();
|
||||||
$year = $sess->get('school_year') ?? $configModel->getConfig('school_year');
|
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');
|
$semester = $sess->get('semester') ?? $configModel->getConfig('semester');
|
||||||
$activeEventCount = count($eventModel->getActiveEvents($year, $semester) ?? []);
|
$activeEventCount = count($eventModel->getActiveEvents($year, $semester) ?? []);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?php if (!empty($schoolYearSelectorEnabled) && isset($schoolYearContext, $schoolYearOptions)): ?>
|
||||||
|
<?php
|
||||||
|
$query = service('request')->getUri()->getQuery();
|
||||||
|
$returnTo = current_url() . ($query !== '' ? '?' . $query : '');
|
||||||
|
?>
|
||||||
|
<div class="school-year-context-bar border-bottom bg-light py-2">
|
||||||
|
<div class="container-fluid px-3">
|
||||||
|
<form method="post" action="<?= site_url('school-year/select') ?>" class="d-flex align-items-center gap-2 flex-wrap">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="return_to" value="<?= esc($returnTo) ?>">
|
||||||
|
|
||||||
|
<label for="global-school-year" class="form-label mb-0 fw-semibold">
|
||||||
|
School year
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<select
|
||||||
|
id="global-school-year"
|
||||||
|
name="school_year_id"
|
||||||
|
class="form-select form-select-sm w-auto"
|
||||||
|
onchange="this.form.submit()"
|
||||||
|
aria-label="Selected school year"
|
||||||
|
>
|
||||||
|
<?php foreach ($schoolYearOptions as $year): ?>
|
||||||
|
<option
|
||||||
|
value="<?= (int) $year['id'] ?>"
|
||||||
|
<?= (int) $year['id'] === $schoolYearContext->id() ? 'selected' : '' ?>
|
||||||
|
>
|
||||||
|
<?= esc($year['name']) ?> - <?= esc(ucfirst((string) $year['status'])) ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<?php if ($schoolYearContext->isReadonly()): ?>
|
||||||
|
<span class="btn btn-warning btn-sm disabled" aria-disabled="true">
|
||||||
|
<i class="bi bi-lock-fill"></i> READ-ONLY
|
||||||
|
</span>
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="badge text-bg-success">ACTIVE</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<span class="text-muted small"><?= esc($schoolYearContext->yearName()) ?></span>
|
||||||
|
</form>
|
||||||
|
<?php if ($schoolYearContext->isExplicitSelection()): ?>
|
||||||
|
<form method="post" action="<?= site_url('school-year/reset') ?>" class="d-inline-block mt-2 mt-sm-0 ms-sm-2">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="return_to" value="<?= esc($returnTo) ?>">
|
||||||
|
<button type="submit" class="btn btn-outline-secondary btn-sm">
|
||||||
|
Active year
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
@@ -1,3 +1,35 @@
|
|||||||
|
|
||||||
|
Yesterday 11:12 PM
|
||||||
|
write sql querry to update school_year = 2025-2026
|
||||||
|
|
||||||
|
UPDATE your_table_name
|
||||||
|
SET school_year = '2025-2026'
|
||||||
|
WHERE your_condition;
|
||||||
|
To update every row:
|
||||||
|
|
||||||
|
UPDATE your_table_name
|
||||||
|
SET school_year = '2025-2026';
|
||||||
|
Use the second version only if you genuinely intend to rewrite the entire table, because databases are remarkably obedient when humans forget WHERE.
|
||||||
|
|
||||||
|
|
||||||
|
Today 12:49 AM
|
||||||
|
Screenshot 2026-07-14 at 12.49.07 AM.png
|
||||||
|
|
||||||
|
Pasted code(6).php
|
||||||
|
File
|
||||||
|
remove the image content from the view
|
||||||
|
|
||||||
|
Removed the School year / Semester filter from the view by deleting:
|
||||||
|
|
||||||
|
<?= $this->include('partials/academic_filter') ?>
|
||||||
|
Pasted_code_6_without_academic_filter.php
|
||||||
|
|
||||||
|
|
||||||
|
Library
|
||||||
|
/
|
||||||
|
Pasted_code_6_without_academic_filter.php
|
||||||
|
|
||||||
|
|
||||||
<?= $this->extend('layout/management_layout') ?>
|
<?= $this->extend('layout/management_layout') ?>
|
||||||
<?= $this->section('content') ?>
|
<?= $this->section('content') ?>
|
||||||
<?php
|
<?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>
|
<h2 class="text-dark mb-3" style="font-family: Arial, sans-serif;">Generate Staff Badges</h2>
|
||||||
</div>
|
</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">
|
<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 -->
|
<!-- Submit button associates to form below -->
|
||||||
<button type="submit" class="btn btn-success mt-3" form="badgeForm">Generate Badges</button>
|
<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 -->
|
<!-- Staff selection -->
|
||||||
<form method="post" action="<?= base_url('badge') ?>" target="_blank" id="badgeForm">
|
<form method="post" action="<?= base_url('badge') ?>" target="_blank" id="badgeForm">
|
||||||
<?= csrf_field() ?>
|
<?= 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">
|
<table id="staffTable" class="table table-bordered table-striped align-middle w-100">
|
||||||
<thead class="table-dark">
|
<thead class="table-dark">
|
||||||
@@ -278,11 +305,356 @@ $formatRole = function (?string $role): string {
|
|||||||
async function refreshCsrf() {
|
async function refreshCsrf() {
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams();
|
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()) : ''), {
|
if (!resp.ok) return;
|
||||||
method: 'GET',
|
const json = await resp.json();
|
||||||
headers: { 'Accept': 'application/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;
|
if (!resp.ok) return;
|
||||||
const json = await resp.json();
|
const json = await resp.json();
|
||||||
if (json && json.csrf_token && json.csrf_hash && csrfInput && json.csrf_token === csrfName) {
|
if (json && json.csrf_token && json.csrf_hash && csrfInput && json.csrf_token === csrfName) {
|
||||||
|
|||||||
@@ -6,12 +6,48 @@
|
|||||||
$target = $preview['target'] ?? null;
|
$target = $preview['target'] ?? null;
|
||||||
$overview = $preview['overview'] ?? [];
|
$overview = $preview['overview'] ?? [];
|
||||||
$finance = $preview['finance'] ?? [];
|
$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'] ?? [];
|
$blockers = $preview['blockers'] ?? [];
|
||||||
$warnings = $preview['warnings'] ?? [];
|
$warnings = $preview['warnings'] ?? [];
|
||||||
$carryForward = $preview['carry_forward'] ?? [];
|
$carryForward = $preview['carry_forward'] ?? [];
|
||||||
$status = (string) ($source['status'] ?? '');
|
$status = (string) ($source['status'] ?? '');
|
||||||
$batchStatus = (string) ($latestBatch['status'] ?? '');
|
$batchStatus = (string) ($latestBatch['status'] ?? '');
|
||||||
$money = static fn ($value): string => '$' . number_format((float) $value, 2);
|
$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')): ?>
|
<?php if (session()->getFlashdata('success')): ?>
|
||||||
@@ -23,21 +59,30 @@
|
|||||||
<div class="container-fluid py-3">
|
<div class="container-fluid py-3">
|
||||||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
|
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
|
||||||
<div>
|
<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">
|
<div class="text-muted">
|
||||||
Status: <span class="fw-semibold"><?= esc(ucfirst($status)) ?></span>
|
Status: <span class="fw-semibold"><?= esc(ucfirst($status)) ?></span>
|
||||||
<?php if ($target): ?>
|
<?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; ?>
|
<?php endif; ?>
|
||||||
<span>Generated: <?= esc($preview['generated_at'] ?? '') ?></span>
|
<span>Generated: <?= esc($preview['generated_at'] ?? '') ?></span>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</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') ?>">
|
<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">
|
<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">
|
<select class="form-select" id="target_school_year_id" name="target_school_year_id">
|
||||||
<option value="">Auto-select next draft year</option>
|
<option value="">Auto-select next draft year</option>
|
||||||
<?php foreach (($schoolYears ?? []) as $year): ?>
|
<?php foreach (($schoolYears ?? []) as $year): ?>
|
||||||
@@ -50,7 +95,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -130,11 +175,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-lg-7">
|
<div class="col-lg-7">
|
||||||
<div class="border rounded bg-white p-3 h-100">
|
<div class="border rounded bg-white p-3 h-100">
|
||||||
<h5>Closing Progress</h5>
|
<h5>End-Year Progress</h5>
|
||||||
<ol class="mb-0">
|
<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="<?= $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="<?= in_array($batchStatus, ['executed', 'completed'], true) ? '' : 'text-muted' ?>">Carry-forward completed</li>
|
||||||
<li class="<?= $status === 'closed' ? '' : 'text-muted' ?>">Year closed</li>
|
<li class="<?= $status === 'closed' ? '' : 'text-muted' ?>">Year closed</li>
|
||||||
</ol>
|
</ol>
|
||||||
@@ -142,13 +187,186 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="border rounded bg-white p-3 mb-4">
|
||||||
<h5>Carry-Forward Families</h5>
|
<h5>Carry-Forward Families</h5>
|
||||||
<div class="table-responsive">
|
<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>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Family</th>
|
<th>Family</th>
|
||||||
|
<th>Parent</th>
|
||||||
<th class="text-end">Source Balance</th>
|
<th class="text-end">Source Balance</th>
|
||||||
<th class="text-end">Credits</th>
|
<th class="text-end">Credits</th>
|
||||||
<th class="text-end">Adjustments</th>
|
<th class="text-end">Adjustments</th>
|
||||||
@@ -157,11 +375,17 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<?php if ($carryForward === []): ?>
|
<?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 endif; ?>
|
||||||
<?php foreach ($carryForward as $row): ?>
|
<?php foreach ($carryForward as $row): ?>
|
||||||
<tr>
|
<tr>
|
||||||
<td><?= esc($row['family']) ?></td>
|
<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['source_balance'])) ?></td>
|
||||||
<td class="text-end"><?= esc($money($row['credit_amount'])) ?></td>
|
<td class="text-end"><?= esc($money($row['credit_amount'])) ?></td>
|
||||||
<td class="text-end"><?= esc($money($row['adjustment_amount'])) ?></td>
|
<td class="text-end"><?= esc($money($row['adjustment_amount'])) ?></td>
|
||||||
@@ -178,28 +402,37 @@
|
|||||||
<form action="<?= site_url('administrator/school-years/' . (int) $source['id'] . '/closing/start') ?>" method="post">
|
<form action="<?= site_url('administrator/school-years/' . (int) $source['id'] . '/closing/start') ?>" method="post">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<input type="hidden" name="target_school_year_id" value="<?= (int) $target['id'] ?>">
|
<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>
|
</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 endif; ?>
|
||||||
|
|
||||||
<?php if ($status === 'closing' && $batchStatus === 'started'): ?>
|
<?php if ($status === 'closing' && $batchStatus === 'started'): ?>
|
||||||
<form action="<?= site_url('administrator/school-years/' . (int) $source['id'] . '/closing/execute') ?>" method="post">
|
<form action="<?= site_url('administrator/school-years/' . (int) $source['id'] . '/closing/execute') ?>" method="post">
|
||||||
<?= csrf_field() ?>
|
<?= 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>
|
</form>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php if ($status === 'closing' && $batchStatus === 'executed'): ?>
|
<?php if ($status === 'closing' && $batchStatus === 'executed'): ?>
|
||||||
<form action="<?= site_url('administrator/school-years/' . (int) $source['id'] . '/closing/complete') ?>" method="post">
|
<form action="<?= site_url('administrator/school-years/' . (int) $source['id'] . '/closing/complete') ?>" method="post">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<button class="btn btn-success" type="submit">Complete Closing</button>
|
<button class="btn btn-success" type="submit">Close Year</button>
|
||||||
</form>
|
</form>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php if ($status === 'closing' && ! in_array($batchStatus, ['executed', 'completed'], true)): ?>
|
<?php if ($status === 'closing' && ! in_array($batchStatus, ['executed', 'completed'], true)): ?>
|
||||||
<form action="<?= site_url('administrator/school-years/' . (int) $source['id'] . '/closing/cancel') ?>" method="post">
|
<form action="<?= site_url('administrator/school-years/' . (int) $source['id'] . '/closing/cancel') ?>" method="post">
|
||||||
<?= csrf_field() ?>
|
<?= 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>
|
</form>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,12 @@
|
|||||||
'archived' => 'bg-dark',
|
'archived' => 'bg-dark',
|
||||||
];
|
];
|
||||||
$formatDate = static fn ($value): string => $value ? esc((string) $value) : 'Not set';
|
$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')): ?>
|
<?php if (session()->getFlashdata('success')): ?>
|
||||||
@@ -19,46 +25,53 @@
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<div class="container-fluid py-3">
|
<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>
|
<div>
|
||||||
<h2 class="mb-1">School Year Management</h2>
|
<h2 class="mb-1">School Years</h2>
|
||||||
<div class="text-muted">
|
<div class="text-muted">End the current school year after its checklist is clear, then start the next year.</div>
|
||||||
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; ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-success" type="button" data-bs-toggle="collapse" data-bs-target="#schoolYearCreateForm">
|
|
||||||
Add School Year
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row g-3 mb-4">
|
<div class="border rounded bg-white p-3 mb-4">
|
||||||
<div class="col-md-3">
|
<div class="d-flex flex-wrap justify-content-between gap-3">
|
||||||
<div class="border rounded bg-white p-3 h-100">
|
<div>
|
||||||
<div class="text-muted small">Active Year</div>
|
<h5 class="mb-2">End Current School Year</h5>
|
||||||
<div class="fs-5 fw-semibold"><?= esc($activeYear['name'] ?? 'None') ?></div>
|
<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>
|
||||||
</div>
|
<div class="d-flex align-items-start">
|
||||||
<div class="col-md-3">
|
<?php if ($closingYear): ?>
|
||||||
<div class="border rounded bg-white p-3 h-100">
|
<a class="btn btn-primary" href="<?= site_url('administrator/school-years/' . (int) $closingYear['id'] . '/closing/preview') ?>">Finish End-Year Checklist</a>
|
||||||
<div class="text-muted small">Next Draft</div>
|
<?php elseif ($activeYear && $nextDraftYear): ?>
|
||||||
<div class="fs-5 fw-semibold"><?= esc($nextDraftYear['name'] ?? 'None') ?></div>
|
<a class="btn btn-primary" href="<?= esc($closingPreviewUrl, 'attr') ?>">Close Year</a>
|
||||||
</div>
|
<?php elseif ($activeYear): ?>
|
||||||
</div>
|
<button class="btn btn-success" type="button" data-bs-toggle="collapse" data-bs-target="#schoolYearCreateForm">Create Next Year</button>
|
||||||
<div class="col-md-3">
|
<?php elseif ($nextDraftYear): ?>
|
||||||
<div class="border rounded bg-white p-3 h-100">
|
<button class="btn btn-success" type="button" data-bs-toggle="modal" data-bs-target="#activateSchoolYearModal<?= $nextDraftId ?>">Start <?= esc($nextDraftYear['name']) ?></button>
|
||||||
<div class="text-muted small">Currently Closing</div>
|
<?php else: ?>
|
||||||
<div class="fs-5 fw-semibold"><?= esc($closingYear['name'] ?? 'None') ?></div>
|
<button class="btn btn-success" type="button" data-bs-toggle="collapse" data-bs-target="#schoolYearCreateForm">Create School Year</button>
|
||||||
</div>
|
<?php endif; ?>
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -71,6 +84,15 @@
|
|||||||
<label class="form-label" for="new_school_year_name">School Year</label>
|
<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}">
|
<input class="form-control" id="new_school_year_name" name="name" placeholder="2026-2027" required pattern="\d{4}-\d{4}">
|
||||||
</div>
|
</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">
|
<div class="col-md-2">
|
||||||
<label class="form-label" for="new_school_year_starts_on">Starts On</label>
|
<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">
|
<input class="form-control" id="new_school_year_starts_on" name="starts_on" type="date">
|
||||||
@@ -106,10 +128,8 @@
|
|||||||
<th>School Year</th>
|
<th>School Year</th>
|
||||||
<th>Dates</th>
|
<th>Dates</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Students</th>
|
<th>Verify</th>
|
||||||
<th>Families</th>
|
<th>Last Activity</th>
|
||||||
<th>Financial Balance</th>
|
|
||||||
<th>Last Transition</th>
|
|
||||||
<th>Updated</th>
|
<th>Updated</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -121,6 +141,8 @@
|
|||||||
$status = (string) ($year['status'] ?? 'draft');
|
$status = (string) ($year['status'] ?? 'draft');
|
||||||
$readonly = in_array($status, ['closed', 'archived'], true);
|
$readonly = in_array($status, ['closed', 'archived'], true);
|
||||||
$transition = $latestTransitions[$id] ?? null;
|
$transition = $latestTransitions[$id] ?? null;
|
||||||
|
$verify = $yearVerification[$id] ?? [];
|
||||||
|
$previousYearName = $schoolYearNamesById[(int) ($year['previous_school_year_id'] ?? 0)] ?? null;
|
||||||
?>
|
?>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="fw-semibold"><?= esc($year['name'] ?? '') ?></td>
|
<td class="fw-semibold"><?= esc($year['name'] ?? '') ?></td>
|
||||||
@@ -133,9 +155,13 @@
|
|||||||
<?= esc(ucfirst($status)) ?>
|
<?= esc(ucfirst($status)) ?>
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-muted">-</td>
|
<td class="small">
|
||||||
<td class="text-muted">-</td>
|
<div>Previous: <span class="fw-semibold"><?= esc($previousYearName ?? 'Not linked') ?></span></div>
|
||||||
<td class="text-muted">Preview</td>
|
<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>
|
<td>
|
||||||
<?php if ($transition): ?>
|
<?php if ($transition): ?>
|
||||||
<div><?= esc($transition['action'] ?? '') ?></div>
|
<div><?= esc($transition['action'] ?? '') ?></div>
|
||||||
@@ -159,11 +185,13 @@
|
|||||||
</li>
|
</li>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php if ($status === 'draft'): ?>
|
<?php if ($status === 'draft'): ?>
|
||||||
<li>
|
<?php if (empty($activeYear) && empty($closingYear)): ?>
|
||||||
<button class="dropdown-item" type="button" data-bs-toggle="modal" data-bs-target="#activateSchoolYearModal<?= $id ?>">
|
<li>
|
||||||
Activate
|
<button class="dropdown-item" type="button" data-bs-toggle="modal" data-bs-target="#activateSchoolYearModal<?= $id ?>">
|
||||||
</button>
|
Start this year
|
||||||
</li>
|
</button>
|
||||||
|
</li>
|
||||||
|
<?php endif; ?>
|
||||||
<li><hr class="dropdown-divider"></li>
|
<li><hr class="dropdown-divider"></li>
|
||||||
<li>
|
<li>
|
||||||
<form action="<?= site_url('administrator/school-years/' . $id . '/delete-draft') ?>" method="post" onsubmit="return confirm('Delete this unused draft school year?');">
|
<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>
|
</li>
|
||||||
<?php elseif ($status === 'active'): ?>
|
<?php elseif ($status === 'active'): ?>
|
||||||
<li>
|
<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>
|
</li>
|
||||||
<?php elseif ($status === 'closing'): ?>
|
<?php elseif ($status === 'closing'): ?>
|
||||||
<li>
|
<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>
|
||||||
<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() ?>
|
<?= csrf_field() ?>
|
||||||
<button class="dropdown-item" type="submit">Cancel closing</button>
|
<button class="dropdown-item" type="submit">Cancel end-year process</button>
|
||||||
</form>
|
</form>
|
||||||
</li>
|
</li>
|
||||||
<?php elseif ($status === 'closed'): ?>
|
<?php elseif ($status === 'closed'): ?>
|
||||||
<li>
|
<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>
|
||||||
<li>
|
<li>
|
||||||
<button class="dropdown-item" type="button" data-bs-toggle="modal" data-bs-target="#reopenSchoolYearModal<?= $id ?>">
|
<button class="dropdown-item" type="button" data-bs-toggle="modal" data-bs-target="#reopenSchoolYearModal<?= $id ?>">
|
||||||
@@ -220,6 +248,8 @@
|
|||||||
$id = (int) ($year['id'] ?? 0);
|
$id = (int) ($year['id'] ?? 0);
|
||||||
$status = (string) ($year['status'] ?? 'draft');
|
$status = (string) ($year['status'] ?? 'draft');
|
||||||
$readonly = in_array($status, ['closed', 'archived', 'closing'], true);
|
$readonly = in_array($status, ['closed', 'archived', 'closing'], true);
|
||||||
|
$verify = $yearVerification[$id] ?? [];
|
||||||
|
$previousYearName = $schoolYearNamesById[(int) ($year['previous_school_year_id'] ?? 0)] ?? null;
|
||||||
?>
|
?>
|
||||||
<?php if (! $readonly): ?>
|
<?php if (! $readonly): ?>
|
||||||
<div class="modal fade" id="editSchoolYearModal<?= $id ?>" tabindex="-1" aria-hidden="true">
|
<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>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<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="row">
|
||||||
<div class="col-md-4 mb-3">
|
<div class="col-md-4 mb-3">
|
||||||
<label class="form-label" for="school_year_name_<?= $id ?>">School Year</label>
|
<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'] ?? '') ?>">
|
<input class="form-control" id="school_year_ends_on_<?= $id ?>" name="ends_on" type="date" value="<?= esc($year['ends_on'] ?? '') ?>">
|
||||||
</div>
|
</div>
|
||||||
</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="row">
|
||||||
<div class="col-md-6 mb-3">
|
<div class="col-md-6 mb-3">
|
||||||
<label class="form-label" for="registration_starts_on_<?= $id ?>">Registration Starts</label>
|
<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">
|
<form class="modal-content" action="<?= site_url('administrator/school-years/' . $id . '/activate') ?>" method="post">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<div class="modal-header">
|
<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>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<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)): ?>
|
<?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; ?>
|
<?php endif; ?>
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
<input class="form-check-input" type="checkbox" value="1" name="confirm_activation" id="confirm_activation_<?= $id ?>" required>
|
<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 ?>">
|
<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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
<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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -101,9 +101,9 @@ function orderNum($v)
|
|||||||
?>
|
?>
|
||||||
|
|
||||||
<div class="mb-4 text-center">
|
<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>
|
<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>
|
</div>
|
||||||
|
|
||||||
<form method="get" class="row g-3 mb-4 align-items-end">
|
<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="col-lg-4">
|
||||||
<div class="card shadow-sm h-100">
|
<div class="card shadow-sm h-100">
|
||||||
<div class="card-header py-2 d-flex justify-content-between align-items-center">
|
<div class="card-header py-2 d-flex justify-content-between align-items-center">
|
||||||
<strong>Active Student Snapshot</strong>
|
<strong>Enrolled Student Snapshot</strong>
|
||||||
<span class="badge bg-primary"><?= (int)$totalStudents ?> active</span>
|
<span class="badge bg-primary"><?= (int)$totalStudents ?> enrolled</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="d-flex flex-wrap gap-3">
|
<div class="d-flex flex-wrap gap-3">
|
||||||
|
|||||||
@@ -66,7 +66,11 @@
|
|||||||
if ($userId) {
|
if ($userId) {
|
||||||
$configModel = new \App\Models\ConfigurationModel();
|
$configModel = new \App\Models\ConfigurationModel();
|
||||||
$teacherClassModel = new \App\Models\TeacherClassModel();
|
$teacherClassModel = new \App\Models\TeacherClassModel();
|
||||||
$sy = $configModel->getConfig('school_year');
|
try {
|
||||||
|
$sy = service('schoolYearContext')->resolve(service('request'))->yearName();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$sy = $configModel->getConfig('school_year');
|
||||||
|
}
|
||||||
$sem = $configModel->getConfig('semester');
|
$sem = $configModel->getConfig('semester');
|
||||||
$classOptions = $teacherClassModel->getClassAssignmentsByUserId((int)$userId, (string)$sy, (string)$sem);
|
$classOptions = $teacherClassModel->getClassAssignmentsByUserId((int)$userId, (string)$sy, (string)$sem);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,12 @@
|
|||||||
|
|
||||||
<div class="container mt-4">
|
<div class="container mt-4">
|
||||||
<h3>Competition Winners</h3>
|
<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">
|
<div class="list-group">
|
||||||
<?php foreach ($competitions as $c): ?>
|
<?php foreach ($competitions as $c): ?>
|
||||||
|
|||||||
@@ -7,6 +7,9 @@
|
|||||||
<h3><?= esc($competition['title']) ?></h3>
|
<h3><?= esc($competition['title']) ?></h3>
|
||||||
<div class="text-muted mb-3">
|
<div class="text-muted mb-3">
|
||||||
Published: <?= esc($competition['published_at'] ?? '') ?>
|
Published: <?= esc($competition['published_at'] ?? '') ?>
|
||||||
|
<?php if (!empty($schoolYear)): ?>
|
||||||
|
<span class="ms-2">School Year: <?= esc($schoolYear) ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if (empty($winnersByClass)): ?>
|
<?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());
|
$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(2, '2023-2024', 'closed'))->isReadonly());
|
||||||
$this->assertTrue((new SchoolYearContext(1, '2022-2023', 'archived'))->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
|
public function testArrayRepresentationIncludesReadonlyState(): void
|
||||||
|
|||||||
Reference in New Issue
Block a user