This commit is contained in:
@@ -132,6 +132,16 @@ abstract class BaseController extends Controller
|
||||
service('schoolYearWriteGuard')->assertWritable($context, $allowDraftForAdmin, $isAdmin);
|
||||
}
|
||||
|
||||
protected function assertSchoolYearNameWritable(
|
||||
string $schoolYear,
|
||||
bool $allowDraftForAdmin = false,
|
||||
bool $isAdmin = false
|
||||
): void {
|
||||
$context = service('schoolYearContext')->forYearName($schoolYear);
|
||||
|
||||
$this->assertSchoolYearWritable($context, $allowDraftForAdmin, $isAdmin);
|
||||
}
|
||||
|
||||
private function syncSchoolYearPropertyFromContext(): void
|
||||
{
|
||||
if (! property_exists($this, 'schoolYear')) {
|
||||
|
||||
@@ -33,6 +33,7 @@ use App\Models\TeacherSubmissionNotificationHistoryModel;
|
||||
use App\Models\ExamDraftModel;
|
||||
use App\Models\HomeworkModel;
|
||||
use App\Services\SemesterRangeService;
|
||||
use App\Support\Enrollment\DeliberationDecision;
|
||||
|
||||
use CodeIgniter\Events\Events;
|
||||
|
||||
@@ -572,14 +573,14 @@ class AdministratorController extends BaseController
|
||||
// USERS (phone col: cellphone)
|
||||
$uCols = ['firstname', 'lastname', 'email', 'cellphone', 'school_id', 'city', 'state'];
|
||||
$uQB = $db->table('users')
|
||||
->select('id, firstname, lastname, email, cellphone, school_id, city, state, semester');
|
||||
->select('id, firstname, lastname, email, cellphone, school_id, city, state');
|
||||
$applyMultiTokenLike($uQB, $uCols, $tokens, ['cellphone']);
|
||||
$users = $uQB->limit(150)->get()->getResultArray();
|
||||
|
||||
// STUDENTS (no phone column to search)
|
||||
$sCols = ['firstname', 'lastname', 'school_id', 'rfid_tag', 'dob', 'gender'];
|
||||
$sQB = $db->table('students')
|
||||
->select('id, parent_id, school_id, firstname, lastname, dob, gender, semester, rfid_tag');
|
||||
->select('id, parent_id, school_id, firstname, lastname, dob, gender, rfid_tag');
|
||||
$applyMultiTokenLike($sQB, $sCols, $tokens, []);
|
||||
$students = $sQB->limit(150)->get()->getResultArray();
|
||||
|
||||
@@ -2061,6 +2062,7 @@ class AdministratorController extends BaseController
|
||||
{
|
||||
$db = db_connect();
|
||||
$isPg = ($db->getPlatform() === 'Postgre'); // 'MySQLi', 'Postgre', 'SQLSRV', ...
|
||||
$selectedYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
|
||||
|
||||
// In MySQL, avoid truncation of long lists
|
||||
if (!$isPg) {
|
||||
@@ -2133,20 +2135,23 @@ class AdministratorController extends BaseController
|
||||
->getResultArray();
|
||||
}
|
||||
|
||||
// === Inject class_section_name from student_class via your method and replace grade ===
|
||||
// === Inject current-year class_section_name from student_class and replace grade ===
|
||||
foreach ($students as $i => $row) {
|
||||
$sid = (int) ($row['id'] ?? 0);
|
||||
if ($sid > 0) {
|
||||
// Fetch class_section_name for this student
|
||||
// Assumes your method signature: getClassSectionNameByStudentId(int $studentId): ?string
|
||||
$classSectionName = (string) ($this->studentClassModel->getClassSectionNameByStudentId($sid) ?? '');
|
||||
$classSectionName = (string) ($this->studentClassModel->getClassSectionNameByStudentId($sid, $selectedYear) ?? '');
|
||||
|
||||
// Expose it explicitly and replace original grade if present
|
||||
$students[$i]['class_section_name'] = $classSectionName;
|
||||
} else {
|
||||
// Keep keys consistent even if id missing
|
||||
$students[$i]['class_section_name'] = '';
|
||||
}
|
||||
|
||||
$studentYear = trim((string) ($row['school_year'] ?? ''));
|
||||
$students[$i]['age'] = $this->calculateAgeAsOfSchoolYearStartYear(
|
||||
$row['dob'] ?? null,
|
||||
$studentYear !== '' ? $studentYear : $selectedYear
|
||||
);
|
||||
}
|
||||
// === end injection ===
|
||||
|
||||
@@ -2154,9 +2159,45 @@ class AdministratorController extends BaseController
|
||||
'students' => $students,
|
||||
'gradeOptions' => ['K', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'Youth'],
|
||||
'genderOptions' => ['Male', 'Female', 'Other'],
|
||||
'selectedYear' => $selectedYear,
|
||||
]);
|
||||
}
|
||||
|
||||
private function calculateAgeAsOfSchoolYearStartYear(?string $dob, string $schoolYear): ?int
|
||||
{
|
||||
$dob = trim((string) $dob);
|
||||
$schoolYear = trim($schoolYear);
|
||||
|
||||
if ($dob === '' || ! preg_match('/^(\d{4})/', $schoolYear, $matches)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$timezone = new \DateTimeZone((string) (config('School')->attendance['timezone'] ?? user_timezone()));
|
||||
$birthDate = \DateTimeImmutable::createFromFormat('!Y-m-d', $dob, $timezone);
|
||||
$errors = \DateTimeImmutable::getLastErrors();
|
||||
$hasParseErrors = is_array($errors)
|
||||
&& (($errors['warning_count'] ?? 0) > 0 || ($errors['error_count'] ?? 0) > 0);
|
||||
|
||||
if ($birthDate === false || $hasParseErrors) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$schoolYearStartYearCutoff = new \DateTimeImmutable($matches[1] . '-09-01', $timezone);
|
||||
if ($birthDate > $schoolYearStartYearCutoff) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $birthDate->diff($schoolYearStartYearCutoff)->y;
|
||||
} catch (\Throwable $e) {
|
||||
log_message('warning', 'Unable to calculate school-year age from DOB: {message}', [
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function parentProfiles()
|
||||
@@ -2330,28 +2371,19 @@ class AdministratorController extends BaseController
|
||||
$schoolYearContext = $this->resolveSchoolYearContext();
|
||||
$selectedYear = $schoolYearContext->yearName();
|
||||
|
||||
$this->syncReviewDecisionEnrollments($selectedYear);
|
||||
|
||||
$students = $this->studentModel->getStudentsWithClassAndEnrollment($selectedYear);
|
||||
|
||||
$selectedStartYear = $this->getSchoolYearStartYear((string)$selectedYear);
|
||||
$removedPriorIds = [];
|
||||
if ($selectedStartYear !== null) {
|
||||
$removedRows = $this->db->table('enrollments')
|
||||
->select('student_id, school_year')
|
||||
->where('is_withdrawn', 1)
|
||||
->get()->getResultArray();
|
||||
foreach ($removedRows as $row) {
|
||||
$rowYear = $this->getSchoolYearStartYear((string)($row['school_year'] ?? ''));
|
||||
if ($rowYear !== null && $rowYear < $selectedStartYear) {
|
||||
$removedPriorIds[(int)($row['student_id'] ?? 0)] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
$removedPriorStatuses = $this->removedPriorYearStudentStatuses($selectedYear);
|
||||
$returningStudentIds = $this->priorYearStudentIds($selectedYear);
|
||||
|
||||
foreach ($students as &$s) {
|
||||
// ===== Ensure IDs needed by the modal =====
|
||||
$s['student_id'] = (int)($s['id'] ?? 0);
|
||||
$s['removed_previous_year'] = isset($removedPriorIds[$s['student_id']]) ? 'Yes' : 'No';
|
||||
$priorRemovedStatus = $removedPriorStatuses[$s['student_id']] ?? null;
|
||||
$s['removed_previous_year'] = $priorRemovedStatus !== null ? 'Yes' : 'No';
|
||||
$s['prior_removed_status'] = $priorRemovedStatus;
|
||||
|
||||
// Prefer parent_id; fallback to secondparent_user_id if present
|
||||
if (empty($s['parent_id']) && !empty($s['secondparent_user_id'])) {
|
||||
@@ -2389,7 +2421,9 @@ class AdministratorController extends BaseController
|
||||
// ===== Admission override =====
|
||||
// Enrollment status for selected year
|
||||
$statusForYear = $this->enrollmentModel->getEnrollmentStatus((int)$s['student_id'], $selectedYear);
|
||||
if (!empty($statusForYear)) {
|
||||
if (!empty($priorRemovedStatus)) {
|
||||
$s['enrollment_status'] = $priorRemovedStatus;
|
||||
} elseif (!empty($statusForYear)) {
|
||||
$s['enrollment_status'] = $statusForYear;
|
||||
} elseif (($s['admission_status'] ?? null) === 'denied') {
|
||||
$s['enrollment_status'] = 'denied';
|
||||
@@ -2401,6 +2435,8 @@ class AdministratorController extends BaseController
|
||||
// ===== Class section name for the selected year =====
|
||||
$name = $this->studentClassModel->getClassSectionsByStudentId((int)$s['student_id'], $selectedYear);
|
||||
$s['class_section'] = $name ?: 'Class not Assigned';
|
||||
$calculatedAge = $this->calculateAgeAsOfSchoolYearStartYear($s['dob'] ?? null, $selectedYear);
|
||||
$s['age'] = $calculatedAge ?? ($s['age'] ?? null);
|
||||
|
||||
// ===== Sortable registration date (for data-order in view) =====
|
||||
$s['registration_date_order'] = !empty($s['registration_date'])
|
||||
@@ -2445,27 +2481,18 @@ class AdministratorController extends BaseController
|
||||
try {
|
||||
$selectedYear = $this->currentSchoolYearName((string)($this->schoolYear ?? ''));
|
||||
|
||||
$this->syncReviewDecisionEnrollments($selectedYear);
|
||||
|
||||
$students = $this->studentModel->getStudentsWithClassAndEnrollment($selectedYear);
|
||||
|
||||
$selectedStartYear = $this->getSchoolYearStartYear((string)$selectedYear);
|
||||
$removedPriorIds = [];
|
||||
if ($selectedStartYear !== null) {
|
||||
$removedRows = $this->db->table('enrollments')
|
||||
->select('student_id, school_year')
|
||||
->where('is_withdrawn', 1)
|
||||
->get()->getResultArray();
|
||||
foreach ($removedRows as $row) {
|
||||
$rowYear = $this->getSchoolYearStartYear((string)($row['school_year'] ?? ''));
|
||||
if ($rowYear !== null && $rowYear < $selectedStartYear) {
|
||||
$removedPriorIds[(int)($row['student_id'] ?? 0)] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
$removedPriorStatuses = $this->removedPriorYearStudentStatuses($selectedYear);
|
||||
$returningStudentIds = $this->priorYearStudentIds($selectedYear);
|
||||
|
||||
foreach ($students as &$s) {
|
||||
$s['student_id'] = (int)($s['id'] ?? 0);
|
||||
$s['removed_previous_year'] = isset($removedPriorIds[$s['student_id']]) ? 'Yes' : 'No';
|
||||
$priorRemovedStatus = $removedPriorStatuses[$s['student_id']] ?? null;
|
||||
$s['removed_previous_year'] = $priorRemovedStatus !== null ? 'Yes' : 'No';
|
||||
$s['prior_removed_status'] = $priorRemovedStatus;
|
||||
|
||||
if (empty($s['parent_id']) && !empty($s['secondparent_user_id'])) {
|
||||
$s['parent_id'] = (int)$s['secondparent_user_id'];
|
||||
@@ -2490,7 +2517,9 @@ class AdministratorController extends BaseController
|
||||
$s['new_student'] = $s['is_new'] === 1 ? 'Yes' : 'No';
|
||||
|
||||
$statusForYear = $this->enrollmentModel->getEnrollmentStatus((int)$s['student_id'], $selectedYear);
|
||||
if (!empty($statusForYear)) {
|
||||
if (!empty($priorRemovedStatus)) {
|
||||
$s['enrollment_status'] = $priorRemovedStatus;
|
||||
} elseif (!empty($statusForYear)) {
|
||||
$s['enrollment_status'] = $statusForYear;
|
||||
} elseif (($s['admission_status'] ?? null) === 'denied') {
|
||||
$s['enrollment_status'] = 'denied';
|
||||
@@ -2501,6 +2530,8 @@ class AdministratorController extends BaseController
|
||||
|
||||
$className = $this->studentClassModel->getClassSectionsByStudentId((int)$s['student_id'], $selectedYear);
|
||||
$s['class_section'] = $className ?: 'Class not Assigned';
|
||||
$calculatedAge = $this->calculateAgeAsOfSchoolYearStartYear($s['dob'] ?? null, $selectedYear);
|
||||
$s['age'] = $calculatedAge ?? ($s['age'] ?? null);
|
||||
|
||||
$s['registration_date_order'] = !empty($s['registration_date'])
|
||||
? date('Y-m-d', strtotime($s['registration_date']))
|
||||
@@ -2536,6 +2567,172 @@ class AdministratorController extends BaseController
|
||||
}
|
||||
}
|
||||
|
||||
private function syncReviewDecisionEnrollments(string $selectedYear): void
|
||||
{
|
||||
$selectedYear = trim($selectedYear);
|
||||
$sourceYear = $this->getPreviousSchoolYear($selectedYear);
|
||||
if ($selectedYear === '' || $sourceYear === '' || ! $this->db->tableExists('enrollments')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$studentIds = $this->sourceYearStudentIds($sourceYear);
|
||||
if ($studentIds === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$transitionService = service('enrollmentTransition');
|
||||
$now = utc_now();
|
||||
|
||||
foreach ($studentIds as $studentId) {
|
||||
try {
|
||||
$evaluation = $transitionService->evaluate((int) $studentId, $sourceYear, $selectedYear, 'parent');
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Review & Decision enrollment sync evaluation failed for student {studentId}: {message}', [
|
||||
'studentId' => $studentId,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $this->needsReviewDecisionEnrollment($evaluation)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$student = $this->studentModel->find((int) $studentId);
|
||||
if (! is_array($student)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parentId = (int) ($student['parent_id'] ?? ($student['secondparent_user_id'] ?? 0));
|
||||
if ($parentId <= 0) {
|
||||
log_message('warning', 'Review & Decision enrollment sync skipped student {studentId}: no parent ID.', [
|
||||
'studentId' => $studentId,
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
|
||||
$existing = $this->db->table('enrollments')
|
||||
->select('id, enrollment_status')
|
||||
->where('student_id', (int) $studentId)
|
||||
->where('school_year', $selectedYear)
|
||||
->orderBy('updated_at', 'DESC')
|
||||
->orderBy('id', 'DESC')
|
||||
->limit(1)
|
||||
->get()
|
||||
->getRowArray();
|
||||
|
||||
if ($existing !== null) {
|
||||
$existingStatus = (string) ($existing['enrollment_status'] ?? '');
|
||||
if ($existingStatus === 'review & decision' || ! in_array($existingStatus, ['', 'admission under review'], true)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'student_id' => (int) $studentId,
|
||||
'parent_id' => $parentId,
|
||||
'school_year' => $selectedYear,
|
||||
'semester' => (string) $this->semester,
|
||||
'source_school_year' => $sourceYear,
|
||||
'deliberation_decision' => $evaluation['deliberation_decision'] ?? null,
|
||||
'source_grade_id' => $evaluation['source_grade_id'] ?? null,
|
||||
'assigned_grade_id' => $evaluation['assigned_grade_id'] ?? null,
|
||||
'source_class_section_id' => $evaluation['source_class_section_id'] ?? null,
|
||||
'assigned_class_section_id' => $evaluation['assigned_class_section_id'] ?? null,
|
||||
'placement_status' => $evaluation['placement_status'] ?? 'not_created',
|
||||
'age_reference_date' => $evaluation['age_reference_date'] ?? null,
|
||||
'age_on_reference_date' => $evaluation['age_on_reference_date'] ?? null,
|
||||
'adult_student' => ! empty($evaluation['adult_student']) ? 1 : 0,
|
||||
'parent_enrollment_allowed' => ! empty($evaluation['parent_enrollment_allowed']) ? 1 : 0,
|
||||
'student_self_enrollment_allowed' => ! empty($evaluation['student_self_enrollment_allowed']) ? 1 : 0,
|
||||
'exception_required' => 1,
|
||||
'exception_reason' => implode(', ', array_filter(array_column($evaluation['flags'] ?? [], 'flag_type'))) ?: implode(' ', array_map('strval', $evaluation['blockers'] ?? [])),
|
||||
'enrollment_date' => local_date(utc_now(), 'Y-m-d'),
|
||||
'enrollment_status' => 'review & decision',
|
||||
'admission_status' => 'pending',
|
||||
'is_withdrawn' => 0,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
$payload = $this->filterEnrollmentPayloadByColumns($payload);
|
||||
|
||||
if ($existing !== null) {
|
||||
$this->db->table('enrollments')
|
||||
->where('id', (int) $existing['id'])
|
||||
->update($payload);
|
||||
} else {
|
||||
$payload['created_at'] = $now;
|
||||
$this->db->table('enrollments')->insert($this->filterEnrollmentPayloadByColumns($payload));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function needsReviewDecisionEnrollment(array $evaluation): bool
|
||||
{
|
||||
$decision = (string) ($evaluation['deliberation_decision'] ?? '');
|
||||
|
||||
if (in_array($decision, [
|
||||
DeliberationDecision::EXPELLED,
|
||||
DeliberationDecision::WITHDRAWN,
|
||||
DeliberationDecision::DEFERRED_DECISION,
|
||||
], true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$hasSourceAssignment = (int) ($evaluation['source_class_section_id'] ?? 0) > 0
|
||||
|| (int) ($evaluation['source_grade_id'] ?? 0) > 0;
|
||||
|
||||
return $decision === ''
|
||||
&& $hasSourceAssignment
|
||||
&& array_filter($evaluation['blockers'] ?? []) !== [];
|
||||
}
|
||||
|
||||
private function sourceYearStudentIds(string $sourceYear): array
|
||||
{
|
||||
$studentIds = [];
|
||||
|
||||
foreach (['student_class', 'enrollments', 'student_decisions'] as $table) {
|
||||
if (! $this->db->tableExists($table) || ! $this->db->fieldExists('student_id', $table)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$yearColumn = match ($table) {
|
||||
'student_class', 'student_decisions' => 'school_year',
|
||||
default => 'school_year',
|
||||
};
|
||||
|
||||
if (! $this->db->fieldExists($yearColumn, $table)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rows = $this->db->table($table)
|
||||
->select('student_id')
|
||||
->where($yearColumn, $sourceYear)
|
||||
->where('student_id IS NOT NULL', null, false)
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$studentId = (int) ($row['student_id'] ?? 0);
|
||||
if ($studentId > 0) {
|
||||
$studentIds[$studentId] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_keys($studentIds);
|
||||
}
|
||||
|
||||
private function filterEnrollmentPayloadByColumns(array $payload): array
|
||||
{
|
||||
foreach (array_keys($payload) as $column) {
|
||||
if (! $this->db->fieldExists($column, 'enrollments')) {
|
||||
unset($payload[$column]);
|
||||
}
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
private function enrollmentClassOptions(string $selectedYear): array
|
||||
{
|
||||
$select = ['id', 'class_section_id', 'class_section_name'];
|
||||
@@ -2572,6 +2769,115 @@ class AdministratorController extends BaseController
|
||||
->findAll();
|
||||
}
|
||||
|
||||
private function removedPriorYearStudentStatuses(string $selectedYear): array
|
||||
{
|
||||
$selectedStartYear = $this->getSchoolYearStartYear($selectedYear);
|
||||
if ($selectedStartYear === null || ! $this->db->tableExists('enrollments')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$select = ['student_id', 'school_year'];
|
||||
$hasIsWithdrawn = $this->db->fieldExists('is_withdrawn', 'enrollments');
|
||||
$hasEnrollmentStatus = $this->db->fieldExists('enrollment_status', 'enrollments');
|
||||
$hasAdmissionStatus = $this->db->fieldExists('admission_status', 'enrollments');
|
||||
|
||||
if ($hasIsWithdrawn) {
|
||||
$select[] = 'is_withdrawn';
|
||||
}
|
||||
if ($hasEnrollmentStatus) {
|
||||
$select[] = 'enrollment_status';
|
||||
}
|
||||
if ($hasAdmissionStatus) {
|
||||
$select[] = 'admission_status';
|
||||
}
|
||||
|
||||
if (! $hasIsWithdrawn && ! $hasEnrollmentStatus && ! $hasAdmissionStatus) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$builder = $this->db->table('enrollments')
|
||||
->select(implode(', ', $select))
|
||||
->where('student_id IS NOT NULL', null, false)
|
||||
->where('school_year IS NOT NULL', null, false)
|
||||
->groupStart();
|
||||
|
||||
$hasRemovalCondition = false;
|
||||
if ($hasIsWithdrawn) {
|
||||
$builder->where('is_withdrawn', 1);
|
||||
$hasRemovalCondition = true;
|
||||
}
|
||||
|
||||
if ($hasEnrollmentStatus) {
|
||||
if ($hasRemovalCondition) {
|
||||
$builder->orWhereIn('enrollment_status', ['withdrawn', 'widthran', 'denied']);
|
||||
} else {
|
||||
$builder->whereIn('enrollment_status', ['withdrawn', 'widthran', 'denied']);
|
||||
}
|
||||
$hasRemovalCondition = true;
|
||||
}
|
||||
|
||||
if ($hasAdmissionStatus) {
|
||||
if ($hasRemovalCondition) {
|
||||
$builder->orWhere('admission_status', 'denied');
|
||||
} else {
|
||||
$builder->where('admission_status', 'denied');
|
||||
}
|
||||
$hasRemovalCondition = true;
|
||||
}
|
||||
|
||||
$builder->groupEnd();
|
||||
if (! $hasRemovalCondition) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$removedPriorStatuses = [];
|
||||
foreach ($builder->get()->getResultArray() as $row) {
|
||||
$rowYear = $this->getSchoolYearStartYear((string)($row['school_year'] ?? ''));
|
||||
$studentId = (int)($row['student_id'] ?? 0);
|
||||
if ($studentId <= 0 || $rowYear === null || $rowYear >= $selectedStartYear) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$status = $this->priorRemovedEnrollmentStatus($row);
|
||||
if ($status === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
!isset($removedPriorStatuses[$studentId])
|
||||
|| $rowYear > (int)$removedPriorStatuses[$studentId]['year']
|
||||
) {
|
||||
$removedPriorStatuses[$studentId] = [
|
||||
'year' => $rowYear,
|
||||
'status' => $status,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$statusByStudentId = [];
|
||||
foreach ($removedPriorStatuses as $studentId => $row) {
|
||||
$statusByStudentId[(int)$studentId] = (string)$row['status'];
|
||||
}
|
||||
|
||||
return $statusByStudentId;
|
||||
}
|
||||
|
||||
private function priorRemovedEnrollmentStatus(array $row): ?string
|
||||
{
|
||||
$enrollmentStatus = strtolower(trim((string)($row['enrollment_status'] ?? '')));
|
||||
$admissionStatus = strtolower(trim((string)($row['admission_status'] ?? '')));
|
||||
|
||||
if ($enrollmentStatus === 'denied' || $admissionStatus === 'denied') {
|
||||
return 'denied';
|
||||
}
|
||||
|
||||
if (in_array($enrollmentStatus, ['withdrawn', 'widthran'], true) || (int)($row['is_withdrawn'] ?? 0) === 1) {
|
||||
return 'withdrawn';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function priorYearStudentIds(string $selectedYear): array
|
||||
{
|
||||
$selectedStartYear = $this->getSchoolYearStartYear($selectedYear);
|
||||
@@ -2674,6 +2980,7 @@ class AdministratorController extends BaseController
|
||||
|
||||
$validStatuses = [
|
||||
'admission under review',
|
||||
'review & decision',
|
||||
'payment pending',
|
||||
'enrolled',
|
||||
'withdraw under review',
|
||||
@@ -2921,6 +3228,7 @@ class AdministratorController extends BaseController
|
||||
// === AFTER COMMIT: fire specific events, batched per parent/status ===
|
||||
$eventMap = [
|
||||
'admission under review' => 'admissionUnderReview',
|
||||
'review & decision' => 'admissionUnderReview',
|
||||
'payment pending' => 'paymentPending',
|
||||
'enrolled' => 'studentEnrolled',
|
||||
'withdraw under review' => 'withdrawUnderReview',
|
||||
|
||||
@@ -46,9 +46,35 @@ class AssignmentController extends BaseController
|
||||
|
||||
// Apply school year filter (default to current config) but avoid semester filtering so the full year is visible
|
||||
$selectedSemester = (string)($this->request->getGet('semester') ?? $this->semester ?? '');
|
||||
$year = (string)($this->schoolYear ?? '');
|
||||
$year = trim((string)($this->request->getGet('schoolYear') ?? $this->request->getGet('school_year') ?? ''));
|
||||
if ($year === '') {
|
||||
$year = $this->currentSchoolYearName((string)($this->schoolYear ?? ''));
|
||||
}
|
||||
|
||||
$this->applyPendingDistributionDraftsForEnrolledStudents($year);
|
||||
$this->repairMissingEnrollmentClassAssignments($year);
|
||||
$distributedSectionIds = array_values(array_unique(array_merge(
|
||||
$this->distributedClassSectionIds($year),
|
||||
$this->enrollmentAssignedClassSectionIds($year)
|
||||
)));
|
||||
|
||||
$classSectionsAll = [];
|
||||
if (!empty($distributedSectionIds)) {
|
||||
$classSectionsAll = $this->classSectionModel
|
||||
->select('id, class_section_id, class_section_name, school_year')
|
||||
->where('school_year', $year)
|
||||
->whereIn('class_section_id', $distributedSectionIds)
|
||||
->orderBy('class_section_name', 'ASC')
|
||||
->findAll();
|
||||
}
|
||||
|
||||
$classSectionById = [];
|
||||
foreach ($classSectionsAll as $section) {
|
||||
$sectionId = (int)($section['class_section_id'] ?? 0);
|
||||
if ($sectionId > 0) {
|
||||
$classSectionById[$sectionId] = $section;
|
||||
}
|
||||
}
|
||||
|
||||
$tcQ = $this->teacherClassModel;
|
||||
if ($year !== '') {
|
||||
@@ -73,20 +99,26 @@ class AssignmentController extends BaseController
|
||||
$studentsBySection[$sc['class_section_id']][] = $sc;
|
||||
}
|
||||
|
||||
$allSectionIds = array_unique(array_merge(array_keys($teacherBySection), array_keys($studentsBySection)));
|
||||
$allSectionIds = array_unique(array_merge(array_keys($classSectionById), array_keys($teacherBySection), array_keys($studentsBySection)));
|
||||
$distributedSectionSet = array_fill_keys($distributedSectionIds, true);
|
||||
|
||||
foreach ($allSectionIds as $classSectionId) {
|
||||
if (!isset($distributedSectionSet[(int)$classSectionId])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$teacherClasses = $teacherBySection[$classSectionId] ?? [];
|
||||
$studentClasses = $studentsBySection[$classSectionId] ?? [];
|
||||
|
||||
$hasTeacher = !empty($teacherClasses);
|
||||
$hasStudents = !empty($studentClasses);
|
||||
$hasClassSection = isset($classSectionById[(int)$classSectionId]);
|
||||
|
||||
if (!$hasTeacher && !$hasStudents) {
|
||||
if (!$hasClassSection && !$hasTeacher && !$hasStudents) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$classSectionName = (string) ($this->classSectionModel->getClassSectionNameBySectionId($classSectionId) ?? '');
|
||||
$classSectionName = (string) ($classSectionById[(int)$classSectionId]['class_section_name'] ?? $this->classSectionModel->getClassSectionNameBySectionId($classSectionId) ?? '');
|
||||
|
||||
$mainTeachers = [];
|
||||
$teacherAssistants = [];
|
||||
@@ -176,12 +208,19 @@ class AssignmentController extends BaseController
|
||||
$schoolYearsList = [];
|
||||
try {
|
||||
$db = Database::connect();
|
||||
$yearsQuery = $db->table('teacher_class')
|
||||
$yearsQuery = $db->table('classSection')
|
||||
->select('DISTINCT school_year', false)
|
||||
->where('school_year IS NOT NULL', null, false)
|
||||
->orderBy('school_year', 'DESC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
$studentYearsQuery = $db->table('student_class')
|
||||
->select('DISTINCT school_year', false)
|
||||
->where('school_year IS NOT NULL', null, false)
|
||||
->orderBy('school_year', 'DESC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
$yearsQuery = array_merge($yearsQuery, $studentYearsQuery);
|
||||
foreach ($yearsQuery as $row) {
|
||||
$val = (string)($row['school_year'] ?? '');
|
||||
if ($val !== '' && !in_array($val, $schoolYearsList, true)) {
|
||||
@@ -196,7 +235,7 @@ class AssignmentController extends BaseController
|
||||
}
|
||||
|
||||
// Sort sections
|
||||
usort($data['classSections'], fn($a, $b) => strcmp((string) $a['class_section_name'], (string) $b['class_section_name']));
|
||||
usort($data['classSections'], fn($a, $b) => strnatcasecmp((string) $a['class_section_name'], (string) $b['class_section_name']));
|
||||
|
||||
$data['schoolYears'] = $schoolYearsList;
|
||||
$data['schoolYear'] = $year;
|
||||
@@ -206,6 +245,205 @@ class AssignmentController extends BaseController
|
||||
return view('administrator/class_assignment', $data);
|
||||
}
|
||||
|
||||
private function distributedClassSectionIds(string $year): array
|
||||
{
|
||||
$year = trim($year);
|
||||
if ($year === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$db = Database::connect();
|
||||
if (! $db->tableExists('student_section_distribution_drafts')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$baseRows = $db->table('classSection')
|
||||
->select('class_id, class_section_id, class_section_name')
|
||||
->where('school_year', $year)
|
||||
->where("class_section_name NOT LIKE '%-%'", null, false)
|
||||
->orderBy('class_id', 'ASC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
$draftRows = $db->table('student_section_distribution_drafts d')
|
||||
->select('d.class_id, d.class_section_id, COALESCE(cs.class_section_name, d.class_section_id) AS class_section_name', false)
|
||||
->join(
|
||||
'classSection cs',
|
||||
'cs.class_section_id = d.class_section_id AND cs.school_year = d.school_year',
|
||||
'left',
|
||||
false
|
||||
)
|
||||
->where('d.school_year', $year)
|
||||
->where('d.class_section_id >', 0)
|
||||
->whereIn('status', ['pending', 'applied'])
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
$draftsByClassId = [];
|
||||
foreach ($draftRows as $row) {
|
||||
$classId = (int)($row['class_id'] ?? 0);
|
||||
$sectionId = (int)($row['class_section_id'] ?? 0);
|
||||
if ($classId <= 0 || $sectionId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$draftsByClassId[$classId][$sectionId] = [
|
||||
'class_section_id' => $sectionId,
|
||||
'class_section_name' => (string)($row['class_section_name'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
$allowed = [];
|
||||
$seenClassIds = [];
|
||||
foreach ($baseRows as $row) {
|
||||
$classId = (int)($row['class_id'] ?? 0);
|
||||
$baseSectionId = (int)($row['class_section_id'] ?? 0);
|
||||
$baseName = trim((string)($row['class_section_name'] ?? ''));
|
||||
if ($classId <= 0 || $baseSectionId <= 0 || $baseName === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized = strtolower($baseName);
|
||||
$isAutoDistributeBase = $normalized === 'youth'
|
||||
|| (ctype_digit($normalized) && (int)$normalized >= 1 && (int)$normalized <= 10);
|
||||
if (!$isAutoDistributeBase) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$seenClassIds[$classId] = true;
|
||||
$classDrafts = $draftsByClassId[$classId] ?? [];
|
||||
$hasBaseDraft = false;
|
||||
$letteredDraftIds = [];
|
||||
foreach ($classDrafts as $draft) {
|
||||
$draftSectionId = (int)($draft['class_section_id'] ?? 0);
|
||||
$draftName = trim((string)($draft['class_section_name'] ?? ''));
|
||||
if ($draftSectionId === $baseSectionId || $draftName === '' || strpos($draftName, '-') === false) {
|
||||
$hasBaseDraft = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
$letteredDraftIds[] = $draftSectionId;
|
||||
}
|
||||
|
||||
if ($hasBaseDraft || empty($letteredDraftIds)) {
|
||||
$allowed[] = $baseSectionId;
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($letteredDraftIds as $draftSectionId) {
|
||||
$allowed[] = $draftSectionId;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($draftsByClassId as $classId => $classDrafts) {
|
||||
if (isset($seenClassIds[$classId])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($classDrafts as $draft) {
|
||||
$sectionId = (int)($draft['class_section_id'] ?? 0);
|
||||
if ($sectionId > 0) {
|
||||
$allowed[] = $sectionId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique(array_filter(
|
||||
$allowed,
|
||||
static fn(int $sectionId): bool => $sectionId > 0
|
||||
)));
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'distributedClassSectionIds failed: ' . $e->getMessage());
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private function enrollmentAssignedClassSectionIds(string $year): array
|
||||
{
|
||||
$year = trim($year);
|
||||
if ($year === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$db = Database::connect();
|
||||
$allowedStatuses = ['admission under review', 'review & decision', 'payment pending', 'enrolled'];
|
||||
$ids = [];
|
||||
|
||||
if ($db->tableExists('enrollments')) {
|
||||
$builder = $db->table('enrollments e')
|
||||
->select('e.class_section_id')
|
||||
->join('students s', 's.id = e.student_id', 'inner')
|
||||
->where('e.school_year', $year)
|
||||
->whereIn('e.enrollment_status', $allowedStatuses)
|
||||
->where('e.class_section_id IS NOT NULL', null, false)
|
||||
->where('e.class_section_id >', 0);
|
||||
|
||||
if ($db->fieldExists('is_active', 'students')) {
|
||||
$builder->where('s.is_active', 1);
|
||||
}
|
||||
if ($db->fieldExists('is_withdrawn', 'enrollments')) {
|
||||
$builder->groupStart()
|
||||
->where('e.is_withdrawn', 0)
|
||||
->orWhere('e.is_withdrawn', null)
|
||||
->groupEnd();
|
||||
}
|
||||
|
||||
foreach ($builder->groupBy('e.class_section_id')->get()->getResultArray() as $row) {
|
||||
$sectionId = (int)($row['class_section_id'] ?? 0);
|
||||
if ($sectionId > 0) {
|
||||
$ids[] = $sectionId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($db->tableExists('student_class') && $db->tableExists('enrollments')) {
|
||||
$builder = $db->table('student_class sc')
|
||||
->select('sc.class_section_id')
|
||||
->join(
|
||||
'enrollments e',
|
||||
'e.student_id = sc.student_id AND e.school_year = sc.school_year',
|
||||
'inner',
|
||||
false
|
||||
)
|
||||
->join('students s', 's.id = sc.student_id', 'inner')
|
||||
->where('sc.school_year', $year)
|
||||
->whereIn('e.enrollment_status', $allowedStatuses)
|
||||
->where('sc.class_section_id IS NOT NULL', null, false)
|
||||
->where('sc.class_section_id >', 0);
|
||||
|
||||
if ($db->fieldExists('is_active', 'students')) {
|
||||
$builder->where('s.is_active', 1);
|
||||
}
|
||||
if ($db->fieldExists('is_event_only', 'student_class')) {
|
||||
$builder->groupStart()
|
||||
->where('sc.is_event_only', 0)
|
||||
->orWhere('sc.is_event_only', null)
|
||||
->groupEnd();
|
||||
}
|
||||
if ($db->fieldExists('is_withdrawn', 'enrollments')) {
|
||||
$builder->groupStart()
|
||||
->where('e.is_withdrawn', 0)
|
||||
->orWhere('e.is_withdrawn', null)
|
||||
->groupEnd();
|
||||
}
|
||||
|
||||
foreach ($builder->groupBy('sc.class_section_id')->get()->getResultArray() as $row) {
|
||||
$sectionId = (int)($row['class_section_id'] ?? 0);
|
||||
if ($sectionId > 0) {
|
||||
$ids[] = $sectionId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($ids));
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'enrollmentAssignedClassSectionIds failed: ' . $e->getMessage());
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private function applyPendingDistributionDraftsForEnrolledStudents(string $year): void
|
||||
{
|
||||
if ($year === '') {
|
||||
@@ -227,7 +465,7 @@ class AssignmentController extends BaseController
|
||||
)
|
||||
->where('d.school_year', $year)
|
||||
->where('d.status', 'pending')
|
||||
->whereIn('e.enrollment_status', ['payment pending', 'enrolled'])
|
||||
->whereIn('e.enrollment_status', ['admission under review', 'payment pending', 'enrolled'])
|
||||
->groupBy('d.id, d.student_id, d.class_section_id')
|
||||
->get()
|
||||
->getResultArray();
|
||||
@@ -274,10 +512,12 @@ class AssignmentController extends BaseController
|
||||
$db->table('enrollments')
|
||||
->where('student_id', $studentId)
|
||||
->where('school_year', $year)
|
||||
->whereIn('enrollment_status', ['payment pending', 'enrolled'])
|
||||
->whereIn('enrollment_status', ['admission under review', 'payment pending', 'enrolled'])
|
||||
->update([
|
||||
'class_section_id' => $sectionId,
|
||||
'updated_at' => $now,
|
||||
'class_section_id' => $sectionId,
|
||||
'assigned_class_section_id' => $sectionId,
|
||||
'placement_status' => 'automatic_distribution_applied',
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
$db->table('student_section_distribution_drafts')
|
||||
@@ -294,6 +534,188 @@ class AssignmentController extends BaseController
|
||||
}
|
||||
}
|
||||
|
||||
private function repairMissingEnrollmentClassAssignments(string $year): void
|
||||
{
|
||||
if ($year === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$db = Database::connect();
|
||||
$this->ensureClassSectionsForYear($db, $year);
|
||||
if (! $db->tableExists('enrollments') || ! $db->tableExists('student_class')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$previousYear = $this->previousSchoolYearName($year);
|
||||
if ($previousYear === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$rows = $db->table('enrollments e')
|
||||
->select('e.student_id, e.school_year, e.enrollment_status, e.class_section_id, sc.id AS student_class_id')
|
||||
->join('student_class sc', 'sc.student_id = e.student_id AND sc.school_year = e.school_year', 'left')
|
||||
->where('e.school_year', $year)
|
||||
->whereIn('e.enrollment_status', ['admission under review', 'payment pending', 'enrolled'])
|
||||
->groupStart()
|
||||
->where('e.class_section_id', null)
|
||||
->orWhere('e.class_section_id', 0)
|
||||
->orWhere('sc.id', null)
|
||||
->groupEnd()
|
||||
->groupBy('e.student_id, e.school_year, e.enrollment_status, e.class_section_id, sc.id')
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$studentId = (int)($row['student_id'] ?? 0);
|
||||
if ($studentId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$evaluation = service('enrollmentTransition')->evaluate($studentId, $previousYear, $year, 'admin');
|
||||
if (!($evaluation['academic_eligible'] ?? false) || ($evaluation['blockers'] ?? []) !== []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$targetSectionId = (int)($evaluation['assigned_class_section_id'] ?? 0);
|
||||
if ($targetSectionId <= 0 && (int)($evaluation['assigned_grade_id'] ?? 0) > 0) {
|
||||
$base = $this->baseSectionForClassYear((int)$evaluation['assigned_grade_id'], $year);
|
||||
$targetSectionId = (int)($base['class_section_id'] ?? 0);
|
||||
}
|
||||
|
||||
if ($targetSectionId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$placementStatus = match ((string)($evaluation['placement_status'] ?? '')) {
|
||||
'automatic_distribution_pending' => 'base_section_pending_distribution',
|
||||
'same_class_assigned', 'temporary_same_grade' => (string)$evaluation['placement_status'],
|
||||
default => 'manual_class_assigned',
|
||||
};
|
||||
|
||||
$existing = $db->table('student_class')
|
||||
->select('id')
|
||||
->where('student_id', $studentId)
|
||||
->where('school_year', $year)
|
||||
->orderBy('id', 'DESC')
|
||||
->limit(1)
|
||||
->get()
|
||||
->getRowArray();
|
||||
|
||||
$studentClassPayload = [
|
||||
'student_id' => $studentId,
|
||||
'class_section_id' => $targetSectionId,
|
||||
'school_year' => $year,
|
||||
'updated_by' => (int)(session()->get('user_id') ?? 0) ?: null,
|
||||
'updated_at' => utc_now(),
|
||||
];
|
||||
|
||||
if ($existing !== null) {
|
||||
$db->table('student_class')->where('id', (int)$existing['id'])->update($studentClassPayload);
|
||||
} else {
|
||||
$studentClassPayload['created_at'] = utc_now();
|
||||
$db->table('student_class')->insert($studentClassPayload);
|
||||
}
|
||||
|
||||
$db->table('enrollments')
|
||||
->where('student_id', $studentId)
|
||||
->where('school_year', $year)
|
||||
->whereIn('enrollment_status', ['admission under review', 'payment pending', 'enrolled'])
|
||||
->update([
|
||||
'class_section_id' => $targetSectionId,
|
||||
'assigned_class_section_id' => $targetSectionId,
|
||||
'placement_status' => $placementStatus,
|
||||
'updated_at' => utc_now(),
|
||||
]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'repairMissingEnrollmentClassAssignments failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function baseSectionForClassYear(int $classId, string $schoolYear): ?array
|
||||
{
|
||||
$db = Database::connect();
|
||||
$this->ensureClassSectionsForYear($db, $schoolYear);
|
||||
if ($classId <= 0 || $schoolYear === '' || ! $db->tableExists('classSection')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$builder = $db->table('classSection')
|
||||
->select('class_section_id, class_section_name, class_id')
|
||||
->where('class_id', $classId)
|
||||
->where("class_section_name NOT LIKE '%-%'", null, false)
|
||||
->orderBy('id', 'ASC')
|
||||
->limit(1);
|
||||
|
||||
if ($db->fieldExists('school_year', 'classSection')) {
|
||||
$builder->where('school_year', $schoolYear);
|
||||
}
|
||||
|
||||
$row = $builder->get()->getRowArray();
|
||||
|
||||
return $row !== null && (int)($row['class_section_id'] ?? 0) > 0 ? $row : null;
|
||||
}
|
||||
|
||||
private function ensureClassSectionsForYear($db, string $targetSchoolYear): void
|
||||
{
|
||||
$targetSchoolYear = trim($targetSchoolYear);
|
||||
if ($targetSchoolYear === '' || ! $db->tableExists('classSection') || ! $db->fieldExists('school_year', 'classSection')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($db->table('classSection')->where('school_year', $targetSchoolYear)->countAllResults() > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$sourceSchoolYear = $this->previousSchoolYearName($targetSchoolYear);
|
||||
if ($sourceSchoolYear === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$sourceRows = $db->table('classSection')
|
||||
->select('class_id, class_section_id, class_section_name')
|
||||
->where('school_year', $sourceSchoolYear)
|
||||
->orderBy('id', 'ASC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
$now = utc_now();
|
||||
foreach ($sourceRows as $row) {
|
||||
$classSectionId = (int)($row['class_section_id'] ?? 0);
|
||||
if ($classSectionId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$exists = $db->table('classSection')
|
||||
->where('school_year', $targetSchoolYear)
|
||||
->where('class_section_id', $classSectionId)
|
||||
->countAllResults();
|
||||
if ($exists > 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$db->table('classSection')->insert([
|
||||
'class_id' => (int)($row['class_id'] ?? 0),
|
||||
'class_section_id' => $classSectionId,
|
||||
'class_section_name' => (string)($row['class_section_name'] ?? ''),
|
||||
'school_year' => $targetSchoolYear,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function previousSchoolYearName(string $schoolYear): ?string
|
||||
{
|
||||
$schoolYear = trim($schoolYear);
|
||||
if (! preg_match('/^(\d{4})-(\d{4})$/', $schoolYear, $matches)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ((int)$matches[1] - 1) . '-' . ((int)$matches[2] - 1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function save()
|
||||
@@ -316,8 +738,46 @@ class AssignmentController extends BaseController
|
||||
// API: JSON payload for Classes List page
|
||||
public function classAssignmentData()
|
||||
{
|
||||
$teacherClassesAll = $this->teacherClassModel->findAll();
|
||||
$studentClassesAll = $this->studentClassModel->findAll();
|
||||
$year = trim((string)($this->request->getGet('schoolYear') ?? $this->request->getGet('school_year') ?? ''));
|
||||
if ($year === '') {
|
||||
$year = $this->currentSchoolYearName((string)($this->schoolYear ?? ''));
|
||||
}
|
||||
$this->applyPendingDistributionDraftsForEnrolledStudents($year);
|
||||
$this->repairMissingEnrollmentClassAssignments($year);
|
||||
$distributedSectionIds = array_values(array_unique(array_merge(
|
||||
$this->distributedClassSectionIds($year),
|
||||
$this->enrollmentAssignedClassSectionIds($year)
|
||||
)));
|
||||
|
||||
$classSectionsAll = [];
|
||||
if (!empty($distributedSectionIds)) {
|
||||
$classSectionsAll = $this->classSectionModel
|
||||
->select('id, class_section_id, class_section_name, school_year')
|
||||
->where('school_year', $year)
|
||||
->whereIn('class_section_id', $distributedSectionIds)
|
||||
->orderBy('class_section_name', 'ASC')
|
||||
->findAll();
|
||||
}
|
||||
|
||||
$classSectionById = [];
|
||||
foreach ($classSectionsAll as $section) {
|
||||
$sectionId = (int)($section['class_section_id'] ?? 0);
|
||||
if ($sectionId > 0) {
|
||||
$classSectionById[$sectionId] = $section;
|
||||
}
|
||||
}
|
||||
|
||||
$tcQ = $this->teacherClassModel;
|
||||
if ($year !== '') {
|
||||
$tcQ = $tcQ->where('school_year', $year);
|
||||
}
|
||||
$teacherClassesAll = $tcQ->findAll();
|
||||
|
||||
$scQ = $this->studentClassModel->active();
|
||||
if ($year !== '') {
|
||||
$scQ = $scQ->where('student_class.school_year', $year);
|
||||
}
|
||||
$studentClassesAll = $scQ->findAll();
|
||||
|
||||
// Group by section
|
||||
$teacherBySection = [];
|
||||
@@ -331,16 +791,22 @@ class AssignmentController extends BaseController
|
||||
if ($secId) $studentsBySection[$secId][] = $sc;
|
||||
}
|
||||
|
||||
$allSectionIds = array_values(array_unique(array_merge(array_keys($teacherBySection), array_keys($studentsBySection))));
|
||||
$allSectionIds = array_values(array_unique(array_merge(array_keys($classSectionById), array_keys($teacherBySection), array_keys($studentsBySection))));
|
||||
$distributedSectionSet = array_fill_keys($distributedSectionIds, true);
|
||||
|
||||
$classSections = [];
|
||||
|
||||
foreach ($allSectionIds as $classSectionId) {
|
||||
if (!isset($distributedSectionSet[(int)$classSectionId])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$hasTeacher = !empty($teacherBySection[$classSectionId]);
|
||||
$hasStudents = !empty($studentsBySection[$classSectionId]);
|
||||
if (!$hasTeacher && !$hasStudents) continue;
|
||||
$hasClassSection = isset($classSectionById[(int)$classSectionId]);
|
||||
if (!$hasClassSection && !$hasTeacher && !$hasStudents) continue;
|
||||
|
||||
$classSectionName = (string) ($this->classSectionModel->getClassSectionNameBySectionId($classSectionId) ?? '');
|
||||
$classSectionName = (string) ($classSectionById[(int)$classSectionId]['class_section_name'] ?? $this->classSectionModel->getClassSectionNameBySectionId($classSectionId) ?? '');
|
||||
|
||||
$mainTeachers = [];
|
||||
$teacherAssistants = [];
|
||||
@@ -374,12 +840,18 @@ class AssignmentController extends BaseController
|
||||
|
||||
// Load students for the section
|
||||
$students = [];
|
||||
foreach ($this->studentClassModel->active()->where('student_class.class_section_id', $classSectionId)->findAll() as $studentClass) {
|
||||
$seenStudentIds = [];
|
||||
foreach ($studentsBySection[$classSectionId] ?? [] as $studentClass) {
|
||||
$sid = (int)($studentClass['student_id'] ?? 0);
|
||||
if ($sid <= 0 || isset($seenStudentIds[$sid])) {
|
||||
continue;
|
||||
}
|
||||
$stu = $this->studentModel
|
||||
->where('id', (int)$studentClass['student_id'])
|
||||
->where('id', $sid)
|
||||
->where('is_active', 1)
|
||||
->first();
|
||||
if (!$stu) continue;
|
||||
|
||||
$students[] = [
|
||||
'id' => (int)$stu['id'],
|
||||
'firstname' => (string)($stu['firstname'] ?? ''),
|
||||
@@ -391,6 +863,7 @@ class AssignmentController extends BaseController
|
||||
'tuition_paid' => (bool)($stu['tuition_paid'] ?? false),
|
||||
'school_id' => (string)($stu['school_id'] ?? ''),
|
||||
];
|
||||
$seenStudentIds[$sid] = true;
|
||||
}
|
||||
|
||||
$classSections[] = [
|
||||
@@ -406,7 +879,7 @@ class AssignmentController extends BaseController
|
||||
}
|
||||
|
||||
// Sort by class_section_name
|
||||
usort($classSections, fn($a, $b) => strcmp((string)($a['class_section_name'] ?? ''), (string)($b['class_section_name'] ?? '')));
|
||||
usort($classSections, fn($a, $b) => strnatcasecmp((string)($a['class_section_name'] ?? ''), (string)($b['class_section_name'] ?? '')));
|
||||
|
||||
return $this->response->setJSON([
|
||||
'classSections' => $classSections,
|
||||
|
||||
@@ -0,0 +1,633 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use CodeIgniter\Database\BaseConnection;
|
||||
use CodeIgniter\HTTP\RequestInterface;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Throwable;
|
||||
|
||||
class EnrollmentAdminController extends BaseController
|
||||
{
|
||||
protected BaseConnection $db;
|
||||
|
||||
public function initController(RequestInterface $request, ResponseInterface $response, LoggerInterface $logger)
|
||||
{
|
||||
parent::initController($request, $response, $logger);
|
||||
|
||||
$this->db = \Config\Database::connect();
|
||||
}
|
||||
|
||||
public function dashboard()
|
||||
{
|
||||
$schoolYear = trim((string) ($this->request->getGet('school_year') ?? $this->currentSchoolYearName((string) ($this->schoolYear ?? ''))));
|
||||
$status = trim((string) ($this->request->getGet('status') ?? 'open'));
|
||||
$flagType = trim((string) ($this->request->getGet('flag_type') ?? ''));
|
||||
$assignedTo = trim((string) ($this->request->getGet('assigned_to') ?? ''));
|
||||
|
||||
$flags = $this->enrollmentFlags($schoolYear, $status, $flagType, $assignedTo);
|
||||
|
||||
return view('administrator/enrollment_admin_dashboard', [
|
||||
'flags' => $flags,
|
||||
'schoolYear' => $schoolYear,
|
||||
'status' => $status,
|
||||
'flagType' => $flagType,
|
||||
'assignedTo' => $assignedTo,
|
||||
'flagTypes' => $this->flagTypes(),
|
||||
'schoolYears' => $this->schoolYears(),
|
||||
'classSections' => $this->classSections($schoolYear),
|
||||
'admins' => $this->adminUsers(),
|
||||
'enrollmentFollowups' => $this->enrollmentFollowups($schoolYear),
|
||||
'auditRows' => $this->auditRows($schoolYear),
|
||||
'launchState' => $this->launchState($schoolYear),
|
||||
'previewParentId' => $this->firstParentWithStudents(),
|
||||
'emailExamples' => service('enrollmentRegistrationEmail')->previewExamplesForSchoolYear($schoolYear),
|
||||
]);
|
||||
}
|
||||
|
||||
public function approveLaunch()
|
||||
{
|
||||
$schoolYear = trim((string) ($this->request->getPost('school_year') ?? ''));
|
||||
if ($schoolYear === '') {
|
||||
return redirect()->back()->with('error', 'School year is required.');
|
||||
}
|
||||
|
||||
if (! $this->launchConfigurationComplete($schoolYear, $missing)) {
|
||||
return redirect()->back()->with('error', 'Registration launch is not ready: ' . implode(', ', $missing));
|
||||
}
|
||||
|
||||
$this->db->table('school_years')
|
||||
->where('name', $schoolYear)
|
||||
->update([
|
||||
'registration_launch_approved_at' => date('Y-m-d H:i:s'),
|
||||
'registration_launch_approved_by' => $this->userId(),
|
||||
'registration_email_template_version' => \App\Services\EnrollmentRegistrationEmailService::TEMPLATE_VERSION,
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
return redirect()->back()->with('success', 'Registration launch approved for ' . $schoolYear . '.');
|
||||
}
|
||||
|
||||
public function sendRegistrationEmails()
|
||||
{
|
||||
$schoolYear = trim((string) ($this->request->getPost('school_year') ?? ''));
|
||||
if ($schoolYear === '') {
|
||||
return redirect()->back()->with('error', 'School year is required.');
|
||||
}
|
||||
|
||||
if (! $this->launchConfigurationComplete($schoolYear, $missing)) {
|
||||
return redirect()->back()->with('error', 'Registration launch is not ready: ' . implode(', ', $missing));
|
||||
}
|
||||
|
||||
$force = (bool) $this->request->getPost('force_resend');
|
||||
$result = service('enrollmentRegistrationEmail')->sendForSchoolYearName($schoolYear, $force);
|
||||
$message = sprintf(
|
||||
'Registration emails processed for %s: %d sent, %d failed, %d skipped.',
|
||||
$schoolYear,
|
||||
(int) ($result['sent'] ?? 0),
|
||||
(int) ($result['failed'] ?? 0),
|
||||
(int) ($result['skipped'] ?? 0)
|
||||
);
|
||||
|
||||
$details = array_filter(array_map('strval', $result['messages'] ?? []));
|
||||
if ($details !== []) {
|
||||
$message .= ' ' . implode(' ', $details);
|
||||
}
|
||||
|
||||
return redirect()->back()->with(((int) ($result['failed'] ?? 0) > 0) ? 'error' : 'success', $message);
|
||||
}
|
||||
|
||||
public function previewEmail()
|
||||
{
|
||||
$schoolYear = trim((string) ($this->request->getGet('school_year') ?? ''));
|
||||
$parentId = (int) ($this->request->getGet('parent_id') ?? 0);
|
||||
if ($schoolYear === '' || $parentId <= 0) {
|
||||
return redirect()->back()->with('error', 'School year and parent are required for preview.');
|
||||
}
|
||||
|
||||
$message = service('enrollmentRegistrationEmail')->previewForParent($schoolYear, $parentId);
|
||||
if ($message === null) {
|
||||
return redirect()->back()->with('error', 'No preview email could be generated for that parent.');
|
||||
}
|
||||
|
||||
return view('administrator/enrollment_email_preview', [
|
||||
'schoolYear' => $schoolYear,
|
||||
'parentId' => $parentId,
|
||||
'subject' => $message['subject'],
|
||||
'body' => $message['body'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function resolveFlag(int $id)
|
||||
{
|
||||
try {
|
||||
$flag = $this->requireFlag($id);
|
||||
$notes = trim((string) ($this->request->getPost('resolution_notes') ?? ''));
|
||||
if ($notes === '') {
|
||||
return redirect()->back()->with('error', 'Resolution notes are required.');
|
||||
}
|
||||
|
||||
$this->resolveFlagRow($flag, $notes, 'flag_resolved');
|
||||
return redirect()->back()->with('success', 'Enrollment flag resolved.');
|
||||
} catch (Throwable $e) {
|
||||
return redirect()->back()->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function assignClass(int $id)
|
||||
{
|
||||
try {
|
||||
$flag = $this->requireFlag($id);
|
||||
$sectionId = (int) ($this->request->getPost('class_section_id') ?? 0);
|
||||
$notes = trim((string) ($this->request->getPost('resolution_notes') ?? ''));
|
||||
if ($sectionId <= 0) {
|
||||
return redirect()->back()->with('error', 'Select a target class section.');
|
||||
}
|
||||
if ($notes === '') {
|
||||
return redirect()->back()->with('error', 'Resolution notes are required.');
|
||||
}
|
||||
|
||||
$this->applyClassSection((int) $flag['student_id'], (string) $flag['school_year'], $sectionId, 'manual_class_assignment');
|
||||
$this->resolveFlagRow($flag, $notes, 'manual_class_assignment', ['class_section_id' => $sectionId]);
|
||||
|
||||
return redirect()->back()->with('success', 'Class assignment applied.');
|
||||
} catch (Throwable $e) {
|
||||
return redirect()->back()->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function confirmMakeupPromotion(int $id)
|
||||
{
|
||||
try {
|
||||
$flag = $this->requireFlag($id);
|
||||
if ((string) ($flag['flag_type'] ?? '') !== 'PENDING_MAKE_UP_EXAM_PROMOTION') {
|
||||
return redirect()->back()->with('error', 'This flag is not a make-up exam promotion flag.');
|
||||
}
|
||||
|
||||
$sectionId = (int) ($this->request->getPost('class_section_id') ?? 0);
|
||||
$examResult = trim((string) ($this->request->getPost('exam_result') ?? ''));
|
||||
$notes = trim((string) ($this->request->getPost('resolution_notes') ?? ''));
|
||||
if (! in_array($examResult, ['passed', 'failed'], true)) {
|
||||
return redirect()->back()->with('error', 'Select whether the make-up exam was passed or failed.');
|
||||
}
|
||||
if ($notes === '') {
|
||||
return redirect()->back()->with('error', 'Resolution notes are required.');
|
||||
}
|
||||
|
||||
if ($examResult === 'passed') {
|
||||
if ($sectionId <= 0) {
|
||||
return redirect()->back()->with('error', 'Select the promoted class section.');
|
||||
}
|
||||
|
||||
$this->applyClassSection((int) $flag['student_id'], (string) $flag['school_year'], $sectionId, 'make_up_exam_promotion_completed', 'promotion_completed');
|
||||
$this->resolveFlagRow($flag, $notes, 'make_up_exam_promotion_completed', [
|
||||
'exam_result' => $examResult,
|
||||
'class_section_id' => $sectionId,
|
||||
]);
|
||||
} else {
|
||||
$this->updateEnrollmentPlacementStatus((int) $flag['student_id'], (string) $flag['school_year'], 'no_promotion_required');
|
||||
$this->resolveFlagRow($flag, $notes, 'make_up_exam_no_promotion_required', [
|
||||
'exam_result' => $examResult,
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', 'Make-up exam follow-up resolved.');
|
||||
} catch (Throwable $e) {
|
||||
return redirect()->back()->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function approveException(int $id)
|
||||
{
|
||||
try {
|
||||
$flag = $this->requireFlag($id);
|
||||
$reason = trim((string) ($this->request->getPost('reason') ?? ''));
|
||||
if ($reason === '') {
|
||||
return redirect()->back()->with('error', 'Approval reason is required.');
|
||||
}
|
||||
|
||||
$this->db->table('enrollments')
|
||||
->where('student_id', (int) $flag['student_id'])
|
||||
->where('school_year', (string) $flag['school_year'])
|
||||
->update([
|
||||
'exception_required' => 0,
|
||||
'exception_reason' => $reason,
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
$this->resolveFlagRow($flag, $reason, 'enrollment_exception_approved');
|
||||
return redirect()->back()->with('success', 'Enrollment exception approved.');
|
||||
} catch (Throwable $e) {
|
||||
return redirect()->back()->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function enrollmentFlags(string $schoolYear, string $status, string $flagType, string $assignedTo): array
|
||||
{
|
||||
if (! $this->db->tableExists('enrollment_flags')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$builder = $this->db->table('enrollment_flags ef')
|
||||
->select('ef.*')
|
||||
->select('s.firstname, s.lastname, s.school_id')
|
||||
->select('u.firstname AS assignee_firstname, u.lastname AS assignee_lastname')
|
||||
->join('students s', 's.id = ef.student_id', 'left')
|
||||
->join('users u', 'u.id = ef.assigned_to', 'left')
|
||||
->orderBy('ef.created_at', 'DESC')
|
||||
->orderBy('ef.id', 'DESC');
|
||||
|
||||
if ($schoolYear !== '') {
|
||||
$builder->where('ef.school_year', $schoolYear);
|
||||
}
|
||||
if ($status !== '') {
|
||||
$builder->where('ef.status', $status);
|
||||
}
|
||||
if ($flagType !== '') {
|
||||
$builder->where('ef.flag_type', $flagType);
|
||||
}
|
||||
if (is_numeric($assignedTo) && (int) $assignedTo > 0) {
|
||||
$builder->where('ef.assigned_to', (int) $assignedTo);
|
||||
}
|
||||
|
||||
$rows = $builder->get()->getResultArray();
|
||||
foreach ($rows as &$row) {
|
||||
$row['student_name'] = trim((string) ($row['firstname'] ?? '') . ' ' . (string) ($row['lastname'] ?? '')) ?: 'Student #' . (int) ($row['student_id'] ?? 0);
|
||||
$row['assignee_name'] = trim((string) ($row['assignee_firstname'] ?? '') . ' ' . (string) ($row['assignee_lastname'] ?? ''));
|
||||
$row['details'] = json_decode((string) ($row['details_json'] ?? ''), true) ?: [];
|
||||
}
|
||||
unset($row);
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
private function enrollmentFollowups(string $schoolYear): array
|
||||
{
|
||||
if (! $this->db->tableExists('enrollments')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$fields = $this->db->getFieldNames('enrollments');
|
||||
$select = [
|
||||
'e.id',
|
||||
'e.student_id',
|
||||
'e.school_year',
|
||||
'e.enrollment_status',
|
||||
'e.class_section_id',
|
||||
'e.updated_at',
|
||||
's.firstname',
|
||||
's.lastname',
|
||||
's.school_id',
|
||||
'cs.class_section_name',
|
||||
];
|
||||
|
||||
foreach ([
|
||||
'deliberation_decision',
|
||||
'placement_status',
|
||||
'exception_required',
|
||||
'exception_reason',
|
||||
'source_school_year',
|
||||
'assigned_class_section_id',
|
||||
'age_on_reference_date',
|
||||
] as $field) {
|
||||
if (in_array($field, $fields, true)) {
|
||||
$select[] = 'e.' . $field;
|
||||
}
|
||||
}
|
||||
|
||||
$builder = $this->db->table('enrollments e')
|
||||
->select(implode(', ', $select))
|
||||
->join('students s', 's.id = e.student_id', 'left')
|
||||
->join('classSection cs', 'cs.class_section_id = e.class_section_id', 'left')
|
||||
->orderBy('e.updated_at', 'DESC')
|
||||
->orderBy('e.id', 'DESC')
|
||||
->limit(200);
|
||||
|
||||
if ($schoolYear !== '') {
|
||||
$builder->where('e.school_year', $schoolYear);
|
||||
}
|
||||
|
||||
$builder->groupStart()
|
||||
->whereIn('e.enrollment_status', [
|
||||
'review & decision',
|
||||
'admission under review',
|
||||
'waitlist',
|
||||
'denied',
|
||||
'Review & Decision',
|
||||
'Admission Under Review',
|
||||
'Waitlist',
|
||||
'Denied',
|
||||
]);
|
||||
|
||||
if (in_array('placement_status', $fields, true)) {
|
||||
$builder->orWhereIn('e.placement_status', [
|
||||
'temporary_same_grade',
|
||||
'temporary_manual_class_required',
|
||||
'manual_class_required',
|
||||
'exit_required',
|
||||
'automatic_distribution_pending',
|
||||
]);
|
||||
}
|
||||
|
||||
if (in_array('exception_required', $fields, true)) {
|
||||
$builder->orWhere('e.exception_required', 1);
|
||||
}
|
||||
|
||||
if (in_array('deliberation_decision', $fields, true)) {
|
||||
$builder->orWhereIn('e.deliberation_decision', [
|
||||
'make_up_exam',
|
||||
'repeat_class',
|
||||
'deferred',
|
||||
'expelled',
|
||||
'withdrawn',
|
||||
]);
|
||||
}
|
||||
|
||||
$rows = $builder->groupEnd()->get()->getResultArray();
|
||||
foreach ($rows as &$row) {
|
||||
$row['student_name'] = trim((string) ($row['firstname'] ?? '') . ' ' . (string) ($row['lastname'] ?? '')) ?: 'Student #' . (int) ($row['student_id'] ?? 0);
|
||||
}
|
||||
unset($row);
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
private function requireFlag(int $id): array
|
||||
{
|
||||
if ($id <= 0 || ! $this->db->tableExists('enrollment_flags')) {
|
||||
throw new \RuntimeException('Enrollment flag was not found.');
|
||||
}
|
||||
|
||||
$flag = $this->db->table('enrollment_flags')->where('id', $id)->limit(1)->get()->getRowArray();
|
||||
if ($flag === null) {
|
||||
throw new \RuntimeException('Enrollment flag was not found.');
|
||||
}
|
||||
|
||||
return $flag;
|
||||
}
|
||||
|
||||
private function resolveFlagRow(array $flag, string $notes, string $auditAction, array $metadata = []): void
|
||||
{
|
||||
$this->db->transStart();
|
||||
$original = $flag;
|
||||
$this->db->table('enrollment_flags')
|
||||
->where('id', (int) $flag['id'])
|
||||
->update([
|
||||
'status' => 'resolved',
|
||||
'resolved_at' => date('Y-m-d H:i:s'),
|
||||
'resolution_notes' => $notes,
|
||||
]);
|
||||
|
||||
$this->audit((int) $flag['student_id'], (string) $flag['school_year'], (string) ($flag['source_school_year'] ?? ''), $auditAction, $original, array_merge($metadata, [
|
||||
'flag_id' => (int) $flag['id'],
|
||||
'flag_type' => (string) $flag['flag_type'],
|
||||
'resolution_notes' => $notes,
|
||||
]), $notes);
|
||||
$this->db->transComplete();
|
||||
|
||||
if ($this->db->transStatus() === false) {
|
||||
throw new \RuntimeException('Unable to resolve enrollment flag.');
|
||||
}
|
||||
}
|
||||
|
||||
private function applyClassSection(int $studentId, string $schoolYear, int $sectionId, string $auditAction, string $placementStatus = 'manual_class_assigned'): void
|
||||
{
|
||||
$section = $this->db->table('classSection')
|
||||
->select('class_section_id, class_id, class_section_name')
|
||||
->where('class_section_id', $sectionId)
|
||||
->orderBy('id', 'DESC')
|
||||
->limit(1)
|
||||
->get()
|
||||
->getRowArray();
|
||||
if ($section === null) {
|
||||
throw new \RuntimeException('Selected class section was not found.');
|
||||
}
|
||||
|
||||
$originalEnrollment = $this->latestEnrollment($studentId, $schoolYear);
|
||||
$payload = [
|
||||
'class_section_id' => $sectionId,
|
||||
'assigned_class_section_id' => $sectionId,
|
||||
'assigned_grade_id' => (int) ($section['class_id'] ?? 0) ?: null,
|
||||
'placement_status' => $placementStatus,
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
|
||||
$this->db->table('enrollments')
|
||||
->where('student_id', $studentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->update($payload);
|
||||
|
||||
$studentClass = $this->db->table('student_class')
|
||||
->where('student_id', $studentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->orderBy('id', 'DESC')
|
||||
->limit(1)
|
||||
->get()
|
||||
->getRowArray();
|
||||
$studentClassPayload = [
|
||||
'student_id' => $studentId,
|
||||
'class_section_id' => $sectionId,
|
||||
'school_year' => $schoolYear,
|
||||
'updated_by' => $this->userId(),
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
if ($studentClass !== null) {
|
||||
$this->db->table('student_class')->where('id', (int) $studentClass['id'])->update($studentClassPayload);
|
||||
} else {
|
||||
$studentClassPayload['created_at'] = date('Y-m-d H:i:s');
|
||||
$this->db->table('student_class')->insert($studentClassPayload);
|
||||
}
|
||||
|
||||
$this->audit($studentId, $schoolYear, (string) ($originalEnrollment['source_school_year'] ?? ''), $auditAction, $originalEnrollment, $payload, 'Class section assigned by administrator.');
|
||||
}
|
||||
|
||||
private function updateEnrollmentPlacementStatus(int $studentId, string $schoolYear, string $placementStatus): void
|
||||
{
|
||||
$this->db->table('enrollments')
|
||||
->where('student_id', $studentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->update([
|
||||
'placement_status' => $placementStatus,
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
|
||||
private function latestEnrollment(int $studentId, string $schoolYear): ?array
|
||||
{
|
||||
return $this->db->table('enrollments')
|
||||
->where('student_id', $studentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->orderBy('updated_at', 'DESC')
|
||||
->orderBy('id', 'DESC')
|
||||
->limit(1)
|
||||
->get()
|
||||
->getRowArray() ?: null;
|
||||
}
|
||||
|
||||
private function audit(int $studentId, string $schoolYear, string $sourceSchoolYear, string $action, ?array $original, array $new, string $reason): void
|
||||
{
|
||||
if (! $this->db->tableExists('enrollment_transition_audits')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->db->table('enrollment_transition_audits')->insert([
|
||||
'student_id' => $studentId,
|
||||
'school_year' => $schoolYear,
|
||||
'source_school_year' => $sourceSchoolYear !== '' ? $sourceSchoolYear : null,
|
||||
'action' => $action,
|
||||
'performed_by' => $this->userId(),
|
||||
'original_values_json' => $original !== null ? json_encode($original, JSON_UNESCAPED_SLASHES) : null,
|
||||
'new_values_json' => json_encode($new, JSON_UNESCAPED_SLASHES),
|
||||
'reason' => $reason,
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
|
||||
private function auditRows(string $schoolYear): array
|
||||
{
|
||||
if (! $this->db->tableExists('enrollment_transition_audits')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$builder = $this->db->table('enrollment_transition_audits eta')
|
||||
->select('eta.*')
|
||||
->select('s.firstname, s.lastname')
|
||||
->select('u.firstname AS user_firstname, u.lastname AS user_lastname')
|
||||
->join('students s', 's.id = eta.student_id', 'left')
|
||||
->join('users u', 'u.id = eta.performed_by', 'left')
|
||||
->orderBy('eta.created_at', 'DESC')
|
||||
->limit(50);
|
||||
|
||||
if ($schoolYear !== '') {
|
||||
$builder->where('eta.school_year', $schoolYear);
|
||||
}
|
||||
|
||||
$rows = $builder->get()->getResultArray();
|
||||
foreach ($rows as &$row) {
|
||||
$row['student_name'] = trim((string) ($row['firstname'] ?? '') . ' ' . (string) ($row['lastname'] ?? '')) ?: 'Student #' . (int) ($row['student_id'] ?? 0);
|
||||
$row['performed_by_name'] = trim((string) ($row['user_firstname'] ?? '') . ' ' . (string) ($row['user_lastname'] ?? '')) ?: ((int) ($row['performed_by'] ?? 0) > 0 ? 'User #' . (int) $row['performed_by'] : '');
|
||||
}
|
||||
unset($row);
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
private function launchState(string $schoolYear): array
|
||||
{
|
||||
if ($schoolYear === '' || ! $this->db->tableExists('school_years')) {
|
||||
return ['approved' => false, 'approved_at' => null, 'missing' => ['school year']];
|
||||
}
|
||||
|
||||
$row = $this->db->table('school_years')->where('name', $schoolYear)->limit(1)->get()->getRowArray();
|
||||
$this->launchConfigurationComplete($schoolYear, $missing);
|
||||
|
||||
return [
|
||||
'approved' => ! empty($row['registration_launch_approved_at'] ?? null),
|
||||
'approved_at' => $row['registration_launch_approved_at'] ?? null,
|
||||
'missing' => $missing,
|
||||
];
|
||||
}
|
||||
|
||||
private function launchConfigurationComplete(string $schoolYear, ?array &$missing = null): bool
|
||||
{
|
||||
$missing = [];
|
||||
$row = $this->db->table('school_years')->where('name', $schoolYear)->limit(1)->get()->getRowArray();
|
||||
if ($row === null) {
|
||||
$missing[] = 'school year record';
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (['registration_starts_on' => 'registration opening date', 'registration_ends_on' => 'registration deadline'] as $field => $label) {
|
||||
if (empty($row[$field])) {
|
||||
$missing[] = $label;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $this->db->tableExists('email_templates')) {
|
||||
$missing[] = 'email templates table';
|
||||
} else {
|
||||
$fields = $this->db->getFieldNames('email_templates');
|
||||
$keyField = in_array('code', $fields, true) ? 'code' : 'template_key';
|
||||
$template = $this->db->table('email_templates')
|
||||
->where($keyField, 'registration_opening')
|
||||
->where('is_active', 1)
|
||||
->countAllResults();
|
||||
if ($template <= 0) {
|
||||
$missing[] = 'approved registration email template';
|
||||
}
|
||||
}
|
||||
|
||||
return $missing === [];
|
||||
}
|
||||
|
||||
private function firstParentWithStudents(): ?int
|
||||
{
|
||||
if (! $this->db->tableExists('students')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$row = $this->db->table('students')
|
||||
->select('parent_id')
|
||||
->where('parent_id IS NOT NULL', null, false)
|
||||
->orderBy('parent_id', 'ASC')
|
||||
->limit(1)
|
||||
->get()
|
||||
->getRowArray();
|
||||
|
||||
return is_numeric($row['parent_id'] ?? null) ? (int) $row['parent_id'] : null;
|
||||
}
|
||||
|
||||
private function flagTypes(): array
|
||||
{
|
||||
if (! $this->db->tableExists('enrollment_flags')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_column($this->db->table('enrollment_flags')->select('flag_type')->distinct()->orderBy('flag_type')->get()->getResultArray(), 'flag_type');
|
||||
}
|
||||
|
||||
private function schoolYears(): array
|
||||
{
|
||||
if (! $this->db->tableExists('school_years')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->db->table('school_years')->select('name')->orderBy('name', 'DESC')->get()->getResultArray();
|
||||
}
|
||||
|
||||
private function classSections(string $schoolYear): array
|
||||
{
|
||||
if (! $this->db->tableExists('classSection')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$builder = $this->db->table('classSection')->select('class_section_id, class_section_name')->orderBy('class_section_name', 'ASC');
|
||||
if ($schoolYear !== '' && $this->db->fieldExists('school_year', 'classSection')) {
|
||||
$builder->where('school_year', $schoolYear);
|
||||
}
|
||||
|
||||
return $builder->get()->getResultArray();
|
||||
}
|
||||
|
||||
private function adminUsers(): array
|
||||
{
|
||||
if (! $this->db->tableExists('users')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->db->table('users')
|
||||
->select('id, firstname, lastname, user_type')
|
||||
->whereIn('user_type', ['administrator', 'admin', 'principal', 'administrative staff'])
|
||||
->orderBy('lastname', 'ASC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
}
|
||||
|
||||
private function userId(): ?int
|
||||
{
|
||||
$id = session('user_id') ?? session('id');
|
||||
return is_numeric($id) ? (int) $id : null;
|
||||
}
|
||||
}
|
||||
@@ -92,6 +92,13 @@ class EventController extends ResourceController
|
||||
return $this->eventChargesHasCreatedBy;
|
||||
}
|
||||
|
||||
private function assertSchoolYearNameWritable(string $schoolYear): void
|
||||
{
|
||||
service('schoolYearWriteGuard')->assertWritable(
|
||||
service('schoolYearContext')->forYearName($schoolYear)
|
||||
);
|
||||
}
|
||||
|
||||
private function currentSchoolYearName(): string
|
||||
{
|
||||
try {
|
||||
@@ -939,6 +946,7 @@ class EventController extends ResourceController
|
||||
if (!$charge) {
|
||||
return redirect()->to($returnTo)->with('error', 'Charge not found.');
|
||||
}
|
||||
$this->assertSchoolYearNameWritable((string)($charge['school_year'] ?? ''));
|
||||
|
||||
$paymentId = (int)($charge['event_payment_id'] ?? 0);
|
||||
if ($paymentId > 0) {
|
||||
@@ -989,6 +997,7 @@ class EventController extends ResourceController
|
||||
if (!$charge) {
|
||||
return redirect()->to($returnTo)->with('error', 'Charge not found.');
|
||||
}
|
||||
$this->assertSchoolYearNameWritable((string)($charge['school_year'] ?? ''));
|
||||
|
||||
$signed = $this->request->getPost('waiver_signed') === '1';
|
||||
|
||||
@@ -1070,6 +1079,7 @@ class EventController extends ResourceController
|
||||
if (!$charge) {
|
||||
return null;
|
||||
}
|
||||
$this->assertSchoolYearNameWritable((string)($charge['school_year'] ?? ''));
|
||||
|
||||
$event = $this->eventModel->find($charge['event_id']);
|
||||
$eventAmount = max(0.0, (float)($event['amount'] ?? 0));
|
||||
|
||||
@@ -261,6 +261,7 @@ class ExpenseController extends BaseController
|
||||
if (!$expense) {
|
||||
throw new \RuntimeException('Expense not found');
|
||||
}
|
||||
$this->assertSchoolYearNameWritable((string)($expense['school_year'] ?? ''));
|
||||
$db->query(
|
||||
"SELECT id FROM reimbursements WHERE expense_id = ? AND LOWER(status) NOT IN ('reversed','rejected','cancelled','canceled','voided') FOR UPDATE",
|
||||
[$id]
|
||||
@@ -338,6 +339,7 @@ class ExpenseController extends BaseController
|
||||
if (!$expense) {
|
||||
throw PageNotFoundException::forPageNotFound("Expense #$id not found");
|
||||
}
|
||||
$this->assertSchoolYearNameWritable((string)($expense['school_year'] ?? ''));
|
||||
|
||||
// Base rules
|
||||
$rules = [
|
||||
|
||||
@@ -254,7 +254,41 @@ class FamilyAdminController extends BaseController
|
||||
LIMIT 1",
|
||||
[$studentId]
|
||||
)->getRowArray();
|
||||
if (!empty($row['id'])) $familyId = (int) $row['id'];
|
||||
if (!empty($row['id'])) {
|
||||
$familyId = (int) $row['id'];
|
||||
} else {
|
||||
// Legacy/newly-created students can have students.parent_id set
|
||||
// before the normalized family_students row is created.
|
||||
$row = $db->query(
|
||||
"SELECT f.id
|
||||
FROM students s
|
||||
JOIN family_guardians fg ON fg.user_id = s.parent_id
|
||||
JOIN families f ON f.id = fg.family_id
|
||||
WHERE s.id = ?
|
||||
ORDER BY fg.is_primary DESC, f.household_name
|
||||
LIMIT 1",
|
||||
[$studentId]
|
||||
)->getRowArray();
|
||||
|
||||
if (!empty($row['id'])) {
|
||||
$familyId = (int) $row['id'];
|
||||
} else {
|
||||
$row = $db->query(
|
||||
"SELECT f.id
|
||||
FROM students s
|
||||
JOIN families f ON f.family_code = CONCAT('FAM-', s.parent_id)
|
||||
WHERE s.id = ?
|
||||
AND s.parent_id IS NOT NULL
|
||||
ORDER BY f.household_name
|
||||
LIMIT 1",
|
||||
[$studentId]
|
||||
)->getRowArray();
|
||||
|
||||
if (!empty($row['id'])) {
|
||||
$familyId = (int) $row['id'];
|
||||
}
|
||||
}
|
||||
}
|
||||
} elseif ($guardianId) {
|
||||
// 1) Try via guardians link
|
||||
$row = $db->query(
|
||||
@@ -330,6 +364,24 @@ class FamilyAdminController extends BaseController
|
||||
ORDER BY s.lastname, s.firstname",
|
||||
[$familyId]
|
||||
)->getResultArray();
|
||||
|
||||
if ($studentId > 0) {
|
||||
$studentIds = array_map(static fn(array $row): int => (int) ($row['id'] ?? 0), $studentsRows);
|
||||
if (!in_array($studentId, $studentIds, true)) {
|
||||
$selectedStudent = $db->query(
|
||||
"SELECT id, firstname, lastname
|
||||
FROM students
|
||||
WHERE id = ?
|
||||
LIMIT 1",
|
||||
[$studentId]
|
||||
)->getRowArray();
|
||||
|
||||
if ($selectedStudent) {
|
||||
$studentsRows[] = $selectedStudent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($studentsRows)) {
|
||||
foreach ($studentsRows as &$sr) {
|
||||
$sid = (int) ($sr['id'] ?? 0);
|
||||
|
||||
@@ -24,6 +24,13 @@ class FlagController extends Controller
|
||||
helper(['url', 'form']);
|
||||
}
|
||||
|
||||
private function assertSchoolYearNameWritable(string $schoolYear): void
|
||||
{
|
||||
service('schoolYearWriteGuard')->assertWritable(
|
||||
service('schoolYearContext')->forYearName($schoolYear)
|
||||
);
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$currentFlagModel = new CurrentFlagModel();
|
||||
@@ -436,6 +443,13 @@ class FlagController extends Controller
|
||||
return $this->index();
|
||||
}
|
||||
|
||||
$flagData = $currentFlagModel->find($id);
|
||||
if (!$flagData) {
|
||||
session()->setFlashdata('error', 'Incident not found.');
|
||||
return $this->index();
|
||||
}
|
||||
$this->assertSchoolYearNameWritable((string)($flagData['school_year'] ?? ''));
|
||||
|
||||
$update = ['flag_state' => $newState];
|
||||
if ($newState === 'Closed') {
|
||||
$update['updated_by_closed'] = $userId;
|
||||
@@ -490,6 +504,7 @@ class FlagController extends Controller
|
||||
session()->setFlashdata('error', 'Incident not found.');
|
||||
return redirect()->back();
|
||||
}
|
||||
$this->assertSchoolYearNameWritable((string)($flagData['school_year'] ?? ''));
|
||||
|
||||
// Proceed only if flag is not closed
|
||||
if ($flagData['flag_state'] !== 'Closed') {
|
||||
@@ -537,6 +552,7 @@ class FlagController extends Controller
|
||||
session()->setFlashdata('error', 'Incident not found.');
|
||||
return redirect()->back();
|
||||
}
|
||||
$this->assertSchoolYearNameWritable((string)($flagData['school_year'] ?? ''));
|
||||
|
||||
// Check if the flag is not already canceled
|
||||
if ($flagData['flag_state'] !== 'Canceled') {
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Models\StudentModel;
|
||||
use App\Models\StudentClassModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
use CodeIgniter\Controller;
|
||||
use CodeIgniter\Events\Events;
|
||||
use App\Models\HomeworkModel;
|
||||
use App\Models\QuizModel;
|
||||
@@ -35,7 +35,7 @@ use App\Services\NavbarService;
|
||||
//use App\Models\ScoreModel;
|
||||
|
||||
|
||||
class GradingController extends Controller
|
||||
class GradingController extends BaseController
|
||||
{
|
||||
protected $semesterScoreService;
|
||||
protected $db;
|
||||
@@ -1119,15 +1119,6 @@ public function belowSixty()
|
||||
]);
|
||||
}
|
||||
|
||||
private function currentSchoolYearName(?string $fallback = null): string
|
||||
{
|
||||
try {
|
||||
return service('schoolYearContext')->resolve($this->request)->yearName();
|
||||
} catch (\Throwable) {
|
||||
return trim((string) ($fallback ?? ''));
|
||||
}
|
||||
}
|
||||
|
||||
public function editBelowSixtyEmail()
|
||||
{
|
||||
$studentId = (int)$this->request->getGet('student_id');
|
||||
@@ -2329,7 +2320,8 @@ public function belowSixty()
|
||||
|
||||
public function belowSixtyDecisions()
|
||||
{
|
||||
$configuredYear = (string) $this->schoolYear;
|
||||
$schoolYearContext = $this->resolveSchoolYearContext();
|
||||
$configuredYear = $schoolYearContext->yearName();
|
||||
|
||||
$schoolYear = trim((string)($this->request->getGet('school_year') ?? ''));
|
||||
|
||||
@@ -2541,11 +2533,15 @@ public function belowSixty()
|
||||
'schoolYear' => $schoolYear,
|
||||
'schoolYears' => $schoolYears,
|
||||
'canViewGrading' => $canViewGrading,
|
||||
'isEditable' => ! $schoolYearContext->isReadonly(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function saveBelowSixtyDecision()
|
||||
{
|
||||
$schoolYearContext = $this->resolveSchoolYearContext();
|
||||
$this->assertSchoolYearWritable($schoolYearContext);
|
||||
|
||||
$studentId = (int)($this->request->getPost('student_id') ?? 0);
|
||||
$semester = strtolower(trim((string)($this->request->getPost('semester') ?? 'year')));
|
||||
$schoolYear = trim((string)($this->request->getPost('school_year') ?? ''));
|
||||
@@ -2556,6 +2552,10 @@ public function saveBelowSixtyDecision()
|
||||
return redirect()->back()->with('error', 'Missing student or school year.');
|
||||
}
|
||||
|
||||
if ($schoolYear !== $schoolYearContext->yearName()) {
|
||||
return redirect()->back()->with('error', 'Selected school year does not match the submitted decision.');
|
||||
}
|
||||
|
||||
// This decision page should feed certificate decisions as whole-year decisions.
|
||||
// Force year mode here so certificate logic receives final year decision.
|
||||
$semester = 'year';
|
||||
|
||||
@@ -81,7 +81,8 @@ class InvoiceController extends ResourceController
|
||||
$this->gradeFee = $this->configModel->getConfig('grade_fee');
|
||||
$this->schoolYear = $this->currentSchoolYearName();
|
||||
$this->semester = $this->configModel->getConfig('semester');
|
||||
$this->dueDate = $this->configModel->getConfig('due_date');
|
||||
$this->dueDate = $this->configModel->getConfig('first_day_of_school')
|
||||
?: $this->configModel->getConfig('due_date');
|
||||
$this->firstStudentFee = (float) ($this->configModel->getConfig('first_student_fee') ?? 350);
|
||||
$this->secondStudentFee = (float) ($this->configModel->getConfig('second_student_fee') ?? 200);
|
||||
$this->youthFee = (float) ($this->configModel->getConfig('youth_fee') ?? 200);
|
||||
@@ -230,6 +231,13 @@ class InvoiceController extends ResourceController
|
||||
}
|
||||
}
|
||||
|
||||
private function assertSchoolYearNameWritable(string $schoolYear): void
|
||||
{
|
||||
service('schoolYearWriteGuard')->assertWritable(
|
||||
service('schoolYearContext')->forYearName($schoolYear)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* API: Invoice management composite data (used by invoice_management view)
|
||||
* Returns the same structure previously rendered server-side in index().
|
||||
@@ -891,7 +899,7 @@ class InvoiceController extends ResourceController
|
||||
}
|
||||
}
|
||||
|
||||
$eventsList = $this->chargesModel->getChargesWithEventInfo($parentId, $schoolYear);
|
||||
$eventsList = $this->chargesModel->getChargesWithEventInfo($parentId, $schoolYear, $invoice['semester'] ?? null);
|
||||
|
||||
// Attach SCHOOL IDs
|
||||
$allKids = array_merge($registeredKids, $withdrawnKids);
|
||||
@@ -1073,18 +1081,40 @@ class InvoiceController extends ResourceController
|
||||
$pdf->SetFont('Arial', 'B', 12);
|
||||
$pdf->Cell(40, 6, 'Due Date:', 0, 0, 'L');
|
||||
$pdf->SetFont('Arial', '', 12);
|
||||
$dueLocal = null;
|
||||
$formatCalendarDate = static function ($raw): ?string {
|
||||
if ($raw instanceof \DateTimeInterface) {
|
||||
return $raw->format('m-d-Y');
|
||||
}
|
||||
|
||||
$value = trim((string)($raw ?? ''));
|
||||
if ($value === '' || preg_match('/^0{4}-0{2}-0{2}/', $value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (preg_match('/^(\d{4})-(\d{2})-(\d{2})/', $value, $matches)) {
|
||||
return $matches[2] . '-' . $matches[3] . '-' . $matches[1];
|
||||
}
|
||||
|
||||
if (preg_match('/^(\d{1,2})[-\/](\d{1,2})[-\/](\d{4})/', $value, $matches)) {
|
||||
return sprintf('%02d-%02d-%04d', (int)$matches[1], (int)$matches[2], (int)$matches[3]);
|
||||
}
|
||||
|
||||
$timestamp = strtotime($value);
|
||||
return $timestamp === false ? null : date('m-d-Y', $timestamp);
|
||||
};
|
||||
|
||||
$dueDisplay = $formatCalendarDate($invoice['due_date'] ?? null);
|
||||
try {
|
||||
$tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
|
||||
if (!empty($invoice['due_date'])) {
|
||||
$dueLocal = (new \DateTimeImmutable($invoice['due_date'], new \DateTimeZone('UTC')))
|
||||
->setTimezone(new \DateTimeZone($tzName));
|
||||
} elseif (!empty($invoice['created_at'])) {
|
||||
if ($dueDisplay === null && !empty($invoice['created_at'])) {
|
||||
$dueLocal = new \DateTimeImmutable($invoice['created_at'], new \DateTimeZone($tzName));
|
||||
$dueDisplay = $dueLocal->format('m-d-Y');
|
||||
}
|
||||
} catch (\Throwable $e) {}
|
||||
if (!$dueLocal) { $dueLocal = new \DateTimeImmutable('now', new \DateTimeZone($tzName ?? 'UTC')); }
|
||||
$pdf->Cell(0, 6, $dueLocal->format('m-d-Y'), 0, 1, 'L');
|
||||
if ($dueDisplay === null) {
|
||||
$dueDisplay = (new \DateTimeImmutable('now', new \DateTimeZone($tzName ?? 'UTC')))->format('m-d-Y');
|
||||
}
|
||||
$pdf->Cell(0, 6, $dueDisplay, 0, 1, 'L');
|
||||
|
||||
$pdf->Ln(5);
|
||||
$pdf->SetFont('Arial', '', 9);
|
||||
@@ -1145,16 +1175,111 @@ class InvoiceController extends ResourceController
|
||||
];
|
||||
};
|
||||
|
||||
// --- Frozen invoice charge lines. Do not rebuild issued charges from current enrollment/events.
|
||||
$studentById = [];
|
||||
foreach (($data['students'] ?? []) as $student) {
|
||||
$sid = (int)($student['student_id'] ?? 0);
|
||||
if ($sid <= 0) {
|
||||
continue;
|
||||
}
|
||||
$studentById[$sid] = trim((string)($student['student_firstname'] ?? '') . ' ' . (string)($student['student_lastname'] ?? ''));
|
||||
}
|
||||
|
||||
$studentTuitionRows = [];
|
||||
foreach (array_merge($registeredKids ?? [], $withdrawnKids ?? []) as $student) {
|
||||
$sid = (int)($student['student_id'] ?? 0);
|
||||
$charge = $studentCharges[$sid] ?? null;
|
||||
$amount = (float)($charge['unit_fee'] ?? 0.0);
|
||||
if ($sid <= 0 || abs($amount) < 0.00001) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = trim((string)($student['student_firstname'] ?? '') . ' ' . (string)($student['student_lastname'] ?? ''));
|
||||
$grade = trim((string)($student['grade'] ?? ''));
|
||||
$desc = 'Tuition - ' . ($name !== '' ? $name : 'Student #' . $sid);
|
||||
if ($grade !== '' && strtoupper($grade) !== 'N/A') {
|
||||
$desc .= ' (' . $grade . ')';
|
||||
}
|
||||
|
||||
$studentTuitionRows[] = [
|
||||
'description' => $desc,
|
||||
'amount' => $amount,
|
||||
];
|
||||
}
|
||||
|
||||
$eventRows = [];
|
||||
foreach (($events ?? []) as $event) {
|
||||
$amount = (float)($event['charged'] ?? 0.0);
|
||||
if (abs($amount) < 0.00001) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sid = (int)($event['student_id'] ?? 0);
|
||||
$studentName = $sid > 0 ? ($studentById[$sid] ?? '') : '';
|
||||
if ($studentName === '') {
|
||||
$studentName = trim((string)($event['external_firstname'] ?? '') . ' ' . (string)($event['external_lastname'] ?? ''));
|
||||
}
|
||||
|
||||
$desc = trim((string)($event['event_name'] ?? 'Event charge'));
|
||||
if ($studentName !== '') {
|
||||
$desc .= ' - ' . $studentName;
|
||||
}
|
||||
|
||||
$eventRows[] = [
|
||||
'description' => $desc,
|
||||
'amount' => $amount,
|
||||
];
|
||||
}
|
||||
|
||||
// --- Frozen invoice charge lines remain authoritative for totals.
|
||||
// Aggregate tuition/event lines are expanded for display when invoice details are available.
|
||||
foreach (($data['invoiceLines'] ?? []) as $line) {
|
||||
$dt = $toLocal($line['created_at'] ?? ($invoice['created_at'] ?? null), true);
|
||||
$amount = ((int)($line['line_amount_cents'] ?? 0)) / 100;
|
||||
$type = (string)($line['line_type'] ?? 'other');
|
||||
$category = str_contains($type, 'event') ? 'event'
|
||||
: (str_contains($type, 'additional') ? 'additional' : 'registration');
|
||||
|
||||
if (!str_contains($type, 'additional') && !str_contains($type, 'event') && !empty($studentTuitionRows)) {
|
||||
$expandedTotal = 0.0;
|
||||
foreach ($studentTuitionRows as $row) {
|
||||
$expandedTotal += (float)$row['amount'];
|
||||
$push($dt, $row['description'], (float)$row['amount'], 'registration');
|
||||
}
|
||||
|
||||
$delta = round($amount - $expandedTotal, 2);
|
||||
if (abs($delta) >= 0.01) {
|
||||
$push($dt, 'Tuition charges adjustment', $delta, 'registration');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (str_contains($type, 'event') && !empty($eventRows)) {
|
||||
$expandedTotal = 0.0;
|
||||
foreach ($eventRows as $row) {
|
||||
$expandedTotal += (float)$row['amount'];
|
||||
$push($dt, $row['description'], (float)$row['amount'], 'event');
|
||||
}
|
||||
|
||||
$delta = round($amount - $expandedTotal, 2);
|
||||
if (abs($delta) >= 0.01) {
|
||||
$push($dt, 'Event charges adjustment', $delta, 'event');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
$push($dt, (string)($line['description'] ?? 'Invoice line'), $amount, $category);
|
||||
}
|
||||
|
||||
if (empty($data['invoiceLines'] ?? [])) {
|
||||
$fallbackDt = $toLocal($invoice['created_at'] ?? ($invoice['issue_date'] ?? null), true);
|
||||
foreach ($studentTuitionRows as $row) {
|
||||
$push($fallbackDt, $row['description'], (float)$row['amount'], 'registration');
|
||||
}
|
||||
foreach ($eventRows as $row) {
|
||||
$push($fallbackDt, $row['description'], (float)$row['amount'], 'event');
|
||||
}
|
||||
}
|
||||
|
||||
// --- Payments (negative) — stored in local time
|
||||
foreach ($payments as $payment) {
|
||||
$dt = $toLocal($payment['payment_date'] ?? null, false /* local */);
|
||||
@@ -1513,6 +1638,12 @@ private function getGradeLevel($grade): array
|
||||
// API: Update invoice status
|
||||
public function updateStatusAPI($invoiceId)
|
||||
{
|
||||
$invoice = $this->invoiceModel->find($invoiceId);
|
||||
if (!$invoice) {
|
||||
return $this->failNotFound('Invoice not found.');
|
||||
}
|
||||
$this->assertSchoolYearNameWritable((string)($invoice['school_year'] ?? ''));
|
||||
|
||||
$status = $this->request->getPost('status');
|
||||
if ($this->invoiceModel->updateInvoiceStatus($invoiceId, $status)) {
|
||||
return $this->respond(['status' => 'success']);
|
||||
@@ -1524,6 +1655,12 @@ private function getGradeLevel($grade): array
|
||||
// View: Update invoice status
|
||||
public function updateStatus($invoiceId)
|
||||
{
|
||||
$invoice = $this->invoiceModel->find($invoiceId);
|
||||
if (!$invoice) {
|
||||
return redirect()->back()->with('error', 'Invoice not found.');
|
||||
}
|
||||
$this->assertSchoolYearNameWritable((string)($invoice['school_year'] ?? ''));
|
||||
|
||||
$status = $this->request->getPost('status');
|
||||
if ($this->invoiceModel->updateInvoiceStatus($invoiceId, $status)) {
|
||||
return redirect()->to('/invoices');
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -151,6 +151,13 @@ class PaymentController extends ResourceController
|
||||
}
|
||||
}
|
||||
|
||||
private function assertSchoolYearNameWritable(string $schoolYear): void
|
||||
{
|
||||
service('schoolYearWriteGuard')->assertWritable(
|
||||
service('schoolYearContext')->forYearName($schoolYear)
|
||||
);
|
||||
}
|
||||
|
||||
// View: Create a new payment plan
|
||||
public function create()
|
||||
{
|
||||
@@ -160,6 +167,12 @@ class PaymentController extends ResourceController
|
||||
// API: Update balance after payment
|
||||
public function updateBalanceAPI($paymentId)
|
||||
{
|
||||
$payment = $this->paymentModel->find($paymentId);
|
||||
if (!$payment) {
|
||||
return $this->failNotFound('Payment not found.');
|
||||
}
|
||||
$this->assertSchoolYearNameWritable((string)($payment['school_year'] ?? ''));
|
||||
|
||||
$amountPaid = $this->request->getPost('paid_amount');
|
||||
if ($this->paymentModel->updateBalance($paymentId, $amountPaid)) {
|
||||
return $this->respond(['status' => 'success']);
|
||||
@@ -171,6 +184,12 @@ class PaymentController extends ResourceController
|
||||
// View: Update balance after payment
|
||||
public function updateBalance($paymentId)
|
||||
{
|
||||
$payment = $this->paymentModel->find($paymentId);
|
||||
if (!$payment) {
|
||||
return redirect()->back()->with('error', 'Payment not found.');
|
||||
}
|
||||
$this->assertSchoolYearNameWritable((string)($payment['school_year'] ?? ''));
|
||||
|
||||
$amountPaid = $this->request->getPost('paid_amount');
|
||||
if ($this->paymentModel->updateBalance($paymentId, $amountPaid)) {
|
||||
return redirect()->to('/payments');
|
||||
@@ -879,6 +898,7 @@ class PaymentController extends ResourceController
|
||||
if (!$invoice) {
|
||||
return redirect()->back()->with('error', 'Invoice not found.');
|
||||
}
|
||||
$this->assertSchoolYearNameWritable((string)($invoice['school_year'] ?? ''));
|
||||
|
||||
// Snapshot pre-payment balance
|
||||
$initialPreBalance = $this->getCurrentInvoiceBalance($invoiceId);
|
||||
@@ -1142,6 +1162,7 @@ class PaymentController extends ResourceController
|
||||
if (!$invoice) {
|
||||
return redirect()->back()->with('error', 'Linked invoice not found.');
|
||||
}
|
||||
$this->assertSchoolYearNameWritable((string)($invoice['school_year'] ?? ($payment['school_year'] ?? '')));
|
||||
|
||||
//$schoolYear = (new ConfigurationModel())->getConfig('school_year');
|
||||
$checkFile = $payment['check_file']; // default keep old file
|
||||
@@ -1280,6 +1301,7 @@ class PaymentController extends ResourceController
|
||||
}
|
||||
|
||||
$schoolYear = (string)($invoice['school_year'] ?? $this->schoolYear);
|
||||
$this->assertSchoolYearNameWritable($schoolYear);
|
||||
$this->recalculateInvoice((int)$invoice['id'], $schoolYear);
|
||||
|
||||
return redirect()->back()->with('success', 'Invoice recalculated.');
|
||||
|
||||
@@ -488,6 +488,7 @@ class ReimbursementController extends BaseController
|
||||
'error' => 'Expense not found.',
|
||||
]);
|
||||
}
|
||||
$this->assertSchoolYearNameWritable((string)($expense['school_year'] ?? ''));
|
||||
|
||||
if (!empty($expense['reimbursement_id'])) {
|
||||
return $this->response->setStatusCode(409)->setJSON([
|
||||
@@ -655,6 +656,12 @@ public function updateBatchAssignment()
|
||||
$this->db->transBegin();
|
||||
|
||||
try {
|
||||
$expense = $this->expenseModel->find($expenseId);
|
||||
if (!$expense) {
|
||||
throw new \RuntimeException('Expense not found.');
|
||||
}
|
||||
$this->assertSchoolYearNameWritable((string)($expense['school_year'] ?? ''));
|
||||
|
||||
$activeItem = $this->batchItemModel
|
||||
->where('expense_id', $expenseId)
|
||||
->where('unassigned_at IS NULL', null, false)
|
||||
@@ -698,6 +705,7 @@ public function updateBatchAssignment()
|
||||
'error' => 'Batch not found or already closed.',
|
||||
]);
|
||||
}
|
||||
$this->assertSchoolYearNameWritable((string)($batch['school_year'] ?? ''));
|
||||
|
||||
if (!$reimbursementId) {
|
||||
if ($activeItem && !empty($activeItem['reimbursement_id'])) {
|
||||
@@ -847,6 +855,7 @@ public function updateBatchAssignment()
|
||||
'error' => 'Batch not found or already closed.',
|
||||
]);
|
||||
}
|
||||
$this->assertSchoolYearNameWritable((string)($batch['school_year'] ?? ''));
|
||||
|
||||
$adminRows = $this->batchItemModel
|
||||
->select('DISTINCT COALESCE(admin_id, 0) AS admin_id')
|
||||
@@ -1038,6 +1047,7 @@ public function updateBatchAssignment()
|
||||
'error' => 'Batch not found.',
|
||||
]);
|
||||
}
|
||||
$this->assertSchoolYearNameWritable((string)($batch['school_year'] ?? ''));
|
||||
if (strtolower((string) ($batch['status'] ?? '')) !== 'open') {
|
||||
return $this->response->setStatusCode(409)->setJSON([
|
||||
'success' => false,
|
||||
@@ -1618,6 +1628,7 @@ public function updateBatchAssignment()
|
||||
'error' => 'Requested batch was not found.',
|
||||
]);
|
||||
}
|
||||
$this->assertSchoolYearNameWritable((string)($batch['school_year'] ?? ''));
|
||||
|
||||
$receiptRows = !empty($receiptIds) ? $this->fetchBatchReceiptRows($batchId, $receiptIds) : [];
|
||||
|
||||
@@ -2108,6 +2119,7 @@ public function updateBatchAssignment()
|
||||
if (!$reimb) {
|
||||
throw PageNotFoundException::forPageNotFound("Reimbursement #$id not found");
|
||||
}
|
||||
$this->assertSchoolYearNameWritable((string)($reimb['school_year'] ?? ''));
|
||||
if ($this->isPaidReimbursement($reimb)) {
|
||||
return redirect()->to('/reimbursements')->with('error', 'Paid reimbursements are immutable. Reverse and replace the transaction instead.');
|
||||
}
|
||||
@@ -2207,6 +2219,7 @@ public function updateBatchAssignment()
|
||||
if (!$reimbursement) {
|
||||
throw new \RuntimeException('Reimbursement not found.');
|
||||
}
|
||||
$this->assertSchoolYearNameWritable((string)($reimbursement['school_year'] ?? ''));
|
||||
if (FinancialStatus::normalizeReimbursementStatus($reimbursement['status'] ?? null) !== FinancialStatus::REIMBURSEMENT_PAID) {
|
||||
throw new \RuntimeException('Only paid reimbursements can be reversed.');
|
||||
}
|
||||
@@ -2277,6 +2290,7 @@ public function updateBatchAssignment()
|
||||
if (!$expense) {
|
||||
throw new \RuntimeException('Expense not found.');
|
||||
}
|
||||
$this->assertSchoolYearNameWritable((string)($expense['school_year'] ?? ''));
|
||||
if (FinancialStatus::normalize((string) ($expense['status'] ?? '')) !== 'approved') {
|
||||
throw new \RuntimeException('Expense must be approved before reimbursement.');
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user