move service logic from grading and administrator controller
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 47s
Tests / PHPUnit (push) Successful in 1m16s

This commit is contained in:
root
2026-08-20 16:59:51 -04:00
parent 383bbb7269
commit 2b0206e7f2
12 changed files with 8421 additions and 6983 deletions
+163
View File
@@ -329,4 +329,167 @@ class Services extends BaseService
new \App\Libraries\InvoiceLedgerService()
);
}
public static function enrollmentWithdrawal(bool $getShared = true): \App\Services\EnrollmentWithdrawalService
{
if ($getShared) {
return static::getSharedInstance('enrollmentWithdrawal');
}
$db = \Config\Database::connect();
return new \App\Services\EnrollmentWithdrawalService(
$db,
model(\App\Models\StudentModel::class),
model(\App\Models\EnrollmentModel::class),
model(\App\Models\StudentClassModel::class),
model(\App\Models\ClassSectionModel::class),
model(\App\Models\UserModel::class),
model(\App\Models\InvoiceModel::class),
model(\App\Models\RefundModel::class)
);
}
public static function teacherSubmissionReport(bool $getShared = true): \App\Services\TeacherSubmissionReportService
{
if ($getShared) {
return static::getSharedInstance('teacherSubmissionReport');
}
return new \App\Services\TeacherSubmissionReportService(
\Config\Database::connect(),
model(\App\Models\ConfigurationModel::class),
model(\App\Models\StudentClassModel::class),
model(\App\Models\ClassSectionModel::class),
model(\App\Models\UserModel::class)
);
}
public static function administratorDashboard(bool $getShared = true): \App\Services\AdministratorDashboardService
{
if ($getShared) {
return static::getSharedInstance('administratorDashboard');
}
return new \App\Services\AdministratorDashboardService(
\Config\Database::connect(),
model(\App\Models\UserModel::class),
model(\App\Models\LoginActivityModel::class)
);
}
public static function adminNotificationSettings(bool $getShared = true): \App\Services\AdminNotificationSettingsService
{
if ($getShared) {
return static::getSharedInstance('adminNotificationSettings');
}
return new \App\Services\AdminNotificationSettingsService(
\Config\Database::connect(),
model(\App\Models\AdminNotificationSubjectModel::class)
);
}
public static function administratorDirectory(bool $getShared = true): \App\Services\AdministratorDirectoryService
{
if ($getShared) {
return static::getSharedInstance('administratorDirectory');
}
return new \App\Services\AdministratorDirectoryService(
\Config\Database::connect(),
model(\App\Models\StudentClassModel::class),
model(\App\Models\UserModel::class),
model(\App\Models\UserRoleModel::class),
model(\App\Models\InvoiceModel::class),
model(\App\Models\StudentModel::class)
);
}
public static function gradingScore(bool $getShared = true): \App\Services\GradingScoreService
{
if ($getShared) {
return static::getSharedInstance('gradingScore');
}
$configModel = model(\App\Models\ConfigurationModel::class);
$attendanceCalculator = new \App\Services\Calculators\AttendanceCalculator(
model(\App\Models\AttendanceRecordModel::class),
$configModel,
model(\App\Models\CalendarModel::class)
);
return new \App\Services\GradingScoreService(
\Config\Database::connect(),
$configModel,
model(\App\Models\HomeworkModel::class),
model(\App\Models\UserModel::class),
model(\App\Models\StudentClassModel::class),
model(\App\Models\StudentModel::class),
model(\App\Models\TeacherClassModel::class),
model(\App\Models\ClassSectionModel::class),
$attendanceCalculator,
model(\App\Models\GradingLockModel::class),
static::semesterScoreService(),
(string) $configModel->getConfig('school_year'),
(string) getSemester()
);
}
public static function placementGrading(bool $getShared = true): \App\Services\PlacementGradingService
{
if ($getShared) {
return static::getSharedInstance('placementGrading');
}
$configModel = model(\App\Models\ConfigurationModel::class);
return new \App\Services\PlacementGradingService(
\Config\Database::connect(),
model(\App\Models\StudentModel::class),
model(\App\Models\PlacementLevelModel::class),
model(\App\Models\PlacementBatchModel::class),
model(\App\Models\PlacementScoreModel::class),
(string) $configModel->getConfig('school_year')
);
}
public static function belowSixty(bool $getShared = true): \App\Services\BelowSixtyService
{
if ($getShared) {
return static::getSharedInstance('belowSixty');
}
$configModel = model(\App\Models\ConfigurationModel::class);
return new \App\Services\BelowSixtyService(
\Config\Database::connect(),
$configModel,
model(\App\Models\StudentModel::class),
model(\App\Models\StudentClassModel::class),
model(\App\Models\ParentMeetingScheduleModel::class),
model(\App\Models\UserModel::class),
(string) $configModel->getConfig('school_year'),
(string) getSemester()
);
}
public static function studentDecision(bool $getShared = true): \App\Services\StudentDecisionService
{
if ($getShared) {
return static::getSharedInstance('studentDecision');
}
$configModel = model(\App\Models\ConfigurationModel::class);
return new \App\Services\StudentDecisionService(
\Config\Database::connect(),
$configModel,
model(\App\Models\StudentModel::class),
model(\App\Models\StudentClassModel::class),
model(\App\Models\UserModel::class),
(string) $configModel->getConfig('school_year'),
(string) getSemester()
);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,280 @@
<?php
namespace App\Services;
use App\Models\AdminNotificationSubjectModel;
class AdminNotificationSettingsService
{
protected $db;
protected $adminNotificationSubjectModel;
public function __construct(
\CodeIgniter\Database\BaseConnection $db,
AdminNotificationSubjectModel $adminNotificationSubjectModel
) {
$this->db = $db;
$this->adminNotificationSubjectModel = $adminNotificationSubjectModel;
}
public function alertsPage(): array
{
$admins = $this->eligibleUsers();
$subjects = $this->subjectOptions();
$assignedSubjects = [];
$tableReady = $this->db->tableExists('admin_notification_subjects');
if ($tableReady && !empty($admins)) {
$adminIds = array_map('intval', array_column($admins, 'id'));
$rows = $this->adminNotificationSubjectModel
->select('id, admin_id, subject')
->whereIn('admin_id', $adminIds)
->findAll();
foreach ($rows as $row) {
$adminId = (int) ($row['admin_id'] ?? 0);
$subject = (string) ($row['subject'] ?? '');
if ($adminId <= 0 || $subject === '') {
continue;
}
if (!isset($assignedSubjects[$adminId])) {
$assignedSubjects[$adminId] = [];
}
$assignedSubjects[$adminId][$subject] = true;
}
}
return [
'admins' => $admins,
'subjects' => $subjects,
'assignedSubjects' => $assignedSubjects,
'tableReady' => $tableReady,
];
}
public function saveSubjects($posted): array
{
if (!$this->db->tableExists('admin_notification_subjects')) {
return ['type' => 'error', 'message' => 'Notification subject storage is missing. Run migrations first.'];
}
if (!is_array($posted)) {
return ['type' => 'error', 'message' => 'No selections were submitted.'];
}
$subjects = $this->subjectOptions();
$allowed = array_keys($subjects);
$admins = $this->eligibleUsers();
$adminIds = array_map('intval', array_column($admins, 'id'));
if (empty($adminIds)) {
return ['type' => 'info', 'message' => 'No admins found to update.'];
}
$existing = $this->adminNotificationSubjectModel
->select('id, admin_id, subject')
->whereIn('admin_id', $adminIds)
->findAll();
$existingMap = [];
foreach ($existing as $row) {
$adminId = (int) ($row['admin_id'] ?? 0);
$subject = (string) ($row['subject'] ?? '');
if ($adminId <= 0 || $subject === '') {
continue;
}
if (!isset($existingMap[$adminId])) {
$existingMap[$adminId] = [];
}
$existingMap[$adminId][$subject] = (int) ($row['id'] ?? 0);
}
$updates = 0;
foreach ($adminIds as $adminId) {
$subjectRaw = $posted[$adminId] ?? [];
$selected = [];
if (is_array($subjectRaw)) {
foreach ($subjectRaw as $key => $value) {
$candidate = is_string($key) ? $key : $value;
if (!is_string($candidate)) {
continue;
}
$candidate = trim($candidate);
if ($candidate !== '') {
$selected[] = $candidate;
}
}
}
$selected = array_values(array_unique(array_filter($selected, function ($value) use ($allowed) {
return in_array($value, $allowed, true);
})));
$current = array_keys($existingMap[$adminId] ?? []);
$toDelete = array_values(array_diff($current, $selected));
$toInsert = array_values(array_diff($selected, $current));
if (!empty($toDelete)) {
$this->adminNotificationSubjectModel
->where('admin_id', $adminId)
->whereIn('subject', $toDelete)
->delete();
$updates += count($toDelete);
}
if (!empty($toInsert)) {
$batch = [];
foreach ($toInsert as $subject) {
$batch[] = [
'admin_id' => $adminId,
'subject' => $subject,
];
}
$this->adminNotificationSubjectModel->insertBatch($batch);
$updates += count($toInsert);
}
}
return ['type' => 'success', 'message' => $updates > 0 ? 'Notification subjects updated.' : 'No changes were made.'];
}
public function printRecipientsPage(): array
{
$admins = $this->eligibleUsers();
$tableReady = $this->db->tableExists('admin_notification_subjects');
$assigned = [];
if ($tableReady && !empty($admins)) {
$adminIds = array_map('intval', array_column($admins, 'id'));
$rows = $this->adminNotificationSubjectModel
->select('admin_id')
->where('subject', 'print_requests')
->whereIn('admin_id', $adminIds)
->findAll();
foreach ($rows as $row) {
$adminId = (int) ($row['admin_id'] ?? 0);
if ($adminId <= 0) {
continue;
}
$assigned[$adminId] = true;
}
}
return [
'admins' => $admins,
'assigned' => $assigned,
'tableReady' => $tableReady,
];
}
public function savePrintRecipients(array $posted): array
{
if (!$this->db->tableExists('admin_notification_subjects')) {
return ['type' => 'error', 'message' => 'Notification subject storage is missing. Run migrations first.'];
}
$admins = $this->eligibleUsers();
$adminIds = array_map('intval', array_column($admins, 'id'));
if (empty($adminIds)) {
return ['type' => 'info', 'message' => 'No admins found to update.'];
}
$selected = [];
foreach ($posted as $key => $value) {
$adminId = (int) $key;
if ($adminId <= 0) {
continue;
}
if (!in_array($adminId, $adminIds, true)) {
continue;
}
$selected[] = $adminId;
}
$selected = array_values(array_unique($selected));
$existingRows = $this->adminNotificationSubjectModel
->select('admin_id')
->where('subject', 'print_requests')
->whereIn('admin_id', $adminIds)
->findAll();
$current = array_values(array_unique(array_map(
fn($row) => (int) ($row['admin_id'] ?? 0),
$existingRows
)));
$toDelete = array_values(array_diff($current, $selected));
$toInsert = array_values(array_diff($selected, $current));
if (!empty($toDelete)) {
$this->adminNotificationSubjectModel
->where('subject', 'print_requests')
->whereIn('admin_id', $toDelete)
->delete();
}
if (!empty($toInsert)) {
$batch = [];
foreach ($toInsert as $adminId) {
$batch[] = [
'admin_id' => $adminId,
'subject' => 'print_requests',
];
}
$this->adminNotificationSubjectModel->insertBatch($batch);
}
$changes = count($toDelete) + count($toInsert);
return ['type' => 'success', 'message' => $changes > 0 ? 'Print notification recipients updated.' : 'No changes were made.'];
}
public function subjectOptions(): array
{
return [
'academics' => 'Academics',
'attendance' => 'Attendance',
'events' => 'Events',
'finance' => 'Finance',
'general' => 'General',
'print_requests' => 'Print Requests',
];
}
public function excludedRoles(): array
{
return [
'parent',
'student',
'guest',
'teacher',
'assistant teacher',
'teacher assistant',
'teacher_assistant',
'assistant_teacher',
'ta',
'authorized_user',
];
}
public function eligibleUsers(): array
{
$excluded = $this->excludedRoles();
$excludedList = "'" . implode("','", $excluded) . "'";
return $this->db->table('users u')
->select('u.id, u.firstname, u.lastname, u.email')
->join('user_roles ur', 'ur.user_id = u.id', 'inner')
->join('roles r', 'r.id = ur.role_id', 'inner')
->where('r.name IS NOT NULL', null, false)
->where('ur.deleted_at', null)
->where("LOWER(r.name) NOT IN ({$excludedList})", null, false)
->groupBy('u.id, u.firstname, u.lastname, u.email')
->orderBy('u.lastname', 'ASC')
->orderBy('u.firstname', 'ASC')
->get()
->getResultArray();
}
}
@@ -0,0 +1,250 @@
<?php
namespace App\Services;
use App\Models\LoginActivityModel;
use App\Models\UserModel;
class AdministratorDashboardService
{
protected $db;
protected $userModel;
protected $loginActivityModel;
protected string $schoolYear = '';
protected string $semester = '';
public function __construct(
\CodeIgniter\Database\BaseConnection $db,
UserModel $userModel,
LoginActivityModel $loginActivityModel
) {
$this->db = $db;
$this->userModel = $userModel;
$this->loginActivityModel = $loginActivityModel;
}
public function metrics(string $schoolYear, string $semester): array
{
$this->schoolYear = $schoolYear;
$this->semester = $semester;
$recentActivities = $this->loginActivityModel->getLastActivities(4);
if (!is_array($recentActivities)) {
$recentActivities = [];
}
$totalAdmins = (int) ($this->userModel->countAdminsBySchoolYear($this->schoolYear) ?? 0);
$teachers = $this->userModel->getUsersByRoleAndSchoolYear('teacher', $this->schoolYear);
$totalTeachers = $this->countUniqueEntities($teachers);
$teacherAssistants = $this->userModel->getUsersByRoleAndSchoolYear('teacher_assistant', $this->schoolYear);
$totalTeacherAssistants = $this->countUniqueEntities($teacherAssistants);
$parents = $this->userModel->getUsersByRoleAndSchoolYear('parent', $this->schoolYear);
$totalParents = $this->countUniqueEntities($parents);
// Count only students that have a class assigned and exist in student_class for the current school year
$totalStudents = (int) (
$this->db->table('student_class')
->select('COUNT(DISTINCT student_class.student_id) AS cnt')
->join('students', 'students.id = student_class.student_id', 'inner')
->where('student_class.school_year', $this->schoolYear)
->where('student_class.class_section_id IS NOT NULL', null, false)
->where('students.is_active', 1)
->get()
->getRow('cnt')
?? 0
);
return [
'counts' => [
'students' => $totalStudents,
'teachers' => $totalTeachers,
'teacherAssistants' => $totalTeacherAssistants,
'admins' => $totalAdmins,
'parents' => $totalParents,
],
'recentActivities' => array_map(static function ($activity) {
if (!is_array($activity)) {
return [];
}
return [
'login_time' => $activity['login_time'] ?? null,
'email' => $activity['email'] ?? null,
];
}, $recentActivities),
'meta' => [
'schoolYear' => $this->schoolYear,
'semester' => $this->semester,
],
];
}
private function countUniqueEntities($rows): int
{
if (!is_array($rows) || $rows === []) {
return 0;
}
$ids = [];
foreach ($rows as $row) {
if (!is_array($row)) {
continue;
}
if (isset($row['id'])) {
$ids[] = (int) $row['id'];
continue;
}
if (isset($row['user_id'])) {
$ids[] = (int) $row['user_id'];
}
}
return count(array_unique($ids));
}
public function search(string $query): array
{
$q = trim($query);
if ($q === '') {
return [
'query' => '',
'results' => [],
'scope_used' => 'unscoped-raw',
'scope_label' => 'all years/semesters (raw)',
'total_found' => 0,
];
}
$db = $this->db;
// 1) Tokenize input: split by whitespace and punctuation, keep meaningful pieces
$rawTokens = preg_split('/[,\s]+/u', $q, -1, PREG_SPLIT_NO_EMPTY) ?: [];
$tokens = array_values(array_filter(array_map('trim', $rawTokens)));
// 2) Build phone variants for any token that looks numeric-ish
$phoneMap = []; // token => variants[]
foreach ($tokens as $t) {
$digits = preg_replace('/\D+/', '', $t);
if ($digits === '') {
continue;
}
$v = [];
if (strlen($digits) >= 7) {
// base forms
$v[] = $digits;
if (strlen($digits) === 10) {
$v[] = sprintf('(%s)-%s-%s', substr($digits, 0, 3), substr($digits, 3, 3), substr($digits, 6));
$v[] = sprintf('%s-%s-%s', substr($digits, 0, 3), substr($digits, 3, 3), substr($digits, 6));
$v[] = sprintf('%s %s %s', substr($digits, 0, 3), substr($digits, 3, 3), substr($digits, 6));
// country-code forms
$v[] = '1' . $digits;
$v[] = '+1' . $digits;
$v[] = '+1 ' . sprintf('(%s) %s-%s', substr($digits, 0, 3), substr($digits, 3, 3), substr($digits, 6));
$v[] = '+1-' . sprintf('%s-%s-%s', substr($digits, 0, 3), substr($digits, 3, 3), substr($digits, 6));
} elseif (strlen($digits) === 11 && str_starts_with($digits, '1')) {
$ten = substr($digits, 1);
$v[] = $ten;
$v[] = sprintf('(%s)-%s-%s', substr($ten, 0, 3), substr($ten, 3, 3), substr($ten, 6));
$v[] = sprintf('%s-%s-%s', substr($ten, 0, 3), substr($ten, 3, 3), substr($ten, 6));
$v[] = sprintf('%s %s %s', substr($ten, 0, 3), substr($ten, 3, 3), substr($ten, 6));
$v[] = '+1' . $ten;
$v[] = '+1 ' . sprintf('(%s) %s-%s', substr($ten, 0, 3), substr($ten, 3, 3), substr($ten, 6));
$v[] = '+1-' . sprintf('%s-%s-%s', substr($ten, 0, 3), substr($ten, 3, 3), substr($ten, 6));
} else {
// 7-9 digits: keep as-is (partial phone fragment)
$v[] = $digits;
}
}
if (!empty($v)) {
$phoneMap[$t] = array_values(array_unique($v));
}
}
// Helper: ( token1 AND token2 AND ... ), each token may match ANY of $columns,
// and phone variants are only applied to the provided $phoneCols for THIS table.
$applyMultiTokenLike = function ($qb, array $columns, array $tokens, array $phoneCols = []) use ($phoneMap) {
foreach ($tokens as $t) {
$qb->groupStart(); // OR across columns for this token
foreach ($columns as $i => $col) {
if ($i === 0) {
$qb->like($col, $t);
} else {
$qb->orLike($col, $t);
}
}
if (!empty($phoneMap[$t]) && !empty($phoneCols)) {
foreach ($phoneMap[$t] as $pv) {
foreach ($phoneCols as $pcol) {
$qb->orLike($pcol, $pv);
}
}
}
$qb->groupEnd();
}
return $qb;
};
// ===== RAW UNscoped searches (flat arrays) =====
// 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');
$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, rfid_tag');
$applyMultiTokenLike($sQB, $sCols, $tokens, []);
$students = $sQB->limit(150)->get()->getResultArray();
// PARENTS (phone col: secondparent_phone)
$pCols = ['secondparent_firstname', 'secondparent_lastname', 'secondparent_email', 'secondparent_phone'];
$pQB = $db->table('parents')
->select('id, firstparent_id, secondparent_firstname, secondparent_lastname, secondparent_email, secondparent_phone');
$applyMultiTokenLike($pQB, $pCols, $tokens, ['secondparent_phone']);
foreach ($tokens as $t) {
if (ctype_digit($t)) {
$pQB->orWhere('firstparent_id', (int) $t)->orWhere('id', (int) $t);
}
}
$parents = $pQB->limit(150)->get()->getResultArray();
// STAFF (phone col: phone)
$stCols = ['firstname', 'lastname', 'email', 'role_name', 'phone'];
$stQB = $db->table('staff')
->select('id, user_id, firstname, lastname, email, phone, role_name, active_role');
$applyMultiTokenLike($stQB, $stCols, $tokens, ['phone']);
$staff = $stQB->limit(150)->get()->getResultArray();
// EMERGENCY CONTACTS (phone col: cellphone)
$ecCols = ['emergency_contact_name', 'relation', 'email', 'cellphone'];
$ecQB = $db->table('emergency_contacts')
->select('id, parent_id, emergency_contact_name, relation, cellphone, email');
$applyMultiTokenLike($ecQB, $ecCols, $tokens, ['cellphone']);
$emergency = $ecQB->limit(150)->get()->getResultArray();
$raw = [
'users' => $users,
'students' => $students,
'parents' => $parents,
'staff' => $staff,
'emergency_contacts' => $emergency,
];
$total = count($users) + count($students) + count($parents) + count($staff) + count($emergency);
return [
'query' => $q,
'results' => $raw,
'scope_used' => 'unscoped-raw',
'scope_label' => 'all years/semesters (raw, tokenized)',
'total_found' => $total,
];
}
}
@@ -0,0 +1,224 @@
<?php
namespace App\Services;
use App\Models\InvoiceModel;
use App\Models\StudentClassModel;
use App\Models\StudentModel;
use App\Models\UserModel;
use App\Models\UserRoleModel;
use App\Support\Enrollment\EnrollmentEligibility;
class AdministratorDirectoryService
{
protected $db;
protected $studentClassModel;
protected $userModel;
protected $userRoleModel;
protected $invoiceModel;
protected $studentModel;
public function __construct(
\CodeIgniter\Database\BaseConnection $db,
StudentClassModel $studentClassModel,
UserModel $userModel,
UserRoleModel $userRoleModel,
InvoiceModel $invoiceModel,
StudentModel $studentModel
) {
$this->db = $db;
$this->studentClassModel = $studentClassModel;
$this->userModel = $userModel;
$this->userRoleModel = $userRoleModel;
$this->invoiceModel = $invoiceModel;
$this->studentModel = $studentModel;
}
public function studentProfiles(string $selectedYear): array
{
$db = db_connect();
$isPg = ($db->getPlatform() === 'Postgre'); // 'MySQLi', 'Postgre', 'SQLSRV', ...
// In MySQL, avoid truncation of long lists
if (!$isPg) {
$db->query('SET SESSION group_concat_max_len = 8192');
}
$b = $db->table('students');
if ($isPg) {
// Postgres: use subqueries for aggregates to avoid GROUP BY on students.*
$students = $b->select([
'students.*',
'users.firstname AS parent_firstname',
'users.lastname AS parent_lastname',
'users.email AS parent_email',
'users.cellphone AS parent_phone',
// Pick one emergency contact (first by id)
"(SELECT ec.emergency_contact_name FROM emergency_contacts ec
WHERE ec.parent_id = students.parent_id
ORDER BY ec.id ASC LIMIT 1) AS emergency_name",
"(SELECT ec.relation FROM emergency_contacts ec
WHERE ec.parent_id = students.parent_id
ORDER BY ec.id ASC LIMIT 1) AS emergency_relationship",
"(SELECT ec.cellphone FROM emergency_contacts ec
WHERE ec.parent_id = students.parent_id
ORDER BY ec.id ASC LIMIT 1) AS emergency_phone",
"(SELECT ec.email FROM emergency_contacts ec
WHERE ec.parent_id = students.parent_id
ORDER BY ec.id ASC LIMIT 1) AS emergency_email",
// Aggregated lists
"(SELECT STRING_AGG(DISTINCT sa.allergy, ', ' ORDER BY sa.allergy)
FROM student_allergies sa WHERE sa.student_id = students.id) AS allergies",
"(SELECT STRING_AGG(DISTINCT smc.condition_name, ', ' ORDER BY smc.condition_name)
FROM student_medical_conditions smc WHERE smc.student_id = students.id) AS medical_conditions",
])
->join('users', 'users.id = students.parent_id', 'left')
->orderBy('students.lastname', 'ASC')
->orderBy('students.firstname', 'ASC')
->get()
->getResultArray();
} else {
// MySQL: GROUP_CONCAT + MIN() for emergency_* to satisfy ONLY_FULL_GROUP_BY
$students = $b->select([
'students.*',
'users.firstname AS parent_firstname',
'users.lastname AS parent_lastname',
'users.email AS parent_email',
'users.cellphone AS parent_phone',
'MIN(emergency_contacts.emergency_contact_name) AS emergency_name',
'MIN(emergency_contacts.relation) AS emergency_relationship',
'MIN(emergency_contacts.cellphone) AS emergency_phone',
'MIN(emergency_contacts.email) AS emergency_email',
"GROUP_CONCAT(DISTINCT student_allergies.allergy
ORDER BY student_allergies.allergy SEPARATOR ', ') AS allergies",
"GROUP_CONCAT(DISTINCT student_medical_conditions.condition_name
ORDER BY student_medical_conditions.condition_name SEPARATOR ', ') AS medical_conditions",
])
->join('users', 'users.id = students.parent_id', 'left')
->join('emergency_contacts', 'emergency_contacts.parent_id = students.parent_id', 'left')
->join('student_allergies', 'student_allergies.student_id = students.id', 'left')
->join('student_medical_conditions', 'student_medical_conditions.student_id = students.id', 'left')
->groupBy('students.id')
->orderBy('students.lastname', 'ASC')
->orderBy('students.firstname', 'ASC')
->get()
->getResultArray();
}
$enrollmentStatusByStudentId = [];
$studentIds = array_values(array_unique(array_filter(array_map(
static fn (array $row): int => (int) ($row['id'] ?? 0),
$students
))));
if ($selectedYear !== '' && !empty($studentIds)) {
$enrollmentRows = $db->table('enrollments')
->select('student_id, enrollment_status')
->whereIn('student_id', $studentIds)
->where('school_year', $selectedYear)
->orderBy('student_id', 'ASC')
->orderBy('updated_at', 'DESC')
->orderBy('enrollment_date', 'DESC')
->orderBy('id', 'DESC')
->get()
->getResultArray();
foreach ($enrollmentRows as $enrollmentRow) {
$studentId = (int) ($enrollmentRow['student_id'] ?? 0);
if ($studentId > 0 && !isset($enrollmentStatusByStudentId[$studentId])) {
$enrollmentStatusByStudentId[$studentId] = (string) ($enrollmentRow['enrollment_status'] ?? '');
}
}
}
// === 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) {
$classSectionName = (string) ($this->studentClassModel->getClassSectionNameByStudentId($sid, $selectedYear) ?? '');
$students[$i]['class_section_name'] = $classSectionName;
$students[$i]['enrollment_status'] = $enrollmentStatusByStudentId[$sid] ?? '';
} else {
// Keep keys consistent even if id missing
$students[$i]['class_section_name'] = '';
$students[$i]['enrollment_status'] = '';
}
$studentYear = trim((string) ($row['school_year'] ?? ''));
$students[$i]['age'] = EnrollmentEligibility::ageOnSeptemberFirst(
$row['dob'] ?? null,
$studentYear !== '' ? $studentYear : $selectedYear
);
}
// === end injection ===
return [
'students' => $students,
'gradeOptions' => ['K', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'Youth'],
'genderOptions' => ['Male', 'Female', 'Other'],
'selectedYear' => $selectedYear,
];
}
public function parentProfiles(): array
{
$allUsers = $this->userModel->findAll();
$parents = [];
// Fetch roles for users in one query to avoid looping over all users
foreach ($allUsers as $user) {
// Get the roles of the user directly
$roles = $this->userRoleModel->getRolesByUserId($user['id']);
$isParent = false;
// Check if $roles is iterable and contains the 'parent' role
if (is_array($roles)) {
foreach ($roles as $role) {
if (isset($role['role_name']) && $role['role_name'] === 'parent') {
$isParent = true;
break;
}
}
}
if ($isParent) {
// Retrieve the latest paid amount and balance for this parent
$paidAmount = $this->invoiceModel->getLatestInvoicePaidAmount($user['id']) ?? 0;
$balance = $this->invoiceModel->getLatestInvoiceBalance($user['id']) ?? 0;
// Get students for this parent
$studentsData = $this->studentModel->where('parent_id', $user['id'])->findAll();
$students = [];
foreach ($studentsData as $student) {
$classSectionName = $this->studentClassModel->getClassSectionNameByStudentId($student['id']) ?? 'N/A';
$students[] = [
'name' => $student['firstname'] . ' ' . $student['lastname'],
'class_section' => $classSectionName
];
}
// Add parent data to the array
$parents[] = [
'id' => $user['id'],
'school_id' => $user['school_id'],
'firstname' => $user['firstname'],
'lastname' => $user['lastname'],
'email' => $user['email'],
'cellphone' => $user['cellphone'],
'gender' => $user['gender'],
'created_at' => $user['created_at'],
'paid_amount' => $paidAmount,
'balance' => $balance,
'students' => $students,
];
}
}
return ['parents' => $parents];
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+437
View File
@@ -0,0 +1,437 @@
<?php
namespace App\Services;
use App\Models\PlacementBatchModel;
use App\Models\PlacementLevelModel;
use App\Models\PlacementScoreModel;
use App\Models\StudentModel;
class PlacementGradingService
{
protected $db;
protected $studentModel;
protected $placementLevelModel;
protected $placementBatchModel;
protected $placementScoreModel;
protected string $schoolYear = '';
public function __construct(
\CodeIgniter\Database\BaseConnection $db,
StudentModel $studentModel,
PlacementLevelModel $placementLevelModel,
PlacementBatchModel $placementBatchModel,
PlacementScoreModel $placementScoreModel,
string $schoolYear = ''
) {
$this->db = $db;
$this->studentModel = $studentModel;
$this->placementLevelModel = $placementLevelModel;
$this->placementBatchModel = $placementBatchModel;
$this->placementScoreModel = $placementScoreModel;
$this->schoolYear = $schoolYear;
}
public function setSchoolYear(string $schoolYear): void
{
$this->schoolYear = $schoolYear;
}
public function updatePlacementLevel(array $params = [])
{
$get = $params['get'] ?? [];
$post = $params['post'] ?? [];
$studentId = (int) (($post['student_id'] ?? null) ?? 0);
$levelRaw = trim((string) (($post['placement_level'] ?? null) ?? ''));
$schoolYear = (string) (($post['school_year'] ?? null) ?? $this->schoolYear);
if ($studentId <= 0 || $schoolYear === '') {
return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'Missing student or school year.'];
}
$level = $levelRaw === '' ? null : (int) $levelRaw;
if ($level !== null && !in_array($level, [1, 2, 3], true)) {
return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'Invalid placement level.'];
}
$existing = $this->placementLevelModel
->where('student_id', $studentId)
->first();
if ($level === null) {
if ($existing) {
$this->placementLevelModel->delete($existing['id']);
}
return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'status', 'message' => 'Placement level cleared.'];
}
$payload = [
'student_id' => $studentId,
'level' => $level,
'updated_by' => session()->get('user_id'),
];
if ($existing) {
$this->placementLevelModel->update($existing['id'], $payload);
} else {
$payload['created_by'] = session()->get('user_id');
$this->placementLevelModel->insert($payload);
}
return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'status', 'message' => 'Placement level updated.'];
}
public function placementPage(array $params = [])
{
$get = $params['get'] ?? [];
$post = $params['post'] ?? [];
$classSectionId = (int) (($get['class_section_id'] ?? null) ?? 0);
$schoolYear = (string) (($get['school_year'] ?? null) ?? $this->schoolYear);
$placementTest = (string) (($get['placement_test'] ?? null) ?? '');
$openFlag = (string) (($get['open'] ?? null) ?? '');
if ($classSectionId <= 0 || $schoolYear === '') {
$showStudents = ($placementTest !== '' && $openFlag === '1');
$students = $showStudents ? $this->fetchActiveStudentsWithSection($schoolYear) : [];
$batches = $this->fetchPlacementBatches($schoolYear);
$batchDetails = $this->fetchPlacementBatchDetails($batches, $schoolYear);
return ['kind' => 'view', 'view' => 'grading/placement_index', 'data' => [
'schoolYear' => $schoolYear,
'students' => $students,
'batches' => $batches,
'batchDetails' => $batchDetails,
'placementTest' => $placementTest,
'showStudents' => $showStudents,
]];
}
$sectionName = $this->classSection->getClassSectionNameBySectionId($classSectionId) ?? '';
$classId = $this->classSection->getClassId($classSectionId);
$students = $this->studentModel->getStudentInfoByClassSectionId($classSectionId, null, $schoolYear);
$studentIds = array_values(array_filter(array_map(
static fn($row) => (int) ($row['student_id'] ?? 0),
$students
), static fn($id) => $id > 0));
$levels = [];
if (!empty($studentIds)) {
$rows = $this->placementLevelModel
->whereIn('student_id', $studentIds)
->findAll();
foreach ($rows as $row) {
$levels[(int) $row['student_id']] = $row['level'] ?? null;
}
}
return ['kind' => 'view', 'view' => 'grading/placement', 'data' => [
'classSectionId' => $classSectionId,
'classSectionName' => $sectionName,
'classId' => $classId,
'schoolYear' => $schoolYear,
'students' => $students,
'levels' => $levels,
]];
}
public function updatePlacementLevels(array $params = [])
{
$get = $params['get'] ?? [];
$post = $params['post'] ?? [];
$classSectionId = (int) (($post['class_section_id'] ?? null) ?? 0);
$schoolYear = (string) (($post['school_year'] ?? null) ?? $this->schoolYear);
$levels = ($post['placement_level'] ?? null) ?? [];
if ($classSectionId <= 0 || $schoolYear === '') {
return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'Missing class section or school year.'];
}
$students = $this->studentModel->getStudentInfoByClassSectionId($classSectionId, null, $schoolYear);
$validIds = array_values(array_filter(array_map(
static fn($row) => (int) ($row['student_id'] ?? 0),
$students
), static fn($id) => $id > 0));
$validSet = array_flip($validIds);
$existingRows = [];
if (!empty($validIds)) {
$rows = $this->placementLevelModel
->whereIn('student_id', $validIds)
->findAll();
foreach ($rows as $row) {
$existingRows[(int) $row['student_id']] = $row;
}
}
$userId = session()->get('user_id');
foreach ($levels as $studentIdRaw => $levelRaw) {
$studentId = (int) $studentIdRaw;
if (!isset($validSet[$studentId])) {
continue;
}
$levelRaw = trim((string) $levelRaw);
$level = $levelRaw === '' ? null : (int) $levelRaw;
if ($level !== null && !in_array($level, [1, 2, 3], true)) {
continue;
}
if ($level === null) {
if (isset($existingRows[$studentId])) {
$this->placementLevelModel->delete($existingRows[$studentId]['id']);
}
continue;
}
$payload = [
'student_id' => $studentId,
'level' => $level,
'updated_by' => $userId,
];
if (isset($existingRows[$studentId])) {
$this->placementLevelModel->update($existingRows[$studentId]['id'], $payload);
} else {
$payload['created_by'] = $userId;
$this->placementLevelModel->insert($payload);
}
}
return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'status', 'message' => 'Placement levels updated.'];
}
public function updatePlacementLevelsAll(array $params = [])
{
$get = $params['get'] ?? [];
$post = $params['post'] ?? [];
$schoolYear = (string) (($post['school_year'] ?? null) ?? $this->schoolYear);
$placementTest = (string) (($post['placement_test'] ?? null) ?? '');
$levels = ($post['placement_level'] ?? null) ?? [];
if ($schoolYear === '' || $placementTest === '') {
return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'Missing placement test or school year.'];
}
$students = $this->fetchActiveStudentsWithSection($schoolYear);
$validIds = array_values(array_filter(array_map(
static fn($row) => (int) ($row['student_id'] ?? 0),
$students
), static fn($id) => $id > 0));
$validSet = array_flip($validIds);
$userId = session()->get('user_id');
$batchId = $this->placementBatchModel->insert([
'placement_test' => $placementTest,
'school_year' => $schoolYear,
'created_by' => $userId,
'updated_by' => $userId,
]);
if (!$batchId) {
return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'Unable to create placement batch.'];
}
$savedCount = 0;
foreach ($levels as $studentIdRaw => $levelRaw) {
$studentId = (int) $studentIdRaw;
if (!isset($validSet[$studentId])) {
continue;
}
$levelRaw = trim((string) $levelRaw);
if ($levelRaw === '') {
continue;
}
$level = (int) $levelRaw;
if ($level < 0 || $level > 100) {
continue;
}
$this->placementScoreModel->insert([
'batch_id' => (int) $batchId,
'student_id' => $studentId,
'score' => $level,
'created_by' => $userId,
'updated_by' => $userId,
]);
$savedCount++;
}
if ($savedCount === 0) {
$this->placementBatchModel->delete((int) $batchId);
return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'No scores entered. Batch not saved.'];
}
return ['kind' => 'flash', 'redirect' => base_url('grading/placement'), 'type' => 'status', 'message' => 'Placement batch saved.'];
}
public function editPlacementBatch(int $batchId, array $params = [])
{
$get = $params['get'] ?? [];
$post = $params['post'] ?? [];
$batch = $this->placementBatchModel->find($batchId);
if (!$batch) {
return ['kind' => 'flash', 'redirect' => base_url('grading/placement'), 'type' => 'error', 'message' => 'Placement batch not found.'];
}
$schoolYear = (string) ($batch['school_year'] ?? $this->schoolYear);
$students = $this->fetchActiveStudentsWithSection($schoolYear);
$scores = $this->fetchPlacementScoresForBatch($batchId);
return ['kind' => 'view', 'view' => 'grading/placement_batch', 'data' => [
'batch' => $batch,
'students' => $students,
'scores' => $scores,
]];
}
public function updatePlacementBatch(int $batchId, array $params = [])
{
$get = $params['get'] ?? [];
$post = $params['post'] ?? [];
$batch = $this->placementBatchModel->find($batchId);
if (!$batch) {
return ['kind' => 'flash', 'redirect' => base_url('grading/placement'), 'type' => 'error', 'message' => 'Placement batch not found.'];
}
$schoolYear = (string) ($batch['school_year'] ?? $this->schoolYear);
$levels = ($post['placement_level'] ?? null) ?? [];
$students = $this->fetchActiveStudentsWithSection($schoolYear);
$validIds = array_values(array_filter(array_map(
static fn($row) => (int) ($row['student_id'] ?? 0),
$students
), static fn($id) => $id > 0));
$validSet = array_flip($validIds);
$existing = $this->fetchPlacementScoresForBatch($batchId);
$userId = session()->get('user_id');
foreach ($levels as $studentIdRaw => $scoreRaw) {
$studentId = (int) $studentIdRaw;
if (!isset($validSet[$studentId])) {
continue;
}
$scoreRaw = trim((string) $scoreRaw);
if ($scoreRaw === '') {
if (isset($existing[$studentId])) {
$this->placementScoreModel->delete($existing[$studentId]['id']);
}
continue;
}
$score = (int) $scoreRaw;
if ($score < 0 || $score > 100) {
continue;
}
if (isset($existing[$studentId])) {
$this->placementScoreModel->update($existing[$studentId]['id'], [
'score' => $score,
'updated_by' => $userId,
]);
} else {
$this->placementScoreModel->insert([
'batch_id' => $batchId,
'student_id' => $studentId,
'score' => $score,
'created_by' => $userId,
'updated_by' => $userId,
]);
}
}
$this->placementBatchModel->update($batchId, [
'updated_by' => $userId,
]);
return ['kind' => 'flash', 'redirect' => base_url('grading/placement'), 'type' => 'status', 'message' => 'Placement batch updated.'];
}
public function fetchActiveStudentsWithSection(string $schoolYear): array
{
return $this->db->table('students s')
->select('s.id AS student_id, s.school_id, s.firstname, s.lastname, s.is_active, sc.class_section_id, cs.class_section_name, c.class_name, e.enrollment_status, e.is_withdrawn')
->join('student_class sc', 'sc.student_id = s.id AND sc.school_year = ' . $this->db->escape($schoolYear), 'left')
->join('enrollments e', 'e.student_id = s.id AND e.school_year = ' . $this->db->escape($schoolYear), 'left')
->join('`classSection` cs', 'cs.class_section_id = sc.class_section_id', 'left')
->join('classes c', 'c.id = cs.class_id', 'left')
->groupStart()
->where('s.is_active', 1)
->orWhere("LOWER(TRIM(e.enrollment_status)) IN ('denied','withdrawn','waitlist')", null, false)
->orWhere('e.is_withdrawn', 1)
->groupEnd()
->orderBy('s.lastname', 'ASC')
->orderBy('s.firstname', 'ASC')
->get()
->getResultArray();
}
public function fetchPlacementBatches(string $schoolYear): array
{
return $this->placementBatchModel
->where('school_year', $schoolYear)
->orderBy('created_at', 'DESC')
->findAll();
}
public function fetchPlacementBatchDetails(array $batches, string $schoolYear): array
{
if (empty($batches)) {
return [];
}
$batchIds = array_values(array_filter(array_map(
static fn($row) => (int) ($row['id'] ?? 0),
$batches
), static fn($id) => $id > 0));
if (empty($batchIds)) {
return [];
}
$rows = $this->db->table('placement_scores ps')
->select('ps.batch_id, ps.student_id, ps.score, s.school_id, s.firstname, s.lastname, s.is_active, e.enrollment_status, e.is_withdrawn, cs.class_section_name, c.class_name')
->join('students s', 's.id = ps.student_id', 'inner')
->join('student_class sc', 'sc.student_id = s.id AND sc.school_year = ' . $this->db->escape($schoolYear), 'left')
->join('enrollments e', 'e.student_id = s.id AND e.school_year = ' . $this->db->escape($schoolYear), 'left')
->join('`classSection` cs', 'cs.class_section_id = sc.class_section_id', 'left')
->join('classes c', 'c.id = cs.class_id', 'left')
->whereIn('ps.batch_id', $batchIds)
->groupStart()
->where('s.is_active', 1)
->orWhere("LOWER(TRIM(e.enrollment_status)) IN ('denied','withdrawn','waitlist')", null, false)
->orWhere('e.is_withdrawn', 1)
->groupEnd()
->orderBy('ps.batch_id', 'ASC')
->orderBy('s.lastname', 'ASC')
->orderBy('s.firstname', 'ASC')
->get()
->getResultArray();
$details = [];
foreach ($rows as $row) {
$bid = (int) ($row['batch_id'] ?? 0);
if ($bid <= 0) continue;
$details[$bid][] = $row;
}
return $details;
}
public function fetchPlacementScoresForBatch(int $batchId): array
{
$rows = $this->placementScoreModel
->where('batch_id', $batchId)
->findAll();
$scores = [];
foreach ($rows as $row) {
$scores[(int) $row['student_id']] = $row;
}
return $scores;
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff