recreate project
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,322 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
use App\Controllers\BaseController;
|
||||
use App\Models\StudentClassModel;
|
||||
use App\Models\UserModel;
|
||||
use App\Models\TeacherClassModel;
|
||||
use App\Models\StudentModel;
|
||||
use App\Models\TeacherModel;
|
||||
use App\Models\ClassSectionModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
use Config\Database;
|
||||
|
||||
class AssignmentController extends BaseController
|
||||
{
|
||||
protected $userModel;
|
||||
protected $configModel;
|
||||
protected $semester;
|
||||
protected $schoolYear;
|
||||
protected $studentModel;
|
||||
protected $teacherClassModel;
|
||||
protected $studentClassModel;
|
||||
protected $classSectionModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
helper('auth');
|
||||
// Load models
|
||||
$this->userModel = new UserModel();
|
||||
$this->configModel = new ConfigurationModel();
|
||||
$this->studentModel = new StudentModel();
|
||||
$this->classSectionModel = new ClassSectionModel();
|
||||
$this->teacherClassModel = new TeacherClassModel();
|
||||
$this->studentClassModel = new StudentClassModel();
|
||||
|
||||
$this->semester = $this->configModel->getConfig('semester');
|
||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$data = [
|
||||
'classSections' => []
|
||||
];
|
||||
|
||||
// Apply school year filter (default to current config) but avoid semester filtering so the full year is visible
|
||||
$selectedSemester = (string)($this->request->getGet('semester') ?? $this->semester ?? '');
|
||||
$year = (string)($this->request->getGet('school_year') ?? $this->schoolYear ?? '');
|
||||
|
||||
$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 teacher and student classes by section
|
||||
$teacherBySection = [];
|
||||
foreach ($teacherClassesAll as $tc) {
|
||||
$teacherBySection[$tc['class_section_id']][] = $tc;
|
||||
}
|
||||
|
||||
$studentsBySection = [];
|
||||
foreach ($studentClassesAll as $sc) {
|
||||
$studentsBySection[$sc['class_section_id']][] = $sc;
|
||||
}
|
||||
|
||||
$allSectionIds = array_unique(array_merge(array_keys($teacherBySection), array_keys($studentsBySection)));
|
||||
|
||||
foreach ($allSectionIds as $classSectionId) {
|
||||
$teacherClasses = $teacherBySection[$classSectionId] ?? [];
|
||||
$studentClasses = $studentsBySection[$classSectionId] ?? [];
|
||||
|
||||
$hasTeacher = !empty($teacherClasses);
|
||||
$hasStudents = !empty($studentClasses);
|
||||
|
||||
if (!$hasTeacher && !$hasStudents) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$classSectionName = (string) ($this->classSectionModel->getClassSectionNameBySectionId($classSectionId) ?? '');
|
||||
|
||||
$mainTeachers = [];
|
||||
$teacherAssistants = [];
|
||||
$sectionSemester = '';
|
||||
$sectionSchoolYear = '';
|
||||
$description = '';
|
||||
|
||||
if ($hasTeacher) {
|
||||
foreach ($teacherClasses as $teacherClass) {
|
||||
$teacher = $this->userModel->find($teacherClass['teacher_id']);
|
||||
if (!$teacher) continue;
|
||||
|
||||
$teacherName = trim(($teacher['firstname'] ?? '') . ' ' . ($teacher['lastname'] ?? ''));
|
||||
if ($teacherName === '') continue;
|
||||
|
||||
if (($teacherClass['position'] ?? '') === 'main') {
|
||||
$mainTeachers[] = $teacherName;
|
||||
} elseif (($teacherClass['position'] ?? '') === 'ta') {
|
||||
$teacherAssistants[] = $teacherName;
|
||||
}
|
||||
|
||||
if ($sectionSemester === '' && !empty($teacherClass['semester'])) {
|
||||
$sectionSemester = (string)$teacherClass['semester'];
|
||||
}
|
||||
if ($sectionSchoolYear === '' && !empty($teacherClass['school_year'])) {
|
||||
$sectionSchoolYear = (string)$teacherClass['school_year'];
|
||||
}
|
||||
if ($description === '' && !empty($teacherClass['description'])) {
|
||||
$description = (string)$teacherClass['description'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$students = [];
|
||||
foreach ($studentClasses as $studentClass) {
|
||||
if ($sectionSemester === '' && !empty($studentClass['semester'])) {
|
||||
$sectionSemester = (string)$studentClass['semester'];
|
||||
}
|
||||
if ($sectionSchoolYear === '' && !empty($studentClass['school_year'])) {
|
||||
$sectionSchoolYear = (string)$studentClass['school_year'];
|
||||
}
|
||||
if ($description === '' && !empty($studentClass['description'])) {
|
||||
$description = (string)$studentClass['description'];
|
||||
}
|
||||
|
||||
$student = $this->studentModel
|
||||
->where('id', $studentClass['student_id'])
|
||||
->where('is_active', 1)
|
||||
->first();
|
||||
if (!$student) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$students[] = [
|
||||
'id' => (int)$student['id'],
|
||||
'firstname' => esc($student['firstname']),
|
||||
'lastname' => esc($student['lastname']),
|
||||
'age' => esc($student['age']),
|
||||
'gender' => esc($student['gender']),
|
||||
'registration_grade' => esc($student['registration_grade']),
|
||||
'photo_consent' => esc($student['photo_consent'] ? 'Yes' : 'No'),
|
||||
'tuition_paid' => esc($student['tuition_paid'] ? 'Yes' : 'No'),
|
||||
'school_id' => esc($student['school_id']),
|
||||
];
|
||||
}
|
||||
|
||||
$sectionSemesterDisplay = $sectionSemester !== '' ? $sectionSemester : ((string)($this->semester ?? ''));
|
||||
$sectionSchoolYearDisplay = $sectionSchoolYear !== '' ? $sectionSchoolYear : ((string)($this->schoolYear ?? ''));
|
||||
$data['classSections'][] = [
|
||||
'class_section_id' => $classSectionId,
|
||||
'class_section_name' => $classSectionName,
|
||||
'main_teachers' => $mainTeachers,
|
||||
'teacher_assistants' => $teacherAssistants,
|
||||
'students' => $students,
|
||||
'semester' => $sectionSemesterDisplay,
|
||||
'school_year' => $sectionSchoolYearDisplay,
|
||||
'description' => $description,
|
||||
];
|
||||
}
|
||||
|
||||
$schoolYearsList = [];
|
||||
try {
|
||||
$db = Database::connect();
|
||||
$yearsQuery = $db->table('teacher_class')
|
||||
->select('DISTINCT school_year', false)
|
||||
->where('school_year IS NOT NULL', null, false)
|
||||
->orderBy('school_year', 'DESC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
foreach ($yearsQuery as $row) {
|
||||
$val = (string)($row['school_year'] ?? '');
|
||||
if ($val !== '' && !in_array($val, $schoolYearsList, true)) {
|
||||
$schoolYearsList[] = $val;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// ignore fallback below
|
||||
}
|
||||
if (empty($schoolYearsList) && $this->schoolYear !== null && $this->schoolYear !== '') {
|
||||
$schoolYearsList[] = (string)$this->schoolYear;
|
||||
}
|
||||
|
||||
// Sort sections
|
||||
usort($data['classSections'], fn($a, $b) => strcmp((string) $a['class_section_name'], (string) $b['class_section_name']));
|
||||
|
||||
$data['schoolYears'] = $schoolYearsList;
|
||||
$data['schoolYear'] = $year;
|
||||
$data['selectedYear'] = $year;
|
||||
$data['selectedSemester'] = $selectedSemester;
|
||||
|
||||
return view('administrator/class_assignment', $data);
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function save()
|
||||
{
|
||||
|
||||
$data = [
|
||||
'student_id' => $this->request->getPost('student_id'),
|
||||
'class_section_id' => $this->request->getPost('class_section_id'),
|
||||
'semester' => $this->request->getPost('semester'),
|
||||
'school_year' => $this->request->getPost('school_year'),
|
||||
'description' => $this->request->getPost('description'),
|
||||
'updated_by' => session()->get('user_id'),
|
||||
];
|
||||
|
||||
$this->studentClassModel->save($data);
|
||||
|
||||
return redirect()->to('/assignments')->with('message', 'Assignment saved successfully');
|
||||
}
|
||||
|
||||
// API: JSON payload for Classes List page
|
||||
public function classAssignmentData()
|
||||
{
|
||||
$teacherClassesAll = $this->teacherClassModel->findAll();
|
||||
$studentClassesAll = $this->studentClassModel->findAll();
|
||||
|
||||
// Group by section
|
||||
$teacherBySection = [];
|
||||
foreach ($teacherClassesAll as $tc) {
|
||||
$secId = (int)($tc['class_section_id'] ?? 0);
|
||||
if ($secId) $teacherBySection[$secId][] = $tc;
|
||||
}
|
||||
$studentsBySection = [];
|
||||
foreach ($studentClassesAll as $sc) {
|
||||
$secId = (int)($sc['class_section_id'] ?? 0);
|
||||
if ($secId) $studentsBySection[$secId][] = $sc;
|
||||
}
|
||||
|
||||
$allSectionIds = array_values(array_unique(array_merge(array_keys($teacherBySection), array_keys($studentsBySection))));
|
||||
|
||||
$classSections = [];
|
||||
|
||||
foreach ($allSectionIds as $classSectionId) {
|
||||
$hasTeacher = !empty($teacherBySection[$classSectionId]);
|
||||
$hasStudents = !empty($studentsBySection[$classSectionId]);
|
||||
if (!$hasTeacher && !$hasStudents) continue;
|
||||
|
||||
$classSectionName = (string) ($this->classSectionModel->getClassSectionNameBySectionId($classSectionId) ?? '');
|
||||
|
||||
$mainTeachers = [];
|
||||
$teacherAssistants = [];
|
||||
$semesterMeta = '';
|
||||
$schoolYearMeta = '';
|
||||
$descriptionMeta = '';
|
||||
|
||||
if ($hasTeacher) {
|
||||
foreach ($teacherBySection[$classSectionId] as $teacherClass) {
|
||||
$teacher = $this->userModel->find((int)$teacherClass['teacher_id']);
|
||||
if ($teacher) {
|
||||
$tname = trim(($teacher['firstname'] ?? '') . ' ' . ($teacher['lastname'] ?? ''));
|
||||
if (($teacherClass['position'] ?? '') === 'main') {
|
||||
$mainTeachers[] = $tname;
|
||||
} elseif (($teacherClass['position'] ?? '') === 'ta') {
|
||||
$teacherAssistants[] = $tname;
|
||||
}
|
||||
}
|
||||
// assign meta (same for all rows in a section)
|
||||
if ($semesterMeta === '' && !empty($teacherClass['semester'])) {
|
||||
$semesterMeta = (string)$teacherClass['semester'];
|
||||
}
|
||||
if ($schoolYearMeta === '' && !empty($teacherClass['school_year'])) {
|
||||
$schoolYearMeta = (string)$teacherClass['school_year'];
|
||||
}
|
||||
if ($descriptionMeta === '' && !empty($teacherClass['description'])) {
|
||||
$descriptionMeta = (string)$teacherClass['description'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load students for the section
|
||||
$students = [];
|
||||
foreach ($this->studentClassModel->active()->where('student_class.class_section_id', $classSectionId)->findAll() as $studentClass) {
|
||||
$stu = $this->studentModel
|
||||
->where('id', (int)$studentClass['student_id'])
|
||||
->where('is_active', 1)
|
||||
->first();
|
||||
if (!$stu) continue;
|
||||
$students[] = [
|
||||
'id' => (int)$stu['id'],
|
||||
'firstname' => (string)($stu['firstname'] ?? ''),
|
||||
'lastname' => (string)($stu['lastname'] ?? ''),
|
||||
'age' => $stu['age'] ?? null,
|
||||
'gender' => (string)($stu['gender'] ?? ''),
|
||||
'registration_grade' => (string)($stu['registration_grade'] ?? ''),
|
||||
'photo_consent' => (bool)($stu['photo_consent'] ?? false),
|
||||
'tuition_paid' => (bool)($stu['tuition_paid'] ?? false),
|
||||
'school_id' => (string)($stu['school_id'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
$classSections[] = [
|
||||
'class_section_id' => (int)$classSectionId,
|
||||
'class_section_name' => $classSectionName,
|
||||
'main_teachers' => array_values(array_unique($mainTeachers)),
|
||||
'teacher_assistants' => array_values(array_unique($teacherAssistants)),
|
||||
'students' => $students,
|
||||
'semester' => $semesterMeta ?: (string)$this->semester,
|
||||
'school_year' => $schoolYearMeta ?: (string)$this->schoolYear,
|
||||
'description' => $descriptionMeta,
|
||||
];
|
||||
}
|
||||
|
||||
// Sort by class_section_name
|
||||
usort($classSections, fn($a, $b) => strcmp((string)($a['class_section_name'] ?? ''), (string)($b['class_section_name'] ?? '')));
|
||||
|
||||
return $this->response->setJSON([
|
||||
'classSections' => $classSections,
|
||||
'csrfHash' => csrf_hash(),
|
||||
'semester' => (string)$this->semester,
|
||||
'school_year' => (string)$this->schoolYear,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Models\AttendanceCommentTemplateModel;
|
||||
|
||||
class AttendanceCommentTemplateController extends BaseController
|
||||
{
|
||||
protected $templateModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->templateModel = new AttendanceCommentTemplateModel();
|
||||
}
|
||||
|
||||
// Method to load configuration management page
|
||||
public function index()
|
||||
{
|
||||
helper('url');
|
||||
return view('attendance_templates/index', [
|
||||
'templateEndpoint' => site_url('api/attendance-templates'),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function save()
|
||||
{
|
||||
$id = $this->request->getPost('id');
|
||||
$data = [
|
||||
'min_score' => $this->request->getPost('min_score'),
|
||||
'max_score' => $this->request->getPost('max_score'),
|
||||
'template_text' => $this->request->getPost('template_text'),
|
||||
'is_active' => $this->request->getPost('is_active') === 'on' ? 1 : 0,
|
||||
];
|
||||
|
||||
if ($id) {
|
||||
$this->templateModel->update($id, $data);
|
||||
} else {
|
||||
$this->templateModel->insert($data);
|
||||
}
|
||||
|
||||
return $this->response->setJSON(['status' => 'success']);
|
||||
}
|
||||
|
||||
public function delete()
|
||||
{
|
||||
$id = $this->request->getPost('id');
|
||||
if ($id) {
|
||||
$this->templateModel->delete($id);
|
||||
return $this->response->setJSON(['status' => 'success']);
|
||||
}
|
||||
return $this->response->setJSON(['status' => 'error', 'message' => 'ID not provided']);
|
||||
}
|
||||
|
||||
public function listData()
|
||||
{
|
||||
$rows = $this->templateModel
|
||||
->orderBy('min_score', 'ASC')
|
||||
->findAll();
|
||||
|
||||
$templates = array_map(static function ($row) {
|
||||
if (!is_array($row)) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'min_score' => (int) ($row['min_score'] ?? 0),
|
||||
'max_score' => (int) ($row['max_score'] ?? 0),
|
||||
'template_text' => (string) ($row['template_text'] ?? ''),
|
||||
'is_active' => (bool) ($row['is_active'] ?? false),
|
||||
];
|
||||
}, $rows ?? []);
|
||||
|
||||
return $this->response->setJSON(['templates' => $templates]);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,245 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
use CodeIgniter\RESTful\ResourceController;
|
||||
use App\Models\UserModel;
|
||||
use App\Models\AuthorizedUserModel;
|
||||
use CodeIgniter\I18n\Time;
|
||||
|
||||
class AuthorizedUsersController extends ResourceController
|
||||
{
|
||||
protected $userModel;
|
||||
protected $authorizedUserModel;
|
||||
|
||||
public function __construct(private \CodeIgniter\HTTP\IncomingRequest $request)
|
||||
{
|
||||
$this->userModel = new UserModel();
|
||||
$this->authorizedUserModel = new AuthorizedUserModel();
|
||||
}
|
||||
/**
|
||||
* Return a list of authorized users for the logged-in main user.
|
||||
*
|
||||
* @return ResponseInterface
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
|
||||
$userId = session()->get('user_id');
|
||||
$authorizedUsers = $this->authorizedUserModel->where('user_id', $userId)->findAll();
|
||||
|
||||
return $this->respond($authorizedUsers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a specific authorized user by ID.
|
||||
*
|
||||
* @param int|string|null $id
|
||||
* @return ResponseInterface
|
||||
*/
|
||||
public function show($id = null)
|
||||
{
|
||||
$authorizedUser = $this->authorizedUserModel->find($id);
|
||||
|
||||
if (!$authorizedUser) {
|
||||
return $this->failNotFound('Authorized user not found.');
|
||||
}
|
||||
|
||||
return $this->respond($authorizedUser);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new authorized user (add by email).
|
||||
*
|
||||
* @return ResponseInterface
|
||||
*/
|
||||
public function create()
|
||||
{
|
||||
$email = strtolower($this->request->getPost('email'));
|
||||
|
||||
// Validate email
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
return $this->failValidationErrors('Invalid email address.');
|
||||
}
|
||||
|
||||
$user = $this->userModel->where('email', $email)->first();
|
||||
|
||||
if (!$user) {
|
||||
return $this->failNotFound('No user found with this email.');
|
||||
}
|
||||
|
||||
// Generate a token for confirmation
|
||||
helper('text');
|
||||
$token = bin2hex(random_bytes(48));
|
||||
|
||||
// Add entry to the authorized_users table
|
||||
$this->authorizedUserModel->insert([
|
||||
'user_id' => session()->get('user_id'), // Main user ID
|
||||
'authorized_user_id' => $user['id'],
|
||||
'email' => $email,
|
||||
'token' => $token,
|
||||
'status' => 'Pending'
|
||||
]);
|
||||
|
||||
// Send confirmation email to the authorized user
|
||||
$this->sendAuthorizedUserConfirmationEmail($email, $token);
|
||||
|
||||
return $this->respondCreated(['message' => 'Authorized user added. A confirmation email has been sent.']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing authorized user.
|
||||
*
|
||||
* @param int|string|null $id
|
||||
* @return ResponseInterface
|
||||
*/
|
||||
public function update($id = null)
|
||||
{
|
||||
// Fetch the authorized user
|
||||
$authorizedUser = $this->authorizedUserModel->find($id);
|
||||
|
||||
if (!$authorizedUser) {
|
||||
return $this->failNotFound('Authorized user not found.');
|
||||
}
|
||||
|
||||
// Update the authorized user’s information (e.g., email)
|
||||
$email = strtolower($this->request->getPost('email'));
|
||||
if ($email && filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$authorizedUser['email'] = $email;
|
||||
}
|
||||
|
||||
$this->authorizedUserModel->save($authorizedUser);
|
||||
|
||||
return $this->respondUpdated(['message' => 'Authorized user information updated.']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an authorized user.
|
||||
*
|
||||
* @param int|string|null $id
|
||||
* @return ResponseInterface
|
||||
*/
|
||||
public function delete($id = null)
|
||||
{
|
||||
$authorizedUser = $this->authorizedUserModel->find($id);
|
||||
|
||||
if (!$authorizedUser) {
|
||||
return $this->failNotFound('Authorized user not found.');
|
||||
}
|
||||
|
||||
// Delete the authorized user record
|
||||
$this->authorizedUserModel->delete($id);
|
||||
|
||||
return $this->respondDeleted(['message' => 'Authorized user deleted successfully.']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirms the authorized user's token and allows them to set a password.
|
||||
*
|
||||
* @return ResponseInterface
|
||||
*/
|
||||
public function confirm()
|
||||
{
|
||||
$token = $this->request->getGet('token');
|
||||
|
||||
if (!$token) {
|
||||
return $this->fail('Invalid confirmation link.');
|
||||
}
|
||||
|
||||
$authorizedUser = $this->authorizedUserModel->where('token', $token)->first();
|
||||
|
||||
if (!$authorizedUser) {
|
||||
return $this->fail('Invalid or expired confirmation link.');
|
||||
}
|
||||
|
||||
// Mark the authorized user as active
|
||||
$this->authorizedUserModel->update($authorizedUser['id'], ['status' => 'Active', 'token' => null]);
|
||||
|
||||
return redirect()->to('/set_authorized_user_password/' . $authorizedUser['authorized_user_id']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays the form for the authorized user to set their password.
|
||||
*
|
||||
* @param int $authorizedUserId
|
||||
* @return ResponseInterface
|
||||
*/
|
||||
public function setPassword($authorizedUserId)
|
||||
{
|
||||
$user = $this->userModel->find($authorizedUserId);
|
||||
|
||||
if (!$user) {
|
||||
return $this->failNotFound('User not found.');
|
||||
}
|
||||
|
||||
return view('user/set_authorized_user_password', ['userId' => $authorizedUserId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes the password for the authorized user.
|
||||
*
|
||||
* @return ResponseInterface
|
||||
*/
|
||||
/*
|
||||
public function savePassword()
|
||||
{
|
||||
// Validate the request
|
||||
$validation = \Config\Services::validation();
|
||||
$validation->setRules([
|
||||
'password' => 'required|min_length[6]',
|
||||
'password_confirm' => 'required|matches[password]',
|
||||
'user_id' => 'required|integer'
|
||||
]);
|
||||
|
||||
if (!$this->validate($validation->getRules())) {
|
||||
return $this->failValidationErrors($validation->getErrors());
|
||||
}
|
||||
|
||||
// Get the validated input
|
||||
$userId = $this->request->getPost('user_id');
|
||||
$password = $this->request->getPost('password');
|
||||
|
||||
$model = new UserModel();
|
||||
$user = $model->find($userId);
|
||||
|
||||
if (!$user) {
|
||||
return $this->failNotFound('User not found.');
|
||||
}
|
||||
|
||||
// Save the password
|
||||
$model->update($userId, ['password' => password_hash($password, PASSWORD_DEFAULT)]);
|
||||
|
||||
return $this->respond(['message' => 'Password has been successfully set.']);
|
||||
}
|
||||
*/
|
||||
/**
|
||||
* Sends a confirmation email to the authorized user.
|
||||
*
|
||||
* @param string $email
|
||||
* @param string $token
|
||||
*/
|
||||
private function sendAuthorizedUserConfirmationEmail($email, $token)
|
||||
{
|
||||
// Generate the confirmation link
|
||||
$confirmLink = site_url('/confirm_authorized_user?token=' . $token);
|
||||
|
||||
// Compose the email message
|
||||
$message = "
|
||||
<p>You have been added as an authorized user for another account. Click the link below to confirm your access:</p>
|
||||
<p><a href='{$confirmLink}'>Confirm Access</a></p>
|
||||
<p>If you did not request this, please ignore this email.</p>
|
||||
";
|
||||
|
||||
// Create an instance of the EmailController
|
||||
$emailController = new \App\Controllers\View\EmailController();
|
||||
$subject = 'Authorized User Confirmation';
|
||||
|
||||
// Send email
|
||||
if ($emailController->sendEmail($email, $subject, $message)) {
|
||||
log_message('info', 'Authorized user confirmation email sent to ' . $email);
|
||||
} else {
|
||||
log_message('error', 'Failed to send authorized user confirmation email to ' . $email);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,850 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
class BadgesController extends PrintablesBaseController
|
||||
{
|
||||
//Badges
|
||||
public function badge()
|
||||
{
|
||||
// 1) Collect & sanitize IDs
|
||||
$userIds = $this->request->getPost('user_ids') ?? [];
|
||||
if (!is_array($userIds)) $userIds = [$userIds];
|
||||
|
||||
$userIds = array_values(array_filter(array_map(static function ($v) {
|
||||
$v = is_string($v) ? trim($v) : $v;
|
||||
return ($v !== '' && $v !== null) ? (int)$v : null;
|
||||
}, $userIds), static fn($v) => $v !== null));
|
||||
$userIds = array_values(array_unique($userIds));
|
||||
|
||||
// 2) Consistent school_year
|
||||
$schoolYear = $this->request->getPost('school_year')
|
||||
?? $this->request->getGet('school_year')
|
||||
?? ($this->schoolYear ?? null);
|
||||
|
||||
// 2a) Posted maps from the view
|
||||
$rolesMap = $this->request->getPost('roles') ?? [];
|
||||
$classesMap = $this->request->getPost('classes') ?? [];
|
||||
if (!is_array($rolesMap)) $rolesMap = [];
|
||||
if (!is_array($classesMap)) $classesMap = [];
|
||||
|
||||
// 2b) Normalizer to mirror the view's formatting
|
||||
$formatRole = static function (?string $role): string {
|
||||
$role = (string)$role;
|
||||
$role = str_replace(['-', '_'], ' ', $role);
|
||||
$role = preg_replace('/\s+/', ' ', trim($role));
|
||||
if ($role === '') return '';
|
||||
$out = [];
|
||||
foreach (explode(' ', $role) as $w) {
|
||||
if ($w === '') continue;
|
||||
if (preg_match('/^[A-Za-z]{1,3}$/', $w)) {
|
||||
$out[] = strtoupper($w);
|
||||
} else {
|
||||
$out[] = ucfirst(strtolower($w));
|
||||
}
|
||||
}
|
||||
return implode(' ', $out);
|
||||
};
|
||||
|
||||
// 3) PDF setup
|
||||
$pdf = new \FPDF('P', 'mm', 'A4');
|
||||
$pdf->SetAutoPageBreak(false);
|
||||
|
||||
// Layout: 2 cols - 5 rows = 10 per page
|
||||
$badgeW = 95;
|
||||
$badgeH = 67;
|
||||
$marginL = 14;
|
||||
$marginT = 6;
|
||||
$gutterX = 0;
|
||||
$gutterY = 0;
|
||||
$cols = 2;
|
||||
$rows = 4;
|
||||
$perPage = $cols * $rows;
|
||||
|
||||
$pagesAdded = 0;
|
||||
$i = 0;
|
||||
|
||||
$norm = static function (?string $s): string {
|
||||
$s = (string)$s;
|
||||
$s = str_replace(["\xC2\xA0", "\xA0"], ' ', $s);
|
||||
$s = preg_replace('/\s+/u', ' ', $s) ?? $s;
|
||||
$s = preg_replace('/[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F]/u', '', $s) ?? $s;
|
||||
return trim($s);
|
||||
};
|
||||
|
||||
$resolveRole = static function (array $info) use ($norm): string {
|
||||
$candidates = [
|
||||
'role',
|
||||
'active_role',
|
||||
'role_name',
|
||||
function ($r) {
|
||||
if (!empty($r['roles'])) {
|
||||
$parts = array_filter(array_map('trim', explode(',', (string)$r['roles'])));
|
||||
return $parts[0] ?? '';
|
||||
}
|
||||
return '';
|
||||
},
|
||||
'job_title',
|
||||
'title',
|
||||
'position',
|
||||
'staff_role',
|
||||
'department_role',
|
||||
'dept_role'
|
||||
];
|
||||
foreach ($candidates as $key) {
|
||||
if (is_callable($key)) {
|
||||
$v = $key($info);
|
||||
if ($norm($v) !== '') return $norm($v);
|
||||
} else {
|
||||
if (!empty($info[$key])) {
|
||||
$v = $norm((string)$info[$key]);
|
||||
if ($v !== '') return $v;
|
||||
}
|
||||
}
|
||||
}
|
||||
return 'STAFF';
|
||||
};
|
||||
|
||||
$seen = [];
|
||||
|
||||
foreach ($userIds as $uid) {
|
||||
$info = $this->getUserInfoById($uid, $schoolYear);
|
||||
if (!$info || !is_array($info)) continue;
|
||||
|
||||
$userId = isset($info['user_id']) ? (int)$info['user_id']
|
||||
: (isset($info['id']) ? (int)$info['id']
|
||||
: (isset($info['users.id']) ? (int)$info['users.id'] : $uid));
|
||||
|
||||
$name = $norm($info['name'] ?? (($info['firstname'] ?? '') . ' ' . ($info['lastname'] ?? '')));
|
||||
|
||||
// Prefer posted formatted role; else fallback and format server-side
|
||||
$postedRole = $rolesMap[$userId] ?? null;
|
||||
$roleResolved = $postedRole !== null ? $postedRole : $resolveRole($info);
|
||||
$roleResolved = $formatRole((string)$roleResolved);
|
||||
$info['role_resolved'] = $roleResolved;
|
||||
|
||||
// Prefer posted class name if provided (keeps what the user saw)
|
||||
if (!empty($classesMap[$userId])) {
|
||||
$info['class_section_name'] = (string)$classesMap[$userId];
|
||||
}
|
||||
|
||||
$class = $norm($info['class_section_name'] ?? '');
|
||||
|
||||
$badgeKey = strtolower(trim($userId . '|' . $name . '|' . $roleResolved . '|' . $class));
|
||||
if (isset($seen[$badgeKey])) continue;
|
||||
$seen[$badgeKey] = true;
|
||||
|
||||
if (($i % $perPage) === 0) {
|
||||
$pdf->AddPage();
|
||||
$pagesAdded++;
|
||||
}
|
||||
|
||||
$indexOnPage = $i % $perPage;
|
||||
$row = intdiv($indexOnPage, $cols);
|
||||
$col = $indexOnPage % $cols;
|
||||
|
||||
$x = $marginL + $col * ($badgeW + $gutterX);
|
||||
$y = $marginT + $row * ($badgeH + $gutterY);
|
||||
|
||||
// drawBadgeInCell expects 'role_resolved' and (optionally) 'class_section_name'
|
||||
$this->drawBadgeInCell($pdf, $info, $x, $y, $badgeW, $badgeH);
|
||||
$i++;
|
||||
}
|
||||
|
||||
if ($pagesAdded === 0) {
|
||||
$pdf->AddPage();
|
||||
$pdf->SetFont('Arial', '', 10);
|
||||
$pdf->SetXY(10, 20);
|
||||
$pdf->MultiCell(0, 6, "No valid staff selected or data not found.", 0, 'L');
|
||||
}
|
||||
|
||||
if (ob_get_length()) {
|
||||
ob_end_clean();
|
||||
}
|
||||
$pdfString = $pdf->Output('S');
|
||||
|
||||
// 4) Track prints (best-effort; swallow errors if table missing)
|
||||
try {
|
||||
$actorId = (int) (session()->get('user_id') ?? 0) ?: null;
|
||||
$this->badgePrintLogModel->logPrints(
|
||||
$userIds,
|
||||
$actorId,
|
||||
is_string($schoolYear) ? $schoolYear : null,
|
||||
is_array($rolesMap) ? $rolesMap : [],
|
||||
is_array($classesMap) ? $classesMap : [],
|
||||
1
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Failed to log badge prints: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return $this->response
|
||||
->setHeader('Content-Type', 'application/pdf')
|
||||
->setHeader('Content-Disposition', 'inline; filename="Staff_Badges.pdf"')
|
||||
->setBody($pdfString);
|
||||
}
|
||||
|
||||
/**
|
||||
* API: Return print status for given users (count + last print time).
|
||||
* GET: user_ids[] or CSV in user_ids, optional school_year
|
||||
* Response: { ok: true, data: { <id>: { count, last_printed_at, last_printed_by } } }
|
||||
*/
|
||||
public function badgePrintStatus()
|
||||
{
|
||||
$ids = $this->request->getGet('user_ids');
|
||||
if (is_string($ids)) {
|
||||
$ids = array_filter(array_map('trim', explode(',', $ids)), 'strlen');
|
||||
}
|
||||
if (!is_array($ids)) $ids = $ids ? [$ids] : [];
|
||||
$ids = array_values(array_unique(array_map(static fn($v) => (int)$v, $ids)));
|
||||
|
||||
$schoolYear = $this->request->getGet('school_year') ?? $this->schoolYear;
|
||||
|
||||
try {
|
||||
$status = $this->badgePrintLogModel->getStatus($ids, is_string($schoolYear) ? $schoolYear : null);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->response->setJSON(['ok' => false, 'error' => 'Failed to query status']);
|
||||
}
|
||||
|
||||
return $this->response->setJSON([
|
||||
'ok' => true,
|
||||
'data' => $status,
|
||||
'csrf_token' => csrf_token(),
|
||||
'csrf_hash' => csrf_hash(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* API: Log a set of prints explicitly (optional; PDF endpoint already logs).
|
||||
* POST: user_ids[], optional school_year, roles[ID], classes[ID]
|
||||
*/
|
||||
public function logBadgePrint()
|
||||
{
|
||||
$ids = $this->request->getPost('user_ids') ?? [];
|
||||
if (!is_array($ids)) $ids = $ids ? [$ids] : [];
|
||||
$ids = array_values(array_unique(array_map(static fn($v) => (int)$v, $ids)));
|
||||
|
||||
$roles = $this->request->getPost('roles') ?? [];
|
||||
$class = $this->request->getPost('classes') ?? [];
|
||||
if (!is_array($roles)) $roles = [];
|
||||
if (!is_array($class)) $class = [];
|
||||
|
||||
$schoolYear = $this->request->getPost('school_year') ?? $this->schoolYear;
|
||||
$actorId = (int) (session()->get('user_id') ?? 0) ?: null;
|
||||
|
||||
try {
|
||||
$cnt = $this->badgePrintLogModel->logPrints(
|
||||
$ids,
|
||||
$actorId,
|
||||
is_string($schoolYear) ? $schoolYear : null,
|
||||
$roles,
|
||||
$class,
|
||||
1
|
||||
);
|
||||
return $this->response->setJSON([
|
||||
'ok' => true,
|
||||
'inserted' => $cnt,
|
||||
'csrf_token' => csrf_token(),
|
||||
'csrf_hash' => csrf_hash(),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->response->setJSON(['ok' => false, 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
protected function drawBadgeInCell(\FPDF $pdf, array $data, float $x, float $y, float $w, float $h): void
|
||||
{
|
||||
// Background & border
|
||||
$pdf->SetFillColor(255, 255, 255);
|
||||
$pdf->Rect($x, $y, $w, $h, 'F');
|
||||
$pdf->SetDrawColor(200, 200, 200);
|
||||
$pdf->Rect($x, $y, $w, $h);
|
||||
|
||||
// Logos
|
||||
$logoSize = 6;
|
||||
if (!empty($data['school_logo']) && file_exists($data['school_logo'])) {
|
||||
$pdf->Image($data['school_logo'], $x + $w - 6 - $logoSize, $y + 4, $logoSize + 3, $logoSize - 1);
|
||||
}
|
||||
if (!empty($data['isgl_logo']) && file_exists($data['isgl_logo'])) {
|
||||
$pdf->Image($data['isgl_logo'], $x + 4, $y + 4, $logoSize + 3, $logoSize - 1);
|
||||
}
|
||||
|
||||
// Helpers
|
||||
$padX = 4;
|
||||
$cursorY = $y + 4 + $logoSize + 2;
|
||||
|
||||
$norm = static function (?string $s): string {
|
||||
$s = (string)$s;
|
||||
$s = str_replace(["\xC2\xA0", "\xA0"], ' ', $s);
|
||||
$s = preg_replace('/\s+/u', ' ', $s) ?? $s;
|
||||
$s = preg_replace('/[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F]/u', '', $s) ?? $s;
|
||||
return trim($s);
|
||||
};
|
||||
$toPdf = static function (string $s): string {
|
||||
if (function_exists('iconv')) {
|
||||
$o = @iconv('UTF-8', 'ISO-8859-1//TRANSLIT', $s);
|
||||
if ($o !== false && $o !== '') return $o;
|
||||
}
|
||||
if (function_exists('mb_convert_encoding')) {
|
||||
$o = @mb_convert_encoding($s, 'ISO-8859-1', 'UTF-8');
|
||||
if ($o !== '') return $o;
|
||||
}
|
||||
$o = @utf8_decode($s);
|
||||
return $o !== '' ? $o : 'STAFF';
|
||||
};
|
||||
$fitText = static function (\FPDF $pdf, string $text, int $startSize, int $minSize, float $maxWidth): int {
|
||||
$size = $startSize;
|
||||
while ($size >= $minSize) {
|
||||
$pdf->SetFontSize($size);
|
||||
if ($pdf->GetStringWidth($text) <= $maxWidth) return $size;
|
||||
$size--;
|
||||
}
|
||||
return $minSize;
|
||||
};
|
||||
|
||||
// === Role formatter (fixes "OF" -> "Of", "HEAD" -> "Head"; keeps TA/PTA/HR/KG/IT/DEPT in ALL-CAPS) ===
|
||||
$formatRole = static function (string $role): string {
|
||||
// Normalize separators & spaces
|
||||
$role = str_replace(['-', '_'], ' ', $role);
|
||||
$role = preg_replace('/\s+/u', ' ', trim($role));
|
||||
if ($role === '') return '';
|
||||
|
||||
// Special-casing map (case-insensitive keys)
|
||||
// - "of" must be lower-case
|
||||
// - "head" should be Title-case
|
||||
$special = [
|
||||
'of' => 'of',
|
||||
'head' => 'Head',
|
||||
];
|
||||
|
||||
$tokens = explode(' ', $role);
|
||||
$out = [];
|
||||
|
||||
foreach ($tokens as $tok) {
|
||||
if ($tok === '') continue;
|
||||
|
||||
// Strip leading/trailing NON-letters for the decision; reattach afterwards
|
||||
$core = preg_replace('/^\P{L}+|\P{L}+$/u', '', $tok);
|
||||
$start = strpos($tok, $core);
|
||||
if ($start === false) { // no letter content
|
||||
$out[] = ucfirst(mb_strtolower($tok, 'UTF-8'));
|
||||
continue;
|
||||
}
|
||||
$pre = substr($tok, 0, $start);
|
||||
$suf = substr($tok, $start + strlen($core));
|
||||
|
||||
$lw = mb_strtolower($core, 'UTF-8');
|
||||
|
||||
if (isset($special[$lw])) {
|
||||
// Use the exact desired casing from the map
|
||||
$coreFmt = $special[$lw]; // "of" -> "of", "head" -> "Head"
|
||||
} elseif (preg_match('/^\p{L}{1,4}$/u', $core)) {
|
||||
// 1-4 letters -> ALL CAPS (TA, PTA, HR, KG, IT, DEPT)
|
||||
$coreFmt = mb_strtoupper($core, 'UTF-8');
|
||||
} else {
|
||||
// Title-case longer words
|
||||
$coreFmt = preg_replace_callback(
|
||||
'/\p{L}+/u',
|
||||
static fn($m) => mb_strtoupper(mb_substr($m[0], 0, 1, 'UTF-8'), 'UTF-8')
|
||||
. mb_strtolower(mb_substr($m[0], 1, null, 'UTF-8'), 'UTF-8'),
|
||||
mb_strtolower($core, 'UTF-8')
|
||||
);
|
||||
}
|
||||
|
||||
$out[] = $pre . $coreFmt . $suf;
|
||||
}
|
||||
|
||||
return implode(' ', $out);
|
||||
};
|
||||
|
||||
// Class label formatter (KG stays KG; Youth title-cased; otherwise pass through)
|
||||
$formatClass = static function (string $class): string {
|
||||
$c = trim($class);
|
||||
if ($c === '') return '';
|
||||
$lc = strtolower($c);
|
||||
if ($lc === 'kg' || $lc === 'kindergarten') return 'KG';
|
||||
if ($lc === 'youth') return 'Youth';
|
||||
return $c; // e.g., "1-A", "3", "HS-2"
|
||||
};
|
||||
|
||||
// School name
|
||||
$schoolName = strtoupper($norm($data['school_name'] ?? 'AL RAHMA SUNDAY SCHOOL'));
|
||||
$pdf->SetFont('Arial', '', 16);
|
||||
$pdf->SetXY($x + $padX, $cursorY);
|
||||
$pdf->MultiCell($w - 2 * $padX, 5, $toPdf($schoolName), 0, 'C');
|
||||
|
||||
// Name
|
||||
$pdf->Ln(9);
|
||||
$pdf->SetFont('Arial', 'B', 14);
|
||||
$pdf->SetX($x + $padX);
|
||||
$name = strtoupper($norm($data['name'] ?? (($data['firstname'] ?? '') . ' ' . ($data['lastname'] ?? 'STAFF'))));
|
||||
$pdf->Cell($w - 2 * $padX, 6, $toPdf($name), 0, 1, 'C');
|
||||
|
||||
// Role (prefer preformatted; then fix with formatter to guarantee "Of"/"Head" etc.)
|
||||
$roleResolved = $norm((string)($data['role_resolved'] ?? ''));
|
||||
if ($roleResolved === '') {
|
||||
// fallback: find first available role-like field
|
||||
$candidates = [
|
||||
'role',
|
||||
'active_role',
|
||||
'role_name',
|
||||
function ($r) {
|
||||
if (!empty($r['roles'])) {
|
||||
$parts = array_filter(array_map('trim', explode(',', (string)$r['roles'])));
|
||||
return $parts[0] ?? '';
|
||||
}
|
||||
return '';
|
||||
},
|
||||
'job_title',
|
||||
'title',
|
||||
'position',
|
||||
'staff_role',
|
||||
'department_role',
|
||||
'dept_role'
|
||||
];
|
||||
foreach ($candidates as $key) {
|
||||
$v = '';
|
||||
if (is_callable($key)) {
|
||||
$v = (string)$key($data);
|
||||
} elseif (!empty($data[$key])) {
|
||||
$v = (string)$data[$key];
|
||||
}
|
||||
$v = $norm($v);
|
||||
if ($v !== '') {
|
||||
$roleResolved = $v;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Final fix to ensure exceptions and acronyms are correct even if upstream passed "OF"
|
||||
if ($roleResolved !== '') {
|
||||
$roleResolved = $formatRole($roleResolved);
|
||||
}
|
||||
|
||||
// Class
|
||||
$classRaw = $norm($data['class_section_name'] ?? '');
|
||||
$class = $formatClass($classRaw);
|
||||
|
||||
// Teacher-ish detection (lowercased source; do NOT change printed casing)
|
||||
$detectSrc = strtolower($norm(
|
||||
($data['roles_raw'] ?? '') !== '' ? $data['roles_raw'] : (($data['role_name_raw'] ?? '') !== '' ? $data['role_name_raw'] : $roleResolved)
|
||||
));
|
||||
$isTeacherish = (strpos($detectSrc, 'teacher') !== false) || preg_match('/\bta\b/', $detectSrc);
|
||||
|
||||
// Compose final display (no re-casing beyond $formatRole)
|
||||
if ($isTeacherish && $class !== '') {
|
||||
if (strtolower($class) === 'youth') {
|
||||
$display = 'Youth ' . $roleResolved;
|
||||
} elseif ($class === 'KG') {
|
||||
$display = 'KG ' . $roleResolved;
|
||||
} else {
|
||||
$display = 'Grade ' . $class . ' ' . $roleResolved;
|
||||
}
|
||||
} elseif ($roleResolved !== '') {
|
||||
$display = $roleResolved;
|
||||
} elseif ($class !== '') {
|
||||
$display = $class;
|
||||
} else {
|
||||
$display = 'STAFF';
|
||||
}
|
||||
|
||||
// Print role/class
|
||||
$pdf->Ln(6);
|
||||
$pdf->SetFont('Arial', '', 14);
|
||||
$displayOut = $toPdf($display);
|
||||
$maxTextWidth = $w - 2 * $padX;
|
||||
$best = $fitText($pdf, $displayOut, 11, 7, $maxTextWidth);
|
||||
$pdf->SetFont('Arial', '', $best + 4);
|
||||
if ($pdf->GetStringWidth($displayOut) <= $maxTextWidth) {
|
||||
$pdf->SetX($x + $padX);
|
||||
$pdf->Cell($maxTextWidth, 5, $displayOut, 0, 1, 'C');
|
||||
} else {
|
||||
$pdf->SetX($x + $padX);
|
||||
$pdf->MultiCell($maxTextWidth, 5, $displayOut, 0, 'C');
|
||||
}
|
||||
|
||||
// Footer: year
|
||||
$footerYear = $norm((string)($this->schoolYear ?? ($data['school_year'] ?? '')));
|
||||
if ($footerYear !== '') {
|
||||
$pdf->SetFont('Arial', 'I', 14);
|
||||
$pdf->SetXY($x + $padX, $y + $h - 9);
|
||||
$pdf->Cell($w - 2 * $padX, 4, $toPdf($footerYear), 0, 0, 'C');
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Helper: get staff rows (no joins) ----
|
||||
private function fetchStaffList(?string $schoolYear, array $selectedUserIds = []): array
|
||||
{
|
||||
// $this->userModel should be injected/constructed already
|
||||
return $this->userModel->getNoParentUsersWithRole($schoolYear, $selectedUserIds);
|
||||
}
|
||||
|
||||
// ---- Helper: is this person teacher-ish? (role_name can be CSV) ----
|
||||
private static function isTeacherish(string $roleCsv): bool
|
||||
{
|
||||
$roles = array_filter(array_map('trim', explode(',', strtolower($roleCsv))));
|
||||
foreach ($roles as $r) {
|
||||
if ($r === 'teacher' || $r === 'teacher_assistant' || $r === 'teacher assistant') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---- Helper: return ONE latest assignment (pref Teacher, else Assistant) and its class name ----
|
||||
private function getLatestClassForUser(int $userId, ?string $schoolYear = null): ?array
|
||||
{
|
||||
// Build a single query that considers teacher or assistant rows,
|
||||
$qb = $this->db->table('teacher_class tc')
|
||||
->select('tc.class_section_id, cs.class_section_name')
|
||||
->join('classSection cs', 'cs.class_section_id = tc.class_section_id', 'left')
|
||||
->where('tc.teacher_id', $userId);
|
||||
|
||||
if (!empty($schoolYear)) {
|
||||
$qb->where('tc.school_year', $schoolYear);
|
||||
}
|
||||
|
||||
$qb->orderBy('FIELD(tc.position, "main", "ta")', '', false)
|
||||
->orderBy('IFNULL(tc.updated_at,"0000-00-00 00:00:00") DESC', '', false)
|
||||
->limit(1);
|
||||
|
||||
|
||||
if (!empty($schoolYear)) {
|
||||
$qb->where('tc.school_year', $schoolYear);
|
||||
}
|
||||
|
||||
// Prefer a TEACHER row if one exists, then the latest update.
|
||||
// MySQL treats boolean expressions as 0/1, so we can order by it.
|
||||
$qb->orderBy('(tc.teacher_id = ' . $this->db->escape($userId) . ') DESC', '', false)
|
||||
->orderBy('IFNULL(tc.updated_at,"0000-00-00 00:00:00") DESC, tc.id DESC', '', false)
|
||||
->limit(1);
|
||||
|
||||
$row = $qb->get()->getRowArray();
|
||||
if (!$row || empty($row['class_section_id'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'class_section_id' => (int) $row['class_section_id'],
|
||||
'class_section_name' => $row['class_section_name'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
// ---- Controller: badgeForm (simple + deterministic) ----
|
||||
public function badgeForm()
|
||||
{
|
||||
$request = $this->request;
|
||||
|
||||
$schoolYear = $this->schoolYear;
|
||||
if ($this->schoolYear === null || $schoolYear === '') {
|
||||
$schoolYear = $this->schoolYear ?? null;
|
||||
}
|
||||
|
||||
// --- Role formatting helpers ---
|
||||
$formatRole = static function (string $role): string {
|
||||
// Normalize separators & spaces
|
||||
$role = str_replace(['-', '_'], ' ', $role);
|
||||
$role = preg_replace('/\s+/u', ' ', trim($role));
|
||||
if ($role === '') return '';
|
||||
|
||||
// Special-casing map (case-insensitive keys)
|
||||
// - "of" must be lower-case
|
||||
// - "head" should be Title-case
|
||||
$special = [
|
||||
'of' => 'of',
|
||||
'head' => 'Head',
|
||||
];
|
||||
|
||||
$tokens = explode(' ', $role);
|
||||
$out = [];
|
||||
|
||||
foreach ($tokens as $tok) {
|
||||
if ($tok === '') continue;
|
||||
|
||||
// Strip leading/trailing NON-letters for the decision; reattach afterwards
|
||||
$core = preg_replace('/^\P{L}+|\P{L}+$/u', '', $tok);
|
||||
$start = strpos($tok, $core);
|
||||
if ($start === false) { // no letter content
|
||||
$out[] = ucfirst(mb_strtolower($tok, 'UTF-8'));
|
||||
continue;
|
||||
}
|
||||
$pre = substr($tok, 0, $start);
|
||||
$suf = substr($tok, $start + strlen($core));
|
||||
|
||||
$lw = mb_strtolower($core, 'UTF-8');
|
||||
|
||||
if (isset($special[$lw])) {
|
||||
// Use the exact desired casing from the map
|
||||
$coreFmt = $special[$lw]; // "of" -> "of", "head" -> "Head"
|
||||
} elseif (preg_match('/^\p{L}{1,4}$/u', $core)) {
|
||||
// 1-4 letters -> ALL CAPS (TA, PTA, HR, KG, IT, DEPT)
|
||||
$coreFmt = mb_strtoupper($core, 'UTF-8');
|
||||
} else {
|
||||
// Title-case longer words
|
||||
$coreFmt = preg_replace_callback(
|
||||
'/\p{L}+/u',
|
||||
static fn($m) => mb_strtoupper(mb_substr($m[0], 0, 1, 'UTF-8'), 'UTF-8')
|
||||
. mb_strtolower(mb_substr($m[0], 1, null, 'UTF-8'), 'UTF-8'),
|
||||
mb_strtolower($core, 'UTF-8')
|
||||
);
|
||||
}
|
||||
|
||||
$out[] = $pre . $coreFmt . $suf;
|
||||
}
|
||||
|
||||
return implode(' ', $out);
|
||||
};
|
||||
|
||||
|
||||
$formatRolesCsv = static function ($csv) use ($formatRole): string {
|
||||
if ($csv === null) return '';
|
||||
$parts = array_filter(array_map('trim', explode(',', (string)$csv)), 'strlen');
|
||||
if (empty($parts)) return '';
|
||||
$parts = array_map($formatRole, $parts);
|
||||
return implode(', ', $parts);
|
||||
};
|
||||
|
||||
// --- 2) Selected user IDs: accept array, single value, or CSV ---
|
||||
$selectedUsers = $request->getGet('user_ids');
|
||||
if (is_string($selectedUsers)) {
|
||||
// Handle CSV like "12,34,56"
|
||||
$selectedUsers = array_filter(array_map('trim', explode(',', $selectedUsers)), 'strlen');
|
||||
}
|
||||
if (!is_array($selectedUsers)) {
|
||||
$selectedUsers = $selectedUsers ? [$selectedUsers] : [];
|
||||
}
|
||||
// Normalize to unique ints
|
||||
$selectedUserIds = array_values(array_unique(array_map(static function ($v) {
|
||||
return (int) $v;
|
||||
}, $selectedUsers)));
|
||||
|
||||
// --- 3) Base staff rows (non-parent users) ---
|
||||
// fetchStaffList() should call your model method getNoParentUsersWithRole($schoolYear, $selectedUserIds)
|
||||
$users = $this->fetchStaffList($schoolYear, $selectedUserIds);
|
||||
|
||||
// Normalize keys so the view logic is consistent:
|
||||
// Expect at least: id (or users.id), firstname, lastname, email, roles (CSV of non-parent roles)
|
||||
foreach ($users as &$u) {
|
||||
// Ensure user_id exists
|
||||
if (!isset($u['user_id'])) {
|
||||
if (isset($u['users.id'])) {
|
||||
$u['user_id'] = (int) $u['users.id'];
|
||||
} elseif (isset($u['id'])) {
|
||||
$u['user_id'] = (int) $u['id'];
|
||||
} elseif (isset($u['users_id'])) {
|
||||
$u['user_id'] = (int) $u['users_id'];
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure roles string exists (model may already provide it as 'roles')
|
||||
$rolesStr = $u['roles'] ?? '';
|
||||
if ($rolesStr === '' && isset($u['role_name'])) {
|
||||
// fallback if model returned single role
|
||||
$rolesStr = (string) $u['role_name'];
|
||||
}
|
||||
|
||||
// Keep raw + add formatted CSV
|
||||
$u['roles_raw'] = $rolesStr;
|
||||
$u['roles'] = $formatRolesCsv($rolesStr);
|
||||
|
||||
// Provide a role_name for legacy view code (pick a representative non-parent role)
|
||||
if (empty($u['role_name'])) {
|
||||
$firstRole = '';
|
||||
if ($rolesStr !== '') {
|
||||
$parts = array_filter(array_map('trim', explode(',', $rolesStr)));
|
||||
$firstRole = $parts[0] ?? '';
|
||||
}
|
||||
$u['role_name'] = $firstRole;
|
||||
}
|
||||
|
||||
// Keep raw + add formatted single role name
|
||||
$u['role_name_raw'] = $u['role_name'];
|
||||
$u['role_name'] = $formatRole((string)$u['role_name']);
|
||||
|
||||
// Defaults for class assignment (only filled for teacherish)
|
||||
$u['class_section_id'] = $u['class_section_id'] ?? null;
|
||||
$u['class_section_name'] = $u['class_section_name'] ?? null;
|
||||
}
|
||||
unset($u);
|
||||
|
||||
// --- 4) For teachers/assistants, fetch latest assignment in this year ---
|
||||
foreach ($users as &$u) {
|
||||
// Use RAW roles for detection to avoid any formatting side-effects
|
||||
$rolesDetect = strtolower($u['roles_raw'] ?? ($u['role_name_raw'] ?? ''));
|
||||
$isTeacherish = (strpos($rolesDetect, 'teacher') !== false) || preg_match('/\bta\b/', $rolesDetect);
|
||||
|
||||
if ($isTeacherish && !empty($u['user_id'])) {
|
||||
$assign = $this->getLatestClassForUser((int) $u['user_id'], $schoolYear);
|
||||
if ($assign) {
|
||||
$u['class_section_id'] = $assign['class_section_id'] ?? null;
|
||||
$u['class_section_name'] = $assign['class_section_name'] ?? null;
|
||||
}
|
||||
}
|
||||
}
|
||||
unset($u);
|
||||
|
||||
// --- 5) Build list of available school years (robust) ---
|
||||
$db = \Config\Database::connect();
|
||||
$schoolYears = [];
|
||||
|
||||
// Prefer teacher_class if available
|
||||
try {
|
||||
if (method_exists($db, 'tableExists') ? $db->tableExists('teacher_class') : true) {
|
||||
$q1 = $db->table('teacher_class')
|
||||
->select('DISTINCT school_year', false)
|
||||
->where('school_year IS NOT NULL', null, false)
|
||||
->orderBy('school_year', 'DESC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
$schoolYears = array_column($q1, 'school_year');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// ignore and try fallback
|
||||
}
|
||||
|
||||
// Fallback to user_roles if teacher_class didn't produce anything
|
||||
if (empty($schoolYears)) {
|
||||
try {
|
||||
if (method_exists($db, 'tableExists') ? $db->tableExists('user_roles') : true) {
|
||||
$fields = $db->getFieldNames('user_roles');
|
||||
if (in_array('school_year', $fields, true)) {
|
||||
$q2 = $db->table('user_roles')
|
||||
->select('DISTINCT school_year', false)
|
||||
->where('school_year IS NOT NULL', null, false)
|
||||
->orderBy('school_year', 'DESC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
$schoolYears = array_column($q2, 'school_year');
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// swallow; leave $schoolYears empty
|
||||
}
|
||||
}
|
||||
|
||||
// --- 6) Active role tab (defensive default for the view) ---
|
||||
$rolesTabs = [
|
||||
'teacher' => 'Teachers',
|
||||
'ta' => 'Teacher Assistants',
|
||||
'admin' => 'Admins',
|
||||
'staff' => 'Staff',
|
||||
];
|
||||
$requestedRole = $request->getGet('active_role') ?? $request->getPost('active_role') ?? null;
|
||||
$activeRole = array_key_exists((string)$requestedRole, $rolesTabs) ? (string)$requestedRole : 'teacher';
|
||||
|
||||
// --- 7) Pack data for the view ---
|
||||
$data = [
|
||||
'users' => $users,
|
||||
'schoolYears' => $schoolYears,
|
||||
'selectedYear' => $schoolYear,
|
||||
'selectedUserIds' => $selectedUserIds,
|
||||
'rolesTabs' => $rolesTabs,
|
||||
'active_role' => $activeRole, // prevents "Undefined array key 'active_role'"
|
||||
];
|
||||
|
||||
return view('printables_reports/badge_form', $data);
|
||||
}
|
||||
|
||||
protected function getUserInfoById($id, $schoolYear = null)
|
||||
{
|
||||
// ---- A) Resolve the user row
|
||||
$u = $this->db->table('users')
|
||||
->select('id AS user_id, firstname, lastname')
|
||||
->where('id', $id)
|
||||
->limit(1)
|
||||
->get()->getRowArray();
|
||||
|
||||
if (!$u) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$userId = (int) $u['user_id'];
|
||||
$fullname = trim(($u['firstname'] ?? '') . ' ' . ($u['lastname'] ?? ''));
|
||||
|
||||
// ---- B) Get roles (non-parent) for this user
|
||||
$rolesRows = $this->db->table('user_roles ur')
|
||||
->select('r.id AS role_id, r.name AS role_name')
|
||||
->join('roles r', 'r.id = ur.role_id', 'inner')
|
||||
->where('ur.user_id', $userId)
|
||||
->get()->getResultArray();
|
||||
|
||||
$allRoles = [];
|
||||
foreach ($rolesRows as $rr) {
|
||||
$name = trim((string)($rr['role_name'] ?? ''));
|
||||
if ($name !== '' && strtolower($name) !== 'parent') {
|
||||
// normalize to "Ucwords"
|
||||
$allRoles[] = ucwords(strtolower($name));
|
||||
}
|
||||
}
|
||||
$allRoles = array_values(array_unique($allRoles));
|
||||
$rolesCsv = $allRoles ? implode(', ', $allRoles) : '';
|
||||
|
||||
// ---- C) Latest assignment from teacher_class
|
||||
$tc = $this->db->table('teacher_class')
|
||||
->select('class_section_id, updated_at, id, position, school_year')
|
||||
->where('teacher_id', $userId);
|
||||
|
||||
if (!empty($schoolYear)) {
|
||||
$tc->where('school_year', $schoolYear);
|
||||
}
|
||||
|
||||
$assignment = $tc->orderBy('IFNULL(updated_at, "1970-01-01 00:00:00") DESC, id DESC', '', false)
|
||||
->limit(1)
|
||||
->get()->getRowArray();
|
||||
|
||||
$classId = $assignment['class_section_id'] ?? null;
|
||||
$position = strtolower(trim((string)($assignment['position'] ?? '')));
|
||||
$className = null;
|
||||
|
||||
if (!empty($classId)) {
|
||||
$className = $this->resolveClassName($this->db, $classId);
|
||||
}
|
||||
|
||||
// ---- D) Decide primary role label
|
||||
$roleLabel = '';
|
||||
if ($position === 'ta' || $position === 'teacher_assistant' || $position === 'assistant') {
|
||||
$roleLabel = 'Teacher Assistant';
|
||||
} elseif ($position === 'teacher') {
|
||||
$roleLabel = 'Teacher';
|
||||
} elseif (!empty($allRoles)) {
|
||||
// Use first role from normalized list
|
||||
$roleLabel = $allRoles[0];
|
||||
} else {
|
||||
$roleLabel = 'Staff';
|
||||
}
|
||||
|
||||
// ---- E) Logos
|
||||
$schoolLogo = $this->firstExisting([
|
||||
FCPATH . 'assets/images/school_logo.png',
|
||||
FCPATH . 'assets/images/logo.png',
|
||||
]);
|
||||
$isglLogo = $this->firstExisting([
|
||||
FCPATH . 'assets/images/isgl_logo.png',
|
||||
FCPATH . 'assets/images/isgl.png',
|
||||
]);
|
||||
|
||||
// ---- F) Build job title / display title
|
||||
$jobTitle = '';
|
||||
if (!empty($roleLabel) && !empty($className)) {
|
||||
$jobTitle = "{$roleLabel} - {$className}";
|
||||
} elseif (!empty($roleLabel)) {
|
||||
$jobTitle = $roleLabel;
|
||||
} elseif (!empty($className)) {
|
||||
$jobTitle = $className;
|
||||
}
|
||||
|
||||
return [
|
||||
'name' => $fullname !== '' ? $fullname : 'STAFF',
|
||||
'role' => $roleLabel, // primary label
|
||||
'roles' => $rolesCsv, // all roles (comma-separated)
|
||||
'class_section_id' => $classId ? (int)$classId : null,
|
||||
'class_section_name' => $className,
|
||||
'job_title' => $jobTitle,
|
||||
'school_name' => 'Al Rahma Sunday School',
|
||||
'school_id' => $userId,
|
||||
'school_logo' => $schoolLogo,
|
||||
'isgl_logo' => $isglLogo,
|
||||
'school_year' => $assignment['school_year'] ?? ($schoolYear ?? null),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Models\UserModel;
|
||||
use App\Services\EmailService;
|
||||
|
||||
class BroadcastEmailController extends BaseController
|
||||
{
|
||||
protected UserModel $userModel;
|
||||
protected EmailService $mailer;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->userModel = new UserModel();
|
||||
$this->mailer = new EmailService(); // use your existing mailer unmodified
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
helper(['form']);
|
||||
|
||||
// Parents via your role-based function
|
||||
$parents = $this->userModel->getParents();
|
||||
$parents = array_values(array_filter($parents, static fn($p) => !empty($p['email'])));
|
||||
|
||||
// Sender list from MAIL_SENDERS directly (no change to EmailService)
|
||||
$fromOptions = $this->senderOptionsFromEnv();
|
||||
|
||||
return view('administrator/broadcast_email', [
|
||||
'parents' => $parents,
|
||||
'fromOptions' => $fromOptions, // [['key'=>'general','label'=>'Al Rahma Office <office@...>'], ...]
|
||||
]);
|
||||
}
|
||||
|
||||
public function send()
|
||||
{
|
||||
helper(['form']);
|
||||
|
||||
if (strtolower($this->request->getMethod()) !== 'post') {
|
||||
return redirect()->to(site_url('admin/broadcast-email'));
|
||||
}
|
||||
|
||||
$isTestOnly = $this->request->getPost('send_test_only') !== null;
|
||||
|
||||
$mode = (string) $this->request->getPost('mode'); // 'personalized' | 'standard'
|
||||
$subject = trim((string) $this->request->getPost('subject'));
|
||||
$fromKey = trim((string) $this->request->getPost('from_key') ?: 'general');
|
||||
$body = (string) $this->request->getPost('body_html');
|
||||
$body = $this->sanitizeEmailHtml($body);
|
||||
|
||||
|
||||
// Layout options
|
||||
$wrapLayout = (bool) $this->request->getPost('wrap_layout');
|
||||
$preheader = (string) ($this->request->getPost('preheader') ?? '');
|
||||
$ctaText = (string) ($this->request->getPost('cta_text') ?? '');
|
||||
$ctaUrl = (string) ($this->request->getPost('cta_url') ?? '');
|
||||
|
||||
$testEmail = trim((string) $this->request->getPost('test_email'));
|
||||
|
||||
if ($subject === '' || $body === '') {
|
||||
return redirect()->back()->withInput()->with('error', 'Subject and Body are required.');
|
||||
}
|
||||
|
||||
$isPersonalized = ($mode === 'personalized');
|
||||
|
||||
// --- TEST ONLY ---
|
||||
if ($isTestOnly) {
|
||||
if ($testEmail === '') {
|
||||
return redirect()->back()->withInput()->with('error', 'Provide a test email address.');
|
||||
}
|
||||
|
||||
$recipientName = 'Parent';
|
||||
$html = $this->composeEmailHtml(
|
||||
$wrapLayout,
|
||||
$subject,
|
||||
$body,
|
||||
$recipientName,
|
||||
$preheader,
|
||||
$ctaText,
|
||||
$ctaUrl,
|
||||
$isPersonalized
|
||||
);
|
||||
|
||||
$ok = $this->mailer->send($testEmail, '[TEST] ' . $subject, $html, $fromKey);
|
||||
return redirect()->back()->with(
|
||||
$ok ? 'message' : 'error',
|
||||
$ok ? "Test email sent to {$testEmail}." : "Test email failed (mailer->send() returned false). Check logs."
|
||||
);
|
||||
}
|
||||
|
||||
// --- BROADCAST ---
|
||||
$rawIds = (array) ($this->request->getPost('parent_ids') ?? []);
|
||||
$ids = [];
|
||||
foreach ($rawIds as $v) {
|
||||
if (is_string($v) && strpos($v, ',') !== false) {
|
||||
$ids = array_merge($ids, array_map('intval', explode(',', $v)));
|
||||
} else {
|
||||
$ids[] = (int) $v;
|
||||
}
|
||||
}
|
||||
$ids = array_values(array_unique(array_filter($ids)));
|
||||
|
||||
if (empty($ids)) {
|
||||
return redirect()->back()->withInput()->with('error', 'Please select at least one parent.');
|
||||
}
|
||||
|
||||
$rows = model(\App\Models\UserModel::class)
|
||||
->select('users.id, users.email, CONCAT(users.firstname, " ", users.lastname) AS name')
|
||||
->whereIn('users.id', $ids)
|
||||
->where('users.email IS NOT NULL AND users.email != ""')
|
||||
->findAll();
|
||||
|
||||
if (empty($rows)) {
|
||||
return redirect()->back()->withInput()->with('error', 'No valid parent emails found.');
|
||||
}
|
||||
|
||||
$stats = ['attempted' => 0, 'sent' => 0, 'failed' => 0, 'mode' => $mode];
|
||||
|
||||
foreach ($rows as $r) {
|
||||
$stats['attempted']++;
|
||||
$recipientName = $r['name'] ?: 'Parent';
|
||||
|
||||
$html = $this->composeEmailHtml(
|
||||
$wrapLayout,
|
||||
$subject,
|
||||
$body,
|
||||
$recipientName,
|
||||
$preheader,
|
||||
$ctaText,
|
||||
$ctaUrl,
|
||||
$isPersonalized
|
||||
);
|
||||
|
||||
$ok = $this->mailer->send($r['email'], $subject, $html, $fromKey);
|
||||
$ok ? $stats['sent']++ : $stats['failed']++;
|
||||
}
|
||||
|
||||
$msg = "Broadcast finished. Mode: {$stats['mode']}. Sent: {$stats['sent']}/{$stats['attempted']}. Failures: {$stats['failed']}.";
|
||||
return redirect()->to(site_url('admin/broadcast-email'))
|
||||
->with($stats['failed'] > 0 ? 'error' : 'message', $msg);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Build HTML: optionally wrap $body inside your view('layout/email_layout', ...).
|
||||
*/
|
||||
private function composeEmailHtml(
|
||||
bool $wrap,
|
||||
string $subject,
|
||||
string $body,
|
||||
string $recipientName,
|
||||
string $preheader = '',
|
||||
string $ctaText = '',
|
||||
string $ctaUrl = '',
|
||||
bool $doPersonalize = true
|
||||
): string {
|
||||
$content = $doPersonalize ? str_replace('{{name}}', $recipientName, $body) : $body;
|
||||
|
||||
if (!$wrap) {
|
||||
return $content; // raw body
|
||||
}
|
||||
|
||||
// Render a CHILD view that defines the section your layout expects
|
||||
return view('emails/broadcast_wrapper', [
|
||||
'subject' => $subject,
|
||||
'content' => $content,
|
||||
// pass more if you later wire them in your layout
|
||||
'preheader' => $preheader,
|
||||
'cta_text' => $ctaText,
|
||||
'cta_url' => $ctaUrl,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
private function sanitizeEmailHtml(string $html): string
|
||||
{
|
||||
// Remove <script> and <iframe> blocks (cheap and effective for admin inputs)
|
||||
$html = preg_replace('#<(script|iframe)[^>]*>.*?</\1>#is', '', $html);
|
||||
// Optionally strip on* event handlers (onclick, etc.)
|
||||
$html = preg_replace('/\son\w+="[^"]*"/i', '', $html);
|
||||
$html = preg_replace("/\son\w+='[^']*'/i", '', $html);
|
||||
return $html;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Read MAIL_SENDERS from .env so we don’t need changes in EmailService.
|
||||
* Returns [['key' => 'general', 'label' => 'Al Rahma Office <office@...>'], ...]
|
||||
*/
|
||||
private function senderOptionsFromEnv(): array
|
||||
{
|
||||
$json = env('MAIL_SENDERS', '{}');
|
||||
$arr = json_decode($json, true);
|
||||
if (!is_array($arr) || empty($arr)) {
|
||||
// Fallback to SMTP_USER as a single "general" option
|
||||
$smtpUser = getenv('SMTP_USER') ?: '';
|
||||
$name = 'Al Rahma Sunday School';
|
||||
return [['key' => 'general', 'label' => $name . ($smtpUser ? " <{$smtpUser}>" : '')]];
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($arr as $key => $info) {
|
||||
$nm = $info['name'] ?? 'Sender';
|
||||
$em = $info['email'] ?? '';
|
||||
$out[] = ['key' => (string)$key, 'label' => trim($nm . ($em ? " <{$em}>" : ''))];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
public function uploadImage()
|
||||
{
|
||||
if (strtolower($this->request->getMethod()) !== 'post') {
|
||||
return $this->response->setStatusCode(405)->setJSON(['success' => false, 'error' => 'Method not allowed']);
|
||||
}
|
||||
|
||||
$file = $this->request->getFile('image');
|
||||
if (!$file || !$file->isValid()) {
|
||||
return $this->response->setStatusCode(400)->setJSON(['success' => false, 'error' => 'No image uploaded']);
|
||||
}
|
||||
|
||||
$mime = strtolower((string) $file->getMimeType());
|
||||
$allowed = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif', 'image/webp' => 'webp'];
|
||||
if (!isset($allowed[$mime])) {
|
||||
return $this->response->setStatusCode(415)->setJSON(['success' => false, 'error' => 'Unsupported image type']);
|
||||
}
|
||||
if ($file->getSize() > 5 * 1024 * 1024) {
|
||||
return $this->response->setStatusCode(413)->setJSON(['success' => false, 'error' => 'Image too large (max 5MB)']);
|
||||
}
|
||||
|
||||
$targetDir = rtrim(FCPATH, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'uploads' . DIRECTORY_SEPARATOR . 'email';
|
||||
if (!is_dir($targetDir)) {
|
||||
@mkdir($targetDir, 0755, true);
|
||||
}
|
||||
|
||||
$ext = $allowed[$mime];
|
||||
$newName = uniqid('em_', true) . '.' . $ext;
|
||||
$file->move($targetDir, $newName, true);
|
||||
|
||||
helper('url');
|
||||
$url = base_url('uploads/email/' . $newName);
|
||||
|
||||
// 👈 send the rotated token back both in JSON and a response header
|
||||
$newHash = function_exists('csrf_hash') ? csrf_hash() : null;
|
||||
return $this->response
|
||||
->setHeader('X-CSRF-HASH', (string) $newHash)
|
||||
->setJSON([
|
||||
'success' => true,
|
||||
'url' => $url,
|
||||
'csrf_hash' => $newHash,
|
||||
]);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
use App\Controllers\BaseController;
|
||||
use App\Models\ClassSectionModel;
|
||||
use App\Models\StudentModel;
|
||||
use App\Models\TeacherModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
|
||||
class ClassController extends BaseController
|
||||
{
|
||||
protected $classsectionModel;
|
||||
protected $studentModel;
|
||||
protected $teacherModel;
|
||||
protected $configModel;
|
||||
protected $semester;
|
||||
protected $schoolYear;
|
||||
|
||||
protected $db;
|
||||
|
||||
public function __construct(private \CodeIgniter\HTTP\IncomingRequest $request)
|
||||
{
|
||||
$this->db = \Config\Database::connect();
|
||||
$this->classsectionModel = new ClassSectionModel();
|
||||
|
||||
// Load necessary models
|
||||
$this->studentModel = new StudentModel();
|
||||
$this->teacherModel = new TeacherModel();
|
||||
$this->configModel = new ConfigurationModel();
|
||||
|
||||
// Get the semester from the configuration table
|
||||
$this->semester = $this->configModel->getConfig('semester');
|
||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
// Retrieve all classes from the database
|
||||
$classsection = $this->classsectionModel->getAllClasses();
|
||||
// Pass the classes to the view
|
||||
return view('administrator/class_section', ['classsection' => $classsection]);
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
// Show the form to create a new class
|
||||
return view('administrator/create_class');
|
||||
}
|
||||
|
||||
public function store()
|
||||
{
|
||||
// Handle the form submission to create a new class
|
||||
$data = [
|
||||
'name' => $this->request->getPost('name'),
|
||||
'description' => $this->request->getPost('description'),
|
||||
];
|
||||
//$this->classModel->createClass($data);
|
||||
return redirect()->to('/administrator/classes');
|
||||
}
|
||||
|
||||
public function edit($id)
|
||||
{
|
||||
// Retrieve the class details to edit
|
||||
//$class = $this->ClassSectionModel->getClassById($id);
|
||||
//return view('administrator/edit_class', ['class' => $class]);
|
||||
}
|
||||
|
||||
public function updateClass($id)
|
||||
{
|
||||
// Handle the form submission to update an existing class
|
||||
$data = [
|
||||
'name' => $this->request->getPost('name'),
|
||||
'description' => $this->request->getPost('description'),
|
||||
];
|
||||
//$this->classModel->updateClass($id, $data);
|
||||
return redirect()->to('/administrator/classes');
|
||||
}
|
||||
|
||||
public function destroyClass($id)
|
||||
{
|
||||
// Delete the class
|
||||
//$this->classModel->deleteClass($id);
|
||||
return redirect()->to('/administrator/classes');
|
||||
}
|
||||
|
||||
public function addClasses()
|
||||
{
|
||||
// Include the shared database connection file
|
||||
$file = __DIR__ . '/../db_connection.php';
|
||||
|
||||
if (file_exists($file)) {
|
||||
require_once $file;
|
||||
} else {
|
||||
die("Error: Could not find the required file '$file'.");
|
||||
}
|
||||
|
||||
|
||||
$classes = [
|
||||
['class_name' => 'Class 1', 'teacher_id' => 1, 'schedule' => 'Monday 9:00 AM - 10:00 AM', 'capacity' => 30],
|
||||
['class_name' => 'Class 2', 'teacher_id' => 2, 'schedule' => 'Tuesday 9:00 AM - 10:00 AM', 'capacity' => 30],
|
||||
['class_name' => 'Class 3', 'teacher_id' => 3, 'schedule' => 'Wednesday 9:00 AM - 10:00 AM', 'capacity' => 30],
|
||||
['class_name' => 'Class 4', 'teacher_id' => 4, 'schedule' => 'Thursday 9:00 AM - 10:00 AM', 'capacity' => 30],
|
||||
['class_name' => 'Class 5', 'teacher_id' => 5, 'schedule' => 'Friday 9:00 AM - 10:00 AM', 'capacity' => 30],
|
||||
['class_name' => 'Class 6', 'teacher_id' => 6, 'schedule' => 'Monday 10:00 AM - 11:00 AM', 'capacity' => 30],
|
||||
['class_name' => 'Class 7', 'teacher_id' => 7, 'schedule' => 'Tuesday 10:00 AM - 11:00 AM', 'capacity' => 30],
|
||||
['class_name' => 'Class 8', 'teacher_id' => 8, 'schedule' => 'Wednesday 10:00 AM - 11:00 AM', 'capacity' => 30],
|
||||
['class_name' => 'Class 9', 'teacher_id' => 9, 'schedule' => 'Thursday 10:00 AM - 11:00 AM', 'capacity' => 30],
|
||||
['class_name' => 'Youth', 'teacher_id' => 10, 'schedule' => 'Friday 10:00 AM - 11:00 AM', 'capacity' => 30],
|
||||
];
|
||||
|
||||
foreach ($classes as $class) {
|
||||
$stmt = $conn->prepare("INSERT INTO classes (class_name, teacher_id, schedule, capacity) VALUES (?, ?, ?, ?)");
|
||||
if (!$stmt) {
|
||||
die("Prepare failed: (" . $conn->errno . ") " . $conn->error);
|
||||
}
|
||||
$stmt->bind_param("sisi", $class['class_name'], $class['teacher_id'], $class['schedule'], $class['capacity']);
|
||||
$stmt->execute();
|
||||
$stmt->close();
|
||||
}
|
||||
|
||||
$conn->close();
|
||||
|
||||
return redirect()->to('/parent/classes')->with('success', 'Classes added successfully.');
|
||||
}
|
||||
|
||||
public function classAttendance($class_section_id)
|
||||
{
|
||||
// Get the teacher's ID from the session
|
||||
$teacherId = session()->get('user_id');
|
||||
|
||||
// Fetch teacher data
|
||||
$teacherData = $this->teacherModel->find($teacherId);
|
||||
|
||||
// Fetch students for the class
|
||||
$studentsData = $this->studentModel->join('student_class', 'students.id = student_class.student_id')
|
||||
->where('student_class.class_section_id', $class_section_id)
|
||||
->select('students.*, student_class.class_section_id')
|
||||
->findAll();
|
||||
|
||||
// Get class name (assuming it's stored in the students data)
|
||||
$className = !empty($studentsData) && isset($studentsData[0]['class_name']) ? $studentsData[0]['class_name'] : 'Class Name Not Available';
|
||||
|
||||
// Check if the teacher data and students data are available
|
||||
if (empty($teacherData) || empty($studentsData)) {
|
||||
return redirect()->to('/teacher/classes')->with('error', 'No data found for this class.');
|
||||
}
|
||||
|
||||
// Pass data to the view
|
||||
return view('/teacher/classes', [
|
||||
'teacher_name' => $teacherData['teacher_first'] . ' ' . $teacherData['teacher_last'],
|
||||
'students' => $studentsData,
|
||||
'class_name' => $className,
|
||||
'semester' => $this->semester,
|
||||
'class_section_id' => $class_section_id, // Pass the class_id for attendance update forms
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
class ClassPrepController extends PrintablesBaseController
|
||||
{
|
||||
// Compute per-student sticker counts for entire schoolYear (optionally semester).
|
||||
protected function computeStickerCountsAll(\CodeIgniter\Database\BaseConnection $db, string $schoolYear, ?string $semester = null): array
|
||||
{
|
||||
return $this->computeStickerCounts($schoolYear, null, $semester);
|
||||
}
|
||||
|
||||
// Compute per-student sticker counts for one class section.
|
||||
protected function computeStickerCountsForClass(\CodeIgniter\Database\BaseConnection $db, string $schoolYear, int $classSectionId, ?string $semester = null): array
|
||||
{
|
||||
return $this->computeStickerCounts($schoolYear, $classSectionId, $semester);
|
||||
}
|
||||
|
||||
protected function computeStickerCounts(
|
||||
string $schoolYear,
|
||||
?int $classSectionId,
|
||||
?string $semester
|
||||
): array {
|
||||
$specialMap = ['KG' => 1, '1-A' => 3, '1-B' => 4, '2-A' => 5, '2-B' => 5, '9' => 2];
|
||||
$isUpperBlock = static fn(string $g) => (bool) preg_match('/^(3|4|5|6|7|8)(-[AB])?$/', trim($g));
|
||||
$isGrade5 = static fn(string $g) => (bool) preg_match('/^5(\-|$)/', trim($g));
|
||||
|
||||
$b = $this->db->table('student_class sc')
|
||||
->select('sc.student_id, s.firstname, s.lastname, s.is_new, cs.class_section_name AS grade_label')
|
||||
->join('students s', 's.id = sc.student_id', 'inner')
|
||||
->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'inner')
|
||||
->join('classes c', 'c.id = cs.class_id', 'inner')
|
||||
->where('sc.school_year', $schoolYear);
|
||||
|
||||
if (!empty($classSectionId)) {
|
||||
$b->where('sc.class_section_id', $classSectionId);
|
||||
}
|
||||
|
||||
$rows = $b->get()->getResultArray();
|
||||
|
||||
// Exclude Youth and KG (case-insensitive; also ignore variants like youth-*, KG-*)
|
||||
$rows = array_values(array_filter($rows, static function ($r) {
|
||||
$g = trim((string) $r['grade_label']);
|
||||
return $g !== ''
|
||||
&& !preg_match('/^youth(?:\b|-)/i', $g)
|
||||
&& !preg_match('/^kg(?:\b|-)/i', $g);
|
||||
}));
|
||||
|
||||
$students = [];
|
||||
$total = 0;
|
||||
|
||||
foreach ($rows as $r) {
|
||||
$g = trim((string) $r['grade_label']);
|
||||
$isNew = ((int) $r['is_new']) === 1;
|
||||
|
||||
// Primary-only rules
|
||||
if (array_key_exists($g, $specialMap)) {
|
||||
$p = $specialMap[$g];
|
||||
} elseif ($isUpperBlock($g)) {
|
||||
$p = $isNew ? 4 : ($isGrade5($g) ? 3 : 2);
|
||||
} else {
|
||||
$p = 4;
|
||||
}
|
||||
|
||||
$total += $p;
|
||||
|
||||
$students[] = [
|
||||
'student_id' => (int) $r['student_id'],
|
||||
'firstname' => trim($r['firstname']),
|
||||
'lastname' => trim($r['lastname']),
|
||||
'grade_label' => $g,
|
||||
'primary_count' => $p,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'students' => $students,
|
||||
'totals' => [
|
||||
'stickers' => $total,
|
||||
'students' => count($students),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute sticker counts per student (no file I/O).
|
||||
*
|
||||
* @param BaseConnection $db
|
||||
* @param string $schoolYear e.g. "2025-2026"
|
||||
* @param string|null $semester e.g. "Fall" (optional filter)
|
||||
* @return array{
|
||||
* students: array<int, array{
|
||||
* student_id:int,
|
||||
* firstname:string,
|
||||
* lastname:string,
|
||||
* grade_label:string,
|
||||
* primary_count:int,
|
||||
* secondary_count:int
|
||||
* }>,
|
||||
* totals: array{primary:int, secondary:int, students:int}
|
||||
* }
|
||||
*/
|
||||
private function getStickerCountsPerStudent(string $schoolYear, ?string $semester = null): array
|
||||
{
|
||||
// --- Sticker rules helpers ----------------------------------------------
|
||||
$specialMap = [
|
||||
'KG' => 1,
|
||||
'1-A' => 3,
|
||||
'1-B' => 4,
|
||||
'2-A' => 5,
|
||||
'2-B' => 5,
|
||||
'9' => 2,
|
||||
];
|
||||
$isUpperBlock = static function (string $g): bool {
|
||||
// 3-A, 3-B, 4-A, 4-B, 5-A, 5-B, 6, 7, 8 (also allow plain 3/4/5/6/7/8)
|
||||
return (bool) preg_match('/^(3|4|5|6|7|8)(-[AB])?$/', trim($g));
|
||||
};
|
||||
$isGrade5 = static function (string $g): bool {
|
||||
// "5" OR "5-A"/"5-B"
|
||||
return (bool) preg_match('/^5(\-|$)/', trim($g));
|
||||
};
|
||||
|
||||
// --- Pull roster: student_class -> classSection -> classes -> students ---
|
||||
$builder = $this->db->table('student_class sc')
|
||||
->select([
|
||||
'sc.student_id',
|
||||
's.firstname',
|
||||
's.lastname',
|
||||
's.is_new',
|
||||
'cs.class_section_name AS grade_label',
|
||||
])
|
||||
->join('students s', 's.id = sc.student_id')
|
||||
->join('classSection cs', 'cs.class_section_id = sc.class_section_id')
|
||||
->join('classes c', 'c.id = cs.class_id')
|
||||
->where('sc.school_year', $schoolYear);
|
||||
|
||||
|
||||
$rows = $builder->get()->getResultArray();
|
||||
|
||||
// Filter out Youth (any case; includes youth-*), and empty labels
|
||||
$rows = array_values(array_filter($rows, static function ($r) {
|
||||
$g = trim((string) $r['grade_label']);
|
||||
return $g !== '' && strcasecmp($g, 'youth') !== 0 && strncasecmp($g, 'youth-', 6) !== 0;
|
||||
}));
|
||||
|
||||
$out = [];
|
||||
$totalPrimary = 0;
|
||||
$totalSecondary = 0;
|
||||
|
||||
foreach ($rows as $r) {
|
||||
$first = trim((string) $r['firstname']);
|
||||
$last = trim((string) $r['lastname']);
|
||||
$grade = trim((string) $r['grade_label']);
|
||||
$isNew = ((int) $r['is_new']) === 1;
|
||||
|
||||
$primary = 0;
|
||||
$secondary = 0;
|
||||
|
||||
// 1) Special fixed grades
|
||||
if (array_key_exists($grade, $specialMap)) {
|
||||
$primary = (int) $specialMap[$grade];
|
||||
$secondary = 0;
|
||||
}
|
||||
// 2) Upper block 3..8
|
||||
elseif ($isUpperBlock($grade)) {
|
||||
if ($isNew) {
|
||||
$primary = 4;
|
||||
$secondary = 0;
|
||||
} else {
|
||||
$primary = $isGrade5($grade) ? 3 : 2;
|
||||
$secondary = max(0, 4 - $primary);
|
||||
}
|
||||
}
|
||||
// 3) Fallback for other non-Youth labels (e.g., "1", "2", "10", "11")
|
||||
else {
|
||||
$primary = 4;
|
||||
$secondary = 0;
|
||||
}
|
||||
|
||||
$totalPrimary += $primary;
|
||||
$totalSecondary += $secondary;
|
||||
|
||||
$out[] = [
|
||||
'student_id' => (int) $r['student_id'],
|
||||
'firstname' => $first,
|
||||
'lastname' => $last,
|
||||
'grade_label' => $grade,
|
||||
'primary_count' => $primary,
|
||||
'secondary_count' => $secondary,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'students' => $out,
|
||||
'totals' => [
|
||||
'primary' => $totalPrimary,
|
||||
'secondary' => $totalSecondary,
|
||||
'students' => count($out),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
protected function getStickerCountsPerStudentForClass(
|
||||
string $schoolYear,
|
||||
int $classSectionId,
|
||||
?string $semester = null
|
||||
): array {
|
||||
// Reuse exact helpers/rules from getStickerCountsPerStudent()
|
||||
$specialMap = [
|
||||
'1-A' => 3,
|
||||
'1-B' => 4,
|
||||
'2-A' => 5,
|
||||
'2-B' => 5,
|
||||
'9' => 2,
|
||||
];
|
||||
$isUpperBlock = static fn($g) => (bool) preg_match('/^(3|4|5|6|7|8)(-[AB])?$/', trim($g));
|
||||
$isGrade5 = static fn($g) => (bool) preg_match('/^5(\-|$)/', trim($g));
|
||||
|
||||
$b = $this->db->table('student_class sc')
|
||||
->select('sc.student_id, s.firstname, s.lastname, s.is_new, cs.class_section_name AS grade_label')
|
||||
->join('students s', 's.id = sc.student_id')
|
||||
->join('classSection cs', 'cs.class_section_id = sc.class_section_id')
|
||||
->join('classes c', 'c.id = cs.class_id')
|
||||
->where('sc.school_year', $schoolYear)
|
||||
->where('sc.class_section_id', $classSectionId);
|
||||
|
||||
|
||||
$rows = $b->get()->getResultArray();
|
||||
|
||||
$rows = array_values(array_filter($rows, static function ($r) {
|
||||
$g = trim((string) $r['grade_label']);
|
||||
return $g !== '' && strcasecmp($g, 'youth') !== 0 && strncasecmp($g, 'youth-', 6) !== 0;
|
||||
}));
|
||||
|
||||
$out = [];
|
||||
$tp = 0;
|
||||
$ts = 0;
|
||||
foreach ($rows as $r) {
|
||||
$g = trim((string) $r['grade_label']);
|
||||
$isNew = ((int) $r['is_new']) === 1;
|
||||
$p = 0;
|
||||
$s = 0;
|
||||
|
||||
if (isset($specialMap[$g])) {
|
||||
$p = $specialMap[$g];
|
||||
} elseif ($isUpperBlock($g)) {
|
||||
if ($isNew) {
|
||||
$p = 4;
|
||||
} else {
|
||||
$p = $isGrade5($g) ? 3 : 2;
|
||||
$s = max(0, 4 - $p);
|
||||
}
|
||||
} else {
|
||||
$p = 4;
|
||||
}
|
||||
|
||||
$tp += $p;
|
||||
$ts += $s;
|
||||
|
||||
$out[] = [
|
||||
'student_id' => (int) $r['student_id'],
|
||||
'firstname' => trim($r['firstname']),
|
||||
'lastname' => trim($r['lastname']),
|
||||
'grade_label' => $g,
|
||||
'primary_count' => $p,
|
||||
'secondary_count' => $s,
|
||||
];
|
||||
}
|
||||
|
||||
return ['students' => $out, 'totals' => ['primary' => $tp, 'secondary' => $ts, 'students' => count($out)]];
|
||||
}
|
||||
|
||||
public function previewStickerCounts()
|
||||
{
|
||||
// Optional: allow class_id filter; if present, preview only that class
|
||||
$classId = (int) ($this->request->getGet('class_id') ?? 0);
|
||||
|
||||
if ($classId > 0) {
|
||||
// same join as getStickerCountsPerStudent but add class filter
|
||||
$result = $this->getStickerCountsPerStudentForClass($this->schoolYear, $classId, $this->semester);
|
||||
} else {
|
||||
// whole school year
|
||||
$result = $this->getStickerCountsPerStudent($this->schoolYear, $this->semester);
|
||||
}
|
||||
|
||||
return $this->response->setJSON([
|
||||
'status' => 'ok',
|
||||
'data' => $result
|
||||
]);
|
||||
}
|
||||
|
||||
public function studentsByClass(int $classSectionId)
|
||||
{
|
||||
$rows = $this->studentModel
|
||||
->select('students.id, students.firstname, students.lastname, students.gender, cs.class_section_name AS registration_grade')
|
||||
->join('student_class sc', 'sc.student_id = students.id', 'inner')
|
||||
->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'inner')
|
||||
->where('sc.school_year', $this->schoolYear)
|
||||
->where('sc.class_section_id', $classSectionId)
|
||||
->groupBy('students.id, students.firstname, students.lastname, students.gender, cs.class_section_name')
|
||||
->orderBy('students.firstname', 'ASC')
|
||||
->orderBy('students.lastname', 'ASC')
|
||||
->findAll(1000);
|
||||
|
||||
return $this->response->setJSON($rows);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,608 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Models\StudentClassModel;
|
||||
use App\Models\ClassPreparationLogModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
use App\Models\ClassSectionModel;
|
||||
use App\Models\ClassPrepAdjustmentModel;
|
||||
use App\Models\UserModel;
|
||||
|
||||
class ClassPreparationController extends BaseController
|
||||
{
|
||||
protected $studentClassModel;
|
||||
protected $prepLogModel;
|
||||
protected $classSectionModel;
|
||||
protected $configModel;
|
||||
protected $userModel;
|
||||
protected $db;
|
||||
protected $schoolYear;
|
||||
protected $semester;
|
||||
protected $adjustmentModel;
|
||||
/** cache for roster presence per term */
|
||||
private array $rosterPresenceCache = [];
|
||||
// Inside ClassPreparationController (class scope, not inside a method)
|
||||
private array $allowedPrepCategories = [
|
||||
'Grade Box',
|
||||
'Large Table',
|
||||
'Regular Chair',
|
||||
'Small Chair',
|
||||
'Small Table',
|
||||
'Teacher Chair',
|
||||
'Trash Bin',
|
||||
'White Board',
|
||||
];
|
||||
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->studentClassModel = new StudentClassModel();
|
||||
$this->prepLogModel = new ClassPreparationLogModel();
|
||||
$this->classSectionModel = new ClassSectionModel();
|
||||
$this->configModel = new ConfigurationModel();
|
||||
$this->adjustmentModel = new ClassPrepAdjustmentModel();
|
||||
|
||||
$this->userModel = new UserModel();
|
||||
$this->db = \Config\Database::connect();
|
||||
|
||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||
$this->semester = $this->configModel->getConfig('semester');
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$schoolYear = (string)($this->request->getGet('school_year') ?? $this->schoolYear);
|
||||
$semParam = $this->request->getGet('semester');
|
||||
$semester = (is_string($semParam) && $semParam !== '') ? (string)$semParam : (string)$this->semester;
|
||||
$allowed = $this->allowedPrepCategories;
|
||||
$limitToSemester = $this->hasRosterForSemester($schoolYear, $semester);
|
||||
|
||||
// 1) Get student count per class-section (distinct students, correct semester)
|
||||
$scQ = $this->studentClassModel
|
||||
->select('class_section_id, COUNT(DISTINCT student_id) AS student_count')
|
||||
->where('school_year', $schoolYear);
|
||||
if ($limitToSemester && $semester !== '') {
|
||||
$scQ->where('semester', $semester);
|
||||
}
|
||||
$classSections = $scQ->groupBy('class_section_id')->findAll();
|
||||
|
||||
// 2) Inventory availability — prefer good_qty when present, else condition='good' quantity.
|
||||
$inventoryMap = $this->buildInventoryAvailability($schoolYear, $semester, $limitToSemester, $allowed);
|
||||
|
||||
// Seed totals with allowed categories for stable ordering
|
||||
$requiredTotals = array_fill_keys($allowed, 0);
|
||||
$prepResults = [];
|
||||
|
||||
// 3) Build prep per section (once!)
|
||||
foreach ($classSections as $section) {
|
||||
$classSectionId = $section['class_section_id'];
|
||||
$studentCount = (int)$section['student_count'];
|
||||
$classLevel = $this->getClassLevelBySection($classSectionId);
|
||||
$className = $this->classSectionModel->getClassSectionNameBySectionId($classSectionId);
|
||||
|
||||
// Calculate required items (whitelist inside)
|
||||
$baseItems = $this->calculatePrepItems($studentCount, $classLevel, $classSectionId);
|
||||
|
||||
// --- Apply adjustments (only Small/Large Table), clamp >= 0 ---
|
||||
$rawAdjustments = $this->adjustmentModel
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('adjustable', 1)
|
||||
->findAll();
|
||||
|
||||
$adjMap = ['Large Table' => 0, 'Small Table' => 0];
|
||||
|
||||
foreach ($rawAdjustments as $a) {
|
||||
$item = $a['item_name'];
|
||||
$delta = (int)$a['adjustment'];
|
||||
|
||||
if (array_key_exists($item, $adjMap)) {
|
||||
$adjMap[$item] = $delta;
|
||||
}
|
||||
|
||||
if (isset($baseItems[$item])) {
|
||||
$baseItems[$item] = max(0, (int)$baseItems[$item] + $delta);
|
||||
} elseif (in_array($item, $allowed, true)) {
|
||||
$baseItems[$item] = max(0, $delta);
|
||||
}
|
||||
}
|
||||
|
||||
// 4) Accumulate global totals (allowed only)
|
||||
foreach ($allowed as $cat) {
|
||||
$requiredTotals[$cat] += (int)($baseItems[$cat] ?? 0);
|
||||
}
|
||||
|
||||
// 5) Compare with last snapshot; do not save here — only on print or explicit API
|
||||
$oldSnap = $this->prepLogModel
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('school_year', $schoolYear)
|
||||
->orderBy('created_at', 'DESC')
|
||||
->first();
|
||||
|
||||
$oldPrep = $oldSnap ? json_decode($oldSnap['prep_data'], true) : [];
|
||||
$hasChanged = $this->hasPrepChanged($baseItems, $oldPrep);
|
||||
|
||||
// 6) Push exactly ONCE
|
||||
$prepResults[] = [
|
||||
'class_section' => $className,
|
||||
'class_section_id' => $classSectionId,
|
||||
'student_count' => $studentCount,
|
||||
'class_level' => $classLevel,
|
||||
'prep_items' => $baseItems,
|
||||
'needs_print' => $hasChanged,
|
||||
'last_printed_at' => $oldSnap['created_at'] ?? null,
|
||||
'adjustments' => $adjMap,
|
||||
];
|
||||
}
|
||||
|
||||
// 7) Shortages (compare totals vs inventory)
|
||||
$shortages = [];
|
||||
foreach ($requiredTotals as $item => $reqQty) {
|
||||
$have = (int)($inventoryMap[$item] ?? 0);
|
||||
if ($have < (int)$reqQty) {
|
||||
$shortages[$item] = (int)$reqQty - $have;
|
||||
}
|
||||
}
|
||||
|
||||
return view('class_prep/list', [
|
||||
'prepResults' => $prepResults,
|
||||
'schoolYear' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
'shortages' => $shortages,
|
||||
'totalNeeded' => $requiredTotals,
|
||||
'available' => $inventoryMap,
|
||||
// For temporary debugging, you can pass these too:
|
||||
// 'joinGood' => $joinGood, 'nameGood' => $nameGood, 'joinCond' => $joinCond, 'nameCond' => $nameCond,
|
||||
]);
|
||||
}
|
||||
|
||||
private function getTeacherCountForSection(string $classSectionId, ?string $schoolYear = null): int
|
||||
{
|
||||
$schoolYear = $schoolYear ?? $this->schoolYear;
|
||||
|
||||
// positions: 'main' and 'ta' (per your new schema)
|
||||
$row = $this->db->table('teacher_class')
|
||||
->select('COUNT(*) AS cnt')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->whereIn('position', ['main', 'ta'])
|
||||
->where('school_year', $schoolYear)
|
||||
->get()->getRowArray();
|
||||
|
||||
return (int)($row['cnt'] ?? 0);
|
||||
}
|
||||
|
||||
private function calculatePrepItems(int $students, int $classLevel, string $classSectionId): array
|
||||
{
|
||||
// 1) Stable keys: your whitelist, all zero to start
|
||||
$allowed = $this->allowedPrepCategories;
|
||||
$items = array_fill_keys($allowed, 0);
|
||||
|
||||
// 2) Fetch only the classroom categories you care about (optionally with grade bounds)
|
||||
$categories = $this->db->table('inventory_categories')
|
||||
->select('name, grade_min, grade_max')
|
||||
->where('type', 'classroom')
|
||||
->whereIn('name', $allowed)
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
// 3) Rules
|
||||
$isLowerGrades = in_array($classLevel, [1, 2], true);
|
||||
$teacherCount = $this->getTeacherCountForSection($classSectionId, $this->schoolYear);
|
||||
|
||||
foreach ($categories as $cat) {
|
||||
$name = $cat['name']; // e.g., 'Small Table'
|
||||
$gradeMin = $cat['grade_min']; // may be null
|
||||
$gradeMax = $cat['grade_max']; // may be null
|
||||
|
||||
// Respect optional bounds
|
||||
if ($gradeMin !== null && $classLevel < (int)$gradeMin) {
|
||||
$items[$name] = 0;
|
||||
continue;
|
||||
}
|
||||
if ($gradeMax !== null && $classLevel > (int)$gradeMax) {
|
||||
$items[$name] = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
$qty = 0;
|
||||
switch (strtolower($name)) {
|
||||
case 'small table':
|
||||
$qty = $isLowerGrades ? (int)ceil($students / 3) : 0;
|
||||
break;
|
||||
case 'large table':
|
||||
$qty = $isLowerGrades ? 0 : (int)ceil($students / 4);
|
||||
break;
|
||||
case 'small chair':
|
||||
$qty = $isLowerGrades ? $students : 0;
|
||||
break;
|
||||
case 'regular chair':
|
||||
$qty = $isLowerGrades ? 0 : $students;
|
||||
break;
|
||||
case 'teacher chair':
|
||||
$qty = (int)$teacherCount;
|
||||
break;
|
||||
case 'trash bin':
|
||||
case 'white board':
|
||||
case 'grade box':
|
||||
$qty = 1;
|
||||
break;
|
||||
default:
|
||||
$qty = 0;
|
||||
}
|
||||
|
||||
// Only set for allowed keys (guards against unexpected DB rows)
|
||||
if (array_key_exists($name, $items)) {
|
||||
$items[$name] = $qty;
|
||||
}
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
|
||||
public function saveAdjustment()
|
||||
{
|
||||
$data = $this->request->getPost();
|
||||
$sectionId = $data['class_section_id'];
|
||||
$schoolYear = $data['school_year'];
|
||||
$adjustments = $data['adjustments'] ?? [];
|
||||
|
||||
foreach ($adjustments as $itemName => $adjustment) {
|
||||
$row = $this->adjustmentModel
|
||||
->where('class_section_id', $sectionId)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('item_name', $itemName)
|
||||
->first();
|
||||
|
||||
if ($row) {
|
||||
// Update
|
||||
$this->adjustmentModel->update($row['id'], ['adjustment' => (int)$adjustment, 'adjustable' => 1]);
|
||||
} else {
|
||||
// Insert
|
||||
$this->adjustmentModel->insert([
|
||||
'class_section_id' => $sectionId,
|
||||
'item_name' => $itemName,
|
||||
'adjustment' => (int)$adjustment,
|
||||
'school_year' => $schoolYear,
|
||||
'adjustable' => 1,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()->to(site_url('class-prep?school_year=' . urlencode($schoolYear)))
|
||||
->with('success', 'Adjustments saved successfully.');
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ---------- helpers ---------- */
|
||||
|
||||
private function hasPrepChanged(array $new, array $old): bool
|
||||
{
|
||||
ksort($new);
|
||||
ksort($old);
|
||||
|
||||
return json_encode($new) !== json_encode($old);
|
||||
}
|
||||
|
||||
public function print($classSectionId, $schoolYear)
|
||||
{
|
||||
$semParam = $this->request->getGet('semester');
|
||||
$semester = (is_string($semParam) && $semParam !== '') ? (string) $semParam : (string) $this->semester;
|
||||
$limitToSemester = $this->hasRosterForSemester((string) $schoolYear, $semester);
|
||||
|
||||
// Friendly label (if you have this helper)
|
||||
$className = $this->classSectionModel->getClassSectionNameBySectionId($classSectionId) ?? $classSectionId;
|
||||
|
||||
// Distinct student count for this term
|
||||
$studentQ = $this->studentClassModel
|
||||
->select('COUNT(DISTINCT student_id) AS cnt')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('school_year', $schoolYear);
|
||||
if ($limitToSemester && $semester !== '') {
|
||||
$studentQ->where('semester', $semester);
|
||||
}
|
||||
$studentRow = $studentQ->first();
|
||||
$studentCount = (int)($studentRow['cnt'] ?? 0);
|
||||
|
||||
// Live calc + adjustments
|
||||
$classLevel = $this->getClassLevelBySection((string)$classSectionId);
|
||||
$items = $this->calculatePrepItems($studentCount, $classLevel, (string)$classSectionId);
|
||||
|
||||
$rawAdjustments = $this->adjustmentModel
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('adjustable', 1)
|
||||
->findAll();
|
||||
foreach ($rawAdjustments as $a) {
|
||||
$item = $a['item_name'];
|
||||
$delta = (int)$a['adjustment'];
|
||||
if (isset($items[$item])) $items[$item] = max(0, (int)$items[$item] + $delta);
|
||||
}
|
||||
|
||||
// Record a snapshot at print time as the new baseline
|
||||
$this->prepLogModel->insert([
|
||||
'class_section_id' => (string)$classSectionId,
|
||||
'class_section' => $className,
|
||||
'school_year' => $schoolYear,
|
||||
'prep_data' => json_encode($items),
|
||||
'created_at' => utc_now(),
|
||||
]);
|
||||
|
||||
// Return PRINT view (HTML) that auto-opens the print dialog
|
||||
return view('class_prep/print', [
|
||||
'classSectionId' => $classSectionId,
|
||||
'classSection' => $className,
|
||||
'schoolYear' => $schoolYear,
|
||||
'prepItems' => $items,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* API: Return class prep data and change flags for all sections in a year.
|
||||
* GET: school_year
|
||||
* Response: { ok, schoolYear, results:[{ class_section_id, class_section, student_count, class_level, prep_items, needs_print, last_printed_at }], totals, shortages }
|
||||
*/
|
||||
public function apiList()
|
||||
{
|
||||
$schoolYear = (string)($this->request->getGet('school_year') ?? $this->schoolYear);
|
||||
$semParam = $this->request->getGet('semester');
|
||||
$semester = (is_string($semParam) && $semParam !== '') ? (string)$semParam : (string)$this->semester;
|
||||
$allowed = $this->allowedPrepCategories;
|
||||
$limitToSemester = $this->hasRosterForSemester($schoolYear, $semester);
|
||||
|
||||
// Student counts
|
||||
$scQ = $this->studentClassModel
|
||||
->select('class_section_id, COUNT(DISTINCT student_id) AS student_count')
|
||||
->where('school_year', $schoolYear);
|
||||
if ($limitToSemester && $semester !== '') {
|
||||
$scQ->where('semester', $semester);
|
||||
}
|
||||
$classSections = $scQ->groupBy('class_section_id')->findAll();
|
||||
|
||||
// Build inventory availability maps
|
||||
$inventoryMap = $this->buildInventoryAvailability($schoolYear, $semester, $limitToSemester, $allowed);
|
||||
|
||||
$requiredTotals = array_fill_keys($allowed, 0);
|
||||
$results = [];
|
||||
|
||||
foreach ($classSections as $row) {
|
||||
$classSectionId = (string)$row['class_section_id'];
|
||||
$studentCount = (int)$row['student_count'];
|
||||
$classLevel = $this->getClassLevelBySection($classSectionId);
|
||||
$className = $this->classSectionModel->getClassSectionNameBySectionId($classSectionId);
|
||||
|
||||
$baseItems = $this->calculatePrepItems($studentCount, $classLevel, $classSectionId);
|
||||
$rawAdjustments = $this->adjustmentModel
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('adjustable', 1)
|
||||
->findAll();
|
||||
$adjMap = ['Large Table' => 0, 'Small Table' => 0];
|
||||
foreach ($rawAdjustments as $a) {
|
||||
$item = $a['item_name'];
|
||||
$delta = (int)$a['adjustment'];
|
||||
if (array_key_exists($item, $adjMap)) $adjMap[$item] = $delta;
|
||||
if (isset($baseItems[$item])) $baseItems[$item] = max(0, (int)$baseItems[$item] + $delta);
|
||||
}
|
||||
|
||||
foreach ($allowed as $cat) {
|
||||
$requiredTotals[$cat] += (int)($baseItems[$cat] ?? 0);
|
||||
}
|
||||
|
||||
$oldSnap = $this->prepLogModel
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('school_year', $schoolYear)
|
||||
->orderBy('created_at', 'DESC')
|
||||
->first();
|
||||
$oldPrep = $oldSnap ? json_decode($oldSnap['prep_data'], true) : [];
|
||||
$hasChanged = $this->hasPrepChanged($baseItems, $oldPrep);
|
||||
|
||||
$results[] = [
|
||||
'class_section' => $className,
|
||||
'class_section_id' => $classSectionId,
|
||||
'student_count' => $studentCount,
|
||||
'class_level' => $classLevel,
|
||||
'prep_items' => $baseItems,
|
||||
'needs_print' => $hasChanged,
|
||||
'last_printed_at' => $oldSnap['created_at'] ?? null,
|
||||
'adjustments' => $adjMap,
|
||||
];
|
||||
}
|
||||
|
||||
// Shortages
|
||||
$shortages = [];
|
||||
foreach ($requiredTotals as $item => $reqQty) {
|
||||
$have = (int)($inventoryMap[$item] ?? 0);
|
||||
if ($have < (int)$reqQty) $shortages[$item] = (int)$reqQty - $have;
|
||||
}
|
||||
|
||||
return $this->response->setJSON([
|
||||
'ok' => true,
|
||||
'schoolYear' => $schoolYear,
|
||||
'results' => $results,
|
||||
'totals' => $requiredTotals,
|
||||
'shortages' => $shortages,
|
||||
'csrf_token' => csrf_token(),
|
||||
'csrf_hash' => csrf_hash(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* API: Mark selected classes as printed (save baseline snapshot).
|
||||
* POST: school_year, class_section_ids[]
|
||||
*/
|
||||
public function apiMarkPrinted()
|
||||
{
|
||||
$schoolYear = (string)($this->request->getPost('school_year') ?? $this->schoolYear);
|
||||
$semParam = $this->request->getPost('semester');
|
||||
$semester = (is_string($semParam) && $semParam !== '') ? (string)$semParam : (string)$this->semester;
|
||||
$limitToSemester = $this->hasRosterForSemester($schoolYear, $semester);
|
||||
$ids = $this->request->getPost('class_section_ids') ?? [];
|
||||
if (!is_array($ids)) $ids = $ids ? [$ids] : [];
|
||||
$ids = array_values(array_unique(array_filter(array_map('strval', $ids))));
|
||||
|
||||
$now = utc_now();
|
||||
$count = 0;
|
||||
foreach ($ids as $classSectionId) {
|
||||
$studentQ = $this->studentClassModel
|
||||
->select('COUNT(DISTINCT student_id) AS cnt')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('school_year', $schoolYear);
|
||||
if ($limitToSemester && $semester !== '') {
|
||||
$studentQ->where('semester', $semester);
|
||||
}
|
||||
$studentRow = $studentQ->first();
|
||||
$studentCount = (int)($studentRow['cnt'] ?? 0);
|
||||
$classLevel = $this->getClassLevelBySection((string)$classSectionId);
|
||||
$className = $this->classSectionModel->getClassSectionNameBySectionId($classSectionId) ?? $classSectionId;
|
||||
|
||||
$items = $this->calculatePrepItems($studentCount, $classLevel, (string)$classSectionId);
|
||||
|
||||
$rawAdjustments = $this->adjustmentModel
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('adjustable', 1)
|
||||
->findAll();
|
||||
foreach ($rawAdjustments as $a) {
|
||||
$item = $a['item_name'];
|
||||
$delta = (int)$a['adjustment'];
|
||||
if (isset($items[$item])) $items[$item] = max(0, (int)$items[$item] + $delta);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->prepLogModel->insert([
|
||||
'class_section_id' => (string)$classSectionId,
|
||||
'class_section' => $className,
|
||||
'school_year' => $schoolYear,
|
||||
'prep_data' => json_encode($items),
|
||||
'created_at' => $now,
|
||||
]);
|
||||
$count++;
|
||||
} catch (\Throwable $e) {
|
||||
// ignore and continue
|
||||
}
|
||||
}
|
||||
|
||||
return $this->response->setJSON([
|
||||
'ok' => true,
|
||||
'updated' => $count,
|
||||
'csrf_token' => csrf_token(),
|
||||
'csrf_hash' => csrf_hash(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
private function getClassLevelBySection(string $classSectionId): int
|
||||
{
|
||||
// Prefer the human-readable section name for grade parsing.
|
||||
$sectionName = $this->classSectionModel->getClassSectionNameBySectionId($classSectionId);
|
||||
$label = $sectionName !== null && $sectionName !== '' ? (string) $sectionName : $classSectionId;
|
||||
|
||||
// If it's clearly Kindergarten (KG/K)
|
||||
if (preg_match('/^(kg|k)(\b|[^a-z0-9])/i', $label)) {
|
||||
return 1; // treat Kindergarten as lower grade
|
||||
}
|
||||
|
||||
// Remove non-digits, then infer by leading digit
|
||||
$numValue = (int) preg_replace('/\D/', '', $label);
|
||||
|
||||
if ($numValue > 0 && $numValue < 30) {
|
||||
$firstDigit = (int) substr((string) $numValue, 0, 1);
|
||||
return $firstDigit === 1 ? 1 : ($firstDigit === 2 ? 2 : 3);
|
||||
}
|
||||
|
||||
// Default to upper grades (3+)
|
||||
return 3;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if student_class has any rows for the given school year.
|
||||
* The semester argument is ignored because assignments are tracked per year.
|
||||
*/
|
||||
private function hasRosterForTerm(string $schoolYear, string $semester): bool
|
||||
{
|
||||
$year = trim((string)$schoolYear);
|
||||
if ($year === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (array_key_exists($year, $this->rosterPresenceCache)) {
|
||||
return $this->rosterPresenceCache[$year];
|
||||
}
|
||||
|
||||
$cnt = $this->db->table('student_class')
|
||||
->where('school_year', $year)
|
||||
->countAllResults();
|
||||
|
||||
$this->rosterPresenceCache[$year] = $cnt > 0;
|
||||
return $this->rosterPresenceCache[$year];
|
||||
}
|
||||
|
||||
private function hasRosterForSemester(string $schoolYear, string $semester): bool
|
||||
{
|
||||
$year = trim((string)$schoolYear);
|
||||
$sem = trim((string)$semester);
|
||||
if ($year === '' || $sem === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$key = sprintf('sem:%s:%s', $year, $sem);
|
||||
if (array_key_exists($key, $this->rosterPresenceCache)) {
|
||||
return $this->rosterPresenceCache[$key];
|
||||
}
|
||||
|
||||
$cnt = $this->db->table('student_class')
|
||||
->where('school_year', $year)
|
||||
->where('semester', $sem)
|
||||
->countAllResults();
|
||||
|
||||
$this->rosterPresenceCache[$key] = $cnt > 0;
|
||||
return $this->rosterPresenceCache[$key];
|
||||
}
|
||||
|
||||
private function buildInventoryAvailability(string $schoolYear, string $semester, bool $limitToSemester, array $allowed): array
|
||||
{
|
||||
$inventoryMap = array_fill_keys($allowed, 0);
|
||||
|
||||
$joinRows = $this->db->table('inventory_items ii')
|
||||
->select('ic.name AS item_name, COALESCE(SUM(CASE WHEN ii.good_qty IS NOT NULL THEN ii.good_qty WHEN ii.`condition`="good" THEN ii.quantity ELSE 0 END),0) AS available')
|
||||
->join('inventory_categories ic', 'ic.id = ii.category_id', 'left')
|
||||
->where('ii.type', 'classroom')
|
||||
->where('ii.school_year', $schoolYear)
|
||||
->whereIn('ic.name', $allowed);
|
||||
if ($limitToSemester && $semester !== '') {
|
||||
$joinRows->where('ii.semester', $semester);
|
||||
}
|
||||
$joinRows = $joinRows->groupBy('ic.name')->get()->getResultArray();
|
||||
|
||||
foreach ($joinRows as $r) {
|
||||
$name = (string)($r['item_name'] ?? '');
|
||||
if ($name !== '' && isset($inventoryMap[$name])) {
|
||||
$inventoryMap[$name] = max($inventoryMap[$name], (int)($r['available'] ?? 0));
|
||||
}
|
||||
}
|
||||
|
||||
$nameRows = $this->db->table('inventory_items')
|
||||
->select('name AS item_name, COALESCE(SUM(CASE WHEN good_qty IS NOT NULL THEN good_qty WHEN `condition`="good" THEN quantity ELSE 0 END),0) AS available')
|
||||
->where('type', 'classroom')
|
||||
->where('school_year', $schoolYear)
|
||||
->whereIn('name', $allowed);
|
||||
if ($limitToSemester && $semester !== '') {
|
||||
$nameRows->where('semester', $semester);
|
||||
}
|
||||
$nameRows = $nameRows->groupBy('name')->get()->getResultArray();
|
||||
|
||||
foreach ($nameRows as $r) {
|
||||
$name = (string)($r['item_name'] ?? '');
|
||||
if ($name !== '' && isset($inventoryMap[$name])) {
|
||||
$inventoryMap[$name] = max($inventoryMap[$name], (int)($r['available'] ?? 0));
|
||||
}
|
||||
}
|
||||
|
||||
return $inventoryMap;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Models\StudentModel;
|
||||
use App\Models\FamilyModel;
|
||||
use App\Models\FamilyStudentModel;
|
||||
use App\Models\FamilyGuardianModel;
|
||||
use App\Models\FamilyCommPrefModel; // optional
|
||||
use App\Models\EmailTemplateModel;
|
||||
use App\Models\CommunicationLogModel;
|
||||
|
||||
class CommunicationController extends BaseController
|
||||
{
|
||||
protected StudentModel $studentModel;
|
||||
protected FamilyModel $familyModel;
|
||||
protected FamilyStudentModel $fsModel;
|
||||
protected FamilyGuardianModel $fgModel;
|
||||
protected ?FamilyCommPrefModel $fcpModel = null;
|
||||
protected EmailTemplateModel $templateModel;
|
||||
protected CommunicationLogModel $logModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->studentModel = new StudentModel();
|
||||
$this->familyModel = new FamilyModel();
|
||||
$this->fsModel = new FamilyStudentModel();
|
||||
$this->fgModel = new FamilyGuardianModel();
|
||||
if (class_exists(FamilyCommPrefModel::class)) {
|
||||
$this->fcpModel = new FamilyCommPrefModel();
|
||||
}
|
||||
$this->templateModel = new EmailTemplateModel();
|
||||
$this->logModel = new CommunicationLogModel();
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$students = $this->studentModel->orderBy('lastname, firstname', 'asc')->findAll();
|
||||
$templates = $this->templateModel->getActiveTemplates();
|
||||
return view('communications/index', compact('students','templates'));
|
||||
}
|
||||
|
||||
// AJAX: families for a student
|
||||
public function families(int $studentId)
|
||||
{
|
||||
if (!$this->request->isAJAX()) return $this->response->setStatusCode(400)->setJSON(['error' => 'Bad request']);
|
||||
$families = $this->fsModel->getFamiliesForStudent($studentId);
|
||||
return $this->response->setJSON(['data' => $families]);
|
||||
}
|
||||
|
||||
// AJAX: guardians for a family
|
||||
public function guardians(int $familyId)
|
||||
{
|
||||
if (!$this->request->isAJAX()) return $this->response->setStatusCode(400)->setJSON(['error' => 'Bad request']);
|
||||
$db = \Config\Database::connect();
|
||||
$rows = $db->query("SELECT u.id as user_id, u.firstname, u.lastname, u.email, fg.relation, fg.is_primary, fg.receive_emails, fg.receive_sms\n FROM family_guardians fg\n JOIN users u ON u.id = fg.user_id\n WHERE fg.family_id = ?", [$familyId])->getResultArray();
|
||||
return $this->response->setJSON(['data' => $rows]);
|
||||
}
|
||||
|
||||
public function preview()
|
||||
{
|
||||
if (!$this->request->isAJAX()) return $this->response->setStatusCode(400)->setJSON(['error' => 'Bad request']);
|
||||
|
||||
$templateKey = (string) $this->request->getPost('template_key');
|
||||
$studentId = (int) $this->request->getPost('student_id');
|
||||
$familyId = (int) $this->request->getPost('family_id');
|
||||
$varsPost = $this->request->getPost('vars');
|
||||
$vars = is_array($varsPost) ? $varsPost : json_decode((string)$varsPost, true) ?? [];
|
||||
|
||||
$template = $this->templateModel->findByKey($templateKey);
|
||||
if (!$template) return $this->response->setStatusCode(404)->setJSON(['error' => 'Template not found']);
|
||||
|
||||
$student = $this->studentModel->getStudentBasic($studentId);
|
||||
if (!$student) return $this->response->setStatusCode(404)->setJSON(['error' => 'Student not found']);
|
||||
|
||||
// Build salutation from guardians in the chosen family
|
||||
$db = \Config\Database::connect();
|
||||
$gs = $db->query("SELECT u.firstname, u.lastname\n FROM family_guardians fg\n JOIN users u ON u.id = fg.user_id\n WHERE fg.family_id = ? AND fg.receive_emails = 1\n ORDER BY fg.is_primary DESC, u.lastname, u.firstname", [$familyId])->getResultArray();
|
||||
$sal = 'Parent/Guardian';
|
||||
if ($gs) {
|
||||
$names = array_map(fn($r)=> trim(($r['firstname']??'').' '.($r['lastname']??'')), $gs);
|
||||
$sal = implode(' & ', $names);
|
||||
}
|
||||
|
||||
$autoVars = [
|
||||
'student_fullname' => trim(($student['firstname'] ?? '').' '.($student['lastname'] ?? '')),
|
||||
'student_grade' => $student['grade'] ?? '',
|
||||
'parent_salutation'=> $sal,
|
||||
'date' => local_date(utc_now(), 'Y-m-d'),
|
||||
'school_name' => 'Al Rahma Sunday School',
|
||||
'teacher_name' => (string)(session('display_name') ?? 'Teacher'),
|
||||
];
|
||||
$all = array_merge($autoVars, $vars);
|
||||
|
||||
$subject = $this->renderTwig($template['subject'], $all);
|
||||
$body = nl2br($this->renderTwig($template['body'], $all));
|
||||
|
||||
return $this->response->setJSON(['subject' => $subject, 'html' => $body]);
|
||||
}
|
||||
|
||||
public function send()
|
||||
{
|
||||
$rules = [
|
||||
'student_id' => 'required|integer',
|
||||
'family_id' => 'required|integer',
|
||||
'template_key' => 'required|string',
|
||||
'subject' => 'required|string',
|
||||
'body' => 'required|string',
|
||||
'recipients' => 'required|string' // JSON array
|
||||
];
|
||||
if (!$this->validate($rules)) {
|
||||
return redirect()->back()->with('error', 'Invalid form submission.');
|
||||
}
|
||||
|
||||
$studentId = (int) $this->request->getPost('student_id');
|
||||
$familyId = (int) $this->request->getPost('family_id');
|
||||
$templateKey = (string) $this->request->getPost('template_key');
|
||||
$subject = (string) $this->request->getPost('subject');
|
||||
$bodyHtml = (string) $this->request->getPost('body');
|
||||
$recipients = json_decode((string)$this->request->getPost('recipients'), true) ?? [];
|
||||
$cc = json_decode((string)($this->request->getPost('cc') ?? '[]'), true) ?? [];
|
||||
$bcc = json_decode((string)($this->request->getPost('bcc') ?? '[]'), true) ?? [];
|
||||
|
||||
$student = $this->studentModel->getStudentBasic($studentId);
|
||||
if (!$student) return redirect()->back()->with('error', 'Student not found');
|
||||
|
||||
// Send email (PHPMailer via service('mailer'))
|
||||
$sendOk = false; $error = null;
|
||||
try {
|
||||
$mailer = service('mailer');
|
||||
foreach (array_unique($recipients) as $to) { if ($to) $mailer->addAddress($to); }
|
||||
foreach (array_unique($cc) as $c) { if ($c) $mailer->addCC($c); }
|
||||
foreach (array_unique($bcc) as $b){ if ($b) $mailer->addBCC($b); }
|
||||
$mailer->Subject = $subject;
|
||||
$mailer->isHTML(true);
|
||||
$mailer->Body = $bodyHtml;
|
||||
$mailer->AltBody = strip_tags($bodyHtml);
|
||||
$sendOk = $mailer->send();
|
||||
} catch (\Throwable $t) {
|
||||
$error = $t->getMessage();
|
||||
}
|
||||
|
||||
// Log
|
||||
$this->logModel->insert([
|
||||
'student_id' => $studentId,
|
||||
'family_id' => $familyId,
|
||||
'student_name' => trim(($student['firstname'] ?? '').' '.($student['lastname'] ?? '')),
|
||||
'template_key' => $templateKey,
|
||||
'subject' => $subject,
|
||||
'body' => $bodyHtml,
|
||||
'recipients' => json_encode(array_values($recipients)),
|
||||
'cc' => json_encode(array_values($cc)),
|
||||
'bcc' => json_encode(array_values($bcc)),
|
||||
'status' => $sendOk ? 'sent' : 'failed',
|
||||
'error_message'=> $error,
|
||||
'sent_by' => (int)(session('user_id') ?? 0),
|
||||
'metadata' => null,
|
||||
]);
|
||||
|
||||
return $sendOk
|
||||
? redirect()->to('/communications')->with('success', 'Email sent successfully.')
|
||||
: redirect()->back()->with('error', 'Failed to send email: '.($error ?? 'Unknown error'));
|
||||
}
|
||||
|
||||
private function renderTwig(string $template, array $vars): string
|
||||
{
|
||||
return preg_replace_callback('/\{\{\s*([a-zA-Z0-9_\.]+)\s*\}\}/', function($m) use ($vars) {
|
||||
$key = $m[1];
|
||||
return htmlspecialchars((string)($vars[$key] ?? ''), ENT_QUOTES, 'UTF-8');
|
||||
}, $template);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Models\ClassSectionModel;
|
||||
use App\Models\CompetitionClassWinnerModel;
|
||||
use App\Models\CompetitionModel;
|
||||
use App\Models\CompetitionScoreModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
use App\Models\TeacherClassModel;
|
||||
|
||||
class CompetitionScoresController extends BaseController
|
||||
{
|
||||
protected $db;
|
||||
protected TeacherClassModel $teacherClassModel;
|
||||
protected ConfigurationModel $configModel;
|
||||
protected ClassSectionModel $classSectionModel;
|
||||
protected CompetitionClassWinnerModel $classWinnerModel;
|
||||
protected string $classStudentTable;
|
||||
protected bool $hasQuestionCount = false;
|
||||
protected bool $hasClassWinnerTable = false;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->db = \Config\Database::connect();
|
||||
$this->teacherClassModel = new TeacherClassModel();
|
||||
$this->configModel = new ConfigurationModel();
|
||||
$this->classSectionModel = new ClassSectionModel();
|
||||
$this->classWinnerModel = new CompetitionClassWinnerModel();
|
||||
|
||||
if ($this->db->tableExists('class_student')) {
|
||||
$this->classStudentTable = 'class_student';
|
||||
} elseif ($this->db->tableExists('student_class')) {
|
||||
$this->classStudentTable = 'student_class';
|
||||
} else {
|
||||
$this->classStudentTable = '';
|
||||
}
|
||||
|
||||
$this->hasClassWinnerTable = $this->db->tableExists('competition_class_winners');
|
||||
$this->hasQuestionCount = $this->hasClassWinnerTable
|
||||
&& $this->db->fieldExists('question_count', 'competition_class_winners');
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
[$assignments, $schoolYear, $semester] = $this->getTeacherContext();
|
||||
$activeClassId = $this->resolveActiveClassId($assignments);
|
||||
$activeClassName = $this->getActiveClassName($assignments, $activeClassId);
|
||||
|
||||
if (empty($assignments) || $activeClassId <= 0) {
|
||||
return view('teacher/competition_scores/index', [
|
||||
'competitions' => [],
|
||||
'activeClassId' => $activeClassId,
|
||||
'activeClassName' => $activeClassName,
|
||||
'questionCounts' => [],
|
||||
'scoreCounts' => [],
|
||||
'studentTotal' => 0,
|
||||
'sectionMap' => $this->getClassSectionMap(),
|
||||
'schoolYear' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
'hasClasses' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
$competitions = $this->getCompetitionsForClass($activeClassId, $schoolYear, $semester);
|
||||
$competitionIds = array_values(array_filter(array_map(static function ($row) {
|
||||
return (int) ($row['id'] ?? 0);
|
||||
}, $competitions)));
|
||||
|
||||
$questionCounts = $this->getQuestionCounts($competitionIds, $activeClassId);
|
||||
$scoreCounts = $this->getScoreCounts($competitionIds, $activeClassId);
|
||||
$studentTotal = $this->getClassStudentCount($activeClassId, $schoolYear);
|
||||
|
||||
return view('teacher/competition_scores/index', [
|
||||
'competitions' => $competitions,
|
||||
'activeClassId' => $activeClassId,
|
||||
'activeClassName' => $activeClassName,
|
||||
'questionCounts' => $questionCounts,
|
||||
'scoreCounts' => $scoreCounts,
|
||||
'studentTotal' => $studentTotal,
|
||||
'sectionMap' => $this->getClassSectionMap(),
|
||||
'schoolYear' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
'hasClasses' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
public function edit($id)
|
||||
{
|
||||
[$assignments, $schoolYear, $semester] = $this->getTeacherContext();
|
||||
$allowedClassIds = $this->getAllowedClassIds($assignments);
|
||||
if (empty($allowedClassIds)) {
|
||||
return redirect()->to('/teacher/competition-scores')
|
||||
->with('error', 'No class assignments found for your account.');
|
||||
}
|
||||
|
||||
$competitionModel = new CompetitionModel();
|
||||
$scoreModel = new CompetitionScoreModel();
|
||||
$competition = $competitionModel->find($id);
|
||||
if (!$competition) {
|
||||
return redirect()->to('/teacher/competition-scores')
|
||||
->with('error', 'Competition not found.');
|
||||
}
|
||||
$isLocked = !empty($competition['is_locked']);
|
||||
|
||||
$activeClassId = $this->resolveActiveClassId($assignments);
|
||||
$lockedClassId = (int) ($competition['class_section_id'] ?? 0);
|
||||
$classSectionId = $lockedClassId > 0 ? $lockedClassId : $activeClassId;
|
||||
if ($classSectionId <= 0) {
|
||||
return redirect()->to('/teacher/competition-scores')
|
||||
->with('error', 'Select a class section before entering scores.');
|
||||
}
|
||||
if (!in_array($classSectionId, $allowedClassIds, true)) {
|
||||
return redirect()->to('/teacher/competition-scores')
|
||||
->with('error', 'You are not assigned to that class.');
|
||||
}
|
||||
|
||||
$students = $this->getStudentsForCompetition($competition, $classSectionId);
|
||||
$existingScores = $scoreModel
|
||||
->where('competition_id', $id)
|
||||
->where('class_section_id', $classSectionId)
|
||||
->findAll();
|
||||
|
||||
$scoreMap = [];
|
||||
foreach ($existingScores as $row) {
|
||||
$scoreMap[$row['student_id']] = $row['score'];
|
||||
}
|
||||
|
||||
$questionCount = null;
|
||||
if ($this->hasQuestionCount && $this->hasClassWinnerTable) {
|
||||
$row = $this->classWinnerModel
|
||||
->select('question_count')
|
||||
->where('competition_id', $id)
|
||||
->where('class_section_id', $classSectionId)
|
||||
->first();
|
||||
if ($row && $row['question_count'] !== null) {
|
||||
$questionCount = (int) $row['question_count'];
|
||||
}
|
||||
}
|
||||
|
||||
$classStudentCount = $this->getClassStudentCount(
|
||||
$classSectionId,
|
||||
$competition['school_year'] ?? $schoolYear
|
||||
);
|
||||
|
||||
$activeClassName = $this->getActiveClassName($assignments, $classSectionId);
|
||||
|
||||
return view('teacher/competition_scores/scores', [
|
||||
'competition' => $competition,
|
||||
'students' => $students,
|
||||
'scoreMap' => $scoreMap,
|
||||
'classSectionId' => $classSectionId,
|
||||
'classSectionName' => $activeClassName,
|
||||
'classStudentCount' => $classStudentCount,
|
||||
'questionCount' => $questionCount,
|
||||
'classSelectionLocked' => $lockedClassId > 0,
|
||||
'isLocked' => $isLocked,
|
||||
]);
|
||||
}
|
||||
|
||||
public function save($id)
|
||||
{
|
||||
[$assignments] = $this->getTeacherContext();
|
||||
$allowedClassIds = $this->getAllowedClassIds($assignments);
|
||||
if (empty($allowedClassIds)) {
|
||||
return redirect()->to('/teacher/competition-scores')
|
||||
->with('error', 'No class assignments found for your account.');
|
||||
}
|
||||
|
||||
$competitionModel = new CompetitionModel();
|
||||
$scoreModel = new CompetitionScoreModel();
|
||||
$competition = $competitionModel->find($id);
|
||||
if (!$competition) {
|
||||
return redirect()->to('/teacher/competition-scores')
|
||||
->with('error', 'Competition not found.');
|
||||
}
|
||||
if (!empty($competition['is_locked'])) {
|
||||
return redirect()->to("/teacher/competition-scores/{$id}")
|
||||
->with('error', 'Competition is locked. You cannot update scores.');
|
||||
}
|
||||
|
||||
$lockedClassId = (int) ($competition['class_section_id'] ?? 0);
|
||||
$classSectionId = $lockedClassId > 0
|
||||
? $lockedClassId
|
||||
: (int) $this->request->getPost('class_section_id');
|
||||
if ($classSectionId <= 0) {
|
||||
return redirect()->to('/teacher/competition-scores')
|
||||
->with('error', 'Select a class section before saving scores.');
|
||||
}
|
||||
if (!in_array($classSectionId, $allowedClassIds, true)) {
|
||||
return redirect()->to('/teacher/competition-scores')
|
||||
->with('error', 'You are not assigned to that class.');
|
||||
}
|
||||
|
||||
$scores = (array) $this->request->getPost('scores');
|
||||
$cleanScores = [];
|
||||
$invalidScores = [];
|
||||
|
||||
foreach ($scores as $studentId => $scoreValue) {
|
||||
$scoreValue = trim((string) $scoreValue);
|
||||
if ($scoreValue === '') {
|
||||
continue;
|
||||
}
|
||||
if (!preg_match('/^\d+$/', $scoreValue)) {
|
||||
$invalidScores[] = $studentId;
|
||||
continue;
|
||||
}
|
||||
$cleanScores[(int) $studentId] = (int) $scoreValue;
|
||||
}
|
||||
|
||||
if (!empty($invalidScores)) {
|
||||
return redirect()->to("/teacher/competition-scores/{$id}")
|
||||
->with('error', 'Scores must be whole numbers (no decimals).');
|
||||
}
|
||||
|
||||
foreach ($cleanScores as $studentId => $scoreValue) {
|
||||
$existing = $scoreModel
|
||||
->where('competition_id', $id)
|
||||
->where('student_id', (int) $studentId)
|
||||
->where('class_section_id', $classSectionId)
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
$scoreModel->update($existing['id'], [
|
||||
'score' => $scoreValue,
|
||||
'class_section_id' => $classSectionId,
|
||||
]);
|
||||
} else {
|
||||
$scoreModel->insert([
|
||||
'competition_id' => (int) $id,
|
||||
'student_id' => (int) $studentId,
|
||||
'class_section_id' => $classSectionId,
|
||||
'score' => $scoreValue,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()->to("/teacher/competition-scores/{$id}")
|
||||
->with('success', 'Scores saved.');
|
||||
}
|
||||
|
||||
private function getTeacherContext(): array
|
||||
{
|
||||
$userId = (int) (session()->get('user_id') ?? 0);
|
||||
$schoolYear = (string) ($this->configModel->getConfig('school_year') ?? '');
|
||||
$semester = (string) ($this->configModel->getConfig('semester') ?? '');
|
||||
$assignments = $this->teacherClassModel->getClassAssignmentsByUserId(
|
||||
$userId,
|
||||
$schoolYear,
|
||||
$semester
|
||||
);
|
||||
|
||||
return [$assignments, $schoolYear, $semester];
|
||||
}
|
||||
|
||||
private function getAllowedClassIds(array $assignments): array
|
||||
{
|
||||
$ids = array_map(static function ($row) {
|
||||
return (int) ($row['class_section_id'] ?? 0);
|
||||
}, $assignments);
|
||||
|
||||
$ids = array_values(array_filter(array_unique($ids)));
|
||||
return $ids;
|
||||
}
|
||||
|
||||
private function resolveActiveClassId(array $assignments): int
|
||||
{
|
||||
$allowed = $this->getAllowedClassIds($assignments);
|
||||
$active = (int) (session()->get('class_section_id') ?? 0);
|
||||
if ($active > 0 && in_array($active, $allowed, true)) {
|
||||
return $active;
|
||||
}
|
||||
|
||||
$active = $allowed[0] ?? 0;
|
||||
if ($active > 0) {
|
||||
session()->set('class_section_id', $active);
|
||||
}
|
||||
|
||||
return (int) $active;
|
||||
}
|
||||
|
||||
private function getActiveClassName(array $assignments, int $classSectionId): ?string
|
||||
{
|
||||
if ($classSectionId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($assignments as $row) {
|
||||
if ((int) ($row['class_section_id'] ?? 0) === $classSectionId) {
|
||||
return $row['class_section_name'] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
$row = $this->classSectionModel
|
||||
->select('class_section_name')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->first();
|
||||
|
||||
return $row['class_section_name'] ?? null;
|
||||
}
|
||||
|
||||
private function getCompetitionsForClass(int $classSectionId, string $schoolYear, string $semester): array
|
||||
{
|
||||
$competitionModel = new CompetitionModel();
|
||||
$builder = $competitionModel->orderBy('id', 'DESC');
|
||||
|
||||
if ($classSectionId > 0) {
|
||||
$builder->groupStart()
|
||||
->where('class_section_id', $classSectionId)
|
||||
->orWhere('class_section_id', 0)
|
||||
->orWhere('class_section_id IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
|
||||
if ($schoolYear !== '') {
|
||||
$builder->groupStart()
|
||||
->where('school_year', $schoolYear)
|
||||
->orWhere('school_year', '')
|
||||
->orWhere('school_year IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
|
||||
if ($semester !== '') {
|
||||
$builder->groupStart()
|
||||
->where('semester', $semester)
|
||||
->orWhere('semester', '')
|
||||
->orWhere('semester IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
|
||||
return $builder->findAll();
|
||||
}
|
||||
|
||||
private function getQuestionCounts(array $competitionIds, int $classSectionId): array
|
||||
{
|
||||
if (!$this->hasQuestionCount || !$this->hasClassWinnerTable || empty($competitionIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = $this->classWinnerModel
|
||||
->select('competition_id, question_count')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->whereIn('competition_id', $competitionIds)
|
||||
->findAll();
|
||||
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$compId = (int) ($row['competition_id'] ?? 0);
|
||||
if ($compId > 0 && $row['question_count'] !== null) {
|
||||
$out[$compId] = (int) $row['question_count'];
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
private function getScoreCounts(array $competitionIds, int $classSectionId): array
|
||||
{
|
||||
if (empty($competitionIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = $this->db->table('competition_scores')
|
||||
->select('competition_id, COUNT(*) AS total')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->whereIn('competition_id', $competitionIds)
|
||||
->groupBy('competition_id')
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$compId = (int) ($row['competition_id'] ?? 0);
|
||||
if ($compId > 0) {
|
||||
$out[$compId] = (int) ($row['total'] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
private function getClassStudentCount(int $classSectionId, ?string $schoolYear): int
|
||||
{
|
||||
if ($classSectionId <= 0 || $this->classStudentTable === '') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$builder = $this->db->table($this->classStudentTable)
|
||||
->select('COUNT(*) AS total')
|
||||
->where('class_section_id', $classSectionId);
|
||||
|
||||
if ($schoolYear && $this->db->fieldExists('school_year', $this->classStudentTable)) {
|
||||
$builder->where('school_year', $schoolYear);
|
||||
}
|
||||
|
||||
$row = $builder->get()->getRowArray();
|
||||
return (int) ($row['total'] ?? 0);
|
||||
}
|
||||
|
||||
private function getStudentsForCompetition(array $competition, int $classSectionId): array
|
||||
{
|
||||
if ($classSectionId <= 0 || $this->classStudentTable === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$builder = $this->db->table('students s')
|
||||
->select('s.id, s.school_id, s.firstname, s.lastname')
|
||||
->join($this->classStudentTable . ' cs', 'cs.student_id = s.id', 'inner')
|
||||
->where('cs.class_section_id', $classSectionId);
|
||||
|
||||
$hasSchoolYear = $this->db->fieldExists('school_year', $this->classStudentTable);
|
||||
if ($hasSchoolYear && !empty($competition['school_year'])) {
|
||||
$builder->where('cs.school_year', $competition['school_year']);
|
||||
}
|
||||
|
||||
$builder->distinct();
|
||||
$builder->orderBy('s.lastname', 'ASC')
|
||||
->orderBy('s.firstname', 'ASC');
|
||||
|
||||
return $builder->get()->getResultArray();
|
||||
}
|
||||
|
||||
private function getClassSectionMap(): array
|
||||
{
|
||||
$sections = $this->classSectionModel
|
||||
->select('class_section_id, class_section_name')
|
||||
->orderBy('class_section_name', 'ASC')
|
||||
->findAll();
|
||||
|
||||
$map = [];
|
||||
foreach ($sections as $section) {
|
||||
$id = (int) ($section['class_section_id'] ?? 0);
|
||||
if ($id > 0) {
|
||||
$map[$id] = $section['class_section_name'] ?? (string) $id;
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Models\ConfigurationModel;
|
||||
use CodeIgniter\Controller;
|
||||
|
||||
class ConfigurationController extends Controller
|
||||
{
|
||||
protected $configModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->configModel = new ConfigurationModel();
|
||||
}
|
||||
|
||||
// Method to load configuration management page
|
||||
public function index()
|
||||
{
|
||||
helper('url');
|
||||
return view('configuration/configuration_view', [
|
||||
'configEndpoint' => site_url('api/configuration'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function addConfig()
|
||||
{
|
||||
// Retrieve POST data
|
||||
$configKey = $this->request->getPost('config_key');
|
||||
$configValue = $this->request->getPost('config_value');
|
||||
|
||||
// Validate inputs (optional)
|
||||
if ($configKey && $configValue) {
|
||||
// Save to the database
|
||||
$this->configModel->save([
|
||||
'config_key' => $configKey,
|
||||
'config_value' => $configValue,
|
||||
]);
|
||||
|
||||
// Redirect to the configuration view page
|
||||
return redirect()->to('/configuration/configuration_view')->with('success', 'Configuration added.');
|
||||
}
|
||||
|
||||
// If validation fails, reload the form with input
|
||||
return redirect()->back()->withInput()->with('error', 'Please fill in all required fields.');
|
||||
}
|
||||
|
||||
|
||||
// Method to edit an existing configuration
|
||||
public function editConfig($id)
|
||||
{
|
||||
$config = $this->configModel->find($id);
|
||||
|
||||
if (strtolower($this->request->getMethod()) === 'post') {
|
||||
$key = trim((string) $this->request->getPost('config_key'));
|
||||
$value = trim((string) $this->request->getPost('config_value'));
|
||||
|
||||
$this->configModel->update($id, [
|
||||
'config_key' => $key,
|
||||
'config_value' => $value
|
||||
]);
|
||||
return redirect()->to('/configuration/configuration_view')->with('success', 'Configuration updated.');
|
||||
}
|
||||
return view('configuration/configuration_edit', ['config' => $config]);
|
||||
}
|
||||
|
||||
public function deleteConfig($id)
|
||||
{
|
||||
if ($this->configModel->find($id)) {
|
||||
$this->configModel->delete($id);
|
||||
return redirect()->to('/configuration/configuration_view')->with('success', 'Configuration deleted successfully.');
|
||||
}
|
||||
|
||||
return redirect()->to('/configuration/configuration_view')->with('error', 'Configuration not found.');
|
||||
}
|
||||
|
||||
public function listData()
|
||||
{
|
||||
$rows = $this->configModel
|
||||
->orderBy('id', 'ASC')
|
||||
->findAll();
|
||||
|
||||
$configs = array_map(static function ($row) {
|
||||
if (!is_array($row)) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'config_key' => (string) ($row['config_key'] ?? ''),
|
||||
'config_value' => (string) ($row['config_value'] ?? ''),
|
||||
];
|
||||
}, $rows ?? []);
|
||||
|
||||
return $this->response->setJSON(['configs' => $configs]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
|
||||
class ContactController extends BaseController
|
||||
{
|
||||
public function __construct(private \CodeIgniter\HTTP\IncomingRequest $request)
|
||||
{
|
||||
helper('form'); // Load the form helper
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
return view('/parent/contact');
|
||||
}
|
||||
|
||||
public function submit()
|
||||
{
|
||||
helper('form'); // Ensure the form helper is loaded
|
||||
|
||||
$validation = \Config\Services::validation();
|
||||
|
||||
// Define validation rules
|
||||
$validation->setRules([
|
||||
'name' => 'required|min_length[3]',
|
||||
'email' => 'required|valid_email',
|
||||
'subject' => 'required|min_length[3]',
|
||||
'message' => 'required|min_length[10]'
|
||||
]);
|
||||
|
||||
if (!$validation->withRequest($this->request)->run()) {
|
||||
return view('/parent/contact', [
|
||||
'validation' => $validation
|
||||
]);
|
||||
}
|
||||
|
||||
// Process form data
|
||||
$name = $this->request->getPost('name');
|
||||
$email = strtolower($this->request->getPost('email'));
|
||||
$subject = $this->request->getPost('subject');
|
||||
$message = $this->request->getPost('message');
|
||||
|
||||
// Initialize the EmailController
|
||||
$emailController = new \App\Controllers\View\EmailController();
|
||||
|
||||
// Prepare the message to send
|
||||
$formattedMessage = "
|
||||
<p><strong>From:</strong> {$name} ({$email})</p>
|
||||
<p><strong>Subject:</strong> {$subject}</p>
|
||||
<p>{$message}</p>
|
||||
";
|
||||
|
||||
// Send the email using EmailController
|
||||
if ($emailController->sendEmail('support@alrahmaisgl.org', $subject, $formattedMessage)) {
|
||||
$session = session();
|
||||
$session->setFlashdata('success', 'Thank you for contacting us! We will get back to you soon.');
|
||||
} else {
|
||||
$session = session();
|
||||
$session->setFlashdata('error', 'There was an error sending your message. Please try again later.');
|
||||
}
|
||||
|
||||
return redirect()->to('/parent/contact');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use CodeIgniter\Controller;
|
||||
use App\Models\RoleModel;
|
||||
|
||||
class DashboardRedirectController extends Controller
|
||||
{
|
||||
public function dashboard()
|
||||
{
|
||||
$session = session();
|
||||
$roles = $session->get('roles') ?? [];
|
||||
|
||||
// Fallback: if only a single role is stored
|
||||
if (empty($roles) && $session->get('role')) {
|
||||
$roles = [$session->get('role')];
|
||||
}
|
||||
|
||||
// Use your existing method
|
||||
return $this->redirectToDashboard($roles);
|
||||
}
|
||||
|
||||
// Your existing function (unchanged)
|
||||
|
||||
private function redirectToDashboard(array $roles)
|
||||
{
|
||||
if (empty($roles)) {
|
||||
log_message('error', 'Empty roles array passed to redirectToDashboard.');
|
||||
return redirect()->to('/landing_page/guest_dashboard');
|
||||
}
|
||||
|
||||
$roleModel = new RoleModel();
|
||||
|
||||
// Resolve all candidate roles (by name or slug), ordered by priority ASC
|
||||
$rows = $roleModel->findByNamesOrSlugs($roles);
|
||||
|
||||
if (!empty($rows)) {
|
||||
$route = $rows[0]['dashboard_route'] ?? '/landing_page/guest_dashboard';
|
||||
log_message('debug', 'Redirecting user to: ' . $route);
|
||||
return redirect()->to($route);
|
||||
}
|
||||
|
||||
log_message('warning', 'No matching role found. Redirecting to guest dashboard.');
|
||||
return redirect()->to('/landing_page/guest_dashboard');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,906 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
use App\Controllers\BaseController;
|
||||
use App\Models\DiscountVoucherModel;
|
||||
use App\Models\DiscountUsageModel;
|
||||
use App\Models\InvoiceModel;
|
||||
use App\Models\UserModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
use App\Models\PaymentModel;
|
||||
use App\Models\EnrollmentModel;
|
||||
use App\Models\EventChargesModel;
|
||||
use App\Models\AdditionalChargeModel;
|
||||
use App\Models\ClassSectionModel;
|
||||
use CodeIgniter\Events\Events;
|
||||
|
||||
class DiscountController extends BaseController
|
||||
{
|
||||
protected $configModel;
|
||||
protected $voucherModel;
|
||||
protected $invoiceModel;
|
||||
protected $userModel;
|
||||
protected $db;
|
||||
protected $schoolYear;
|
||||
protected $semester;
|
||||
protected $paymentModel;
|
||||
protected $enrollmentModel;
|
||||
protected $eventChargesModel;
|
||||
protected $additionalChargeModel;
|
||||
protected $classSectionModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->configModel = new ConfigurationModel();
|
||||
$this->voucherModel = new DiscountVoucherModel();
|
||||
$this->invoiceModel = new InvoiceModel();
|
||||
$this->userModel = new UserModel();
|
||||
$this->db = \Config\Database::connect();
|
||||
$this->paymentModel = new PaymentModel();
|
||||
$this->enrollmentModel = new EnrollmentModel();
|
||||
$this->eventChargesModel = new EventChargesModel();
|
||||
$this->additionalChargeModel = new AdditionalChargeModel();
|
||||
$this->classSectionModel = new ClassSectionModel();
|
||||
|
||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||
$this->semester = $this->configModel->getConfig('semester');
|
||||
}
|
||||
|
||||
public function applyVoucher()
|
||||
{
|
||||
if (strtolower($this->request->getMethod()) === 'post') {
|
||||
$voucherId = $this->request->getPost('voucher_id');
|
||||
$parentIds = $this->request->getPost('parent_ids') ?? [];
|
||||
$allowAdditional = (bool) $this->request->getPost('allow_additional');
|
||||
|
||||
if (empty($parentIds)) {
|
||||
return redirect()->back()->with('error', 'Please select at least one parent.');
|
||||
}
|
||||
|
||||
$voucher = $this->voucherModel->find($voucherId);
|
||||
if (!$voucher) {
|
||||
return redirect()->back()->with('error', 'Voucher not found.');
|
||||
}
|
||||
|
||||
// Normalize description from voucher (optional)
|
||||
$voucherDescription = trim((string) ($voucher['description'] ?? ''));
|
||||
$voucherDescription = preg_replace(
|
||||
'/^Auto-generated on \d{4}-\d{2}-\d{2}\s*[—-]\s*reason:\s*/i',
|
||||
'',
|
||||
$voucherDescription
|
||||
);
|
||||
|
||||
$maxUsesRaw = $voucher['max_uses'] ?? null;
|
||||
$maxUses = ($maxUsesRaw === null || $maxUsesRaw === '') ? null : (int) $maxUsesRaw;
|
||||
$timesUsed = (int) ($voucher['times_used'] ?? 0);
|
||||
$remainingUses = ($maxUses === null) ? PHP_INT_MAX : ($maxUses - $timesUsed);
|
||||
if ($remainingUses <= 0) {
|
||||
return redirect()->back()->with('error', 'This voucher has reached its maximum allowed uses.');
|
||||
}
|
||||
|
||||
// If not allowing additional discounts, filter out parents who already have discounts this school year.
|
||||
if (!$allowAdditional) {
|
||||
$rows = $this->db->table('discount_usages du')
|
||||
->select('i.parent_id')
|
||||
->join('invoices i', 'i.id = du.invoice_id')
|
||||
->whereIn('i.parent_id', $parentIds)
|
||||
->where('i.school_year', $this->schoolYear)
|
||||
->groupBy('i.parent_id')
|
||||
->get()
|
||||
->getResultArray();
|
||||
$blockedParentIds = array_map(static fn($r) => (int) $r['parent_id'], $rows);
|
||||
if (!empty($blockedParentIds)) {
|
||||
$parentIds = array_values(array_diff(
|
||||
array_map('intval', $parentIds),
|
||||
$blockedParentIds
|
||||
));
|
||||
}
|
||||
if (empty($parentIds)) {
|
||||
return redirect()->back()->with('error', 'All selected parents already have discounts. Enable "Allow additional discounts" to apply another.');
|
||||
}
|
||||
}
|
||||
|
||||
$paymentDate = utc_now();
|
||||
|
||||
// Collect invoice IDs that end up fully covered by the voucher in this run
|
||||
$fullyCoveredInvoiceIds = [];
|
||||
$touchedInvoiceIds = [];
|
||||
$appliedCount = 0;
|
||||
|
||||
$this->db->transStart();
|
||||
|
||||
foreach ($parentIds as $parentId) {
|
||||
// Fetch invoices for this parent & school year
|
||||
$invoices = $this->invoiceModel->getInvoicesByUserId($parentId, $this->schoolYear);
|
||||
if (empty($invoices)) {
|
||||
// Do NOT early-return inside a transaction; just continue
|
||||
session()->setFlashdata('error', 'A selected parent has no invoice in record.');
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($invoices as $invoice) {
|
||||
if ($remainingUses <= 0) break 2; // out of parentIds loop too
|
||||
|
||||
// Snapshot current balance BEFORE applying
|
||||
$initialPreBalance = (float) $this->getCurrentInvoiceBalance($invoice['id'], $this->schoolYear);
|
||||
if ($initialPreBalance <= 0) {
|
||||
log_message(
|
||||
'error',
|
||||
'applyVoucher skip: zero balance | voucher_id={vid} parent_id={pid} invoice_id={iid} invoice_number={inum} balance={bal}',
|
||||
[
|
||||
'vid' => (int)$voucherId,
|
||||
'pid' => (int)$parentId,
|
||||
'iid' => (int)$invoice['id'],
|
||||
'inum' => (string)($invoice['invoice_number'] ?? ''),
|
||||
'bal' => $initialPreBalance,
|
||||
]
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Already used this voucher on this invoice?
|
||||
if (!$allowAdditional) {
|
||||
$exists = $this->db->table('discount_usages du')
|
||||
->join('invoices i', 'du.invoice_id = i.id')
|
||||
->where('du.voucher_id', $voucherId)
|
||||
->where('du.invoice_id', $invoice['id'])
|
||||
->where('i.school_year', $this->schoolYear)
|
||||
->countAllResults();
|
||||
if ($exists) {
|
||||
log_message(
|
||||
'error',
|
||||
'applyVoucher skip: voucher already used | voucher_id={vid} parent_id={pid} invoice_id={iid} invoice_number={inum}',
|
||||
[
|
||||
'vid' => (int)$voucherId,
|
||||
'pid' => (int)$parentId,
|
||||
'iid' => (int)$invoice['id'],
|
||||
'inum' => (string)($invoice['invoice_number'] ?? ''),
|
||||
]
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate discount
|
||||
$rawDiscount = ($voucher['discount_type'] === 'percent')
|
||||
? round(((float)$invoice['total_amount'] * (float)$voucher['discount_value']) / 100, 2)
|
||||
: (float) $voucher['discount_value'];
|
||||
|
||||
// Cap by CURRENT invoice balance snapshot
|
||||
$discount = min($rawDiscount, $initialPreBalance);
|
||||
|
||||
// Nothing to do if no discount
|
||||
if ($discount <= 0) {
|
||||
log_message(
|
||||
'error',
|
||||
'applyVoucher skip: discount <= 0 | voucher_id={vid} parent_id={pid} invoice_id={iid} invoice_number={inum} raw_discount={raw} balance={bal}',
|
||||
[
|
||||
'vid' => (int)$voucherId,
|
||||
'pid' => (int)$parentId,
|
||||
'iid' => (int)$invoice['id'],
|
||||
'inum' => (string)($invoice['invoice_number'] ?? ''),
|
||||
'raw' => $rawDiscount,
|
||||
'bal' => $initialPreBalance,
|
||||
]
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Insert discount usage
|
||||
$now = utc_now();
|
||||
$this->db->table('discount_usages')->insert([
|
||||
'voucher_id' => $voucherId,
|
||||
'invoice_id' => $invoice['id'],
|
||||
'parent_id' => $parentId,
|
||||
'discount_amount' => $discount,
|
||||
'description' => $voucherDescription,
|
||||
'school_year' => $this->schoolYear,
|
||||
'updated_by' => session()->get('user_id'),
|
||||
'used_at' => $now,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
// Update invoice balance based on pre-discount snapshot (supports multiple discounts)
|
||||
$newBalance = max(0.0, round($initialPreBalance - $discount, 2));
|
||||
$this->db->table('invoices')
|
||||
->where('id', $invoice['id'])
|
||||
->update([
|
||||
'balance' => $newBalance,
|
||||
'has_discount' => 1,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
// Compute post-balance based on pre-snapshot (more stable than $invoice['balance'])
|
||||
$postBalance = round($initialPreBalance - $discount, 2);
|
||||
if ($postBalance < 0) $postBalance = 0.0;
|
||||
|
||||
// (Optional) current balance re-check (in case of concurrent writes)
|
||||
$currentBalance = (float) $this->getCurrentInvoiceBalance($invoice['id'], $this->schoolYear);
|
||||
|
||||
// Increment voucher usage
|
||||
$this->db->table('discount_vouchers')
|
||||
->where('id', $voucherId)
|
||||
->set('times_used', 'COALESCE(times_used,0) + 1', false)
|
||||
->update();
|
||||
|
||||
// Prepare and trigger payment event
|
||||
[$eventData, $studentData] = $this->buildPaymentEventData(
|
||||
$invoice['id'],
|
||||
$voucherId,
|
||||
$discount,
|
||||
'discount',
|
||||
$paymentDate,
|
||||
'',
|
||||
0,
|
||||
$initialPreBalance, // pre-payment snapshot
|
||||
$postBalance // computed post-payment
|
||||
);
|
||||
Events::trigger('paymentReceived', $eventData, $studentData);
|
||||
|
||||
$touchedInvoiceIds[] = (int) $invoice['id'];
|
||||
$appliedCount++;
|
||||
|
||||
// If voucher covered the entire current balance, mark to update enrollments
|
||||
// Use a small epsilon to tolerate cents rounding.
|
||||
$epsilon = 0.01;
|
||||
$fullyCovered = (abs($discount - $initialPreBalance) <= $epsilon)
|
||||
|| ($postBalance <= $epsilon)
|
||||
|| ($currentBalance <= $epsilon);
|
||||
|
||||
if ($fullyCovered) {
|
||||
$fullyCoveredInvoiceIds[] = (int) $invoice['id'];
|
||||
}
|
||||
|
||||
$remainingUses--;
|
||||
|
||||
// Deactivate if we just hit the cap
|
||||
if ($remainingUses <= 0 && $maxUses !== null) {
|
||||
$this->db->table('discount_vouchers')
|
||||
->where('id', $voucherId)
|
||||
->update([
|
||||
'is_active' => 0,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->db->transComplete();
|
||||
|
||||
if ($this->db->transStatus() === false) {
|
||||
return redirect()->back()->with('error', 'Voucher application failed. Transaction rolled back.');
|
||||
}
|
||||
|
||||
if ($appliedCount === 0) {
|
||||
return redirect()->back()->with('error', 'No discounts were applied. The voucher may already be used for these invoices or balances are zero.');
|
||||
}
|
||||
|
||||
// ✅ Ensure voucher deactivates when max_uses reached (handles edge cases)
|
||||
if ($maxUses !== null) {
|
||||
$row = $this->db->table('discount_vouchers')
|
||||
->select('times_used')
|
||||
->where('id', $voucherId)
|
||||
->get()
|
||||
->getRowArray();
|
||||
$usedNow = (int) ($row['times_used'] ?? 0);
|
||||
if ($usedNow >= $maxUses) {
|
||||
$this->db->table('discount_vouchers')
|
||||
->where('id', $voucherId)
|
||||
->update([
|
||||
'is_active' => 0,
|
||||
'updated_at' => utc_now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ AFTER COMMIT: recalculate invoice totals/balance/paid/discount/refund
|
||||
foreach (array_unique($touchedInvoiceIds) as $iid) {
|
||||
try {
|
||||
$this->recalculateInvoice($iid, $this->schoolYear);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'recalculateInvoice failed for invoice {iid}: {err}', [
|
||||
'iid' => $iid,
|
||||
'err' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ AFTER COMMIT: update enrollments for fully-covered invoices
|
||||
// Avoid nested transactions by doing this post-commit.
|
||||
$updatedTotal = 0;
|
||||
foreach (array_unique($fullyCoveredInvoiceIds) as $iid) {
|
||||
try {
|
||||
$updatedTotal += (int) $this->updateEnrollmentStatusIfPaid($iid);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'updateEnrollmentStatusIfPaid failed for invoice {iid}: {err}', [
|
||||
'iid' => $iid,
|
||||
'err' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($updatedTotal > 0) {
|
||||
return redirect()->back()->with('success', 'Voucher applied successfully and enrollments updated.');
|
||||
}
|
||||
return redirect()->back()->with('success', 'Voucher applied successfully.');
|
||||
}
|
||||
|
||||
// GET: load page
|
||||
$parents = $this->userModel->getParents();
|
||||
|
||||
foreach ($parents as &$parent) {
|
||||
$discount = $this->db->table('discount_usages')
|
||||
->select('COALESCE(SUM(discount_amount),0) AS total_discount')
|
||||
->where('parent_id', $parent['id'])
|
||||
->where('school_year', $this->schoolYear)
|
||||
->get()
|
||||
->getRowArray();
|
||||
|
||||
$parent['total_discount'] = $discount['total_discount'] ?? 0;
|
||||
$parent['has_discount'] = ($parent['total_discount'] > 0) ? 1 : 0;
|
||||
}
|
||||
unset($parent);
|
||||
|
||||
return view('discounts/apply_voucher', [
|
||||
'vouchers' => $this->voucherModel->where('is_active', 1)->findAll(),
|
||||
'parents' => $parents,
|
||||
]);
|
||||
}
|
||||
|
||||
private function updateEnrollmentStatusIfPaid(int $invoiceId): int
|
||||
{
|
||||
// 1) Fetch invoice
|
||||
$invoice = $this->invoiceModel->find($invoiceId);
|
||||
if (!$invoice) {
|
||||
log_message('warning', 'Invoice not found: {id}', ['id' => $invoiceId]);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 2) Payment check (any payment recorded: balance < total)
|
||||
$total = (float) ($invoice['total_amount'] ?? 0);
|
||||
$balance = (float) ($invoice['balance'] ?? 0);
|
||||
if (!($total > 0 && $balance < $total)) {
|
||||
log_message('info', 'No payment yet. Skipping enrollment update. Invoice #{id}', ['id' => $invoiceId]);
|
||||
return 0;
|
||||
}
|
||||
|
||||
$parentId = (int) ($invoice['parent_id'] ?? 0);
|
||||
$schoolYear = (string) $this->schoolYear;
|
||||
if ($parentId <= 0 || $schoolYear === '') {
|
||||
log_message('warning', 'updateEnrollmentStatusIfPaid: missing parent_id/school_year for invoice #{id}', ['id' => $invoiceId]);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 3) Try to limit to enrollments actually present on the invoice (optional)
|
||||
// If invoice_items.enrollment_id doesn't exist, this silently falls back to parent/year scope.
|
||||
$paidEnrollmentIds = [];
|
||||
try {
|
||||
$rows = $this->db->table('invoice_items')
|
||||
->select('enrollment_id')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->where('enrollment_id IS NOT NULL', null, false)
|
||||
->get()->getResultArray();
|
||||
foreach ($rows as $r) {
|
||||
$eid = (int) ($r['enrollment_id'] ?? 0);
|
||||
if ($eid > 0) $paidEnrollmentIds[] = $eid;
|
||||
}
|
||||
$paidEnrollmentIds = array_values(array_unique($paidEnrollmentIds));
|
||||
} catch (\Throwable $e) {
|
||||
// No-op: table/column might not exist in your schema
|
||||
}
|
||||
|
||||
$db = $this->db;
|
||||
$db->transBegin();
|
||||
|
||||
try {
|
||||
// 4) Preview IDs that will be updated (good for debugging “partials”)
|
||||
$previewBuilder = $db->table('enrollments')
|
||||
->select('id')
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->groupStart()
|
||||
// tolerant match for 'payment pending' (case/space/nbsp)
|
||||
->where('enrollment_status', 'payment pending')
|
||||
->orWhere('enrollment_status', 'Payment pending')
|
||||
->orWhere('enrollment_status', 'Payment Pending')
|
||||
->orWhere('enrollment_status', 'PAYMENT PENDING')
|
||||
->orWhere("LOWER(TRIM(REPLACE(enrollment_status, CHAR(160), ' '))) = 'payment pending'", null, false)
|
||||
->groupEnd();
|
||||
|
||||
if (!empty($paidEnrollmentIds)) {
|
||||
$previewBuilder->whereIn('id', $paidEnrollmentIds);
|
||||
}
|
||||
|
||||
$toUpdateIds = array_map(
|
||||
static fn($r) => (int)$r['id'],
|
||||
$previewBuilder->get()->getResultArray()
|
||||
);
|
||||
|
||||
if (empty($toUpdateIds)) {
|
||||
$db->transCommit();
|
||||
log_message('info', 'No enrollments matched pending status for parent {p}, year {y}, sem {s}.', [
|
||||
'p' => $parentId,
|
||||
'y' => $schoolYear,
|
||||
's' => 'ALL'
|
||||
]);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 5) Perform update with same filters
|
||||
$builder = $db->table('enrollments');
|
||||
$builder->set('enrollment_status', 'enrolled')
|
||||
// Use DB date if you prefer: ->set('enrollment_date', 'CURRENT_DATE()', false)
|
||||
->set('enrollment_date', local_date(utc_now(), 'Y-m-d'))
|
||||
->whereIn('id', $toUpdateIds);
|
||||
|
||||
if ($builder->update() === false) {
|
||||
$err = $db->error();
|
||||
throw new \RuntimeException('Enrollments update failed: ' . ($err['message'] ?? 'unknown DB error'));
|
||||
}
|
||||
|
||||
$affected = $db->affectedRows();
|
||||
$db->transCommit();
|
||||
|
||||
log_message(
|
||||
'info',
|
||||
'Enrollment status -> enrolled for {n} row(s). parent={p}, year={y}, sem={s}, ids=[{ids}]',
|
||||
[
|
||||
'n' => $affected,
|
||||
'p' => $parentId,
|
||||
'y' => $schoolYear,
|
||||
's' => 'ALL',
|
||||
'ids' => implode(',', $toUpdateIds),
|
||||
]
|
||||
);
|
||||
|
||||
return $affected;
|
||||
} catch (\Throwable $e) {
|
||||
$db->transRollback();
|
||||
log_message('error', 'updateEnrollmentStatusIfPaid error: ' . $e->getMessage());
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public function listVouchers()
|
||||
{
|
||||
|
||||
$vouchers = $this->voucherModel->findAll();
|
||||
return view('discounts/list', ['vouchers' => $vouchers]);
|
||||
}
|
||||
|
||||
public function createVoucher()
|
||||
{
|
||||
if (strtolower($this->request->getMethod()) === 'post') {
|
||||
|
||||
// -------- Gather & normalize inputs --------
|
||||
$rawCode = (string) $this->request->getPost('code');
|
||||
// Keep A–Z, 0–9 and dashes; uppercase for consistency
|
||||
$code = strtoupper(preg_replace('/[^A-Z0-9\-]/i', '', trim($rawCode)));
|
||||
|
||||
$discountType = strtolower((string) $this->request->getPost('discount_type')); // 'percent' | 'fixed'
|
||||
$discountValue = (float) ($this->request->getPost('discount_value') ?? 0);
|
||||
$maxUsesRaw = $this->request->getPost('max_uses');
|
||||
$maxUses = ($maxUsesRaw === '' || $maxUsesRaw === null) ? null : max(0, (int) $maxUsesRaw);
|
||||
|
||||
$validFrom = trim((string) ($this->request->getPost('valid_from') ?? ''));
|
||||
$validUntil = trim((string) ($this->request->getPost('valid_until') ?? ''));
|
||||
$isActive = $this->request->getPost('is_active') ? 1 : 0;
|
||||
|
||||
// NEW: Description / Reason
|
||||
$description = trim((string) ($this->request->getPost('description') ?? ''));
|
||||
|
||||
// -------- Basic validations (before model rules) --------
|
||||
$errors = [];
|
||||
|
||||
if ($code === '') {
|
||||
$errors[] = 'Voucher code is required.';
|
||||
}
|
||||
|
||||
if (!in_array($discountType, ['percent', 'fixed'], true)) {
|
||||
$errors[] = 'Discount type must be "percent" or "fixed".';
|
||||
}
|
||||
|
||||
if ($discountType === 'percent') {
|
||||
if ($discountValue <= 0 || $discountValue > 100) {
|
||||
$errors[] = 'Percentage discount must be between 0 and 100.';
|
||||
}
|
||||
} else { // fixed
|
||||
if ($discountValue < 0) {
|
||||
$errors[] = 'Fixed discount must be 0 or greater.';
|
||||
}
|
||||
}
|
||||
|
||||
// Dates come from <input type="date"> as YYYY-MM-DD
|
||||
$validFrom = $validFrom !== '' ? $validFrom : null;
|
||||
$validUntil = $validUntil !== '' ? $validUntil : null;
|
||||
|
||||
// Ensure date format is plausible
|
||||
$isDate = static function (?string $d): bool {
|
||||
if ($d === null) return true;
|
||||
return (bool) preg_match('/^\d{4}-\d{2}-\d{2}$/', $d);
|
||||
};
|
||||
if (!$isDate($validFrom)) $errors[] = 'Valid From must be a date (YYYY-MM-DD).';
|
||||
if (!$isDate($validUntil)) $errors[] = 'Valid Until must be a date (YYYY-MM-DD).';
|
||||
|
||||
if ($validFrom && $validUntil && $validFrom > $validUntil) {
|
||||
$errors[] = 'Valid Until must be the same as or after Valid From.';
|
||||
}
|
||||
|
||||
// Optional: require a reason when auto-generated codes are used
|
||||
// if ($description === '' && strlen($code) === 10) {
|
||||
// $errors[] = 'Please add a description/reason for this voucher.';
|
||||
// }
|
||||
|
||||
if (!empty($errors)) {
|
||||
return redirect()->back()->withInput()->with('error', implode(' ', $errors));
|
||||
}
|
||||
|
||||
// -------- Prepare payload for model --------
|
||||
$data = [
|
||||
'code' => $code,
|
||||
'discount_type' => $discountType,
|
||||
'discount_value' => $discountValue,
|
||||
'max_uses' => $maxUses,
|
||||
'valid_from' => $validFrom,
|
||||
'valid_until' => $validUntil,
|
||||
'is_active' => $isActive,
|
||||
'description' => $description, // <-- NEW
|
||||
];
|
||||
|
||||
if ($this->voucherModel->save($data)) {
|
||||
return redirect()->to('/discounts/list')->with('success', 'Voucher created successfully.');
|
||||
}
|
||||
|
||||
// Model-level errors (unique code, etc.)
|
||||
return redirect()->back()
|
||||
->withInput()
|
||||
->with('error', 'Failed to save voucher: ' . implode('; ', (array) $this->voucherModel->errors()));
|
||||
}
|
||||
|
||||
return view('discounts/create');
|
||||
}
|
||||
|
||||
|
||||
public function editVoucher($id)
|
||||
{
|
||||
$voucher = $this->voucherModel->find($id);
|
||||
|
||||
if (!$voucher) {
|
||||
return redirect()->to('discounts/list')->with('error', 'Voucher not found');
|
||||
}
|
||||
|
||||
if (strtolower($this->request->getMethod()) === 'post') {
|
||||
$data = [
|
||||
'id' => $id,
|
||||
'code' => $this->request->getPost('code'),
|
||||
'discount_type' => $this->request->getPost('discount_type'),
|
||||
'discount_value' => $this->request->getPost('discount_value'),
|
||||
'max_uses' => $this->request->getPost('max_uses'),
|
||||
'valid_from' => $this->request->getPost('valid_from') ?: null,
|
||||
'valid_until' => $this->request->getPost('valid_until') ?: null,
|
||||
'is_active' => $this->request->getPost('is_active') ? 1 : 0,
|
||||
];
|
||||
|
||||
$this->voucherModel->save($data);
|
||||
return redirect()->to('discounts/list')->with('success', 'Voucher updated successfully');
|
||||
}
|
||||
|
||||
return view('discounts/edit', ['voucher' => $voucher]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔄 Helper: Current invoice balance (school-year scoped) = total - payments - discounts - refundsPaid
|
||||
*/
|
||||
private function getCurrentInvoiceBalance($invoiceId, $schoolYear)
|
||||
{
|
||||
$invoice = $this->invoiceModel->find($invoiceId);
|
||||
if (!$invoice) return 0.0;
|
||||
|
||||
// Payments (exclude void/refund/failed, honor year)
|
||||
$qb = $this->paymentModel
|
||||
->select('COALESCE(SUM(paid_amount),0) AS total_paid')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->where('school_year', $schoolYear);
|
||||
|
||||
$table = $this->paymentModel->table;
|
||||
$hasStatus = $this->db->fieldExists('status', $table);
|
||||
$hasVoid = $this->db->fieldExists('is_void', $table);
|
||||
if ($hasStatus) {
|
||||
$qb->groupStart()
|
||||
->whereNotIn('status', ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled'])
|
||||
->orWhere('status IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
if ($hasVoid) {
|
||||
$qb->groupStart()
|
||||
->where('is_void', 0)
|
||||
->orWhere('is_void IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
$rowPaid = $qb->first();
|
||||
$totalPaid = (float)($rowPaid['total_paid'] ?? 0);
|
||||
|
||||
// Discounts for this invoice in this year
|
||||
$rowDisc = $this->db->table('discount_usages du')
|
||||
->select('COALESCE(SUM(du.discount_amount),0) AS total_disc')
|
||||
->join('invoices i', 'i.id = du.invoice_id')
|
||||
->where('du.invoice_id', $invoiceId)
|
||||
->where('i.school_year', $schoolYear)
|
||||
->get()->getRowArray();
|
||||
$totalDisc = (float)($rowDisc['total_disc'] ?? 0);
|
||||
|
||||
// Refunds PAID for this invoice in this year
|
||||
$rowRefund = $this->db->table('refunds')
|
||||
->select('COALESCE(SUM(refund_paid_amount),0) AS total_refund_paid')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->where('school_year', $schoolYear)
|
||||
->whereIn('status', ['Partial', 'Paid'])
|
||||
->get()->getRowArray();
|
||||
$totalRefundPaid = (float)($rowRefund['total_refund_paid'] ?? 0);
|
||||
|
||||
$total = (float)($invoice['total_amount'] ?? 0);
|
||||
return max(0.0, round($total - $totalPaid - $totalDisc - $totalRefundPaid, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔄 Recalculate invoice totals and status based on all payments for current school year
|
||||
*/
|
||||
private function recalculateInvoice($invoiceId, $schoolYear): void
|
||||
{
|
||||
$invoice = $this->invoiceModel->find($invoiceId);
|
||||
if (!$invoice) return;
|
||||
|
||||
$parentId = (int)($invoice['parent_id'] ?? 0);
|
||||
if ($parentId <= 0) return;
|
||||
|
||||
// ---- Tuition (recompute from enrollments) ----
|
||||
$enrollments = $this->enrollmentModel
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->findAll();
|
||||
|
||||
$registered = [];
|
||||
$withdrawn = [];
|
||||
foreach ($enrollments as $e) {
|
||||
$row = [
|
||||
'student_id' => (int)($e['student_id'] ?? 0),
|
||||
'class_section_id' => $e['class_section_id'] ?? null,
|
||||
'enrollment_status'=> (string)($e['enrollment_status'] ?? ''),
|
||||
];
|
||||
if (in_array($row['enrollment_status'], ['enrolled','payment pending'], true)) {
|
||||
$registered[] = $row;
|
||||
} elseif (in_array($row['enrollment_status'], ['withdrawn','refund pending','withdraw under review'], true)) {
|
||||
$withdrawn[] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
// Refund window check – if after deadline, withdrawn still billed
|
||||
$refundDeadline = (string)($this->configModel->getConfig('refund_deadline') ?? '');
|
||||
$refundAllowed = true;
|
||||
try {
|
||||
$tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
|
||||
$tz = new \DateTimeZone($tzName);
|
||||
$today = new \DateTimeImmutable('today', $tz);
|
||||
$deadline = new \DateTimeImmutable($refundDeadline, $tz);
|
||||
$refundAllowed = $today <= $deadline;
|
||||
} catch (\Throwable $e) {
|
||||
$refundAllowed = true;
|
||||
}
|
||||
|
||||
$tuitionStudents = $registered;
|
||||
if (!$refundAllowed) {
|
||||
$tuitionStudents = array_merge($tuitionStudents, $withdrawn);
|
||||
}
|
||||
|
||||
// Grade threshold and fees
|
||||
$gradeFee = (int)($this->configModel->getConfig('grade_fee') ?? 9);
|
||||
$firstStudentFee = (float)($this->configModel->getConfig('first_student_fee') ?? 350);
|
||||
$secondStudentFee = (float)($this->configModel->getConfig('second_student_fee') ?? 200);
|
||||
$youthFee = (float)($this->configModel->getConfig('youth_fee') ?? 180);
|
||||
|
||||
// Normalize grades for tuition students
|
||||
foreach ($tuitionStudents as &$s) {
|
||||
$name = null;
|
||||
if (!empty($s['class_section_id'])) {
|
||||
$name = $this->classSectionModel->getClassSectionNameBySectionId($s['class_section_id']);
|
||||
}
|
||||
$s['grade_name'] = is_string($name) ? strtoupper(trim($name)) : 'N/A';
|
||||
}
|
||||
unset($s);
|
||||
|
||||
// Count regular vs youth and compute tuition
|
||||
$regularCount = 0;
|
||||
$youthCount = 0;
|
||||
foreach ($tuitionStudents as $s) {
|
||||
$lvl = $this->parseGradeLevel($s['grade_name']);
|
||||
if ($lvl > $gradeFee) $youthCount++; else $regularCount++;
|
||||
}
|
||||
|
||||
$tuitionSubtotal = 0.0;
|
||||
$tuitionSubtotal += $youthCount * $youthFee;
|
||||
if ($regularCount >= 2) {
|
||||
$tuitionSubtotal += $firstStudentFee + ($regularCount - 1) * $secondStudentFee;
|
||||
} elseif ($regularCount === 1) {
|
||||
$tuitionSubtotal += $firstStudentFee;
|
||||
}
|
||||
|
||||
// ---- Event charges (parent-year) ----
|
||||
$eventSubtotal = 0.0;
|
||||
try {
|
||||
$events = $this->eventChargesModel->getChargesWithEventInfo($parentId, $schoolYear) ?? [];
|
||||
foreach ($events as $ev) { $eventSubtotal += (float)($ev['charged'] ?? 0.0); }
|
||||
} catch (\Throwable $e) {}
|
||||
|
||||
// ---- Additional charges (per-invoice) ----
|
||||
$additionalSubtotal = 0.0;
|
||||
try {
|
||||
$rows = $this->additionalChargeModel
|
||||
->select('charge_type, amount')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->where('status', 'applied')
|
||||
->findAll();
|
||||
foreach ($rows as $r) {
|
||||
$amt = (float)($r['amount'] ?? 0);
|
||||
$typ = strtolower((string)($r['charge_type'] ?? 'add'));
|
||||
if ($typ === 'deduct') $amt = -abs($amt); else $amt = abs($amt);
|
||||
$additionalSubtotal += $amt;
|
||||
}
|
||||
} catch (\Throwable $e) {}
|
||||
|
||||
$newTotal = round($tuitionSubtotal + $eventSubtotal + $additionalSubtotal, 2);
|
||||
|
||||
// ---- Payments / Discounts / Refunds ----
|
||||
$db = $this->db;
|
||||
$table = $this->paymentModel->table;
|
||||
$hasStatus = $db->fieldExists('status', $table);
|
||||
$hasVoid = $db->fieldExists('is_void', $table);
|
||||
$exclude = ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled'];
|
||||
|
||||
$qb = $this->paymentModel
|
||||
->where('invoice_id', $invoiceId)
|
||||
->where('school_year', $schoolYear);
|
||||
|
||||
if ($hasStatus) {
|
||||
$qb->groupStart()
|
||||
->whereNotIn('status', $exclude)
|
||||
->orWhere('status IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
|
||||
if ($hasVoid) {
|
||||
$qb->groupStart()
|
||||
->where('is_void', 0)
|
||||
->orWhere('is_void IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
|
||||
$payments = $qb->findAll();
|
||||
$totalPaid = 0.0;
|
||||
foreach ($payments as $p) { $totalPaid += (float)($p['paid_amount'] ?? 0); }
|
||||
|
||||
$discRow = $this->db->table('discount_usages')
|
||||
->select('COALESCE(SUM(discount_amount),0) AS total_disc')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->get()->getRowArray();
|
||||
$totalDisc = (float)($discRow['total_disc'] ?? 0);
|
||||
|
||||
$refundRow = $this->db->table('refunds')
|
||||
->select('COALESCE(SUM(refund_paid_amount),0) AS total_refund_paid')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->whereIn('status', ['Partial','Paid'])
|
||||
->get()->getRowArray();
|
||||
$totalRefundPaid = (float)($refundRow['total_refund_paid'] ?? 0);
|
||||
|
||||
$newBalance = max(0.0, $newTotal - $totalDisc - $totalPaid - $totalRefundPaid);
|
||||
$newStatus = ($newBalance <= 0.00001) ? 'Paid' : (($totalPaid > 0) ? 'Partially Paid' : 'Unpaid');
|
||||
|
||||
$updateData = [
|
||||
'total_amount' => $newTotal,
|
||||
'paid_amount' => $totalPaid,
|
||||
'balance' => $newBalance,
|
||||
'status' => $newStatus,
|
||||
'has_discount' => ($totalDisc > 0.0) ? 1 : 0,
|
||||
];
|
||||
|
||||
if ($this->db->fieldExists('discount', $this->invoiceModel->table)) {
|
||||
$updateData['discount'] = $totalDisc;
|
||||
}
|
||||
|
||||
$this->invoiceModel->update($invoiceId, $updateData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a grade name into an integer level for tuition rules.
|
||||
* Kindergarten -> 1, Youth -> > 9, numeric grades passthrough.
|
||||
*/
|
||||
private function parseGradeLevel($grade): int
|
||||
{
|
||||
if (is_numeric($grade)) return (int)$grade;
|
||||
if (!is_string($grade)) return 999;
|
||||
$g = strtoupper(trim((string)$grade));
|
||||
$g = preg_replace('/\s+/', ' ', $g);
|
||||
$g = str_replace(['.', '_', '-'], ['', '', ' '], $g);
|
||||
// KG/K/Kindergarten
|
||||
$kg = ['K','KG','K G','KINDER','KINDERGARTEN'];
|
||||
if (in_array($g, $kg, true)) return 1;
|
||||
// Pre-K -> treat below regular
|
||||
$pk = ['PK','P K','PREK','PRE K','PRE KINDER','PREKINDER'];
|
||||
if (in_array($g, $pk, true)) return -1;
|
||||
// Youth variants: Y, YOUTH, Y1, YOUTH2 -> map to 10+
|
||||
if (preg_match('/^Y(?:OUTH)?\s*(\d+)?$/', $g, $m)) {
|
||||
$n = isset($m[1]) && $m[1] !== '' ? max(1, (int)$m[1]) : 1;
|
||||
$gradeFee = (int)($this->configModel->getConfig('grade_fee') ?? 9);
|
||||
return $gradeFee + $n;
|
||||
}
|
||||
if (preg_match('/^(?:GR?ADE\s*)?(\d{1,2})\s*([A-Z]*)$/', $g, $m)) {
|
||||
return (int)$m[1];
|
||||
}
|
||||
return 999;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect parent/invoice/payment data to trigger handlePaymentReceived().
|
||||
*
|
||||
* @return array [$data, $studentdata]
|
||||
*/
|
||||
private function buildPaymentEventData(
|
||||
int $invoiceId,
|
||||
string $transactionIdOrRef,
|
||||
float $amount,
|
||||
string $paymentMethod,
|
||||
string $paymentDate,
|
||||
?string $checkNumber,
|
||||
int $installmentSeq,
|
||||
float $preBalance, // ✅ new param: initial (pre-payment) balance
|
||||
float $postBalance // ✅ new param: computed post-payment balance
|
||||
): array {
|
||||
$invoice = $this->invoiceModel->find($invoiceId);
|
||||
if (!$invoice) {
|
||||
throw new \RuntimeException("Invoice {$invoiceId} not found");
|
||||
}
|
||||
|
||||
$parentRow = $this->db->table('users')
|
||||
->select('id, firstname, lastname, email')
|
||||
->where('id', $invoice['parent_id'])
|
||||
->get()
|
||||
->getRowArray();
|
||||
|
||||
if (!$parentRow) {
|
||||
throw new \RuntimeException("Parent not found for invoice {$invoiceId}");
|
||||
}
|
||||
|
||||
// Students (adjust joins to your schema)
|
||||
$studentRows = $this->db->table('students s')
|
||||
->select('s.id, s.firstname, s.lastname, sc.class_section_id')
|
||||
->join('student_class sc', 'sc.student_id = s.id', 'left')
|
||||
->where('s.parent_id', $invoice['parent_id'])
|
||||
->where('sc.school_year', $this->schoolYear)
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
$data = [
|
||||
'user_id' => (int) $parentRow['id'],
|
||||
'email' => $parentRow['email'],
|
||||
'firstname' => $parentRow['firstname'],
|
||||
'lastname' => $parentRow['lastname'],
|
||||
'school_year' => $this->schoolYear,
|
||||
'semester' => $this->semester,
|
||||
'invoice_id' => $invoiceId,
|
||||
'transaction_id' => $transactionIdOrRef,
|
||||
'payment_date' => $paymentDate,
|
||||
'amount' => $amount,
|
||||
'method' => $paymentMethod,
|
||||
'check_number' => $checkNumber,
|
||||
'installment_seq' => $installmentSeq,
|
||||
'invoice_total' => (float) $invoice['total_amount'],
|
||||
'pre_balance' => $preBalance, // ✅ the captured pre-payment balance
|
||||
'post_balance' => $postBalance, // ✅ computed post-payment balance
|
||||
'portalLink' => site_url('parent/invoices/' . $invoiceId),
|
||||
];
|
||||
|
||||
return [$data, $studentRows];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
|
||||
class DocsController extends BaseController
|
||||
{
|
||||
public function swagger()
|
||||
{
|
||||
// Present both specs in a Swagger UI dropdown
|
||||
$specs = [
|
||||
[
|
||||
'name' => 'Administrator API',
|
||||
'url' => '/docs/openapi_administrator_controller.yaml',
|
||||
],
|
||||
[
|
||||
'name' => 'Parent API',
|
||||
'url' => '/docs/openapi_parent_controller.yaml',
|
||||
],
|
||||
];
|
||||
|
||||
return view('swagger_ui', ['specs' => $specs]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use CodeIgniter\Controller;
|
||||
use PHPMailer\PHPMailer\PHPMailer;
|
||||
use PHPMailer\PHPMailer\Exception;
|
||||
|
||||
class EmailController extends Controller
|
||||
{
|
||||
/**
|
||||
* Send an email using a named profile (e.g., 'communication', 'payment', 'default').
|
||||
*
|
||||
* @param string $recipient
|
||||
* @param string $subject
|
||||
* @param string $htmlMessage
|
||||
* @param string|null $profile e.g. 'communication', 'payment'; if null uses MAIL_PROFILE_DEFAULT or 'default'
|
||||
* @param string|null $replyToEmail optional override
|
||||
* @param string|null $replyToName optional override
|
||||
* @param array $attachments [['path'=>..., 'name'=>...], ...]
|
||||
*/
|
||||
public function sendEmail(
|
||||
string $recipient,
|
||||
string $subject,
|
||||
string $htmlMessage,
|
||||
?string $profile = null,
|
||||
?string $replyToEmail = null,
|
||||
?string $replyToName = null,
|
||||
array $attachments = []
|
||||
): bool {
|
||||
// Composer autoload (if not already loaded by CI4)
|
||||
$autoload = APPPATH . '../vendor/autoload.php';
|
||||
if (is_file($autoload)) {
|
||||
require_once $autoload;
|
||||
}
|
||||
|
||||
$profile = $this->resolveProfile($profile);
|
||||
$cfg = $this->getProfileConfig($profile);
|
||||
|
||||
// Guard rails: refuse to proceed with missing essentials
|
||||
if (empty($cfg['host']) || empty($cfg['user']) || $cfg['pass'] === '') {
|
||||
log_message('error', "[mail:$profile] Missing SMTP config (host/user/pass). Check .env MAIL_{$this->envKeyFromProfile($profile)}_* or MAIL_DEFAULT_*.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Optional env flags
|
||||
$debugEnabled = (bool) env('MAIL_DEBUG', false);
|
||||
$timeout = (int) env('MAIL_TIMEOUT', 15); // seconds
|
||||
$keepAlive = (bool) env('MAIL_KEEPALIVE', false);
|
||||
$verifyPeer = env('MAIL_VERIFY_PEER', 'true'); // 'true'|'false' (string to allow .env)
|
||||
$verifyPeer = filter_var($verifyPeer, FILTER_VALIDATE_BOOLEAN);
|
||||
|
||||
// Preflight DNS + socket (helps distinguish firewall/DNS vs. auth)
|
||||
$targetHost = $cfg['host'];
|
||||
$targetPort = (int) $cfg['port'];
|
||||
|
||||
$resolved = @gethostbyname($targetHost);
|
||||
if (!$resolved || $resolved === $targetHost) {
|
||||
// Not fatal (some environments block gethostbyname), but useful log
|
||||
log_message('debug', "[mail:$profile] DNS resolve note: host=$targetHost, resolved=$resolved");
|
||||
}
|
||||
|
||||
$sockOk = @fsockopen($targetHost, $targetPort, $errno, $errstr, 5);
|
||||
if (!$sockOk) {
|
||||
log_message('error', "[mail:$profile] Socket preflight failed to {$targetHost}:{$targetPort} (errno=$errno, err=$errstr). Likely firewall/port/encryption mismatch or wrong host.");
|
||||
} else {
|
||||
fclose($sockOk);
|
||||
}
|
||||
|
||||
$mail = new PHPMailer(true);
|
||||
|
||||
try {
|
||||
// Capture PHPMailer’s own debug if enabled
|
||||
if ($debugEnabled) {
|
||||
ob_start();
|
||||
}
|
||||
|
||||
// PHPMailer core setup
|
||||
$mail->isSMTP();
|
||||
$mail->Host = $cfg['host'];
|
||||
$mail->Port = $cfg['port'];
|
||||
$mail->SMTPAuth = true;
|
||||
$mail->Username = $cfg['user'];
|
||||
$mail->Password = $cfg['pass'];
|
||||
$mail->CharSet = 'UTF-8';
|
||||
$mail->Timeout = $timeout; // socket timeout
|
||||
$mail->SMTPKeepAlive = $keepAlive; // reuse connection for multiple sends
|
||||
$mail->SMTPAutoTLS = true; // allow auto TLS upgrade when possible
|
||||
|
||||
// Encryption mapping: 'tls' => STARTTLS (587), 'ssl' => implicit TLS (465)
|
||||
if ($cfg['encryption'] === 'ssl') {
|
||||
$mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS;
|
||||
} else {
|
||||
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
|
||||
}
|
||||
|
||||
// TLS verification options (can relax on demand via .env for on-prem/self-signed)
|
||||
$mail->SMTPOptions = [
|
||||
'ssl' => [
|
||||
'verify_peer' => $verifyPeer,
|
||||
'verify_peer_name' => $verifyPeer,
|
||||
'allow_self_signed' => !$verifyPeer,
|
||||
],
|
||||
];
|
||||
|
||||
// Optional verbose debugging (set MAIL_DEBUG=true in .env)
|
||||
$mail->SMTPDebug = $debugEnabled ? 3 : 0;
|
||||
$mail->Debugoutput = 'error_log';
|
||||
|
||||
// From / Return-Path
|
||||
$mail->setFrom($cfg['fromEmail'], $cfg['fromName']);
|
||||
if (!empty($cfg['returnPath'])) {
|
||||
$mail->Sender = $cfg['returnPath'];
|
||||
}
|
||||
|
||||
// Configurable Reply-To: env overrides inputs/config; fallback to profile/defaults
|
||||
$mail->clearReplyTos();
|
||||
$rtEmail = env('MAIL_DEFAULT_REPLY_TO');
|
||||
$rtName = env('MAIL_DEFAULT_REPLY_TO_NAME');
|
||||
if (!$rtEmail || !filter_var($rtEmail, FILTER_VALIDATE_EMAIL)) {
|
||||
$rtEmail = $replyToEmail ?: ($cfg['replyTo'] ?: $cfg['fromEmail']);
|
||||
}
|
||||
if (!$rtName) {
|
||||
$rtName = $replyToName ?: ($cfg['replyToName'] ?: $cfg['fromName']);
|
||||
}
|
||||
$rtName = $this->sanitizeReplyToName($rtName, $cfg['fromName']);
|
||||
if ($rtEmail) {
|
||||
$mail->addReplyTo($rtEmail, $rtName);
|
||||
}
|
||||
|
||||
// DKIM (optional)
|
||||
if (!empty($cfg['dkim']['domain']) && !empty($cfg['dkim']['private']) && !empty($cfg['dkim']['selector'])) {
|
||||
$mail->DKIM_domain = $cfg['dkim']['domain'];
|
||||
$mail->DKIM_private = $cfg['dkim']['private']; // path to private key file
|
||||
$mail->DKIM_selector = $cfg['dkim']['selector'];
|
||||
$mail->DKIM_identity = $cfg['fromEmail'];
|
||||
}
|
||||
|
||||
// Recipient(s)
|
||||
$mail->addAddress($recipient);
|
||||
|
||||
// Attachments
|
||||
foreach ($attachments as $att) {
|
||||
if (!empty($att['path']) && is_file($att['path'])) {
|
||||
$mail->addAttachment($att['path'], $att['name'] ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
// Content
|
||||
$mail->isHTML(true);
|
||||
$mail->Subject = $subject;
|
||||
$mail->Body = $htmlMessage;
|
||||
|
||||
// Send!
|
||||
$ok = $mail->send();
|
||||
|
||||
$dbg = $debugEnabled ? (ob_get_clean() ?: '') : '';
|
||||
if ($ok) {
|
||||
log_message('info', "[mail:$profile] Sent to {$recipient}, subj='{$subject}' via {$cfg['host']}:{$cfg['port']}/{$cfg['encryption']}");
|
||||
return true;
|
||||
}
|
||||
|
||||
log_message('error', "[mail:$profile] Failed: {$mail->ErrorInfo}. Debug: {$dbg}");
|
||||
return false;
|
||||
|
||||
} catch (Exception $e) {
|
||||
$dbg = $debugEnabled ? (ob_get_clean() ?: '') : '';
|
||||
log_message('error', "[mail:$profile] Exception: {$e->getMessage()} | PHPMailer: {$mail->ErrorInfo} | Debug: {$dbg}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve null/alias profile names to a canonical env prefix. */
|
||||
private function resolveProfile(?string $profile): string
|
||||
{
|
||||
$p = strtolower(trim((string) $profile));
|
||||
if ($p === '' || $p === 'auto') {
|
||||
$p = strtolower((string) getenv('MAIL_PROFILE_DEFAULT')) ?: 'default';
|
||||
}
|
||||
|
||||
return match ($p) {
|
||||
'comm', 'comms' => 'communication',
|
||||
'sys', 'system' => 'default',
|
||||
default => $p, // e.g., 'payment', 'admissions', etc.
|
||||
};
|
||||
}
|
||||
|
||||
/** Turn 'communication' into 'COMMUNICATION' for env lookups/logs. */
|
||||
private function envKeyFromProfile(string $profile): string
|
||||
{
|
||||
return strtoupper(preg_replace('/[^A-Z0-9]+/i', '_', $profile));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve SMTP & identity for any profile with layered fallbacks:
|
||||
* 1) MAIL_{PROFILE}_*
|
||||
* 2) MAIL_DEFAULT_*
|
||||
* 3) legacy SMTP_* (HOST/USER/PASS/PORT/ENCRYPTION)
|
||||
* 4) hard defaults
|
||||
*/
|
||||
private function getProfileConfig(string $profile): array
|
||||
{
|
||||
$key = $this->envKeyFromProfile($profile);
|
||||
|
||||
// Helper: first non-empty env from a list
|
||||
$envFirst = function (array $keys, $default = null) {
|
||||
foreach ($keys as $k) {
|
||||
$v = env($k);
|
||||
if (is_string($v)) $v = trim($v);
|
||||
if ($v !== null && $v !== '') return $v;
|
||||
}
|
||||
return $default;
|
||||
};
|
||||
|
||||
// Core SMTP (env first, then legacy SMTP_*)
|
||||
$host = $envFirst(["MAIL_{$key}_HOST", "MAIL_DEFAULT_HOST", "SMTP_HOST"], '');
|
||||
$user = $envFirst(["MAIL_{$key}_USER", "MAIL_DEFAULT_USER", "SMTP_USER"], '');
|
||||
$pass = $envFirst(["MAIL_{$key}_PASS", "MAIL_DEFAULT_PASS", "SMTP_PASS"], '');
|
||||
$portRaw = $envFirst(["MAIL_{$key}_PORT", "MAIL_DEFAULT_PORT", "SMTP_PORT"], 587);
|
||||
$encRaw = $envFirst(["MAIL_{$key}_ENCRYPTION", "MAIL_DEFAULT_ENCRYPTION", "SMTP_ENCRYPTION"], 'tls');
|
||||
|
||||
// Fallback to Config\Email when env is not set (common in local/dev)
|
||||
$cfgEmail = config('Email');
|
||||
if ($cfgEmail) {
|
||||
if ($host === '' && !empty($cfgEmail->SMTPHost)) {
|
||||
$host = $cfgEmail->SMTPHost;
|
||||
}
|
||||
if ($user === '' && !empty($cfgEmail->SMTPUser)) {
|
||||
$user = $cfgEmail->SMTPUser;
|
||||
}
|
||||
if ($pass === '' && !empty($cfgEmail->SMTPPass)) {
|
||||
$pass = $cfgEmail->SMTPPass;
|
||||
}
|
||||
if ((int) $portRaw <= 0 && !empty($cfgEmail->SMTPPort)) {
|
||||
$portRaw = $cfgEmail->SMTPPort;
|
||||
}
|
||||
if (($encRaw === '' || $encRaw === 'tls') && !empty($cfgEmail->SMTPCrypto)) {
|
||||
$encRaw = $cfgEmail->SMTPCrypto;
|
||||
}
|
||||
}
|
||||
|
||||
// Identity
|
||||
$fromEmail = $envFirst(["MAIL_{$key}_FROM_EMAIL", "MAIL_DEFAULT_FROM_EMAIL"], $user ?: 'no-reply@alrahmaisgl.org');
|
||||
$fromName = $envFirst(["MAIL_{$key}_FROM_NAME", "MAIL_DEFAULT_FROM_NAME"], 'Al Rahma Sunday School');
|
||||
$replyTo = $envFirst(["MAIL_{$key}_REPLY_TO", "MAIL_DEFAULT_REPLY_TO"], '');
|
||||
$replyToNm = $envFirst(["MAIL_{$key}_REPLY_TO_NAME","MAIL_DEFAULT_REPLY_TO_NAME"], '');
|
||||
$returnPath = $envFirst(["MAIL_{$key}_RETURN_PATH","MAIL_DEFAULT_RETURN_PATH"], '');
|
||||
|
||||
// DKIM (optional)
|
||||
$dkim = [
|
||||
'domain' => $envFirst(["MAIL_{$key}_DKIM_DOMAIN", "MAIL_DEFAULT_DKIM_DOMAIN"], ''),
|
||||
'selector' => $envFirst(["MAIL_{$key}_DKIM_SELECTOR", "MAIL_DEFAULT_DKIM_SELECTOR"], ''),
|
||||
'private' => $envFirst(["MAIL_{$key}_DKIM_PRIVATE", "MAIL_DEFAULT_DKIM_PRIVATE"], ''), // full path
|
||||
];
|
||||
|
||||
// Normalize types/values
|
||||
$port = (int) $portRaw ?: 587;
|
||||
$encryption = strtolower((string) $encRaw);
|
||||
$encryption = in_array($encryption, ['tls','ssl'], true) ? $encryption : 'tls';
|
||||
|
||||
// --- Autocorrect common mistakes (prevents silent handshake failures) ---
|
||||
if ($port === 587 && $encryption === 'ssl') {
|
||||
$encryption = 'tls';
|
||||
}
|
||||
if ($port === 465 && $encryption === 'tls') {
|
||||
$encryption = 'ssl';
|
||||
}
|
||||
|
||||
return [
|
||||
'host' => (string) $host,
|
||||
'user' => (string) $user,
|
||||
'pass' => (string) $pass,
|
||||
'port' => $port,
|
||||
// map used above: 'tls' => STARTTLS, 'ssl' => SMTPS
|
||||
'encryption' => $encryption,
|
||||
'fromEmail' => (string) $fromEmail,
|
||||
'fromName' => (string) $fromName,
|
||||
'replyTo' => (string) $replyTo,
|
||||
'replyToName' => (string) $this->sanitizeReplyToName($replyToNm, (string) $fromName),
|
||||
'returnPath' => (string) $returnPath,
|
||||
'dkim' => $dkim,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize Reply-To display names, removing "No-Reply/No-Replay" placeholders.
|
||||
*/
|
||||
private function sanitizeReplyToName(?string $name, string $fallback): string
|
||||
{
|
||||
$trimmed = trim((string) $name);
|
||||
if ($trimmed === '' || preg_match('/^no[- ]?repl(?:y|ay)$/i', $trimmed)) {
|
||||
return $fallback;
|
||||
}
|
||||
return $trimmed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
use CodeIgniter\Controller;
|
||||
use CodeIgniter\Database\Exceptions\DatabaseException;
|
||||
|
||||
class EmailExtractorController extends Controller
|
||||
{
|
||||
/**
|
||||
* GET /email-extractor
|
||||
* Renders the frontend page (view) with CSV upload and comparison UI.
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
return view('/emails/parent_email_extractor');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/emails
|
||||
* Returns JSON: { users: string[], parents: string[] }
|
||||
* Pulls emails from users.email and parents.secondparent_email
|
||||
*/
|
||||
public function getEmails()
|
||||
{
|
||||
$db = db_connect();
|
||||
$users = [];
|
||||
$parents = [];
|
||||
|
||||
try {
|
||||
// Fetch users.email (non-null, non-empty)
|
||||
$builderUsers = $db->table('users')->select('email');
|
||||
$userRows = $builderUsers->get()->getResultArray();
|
||||
foreach ($userRows as $row) {
|
||||
$email = strtolower(trim((string)($row['email'] ?? '')));
|
||||
if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$users[] = $email;
|
||||
}
|
||||
}
|
||||
// De-duplicate
|
||||
$users = array_values(array_unique($users));
|
||||
|
||||
// Fetch parents.secondparent_email (non-null, non-empty)
|
||||
$builderParents = $db->table('parents')->select('secondparent_email');
|
||||
$parentRows = $builderParents->get()->getResultArray();
|
||||
foreach ($parentRows as $row) {
|
||||
$email = strtolower(trim((string)($row['secondparent_email'] ?? '')));
|
||||
if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$parents[] = $email;
|
||||
}
|
||||
}
|
||||
// De-duplicate
|
||||
$parents = array_values(array_unique($parents));
|
||||
|
||||
return $this->response->setJSON([
|
||||
'users' => $users,
|
||||
'parents' => $parents,
|
||||
])->setStatusCode(ResponseInterface::HTTP_OK);
|
||||
} catch (DatabaseException $e) {
|
||||
return $this->response->setJSON([
|
||||
'error' => 'Database error: ' . $e->getMessage(),
|
||||
])->setStatusCode(ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/compare
|
||||
* Accepts multipart/form-data with a CSV file named 'file' (optional),
|
||||
* or JSON body with { csvEmails: string[] } (optional).
|
||||
* Compares against DB and returns:
|
||||
* {
|
||||
* existed: string[], // in CSV AND in DB
|
||||
* needToAdd: string[], // in DB BUT NOT in CSV
|
||||
* counts: { csv: number, db: number, users: number, parents: number }
|
||||
* }
|
||||
*/
|
||||
public function compare()
|
||||
{
|
||||
$request = $this->request;
|
||||
$csvEmails = [];
|
||||
|
||||
// 1) Try read from uploaded file
|
||||
$file = $request->getFile('file');
|
||||
if ($file && $file->isValid()) {
|
||||
$contents = file_get_contents($file->getTempName());
|
||||
$csvEmails = $this->extractEmailsFromText($contents);
|
||||
}
|
||||
|
||||
// 2) Or from JSON body
|
||||
if (empty($csvEmails) && $request->getHeaderLine('Content-Type')) {
|
||||
$contentType = $request->getHeaderLine('Content-Type');
|
||||
if (stripos($contentType, 'application/json') !== false) {
|
||||
$json = $request->getJSON(true);
|
||||
if (isset($json['csvEmails']) && is_array($json['csvEmails'])) {
|
||||
$csvEmails = $this->normalizeEmailArray($json['csvEmails']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Compare with DB
|
||||
$db = db_connect();
|
||||
|
||||
// Fetch DB emails
|
||||
$users = [];
|
||||
$parents = [];
|
||||
|
||||
$userRows = $db->table('users')->select('email')->get()->getResultArray();
|
||||
foreach ($userRows as $row) {
|
||||
$e = strtolower(trim((string)($row['email'] ?? '')));
|
||||
if ($e !== '' && filter_var($e, FILTER_VALIDATE_EMAIL)) {
|
||||
$users[] = $e;
|
||||
}
|
||||
}
|
||||
$users = array_values(array_unique($users));
|
||||
|
||||
$parentRows = $db->table('parents')->select('secondparent_email')->get()->getResultArray();
|
||||
foreach ($parentRows as $row) {
|
||||
$e = strtolower(trim((string)($row['secondparent_email'] ?? '')));
|
||||
if ($e !== '' && filter_var($e, FILTER_VALIDATE_EMAIL)) {
|
||||
$parents[] = $e;
|
||||
}
|
||||
}
|
||||
$parents = array_values(array_unique($parents));
|
||||
|
||||
// Sets for comparison
|
||||
$csvSet = array_flip(array_values(array_unique($csvEmails)));
|
||||
$dbUnion = array_values(array_unique(array_merge($users, $parents)));
|
||||
$dbSet = array_flip($dbUnion);
|
||||
|
||||
// existed: in CSV and in DB
|
||||
$existed = [];
|
||||
foreach ($csvSet as $email => $_) {
|
||||
if (isset($dbSet[$email])) {
|
||||
$existed[] = $email;
|
||||
}
|
||||
}
|
||||
sort($existed);
|
||||
|
||||
// needToAdd: in DB but NOT in CSV
|
||||
$needToAdd = [];
|
||||
foreach ($dbSet as $email => $_) {
|
||||
if (!isset($csvSet[$email])) {
|
||||
$needToAdd[] = $email;
|
||||
}
|
||||
}
|
||||
sort($needToAdd);
|
||||
|
||||
return $this->response->setJSON([
|
||||
'existed' => $existed,
|
||||
'needToAdd' => $needToAdd,
|
||||
'counts' => [
|
||||
'csv' => count($csvEmails),
|
||||
'db' => count($dbUnion),
|
||||
'users' => count($users),
|
||||
'parents' => count($parents),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
// Helpers
|
||||
|
||||
private function normalizeEmailArray(array $arr): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($arr as $e) {
|
||||
$email = strtolower(trim((string)$e));
|
||||
if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$out[] = $email;
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($out));
|
||||
}
|
||||
|
||||
private function extractEmailsFromText(string $text): array
|
||||
{
|
||||
$pattern = '/[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}/i';
|
||||
preg_match_all($pattern, $text, $matches);
|
||||
$emails = $matches[0] ?? [];
|
||||
return $this->normalizeEmailArray($emails);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Models\EmergencyContactModel;
|
||||
use App\Models\StudentModel;
|
||||
use App\Models\UserModel;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
|
||||
class EmergencyContactController extends BaseController
|
||||
{
|
||||
protected $contactModel;
|
||||
protected $studentModel;
|
||||
protected $userModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->contactModel = new EmergencyContactModel();
|
||||
$this->studentModel = new StudentModel();
|
||||
$this->userModel = new UserModel(); // Add this
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$db = \Config\Database::connect();
|
||||
$parentIds = $this->contactModel
|
||||
->distinct()
|
||||
->select('parent_id')
|
||||
->findAll();
|
||||
|
||||
$data = [];
|
||||
|
||||
foreach ($parentIds as $row) {
|
||||
$parentId = $row['parent_id'];
|
||||
|
||||
$parent = $this->userModel->find($parentId); // Get parent info
|
||||
$parentName = $parent ? $parent['firstname'] . ' ' . $parent['lastname'] : 'Unknown Parent';
|
||||
$parentPhone = is_array($parent) ? (string)($parent['cellphone'] ?? '') : '';
|
||||
|
||||
// Try to load second parent phone from parents table (if available)
|
||||
$secondPhone = '';
|
||||
try {
|
||||
$row = $db->table('parents')
|
||||
->select('secondparent_phone')
|
||||
->where('firstparent_id', (int) $parentId)
|
||||
->orderBy('updated_at', 'DESC')
|
||||
->get()->getRowArray();
|
||||
if ($row && !empty($row['secondparent_phone'])) {
|
||||
$secondPhone = (string) $row['secondparent_phone'];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
log_message('debug', 'EmergencyContactController: could not load second parent phone: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$students = $this->studentModel
|
||||
->where('parent_id', $parentId)
|
||||
->findAll();
|
||||
|
||||
$contacts = $this->contactModel
|
||||
->getEmergencyContactsByParentId($parentId);
|
||||
|
||||
$data[] = [
|
||||
'parent_id' => $parentId,
|
||||
'parent_name' => $parentName,
|
||||
'students' => $students,
|
||||
'contacts' => $contacts,
|
||||
'parent_phones' => array_values(array_filter([$parentPhone, $secondPhone], static fn($v) => (string)$v !== '')),
|
||||
];
|
||||
}
|
||||
|
||||
return view('administrator/emergency_contact/index', ['groups' => $data]);
|
||||
}
|
||||
|
||||
|
||||
public function edit($id)
|
||||
{
|
||||
$contact = $this->contactModel->find($id);
|
||||
return view('administrator/emergency_contact/edit', ['contact' => $contact]);
|
||||
}
|
||||
|
||||
public function update($id)
|
||||
{
|
||||
dd("Update was called with ID: $id", $this->request->getPost());
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function delete($id)
|
||||
{
|
||||
$this->contactModel->delete($id);
|
||||
return redirect()->to('/administrator/emergency_contact')->with('success', 'Contact deleted.');
|
||||
}
|
||||
|
||||
// API: JSON payload for emergency contacts grouped by parent
|
||||
public function data()
|
||||
{
|
||||
// Build groups similarly to index(), but return JSON
|
||||
$parentRows = $this->contactModel
|
||||
->distinct()
|
||||
->select('parent_id')
|
||||
->findAll();
|
||||
|
||||
$groups = [];
|
||||
foreach ($parentRows as $row) {
|
||||
$parentId = (int)($row['parent_id'] ?? 0);
|
||||
if ($parentId <= 0) continue;
|
||||
|
||||
$parent = $this->userModel->find($parentId) ?: [];
|
||||
$parentName = trim(($parent['firstname'] ?? '') . ' ' . ($parent['lastname'] ?? '')) ?: 'Unknown Parent';
|
||||
|
||||
$students = $this->studentModel
|
||||
->select('id, firstname, lastname, school_id')
|
||||
->where('parent_id', $parentId)
|
||||
->findAll();
|
||||
|
||||
$contacts = $this->contactModel
|
||||
->where('parent_id', $parentId)
|
||||
->orderBy('updated_at', 'DESC')
|
||||
->findAll();
|
||||
|
||||
$groups[] = [
|
||||
'parent_id' => $parentId,
|
||||
'parent_name' => $parentName,
|
||||
'students' => $students,
|
||||
'contacts' => $contacts,
|
||||
];
|
||||
}
|
||||
|
||||
return $this->response->setJSON([
|
||||
'groups' => $groups,
|
||||
'csrfHash' => csrf_hash(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Models\EventModel;
|
||||
use App\Models\EventChargesModel;
|
||||
use App\Models\UserModel;
|
||||
use App\Models\StudentModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
use App\Models\EnrollmentModel;
|
||||
use App\Controllers\View\InvoiceController;
|
||||
use CodeIgniter\RESTful\ResourceController;
|
||||
|
||||
class EventController extends ResourceController
|
||||
{
|
||||
protected $eventChargesModel;
|
||||
protected $studentModel;
|
||||
protected $userModel;
|
||||
protected $configModel;
|
||||
protected $eventModel;
|
||||
protected $invoiceController;
|
||||
protected $schoolYear;
|
||||
protected $semester;
|
||||
protected $categories;
|
||||
protected $enrollmentModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->eventChargesModel = new EventChargesModel(); //eventChargesModel
|
||||
$this->studentModel = new StudentModel();
|
||||
$this->configModel = new ConfigurationModel();
|
||||
$this->userModel = new UserModel(); // Add this
|
||||
$this->eventModel = new EventModel();
|
||||
$this->invoiceController = new InvoiceController();
|
||||
$this->enrollmentModel = new EnrollmentModel();
|
||||
|
||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||
$this->semester = $this->configModel->getConfig('semester');
|
||||
$this->categories = [
|
||||
'workshops',
|
||||
'orientations',
|
||||
'field trips',
|
||||
'Ramadan programs',
|
||||
];
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$eventModel = new EventModel();
|
||||
|
||||
$today = local_date(utc_now(), 'Y-m-d');
|
||||
|
||||
// Fetch all events
|
||||
$events = $eventModel
|
||||
->orderBy('created_at', 'DESC')
|
||||
->findAll();
|
||||
|
||||
// Fetch active events (not expired)
|
||||
$activeEventCount = $eventModel
|
||||
->where('expiration_date >=', $today)
|
||||
->countAllResults();
|
||||
|
||||
return view('administrator/events/event_list', [
|
||||
'events' => $events,
|
||||
'activeEventCount' => $activeEventCount
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
public function create()
|
||||
{
|
||||
helper(['form']);
|
||||
if (strtolower($this->request->getMethod()) === 'post') {
|
||||
$file = $this->request->getFile('flyer');
|
||||
$flyerPath = null;
|
||||
|
||||
if ($file && $file->isValid() && !$file->hasMoved()) {
|
||||
// Move to public/uploads/event_flyers
|
||||
$newName = $file->getRandomName();
|
||||
$file->move(FCPATH . 'uploads/event_flyers', $newName);
|
||||
$flyerPath = 'event_flyers/' . $newName; // store relative path
|
||||
}
|
||||
|
||||
$eventId = $this->eventModel->insert([
|
||||
'event_name' => $this->request->getPost('event_name'),
|
||||
'event_category' => $this->request->getPost('event_category'),
|
||||
'description' => $this->request->getPost('description'),
|
||||
'amount' => $this->request->getPost('amount'),
|
||||
'flyer' => $flyerPath,
|
||||
'expiration_date' => $this->request->getPost('expiration_date'),
|
||||
'semester' => $this->request->getPost('semester'),
|
||||
'school_year' => $this->request->getPost('school_year'),
|
||||
'created_by' => session()->get('user_id'),
|
||||
]);
|
||||
|
||||
if ($eventId) {
|
||||
$amount = (float) $this->request->getPost('amount');
|
||||
$semester = (string) $this->request->getPost('semester');
|
||||
$schoolYear = (string) $this->request->getPost('school_year');
|
||||
$userId = (int) (session()->get('user_id') ?? 0);
|
||||
|
||||
$enrollments = $this->enrollmentModel
|
||||
->select('enrollments.student_id, students.parent_id')
|
||||
->join('students', 'students.id = enrollments.student_id', 'left')
|
||||
->where('enrollments.school_year', $schoolYear)
|
||||
->whereIn('enrollments.enrollment_status', ['enrolled', 'payment pending'])
|
||||
->findAll();
|
||||
|
||||
$parentIds = [];
|
||||
foreach ($enrollments as $row) {
|
||||
$studentId = (int) ($row['student_id'] ?? 0);
|
||||
$parentId = (int) ($row['parent_id'] ?? 0);
|
||||
if ($studentId <= 0 || $parentId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$exists = $this->eventChargesModel
|
||||
->where('event_id', $eventId)
|
||||
->where('student_id', $studentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('semester', $semester)
|
||||
->first();
|
||||
|
||||
if ($exists) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->eventChargesModel->insert([
|
||||
'event_id' => $eventId,
|
||||
'parent_id' => $parentId,
|
||||
'student_id' => $studentId,
|
||||
'participation' => 'yes',
|
||||
'charged' => $amount,
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
'updated_by' => $userId ?: null,
|
||||
]);
|
||||
|
||||
$parentIds[] = $parentId;
|
||||
}
|
||||
|
||||
$parentIds = array_unique($parentIds);
|
||||
foreach ($parentIds as $pid) {
|
||||
$this->invoiceController->generateInvoice((string) $pid);
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()->to('/administrator/events')->with('success', 'Event created successfully');
|
||||
}
|
||||
|
||||
return view('administrator/events/create_event', [
|
||||
'categories' => $this->categories,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
public function edit($id = null)
|
||||
{
|
||||
helper(['form']);
|
||||
$event = $this->eventModel->find($id);
|
||||
|
||||
if (!$event) {
|
||||
return redirect()->to('/administrator/events')->with('error', 'Event not found');
|
||||
}
|
||||
|
||||
if (strtolower($this->request->getMethod()) === 'post') {
|
||||
log_message('debug', 'POST detected');
|
||||
$file = $this->request->getFile('flyer');
|
||||
$flyerPath = $event['flyer']; // Default: keep old flyer
|
||||
|
||||
if ($file && $file->isValid() && !$file->hasMoved()) {
|
||||
$newName = $file->getRandomName();
|
||||
$file->move(FCPATH . 'uploads/event_flyers', $newName);
|
||||
$flyerPath = 'event_flyers/' . $newName; // store relative path
|
||||
}
|
||||
|
||||
$updated = $this->eventModel->update($id, [
|
||||
'event_name' => $this->request->getPost('event_name'),
|
||||
'event_category' => $this->request->getPost('event_category'),
|
||||
'description' => $this->request->getPost('description'),
|
||||
'amount' => $this->request->getPost('amount'),
|
||||
'flyer' => $flyerPath,
|
||||
'expiration_date' => $this->request->getPost('expiration_date'),
|
||||
'semester' => $this->request->getPost('semester'),
|
||||
'school_year' => $this->request->getPost('school_year'),
|
||||
]);
|
||||
|
||||
if ($updated) {
|
||||
return redirect()->to('/administrator/events')->with('success', 'Event updated successfully');
|
||||
} else {
|
||||
log_message('debug', 'GET detected');
|
||||
return redirect()->back()->with('error', 'Failed to update event');
|
||||
}
|
||||
}
|
||||
|
||||
return view('administrator/events/edit_event', [
|
||||
'event' => $event,
|
||||
'categories' => $this->categories,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
public function delete($id = null)
|
||||
{
|
||||
|
||||
$event = $this->eventModel->find($id);
|
||||
|
||||
if (!$event) {
|
||||
return redirect()->to('/administrator/events')->with('error', 'Event not found');
|
||||
}
|
||||
|
||||
// Delete related charges and collect parent IDs
|
||||
$charges = $this->eventChargesModel->where('event_id', $id)->findAll();
|
||||
$parentIds = [];
|
||||
|
||||
foreach ($charges as $charge) {
|
||||
$this->eventChargesModel->delete($charge['id']);
|
||||
$parentIds[] = $charge['parent_id'];
|
||||
}
|
||||
|
||||
// Delete event
|
||||
$this->eventModel->delete($id);
|
||||
|
||||
$parentIds = array_unique($parentIds);
|
||||
|
||||
foreach ($parentIds as $parentId) {
|
||||
$this->invoiceController->generateInvoice($parentId);
|
||||
}
|
||||
|
||||
return redirect()->to('/administrator/events')->with('success', 'Event, charges, and invoices updated.');
|
||||
}
|
||||
|
||||
|
||||
// Optionally keep your eventShow / eventUpdate for legacy administrator event charges
|
||||
public function eventShow()
|
||||
{
|
||||
|
||||
$schoolYear = $this->request->getGet('school_year') ?? $this->schoolYear;
|
||||
$semester = $this->request->getGet('semester') ?? $this->semester;
|
||||
|
||||
$parents = $this->userModel->getParents();
|
||||
$events = $this->eventModel->getActiveEvents($this->schoolYear);
|
||||
|
||||
$charges = $this->eventChargesModel
|
||||
->select('event_charges.*,
|
||||
users.firstname AS parent_firstname, users.lastname AS parent_lastname,
|
||||
students.firstname AS student_firstname, students.lastname AS student_lastname,
|
||||
events.event_name')
|
||||
->join('users', 'users.id = event_charges.parent_id', 'left')
|
||||
->join('students', 'students.id = event_charges.student_id', 'left')
|
||||
->join('events', 'events.id = event_charges.event_id', 'left')
|
||||
->where('event_charges.school_year', $schoolYear)
|
||||
->where('event_charges.semester', $semester)
|
||||
->orderBy('event_charges.created_at', 'DESC')
|
||||
->findAll();
|
||||
|
||||
return view('administrator/events/event_charges', [
|
||||
'charges' => $charges,
|
||||
'parents' => $parents,
|
||||
'events' => $events,
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
public function eventUpdate()
|
||||
{
|
||||
$schoolYear = $this->request->getPost('school_year') ?? $this->schoolYear;
|
||||
$semester = $this->request->getPost('semester') ?? $this->semester;
|
||||
|
||||
$parentId = $this->request->getPost('parent_id');
|
||||
$eventId = $this->request->getPost('event_id');
|
||||
$participations = $this->request->getPost('participation') ?? [];
|
||||
|
||||
if (!$parentId || !$eventId || empty($participations)) {
|
||||
return redirect()->back()->with('error', 'Missing required information.');
|
||||
}
|
||||
|
||||
$userId = session()->get('user_id');
|
||||
$event = $this->eventModel->getEvent($eventId, $schoolYear);
|
||||
|
||||
foreach ($participations as $studentId => $value) {
|
||||
$existing = $this->eventChargesModel->where([
|
||||
'parent_id' => $parentId,
|
||||
'student_id' => $studentId,
|
||||
'event_id' => $eventId,
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester
|
||||
])->first();
|
||||
|
||||
if ($value === 'no') {
|
||||
if ($existing) {
|
||||
$this->eventChargesModel->delete($existing['id']);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// value is 'yes'
|
||||
if ($existing) {
|
||||
$this->eventChargesModel->update($existing['id'], [
|
||||
'participation' => 'yes',
|
||||
'charged' => $event['amount'],
|
||||
'updated_by' => $userId
|
||||
]);
|
||||
} else {
|
||||
$this->eventChargesModel->insert([
|
||||
'parent_id' => $parentId,
|
||||
'student_id' => $studentId,
|
||||
'event_id' => $eventId,
|
||||
'participation' => 'yes',
|
||||
'charged' => $event['amount'],
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
'created_by' => $userId,
|
||||
'updated_by' => $userId
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->invoiceController->generateInvoice($parentId);
|
||||
|
||||
return redirect()->back()->with('success', 'Event charges updated successfully.');
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
public function getStudentsWithCharges()
|
||||
{
|
||||
$parentId = $this->request->getGet('parent_id');
|
||||
$semester = $this->request->getGet('semester');
|
||||
$schoolYear = $this->request->getGet('school_year');
|
||||
|
||||
// Get students for parent
|
||||
$students = $this->studentModel->where('parent_id', $parentId)->findAll();
|
||||
|
||||
// Get student_ids that already have charges
|
||||
$chargedStudentIds = $this->eventChargesModel
|
||||
->where('parent_id', $parentId)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->groupBy('student_id')
|
||||
->select('student_id')
|
||||
->findColumn('student_id');
|
||||
|
||||
$data = [];
|
||||
foreach ($students as $student) {
|
||||
$data[] = [
|
||||
'id' => $student['id'],
|
||||
'name' => $student['firstname'] . ' ' . $student['lastname'],
|
||||
'charged' => in_array($student['id'], $chargedStudentIds ?? []),
|
||||
];
|
||||
}
|
||||
|
||||
return $this->response->setJSON($data);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,605 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Models\ClassSectionModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
use App\Models\ExamDraftModel;
|
||||
use App\Models\TeacherClassModel;
|
||||
use App\Models\UserModel;
|
||||
use CodeIgniter\HTTP\Files\UploadedFile;
|
||||
use Config\Database;
|
||||
|
||||
class ExamDraftController extends BaseController
|
||||
{
|
||||
protected ExamDraftModel $examDraftModel;
|
||||
protected TeacherClassModel $teacherClassModel;
|
||||
protected ClassSectionModel $classSectionModel;
|
||||
protected UserModel $userModel;
|
||||
protected ConfigurationModel $configModel;
|
||||
|
||||
protected string $schoolYear;
|
||||
protected string $semester;
|
||||
protected bool $hasFinalPdfColumn = false;
|
||||
protected bool $hasIsLegacyColumn = false;
|
||||
|
||||
// DB enum: draft, submitted, reviewed, finalized, rejected
|
||||
// (string literals used to avoid excess constants)
|
||||
|
||||
protected const TEACHER_UPLOAD_DIR = 'exams/drafts';
|
||||
protected const FINAL_UPLOAD_DIR = 'exams/finals';
|
||||
protected const MAX_UPLOAD_BYTES = 12 * 1024 * 1024;
|
||||
protected const ALLOWED_EXTENSIONS = ['doc', 'docx'];
|
||||
protected const ADMIN_ALLOWED_EXTENSIONS = ['doc', 'docx', 'pdf'];
|
||||
|
||||
protected array $examTypes = [
|
||||
'Final Exam',
|
||||
'Midterm Exam',
|
||||
'Quiz',
|
||||
'Study Guide',
|
||||
'Practice Exam',
|
||||
'Other',
|
||||
];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->examDraftModel = new ExamDraftModel();
|
||||
$this->teacherClassModel = new TeacherClassModel();
|
||||
$this->classSectionModel = new ClassSectionModel();
|
||||
$this->userModel = new UserModel();
|
||||
$this->configModel = new ConfigurationModel();
|
||||
$this->db = Database::connect();
|
||||
|
||||
$this->schoolYear = (string) ($this->configModel->getConfig('school_year') ?? '');
|
||||
$this->semester = (string) ($this->configModel->getConfig('semester') ?? '');
|
||||
$this->hasFinalPdfColumn = $this->schemaHasColumn('exam_drafts', 'final_pdf_file');
|
||||
$this->hasIsLegacyColumn = $this->schemaHasColumn('exam_drafts', 'is_legacy');
|
||||
|
||||
helper(['form', 'url', 'date']);
|
||||
}
|
||||
|
||||
public function teacherIndex()
|
||||
{
|
||||
$teacherId = (int) (session()->get('user_id') ?? 0);
|
||||
if ($teacherId <= 0) {
|
||||
return redirect()->to('/login');
|
||||
}
|
||||
|
||||
$assignments = $this->teacherClassModel->getClassAssignmentsByUserId($teacherId, $this->schoolYear);
|
||||
$selectedClass = $this->resolveSelectedClassSection($assignments);
|
||||
if ($selectedClass > 0) {
|
||||
session()->set('class_section_id', $selectedClass);
|
||||
}
|
||||
|
||||
$allDrafts = $this->examDraftModel
|
||||
->where('teacher_id', $teacherId)
|
||||
->orderBy('created_at', 'DESC')
|
||||
->findAll();
|
||||
|
||||
foreach ($allDrafts as &$row) {
|
||||
if (empty($row['final_pdf_file'])) {
|
||||
$pdf = $this->ensurePdfExists($row['final_file'] ?? '', pathinfo($row['final_file'] ?? '', PATHINFO_EXTENSION));
|
||||
if ($pdf !== null) {
|
||||
$row['final_pdf_file'] = $pdf;
|
||||
}
|
||||
}
|
||||
}
|
||||
unset($row);
|
||||
|
||||
$classSectionIds = array_map(static fn($a) => (int) $a['class_section_id'], $assignments);
|
||||
$legacyExams = [];
|
||||
// Guard against missing schema column in older databases
|
||||
if ($this->hasIsLegacyColumn) {
|
||||
// Keep all submissions visible; legacy ones are also surfaced in a separate tab
|
||||
$drafts = $allDrafts;
|
||||
|
||||
if (!empty($classSectionIds)) {
|
||||
$legacyExams = $this->examDraftModel
|
||||
->select('exam_drafts.*, cs.class_section_name')
|
||||
->join('classSection cs', 'cs.class_section_id = exam_drafts.class_section_id', 'left')
|
||||
->whereIn('exam_drafts.class_section_id', $classSectionIds)
|
||||
->where('exam_drafts.is_legacy', 1)
|
||||
->where('exam_drafts.final_file IS NOT NULL', null, false)
|
||||
->orderBy('cs.class_section_name', 'ASC')
|
||||
->orderBy('exam_drafts.created_at', 'DESC')
|
||||
->findAll();
|
||||
}
|
||||
} else {
|
||||
// Legacy column absent, show all drafts and skip legacy tab query
|
||||
$drafts = $allDrafts;
|
||||
}
|
||||
|
||||
$validation = session()->getFlashdata('validation') ?? $this->validator;
|
||||
|
||||
return view('teacher/exam_drafts', [
|
||||
'assignments' => $assignments,
|
||||
'selectedClassSection' => $selectedClass,
|
||||
'drafts' => $drafts,
|
||||
'legacyExams' => $legacyExams,
|
||||
'examTypes' => $this->examTypes,
|
||||
'statusBadges' => $this->statusBadgeMap(),
|
||||
'schoolYear' => $this->schoolYear,
|
||||
'semester' => $this->semester,
|
||||
'maxUploadBytes' => self::MAX_UPLOAD_BYTES,
|
||||
'validation' => $validation,
|
||||
]);
|
||||
}
|
||||
|
||||
public function teacherStore()
|
||||
{
|
||||
$teacherId = (int) (session()->get('user_id') ?? 0);
|
||||
if ($teacherId <= 0) {
|
||||
return redirect()->to('/login');
|
||||
}
|
||||
|
||||
$classSectionId = (int) ($this->request->getPost('class_section_id') ?? session()->get('class_section_id') ?? 0);
|
||||
$examType = trim((string) $this->request->getPost('exam_type'));
|
||||
$description = trim((string) $this->request->getPost('description'));
|
||||
|
||||
if ($classSectionId <= 0) {
|
||||
return redirect()->back()->withInput()->with('error', 'Select a class section before submitting.');
|
||||
}
|
||||
|
||||
$assignment = $this->teacherClassModel
|
||||
->where('teacher_id', $teacherId)
|
||||
->where('class_section_id', $classSectionId)
|
||||
->first();
|
||||
|
||||
if (empty($assignment)) {
|
||||
return redirect()->back()->withInput()->with('error', 'You are not assigned to the selected class section.');
|
||||
}
|
||||
|
||||
session()->set('class_section_id', $classSectionId);
|
||||
|
||||
if ($classSectionId <= 0) {
|
||||
$classSectionId = $this->resolveSelectedClassSection($assignments);
|
||||
if ($classSectionId <= 0) {
|
||||
return redirect()->back()->withInput()->with('error', 'Select a class section before submitting.');
|
||||
}
|
||||
}
|
||||
|
||||
$file = $this->request->getFile('draft_file');
|
||||
$teacherFile = null;
|
||||
$teacherFilename = null;
|
||||
if ($file && $file->isValid() && !$file->hasMoved()) {
|
||||
$stored = $this->storeUploadedFile($file, self::TEACHER_UPLOAD_DIR);
|
||||
if ($stored === null) {
|
||||
return redirect()->back()->withInput()->with('error', 'Failed to store the uploaded file.');
|
||||
}
|
||||
$teacherFile = $stored;
|
||||
$teacherFilename = $file->getClientName();
|
||||
} elseif ($file && $file->getError() !== UPLOAD_ERR_NO_FILE) {
|
||||
return redirect()->back()->withInput()->with('error', 'Upload failed. Please try again.');
|
||||
}
|
||||
|
||||
$existingDraft = $this->examDraftModel
|
||||
->where('teacher_id', $teacherId)
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('semester', $this->semester)
|
||||
->where('school_year', $this->schoolYear)
|
||||
->orderBy('version', 'DESC')
|
||||
->first();
|
||||
|
||||
$nextVersion = 1;
|
||||
$previousId = null;
|
||||
if (!empty($existingDraft)) {
|
||||
$nextVersion = ((int) ($existingDraft['version'] ?? 1)) + 1;
|
||||
$previousId = (int) ($existingDraft['id'] ?? 0) ?: null;
|
||||
}
|
||||
|
||||
$title = $examType ?: 'Exam Draft';
|
||||
|
||||
$payload = [
|
||||
'teacher_id' => $teacherId,
|
||||
'class_section_id' => $classSectionId,
|
||||
'semester' => $this->semester,
|
||||
'school_year' => $this->schoolYear,
|
||||
'exam_type' => $examType ?: null,
|
||||
'draft_title' => $title,
|
||||
'description' => $description === '' ? null : $description,
|
||||
'teacher_file' => $teacherFile,
|
||||
'teacher_filename' => $teacherFilename,
|
||||
'status' => 'submitted',
|
||||
'version' => $nextVersion,
|
||||
'previous_draft_id' => $previousId,
|
||||
];
|
||||
|
||||
if ($this->examDraftModel->insert($payload)) {
|
||||
return redirect()->to('/teacher/exam-drafts')->with('success', 'Exam draft submitted for review.');
|
||||
}
|
||||
|
||||
return redirect()->back()->withInput()->with('error', 'Unable to save the exam draft.');
|
||||
}
|
||||
|
||||
public function adminIndex()
|
||||
{
|
||||
$allDrafts = $this->examDraftModel
|
||||
->select('exam_drafts.*, cs.class_section_name, u.firstname AS teacher_first, u.lastname AS teacher_last, a.firstname AS admin_first, a.lastname AS admin_last')
|
||||
->join('classSection cs', 'cs.class_section_id = exam_drafts.class_section_id', 'left')
|
||||
->join('users u', 'u.id = exam_drafts.teacher_id', 'left')
|
||||
->join('users a', 'a.id = exam_drafts.admin_id', 'left')
|
||||
->orderBy('exam_drafts.created_at', 'DESC')
|
||||
->findAll();
|
||||
|
||||
foreach ($allDrafts as &$row) {
|
||||
if (empty($row['final_pdf_file'])) {
|
||||
$pdf = $this->ensurePdfExists($row['final_file'] ?? '', pathinfo($row['final_file'] ?? '', PATHINFO_EXTENSION));
|
||||
if ($pdf !== null) {
|
||||
$row['final_pdf_file'] = $pdf;
|
||||
}
|
||||
}
|
||||
}
|
||||
unset($row);
|
||||
|
||||
$classSections = $this->classSectionModel
|
||||
->select('class_section_id, class_section_name')
|
||||
->orderBy('class_section_name', 'ASC')
|
||||
->findAll();
|
||||
|
||||
// Group legacy uploads (admin-uploaded finalized exams) by class_section for separate tab
|
||||
$legacyByClass = [];
|
||||
if ($this->hasIsLegacyColumn) {
|
||||
// Keep all submissions visible; additionally surface legacy items in a separate tab
|
||||
$drafts = $allDrafts;
|
||||
|
||||
foreach ($allDrafts as $d) {
|
||||
$isLegacy = !empty($d['is_legacy']);
|
||||
if (!$isLegacy) {
|
||||
continue;
|
||||
}
|
||||
$cid = (int)($d['class_section_id'] ?? 0);
|
||||
if (!isset($legacyByClass[$cid])) {
|
||||
$legacyByClass[$cid] = [
|
||||
'class_section_id' => $cid,
|
||||
'class_section_name' => $d['class_section_name'] ?? 'Class ' . $cid,
|
||||
'items' => [],
|
||||
];
|
||||
}
|
||||
$legacyByClass[$cid]['items'][] = $d;
|
||||
}
|
||||
} else {
|
||||
// Column missing: keep behavior simple and avoid legacy tab
|
||||
$drafts = $allDrafts;
|
||||
}
|
||||
|
||||
return view('administrator/exam_drafts', [
|
||||
'drafts' => $drafts,
|
||||
'statusBadges' => $this->statusBadgeMap(),
|
||||
'statusOptions' => $this->statusOptions(),
|
||||
'schoolYear' => $this->schoolYear,
|
||||
'semester' => $this->semester,
|
||||
'allowedExtensions' => self::ADMIN_ALLOWED_EXTENSIONS,
|
||||
'maxUploadBytes' => self::MAX_UPLOAD_BYTES,
|
||||
'examTypes' => $this->examTypes,
|
||||
'classSections' => $classSections,
|
||||
'legacyByClass' => $legacyByClass,
|
||||
]);
|
||||
}
|
||||
|
||||
public function adminUploadLegacy()
|
||||
{
|
||||
$adminId = (int) (session()->get('user_id') ?? 0);
|
||||
if ($adminId <= 0) {
|
||||
return redirect()->to('/login');
|
||||
}
|
||||
|
||||
$classSectionId = (int) ($this->request->getPost('class_section_id') ?? 0);
|
||||
$schoolYear = trim((string) ($this->request->getPost('school_year') ?? $this->schoolYear));
|
||||
$semester = trim((string) ($this->request->getPost('semester') ?? $this->semester));
|
||||
$examType = trim((string) $this->request->getPost('exam_type'));
|
||||
|
||||
if ($classSectionId <= 0) {
|
||||
return redirect()->back()->withInput()->with('error', 'Select a class section.');
|
||||
}
|
||||
if ($schoolYear === '') {
|
||||
return redirect()->back()->withInput()->with('error', 'School year is required.');
|
||||
}
|
||||
if ($semester === '') {
|
||||
return redirect()->back()->withInput()->with('error', 'Semester is required.');
|
||||
}
|
||||
|
||||
$file = $this->request->getFile('old_exam_file');
|
||||
if (!$file || !$file->isValid()) {
|
||||
return redirect()->back()->withInput()->with('error', 'A valid file is required.');
|
||||
}
|
||||
|
||||
$stored = $this->storeUploadedFile($file, self::FINAL_UPLOAD_DIR, self::ADMIN_ALLOWED_EXTENSIONS);
|
||||
if ($stored === null) {
|
||||
return redirect()->back()->withInput()->with('error', 'File type not allowed or upload failed.');
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'teacher_id' => $adminId, // store under admin user since legacy uploads are admin-only
|
||||
'class_section_id' => $classSectionId,
|
||||
'semester' => ucfirst(strtolower($semester)),
|
||||
'school_year' => $schoolYear,
|
||||
'exam_type' => $examType === '' ? null : $examType,
|
||||
'draft_title' => $examType === '' ? 'Legacy Exam' : $examType,
|
||||
'description' => null,
|
||||
'final_file' => $stored,
|
||||
'final_filename' => $file->getClientName(),
|
||||
'status' => 'finalized',
|
||||
'admin_id' => $adminId,
|
||||
'reviewed_at' => utc_now(),
|
||||
'version' => 1,
|
||||
];
|
||||
if ($this->hasIsLegacyColumn) {
|
||||
$payload['is_legacy'] = 1;
|
||||
}
|
||||
|
||||
$pdfName = null;
|
||||
if (strtolower($file->getClientExtension()) === 'pdf') {
|
||||
$pdfName = $stored;
|
||||
} else {
|
||||
$pdfName = $this->convertDocToPdf(
|
||||
$this->fullUploadPath(self::FINAL_UPLOAD_DIR, $stored),
|
||||
self::FINAL_UPLOAD_DIR
|
||||
);
|
||||
}
|
||||
if ($pdfName !== null && $this->hasFinalPdfColumn) {
|
||||
$payload['final_pdf_file'] = $pdfName;
|
||||
}
|
||||
|
||||
if ($this->examDraftModel->insert($payload)) {
|
||||
return redirect()->to('/administrator/exam-drafts')->with('success', 'Old exam uploaded successfully.');
|
||||
}
|
||||
|
||||
return redirect()->back()->withInput()->with('error', 'Unable to save the old exam.');
|
||||
}
|
||||
|
||||
public function adminReview()
|
||||
{
|
||||
$draftId = (int) ($this->request->getPost('draft_id') ?? 0);
|
||||
if ($draftId <= 0) {
|
||||
return redirect()->back()->with('error', 'Invalid submission selected.');
|
||||
}
|
||||
|
||||
$draft = $this->examDraftModel->find($draftId);
|
||||
if (empty($draft)) {
|
||||
return redirect()->back()->with('error', 'Submission not found.');
|
||||
}
|
||||
|
||||
$comments = trim((string) $this->request->getPost('admin_comments'));
|
||||
$statusInput = trim((string) $this->request->getPost('review_status'));
|
||||
$currentStatus = (string) ($draft['status'] ?? 'draft');
|
||||
$status = $this->normalizeStatus(
|
||||
$statusInput !== '' ? $statusInput : $currentStatus,
|
||||
$currentStatus
|
||||
);
|
||||
$status = strtolower($status);
|
||||
if (!in_array($status, $this->statusOptions(), true)) {
|
||||
$status = 'reviewed';
|
||||
}
|
||||
$finalFile = null;
|
||||
$finalFilename = null;
|
||||
|
||||
$file = $this->request->getFile('final_file');
|
||||
if ($file && $file->isValid() && !$file->hasMoved()) {
|
||||
$stored = $this->storeUploadedFile($file, self::FINAL_UPLOAD_DIR);
|
||||
if ($stored === null) {
|
||||
return redirect()->back()->with('error', 'Unable to store the final draft.')->withInput();
|
||||
}
|
||||
$finalFile = $stored;
|
||||
$finalFilename = $file->getClientName();
|
||||
// Only auto-finalize if the admin explicitly chose "finalized"
|
||||
// (previously any uploaded file forced finalization)
|
||||
if ($status === 'finalized') {
|
||||
$status = 'finalized';
|
||||
}
|
||||
} elseif ($file && $file->getError() !== UPLOAD_ERR_NO_FILE) {
|
||||
return redirect()->back()->with('error', 'Final file upload failed.');
|
||||
}
|
||||
|
||||
$update = [
|
||||
'status' => $status,
|
||||
'admin_comments' => $comments === '' ? null : $comments,
|
||||
'admin_id' => (int) (session()->get('user_id') ?? 0),
|
||||
'reviewed_at' => utc_now(),
|
||||
];
|
||||
|
||||
if ($finalFile !== null) {
|
||||
$update['final_file'] = $finalFile;
|
||||
$update['final_filename'] = $finalFilename;
|
||||
$pdfName = $this->ensurePdfExists($finalFile, $file ? $file->getClientExtension() : null);
|
||||
if ($pdfName !== null && $this->hasFinalPdfColumn) {
|
||||
$update['final_pdf_file'] = $pdfName;
|
||||
}
|
||||
} elseif (strtolower($status) === 'finalized' && !empty($draft['teacher_file'])) {
|
||||
// Auto-promote teacher file when admin finalizes without uploading a final
|
||||
$copied = $this->copyDraftToFinal($draft['teacher_file']);
|
||||
if ($copied !== null) {
|
||||
$update['final_file'] = $copied;
|
||||
$update['final_filename'] = $draft['teacher_filename'] ?? $draft['teacher_file'];
|
||||
$pdfName = $this->ensurePdfExists($copied, pathinfo($copied, PATHINFO_EXTENSION));
|
||||
if ($pdfName !== null && $this->hasFinalPdfColumn) {
|
||||
$update['final_pdf_file'] = $pdfName;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (strtolower($status) === 'finalized' && $this->hasIsLegacyColumn) {
|
||||
$update['is_legacy'] = 1; // finalized exams move to Previous Exams
|
||||
}
|
||||
|
||||
if ($this->hasIsLegacyColumn) {
|
||||
// Keep existing legacy flag, do not set legacy automatically here
|
||||
}
|
||||
|
||||
$updated = $this->examDraftModel
|
||||
->set($update)
|
||||
->where('id', $draftId)
|
||||
->update();
|
||||
|
||||
if ($updated) {
|
||||
return redirect()->back()->with('success', 'Review saved successfully.');
|
||||
}
|
||||
|
||||
return redirect()->back()->with('error', 'Unable to save the review.');
|
||||
}
|
||||
|
||||
private function resolveSelectedClassSection(array $assignments): int
|
||||
{
|
||||
$candidate = (int) ($this->request->getGet('class_section_id') ?? session()->get('class_section_id') ?? 0);
|
||||
$validIds = array_map('intval', array_column($assignments, 'class_section_id'));
|
||||
if ($candidate > 0 && in_array($candidate, $validIds, true)) {
|
||||
return $candidate;
|
||||
}
|
||||
return $validIds[0] ?? 0;
|
||||
}
|
||||
|
||||
private function statusBadgeMap(): array
|
||||
{
|
||||
return [
|
||||
'draft' => [
|
||||
'label' => 'Draft',
|
||||
'class' => 'bg-secondary text-white',
|
||||
],
|
||||
'submitted' => [
|
||||
'label' => 'Submitted',
|
||||
'class' => 'bg-warning text-dark',
|
||||
],
|
||||
'reviewed' => [
|
||||
'label' => 'Reviewed',
|
||||
'class' => 'bg-info text-dark',
|
||||
],
|
||||
'finalized' => [
|
||||
'label' => 'Finalized',
|
||||
'class' => 'bg-success text-white',
|
||||
],
|
||||
'rejected' => [
|
||||
'label' => 'Rejected',
|
||||
'class' => 'bg-danger text-white',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function statusOptions(): array
|
||||
{
|
||||
return ['draft', 'submitted', 'reviewed', 'finalized', 'rejected'];
|
||||
}
|
||||
|
||||
private function normalizeStatus(string $input, string $default): string
|
||||
{
|
||||
$input = strtolower(trim($input));
|
||||
$aliases = [
|
||||
'pending' => 'submitted', // legacy value
|
||||
'final' => 'finalized',
|
||||
'approved' => 'finalized', // legacy value
|
||||
];
|
||||
if (isset($aliases[$input])) {
|
||||
$input = $aliases[$input];
|
||||
}
|
||||
return in_array($input, $this->statusOptions(), true) ? $input : $default;
|
||||
}
|
||||
|
||||
private function storeUploadedFile(UploadedFile $file, string $subdir, array $allowedExtensions = self::ALLOWED_EXTENSIONS): ?string
|
||||
{
|
||||
$ext = strtolower($file->getClientExtension());
|
||||
if (!in_array($ext, $allowedExtensions, true)) {
|
||||
return null;
|
||||
}
|
||||
if ($file->getSize() > self::MAX_UPLOAD_BYTES) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$destination = WRITEPATH . 'uploads/' . trim($subdir, '/');
|
||||
if (!is_dir($destination)) {
|
||||
mkdir($destination, 0755, true);
|
||||
}
|
||||
|
||||
$filename = $file->getRandomName();
|
||||
try {
|
||||
$file->move($destination, $filename);
|
||||
return $filename;
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'ExamDraftController::storeUploadedFile error: ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function convertDocToPdf(string $sourcePath, string $targetSubdir): ?string
|
||||
{
|
||||
// Only attempt conversion for doc/docx
|
||||
$ext = strtolower(pathinfo($sourcePath, PATHINFO_EXTENSION));
|
||||
if (!in_array($ext, ['doc', 'docx'], true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$targetDir = WRITEPATH . 'uploads/' . trim($targetSubdir, '/');
|
||||
if (!is_dir($targetDir)) {
|
||||
mkdir($targetDir, 0755, true);
|
||||
}
|
||||
|
||||
$base = pathinfo($sourcePath, PATHINFO_FILENAME);
|
||||
$targetPath = $targetDir . '/' . $base . '.pdf';
|
||||
|
||||
// Attempt conversion via LibreOffice if available
|
||||
$cmd = 'soffice --headless --convert-to pdf --outdir ' . escapeshellarg($targetDir) . ' ' . escapeshellarg($sourcePath) . ' 2>/dev/null';
|
||||
@exec($cmd);
|
||||
|
||||
return is_file($targetPath) ? basename($targetPath) : null;
|
||||
}
|
||||
|
||||
private function schemaHasColumn(string $table, string $column): bool
|
||||
{
|
||||
try {
|
||||
$fields = $this->db->getFieldNames($table);
|
||||
return in_array($column, $fields, true);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', "Schema check failed for {$table}.{$column}: " . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function fullUploadPath(string $subdir, string $filename): string
|
||||
{
|
||||
return WRITEPATH . 'uploads/' . trim($subdir, '/') . '/' . $filename;
|
||||
}
|
||||
|
||||
private function neighborPdfIfExists(?string $filename, string $subdir): ?string
|
||||
{
|
||||
if (empty($filename)) return null;
|
||||
$path = $this->fullUploadPath($subdir, $filename);
|
||||
$base = pathinfo($path, PATHINFO_FILENAME);
|
||||
$dir = pathinfo($path, PATHINFO_DIRNAME);
|
||||
$pdfPath = $dir . '/' . $base . '.pdf';
|
||||
return is_file($pdfPath) ? basename($pdfPath) : null;
|
||||
}
|
||||
|
||||
private function ensurePdfExists(string $finalFilename, ?string $originalExt): ?string
|
||||
{
|
||||
$pdfNeighbor = $this->neighborPdfIfExists($finalFilename, self::FINAL_UPLOAD_DIR);
|
||||
if ($pdfNeighbor !== null) {
|
||||
return $pdfNeighbor;
|
||||
}
|
||||
$ext = strtolower((string)$originalExt);
|
||||
if ($ext === 'pdf') {
|
||||
// final file itself is already pdf
|
||||
$path = $this->fullUploadPath(self::FINAL_UPLOAD_DIR, $finalFilename);
|
||||
return is_file($path) ? $finalFilename : null;
|
||||
}
|
||||
return $this->convertDocToPdf(
|
||||
$this->fullUploadPath(self::FINAL_UPLOAD_DIR, $finalFilename),
|
||||
self::FINAL_UPLOAD_DIR
|
||||
);
|
||||
}
|
||||
|
||||
private function copyDraftToFinal(string $draftFilename): ?string
|
||||
{
|
||||
$source = $this->fullUploadPath(self::TEACHER_UPLOAD_DIR, $draftFilename);
|
||||
if (!is_file($source)) {
|
||||
return null;
|
||||
}
|
||||
$destinationDir = WRITEPATH . 'uploads/' . trim(self::FINAL_UPLOAD_DIR, '/');
|
||||
if (!is_dir($destinationDir)) {
|
||||
mkdir($destinationDir, 0755, true);
|
||||
}
|
||||
$ext = pathinfo($draftFilename, PATHINFO_EXTENSION);
|
||||
$destName = uniqid('final_', true) . '.' . $ext;
|
||||
$destPath = $destinationDir . '/' . $destName;
|
||||
if (!@copy($source, $destPath)) {
|
||||
return null;
|
||||
}
|
||||
return $destName;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
use App\Controllers\BaseController;
|
||||
use App\Models\ExpenseModel;
|
||||
use App\Models\UserModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
use CodeIgniter\Exceptions\PageNotFoundException;
|
||||
|
||||
class ExpenseController extends BaseController
|
||||
{
|
||||
protected $expenseModel;
|
||||
protected $userModel;
|
||||
protected $configModel;
|
||||
protected $schoolYear;
|
||||
protected $semester;
|
||||
protected $retailors;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->expenseModel = new ExpenseModel();
|
||||
$this->userModel = new UserModel();
|
||||
$this->configModel = new ConfigurationModel();
|
||||
|
||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||
$this->semester = $this->configModel->getConfig('semester');
|
||||
|
||||
// Default list of common retailors; adjust as needed
|
||||
$this->retailors = [
|
||||
'Amazon',
|
||||
'Walmart',
|
||||
'Costco',
|
||||
'BJ\'s',
|
||||
'Market Basket',
|
||||
'Aldi',
|
||||
'Hannaford',
|
||||
'Sam\'s Club',
|
||||
'HomeGoods',
|
||||
'Hostinger',
|
||||
'Wicked Cheesy',
|
||||
'Shatila',
|
||||
'Brothers Pizzeria',
|
||||
'Paradise Biryani Pointe',
|
||||
'Emad Leiman',
|
||||
'Nova Trampoline Park',
|
||||
'Lubin\'s Awards',
|
||||
'Dollar Tree',
|
||||
'Stop & Shop',
|
||||
'Dunkin\' Donuts',
|
||||
'Giovanni\'s Pizza',
|
||||
'Trader Joes'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Return staff users (admins/teachers/etc.) excluding parents/guests.
|
||||
*/
|
||||
private function staffUsers(): array
|
||||
{
|
||||
$rows = $this->userModel
|
||||
->select('users.id, users.firstname, users.lastname, roles.name AS role_name')
|
||||
->join('user_roles', 'user_roles.user_id = users.id', 'left')
|
||||
->join('roles', 'roles.id = user_roles.role_id', 'left')
|
||||
->where('roles.name IS NOT NULL', null, false)
|
||||
->findAll();
|
||||
|
||||
$excludedRoles = array_map('strtolower', ['parent', 'student', 'guest']);
|
||||
$staff = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$roleName = strtolower((string) ($row['role_name'] ?? ''));
|
||||
$id = (int) ($row['id'] ?? 0);
|
||||
if ($id <= 0 || in_array($roleName, $excludedRoles, true)) {
|
||||
continue;
|
||||
}
|
||||
if (!isset($staff[$id])) {
|
||||
$staff[$id] = [
|
||||
'id' => $id,
|
||||
'firstname' => $row['firstname'] ?? '',
|
||||
'lastname' => $row['lastname'] ?? '',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
uasort($staff, static function ($a, $b) {
|
||||
$nameA = trim(($a['firstname'] ?? '') . ' ' . ($a['lastname'] ?? ''));
|
||||
$nameB = trim(($b['firstname'] ?? '') . ' ' . ($b['lastname'] ?? ''));
|
||||
return strcasecmp($nameA, $nameB);
|
||||
});
|
||||
|
||||
return array_values($staff);
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$expenses = $this->expenseModel
|
||||
->select("
|
||||
expenses.*,
|
||||
u.firstname AS purchaser_firstname, u.lastname AS purchaser_lastname,
|
||||
approver.firstname AS approver_firstname, approver.lastname AS approver_lastname
|
||||
")
|
||||
->join('users u', 'u.id = expenses.purchased_by', 'left')
|
||||
->join('users approver', 'approver.id = expenses.approved_by', 'left')
|
||||
->orderBy('expenses.created_at', 'DESC')
|
||||
->findAll();
|
||||
|
||||
// Enrich each row with a URL that goes through Files::receipt($name)
|
||||
// We store only the filename in 'receipt_path' (e.g., "1759...f62.png")
|
||||
$expenses = array_map(function ($row) {
|
||||
$name = $row['receipt_path'] ?? null;
|
||||
$row['receipt_url'] = $this->receiptUrl($name);
|
||||
return $row;
|
||||
}, $expenses);
|
||||
|
||||
return view('expenses/index', ['expenses' => $expenses]);
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$users = $this->staffUsers();
|
||||
|
||||
return view('expenses/create', [
|
||||
'users' => $users,
|
||||
'retailors' => $this->retailors,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store()
|
||||
{
|
||||
$rules = [
|
||||
'category' => 'required|in_list[Expense,Purchase,Reimbursement,Donation]',
|
||||
'amount' => 'required|decimal|greater_than[0]',
|
||||
// Frontend sends purchased_by as "id|Full Name"
|
||||
'purchased_by' => 'required',
|
||||
// Optional extra fields
|
||||
'retailor' => 'permit_empty|max_length[255]',
|
||||
'date_of_purchase' => 'permit_empty',
|
||||
// allow JPG/JPEG/PNG/WEBP/GIF and PDF up to 2MB
|
||||
'receipt' => 'uploaded[receipt]'
|
||||
. '|max_size[receipt,2048]'
|
||||
. '|ext_in[receipt,jpg,jpeg,png,webp,gif,pdf]'
|
||||
. '|mime_in[receipt,image/jpg,image/jpeg,image/png,image/webp,image/gif,application/pdf]',
|
||||
];
|
||||
|
||||
$messages = [
|
||||
'receipt' => [
|
||||
'uploaded' => 'Receipt file is required.',
|
||||
'max_size' => 'Maximum file size is 2MB.',
|
||||
'ext_in' => 'Allowed formats: JPG, JPEG, PNG, WEBP, GIF, or PDF.',
|
||||
'mime_in' => 'Allowed formats: JPG, JPEG, PNG, WEBP, GIF, or PDF.',
|
||||
]
|
||||
];
|
||||
|
||||
if (!$this->validate($rules, $messages)) {
|
||||
return redirect()->back()->withInput()->with('error', $this->validator->listErrors());
|
||||
}
|
||||
|
||||
// Safe values
|
||||
$category = (string) $this->request->getPost('category');
|
||||
$amount = (string) $this->request->getPost('amount');
|
||||
$description = (string) $this->request->getPost('description');
|
||||
$retailor = trim((string) $this->request->getPost('retailor'));
|
||||
$datePurchase = (string) $this->request->getPost('date_of_purchase');
|
||||
$userId = (int) (session()->get('user_id') ?? 0);
|
||||
$isDonation = ($category === 'Donation');
|
||||
|
||||
// Parse "purchased_by" as "7|John Doe"
|
||||
$purchasedInfo = (string) $this->request->getPost('purchased_by');
|
||||
[$purchasedById, $purchasedByName] = array_pad(explode('|', $purchasedInfo, 2), 2, null);
|
||||
$purchasedById = (int) $purchasedById;
|
||||
|
||||
// School context
|
||||
$schoolYear = $this->schoolYear ?: date('Y');
|
||||
$semester = $this->semester ?: 'Fall';
|
||||
|
||||
// Handle upload: store under writable/uploads/receipts and save only the filename
|
||||
$receiptName = null;
|
||||
$file = $this->request->getFile('receipt');
|
||||
if ($file && $file->isValid() && !$file->hasMoved()) {
|
||||
$stored = $file->store('receipts'); // -> writable/uploads/receipts/<randomname>.ext
|
||||
$receiptName = basename($stored);
|
||||
}
|
||||
|
||||
$status = $isDonation ? 'approved' : 'pending';
|
||||
$statusReason = $isDonation ? 'Marked as Donation (non-reimbursable).' : null;
|
||||
|
||||
$this->expenseModel->insert([
|
||||
'category' => $category,
|
||||
'amount' => $amount,
|
||||
'receipt_path' => $receiptName, // filename only
|
||||
'description' => $description,
|
||||
'retailor' => ($retailor !== '') ? $retailor : null,
|
||||
'date_of_purchase' => ($datePurchase !== '') ? $datePurchase : null,
|
||||
'purchased_by' => $purchasedById,
|
||||
'added_by' => $userId,
|
||||
'status' => $status,
|
||||
'status_reason'=> $statusReason,
|
||||
'approved_by' => $isDonation ? $userId : null,
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
]);
|
||||
|
||||
return redirect()->to('/expenses/index')->with('success', 'Record added successfully!');
|
||||
}
|
||||
|
||||
|
||||
public function updateStatus()
|
||||
{
|
||||
$data = $this->request->getJSON(true);
|
||||
$id = isset($data['id']) ? (int)$data['id'] : null;
|
||||
$status = $data['status'] ?? null;
|
||||
$reason = $data['reason'] ?? '';
|
||||
$userId = (int) (session()->get('user_id') ?? 0);
|
||||
|
||||
if (!$id || !in_array($status, ['approved', 'denied'], true)) {
|
||||
log_message('error', 'Invalid status or ID');
|
||||
return $this->response->setJSON(['error' => 'Invalid data']);
|
||||
}
|
||||
|
||||
$expense = $this->expenseModel->find($id);
|
||||
if (!$expense) {
|
||||
log_message('error', 'Expense not found for ID ' . $id);
|
||||
return $this->response->setJSON(['error' => 'Expense not found']);
|
||||
}
|
||||
|
||||
$success = $this->expenseModel->update($id, [
|
||||
'status' => $status,
|
||||
'status_reason' => $reason,
|
||||
'approved_by' => $userId,
|
||||
'updated_by' => $userId
|
||||
]);
|
||||
|
||||
if (!$success) {
|
||||
log_message('error', 'Expense update failed for ID ' . $id);
|
||||
return $this->response->setJSON(['error' => 'Update failed']);
|
||||
}
|
||||
|
||||
return $this->response->setJSON(['success' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a public URL for a receipt filename through Files::receipt($name).
|
||||
* Expects just the filename (e.g., "1759113425_1c443e607e1900f92f62.png").
|
||||
*/
|
||||
private function receiptUrl(?string $filename): ?string
|
||||
{
|
||||
if (!$filename) {
|
||||
return null;
|
||||
}
|
||||
// Route should be defined as: $routes->get('receipts/(:any)', 'Files::receipt/$1');
|
||||
return site_url('receipts/' . $filename);
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function edit(int $id)
|
||||
{
|
||||
$expense = $this->expenseModel->find($id);
|
||||
if (!$expense) {
|
||||
throw PageNotFoundException::forPageNotFound("Expense #$id not found");
|
||||
}
|
||||
|
||||
// same user list you use in create()
|
||||
$users = $this->staffUsers();
|
||||
|
||||
return view('expenses/edit', [
|
||||
'expense' => $expense,
|
||||
'users' => $users,
|
||||
'retailors' => $this->retailors,
|
||||
'receipt_url' => $expense['receipt_path'] ? site_url('receipts/' . basename($expense['receipt_path'])) : null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(int $id)
|
||||
{
|
||||
helper(['form']);
|
||||
|
||||
$expense = $this->expenseModel->find($id);
|
||||
if (!$expense) {
|
||||
throw PageNotFoundException::forPageNotFound("Expense #$id not found");
|
||||
}
|
||||
|
||||
// Base rules
|
||||
$rules = [
|
||||
'category' => 'required|in_list[Expense,Purchase,Reimbursement,Donation]',
|
||||
'amount' => 'required|decimal|greater_than[0]',
|
||||
'purchased_by' => 'required', // still "id|Full Name"
|
||||
'retailor' => 'permit_empty|max_length[255]',
|
||||
'date_of_purchase' => 'permit_empty',
|
||||
];
|
||||
|
||||
// Optional new receipt validation (only if provided)
|
||||
$file = $this->request->getFile('receipt');
|
||||
if ($file && $file->isValid() && ($file->getSize() ?? 0) > 0) {
|
||||
$rules['receipt'] = 'max_size[receipt,2048]'
|
||||
. '|ext_in[receipt,jpg,jpeg,png,webp,gif,pdf]'
|
||||
. '|mime_in[receipt,image/jpg,image/jpeg,image/png,image/webp,image/gif,application/pdf]';
|
||||
}
|
||||
|
||||
if (!$this->validate($rules)) {
|
||||
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
||||
}
|
||||
|
||||
// Parse "id|Name"
|
||||
[$purchasedById] = array_pad(explode('|', (string) $this->request->getPost('purchased_by'), 2), 2, null);
|
||||
$purchasedById = (int) $purchasedById;
|
||||
$category = (string) $this->request->getPost('category');
|
||||
$isDonation = ($category === 'Donation');
|
||||
$userId = (int) (session()->get('user_id') ?? 0);
|
||||
|
||||
// Keep old receipt unless replaced or removed
|
||||
$receiptName = $expense['receipt_path'];
|
||||
if ($file && $file->isValid() && !$file->hasMoved() && ($file->getSize() ?? 0) > 0) {
|
||||
$stored = $file->store('receipts');
|
||||
$receiptName = basename($stored);
|
||||
}
|
||||
if ($this->request->getPost('remove_receipt') === '1') {
|
||||
$receiptName = null;
|
||||
}
|
||||
|
||||
$updateData = [
|
||||
'category' => $category,
|
||||
'amount' => (string) $this->request->getPost('amount'),
|
||||
'description' => (string) $this->request->getPost('description'),
|
||||
'retailor' => trim((string) $this->request->getPost('retailor')) ?: null,
|
||||
'date_of_purchase' => (string) $this->request->getPost('date_of_purchase') ?: null,
|
||||
'purchased_by' => $purchasedById,
|
||||
'receipt_path' => $receiptName,
|
||||
'updated_by' => $userId,
|
||||
];
|
||||
|
||||
if ($isDonation) {
|
||||
$updateData['status'] = 'approved';
|
||||
$updateData['status_reason'] = 'Marked as Donation (non-reimbursable).';
|
||||
$updateData['approved_by'] = $userId ?: null;
|
||||
$updateData['reimbursement_id'] = null;
|
||||
} elseif (($expense['category'] ?? '') === 'Donation') {
|
||||
// Moving a donation back to a reimbursable category: clear the marker.
|
||||
$updateData['status_reason'] = null;
|
||||
$updateData['approved_by'] = $expense['approved_by'] ?? null;
|
||||
$updateData['status'] = $expense['status'] ?? 'pending';
|
||||
}
|
||||
|
||||
$this->expenseModel->update($id, $updateData);
|
||||
|
||||
return redirect()->to('/expenses/index')->with('success', 'Expense updated.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,588 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Models\AdditionalChargeModel;
|
||||
use CodeIgniter\Controller;
|
||||
use App\Models\UserModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
use CodeIgniter\Events\Events;
|
||||
use App\Models\InvoiceModel;
|
||||
use CodeIgniter\I18n\Time;
|
||||
|
||||
class ExtraChargesController extends BaseController
|
||||
{
|
||||
protected $additionalChargeModel;
|
||||
protected $db;
|
||||
protected $configModel;
|
||||
protected $semester;
|
||||
protected $schoolYear;
|
||||
protected $attendanceDataModel;
|
||||
protected $studentModel;
|
||||
protected $emergencyContactModel;
|
||||
protected $userModel;
|
||||
protected $teacherClassSection;
|
||||
protected $invoiceModel;
|
||||
protected $classSectionModel;
|
||||
protected $studentClassModel;
|
||||
protected $enableAttendance;
|
||||
protected $attendanceDayModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// Initialize the database connection in the constructor
|
||||
$this->db = \Config\Database::connect();
|
||||
$this->configModel = new ConfigurationModel();
|
||||
$this->additionalChargeModel = new AdditionalChargeModel();
|
||||
$this->userModel = new UserModel();
|
||||
$this->invoiceModel = new InvoiceModel();
|
||||
$this->semester = $this->configModel->getConfig('semester');
|
||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
// API-first: delegate to JSON list endpoint
|
||||
return $this->apiList();
|
||||
}
|
||||
|
||||
/** Render HTML management page */
|
||||
public function page()
|
||||
{
|
||||
$parentId = (int)($this->request->getGet('parent_id') ?? 0);
|
||||
$status = $this->request->getGet('status') ?: null;
|
||||
$yearSelect = (string)($this->request->getGet('school_year') ?? $this->schoolYear);
|
||||
|
||||
$rows = [];
|
||||
if ($parentId > 0) {
|
||||
$rows = $this->additionalChargeModel
|
||||
->byParentTerm($parentId, $this->schoolYear, $this->semester, $status);
|
||||
}
|
||||
|
||||
// Load ALL parents for current view
|
||||
$parents = $this->userModel->select('users.id, users.firstname, users.lastname')
|
||||
->join('user_roles', 'user_roles.user_id = users.id')
|
||||
->join('roles', 'roles.id = user_roles.role_id')
|
||||
->where('LOWER(roles.name) =', 'parent')
|
||||
->where('user_roles.deleted_at IS NULL', null, false)
|
||||
->orderBy('users.lastname', 'ASC')
|
||||
->orderBy('users.firstname', 'ASC')
|
||||
->findAll();
|
||||
|
||||
// Map parent id -> "Lastname, Firstname" (no email/ID shown)
|
||||
$parentMap = [];
|
||||
foreach ($parents as $p) {
|
||||
$label = trim(($p['lastname'] ?? '') . ', ' . ($p['firstname'] ?? ''));
|
||||
$parentMap[(int)$p['id']] = $label !== '' && $label !== ',' ? $label : ('User #' . (int)$p['id']);
|
||||
}
|
||||
|
||||
// 🔹 Get ALL invoices for ALL parents this year, group by parent_id
|
||||
$parentIds = array_map(fn($r) => (int)$r['id'], $parents);
|
||||
$invoicesByParent = [];
|
||||
if (!empty($parentIds)) {
|
||||
$all = $this->invoiceModel->getAllInvoicesByUserIds($parentIds, $yearSelect);
|
||||
foreach ($all as $inv) {
|
||||
$pid = (int)$inv['parent_id'];
|
||||
$invoicesByParent[$pid][] = [
|
||||
'id' => (int)$inv['id'],
|
||||
'invoice_number' => (string)$inv['invoice_number'],
|
||||
'status' => (string)($inv['status'] ?? ''),
|
||||
'balance' => (float)($inv['balance'] ?? 0),
|
||||
'issue_date' => $inv['issue_date'] ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Label for preselect (if filtering by a parent)
|
||||
$selectedParentLabel = ($parentId > 0 && isset($parentMap[$parentId])) ? $parentMap[$parentId] : '';
|
||||
|
||||
// Optional filters
|
||||
$status = $this->request->getGet('status') ?: null;
|
||||
$q = trim((string)($this->request->getGet('q') ?? ''));
|
||||
|
||||
// ✅ Always pull all charges for the selected year & current semester (all parents)
|
||||
$rows = $this->additionalChargeModel->listAllForTerm(
|
||||
$yearSelect,
|
||||
$this->semester,
|
||||
$status,
|
||||
$q,
|
||||
50 // per-page
|
||||
);
|
||||
$pager = $this->additionalChargeModel->pager;
|
||||
|
||||
// Build school year options from data (additional_charges + invoices)
|
||||
$schoolYears = [];
|
||||
try {
|
||||
$q1 = $this->db->table('additional_charges')->select('DISTINCT school_year', false)
|
||||
->where('school_year IS NOT NULL', null, false)
|
||||
->orderBy('school_year', 'DESC')->get()->getResultArray();
|
||||
foreach ($q1 as $r) {
|
||||
$val = (string)($r['school_year'] ?? '');
|
||||
if ($val !== '' && !in_array($val, $schoolYears, true)) $schoolYears[] = $val;
|
||||
}
|
||||
} catch (\Throwable $e) {}
|
||||
try {
|
||||
$q2 = $this->db->table('invoices')->select('DISTINCT school_year', false)
|
||||
->where('school_year IS NOT NULL', null, false)
|
||||
->orderBy('school_year', 'DESC')->get()->getResultArray();
|
||||
foreach ($q2 as $r) {
|
||||
$val = (string)($r['school_year'] ?? '');
|
||||
if ($val !== '' && !in_array($val, $schoolYears, true)) $schoolYears[] = $val;
|
||||
}
|
||||
} catch (\Throwable $e) {}
|
||||
if (empty($schoolYears) && is_string($this->schoolYear) && $this->schoolYear !== '') {
|
||||
// fallback: generate recent years around configured schoolYear
|
||||
$schoolYears[] = $this->schoolYear;
|
||||
// Optionally add previous/next
|
||||
[$start, $end] = explode('-', $this->schoolYear) + [0 => date('Y'), 1 => date('Y')+1];
|
||||
$start = (int)$start;
|
||||
for ($i = 1; $i <= 3; $i++) {
|
||||
$schoolYears[] = ($start - $i) . '-' . (($start - $i) + 1);
|
||||
}
|
||||
}
|
||||
rsort($schoolYears);
|
||||
|
||||
return view('payment/extra_charges', [
|
||||
'q' => $q,
|
||||
'pager' => $pager,
|
||||
'rows' => $rows,
|
||||
'parents' => $parents,
|
||||
'parentMap' => $parentMap,
|
||||
'invoicesByParent' => $invoicesByParent, // 🔹 pass the grouped map
|
||||
'parentId' => $parentId,
|
||||
'selectedParentLabel' => $selectedParentLabel,
|
||||
'status' => $status,
|
||||
'schoolYear' => $yearSelect,
|
||||
'schoolYears' => $schoolYears,
|
||||
'semester' => $this->semester,
|
||||
]);
|
||||
}
|
||||
|
||||
private function wantsJson(): bool
|
||||
{
|
||||
$accept = strtolower((string)($this->request->getHeaderLine('Accept') ?? ''));
|
||||
return $this->request->isAJAX() || str_contains($accept, 'application/json');
|
||||
}
|
||||
|
||||
/** Used by Select2 AJAX: returns {results:[{id,text}]} */
|
||||
public function parentOptions()
|
||||
{
|
||||
$q = trim((string)($this->request->getGet('q') ?? ''));
|
||||
$limit = 20;
|
||||
|
||||
$builder = $this->userModel
|
||||
->select('users.id, users.firstname, users.lastname, users.email')
|
||||
->join('user_roles', 'user_roles.user_id = users.id')
|
||||
->join('roles', 'roles.id = user_roles.role_id')
|
||||
// If your role name may have different case, use LOWER(...):
|
||||
// ->where('LOWER(roles.name) =', 'parent')
|
||||
->where('roles.name', 'parent')
|
||||
// Make sure this produces IS NULL; if not, use the raw condition line below
|
||||
->where('user_roles.deleted_at', null);
|
||||
|
||||
// Alternative guaranteed-IS-NULL:
|
||||
// $builder->where('user_roles.deleted_at IS NULL', null, false);
|
||||
|
||||
if ($q !== '') {
|
||||
$builder->groupStart()
|
||||
->like('users.firstname', $q)
|
||||
->orLike('users.lastname', $q)
|
||||
->orLike('users.email', $q)
|
||||
->groupEnd();
|
||||
}
|
||||
|
||||
$rows = $builder
|
||||
->orderBy('users.lastname', 'ASC')
|
||||
->orderBy('users.firstname', 'ASC')
|
||||
->limit($limit)
|
||||
->find();
|
||||
|
||||
$results = array_map(function ($r) {
|
||||
$name = trim(($r['lastname'] ?? '') . ', ' . ($r['firstname'] ?? ''));
|
||||
$email = $r['email'] ?? '';
|
||||
$label = $name !== ',' ? $name : ('User #' . $r['id']);
|
||||
if ($email) $label .= ' — ' . $email;
|
||||
$label .= ' (ID: ' . $r['id'] . ')';
|
||||
return ['id' => (int)$r['id'], 'text' => $label];
|
||||
}, $rows);
|
||||
|
||||
return $this->response->setJSON(['results' => $results]);
|
||||
}
|
||||
|
||||
/** Helper for preselect label in the modal */
|
||||
private function getParentLabel(int $id): string
|
||||
{
|
||||
$p = $this->userModel->select('firstname, lastname, email')->find($id);
|
||||
if (!$p) return 'User #' . $id;
|
||||
$name = trim(($p['lastname'] ?? '') . ', ' . ($p['firstname'] ?? ''));
|
||||
$email = $p['email'] ?? '';
|
||||
$label = $name !== ',' ? $name : ('User #' . $id);
|
||||
if ($email) $label .= ' — ' . $email;
|
||||
return $label . ' (ID: ' . $id . ')';
|
||||
}
|
||||
|
||||
public function update($id)
|
||||
{
|
||||
$row = $this->additionalChargeModel->find($id);
|
||||
if (!$row) {
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Charge not found', 'csrf_token' => csrf_token(), 'csrf_hash' => csrf_hash()]);
|
||||
return redirect()->back()->with('error', 'Charge not found.');
|
||||
}
|
||||
|
||||
$data = $this->request->getPost();
|
||||
$newAmount = isset($data['amount']) ? (float)$data['amount'] : (float)$row['amount'];
|
||||
$delta = $newAmount - (float)$row['amount'];
|
||||
|
||||
$db = \Config\Database::connect();
|
||||
$db->transStart();
|
||||
|
||||
// Update the charge first
|
||||
$this->additionalChargeModel->update($id, [
|
||||
'title' => trim($data['title'] ?? $row['title']),
|
||||
'description' => trim($data['description'] ?? $row['description']),
|
||||
'amount' => $newAmount,
|
||||
'due_date' => $data['due_date'] ?? $row['due_date'],
|
||||
'charge_type' => $data['charge_type'] ?? $row['charge_type'],
|
||||
// keep status as-is
|
||||
]);
|
||||
|
||||
// If it’s already applied on an invoice and the amount changed, reflect the delta
|
||||
if ($row['status'] === 'applied' && !empty($row['invoice_id']) && abs($delta) > 0.00001) {
|
||||
if ($delta > 0) {
|
||||
$this->invoiceModel->applyAdditionalCharge((int)$row['invoice_id'], $delta);
|
||||
} else {
|
||||
$this->invoiceModel->reverseAdditionalCharge((int)$row['invoice_id'], -$delta);
|
||||
}
|
||||
}
|
||||
|
||||
$db->transComplete();
|
||||
|
||||
if (!$db->transStatus()) {
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Failed to update charge', 'csrf_token' => csrf_token(), 'csrf_hash' => csrf_hash()]);
|
||||
return redirect()->back()->with('error', 'Failed to update charge.');
|
||||
}
|
||||
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => true, 'id' => (int)$id, 'csrf_token' => csrf_token(), 'csrf_hash' => csrf_hash()]);
|
||||
return redirect()->back()->with('status', 'Charge updated.');
|
||||
}
|
||||
|
||||
// (removed duplicate model-like listAllForTerm method; use AdditionalChargeModel::listAllForTerm)
|
||||
|
||||
// ExtraChargesController.php
|
||||
public function invoicesForParent()
|
||||
{
|
||||
$parentId = (int)($this->request->getGet('parent_id') ?? 0);
|
||||
$schoolYear = (string)($this->request->getGet('school_year') ?? $this->schoolYear);
|
||||
|
||||
$rows = ($parentId > 0)
|
||||
? ($this->invoiceModel->getInvoicesByUserId($parentId, $schoolYear) ?? [])
|
||||
: [];
|
||||
|
||||
$results = array_map(function ($inv) {
|
||||
$id = (int)($inv['id'] ?? 0);
|
||||
$num = (string)($inv['invoice_number'] ?? ('INV-' . $id));
|
||||
$status = strtolower((string)($inv['status'] ?? ''));
|
||||
$balance = (float)($inv['balance'] ?? 0);
|
||||
$issue = $inv['issue_date'] ?? null;
|
||||
|
||||
return [
|
||||
'id' => $id,
|
||||
'invoice_number' => $num,
|
||||
'status' => $status,
|
||||
'balance' => $balance,
|
||||
'issue_date' => $issue,
|
||||
'text' => $num
|
||||
. ($status ? ' — ' . ucfirst($status) : '')
|
||||
. ' (Bal: $' . number_format($balance, 2) . ')',
|
||||
];
|
||||
}, $rows);
|
||||
|
||||
return $this->response->setJSON(['results' => $results]);
|
||||
}
|
||||
|
||||
|
||||
public function store()
|
||||
{
|
||||
$data = $this->request->getPost();
|
||||
|
||||
$rules = [
|
||||
'parent_id' => 'required|integer', // this is actually the user_id of the parent
|
||||
'title' => 'required|string|min_length[2]',
|
||||
'amount' => 'required|decimal',
|
||||
'charge_type' => 'required|in_list[add,deduct]',
|
||||
'due_date' => 'permit_empty|valid_date',
|
||||
'invoice_id' => 'permit_empty|integer',
|
||||
];
|
||||
if (!$this->validate($rules)) {
|
||||
$msg = implode(' ', $this->validator->getErrors());
|
||||
if ($this->wantsJson()) {
|
||||
return $this->response->setJSON([
|
||||
'ok' => false,
|
||||
'error' => $msg,
|
||||
'csrf_token' => csrf_token(),
|
||||
'csrf_hash' => csrf_hash(),
|
||||
]);
|
||||
}
|
||||
return redirect()->back()->withInput()->with('error', $msg);
|
||||
}
|
||||
|
||||
$invoiceId = !empty($data['invoice_id']) ? (int)$data['invoice_id'] : null;
|
||||
$chargeType = (string)$data['charge_type'];
|
||||
|
||||
$amountAbs = round(abs((float)$data['amount']), 2);
|
||||
$signedAmount = ($chargeType === 'add') ? $amountAbs : -$amountAbs;
|
||||
|
||||
$payload = [
|
||||
'parent_id' => (int)$data['parent_id'], // ← users.id of the parent
|
||||
'invoice_id' => $invoiceId,
|
||||
'school_year' => $data['school_year'] ?? $this->schoolYear,
|
||||
'semester' => $data['semester'] ?? $this->semester,
|
||||
'charge_type' => $chargeType,
|
||||
'title' => trim($data['title']),
|
||||
'description' => trim($data['description'] ?? ''),
|
||||
'amount' => $signedAmount,
|
||||
'due_date' => !empty($data['due_date']) ? $data['due_date'] : null,
|
||||
'status' => $invoiceId ? 'applied' : 'pending',
|
||||
'created_by' => (int)(session()->get('user_id') ?? 0),
|
||||
'created_at' => \CodeIgniter\I18n\Time::now('UTC')->toDateTimeString(), // store UTC
|
||||
];
|
||||
|
||||
$this->db->transStart();
|
||||
|
||||
// BEFORE
|
||||
$invoiceBefore = $this->invoiceModel->getInvoicesByParentId($data['parent_id'], $this->schoolYear);
|
||||
|
||||
// Insert charge
|
||||
$this->additionalChargeModel->insert($payload);
|
||||
$chargeId = (int)$this->additionalChargeModel->getInsertID();
|
||||
|
||||
// Apply to invoice if present
|
||||
if ($invoiceId) {
|
||||
try {
|
||||
if ($chargeType === 'add') {
|
||||
$this->invoiceModel->applyAdditionalCharge($invoiceId, $amountAbs);
|
||||
} else {
|
||||
$this->invoiceModel->deductAdditionalCharge($invoiceId, $amountAbs);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'apply/deductAdditionalCharge failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// AFTER
|
||||
$invoiceAfter = $this->invoiceModel->getInvoicesByParentId($data['parent_id'], $this->schoolYear);
|
||||
|
||||
// Parent USER (not parent table)
|
||||
$parentUser = $this->userModel->getUserInfoById($data['parent_id']);
|
||||
|
||||
$this->db->transComplete();
|
||||
|
||||
if (!$this->db->transStatus()) {
|
||||
$err = $this->db->error();
|
||||
log_message('error', 'Transaction failed in ExtraChargesController::store: ' . json_encode($err));
|
||||
if ($this->wantsJson()) {
|
||||
return $this->response->setJSON(['ok' => false, 'error' => 'Failed to save charge', 'csrf_token' => csrf_token(), 'csrf_hash' => csrf_hash()]);
|
||||
}
|
||||
return redirect()->back()->withInput()->with('error', 'Failed to save charge.');
|
||||
}
|
||||
|
||||
// Build event payload (same style as your payment handler)
|
||||
// allow either first_name/last_name OR firstname/lastname from users table
|
||||
$first = $parentUser['first_name'] ?? $parentUser['firstname'] ?? '';
|
||||
$last = $parentUser['last_name'] ?? $parentUser['lastname'] ?? '';
|
||||
$parentName = trim($first . ' ' . $last);
|
||||
|
||||
$invoiceBefore = $this->normalizeRow($invoiceBefore);
|
||||
$invoiceAfter = $this->normalizeRow($invoiceAfter);
|
||||
|
||||
$invoiceTotal = $invoiceAfter ? (float)($invoiceAfter['total_amount'] ?? 0) : 0.0;
|
||||
$preBalance = $invoiceBefore ? (float)($invoiceBefore['balance'] ?? 0) : 0.0;
|
||||
$postBalance = $invoiceAfter ? (float)($invoiceAfter['balance'] ?? 0) : 0.0;
|
||||
|
||||
$eventData = [
|
||||
// user identity (parent user)
|
||||
'user_id' => (int)($parentUser['id'] ?? 0),
|
||||
'email' => $parentUser['email'] ?? null,
|
||||
'firstname' => $first,
|
||||
'lastname' => $last,
|
||||
'parentName' => $parentName,
|
||||
|
||||
// context
|
||||
'school_year' => $payload['school_year'],
|
||||
'semester' => $payload['semester'],
|
||||
|
||||
// invoice
|
||||
'invoice_id' => $invoiceId,
|
||||
'invoice_number' => $invoiceAfter['invoice_number'] ?? ($invoiceId ? 'INV-' . $invoiceId : null),
|
||||
|
||||
// charge details
|
||||
'charge_id' => $chargeId,
|
||||
'charge_title' => $payload['title'],
|
||||
'charge_desc' => $payload['description'],
|
||||
'charge_type' => $payload['charge_type'], // add|deduct
|
||||
'amount_signed' => $signedAmount,
|
||||
'amount_abs' => $amountAbs,
|
||||
'due_date' => $payload['due_date'],
|
||||
'created_at' => $payload['created_at'],
|
||||
|
||||
// money snapshot
|
||||
'pre_balance' => $preBalance,
|
||||
'post_balance' => $postBalance,
|
||||
'invoice_total' => $invoiceTotal,
|
||||
|
||||
// links
|
||||
'portal_link' => base_url('/login'),
|
||||
'invoice_link' => $invoiceId ? site_url('parent/invoices/view/' . $invoiceId) : site_url('parent/invoices'),
|
||||
];
|
||||
|
||||
$students = []; // keep [] unless you want to include children under this user
|
||||
Events::trigger('extraCharge', $eventData, $students);
|
||||
|
||||
if ($this->wantsJson()) {
|
||||
return $this->response->setJSON([
|
||||
'ok' => true,
|
||||
'id' => $chargeId,
|
||||
'invoice_id' => $invoiceId,
|
||||
'parent_id' => (int)$data['parent_id'],
|
||||
'csrf_token' => csrf_token(),
|
||||
'csrf_hash' => csrf_hash(),
|
||||
]);
|
||||
}
|
||||
return redirect()->to(site_url('admin/charges'))->with('status', 'Charge recorded.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a charge as void and roll back its impact on the invoice if applied.
|
||||
*/
|
||||
public function void($id)
|
||||
{
|
||||
$row = $this->additionalChargeModel->find((int)$id);
|
||||
if (!$row) {
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Charge not found']);
|
||||
return redirect()->back()->with('error', 'Charge not found.');
|
||||
}
|
||||
|
||||
$invoiceId = (int)($row['invoice_id'] ?? 0);
|
||||
$amountAbs = round(abs((float)($row['amount'] ?? 0)), 2);
|
||||
$chargeType = (string)($row['charge_type'] ?? 'add');
|
||||
$status = (string)($row['status'] ?? 'pending');
|
||||
|
||||
$this->db->transStart();
|
||||
|
||||
if ($status === 'applied' && $invoiceId > 0 && $amountAbs > 0) {
|
||||
try {
|
||||
if ($chargeType === 'add') {
|
||||
$this->invoiceModel->reverseAdditionalCharge($invoiceId, $amountAbs);
|
||||
} else {
|
||||
// voiding a deduction -> add back
|
||||
$this->invoiceModel->applyAdditionalCharge($invoiceId, $amountAbs);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'void(): invoice adjust failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$this->additionalChargeModel->update((int)$id, [
|
||||
'status' => 'void',
|
||||
]);
|
||||
|
||||
$this->db->transComplete();
|
||||
|
||||
if (!$this->db->transStatus()) {
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Failed to void charge']);
|
||||
return redirect()->back()->with('error', 'Failed to void charge.');
|
||||
}
|
||||
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => true]);
|
||||
return redirect()->back()->with('status', 'Charge voided.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse a previously applied charge: undo invoice impact and return to pending state.
|
||||
*/
|
||||
public function reverse($id)
|
||||
{
|
||||
$row = $this->additionalChargeModel->find((int)$id);
|
||||
if (!$row) {
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Charge not found']);
|
||||
return redirect()->back()->with('error', 'Charge not found.');
|
||||
}
|
||||
|
||||
$invoiceId = (int)($row['invoice_id'] ?? 0);
|
||||
$amountAbs = round(abs((float)($row['amount'] ?? 0)), 2);
|
||||
$chargeType = (string)($row['charge_type'] ?? 'add');
|
||||
$status = (string)($row['status'] ?? 'pending');
|
||||
|
||||
if ($status !== 'applied' || $invoiceId <= 0 || $amountAbs <= 0) {
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Nothing to reverse']);
|
||||
return redirect()->back()->with('error', 'Nothing to reverse.');
|
||||
}
|
||||
|
||||
$this->db->transStart();
|
||||
try {
|
||||
if ($chargeType === 'add') {
|
||||
$this->invoiceModel->reverseAdditionalCharge($invoiceId, $amountAbs);
|
||||
} else {
|
||||
$this->invoiceModel->applyAdditionalCharge($invoiceId, $amountAbs);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'reverse(): invoice adjust failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$this->additionalChargeModel->update((int)$id, [
|
||||
'status' => 'pending',
|
||||
'invoice_id' => null,
|
||||
]);
|
||||
|
||||
$this->db->transComplete();
|
||||
|
||||
if (!$this->db->transStatus()) {
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Failed to reverse charge']);
|
||||
return redirect()->back()->with('error', 'Failed to reverse charge.');
|
||||
}
|
||||
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => true]);
|
||||
return redirect()->back()->with('status', 'Charge reversed to pending.');
|
||||
}
|
||||
|
||||
/** JSON: list charges for the current term (with optional filters). */
|
||||
public function apiList()
|
||||
{
|
||||
$year = (string)($this->request->getGet('school_year') ?? $this->schoolYear);
|
||||
$sem = (string)($this->request->getGet('semester') ?? $this->semester);
|
||||
$status = $this->request->getGet('status') ?: null;
|
||||
$q = trim((string)($this->request->getGet('q') ?? '')) ?: null;
|
||||
$per = (int)($this->request->getGet('per_page') ?? 50);
|
||||
|
||||
$rows = $this->additionalChargeModel->listAllForTerm($year, $sem, $status, $q, $per);
|
||||
$pager = $this->additionalChargeModel->pager;
|
||||
|
||||
$meta = [
|
||||
'perPage' => method_exists($pager, 'getPerPage') ? $pager->getPerPage() : $per,
|
||||
'page' => method_exists($pager, 'getCurrentPage') ? $pager->getCurrentPage() : null,
|
||||
'total' => method_exists($pager, 'getTotal') ? $pager->getTotal() : null,
|
||||
'pageCount' => method_exists($pager, 'getPageCount') ? $pager->getPageCount() : null,
|
||||
];
|
||||
|
||||
return $this->response->setJSON([
|
||||
'ok' => true,
|
||||
'school_year' => $year,
|
||||
'semester' => $sem,
|
||||
'rows' => $rows,
|
||||
'pager' => $meta,
|
||||
'csrf_token' => csrf_token(),
|
||||
'csrf_hash' => csrf_hash(),
|
||||
]);
|
||||
}
|
||||
|
||||
// Normalize query result into a single associative row
|
||||
private function normalizeRow($row): ?array
|
||||
{
|
||||
if (!$row) return null;
|
||||
if (is_array($row) && isset($row[0]) && is_array($row[0])) {
|
||||
return $row[0]; // unwrap first row from a result set
|
||||
}
|
||||
return is_array($row) ? $row : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
use App\Models\InvoiceModel;
|
||||
use App\Models\PaymentModel;
|
||||
use App\Models\StudentClassModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
|
||||
class FamilyAdminController extends BaseController
|
||||
{
|
||||
public function index(): ResponseInterface
|
||||
{
|
||||
$db = \Config\Database::connect();
|
||||
$studentId = (int) ($this->request->getGet('student_id') ?? 0);
|
||||
$guardianId = (int) ($this->request->getGet('guardian_id') ?? 0);
|
||||
$data = ['student' => null, 'families' => [], 'guardians' => [], 'students' => []];
|
||||
|
||||
// Simple student list for select
|
||||
$data['students'] = $db->query("SELECT id, CONCAT(lastname, ', ', firstname) AS name FROM students ORDER BY lastname, firstname")->getResultArray();
|
||||
// Preload search datasets (students + guardians/parents)
|
||||
$data['searchStudents'] = $db->query("SELECT id, firstname, lastname FROM students ORDER BY lastname, firstname")->getResultArray();
|
||||
$data['searchGuardians'] = $db->query(
|
||||
"SELECT DISTINCT u.id, u.firstname, u.lastname, u.email, u.cellphone
|
||||
FROM family_guardians fg
|
||||
JOIN users u ON u.id = fg.user_id
|
||||
ORDER BY u.lastname, u.firstname"
|
||||
)->getResultArray();
|
||||
|
||||
// If a guardian is provided, resolve one of their students and redirect to use existing flow
|
||||
if (!$studentId && $guardianId) {
|
||||
$row = $db->query(
|
||||
"SELECT s.id AS student_id
|
||||
FROM family_guardians fg
|
||||
JOIN family_students fs ON fs.family_id = fg.family_id
|
||||
JOIN students s ON s.id = fs.student_id
|
||||
WHERE fg.user_id = ?
|
||||
ORDER BY s.lastname, s.firstname
|
||||
LIMIT 1",
|
||||
[$guardianId]
|
||||
)->getRowArray();
|
||||
if (!$row) {
|
||||
$row = $db->query(
|
||||
"SELECT s.id AS student_id
|
||||
FROM students s
|
||||
WHERE s.parent_id = ?
|
||||
ORDER BY s.lastname, s.firstname
|
||||
LIMIT 1",
|
||||
[$guardianId]
|
||||
)->getRowArray();
|
||||
}
|
||||
if ($row && !empty($row['student_id'])) {
|
||||
return redirect()->to(site_url('family?student_id='.(int)$row['student_id']));
|
||||
}
|
||||
}
|
||||
|
||||
if ($studentId) {
|
||||
$data['student'] = $db->query("SELECT id, firstname, lastname FROM students WHERE id = ?", [$studentId])->getRowArray();
|
||||
|
||||
// Fetch all families for this student with extended fields
|
||||
$families = $db->query(
|
||||
"SELECT f.id, f.family_code, f.household_name, f.address_line1, f.address_line2, f.city, f.state, f.postal_code, f.country,
|
||||
f.primary_phone, f.preferred_lang, f.preferred_contact_method, f.is_active,
|
||||
fs.is_primary_home
|
||||
FROM family_students fs
|
||||
JOIN families f ON f.id = fs.family_id
|
||||
WHERE fs.student_id = ?
|
||||
ORDER BY fs.is_primary_home DESC, f.household_name",
|
||||
[$studentId]
|
||||
)->getResultArray();
|
||||
|
||||
// Enrich each family with guardians, students, and financials
|
||||
$invoiceModel = new InvoiceModel();
|
||||
$paymentModel = new PaymentModel();
|
||||
$studentClassModel = new StudentClassModel();
|
||||
$configModel = new ConfigurationModel();
|
||||
$schoolYear = (string) ($configModel->getConfig('school_year') ?? '');
|
||||
|
||||
foreach ($families as &$fam) {
|
||||
$fid = (int) $fam['id'];
|
||||
|
||||
// Guardians (with contact info)
|
||||
$guardians = $db->query(
|
||||
"SELECT u.id AS user_id, u.firstname, u.lastname, u.email, u.cellphone,
|
||||
u.address_street, u.city, u.state, u.zip,
|
||||
fg.relation, fg.is_primary, fg.receive_emails, fg.receive_sms
|
||||
FROM family_guardians fg
|
||||
JOIN users u ON u.id = fg.user_id
|
||||
WHERE fg.family_id = ?
|
||||
ORDER BY fg.is_primary DESC, u.lastname, u.firstname",
|
||||
[$fid]
|
||||
)->getResultArray();
|
||||
$fam['guardians'] = $guardians;
|
||||
|
||||
// Students in this family
|
||||
$studentsRows = $db->query(
|
||||
"SELECT s.id, s.firstname, s.lastname
|
||||
FROM family_students fs
|
||||
JOIN students s ON s.id = fs.student_id
|
||||
WHERE fs.family_id = ?
|
||||
ORDER BY s.lastname, s.firstname",
|
||||
[$fid]
|
||||
)->getResultArray();
|
||||
// Enrich with grade label if available
|
||||
if (!empty($studentsRows)) {
|
||||
foreach ($studentsRows as &$sr) {
|
||||
$sid = (int) ($sr['id'] ?? 0);
|
||||
$sr['grade'] = $sid ? (string) ($studentClassModel->getClassSectionsByStudentId($sid, $schoolYear) ?? '') : '';
|
||||
}
|
||||
unset($sr);
|
||||
}
|
||||
$fam['students'] = $studentsRows;
|
||||
|
||||
// Financials: invoices and payments for all guardians (by user_id)
|
||||
$parentIds = array_map(static fn($g) => (int)($g['user_id'] ?? 0), $guardians);
|
||||
$parentIds = array_values(array_filter($parentIds));
|
||||
|
||||
$fam['invoices'] = [];
|
||||
$fam['payments'] = [];
|
||||
$fam['finance_summary'] = [
|
||||
'invoices_count' => 0,
|
||||
'total_amount' => 0.0,
|
||||
'paid_amount' => 0.0,
|
||||
'balance' => 0.0,
|
||||
];
|
||||
|
||||
if (!empty($parentIds)) {
|
||||
// Invoices
|
||||
$invRows = $db->table('invoices')
|
||||
->select('id, parent_id, invoice_number, status, total_amount, paid_amount, balance, issue_date, due_date')
|
||||
->whereIn('parent_id', $parentIds)
|
||||
->orderBy('issue_date', 'DESC')
|
||||
->get()->getResultArray();
|
||||
$fam['invoices'] = $invRows;
|
||||
// Map invoice id -> number for payments table
|
||||
$invoiceMap = [];
|
||||
foreach ($invRows as $ir) {
|
||||
$invoiceMap[(int)$ir['id']] = (string)($ir['invoice_number'] ?? '');
|
||||
}
|
||||
$fam['invoice_map'] = $invoiceMap;
|
||||
|
||||
// Aggregate summary
|
||||
foreach ($invRows as $ir) {
|
||||
$fam['finance_summary']['invoices_count']++;
|
||||
$fam['finance_summary']['total_amount'] += (float)($ir['total_amount'] ?? 0);
|
||||
$fam['finance_summary']['paid_amount'] += (float)($ir['paid_amount'] ?? 0);
|
||||
$fam['finance_summary']['balance'] += (float)($ir['balance'] ?? 0);
|
||||
}
|
||||
|
||||
// Recent payments (limit 10)
|
||||
$payRows = $db->table('payments')
|
||||
->select('id, parent_id, invoice_id, paid_amount, balance, payment_method, payment_date, status')
|
||||
->whereIn('parent_id', $parentIds)
|
||||
->orderBy('payment_date', 'DESC')
|
||||
->limit(10)
|
||||
->get()->getResultArray();
|
||||
$fam['payments'] = $payRows;
|
||||
}
|
||||
}
|
||||
unset($fam);
|
||||
|
||||
$data['families'] = $families;
|
||||
|
||||
// Back-compat: also expose guardians of first family for old UI pieces
|
||||
if (!empty($families)) {
|
||||
$familyId = (int)$families[0]['id'];
|
||||
$data['guardians'] = $db->query(
|
||||
"SELECT u.id, u.firstname, u.lastname, u.email, fg.relation, fg.is_primary, fg.receive_emails
|
||||
FROM family_guardians fg JOIN users u ON u.id = fg.user_id
|
||||
WHERE fg.family_id = ? ORDER BY fg.is_primary DESC, u.lastname, u.firstname",
|
||||
[$familyId]
|
||||
)->getResultArray();
|
||||
}
|
||||
}
|
||||
|
||||
return service('response')->setBody(view('family/index', $data));
|
||||
}
|
||||
|
||||
// GET /family/search?q=..
|
||||
public function search(): ResponseInterface
|
||||
{
|
||||
$db = \Config\Database::connect();
|
||||
$q = trim((string)($this->request->getGet('q') ?? ''));
|
||||
if ($q === '') {
|
||||
return $this->response->setJSON(['items' => []]);
|
||||
}
|
||||
$qs = '%' . $db->escapeLikeString($q) . '%';
|
||||
|
||||
// Students suggestions
|
||||
$students = $db->query(
|
||||
"SELECT id, firstname, lastname
|
||||
FROM students
|
||||
WHERE CONCAT_WS(' ', firstname, lastname) LIKE ?
|
||||
ORDER BY lastname, firstname
|
||||
LIMIT 8",
|
||||
[$qs]
|
||||
)->getResultArray();
|
||||
|
||||
// Parents/Guardians suggestions (users)
|
||||
$guardians = $db->query(
|
||||
"SELECT id, firstname, lastname, email, cellphone
|
||||
FROM users
|
||||
WHERE (
|
||||
CONCAT_WS(' ', firstname, lastname) LIKE ?
|
||||
OR email LIKE ?
|
||||
OR cellphone LIKE ?
|
||||
)
|
||||
ORDER BY lastname, firstname
|
||||
LIMIT 8",
|
||||
[$qs, $qs, $qs]
|
||||
)->getResultArray();
|
||||
|
||||
$items = [];
|
||||
foreach ($students as $s) {
|
||||
$items[] = [
|
||||
'type' => 'student',
|
||||
'id' => (int)$s['id'],
|
||||
'label'=> trim(($s['firstname'] ?? '').' '.($s['lastname'] ?? '')),
|
||||
'sub' => 'Student',
|
||||
];
|
||||
}
|
||||
foreach ($guardians as $g) {
|
||||
$items[] = [
|
||||
'type' => 'guardian',
|
||||
'id' => (int)$g['id'],
|
||||
'label' => trim(($g['firstname'] ?? '').' '.($g['lastname'] ?? '')),
|
||||
'sub' => trim(($g['email'] ?? '').' '.($g['cellphone'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
return $this->response->setJSON(['items' => $items]);
|
||||
}
|
||||
|
||||
// GET /family/card?student_id=.. | guardian_id=.. | family_id=..
|
||||
public function card(): ResponseInterface
|
||||
{
|
||||
$db = \Config\Database::connect();
|
||||
$studentId = (int) ($this->request->getGet('student_id') ?? 0);
|
||||
$guardianId = (int) ($this->request->getGet('guardian_id') ?? 0);
|
||||
$familyId = (int) ($this->request->getGet('family_id') ?? 0);
|
||||
|
||||
if (!$familyId) {
|
||||
if ($studentId) {
|
||||
$row = $db->query(
|
||||
"SELECT f.id
|
||||
FROM family_students fs
|
||||
JOIN families f ON f.id = fs.family_id
|
||||
WHERE fs.student_id = ?
|
||||
ORDER BY fs.is_primary_home DESC, 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(
|
||||
"SELECT f.id
|
||||
FROM family_guardians fg
|
||||
JOIN families f ON f.id = fg.family_id
|
||||
WHERE fg.user_id = ?
|
||||
ORDER BY f.household_name
|
||||
LIMIT 1",
|
||||
[$guardianId]
|
||||
)->getRowArray();
|
||||
if (!empty($row['id'])) {
|
||||
$familyId = (int) $row['id'];
|
||||
} else {
|
||||
// 2) Fallback via students.parent_id → family_students
|
||||
$row = $db->query(
|
||||
"SELECT f.id
|
||||
FROM students s
|
||||
JOIN family_students fs ON fs.student_id = s.id
|
||||
JOIN families f ON f.id = fs.family_id
|
||||
WHERE s.parent_id = ?
|
||||
ORDER BY fs.is_primary_home DESC, f.household_name
|
||||
LIMIT 1",
|
||||
[$guardianId]
|
||||
)->getRowArray();
|
||||
if (!empty($row['id'])) $familyId = (int) $row['id'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$familyId) {
|
||||
return $this->response->setStatusCode(404)->setBody('<div class="p-3 text-danger">Family not found.</div>');
|
||||
}
|
||||
|
||||
$family = $db->query(
|
||||
"SELECT f.id, f.family_code, f.household_name, f.address_line1, f.address_line2, f.city, f.state, f.postal_code, f.country,
|
||||
f.primary_phone, f.preferred_lang, f.preferred_contact_method, f.is_active
|
||||
FROM families f
|
||||
WHERE f.id = ?",
|
||||
[$familyId]
|
||||
)->getRowArray();
|
||||
|
||||
if (!$family) {
|
||||
return $this->response->setStatusCode(404)->setBody('<div class="p-3 text-danger">Family not found.</div>');
|
||||
}
|
||||
|
||||
// Hydrate with guardians, students (+grades), invoices, payments
|
||||
$invoiceModel = new \App\Models\InvoiceModel();
|
||||
$paymentModel = new \App\Models\PaymentModel();
|
||||
$studentClassModel = new \App\Models\StudentClassModel();
|
||||
$configModel = new \App\Models\ConfigurationModel();
|
||||
$schoolYear = (string) ($configModel->getConfig('school_year') ?? '');
|
||||
|
||||
// Guardians
|
||||
$guardians = $db->query(
|
||||
"SELECT u.id AS user_id, u.firstname, u.lastname, u.email, u.cellphone,
|
||||
u.address_street, u.city, u.state, u.zip,
|
||||
fg.relation, fg.is_primary, fg.receive_emails, fg.receive_sms
|
||||
FROM family_guardians fg
|
||||
JOIN users u ON u.id = fg.user_id
|
||||
WHERE fg.family_id = ?
|
||||
ORDER BY fg.is_primary DESC, u.lastname, u.firstname",
|
||||
[$familyId]
|
||||
)->getResultArray();
|
||||
$family['guardians'] = $guardians;
|
||||
|
||||
// Students
|
||||
$studentsRows = $db->query(
|
||||
"SELECT s.id, s.firstname, s.lastname
|
||||
FROM family_students fs
|
||||
JOIN students s ON s.id = fs.student_id
|
||||
WHERE fs.family_id = ?
|
||||
ORDER BY s.lastname, s.firstname",
|
||||
[$familyId]
|
||||
)->getResultArray();
|
||||
if (!empty($studentsRows)) {
|
||||
foreach ($studentsRows as &$sr) {
|
||||
$sid = (int) ($sr['id'] ?? 0);
|
||||
$sr['grade'] = $sid ? (string) ($studentClassModel->getClassSectionsByStudentId($sid, $schoolYear) ?? '') : '';
|
||||
}
|
||||
unset($sr);
|
||||
}
|
||||
$family['students'] = $studentsRows;
|
||||
|
||||
// Financials
|
||||
$parentIds = array_map(static fn($g) => (int)($g['user_id'] ?? 0), $guardians);
|
||||
$parentIds = array_values(array_filter($parentIds));
|
||||
|
||||
$family['invoices'] = [];
|
||||
$family['payments'] = [];
|
||||
$family['finance_summary'] = [
|
||||
'invoices_count' => 0,
|
||||
'total_amount' => 0.0,
|
||||
'paid_amount' => 0.0,
|
||||
'balance' => 0.0,
|
||||
];
|
||||
// Emergency contacts (by guardian/parent)
|
||||
$family['emergency_contacts'] = [];
|
||||
if (!empty($parentIds)) {
|
||||
// Map guardian name by user_id
|
||||
$gmap = [];
|
||||
foreach ($guardians as $g) {
|
||||
$gid = (int)($g['user_id'] ?? 0);
|
||||
if ($gid > 0) $gmap[$gid] = trim(($g['firstname'] ?? '') . ' ' . ($g['lastname'] ?? ''));
|
||||
}
|
||||
$ecRows = $db->table('emergency_contacts')
|
||||
->select('id, parent_id, emergency_contact_name, relation, cellphone, email, school_year, semester, created_at, updated_at')
|
||||
->whereIn('parent_id', $parentIds)
|
||||
->orderBy('updated_at', 'DESC')
|
||||
->get()->getResultArray();
|
||||
if (!empty($ecRows)) {
|
||||
foreach ($ecRows as &$ec) {
|
||||
$pid = (int)($ec['parent_id'] ?? 0);
|
||||
$ec['parent_label'] = $gmap[$pid] ?? ('Parent #'.$pid);
|
||||
}
|
||||
unset($ec);
|
||||
$family['emergency_contacts'] = $ecRows;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($parentIds)) {
|
||||
// Invoices
|
||||
$invRows = $db->table('invoices')
|
||||
->select('id, parent_id, invoice_number, status, total_amount, paid_amount, balance, issue_date, due_date')
|
||||
->whereIn('parent_id', $parentIds)
|
||||
->orderBy('issue_date', 'DESC')
|
||||
->get()->getResultArray();
|
||||
$family['invoices'] = $invRows;
|
||||
$invoiceMap = [];
|
||||
foreach ($invRows as $ir) {
|
||||
$invoiceMap[(int)$ir['id']] = (string)($ir['invoice_number'] ?? '');
|
||||
}
|
||||
$family['invoice_map'] = $invoiceMap;
|
||||
|
||||
foreach ($invRows as $ir) {
|
||||
$family['finance_summary']['invoices_count']++;
|
||||
$family['finance_summary']['total_amount'] += (float)($ir['total_amount'] ?? 0);
|
||||
$family['finance_summary']['paid_amount'] += (float)($ir['paid_amount'] ?? 0);
|
||||
$family['finance_summary']['balance'] += (float)($ir['balance'] ?? 0);
|
||||
}
|
||||
|
||||
// Payments
|
||||
$payRows = $db->table('payments')
|
||||
->select('id, parent_id, invoice_id, paid_amount, balance, payment_method, payment_date, status')
|
||||
->whereIn('parent_id', $parentIds)
|
||||
->orderBy('payment_date', 'DESC')
|
||||
->limit(10)
|
||||
->get()->getResultArray();
|
||||
$family['payments'] = $payRows;
|
||||
}
|
||||
|
||||
return service('response')->setBody(view('family/card', ['f' => $family]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use CodeIgniter\Database\BaseConnection;
|
||||
|
||||
// MODELS you should already have (or create tiny ones if not)
|
||||
use App\Models\FamilyModel;
|
||||
use App\Models\FamilyGuardianModel;
|
||||
use App\Models\FamilyStudentModel;
|
||||
|
||||
// Your app's models
|
||||
use App\Models\StudentModel;
|
||||
use App\Models\UserModel;
|
||||
|
||||
/**
|
||||
* FamilyController
|
||||
*
|
||||
* Handles creating/linking Family ←→ Guardians (users) ←→ Students,
|
||||
* including bootstrap from existing schema where students.parent_id is the
|
||||
* “first parent”, and a second parent may be present as a users row
|
||||
* or only as an email (stub user creation).
|
||||
*/
|
||||
class FamilyController extends BaseController
|
||||
{
|
||||
protected FamilyModel $families;
|
||||
protected FamilyGuardianModel $guardians;
|
||||
protected FamilyStudentModel $familyStudents;
|
||||
|
||||
protected StudentModel $students;
|
||||
protected UserModel $users;
|
||||
|
||||
protected BaseConnection $db;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->families = new FamilyModel();
|
||||
$this->guardians = new FamilyGuardianModel();
|
||||
$this->familyStudents = new FamilyStudentModel();
|
||||
|
||||
$this->students = new StudentModel();
|
||||
$this->users = new UserModel();
|
||||
|
||||
$this->db = \Config\Database::connect();
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
* SECTION A — APIs your UI uses
|
||||
* =======================================================*/
|
||||
|
||||
// GET /api/students/{id}/families
|
||||
public function familiesByStudent(int $studentId)
|
||||
{
|
||||
$rows = $this->db->query(
|
||||
"SELECT f.*, fs.is_primary_home
|
||||
FROM family_students fs
|
||||
JOIN families f ON f.id = fs.family_id
|
||||
WHERE fs.student_id = ? AND f.is_active = 1
|
||||
ORDER BY fs.is_primary_home DESC, f.household_name",
|
||||
[$studentId]
|
||||
)->getResultArray();
|
||||
|
||||
return $this->response->setJSON(['data' => $rows]);
|
||||
}
|
||||
|
||||
// GET /api/families/{id}/guardians
|
||||
public function guardiansByFamily(int $familyId)
|
||||
{
|
||||
$rows = $this->db->query(
|
||||
"SELECT u.id AS user_id, u.firstname, u.lastname, u.email,
|
||||
fg.relation, fg.is_primary, fg.receive_emails, fg.receive_sms
|
||||
FROM family_guardians fg
|
||||
JOIN users u ON u.id = fg.user_id
|
||||
WHERE fg.family_id = ?
|
||||
ORDER BY fg.is_primary DESC, u.lastname, u.firstname",
|
||||
[$familyId]
|
||||
)->getResultArray();
|
||||
|
||||
return $this->response->setJSON(['data' => $rows]);
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
* SECTION B — Bootstrap & Linking helpers
|
||||
* =======================================================*/
|
||||
|
||||
// POST /families/bootstrap
|
||||
// Creates/ensures families for every student with parent_id,
|
||||
// links the student to that family, and (optionally) links a second parent.
|
||||
public function bootstrap()
|
||||
{
|
||||
// Optionally restrict this to admins
|
||||
// if (! $this->userCan('families.bootstrap')) return $this->deny();
|
||||
|
||||
// Guard: ensure required tables exist
|
||||
foreach (['families','family_students','family_guardians'] as $t) {
|
||||
if (! $this->db->tableExists($t)) {
|
||||
return $this->response->setStatusCode(500)->setJSON([
|
||||
'status' => 'error',
|
||||
'message' => "Missing required table '{$t}'. Run migrations: php spark migrate",
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->db->transStart();
|
||||
|
||||
// 1) Get all distinct primary parents from students.parent_id
|
||||
$primaryParents = $this->db->query(
|
||||
"SELECT DISTINCT parent_id FROM students WHERE parent_id IS NOT NULL"
|
||||
)->getResultArray();
|
||||
|
||||
$created = 0;
|
||||
$linkedStudents = 0;
|
||||
$linkedGuardians = 0;
|
||||
|
||||
foreach ($primaryParents as $row) {
|
||||
$primaryUserId = (int) $row['parent_id'];
|
||||
if ($primaryUserId <= 0) continue;
|
||||
|
||||
$familyId = $this->ensureFamilyForPrimaryParent($primaryUserId, $created);
|
||||
|
||||
// Link all students of this primary parent
|
||||
$students = $this->db->query(
|
||||
"SELECT id FROM students WHERE parent_id = ?",
|
||||
[$primaryUserId]
|
||||
)->getResultArray();
|
||||
|
||||
foreach ($students as $s) {
|
||||
$linkedStudents += $this->attachStudentToFamily((int)$s['id'], $familyId);
|
||||
}
|
||||
|
||||
// Ensure primary parent is guardian on that family
|
||||
$linkedGuardians += $this->attachGuardianUser($familyId, $primaryUserId, 'primary', true, 1, 0);
|
||||
}
|
||||
|
||||
$this->db->transComplete();
|
||||
|
||||
$payload = [
|
||||
'status' => $this->db->transStatus() ? 'ok' : 'error',
|
||||
'families_created' => $created,
|
||||
'students_linked' => $linkedStudents,
|
||||
'guardians_linked' => $linkedGuardians,
|
||||
];
|
||||
|
||||
// If accessed via GET (browser), redirect back with flash
|
||||
if (strtolower($this->request->getMethod()) === 'get') {
|
||||
$msg = json_encode($payload);
|
||||
return redirect()->to('/family')->with('message', "Bootstrap result: {$msg}");
|
||||
}
|
||||
|
||||
return $this->response->setJSON($payload);
|
||||
}
|
||||
|
||||
// POST /families/attach-second-by-user
|
||||
// body: student_id, user_id, relation='secondary'
|
||||
public function attachSecondByUser()
|
||||
{
|
||||
$studentId = (int) $this->request->getPost('student_id');
|
||||
$userId = (int) $this->request->getPost('user_id');
|
||||
$relation = (string) ($this->request->getPost('relation') ?? 'secondary');
|
||||
|
||||
if (!$studentId || !$userId) return $this->bad('student_id and user_id required');
|
||||
|
||||
$familyId = $this->familyIdFromStudentPrimary($studentId);
|
||||
if (!$familyId) return $this->bad('No primary family found for this student');
|
||||
|
||||
$rows = $this->attachGuardianUser($familyId, $userId, $relation, false, 1, 0);
|
||||
|
||||
return $this->response->setJSON(['status' => 'ok', 'attached' => $rows, 'family_id' => $familyId]);
|
||||
}
|
||||
|
||||
// POST /families/attach-second-by-email
|
||||
// body: student_id, email, firstname, lastname, relation='secondary'
|
||||
// Creates a stub user if email not in users, then links.
|
||||
public function attachSecondByEmail()
|
||||
{
|
||||
$studentId = (int) $this->request->getPost('student_id');
|
||||
$email = trim((string)$this->request->getPost('email'));
|
||||
$first = trim((string)$this->request->getPost('firstname'));
|
||||
$last = trim((string)$this->request->getPost('lastname'));
|
||||
$relation = (string) ($this->request->getPost('relation') ?? 'secondary');
|
||||
|
||||
if (!$studentId || !$email) return $this->bad('student_id and email required');
|
||||
|
||||
$familyId = $this->familyIdFromStudentPrimary($studentId);
|
||||
if (!$familyId) return $this->bad('No primary family found for this student');
|
||||
|
||||
$user = $this->users->where('email', $email)->first();
|
||||
if (!$user) {
|
||||
// Create a minimal/stub user; adjust fields to your schema
|
||||
$this->users->insert([
|
||||
'firstname' => $first ?: '',
|
||||
'lastname' => $last ?: '',
|
||||
'email' => $email,
|
||||
'status' => 'Active',
|
||||
'user_type' => 'secondary', // align with secondary guardian role
|
||||
]);
|
||||
$userId = (int) $this->users->getInsertID();
|
||||
} else {
|
||||
$userId = (int) $user['id'];
|
||||
}
|
||||
|
||||
$rows = $this->attachGuardianUser($familyId, $userId, $relation, false, 1, 0);
|
||||
|
||||
return $this->response->setJSON(['status' => 'ok', 'attached' => $rows, 'family_id' => $familyId, 'user_id' => $userId]);
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
* SECTION C — Maintenance actions
|
||||
* =======================================================*/
|
||||
|
||||
// POST /families/set-primary-home
|
||||
// body: family_id, student_id, is_primary_home (0/1)
|
||||
public function setPrimaryHome()
|
||||
{
|
||||
$familyId = (int) $this->request->getPost('family_id');
|
||||
$studentId = (int) $this->request->getPost('student_id');
|
||||
$flag = (int) $this->request->getPost('is_primary_home');
|
||||
|
||||
if (!$familyId || !$studentId) return $this->bad('family_id and student_id required');
|
||||
|
||||
$this->familyStudents
|
||||
->where(['family_id' => $familyId, 'student_id' => $studentId])
|
||||
->set(['is_primary_home' => $flag ? 1 : 0])
|
||||
->update();
|
||||
|
||||
return $this->response->setJSON(['status' => 'ok']);
|
||||
}
|
||||
|
||||
// POST /families/set-guardian-flags
|
||||
// body: family_id, user_id, receive_emails(0/1), is_primary(0/1), receive_sms(0/1)
|
||||
public function setGuardianFlags()
|
||||
{
|
||||
$familyId = (int) $this->request->getPost('family_id');
|
||||
$userId = (int) $this->request->getPost('user_id');
|
||||
|
||||
if (!$familyId || !$userId) return $this->bad('family_id and user_id required');
|
||||
|
||||
$data = [];
|
||||
foreach (['receive_emails', 'is_primary', 'receive_sms', 'relation'] as $k) {
|
||||
if (null !== $this->request->getPost($k)) {
|
||||
$val = $this->request->getPost($k);
|
||||
$data[$k] = in_array($k, ['receive_emails', 'is_primary', 'receive_sms'])
|
||||
? (int)$val
|
||||
: (string)$val;
|
||||
}
|
||||
}
|
||||
if (!$data) return $this->bad('No flags provided');
|
||||
|
||||
$this->guardians->where(['family_id' => $familyId, 'user_id' => $userId])->set($data)->update();
|
||||
|
||||
return $this->response->setJSON(['status' => 'ok']);
|
||||
}
|
||||
|
||||
// POST /families/unlink-guardian
|
||||
// body: family_id, user_id
|
||||
public function unlinkGuardian()
|
||||
{
|
||||
$familyId = (int) $this->request->getPost('family_id');
|
||||
$userId = (int) $this->request->getPost('user_id');
|
||||
if (!$familyId || !$userId) return $this->bad('family_id and user_id required');
|
||||
|
||||
$this->guardians->where(['family_id' => $familyId, 'user_id' => $userId])->delete();
|
||||
return $this->response->setJSON(['status' => 'ok']);
|
||||
}
|
||||
|
||||
// POST /families/unlink-student
|
||||
// body: family_id, student_id
|
||||
public function unlinkStudent()
|
||||
{
|
||||
$familyId = (int) $this->request->getPost('family_id');
|
||||
$studentId = (int) $this->request->getPost('student_id');
|
||||
if (!$familyId || !$studentId) return $this->bad('family_id and student_id required');
|
||||
|
||||
$this->familyStudents->where(['family_id' => $familyId, 'student_id' => $studentId])->delete();
|
||||
return $this->response->setJSON(['status' => 'ok']);
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
* SECTION D — Private helpers
|
||||
* =======================================================*/
|
||||
|
||||
// Ensure there is exactly one family per primary parent (users.id)
|
||||
// Returns $familyId and increments $created by reference if newly created.
|
||||
protected function ensureFamilyForPrimaryParent(int $primaryUserId, int &$createdCounter): int
|
||||
{
|
||||
$code = 'FAM-' . $primaryUserId;
|
||||
$row = $this->families->where('family_code', $code)->first();
|
||||
if ($row) return (int)$row['id'];
|
||||
|
||||
$this->families->insert([
|
||||
'family_code' => $code,
|
||||
'household_name' => 'Family of User ' . $primaryUserId,
|
||||
'is_active' => 1,
|
||||
]);
|
||||
$createdCounter++;
|
||||
return (int)$this->families->getInsertID();
|
||||
}
|
||||
|
||||
// Attach a student to a family (idempotent; returns rows affected >0 if inserted)
|
||||
protected function attachStudentToFamily(int $studentId, int $familyId): int
|
||||
{
|
||||
// INSERT IGNORE pattern
|
||||
$sql = "INSERT IGNORE INTO family_students (family_id, student_id, is_primary_home)
|
||||
VALUES (?, ?, 1)";
|
||||
$this->db->query($sql, [$familyId, $studentId]);
|
||||
return $this->db->affectedRows();
|
||||
}
|
||||
|
||||
// Attach a guardian user to family (idempotent)
|
||||
protected function attachGuardianUser(
|
||||
int $familyId,
|
||||
int $userId,
|
||||
string $relation = 'primary',
|
||||
bool $isPrimary = false,
|
||||
int $receiveEmails = 1,
|
||||
int $receiveSms = 0
|
||||
): int {
|
||||
$sql = "INSERT IGNORE INTO family_guardians (family_id, user_id, relation, is_primary, receive_emails, receive_sms)
|
||||
VALUES (?, ?, ?, ?, ?, ?)";
|
||||
$this->db->query($sql, [$familyId, $userId, $relation, $isPrimary ? 1 : 0, $receiveEmails, $receiveSms]);
|
||||
return $this->db->affectedRows();
|
||||
}
|
||||
|
||||
// Find the “primary” family for a student = family created from parent_id
|
||||
protected function familyIdFromStudentPrimary(int $studentId): ?int
|
||||
{
|
||||
// 1) Get primary parent id for the student
|
||||
$st = $this->students->select('parent_id')->find($studentId);
|
||||
if (!$st || empty($st['parent_id'])) return null;
|
||||
|
||||
// 2) The canonical family uses code FAM-{parent_id}
|
||||
$code = 'FAM-' . (int)$st['parent_id'];
|
||||
$row = $this->families->select('id')->where('family_code', $code)->first();
|
||||
if ($row) return (int)$row['id'];
|
||||
|
||||
// If not found, fall back to any family linked already
|
||||
$row = $this->db->query(
|
||||
"SELECT family_id FROM family_students WHERE student_id = ? ORDER BY is_primary_home DESC LIMIT 1",
|
||||
[$studentId]
|
||||
)->getRowArray();
|
||||
|
||||
return $row ? (int)$row['family_id'] : null;
|
||||
}
|
||||
|
||||
// Simple 400
|
||||
protected function bad(string $msg)
|
||||
{
|
||||
return $this->response->setStatusCode(400)->setJSON(['status' => 'error', 'message' => $msg]);
|
||||
}
|
||||
|
||||
// Example permission check hook (plug your own logic)
|
||||
protected function userCan(string $perm): bool
|
||||
{
|
||||
$perms = (array) (session('permissions') ?? []);
|
||||
return in_array($perm, $perms, true);
|
||||
}
|
||||
|
||||
protected function deny()
|
||||
{
|
||||
return redirect()->to('/access_denied')->setStatusCode(403);
|
||||
}
|
||||
|
||||
// Legacy import helper: map secondary parents from legacy 'parents' table
|
||||
public function importSecondParentsFromLegacy()
|
||||
{
|
||||
// Protect this route (e.g., admin only) via filter in routes
|
||||
$L = [
|
||||
'table' => 'parents',
|
||||
'firstparent_id' => 'firstparent_id', // users.id of primary (first) parent
|
||||
'second_user_id' => 'secondparent_id', // users.id of second parent (optional)
|
||||
'second_email' => 'secondparent_email',
|
||||
'second_firstname' => 'secondparent_firstname',
|
||||
'second_lastname' => 'secondparent_lastname',
|
||||
];
|
||||
|
||||
if (!$this->db->tableExists($L['table'])) {
|
||||
return $this->response->setJSON(['status' => 'error', 'message' => "Legacy table '{$L['table']}' not found"]);
|
||||
}
|
||||
foreach (['families','family_students','family_guardians'] as $t) {
|
||||
if (! $this->db->tableExists($t)) {
|
||||
return $this->response->setStatusCode(500)->setJSON([
|
||||
'status' => 'error',
|
||||
'message' => "Missing required table '{$t}'. Run migrations: php spark migrate",
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$rows = $this->db->table($L['table'])
|
||||
->select(
|
||||
"{$L['firstparent_id']} AS first_id, " .
|
||||
"{$L['second_user_id']} AS second_id, " .
|
||||
"{$L['second_email']} AS email, " .
|
||||
"{$L['second_firstname']} AS firstname, " .
|
||||
"{$L['second_lastname']} AS lastname",
|
||||
false
|
||||
)
|
||||
->groupStart()
|
||||
->where("{$L['second_user_id']} !=", 0)
|
||||
->orWhere("{$L['second_email']} !=", '')
|
||||
->groupEnd()
|
||||
->get()->getResultArray();
|
||||
|
||||
$createdUsers = 0;
|
||||
$linked = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($rows as $r) {
|
||||
$firstId = (int)($r['first_id'] ?? 0);
|
||||
$secondId = (int)($r['second_id'] ?? 0);
|
||||
$email = trim((string)($r['email'] ?? ''));
|
||||
|
||||
if (!$firstId || (!$secondId && $email === '')) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ensure/find family by primary parent
|
||||
$code = 'FAM-' . $firstId;
|
||||
$fam = $this->families->select('id')->where('family_code', $code)->first();
|
||||
if ($fam) {
|
||||
$familyId = (int)$fam['id'];
|
||||
} else {
|
||||
$tmp = 0; // counter sink
|
||||
$familyId = $this->ensureFamilyForPrimaryParent($firstId, $tmp);
|
||||
}
|
||||
|
||||
// Resolve/create user
|
||||
$userId = 0;
|
||||
if ($secondId > 0) {
|
||||
$userId = $secondId;
|
||||
} else {
|
||||
$user = $this->users->where('email', $email)->first();
|
||||
if (!$user) {
|
||||
$this->users->insert([
|
||||
'firstname' => $r['firstname'] ?? '',
|
||||
'lastname' => $r['lastname'] ?? '',
|
||||
'email' => $email,
|
||||
'status' => 'Active',
|
||||
'user_type' => 'secondary',
|
||||
]);
|
||||
$userId = (int)$this->users->getInsertID();
|
||||
$createdUsers++;
|
||||
} else {
|
||||
$userId = (int)$user['id'];
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure all students for this primary parent are linked to this family
|
||||
$stuRows = $this->db->query("SELECT id FROM students WHERE parent_id = ?", [$firstId])->getResultArray();
|
||||
foreach ($stuRows as $sr) {
|
||||
$sid = (int)($sr['id'] ?? 0);
|
||||
if ($sid > 0) {
|
||||
$this->attachStudentToFamily($sid, $familyId);
|
||||
}
|
||||
}
|
||||
|
||||
// Link as guardian (idempotent)
|
||||
$linked += $this->attachGuardianUser($familyId, $userId, 'guardian', false, 1, 0);
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'status' => 'ok',
|
||||
'created_users' => $createdUsers,
|
||||
'guardians_linked' => $linked,
|
||||
'skipped' => $skipped,
|
||||
'total_source' => count($rows),
|
||||
];
|
||||
|
||||
if (strtolower($this->request->getMethod()) === 'get') {
|
||||
$msg = json_encode($payload);
|
||||
return redirect()->to('/family')->with('message', "Import legacy result: {$msg}");
|
||||
}
|
||||
|
||||
return $this->response->setJSON($payload);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use CodeIgniter\Controller;
|
||||
use CodeIgniter\Exceptions\PageNotFoundException;
|
||||
use Config\Database;
|
||||
|
||||
class FilesController extends Controller
|
||||
{
|
||||
public function receipt(string $name)
|
||||
{
|
||||
// 1) Path traversal guard
|
||||
if ($name !== basename($name)) {
|
||||
return $this->response->setStatusCode(400, 'Invalid filename');
|
||||
}
|
||||
|
||||
// 2) Allow-list extensions (optional but safer)
|
||||
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
|
||||
$allowed = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'pdf'];
|
||||
if (!in_array($ext, $allowed, true)) {
|
||||
throw PageNotFoundException::forPageNotFound();
|
||||
}
|
||||
|
||||
// 3) Build path under writable
|
||||
$path = WRITEPATH . 'uploads/receipts/' . $name;
|
||||
if (!is_file($path)) {
|
||||
throw PageNotFoundException::forPageNotFound();
|
||||
}
|
||||
|
||||
// 4) MIME detection (finfo is more reliable than mime_content_type)
|
||||
$mime = 'application/octet-stream';
|
||||
if (function_exists('finfo_open')) {
|
||||
$fi = finfo_open(FILEINFO_MIME_TYPE);
|
||||
if ($fi) {
|
||||
$detected = finfo_file($fi, $path);
|
||||
if ($detected) $mime = $detected;
|
||||
finfo_close($fi);
|
||||
}
|
||||
}
|
||||
|
||||
// 5) Caching (ETag + Last-Modified) and 304 support
|
||||
$mtime = filemtime($path) ?: time();
|
||||
$etag = md5($name . '|' . $mtime . '|' . filesize($path));
|
||||
|
||||
$ifNoneMatch = $this->request->getHeaderLine('If-None-Match');
|
||||
$ifModifiedSince = $this->request->getHeaderLine('If-Modified-Since');
|
||||
|
||||
if ($ifNoneMatch === $etag || (strtotime($ifModifiedSince) >= $mtime)) {
|
||||
return $this->response
|
||||
->setStatusCode(304)
|
||||
->setHeader('ETag', $etag)
|
||||
->setHeader('Last-Modified', gmdate('D, d M Y H:i:s', $mtime) . ' GMT');
|
||||
}
|
||||
|
||||
// 6) Stream the file inline
|
||||
return $this->response
|
||||
->setStatusCode(200)
|
||||
->setHeader('Content-Type', $mime)
|
||||
->setHeader('Content-Disposition', 'inline; filename="' . $name . '"')
|
||||
->setHeader('Content-Length', (string) filesize($path))
|
||||
->setHeader('ETag', $etag)
|
||||
->setHeader('Last-Modified', gmdate('D, d M Y H:i:s', $mtime) . ' GMT')
|
||||
->setHeader('Cache-Control', 'public, max-age=86400')
|
||||
->setBody(file_get_contents($path));
|
||||
}
|
||||
|
||||
public function reimb(string $name)
|
||||
{
|
||||
// 1) Path traversal guard
|
||||
if ($name !== basename($name)) {
|
||||
return $this->response->setStatusCode(400, 'Invalid filename');
|
||||
}
|
||||
|
||||
// 2) Allow-list extensions
|
||||
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
|
||||
$allowed = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'pdf'];
|
||||
if (!in_array($ext, $allowed, true)) {
|
||||
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
|
||||
}
|
||||
|
||||
// 3) Build path under writable (REIMBURSEMENTS)
|
||||
$path = WRITEPATH . 'uploads/reimbursements/' . $name;
|
||||
if (!is_file($path)) {
|
||||
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
|
||||
}
|
||||
|
||||
// 4) MIME detection (prefer finfo)
|
||||
$mime = 'application/octet-stream';
|
||||
if (function_exists('finfo_open')) {
|
||||
$fi = finfo_open(FILEINFO_MIME_TYPE);
|
||||
if ($fi) {
|
||||
$detected = finfo_file($fi, $path);
|
||||
if ($detected) {
|
||||
$mime = $detected;
|
||||
}
|
||||
finfo_close($fi);
|
||||
}
|
||||
} elseif (function_exists('mime_content_type')) {
|
||||
$mime = mime_content_type($path) ?: $mime;
|
||||
}
|
||||
|
||||
// 5) Caching (ETag + Last-Modified) and 304 support
|
||||
$mtime = filemtime($path) ?: time();
|
||||
$size = filesize($path) ?: 0;
|
||||
$etag = md5($name . '|' . $mtime . '|' . $size);
|
||||
|
||||
$ifNoneMatch = trim($this->request->getHeaderLine('If-None-Match'), '"');
|
||||
$ifModifiedSince = $this->request->getHeaderLine('If-Modified-Since');
|
||||
$imsTime = $ifModifiedSince ? strtotime($ifModifiedSince) : false;
|
||||
|
||||
if (($ifNoneMatch && $ifNoneMatch === $etag) ||
|
||||
($imsTime !== false && $imsTime >= $mtime)
|
||||
) {
|
||||
return $this->response
|
||||
->setStatusCode(304)
|
||||
->setHeader('ETag', $etag)
|
||||
->setHeader('Last-Modified', gmdate('D, d M Y H:i:s', $mtime) . ' GMT');
|
||||
}
|
||||
|
||||
// 6) Stream the file inline
|
||||
return $this->response
|
||||
->setStatusCode(200)
|
||||
->setHeader('Content-Type', $mime)
|
||||
->setHeader('X-Content-Type-Options', 'nosniff')
|
||||
->setHeader('Content-Disposition', 'inline; filename="' . $name . '"')
|
||||
->setHeader('Content-Length', (string) $size)
|
||||
->setHeader('ETag', $etag)
|
||||
->setHeader('Last-Modified', gmdate('D, d M Y H:i:s', $mtime) . ' GMT')
|
||||
->setHeader('Cache-Control', 'public, max-age=86400')
|
||||
->setBody(file_get_contents($path));
|
||||
}
|
||||
|
||||
public function earlyDismissalSignature(string $name)
|
||||
{
|
||||
// 1) Path traversal guard
|
||||
if ($name !== basename($name)) {
|
||||
return $this->response->setStatusCode(400, 'Invalid filename');
|
||||
}
|
||||
|
||||
// 2) Allow-list extensions
|
||||
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
|
||||
$allowed = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'pdf'];
|
||||
if (!in_array($ext, $allowed, true)) {
|
||||
throw PageNotFoundException::forPageNotFound();
|
||||
}
|
||||
|
||||
// 3) Build path under writable (EARLY DISMISSAL SIGNATURES)
|
||||
$path = WRITEPATH . 'uploads/early_dismissal_signatures/' . $name;
|
||||
if (!is_file($path)) {
|
||||
throw PageNotFoundException::forPageNotFound();
|
||||
}
|
||||
|
||||
// 4) MIME detection (prefer finfo)
|
||||
$mime = 'application/octet-stream';
|
||||
if (function_exists('finfo_open')) {
|
||||
$fi = finfo_open(FILEINFO_MIME_TYPE);
|
||||
if ($fi) {
|
||||
$detected = finfo_file($fi, $path);
|
||||
if ($detected) {
|
||||
$mime = $detected;
|
||||
}
|
||||
finfo_close($fi);
|
||||
}
|
||||
} elseif (function_exists('mime_content_type')) {
|
||||
$mime = mime_content_type($path) ?: $mime;
|
||||
}
|
||||
|
||||
// 5) Caching (ETag + Last-Modified) and 304 support
|
||||
$mtime = filemtime($path) ?: time();
|
||||
$size = filesize($path) ?: 0;
|
||||
$etag = md5($name . '|' . $mtime . '|' . $size);
|
||||
|
||||
$ifNoneMatch = trim($this->request->getHeaderLine('If-None-Match'), '"');
|
||||
$ifModifiedSince = $this->request->getHeaderLine('If-Modified-Since');
|
||||
$imsTime = $ifModifiedSince ? strtotime($ifModifiedSince) : false;
|
||||
|
||||
if (($ifNoneMatch && $ifNoneMatch === $etag) ||
|
||||
($imsTime !== false && $imsTime >= $mtime)
|
||||
) {
|
||||
return $this->response
|
||||
->setStatusCode(304)
|
||||
->setHeader('ETag', $etag)
|
||||
->setHeader('Last-Modified', gmdate('D, d M Y H:i:s', $mtime) . ' GMT');
|
||||
}
|
||||
|
||||
// 6) Stream the file inline
|
||||
return $this->response
|
||||
->setStatusCode(200)
|
||||
->setHeader('Content-Type', $mime)
|
||||
->setHeader('X-Content-Type-Options', 'nosniff')
|
||||
->setHeader('Content-Disposition', 'inline; filename="' . $name . '"')
|
||||
->setHeader('Content-Length', (string) $size)
|
||||
->setHeader('ETag', $etag)
|
||||
->setHeader('Last-Modified', gmdate('D, d M Y H:i:s', $mtime) . ' GMT')
|
||||
->setHeader('Cache-Control', 'public, max-age=86400')
|
||||
->setBody(file_get_contents($path));
|
||||
}
|
||||
|
||||
public function examDraftTeacher(string $name)
|
||||
{
|
||||
return $this->serveExamDraftFile($name, 'drafts');
|
||||
}
|
||||
|
||||
public function examDraftFinal(string $name)
|
||||
{
|
||||
return $this->serveExamDraftFile($name, 'finals');
|
||||
}
|
||||
|
||||
private function serveExamDraftFile(string $name, string $subdir)
|
||||
{
|
||||
if ($name !== basename($name)) {
|
||||
return $this->response->setStatusCode(400, 'Invalid filename');
|
||||
}
|
||||
|
||||
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
|
||||
$allowed = ['doc', 'docx', 'pdf'];
|
||||
if (!in_array($ext, $allowed, true)) {
|
||||
throw PageNotFoundException::forPageNotFound();
|
||||
}
|
||||
|
||||
$path = WRITEPATH . 'uploads/exams/' . trim($subdir, '/') . '/' . $name;
|
||||
if (!is_file($path)) {
|
||||
throw PageNotFoundException::forPageNotFound();
|
||||
}
|
||||
|
||||
$mime = 'application/octet-stream';
|
||||
if (function_exists('finfo_open')) {
|
||||
$fi = finfo_open(FILEINFO_MIME_TYPE);
|
||||
if ($fi) {
|
||||
$detected = finfo_file($fi, $path);
|
||||
if ($detected) {
|
||||
$mime = $detected;
|
||||
}
|
||||
finfo_close($fi);
|
||||
}
|
||||
} elseif (function_exists('mime_content_type')) {
|
||||
$mime = mime_content_type($path) ?: $mime;
|
||||
}
|
||||
|
||||
$mtime = filemtime($path) ?: time();
|
||||
$size = filesize($path) ?: 0;
|
||||
$etag = md5($name . '|' . $mtime . '|' . $size);
|
||||
|
||||
$ifNoneMatch = trim($this->request->getHeaderLine('If-None-Match'), '"');
|
||||
$ifModifiedSince = $this->request->getHeaderLine('If-Modified-Since');
|
||||
$imsTime = $ifModifiedSince ? strtotime($ifModifiedSince) : false;
|
||||
|
||||
if (($ifNoneMatch && $ifNoneMatch === $etag) ||
|
||||
($imsTime !== false && $imsTime >= $mtime)
|
||||
) {
|
||||
return $this->response
|
||||
->setStatusCode(304)
|
||||
->setHeader('ETag', $etag)
|
||||
->setHeader('Last-Modified', gmdate('D, d M Y H:i:s', $mtime) . ' GMT');
|
||||
}
|
||||
|
||||
$downloadName = $this->buildDraftDownloadName($name, $subdir);
|
||||
|
||||
return $this->response
|
||||
->setStatusCode(200)
|
||||
->setHeader('Content-Type', $mime)
|
||||
->setHeader('X-Content-Type-Options', 'nosniff')
|
||||
->setHeader('Content-Disposition', 'inline; filename="' . $downloadName . '.' . $ext . '"')
|
||||
->setHeader('Content-Length', (string) $size)
|
||||
->setHeader('ETag', $etag)
|
||||
->setHeader('Last-Modified', gmdate('D, d M Y H:i:s', $mtime) . ' GMT')
|
||||
->setHeader('Cache-Control', 'public, max-age=86400')
|
||||
->setBody(file_get_contents($path));
|
||||
}
|
||||
|
||||
private function buildDraftDownloadName(string $filename, string $subdir): string
|
||||
{
|
||||
$column = $subdir === 'finals' ? 'final_file' : 'teacher_file';
|
||||
$db = Database::connect();
|
||||
$row = $db->table('exam_drafts ed')
|
||||
->select('ed.version, ed.exam_type, ed.class_section_id, cs.class_section_name')
|
||||
->join('classSection cs', 'cs.class_section_id = ed.class_section_id', 'left')
|
||||
->where('ed.' . $column, $filename)
|
||||
->limit(1)
|
||||
->get()
|
||||
->getRowArray();
|
||||
|
||||
$classLabel = trim((string) ($row['class_section_name'] ?? 'Class' . ($row['class_section_id'] ?? '0')));
|
||||
$typeLabel = trim((string) ($row['exam_type'] ?? 'Exam'));
|
||||
$version = 'v' . max(1, (int) ($row['version'] ?? 1));
|
||||
|
||||
$parts = array_filter([
|
||||
$this->slugify($classLabel),
|
||||
$this->slugify($typeLabel),
|
||||
$version,
|
||||
]);
|
||||
|
||||
return implode('_', $parts);
|
||||
}
|
||||
|
||||
private function slugify(string $value): string
|
||||
{
|
||||
$value = trim($value);
|
||||
$value = preg_replace('/[^\p{L}\p{N}]+/u', '_', $value);
|
||||
$value = trim($value, '_');
|
||||
return $value === '' ? 'Exam' : mb_strtolower($value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
use App\Controllers\BaseController;
|
||||
use CodeIgniter\Controller;
|
||||
use App\Models\TeacherClassModel;
|
||||
use App\Models\StudentModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
use App\Models\GradingLockModel;
|
||||
use App\Models\MissingScoreOverrideModel;
|
||||
|
||||
class FinalController extends BaseController
|
||||
{
|
||||
protected $db;
|
||||
protected $schoolYear;
|
||||
protected $semester;
|
||||
protected $configModel;
|
||||
protected $studentModel;
|
||||
protected $teacherClassModel;
|
||||
protected $gradingLockModel;
|
||||
protected $missingScoreOverrideModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->db = \Config\Database::connect();
|
||||
$this->configModel = new ConfigurationModel();
|
||||
$this->studentModel = new StudentModel();
|
||||
$this->teacherClassModel = new TeacherClassModel();
|
||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||
$this->semester = $this->configModel->getConfig('semester');
|
||||
$this->gradingLockModel = new GradingLockModel();
|
||||
$this->missingScoreOverrideModel = new MissingScoreOverrideModel();
|
||||
}
|
||||
|
||||
public function add()
|
||||
{
|
||||
// Accept class_section_id from POST/GET with session fallback
|
||||
$incoming = $this->request->getPost('class_section_id')
|
||||
?? $this->request->getGet('class_section_id')
|
||||
?? session()->get('class_section_id');
|
||||
|
||||
$classSectionId = (int) ($incoming ?: 0);
|
||||
if ($classSectionId <= 0) {
|
||||
return redirect()->back()->with('status', 'Missing class section.');
|
||||
}
|
||||
|
||||
// Persist for downstream calls
|
||||
session()->set('class_section_id', $classSectionId);
|
||||
|
||||
$semester = $this->getTeacherSelectedSemester();
|
||||
$schoolYear = $this->schoolYear;
|
||||
try {
|
||||
$result = $this->getSavedScores($classSectionId, $semester, $schoolYear);
|
||||
$missingOkMap = $this->missingScoreOverrideModel->getOverridesMap($classSectionId, $semester, $schoolYear, 'final_exam');
|
||||
|
||||
return view('teacher/add_final_exam', [
|
||||
'students' => $result['students'],
|
||||
'semester' => $result['semester'],
|
||||
'schoolYear' => $result['schoolYear'],
|
||||
'classSectionId' => $classSectionId,
|
||||
'missingOkMap' => $missingOkMap,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return redirect()->back()->with('status', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function showFinalMngt()
|
||||
{
|
||||
$incoming = $this->request->getPost('class_section_id')
|
||||
?? $this->request->getGet('class_section_id')
|
||||
?? session()->get('class_section_id');
|
||||
|
||||
$classSectionId = (int) ($incoming ?: 0);
|
||||
if ($classSectionId <= 0) {
|
||||
return redirect()->back()->with('status', 'Missing class section.');
|
||||
}
|
||||
|
||||
$semester = $this->getTeacherSelectedSemester();
|
||||
$schoolYear = $this->schoolYear;
|
||||
try {
|
||||
$result = $this->getSavedScores($classSectionId, $semester, $schoolYear);
|
||||
$scoresLocked = $this->gradingLockModel->isLocked($classSectionId, $semester, $schoolYear);
|
||||
|
||||
return view('grading/final', [
|
||||
'students' => $result['students'],
|
||||
'semester' => $result['semester'],
|
||||
'schoolYear' => $result['schoolYear'],
|
||||
'classSectionId' => $classSectionId,
|
||||
'scoresLocked' => $scoresLocked,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return redirect()->back()->with('status', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function getSavedScores(int $classSectionId, string $semester, string $schoolYear): array
|
||||
{
|
||||
if ($classSectionId <= 0) {
|
||||
throw new \InvalidArgumentException('Invalid class section id.');
|
||||
}
|
||||
|
||||
session()->set('class_section_id', $classSectionId);
|
||||
|
||||
// Subquery: pick ONE final exam row per student (latest by id)
|
||||
$latestFinalSub = $this->db->table('final_exam')
|
||||
->select('student_id, MAX(id) AS max_id')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->groupBy('student_id')
|
||||
->getCompiledSelect();
|
||||
|
||||
// Main query: roster + LEFT JOIN the single final exam row
|
||||
$qb = $this->studentModel
|
||||
->select('
|
||||
s.id AS student_id,
|
||||
s.school_id,
|
||||
s.firstname,
|
||||
s.lastname,
|
||||
fe.score
|
||||
')
|
||||
->from('students s')
|
||||
->join(
|
||||
'student_class sc',
|
||||
'sc.student_id = s.id
|
||||
AND sc.class_section_id = ' . (int) $classSectionId . '
|
||||
AND sc.school_year = ' . $this->db->escape($schoolYear),
|
||||
'inner',
|
||||
false
|
||||
)
|
||||
->join('(' . $latestFinalSub . ') fl', 'fl.student_id = s.id', 'left', false)
|
||||
->join('final_exam fe', 'fe.id = fl.max_id', 'left')
|
||||
->orderBy('s.lastname', 'ASC')
|
||||
->orderBy('s.firstname', 'ASC');
|
||||
|
||||
$qb->distinct();
|
||||
$rows = $qb->get()->getResultArray();
|
||||
|
||||
// Ensure one row per student
|
||||
$unique = [];
|
||||
foreach ($rows as $r) {
|
||||
$unique[(int)$r['student_id']] = $r;
|
||||
}
|
||||
$students = array_values($unique);
|
||||
|
||||
return [
|
||||
'students' => $students,
|
||||
'semester' => $semester,
|
||||
'schoolYear' => $schoolYear,
|
||||
];
|
||||
}
|
||||
|
||||
private function getTeacherSelectedSemester(): string
|
||||
{
|
||||
helper('semester_selection_helper');
|
||||
return selected_teacher_semester();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,568 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Models\CurrentFlagModel;
|
||||
use App\Models\FlagModel;
|
||||
use App\Models\StudentModel;
|
||||
use App\Models\ClassSectionModel;
|
||||
use App\Models\StudentClassModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
|
||||
use CodeIgniter\Controller;
|
||||
|
||||
|
||||
class FlagController extends Controller
|
||||
{
|
||||
protected $userModel;
|
||||
protected $db;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->db = \Config\Database::connect();
|
||||
//$this->userModel = new UserModel();
|
||||
helper(['url', 'form']);
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$currentFlagModel = new CurrentFlagModel();
|
||||
$classSectionModel = new ClassSectionModel();
|
||||
$studentClassModel = new StudentClassModel();
|
||||
$configModel = new ConfigurationModel();
|
||||
|
||||
// Format grades with id and name for passing to the view
|
||||
$grades = [];
|
||||
|
||||
// Retrieve flags and class sections that currently have active students
|
||||
$flags = $currentFlagModel->findAll();
|
||||
$schoolYear = $configModel->getConfig('school_year');
|
||||
$studentCounts = $studentClassModel->getStudentCountsBySection($schoolYear);
|
||||
$classSections = [];
|
||||
|
||||
if (!empty($studentCounts)) {
|
||||
$classSectionIdsWithStudents = array_keys($studentCounts);
|
||||
$classSections = $classSectionModel
|
||||
->orderBy('class_section_name', 'ASC')
|
||||
->whereIn('class_section_id', $classSectionIdsWithStudents)
|
||||
->findAll();
|
||||
}
|
||||
|
||||
foreach ($classSections as $section) {
|
||||
$grades[] = [
|
||||
'id' => $section['class_section_id'],
|
||||
'name' => $section['class_section_name']
|
||||
];
|
||||
}
|
||||
|
||||
// Map updater user IDs to display names for the view
|
||||
$updaterIds = [];
|
||||
foreach ($flags as $flag) {
|
||||
foreach (['updated_by_open', 'updated_by_closed', 'updated_by_canceled'] as $key) {
|
||||
if (!empty($flag[$key])) {
|
||||
$updaterIds[(int)$flag[$key]] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$updaterMap = [];
|
||||
if (!empty($updaterIds)) {
|
||||
$rows = $this->db->table('users')
|
||||
->select('id, firstname, lastname')
|
||||
->whereIn('id', array_keys($updaterIds))
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$name = trim((string)($row['firstname'] ?? '') . ' ' . (string)($row['lastname'] ?? ''));
|
||||
$updaterMap[(int)$row['id']] = $name !== '' ? $name : ('User #' . $row['id']);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($flags as &$flag) {
|
||||
foreach (['updated_by_open', 'updated_by_closed', 'updated_by_canceled'] as $key) {
|
||||
$id = (int)($flag[$key] ?? 0);
|
||||
$flag[$key . '_name'] = $id > 0 ? ($updaterMap[$id] ?? null) : null;
|
||||
}
|
||||
}
|
||||
unset($flag);
|
||||
|
||||
// Pass flags and formatted grades to the view
|
||||
return view('flags/flags_management', [
|
||||
'flags' => $flags,
|
||||
'grades' => $grades // Now grades include both id and class_section_name
|
||||
]);
|
||||
}
|
||||
|
||||
public function history()
|
||||
{
|
||||
$flagModel = new FlagModel();
|
||||
|
||||
$schoolYear = (string)($this->request->getGet('school_year') ?? '');
|
||||
$semester = (string)($this->request->getGet('semester') ?? '');
|
||||
|
||||
$builder = $flagModel->orderBy('flag_datetime', 'DESC');
|
||||
if ($schoolYear !== '') {
|
||||
$builder->where('school_year', $schoolYear);
|
||||
}
|
||||
if ($semester !== '') {
|
||||
$builder->where('semester', $semester);
|
||||
}
|
||||
|
||||
return $this->response->setJSON([
|
||||
'flags' => $builder->findAll(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function processedFlags()
|
||||
{
|
||||
$flagModel = new FlagModel();
|
||||
$classSectionModel = new ClassSectionModel();
|
||||
|
||||
$schoolYear = (string)($this->request->getGet('school_year') ?? '');
|
||||
$semester = (string)($this->request->getGet('semester') ?? '');
|
||||
|
||||
$builder = $flagModel->orderBy('flag_datetime', 'DESC');
|
||||
if ($schoolYear !== '') {
|
||||
$builder->where('school_year', $schoolYear);
|
||||
}
|
||||
if ($semester !== '') {
|
||||
$builder->where('semester', $semester);
|
||||
}
|
||||
|
||||
$flags = $builder->findAll();
|
||||
|
||||
$gradeIds = [];
|
||||
foreach ($flags as $flag) {
|
||||
$gradeValue = $flag['grade'] ?? '';
|
||||
if (is_numeric($gradeValue)) {
|
||||
$gradeIds[(int)$gradeValue] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$gradeMap = [];
|
||||
if (!empty($gradeIds)) {
|
||||
$rows = $classSectionModel
|
||||
->select('class_section_id, class_section_name')
|
||||
->whereIn('class_section_id', array_keys($gradeIds))
|
||||
->findAll();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$gradeMap[(int)$row['class_section_id']] = (string)($row['class_section_name'] ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
// Map updater user IDs to display names for the view
|
||||
$updaterIds = [];
|
||||
foreach ($flags as $flag) {
|
||||
foreach (['updated_by_open', 'updated_by_closed', 'updated_by_canceled'] as $key) {
|
||||
if (!empty($flag[$key])) {
|
||||
$updaterIds[(int)$flag[$key]] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$updaterMap = [];
|
||||
if (!empty($updaterIds)) {
|
||||
$rows = $this->db->table('users')
|
||||
->select('id, firstname, lastname')
|
||||
->whereIn('id', array_keys($updaterIds))
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$name = trim((string)($row['firstname'] ?? '') . ' ' . (string)($row['lastname'] ?? ''));
|
||||
$updaterMap[(int)$row['id']] = $name !== '' ? $name : ('User #' . $row['id']);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($flags as &$flag) {
|
||||
foreach (['updated_by_open', 'updated_by_closed', 'updated_by_canceled'] as $key) {
|
||||
$id = (int)($flag[$key] ?? 0);
|
||||
$flag[$key . '_name'] = $id > 0 ? ($updaterMap[$id] ?? null) : null;
|
||||
}
|
||||
|
||||
$gradeValue = $flag['grade'] ?? '';
|
||||
if (is_numeric($gradeValue)) {
|
||||
$flag['grade'] = $gradeMap[(int)$gradeValue] ?? (string)$gradeValue;
|
||||
}
|
||||
}
|
||||
unset($flag);
|
||||
|
||||
return view('flags/processed_flags', [
|
||||
'flags' => $flags
|
||||
]);
|
||||
}
|
||||
|
||||
public function incidentAnalysis()
|
||||
{
|
||||
$flagModel = new FlagModel();
|
||||
$classSectionModel = new ClassSectionModel();
|
||||
|
||||
$schoolYear = (string)($this->request->getGet('school_year') ?? '');
|
||||
$semester = (string)($this->request->getGet('semester') ?? '');
|
||||
|
||||
$builder = $flagModel->orderBy('flag_datetime', 'DESC');
|
||||
if ($schoolYear !== '') {
|
||||
$builder->where('school_year', $schoolYear);
|
||||
}
|
||||
if ($semester !== '') {
|
||||
$builder->where('semester', $semester);
|
||||
}
|
||||
|
||||
$flags = $builder->findAll();
|
||||
|
||||
$gradeIds = [];
|
||||
foreach ($flags as $flag) {
|
||||
$gradeValue = $flag['grade'] ?? '';
|
||||
if (is_numeric($gradeValue)) {
|
||||
$gradeIds[(int)$gradeValue] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$gradeMap = [];
|
||||
if (!empty($gradeIds)) {
|
||||
$rows = $classSectionModel
|
||||
->select('class_section_id, class_section_name')
|
||||
->whereIn('class_section_id', array_keys($gradeIds))
|
||||
->findAll();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$gradeMap[(int)$row['class_section_id']] = (string)($row['class_section_name'] ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
$students = [];
|
||||
foreach ($flags as $flag) {
|
||||
$studentId = (string)($flag['student_id'] ?? '');
|
||||
$studentName = (string)($flag['student_name'] ?? '');
|
||||
$studentKey = $studentId !== '' ? 'id:' . $studentId : 'name:' . strtolower(trim($studentName));
|
||||
$gradeValue = $flag['grade'] ?? '';
|
||||
$gradeLabel = $gradeValue;
|
||||
if (is_numeric($gradeValue)) {
|
||||
$gradeLabel = $gradeMap[(int)$gradeValue] ?? (string)$gradeValue;
|
||||
}
|
||||
|
||||
if (!isset($students[$studentKey])) {
|
||||
$students[$studentKey] = [
|
||||
'student_id' => $studentId,
|
||||
'student_name' => $studentName,
|
||||
'grade' => (string)$gradeLabel,
|
||||
'total' => 0,
|
||||
'open' => 0,
|
||||
'closed' => 0,
|
||||
'canceled' => 0,
|
||||
'last_incident' => null,
|
||||
'logs' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$students[$studentKey]['total'] += 1;
|
||||
|
||||
$state = (string)($flag['flag_state'] ?? '');
|
||||
if ($state === 'Open') {
|
||||
$students[$studentKey]['open'] += 1;
|
||||
} elseif ($state === 'Closed') {
|
||||
$students[$studentKey]['closed'] += 1;
|
||||
} elseif ($state === 'Canceled') {
|
||||
$students[$studentKey]['canceled'] += 1;
|
||||
}
|
||||
|
||||
if ($students[$studentKey]['last_incident'] === null) {
|
||||
$students[$studentKey]['last_incident'] = $flag['flag_datetime'] ?? null;
|
||||
}
|
||||
|
||||
$students[$studentKey]['logs'][] = [
|
||||
'flag' => $flag['flag'] ?? '',
|
||||
'flag_state' => $state,
|
||||
'flag_datetime' => $flag['flag_datetime'] ?? null,
|
||||
'open_description' => $flag['open_description'] ?? null,
|
||||
'close_description' => $flag['close_description'] ?? null,
|
||||
'cancel_description' => $flag['cancel_description'] ?? null,
|
||||
'action_taken' => $flag['action_taken'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
$studentRows = array_values($students);
|
||||
usort($studentRows, function ($a, $b) {
|
||||
if ($a['total'] === $b['total']) {
|
||||
return strcasecmp($a['student_name'], $b['student_name']);
|
||||
}
|
||||
return $b['total'] <=> $a['total'];
|
||||
});
|
||||
|
||||
return view('flags/incident_analysis', [
|
||||
'students' => $studentRows,
|
||||
'schoolYear' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
]);
|
||||
}
|
||||
|
||||
public function getStudentsByGrade($gradeId)
|
||||
{
|
||||
$studentClassModel = new StudentClassModel(); // Model for the student_class table
|
||||
$studentModel = new StudentModel(); // Model for the students table
|
||||
|
||||
// Fetch student IDs for the selected grade
|
||||
$studentIds = $studentClassModel
|
||||
->active()
|
||||
->where('student_class.class_section_id', $gradeId)
|
||||
->findColumn('student_id');
|
||||
|
||||
// If no student IDs are found, return an empty JSON array
|
||||
if (empty($studentIds)) {
|
||||
return $this->response->setJSON([]);
|
||||
}
|
||||
|
||||
// Fetch student details from students table using the retrieved IDs
|
||||
$students = $studentModel
|
||||
->whereIn('id', $studentIds)
|
||||
->where('is_active', 1)
|
||||
->findAll();
|
||||
|
||||
// Prepare data for JSON response with both ID and full name
|
||||
$studentData = [];
|
||||
foreach ($students as $student) {
|
||||
$studentData[] = [
|
||||
'id' => $student['id'], // Student ID
|
||||
'name' => $student['firstname'] . ' ' . $student['lastname'] // Full name
|
||||
];
|
||||
}
|
||||
|
||||
// Return JSON response with student ID and full name
|
||||
return $this->response->setJSON($studentData);
|
||||
}
|
||||
|
||||
public function addFlag()
|
||||
{
|
||||
$currentFlagModel = new CurrentFlagModel();
|
||||
$studentModel = new StudentModel();
|
||||
$configModel = new ConfigurationModel();
|
||||
|
||||
// Get the data from the form submission
|
||||
$studentId = $this->request->getPost('student');
|
||||
$flagType = $this->request->getPost('flag');
|
||||
$description = $this->request->getPost('description');
|
||||
$flagState = $this->request->getPost('flag_state');
|
||||
$stateDescription = $this->request->getPost('state_description'); // Additional description for "Closed" or "Canceled"
|
||||
|
||||
// Retrieve student information from the database
|
||||
$student = $studentModel->find($studentId);
|
||||
if (!$student) {
|
||||
session()->setFlashdata('error', 'Student not found.');
|
||||
return $this->index();
|
||||
}
|
||||
|
||||
// Get the user_id from the session for updated_by
|
||||
$userId = session()->get('user_id');
|
||||
|
||||
// Get the semester and school year from configuration
|
||||
$semester = $configModel->getConfig('semester');
|
||||
$schoolYear = $configModel->getConfig('school_year');
|
||||
|
||||
// Set the current system date and time for flag_datetime
|
||||
$currentDateTime = utc_now();
|
||||
|
||||
// Check if a flag of the same type already exists for this student
|
||||
$existingFlag = $currentFlagModel->where('student_id', $studentId)
|
||||
->where('flag', $flagType)
|
||||
->first();
|
||||
|
||||
if ($existingFlag) {
|
||||
// If the flag exists, update it based on the new state and description
|
||||
$data = [
|
||||
'flag_datetime' => $currentDateTime,
|
||||
'updated_by_open' => $userId
|
||||
];
|
||||
|
||||
// Append the new description to the appropriate field based on flag state
|
||||
if ($flagState === 'Closed') {
|
||||
$data['flag_state'] = 'Closed';
|
||||
$data['close_description'] = ($existingFlag['close_description'] ?? '') . PHP_EOL . $stateDescription;
|
||||
} elseif ($flagState === 'Canceled') {
|
||||
$data['flag_state'] = 'Canceled';
|
||||
$data['cancel_description'] = ($existingFlag['cancel_description'] ?? '') . PHP_EOL . $stateDescription;
|
||||
} else {
|
||||
$data['open_description'] = $existingFlag['open_description'] . PHP_EOL . $description;
|
||||
}
|
||||
|
||||
// Update the existing flag
|
||||
$currentFlagModel->update($existingFlag['id'], $data);
|
||||
session()->setFlashdata('success', 'Flag updated successfully!');
|
||||
} else {
|
||||
// If no existing flag is found, prepare the data for insertion
|
||||
$data = [
|
||||
'student_id' => $student['id'],
|
||||
'student_name' => $student['firstname'] . ' ' . $student['lastname'],
|
||||
'grade' => $this->request->getPost('grade'),
|
||||
'flag' => $flagType,
|
||||
'flag_datetime' => $currentDateTime,
|
||||
'flag_state' => 'Open', // Set initial state to 'Open'
|
||||
'updated_by_open' => $userId,
|
||||
'open_description' => $description,
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear
|
||||
];
|
||||
|
||||
// Insert the new flag
|
||||
if ($currentFlagModel->insert($data)) {
|
||||
session()->setFlashdata('success', 'New incident added successfully!');
|
||||
} else {
|
||||
session()->setFlashdata('error', 'Failed to add new incident.');
|
||||
}
|
||||
}
|
||||
// Redirect to the list view
|
||||
return $this->index();
|
||||
}
|
||||
|
||||
public function updateState($id)
|
||||
{
|
||||
log_message('debug', 'updateState method called for ID: ' . $id);
|
||||
log_message('debug', 'Flag state: ' . $this->request->getPost('flag_state'));
|
||||
|
||||
$currentFlagModel = new CurrentFlagModel();
|
||||
|
||||
// Get the new flag state from the form
|
||||
$newState = $this->request->getPost('flag_state');
|
||||
|
||||
if (!$newState) {
|
||||
session()->setFlashdata('error', 'incident state not provided.');
|
||||
return $this->index();
|
||||
}
|
||||
|
||||
// Update the flag state in the database
|
||||
if ($currentFlagModel->update($id, ['flag_state' => $newState])) {
|
||||
session()->setFlashdata('success', 'Incident state updated successfully!');
|
||||
} else {
|
||||
$errors = $currentFlagModel->errors();
|
||||
log_message('error', 'Failed to update incident state: ' . print_r($errors, true));
|
||||
session()->setFlashdata('error', 'Failed to update incident state.');
|
||||
}
|
||||
|
||||
// Redirect back to the flags list
|
||||
return $this->index();
|
||||
}
|
||||
|
||||
public function closeFlag($flagId)
|
||||
{
|
||||
// Log the whole session array
|
||||
log_message('debug', json_encode(session()->get()));
|
||||
$currentFlagModel = new CurrentFlagModel();
|
||||
$userId = session()->get('user_id');
|
||||
|
||||
// Get the current flag data
|
||||
$flagData = $currentFlagModel->find($flagId);
|
||||
|
||||
// Log and check if flag data is found
|
||||
if (!$flagData) {
|
||||
log_message('error', "incident with ID {$flagId} not found in database.");
|
||||
session()->setFlashdata('error', 'Incident not found.');
|
||||
return redirect()->back();
|
||||
}
|
||||
|
||||
// Proceed only if flag is not closed
|
||||
if ($flagData['flag_state'] !== 'Closed') {
|
||||
// Retrieve 'close_description' from POST request
|
||||
$closeDescription = $this->request->getPost('state_description');
|
||||
$actionTaken = $this->request->getPost('action_taken');
|
||||
|
||||
// Debugging: Check if the close description is received
|
||||
if (empty($closeDescription)) {
|
||||
session()->setFlashdata('error', 'Close description is missing.');
|
||||
return redirect()->back();
|
||||
}
|
||||
|
||||
// Update the flag state to Closed
|
||||
$currentFlagModel->update($flagId, [
|
||||
'flag_state' => 'Closed',
|
||||
'updated_by_closed' => $userId,
|
||||
'close_description' => $closeDescription, // Use dynamic description from form
|
||||
'action_taken' => $actionTaken
|
||||
]);
|
||||
|
||||
$flagData = $currentFlagModel->find($flagId);
|
||||
return $this->moveToHistory($flagData);
|
||||
} else {
|
||||
session()->setFlashdata('error', 'Incident is already closed.');
|
||||
}
|
||||
return $this->index();
|
||||
}
|
||||
|
||||
public function cancelFlag($flagId)
|
||||
{
|
||||
$currentFlagModel = new CurrentFlagModel();
|
||||
$userId = session()->get('user_id');
|
||||
|
||||
// Retrieve 'cancel_description' from POST request
|
||||
$cancelDescription = $this->request->getPost('state_description');
|
||||
$actionTaken = $this->request->getPost('action_taken');
|
||||
|
||||
// Get the current flag data
|
||||
$flagData = $currentFlagModel->find($flagId);
|
||||
|
||||
// Check if flag data is found
|
||||
if (!$flagData) {
|
||||
log_message('error', "Incident with ID {$flagId} not found in database.");
|
||||
session()->setFlashdata('error', 'Incident not found.');
|
||||
return redirect()->back();
|
||||
}
|
||||
|
||||
// Check if the flag is not already canceled
|
||||
if ($flagData['flag_state'] !== 'Canceled') {
|
||||
// Update the flag state to Canceled
|
||||
$currentFlagModel->update($flagId, [
|
||||
'flag_state' => 'Canceled',
|
||||
'updated_by_canceled' => $userId,
|
||||
'cancel_description' => $cancelDescription,
|
||||
'action_taken' => $actionTaken
|
||||
]);
|
||||
|
||||
// Retrieve updated flag data to ensure latest state
|
||||
$flagData = $currentFlagModel->find($flagId);
|
||||
return $this->moveToHistory($flagData);
|
||||
} else {
|
||||
session()->setFlashdata('error', 'Incident is already canceled.');
|
||||
}
|
||||
|
||||
return $this->index();
|
||||
}
|
||||
|
||||
|
||||
private function moveToHistory($flagData)
|
||||
{
|
||||
$flagModel = new FlagModel();
|
||||
|
||||
// Prepare data for the flag table
|
||||
$dataToInsert = [
|
||||
'student_id' => $flagData['student_id'],
|
||||
'student_name' => $flagData['student_name'],
|
||||
'grade' => $flagData['grade'],
|
||||
'flag' => $flagData['flag'],
|
||||
'flag_datetime' => $flagData['flag_datetime'],
|
||||
'flag_state' => $flagData['flag_state'],
|
||||
'updated_by_open' => $flagData['updated_by_open'],
|
||||
'open_description' => $flagData['open_description'],
|
||||
'updated_by_closed' => $flagData['updated_by_closed'],
|
||||
'close_description' => $flagData['close_description'],
|
||||
'action_taken' => $flagData['action_taken'],
|
||||
'updated_by_canceled' => $flagData['updated_by_canceled'],
|
||||
'cancel_description' => $flagData['cancel_description'],
|
||||
'semester' => $flagData['semester'],
|
||||
'school_year' => $flagData['school_year'],
|
||||
'created_at' => $flagData['created_at'],
|
||||
'updated_at' => $flagData['updated_at']
|
||||
];
|
||||
|
||||
// Insert data into the flag table
|
||||
if ($flagModel->insert($dataToInsert)) {
|
||||
// Delete the entry from the current_flag table
|
||||
$currentFlagModel = new CurrentFlagModel();
|
||||
$currentFlagModel->delete($flagData['id']);
|
||||
session()->setFlashdata('success', 'Incident has been moved to history.');
|
||||
} else {
|
||||
session()->setFlashdata('error', 'Failed to move incident to history.');
|
||||
}
|
||||
|
||||
return $this->index();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use CodeIgniter\Controller;
|
||||
|
||||
class FrontendController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
return view('/index');
|
||||
}
|
||||
|
||||
public function facility()
|
||||
{
|
||||
log_message('debug', 'FrontendController::facility called');
|
||||
helper('url'); // Ensure URL helper is loaded
|
||||
return view('/facility');
|
||||
}
|
||||
|
||||
public function team()
|
||||
{
|
||||
log_message('debug', 'FrontendController::team called');
|
||||
return view('/team');
|
||||
}
|
||||
|
||||
public function callToAction()
|
||||
{
|
||||
log_message('debug', 'FrontendController::callToAction called');
|
||||
return view('/call_to_action');
|
||||
}
|
||||
|
||||
public function testimonial()
|
||||
{
|
||||
log_message('debug', 'FrontendController::testimonial called');
|
||||
return view('/testimonial');
|
||||
}
|
||||
|
||||
public function notFound()
|
||||
{
|
||||
log_message('debug', 'FrontendController::notFound called');
|
||||
return view('/notFound');
|
||||
}
|
||||
|
||||
public function fetchUser()
|
||||
{
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Include the shared database connection file
|
||||
$file = __DIR__ . '/../db_connection.php';
|
||||
|
||||
if (file_exists($file)) {
|
||||
require_once $file;
|
||||
} else {
|
||||
die("Error: Could not find the required file '$file'.");
|
||||
}
|
||||
|
||||
// Assuming the logged-in user's information is available in the session
|
||||
session_start();
|
||||
if (!isset($_SESSION['user_id'])) {
|
||||
echo json_encode(['error' => 'User not logged in']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$userId = $_SESSION['user_id'];
|
||||
|
||||
// Fetch user data from the database
|
||||
$sql = "SELECT firstname, lastname FROM users WHERE id = ?";
|
||||
$stmt = $conn->prepare($sql);
|
||||
$stmt->bind_param("i", $userId);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
$user = $result->fetch_assoc();
|
||||
|
||||
if ($user) {
|
||||
echo json_encode($user);
|
||||
} else {
|
||||
echo json_encode(['error' => 'User not found']);
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
$conn->close();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use CodeIgniter\Controller;
|
||||
|
||||
class HealthController extends Controller
|
||||
{
|
||||
private function checkPath(string $label, string $path): array
|
||||
{
|
||||
return [
|
||||
'label' => $label,
|
||||
'path' => $path,
|
||||
'exists' => is_dir($path),
|
||||
'writable' => is_writable($path),
|
||||
];
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$uploadsBase = rtrim(WRITEPATH, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'uploads';
|
||||
$paths = [
|
||||
'uploads' => $uploadsBase,
|
||||
'uploads/reimbursements' => $uploadsBase . DIRECTORY_SEPARATOR . 'reimbursements',
|
||||
'uploads/receipts' => $uploadsBase . DIRECTORY_SEPARATOR . 'receipts',
|
||||
'uploads/checks' => $uploadsBase . DIRECTORY_SEPARATOR . 'checks',
|
||||
'uploads/early_dismissal_signatures' => $uploadsBase . DIRECTORY_SEPARATOR . 'early_dismissal_signatures',
|
||||
];
|
||||
|
||||
$pathsStatus = [];
|
||||
foreach ($paths as $label => $p) {
|
||||
$pathsStatus[] = $this->checkPath($label, $p);
|
||||
}
|
||||
|
||||
$db = \Config\Database::connect();
|
||||
$dbChecks = [
|
||||
'user_preferences_exists' => $db->tableExists('user_preferences'),
|
||||
'settings_exists' => $db->tableExists('settings'),
|
||||
'migrations_exists' => $db->tableExists('migrations'),
|
||||
];
|
||||
|
||||
$dbChecks['user_preferences_has_timezone'] = $dbChecks['user_preferences_exists']
|
||||
? $db->fieldExists('timezone', 'user_preferences')
|
||||
: false;
|
||||
|
||||
$dbChecks['settings_has_timezone'] = $dbChecks['settings_exists']
|
||||
? $db->fieldExists('timezone', 'settings')
|
||||
: false;
|
||||
|
||||
$okPaths = array_reduce($pathsStatus, function ($carry, $row) {
|
||||
return $carry && $row['exists'] && $row['writable'];
|
||||
}, true);
|
||||
|
||||
$okDb = true;
|
||||
if ($dbChecks['user_preferences_exists'] && !$dbChecks['user_preferences_has_timezone']) {
|
||||
$okDb = false;
|
||||
}
|
||||
if ($dbChecks['settings_exists'] && !$dbChecks['settings_has_timezone']) {
|
||||
$okDb = false;
|
||||
}
|
||||
|
||||
$ok = $okPaths && $okDb;
|
||||
|
||||
$payload = [
|
||||
'ok' => $ok,
|
||||
'paths' => $pathsStatus,
|
||||
'database' => $dbChecks,
|
||||
'write_path' => WRITEPATH,
|
||||
'timestamp' => date('c'),
|
||||
];
|
||||
|
||||
$status = $ok ? 200 : 503;
|
||||
return $this->response->setStatusCode($status)->setJSON($payload);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,656 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Models\HomeworkModel;
|
||||
use App\Models\StudentModel;
|
||||
use App\Models\StudentClassModel;
|
||||
use CodeIgniter\Controller;
|
||||
use App\Models\TeacherClassModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
use App\Models\UserModel;
|
||||
use RuntimeException;
|
||||
use App\Services\SemesterScoreService;
|
||||
use Config\Services;
|
||||
use App\Controllers\View\GradingController;
|
||||
use App\Models\GradingLockModel;
|
||||
use App\Models\MissingScoreOverrideModel;
|
||||
|
||||
class HomeworkController extends Controller
|
||||
{
|
||||
protected $db;
|
||||
protected $semesterScoreService;
|
||||
protected $configModel;
|
||||
protected $homeworkModel;
|
||||
protected $userModel;
|
||||
protected $schoolYear;
|
||||
protected $semester;
|
||||
protected $studentClassModel;
|
||||
protected $studentModel;
|
||||
protected $teacherClassModel;
|
||||
protected $gradingLockModel;
|
||||
protected $missingScoreOverrideModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->db = \Config\Database::connect();
|
||||
$this->teacherClassModel = new TeacherClassModel();
|
||||
$this->homeworkModel = new HomeworkModel();
|
||||
$this->userModel = new UserModel();
|
||||
$this->studentClassModel = new StudentClassModel();
|
||||
$this->studentModel = new StudentModel();
|
||||
$this->configModel = new ConfigurationModel();
|
||||
|
||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||
$this->semester = $this->configModel->getConfig('semester');
|
||||
|
||||
// Log the service initialization
|
||||
log_message('debug', 'Initializing SemesterScoreService');
|
||||
|
||||
//$this->semesterScoreService = Services::SemesterScoreService();
|
||||
$this->semesterScoreService = service('semesterScoreService');
|
||||
// $this->semesterScoreService = \Config\Services::semesterScoreService();
|
||||
$this->gradingLockModel = new GradingLockModel();
|
||||
$this->missingScoreOverrideModel = new MissingScoreOverrideModel();
|
||||
|
||||
// Check if the service is null
|
||||
if ($this->semesterScoreService === null) {
|
||||
log_message('error', 'SemesterScoreService is null');
|
||||
}
|
||||
}
|
||||
|
||||
public function updateHomeworkScores(array $scores = null, int $updatedBy = null, int $classSectionId = null)
|
||||
{
|
||||
$scores = $this->request->getPost('scores');
|
||||
$semester = $this->request->getPost('semester') ?? $this->semester;
|
||||
$schoolYear = $this->request->getPost('school_year') ?? $this->schoolYear;
|
||||
|
||||
if ($updatedBy === null) {
|
||||
$updatedBy = session()->get('user_id');
|
||||
}
|
||||
$classSectionId = $this->request->getPost('class_section_id') ?? session()->get('class_section_id');
|
||||
$classSectionId = (int) ($classSectionId ?? 0);
|
||||
if ($classSectionId > 0 && $this->isScoresLocked($classSectionId, $semester, $schoolYear)) {
|
||||
return redirect()->back()->with('error', 'Scores are locked for this class. Unlock to edit.');
|
||||
}
|
||||
|
||||
$missingOk = $this->request->getPost('missing_ok') ?? [];
|
||||
$studentIds = is_array($scores) ? array_keys($scores) : array_keys((array) $missingOk);
|
||||
$indexSet = [];
|
||||
if (is_array($scores)) {
|
||||
foreach ($scores as $studentId => $hwData) {
|
||||
if (!is_array($hwData)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($hwData as $index => $score) {
|
||||
if (is_numeric($index)) {
|
||||
$indexSet[(int)$index] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$checkedItems = [];
|
||||
if (is_array($missingOk)) {
|
||||
foreach ($missingOk as $studentId => $indexes) {
|
||||
if (!is_array($indexes)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($indexes as $index => $flag) {
|
||||
if (!is_numeric($index)) {
|
||||
continue;
|
||||
}
|
||||
$checkedItems[] = [
|
||||
'student_id' => (int) $studentId,
|
||||
'item_index' => (int) $index,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
$this->missingScoreOverrideModel->replaceOverrides(
|
||||
$classSectionId,
|
||||
(string) $semester,
|
||||
(string) $schoolYear,
|
||||
'homework',
|
||||
$studentIds,
|
||||
array_keys($indexSet),
|
||||
$checkedItems,
|
||||
$updatedBy
|
||||
);
|
||||
|
||||
if (is_array($scores)) {
|
||||
foreach ($scores as $studentId => $hwData) {
|
||||
if (!is_numeric($studentId)) continue;
|
||||
|
||||
$student = $this->studentModel->find($studentId);
|
||||
if (!$student) continue;
|
||||
|
||||
foreach ($hwData as $index => $score) {
|
||||
$rawScore = $score ?? null;
|
||||
$isBlank = $rawScore === null || (is_string($rawScore) && trim($rawScore) === '');
|
||||
$normalizedScore = (!$isBlank && is_numeric($rawScore)) ? (float) $rawScore : null;
|
||||
|
||||
// STEP 1: Always fetch the existing record
|
||||
$existing = $this->homeworkModel->where([
|
||||
'student_id' => $studentId,
|
||||
'homework_index' => $index,
|
||||
'class_section_id' => $classSectionId,
|
||||
//'teacher_id' => $updatedBy,
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
])->first();
|
||||
|
||||
// STEP 2: Save numeric score (blank -> null)
|
||||
$data = [
|
||||
'student_id' => $studentId,
|
||||
'school_id' => $student['school_id'] ?? '',
|
||||
'class_section_id' => $classSectionId,
|
||||
'updated_by' => $updatedBy,
|
||||
'homework_index' => $index,
|
||||
'score' => $normalizedScore,
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
'updated_at' => utc_now(),
|
||||
];
|
||||
|
||||
if ($isBlank) {
|
||||
if ($existing) {
|
||||
$this->homeworkModel->update($existing['id'], $data);
|
||||
log_message('debug', "Updated homework ID {$existing['id']} with score null");
|
||||
} else {
|
||||
$data['created_at'] = utc_now();
|
||||
$this->homeworkModel->insert($data);
|
||||
log_message('debug', "Inserted blank homework for student $studentId index $index");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($existing) {
|
||||
$this->homeworkModel->update($existing['id'], $data);
|
||||
log_message('debug', "Updated homework ID {$existing['id']} with score {$normalizedScore}");
|
||||
} else {
|
||||
$data['created_at'] = utc_now();
|
||||
$this->homeworkModel->insert($data);
|
||||
log_message('debug', "Inserted score for student $studentId index $index");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$studentUserInfo = $this->studentModel->getStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear);
|
||||
|
||||
// Call the updateScoresForStudents method
|
||||
try {
|
||||
$this->semesterScoreService->updateScoresForStudents($studentUserInfo, $semester, $schoolYear);
|
||||
} catch (RuntimeException $e) {
|
||||
// Handle error
|
||||
}
|
||||
|
||||
return redirect()->to(base_url('/teacher/addHomework'))->with('status', 'Homework scores updated successfully.');
|
||||
}
|
||||
|
||||
|
||||
public function addNextHomeworkColumn()
|
||||
{
|
||||
$updatedBy = session()->get('user_id');
|
||||
$classSectionId = session()->get('class_section_id');
|
||||
$selectedSemester = $this->getSelectedSemester();
|
||||
$normalized = $this->normalizeSemesterSelection($selectedSemester);
|
||||
$semesterLabel = $normalized !== '' ? ucfirst($normalized) : $selectedSemester;
|
||||
|
||||
// Step 1: Get the highest existing homework_index
|
||||
$existingIndexes = $this->homeworkModel
|
||||
->select('homework_index')
|
||||
->where('class_section_id', $classSectionId)
|
||||
//->where('teacher_id', $updatedBy)
|
||||
->whereIn('semester', $this->getSemesterVariants($semesterLabel))
|
||||
->where('school_year', $this->schoolYear)
|
||||
->groupBy('homework_index')
|
||||
->orderBy('homework_index', 'DESC')
|
||||
->findAll();
|
||||
|
||||
$maxIndex = 0;
|
||||
foreach ($existingIndexes as $row) {
|
||||
if (isset($row['homework_index']) && is_numeric($row['homework_index'])) {
|
||||
$maxIndex = max($maxIndex, (int)$row['homework_index']);
|
||||
}
|
||||
}
|
||||
|
||||
$nextIndex = $maxIndex + 1;
|
||||
|
||||
// Step 2: Get all students in the class
|
||||
$students = $this->studentClassModel
|
||||
->where('class_section_id', $classSectionId)
|
||||
->findAll();
|
||||
|
||||
// Step 3: Insert a new homework row for each student if not already exists
|
||||
$firstInsertedId = null;
|
||||
foreach ($students as $i => $student) {
|
||||
$studentId = $student['student_id'];
|
||||
|
||||
// Check if record already exists
|
||||
$existing = $this->homeworkModel
|
||||
->where('student_id', $studentId)
|
||||
->where('homework_index', $nextIndex)
|
||||
->where('class_section_id', $classSectionId)
|
||||
//->where('teacher_id', $updatedBy)
|
||||
->whereIn('semester', $this->getSemesterVariants($semesterLabel))
|
||||
->where('school_year', $this->schoolYear)
|
||||
->first();
|
||||
|
||||
if ($existing) continue;
|
||||
|
||||
$insertData = [
|
||||
'student_id' => $studentId,
|
||||
'school_id' => '', // Optional: populate if needed
|
||||
'class_section_id' => $classSectionId,
|
||||
'updated_by' => $updatedBy,
|
||||
'homework_index' => $nextIndex,
|
||||
'score' => null,
|
||||
'semester' => $semesterLabel,
|
||||
'school_year' => $this->schoolYear,
|
||||
'created_at' => utc_now(),
|
||||
'updated_at' => utc_now()
|
||||
];
|
||||
|
||||
$id = $this->homeworkModel->insert($insertData, true);
|
||||
if ($i === 0) {
|
||||
$firstInsertedId = $id;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Return index and ID to frontend
|
||||
return $this->response->setJSON([
|
||||
'status' => 'success',
|
||||
'homework_index' => $nextIndex,
|
||||
'new_homework_id' => $firstInsertedId
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
public function showHomework()
|
||||
{
|
||||
$updatedBy = session()->get('user_id');
|
||||
$classSectionId = $this->getClassSectionIdForTeacher($updatedBy);
|
||||
if (!$classSectionId) {
|
||||
return redirect()->back()->with('status', 'No class section found for the current teacher.');
|
||||
}
|
||||
|
||||
session()->set('class_section_id', $classSectionId);
|
||||
|
||||
$semester = $this->getSelectedSemester();
|
||||
$homeworkHeaders = $this->getHomeworkHeaders($classSectionId, $semester, $this->schoolYear);
|
||||
if (empty($homeworkHeaders)) {
|
||||
$homeworkHeaders = [1];
|
||||
}
|
||||
$students = $this->getStudentsWithHomeworkScores(
|
||||
$classSectionId,
|
||||
$homeworkHeaders,
|
||||
$semester,
|
||||
$this->schoolYear
|
||||
);
|
||||
$missingOkMap = $this->missingScoreOverrideModel->getOverridesMap($classSectionId, $semester, $this->schoolYear, 'homework');
|
||||
|
||||
return view('teacher/add_homework', [
|
||||
'students' => $students,
|
||||
'homeworkHeaders' => $homeworkHeaders,
|
||||
'semester' => $semester,
|
||||
'schoolYear' => $this->schoolYear,
|
||||
'class_section_id' => $classSectionId,
|
||||
'missingOkMap' => $missingOkMap,
|
||||
]);
|
||||
}
|
||||
|
||||
// In your GradingController (or the controller that owns this action)
|
||||
public function showHomeworkMngt()
|
||||
{
|
||||
// Accept POST first, then GET (query string), then session fallback
|
||||
$incomingId = $this->request->getPost('class_section_id');
|
||||
if (!$incomingId) {
|
||||
$incomingId = $this->request->getGet('class_section_id');
|
||||
}
|
||||
|
||||
$classSectionId = (int) ($incomingId ?: (session()->get('class_section_id') ?? 0));
|
||||
|
||||
if ($classSectionId <= 0) {
|
||||
return redirect()->back()->with('status', 'No class section found for the current teacher or selection.');
|
||||
}
|
||||
$updatedBy = (int) (session()->get('user_id') ?? 0);
|
||||
|
||||
$semesterParam = $this->request->getPost('semester')
|
||||
?? $this->request->getGet('semester')
|
||||
?? $this->request->getGet('filter');
|
||||
$normalizedSemester = $this->normalizeSemesterSelection($semesterParam);
|
||||
if ($normalizedSemester !== '') {
|
||||
$label = ucfirst($normalizedSemester);
|
||||
$session = session();
|
||||
$session->set('teacher_scores_selected_semester', $label);
|
||||
$session->set('semester', $label);
|
||||
}
|
||||
|
||||
$schoolYearParam = $this->request->getPost('school_year') ?? $this->request->getGet('school_year');
|
||||
$schoolYear = $this->normalizeSchoolYear($schoolYearParam) ?? $this->schoolYear;
|
||||
/*
|
||||
// OPTIONAL (recommended): ensure the classSectionId belongs to this teacher for the active term
|
||||
$updatedBy = (int) (session()->get('user_id') ?? 0);
|
||||
if ($updatedBy > 0) {
|
||||
// If you have a TeacherClassModel with new schema (position = 'main' or 'ta'):
|
||||
$isOwned = $this->teacherClassModel
|
||||
->where('teacher_id', $updatedBy)
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('school_year', $this->schoolYear)
|
||||
->where('semester', $this->semester)
|
||||
->countAllResults() > 0;
|
||||
|
||||
if (!$isOwned) {
|
||||
return redirect()->back()->with('status', 'You do not have access to that class section.');
|
||||
}
|
||||
}
|
||||
*/
|
||||
// Persist chosen class section in the session
|
||||
session()->set('class_section_id', $classSectionId);
|
||||
|
||||
// Load data for the view
|
||||
$semester = $this->getSelectedSemester();
|
||||
$homeworkScores = $this->getHomeworkScoresByStudent($classSectionId, $semester, $schoolYear);
|
||||
$students = $this->getStudentsByClassSectionAndYear($classSectionId, $schoolYear);
|
||||
$homeworkHeaders = $this->getHomeworkHeaders($classSectionId, $semester, $schoolYear);
|
||||
$scoresLocked = $this->isScoresLocked($classSectionId, $semester, $schoolYear);
|
||||
|
||||
// Render
|
||||
return view('grading/homework', [
|
||||
'homeworkScores' => $homeworkScores,
|
||||
'students' => $students,
|
||||
'semester' => $semester,
|
||||
'schoolYear' => $schoolYear,
|
||||
'homeworkHeaders' => $homeworkHeaders,
|
||||
'classSectionId' => $classSectionId,
|
||||
'hasHomeworkCols' => !empty($homeworkHeaders),
|
||||
'isManagement' => true,
|
||||
'scoresLocked' => $scoresLocked,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
public function updateHomework()
|
||||
{
|
||||
$scores = $this->request->getPost('scores');
|
||||
$studentIds = $this->request->getPost('student_ids');
|
||||
$semester = $this->request->getPost('semester') ?? $this->getSelectedSemester();
|
||||
$schoolYear = $this->request->getPost('school_year') ?? $this->schoolYear;
|
||||
|
||||
$updatedBy = $this->request->getPost('teacher_id') ?? session()->get('user_id');
|
||||
$classSectionId = $this->request->getPost('class_section_id') ?? session()->get('class_section_id'); // persisted in showHomeworkMngt
|
||||
$classSectionIdInt = (int) ($classSectionId ?? 0);
|
||||
if ($classSectionIdInt > 0 && $this->isScoresLocked($classSectionIdInt, $semester, $schoolYear)) {
|
||||
return redirect()->back()->with('error', 'Scores are locked for this class. Unlock to edit.');
|
||||
}
|
||||
|
||||
if ($scores && $studentIds && $semester && $schoolYear && $classSectionId) {
|
||||
$this->updateHomeworkScores($scores, $updatedBy, $classSectionId);
|
||||
session()->setFlashdata('status', 'Homework scores updated successfully.');
|
||||
|
||||
// ⬇️ Directly call the method and return its view
|
||||
return $this->showHomeworkMngt($updatedBy);
|
||||
}
|
||||
|
||||
$studentTeacherInfo = $this->studentModel->getStudentInfoByClassSectionId($classSectionId);
|
||||
// Call the updateScoresForStudents method
|
||||
try {
|
||||
|
||||
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo, $semester, $schoolYear);
|
||||
} catch (RuntimeException $e) {
|
||||
// Handle error
|
||||
}
|
||||
return redirect()->back()->with('status', 'Failed to update scores.');
|
||||
}
|
||||
|
||||
private function isScoresLocked(int $classSectionId, string $semester, string $schoolYear): bool
|
||||
{
|
||||
return $this->gradingLockModel->isLocked($classSectionId, $semester, $schoolYear);
|
||||
}
|
||||
|
||||
// ──────────────── Helpers ────────────────
|
||||
|
||||
private function saveHomeworkScores(array $scores, array $studentIds, string $semester, string $schoolYear, int $updatedBy, int $classSectionId)
|
||||
{
|
||||
$now = utc_now();
|
||||
|
||||
foreach ($studentIds as $studentId) {
|
||||
if (!isset($scores[$studentId])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($scores[$studentId] as $index => $score) {
|
||||
$isBlank = $score === null || (is_string($score) && trim($score) === '');
|
||||
|
||||
// Check if the record exists
|
||||
$existing = $this->homeworkModel
|
||||
->where('student_id', $studentId)
|
||||
->where('homework_index', $index)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('class_section_id', $classSectionId)
|
||||
//->where('teacher_id', $updatedBy)
|
||||
->first();
|
||||
|
||||
if ($isBlank) {
|
||||
if ($existing) {
|
||||
$this->homeworkModel->update($existing['id'], [
|
||||
'score' => null,
|
||||
'updated_by' => $updatedBy,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
} else {
|
||||
$this->homeworkModel->insert([
|
||||
'student_id' => $studentId,
|
||||
'class_section_id' => $classSectionId,
|
||||
'updated_by' => $updatedBy,
|
||||
'homework_index' => $index,
|
||||
'score' => null,
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
$data = [
|
||||
'student_id' => $studentId,
|
||||
'class_section_id' => $classSectionId,
|
||||
'updated_by' => $updatedBy,
|
||||
'homework_index' => $index,
|
||||
'score' => is_numeric($score) ? floatval($score) : null,
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
if ($existing) {
|
||||
$this->homeworkModel->update($existing['id'], $data);
|
||||
} else {
|
||||
$data['created_at'] = $now;
|
||||
$data['school_id'] = ''; // Set if needed
|
||||
$this->homeworkModel->insert($data);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function getStudentsByClassSectionAndYear($classSectionId, $schoolYear)
|
||||
{
|
||||
// Step 1: Get student IDs from student_class table
|
||||
$studentClassRows = $this->studentClassModel
|
||||
->select('student_id')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('school_year', $schoolYear)
|
||||
->findAll();
|
||||
|
||||
$studentIds = array_column($studentClassRows, 'student_id');
|
||||
|
||||
if (empty($studentIds)) {
|
||||
return []; // No students found
|
||||
}
|
||||
|
||||
// Step 2: Get student data from students table
|
||||
$students = $this->studentModel
|
||||
->whereIn('id', $studentIds)
|
||||
->where('is_active', 1)
|
||||
->orderBy('lastname', 'ASC')
|
||||
->findAll();
|
||||
|
||||
return $students;
|
||||
}
|
||||
|
||||
private function getClassSectionIdForTeacher($updatedBy)
|
||||
{
|
||||
$class = $this->teacherClassModel->where('teacher_id', $updatedBy)->first();
|
||||
return $class['class_section_id'] ?? null;
|
||||
}
|
||||
|
||||
|
||||
private function getHomeworkScoresByStudent($classSectionId, $semester, $schoolYear)
|
||||
{
|
||||
$semVariants = $this->getSemesterVariants($semester);
|
||||
$rows = $this->homeworkModel
|
||||
->select('student_id, homework_index, score')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->whereIn('semester', $semVariants)
|
||||
->where('school_year', $schoolYear)
|
||||
->findAll();
|
||||
|
||||
$studentScores = [];
|
||||
foreach ($rows as $row) {
|
||||
$studentId = $row['student_id'];
|
||||
$index = $row['homework_index'];
|
||||
$score = $row['score'];
|
||||
|
||||
$studentScores[$studentId]['scores'][$index] = $score;
|
||||
}
|
||||
|
||||
return $studentScores;
|
||||
}
|
||||
|
||||
private function getStudentsWithHomeworkScores($classSectionId, $homeworkHeaders, $semester, $schoolYear)
|
||||
{
|
||||
$semVariants = $this->getSemesterVariants($semester);
|
||||
$studentClasses = $this->studentClassModel
|
||||
->active()
|
||||
->where('student_class.class_section_id', $classSectionId)
|
||||
->findAll();
|
||||
$students = [];
|
||||
|
||||
foreach ($studentClasses as $sc) {
|
||||
$student = $this->studentModel
|
||||
->where('id', $sc['student_id'])
|
||||
->where('is_active', 1)
|
||||
->first();
|
||||
if (!$student) continue;
|
||||
|
||||
$scores = [];
|
||||
foreach ($homeworkHeaders as $ids) {
|
||||
$entry = $this->homeworkModel->where('homework_index', $ids)
|
||||
->where('student_id', $sc['student_id'])
|
||||
->where('class_section_id', $classSectionId)
|
||||
->whereIn('semester', $semVariants)
|
||||
->where('school_year', $schoolYear)
|
||||
->first();
|
||||
// Keep null/blank when no entry exists yet
|
||||
$scores[$ids] = $entry['score'] ?? null;
|
||||
}
|
||||
|
||||
$students[] = [
|
||||
'student_id' => $sc['student_id'],
|
||||
'firstname' => $student['firstname'],
|
||||
'lastname' => $student['lastname'],
|
||||
'scores' => $scores
|
||||
];
|
||||
}
|
||||
|
||||
usort($students, fn($a, $b) => strcmp($a['lastname'], $b['lastname']) ?: strcmp($a['firstname'], $b['firstname']));
|
||||
return $students;
|
||||
}
|
||||
|
||||
private function getHomeworkHeaders($classSectionId, $semester, $schoolYear)
|
||||
{
|
||||
|
||||
$semVariants = $this->getSemesterVariants($semester);
|
||||
$rows = $this->homeworkModel
|
||||
->select('homework_index')
|
||||
->where('class_section_id', $classSectionId)
|
||||
//->where('teacher_id', $updatedBy)
|
||||
->whereIn('semester', $semVariants)
|
||||
->where('school_year', $schoolYear)
|
||||
->orderBy('homework_index', 'ASC')
|
||||
->findAll();
|
||||
|
||||
$headers = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$index = $row['homework_index'];
|
||||
if (!is_array($index)) {
|
||||
$headers[$index] = true;
|
||||
}
|
||||
}
|
||||
|
||||
ksort($headers);
|
||||
return array_keys($headers);
|
||||
}
|
||||
|
||||
private function getSemesterVariants(?string $semester): array
|
||||
{
|
||||
$raw = trim((string) $semester);
|
||||
$variants = [
|
||||
$raw,
|
||||
strtolower($raw),
|
||||
ucfirst(strtolower($raw)),
|
||||
];
|
||||
$normalized = $this->normalizeSemesterSelection($raw);
|
||||
if ($normalized === 'fall') {
|
||||
$variants[] = 'First Semester';
|
||||
$variants[] = 'Semester 1';
|
||||
$variants[] = '1st Semester';
|
||||
$variants[] = 'Fall Semester';
|
||||
} elseif ($normalized === 'spring') {
|
||||
$variants[] = 'Second Semester';
|
||||
$variants[] = 'Semester 2';
|
||||
$variants[] = '2nd Semester';
|
||||
$variants[] = 'Spring Semester';
|
||||
}
|
||||
return array_values(array_unique(array_filter($variants, static fn($v) => $v !== '')));
|
||||
}
|
||||
|
||||
private function getSelectedSemester(): string
|
||||
{
|
||||
$session = session();
|
||||
$selected = $session->get('teacher_scores_selected_semester');
|
||||
if (!empty($selected)) {
|
||||
return (string) $selected;
|
||||
}
|
||||
return $session->get('semester') ?? $this->semester;
|
||||
}
|
||||
|
||||
private function normalizeSemesterSelection(?string $input): string
|
||||
{
|
||||
$value = strtolower(trim((string) $input));
|
||||
if ($value === 'fall' || str_contains($value, '1')) {
|
||||
return 'fall';
|
||||
}
|
||||
if ($value === 'spring' || str_contains($value, '2')) {
|
||||
return 'spring';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private function normalizeSchoolYear(?string $input): ?string
|
||||
{
|
||||
$value = trim((string) $input);
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
if (preg_match('/^\\d{4}\\s*[-\\/]\\s*\\d{4}$/', $value)) {
|
||||
return preg_replace('/\\s+/', '', str_replace('/', '-', $value));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Models\ConfigurationModel;
|
||||
use App\Models\CalendarModel;
|
||||
use App\Models\HomeworkModel;
|
||||
|
||||
class HomeworkTrackingController extends BaseController
|
||||
{
|
||||
protected $configModel;
|
||||
protected $calendarModel;
|
||||
protected $homeworkModel;
|
||||
protected $db;
|
||||
|
||||
protected string $semester;
|
||||
protected string $schoolYear;
|
||||
/** cache for teacher assignment presence per term */
|
||||
private array $teacherAssignmentCache = [];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->configModel = new ConfigurationModel();
|
||||
$this->calendarModel = new CalendarModel();
|
||||
$this->homeworkModel = new HomeworkModel();
|
||||
$this->db = \Config\Database::connect();
|
||||
|
||||
$this->semester = (string) ($this->configModel->getConfig('semester') ?? '');
|
||||
$this->schoolYear = (string) ($this->configModel->getConfig('school_year') ?? '');
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
// Semester-based date range (Fall or Spring) derived from school year start.
|
||||
[$start, $end] = $this->getDateRangeForSemester($this->schoolYear, $this->semester);
|
||||
|
||||
// Move start to the first Sunday on/after start
|
||||
if ((int)$start->format('w') !== 0) {
|
||||
$start = (clone $start)->modify('next sunday');
|
||||
}
|
||||
|
||||
// Collect all Sundays within range
|
||||
$sundays = [];
|
||||
$d = clone $start;
|
||||
while ($d <= $end) {
|
||||
$sundays[] = $d->format('Y-m-d');
|
||||
$d->modify('+7 days');
|
||||
}
|
||||
|
||||
// Map "no school" events by date (Y-m-d) using explicit DB flag
|
||||
$events = $this->calendarModel->getEventsBySchoolYear($this->schoolYear) ?? [];
|
||||
$eventDays = [];
|
||||
foreach ($events as $ev) {
|
||||
$ymd = substr((string)($ev['date'] ?? ''), 0, 10);
|
||||
$no = (int)($ev['no_school'] ?? 0);
|
||||
if ($ymd && $no === 1) {
|
||||
$eventDays[$ymd] = true; // yellow highlight for no-school day
|
||||
}
|
||||
}
|
||||
|
||||
// Map Sundays to ordinal homework_index, skipping event Sundays
|
||||
$dateToIndex = [];
|
||||
$idx = 0;
|
||||
foreach ($sundays as $ymd) {
|
||||
if (!isset($eventDays[$ymd])) {
|
||||
$idx++;
|
||||
$dateToIndex[$ymd] = $idx;
|
||||
} else {
|
||||
$dateToIndex[$ymd] = null; // event day (no homework expected)
|
||||
}
|
||||
}
|
||||
|
||||
$limitToSemester = $this->hasTeacherAssignments($this->schoolYear, $this->semester);
|
||||
|
||||
// Aggregate homework presence + first entered date per class_section_id + homework_index
|
||||
$hwQ = $this->db->table('homework')
|
||||
->select('class_section_id, homework_index, MIN(created_at) AS first_created, COUNT(*) AS cnt')
|
||||
->where('school_year', $this->schoolYear);
|
||||
if ($limitToSemester && $this->semester !== '') {
|
||||
$hwQ->where('semester', $this->semester);
|
||||
}
|
||||
$rows = $hwQ->groupBy('class_section_id, homework_index')
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
$hasHomework = [];
|
||||
$hwEnteredAt = [];
|
||||
foreach ($rows as $r) {
|
||||
$csid = (int)($r['class_section_id'] ?? 0);
|
||||
$hi = (int)($r['homework_index'] ?? 0);
|
||||
$cnt = (int)($r['cnt'] ?? 0);
|
||||
if ($csid > 0 && $hi > 0 && $cnt > 0) {
|
||||
$hasHomework[$csid][$hi] = true;
|
||||
$dateStr = substr((string)($r['first_created'] ?? ''), 0, 10);
|
||||
$hwEnteredAt[$csid][$hi] = $dateStr ?: null;
|
||||
}
|
||||
}
|
||||
|
||||
// Build date-based presence mapped to the nearest prior non-NoSchool Sunday.
|
||||
$hb = $this->db->table('homework')
|
||||
->select("class_section_id, DATE(created_at) AS hw_date, MIN(created_at) AS first_created, COUNT(*) AS cnt", false)
|
||||
->where('school_year', $this->schoolYear);
|
||||
if ($limitToSemester && $this->semester !== '') {
|
||||
$hb->where('semester', $this->semester);
|
||||
}
|
||||
$rowsByDate = $hb->groupBy('class_section_id, DATE(created_at)', '', false)
|
||||
->orderBy('hw_date', 'ASC', false)
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
$hasHomeworkByDate = [];
|
||||
$hwEnteredAtByDate = [];
|
||||
foreach ($rowsByDate as $r) {
|
||||
$csid = (int)($r['class_section_id'] ?? 0);
|
||||
$d = substr((string)($r['hw_date'] ?? ''), 0, 10);
|
||||
$cnt = (int)($r['cnt'] ?? 0);
|
||||
$firstCreated = substr((string)($r['first_created'] ?? ''), 0, 10);
|
||||
if ($csid <= 0 || !$d || $cnt <= 0) { continue; }
|
||||
|
||||
// Find the index of the last Sunday on or before $d
|
||||
$baseIndex = -1;
|
||||
for ($i = count($sundays) - 1; $i >= 0; $i--) {
|
||||
if ($sundays[$i] <= $d) { $baseIndex = $i; break; }
|
||||
}
|
||||
if ($baseIndex < 0) { continue; }
|
||||
|
||||
// Map this homework day to only the nearest prior non–No School Sunday
|
||||
$j = $baseIndex;
|
||||
while ($j >= 0 && !empty($eventDays[$sundays[$j]])) { $j--; }
|
||||
if ($j < 0) { continue; }
|
||||
$sd = $sundays[$j];
|
||||
|
||||
// Mark homework on that Sunday for this class_section (single mapping)
|
||||
$hasHomeworkByDate[$csid][$sd] = true;
|
||||
if (empty($hwEnteredAtByDate[$csid][$sd])) {
|
||||
$hwEnteredAtByDate[$csid][$sd] = $firstCreated ?: $d;
|
||||
} else {
|
||||
// Keep the earliest date for display
|
||||
$existing = (string)$hwEnteredAtByDate[$csid][$sd];
|
||||
$candidate = $firstCreated ?: $d;
|
||||
if ($candidate && (!$existing || $candidate < $existing)) {
|
||||
$hwEnteredAtByDate[$csid][$sd] = $candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch teachers and TAs per section for this term
|
||||
$tb = $this->db->table('teacher_class tc')
|
||||
->select('tc.class_section_id, tc.position, cs.class_section_name, cs.class_id, u.firstname, u.lastname')
|
||||
->join('classSection cs', 'cs.class_section_id = tc.class_section_id', 'left')
|
||||
->join('users u', 'u.id = tc.teacher_id', 'left')
|
||||
->where('tc.school_year', $this->schoolYear);
|
||||
$teacherRows = $tb->where('tc.class_section_id IS NOT NULL', null, false)
|
||||
->whereIn('tc.position', ['main','ta'])
|
||||
->orderBy('cs.class_id', 'ASC')
|
||||
->orderBy('cs.class_section_name', 'ASC')
|
||||
->orderBy("FIELD(tc.position, 'main','ta')", '', false)
|
||||
->orderBy('u.firstname', 'ASC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
$bySection = [];
|
||||
foreach ($teacherRows as $r) {
|
||||
$sid = (int)($r['class_section_id'] ?? 0);
|
||||
if ($sid <= 0) continue;
|
||||
if (!isset($bySection[$sid])) {
|
||||
$bySection[$sid] = [
|
||||
'class_section_id' => $sid,
|
||||
'class_id' => (int)($r['class_id'] ?? 0),
|
||||
'class_section_name' => (string)($r['class_section_name'] ?? ''),
|
||||
'teachers' => [],
|
||||
'tas' => [],
|
||||
];
|
||||
}
|
||||
$name = trim(($r['firstname'] ?? '') . ' ' . ($r['lastname'] ?? ''));
|
||||
$pos = strtolower((string)($r['position'] ?? ''));
|
||||
if ($name !== '') {
|
||||
if ($pos === 'main') { $bySection[$sid]['teachers'][] = $name; }
|
||||
elseif ($pos === 'ta') { $bySection[$sid]['tas'][] = $name; }
|
||||
}
|
||||
}
|
||||
|
||||
$teachers = array_values($bySection);
|
||||
|
||||
// Sort by grade order: KG(13) → Grade 1..11 → Youth(12) → others
|
||||
usort($teachers, function ($a, $b) {
|
||||
$ai = (int)($a['class_id'] ?? 0);
|
||||
$bi = (int)($b['class_id'] ?? 0);
|
||||
$ord = function ($cid) {
|
||||
if ($cid === 13) return 0; // KG first
|
||||
if ($cid >= 1 && $cid <= 11) return $cid; // Grades 1..11
|
||||
if ($cid === 12) return 99; // Youth last of known
|
||||
return 200 + $cid; // any others after
|
||||
};
|
||||
$cmp = $ord($ai) <=> $ord($bi);
|
||||
if ($cmp !== 0) return $cmp;
|
||||
return strnatcasecmp($a['class_section_name'] ?? '', $b['class_section_name'] ?? '');
|
||||
});
|
||||
|
||||
// Simple server-side pagination for rows (8 lines per page)
|
||||
$perPage = 8;
|
||||
$totalRows = count($teachers);
|
||||
$totalPages = max(1, (int)ceil($totalRows / $perPage));
|
||||
$page = (int) ($this->request->getGet('page') ?? 1);
|
||||
if ($page < 1) $page = 1;
|
||||
if ($page > $totalPages) $page = $totalPages;
|
||||
$offset = ($page - 1) * $perPage;
|
||||
$teachersPage = array_slice($teachers, $offset, $perPage);
|
||||
|
||||
return view('grading/homework_tracking', [
|
||||
'semester' => $this->semester,
|
||||
'schoolYear' => $this->schoolYear,
|
||||
'sundays' => $sundays,
|
||||
'eventDays' => $eventDays,
|
||||
'dateToIndex' => $dateToIndex,
|
||||
'teachers' => $teachersPage,
|
||||
'hasHomework' => $hasHomework,
|
||||
'hwEnteredAt' => $hwEnteredAt,
|
||||
'hasHomeworkByDate' => $hasHomeworkByDate,
|
||||
'hwEnteredAtByDate' => $hwEnteredAtByDate,
|
||||
'page' => $page,
|
||||
'totalPages' => $totalPages,
|
||||
'perPage' => $perPage,
|
||||
'totalRows' => $totalRows,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if teacher_class has any rows for the given school year.
|
||||
* The semester argument is ignored because assignments are tracked per year.
|
||||
*/
|
||||
private function hasTeacherAssignments(string $schoolYear, string $semester): bool
|
||||
{
|
||||
$year = trim((string)$schoolYear);
|
||||
if ($year === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (array_key_exists($year, $this->teacherAssignmentCache)) {
|
||||
return $this->teacherAssignmentCache[$year];
|
||||
}
|
||||
|
||||
$cnt = $this->db->table('teacher_class')
|
||||
->where('school_year', $year)
|
||||
->countAllResults();
|
||||
|
||||
$this->teacherAssignmentCache[$year] = $cnt > 0;
|
||||
return $this->teacherAssignmentCache[$year];
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute start/end dates for the given semester within the school year.
|
||||
* Fall: 09/21/{startYear} to 01/18/{startYear+1}
|
||||
* Spring: 01/25/{startYear+1} to 05/31/{startYear+1}
|
||||
* Defaults to today if parsing fails.
|
||||
*/
|
||||
private function getDateRangeForSemester(string $schoolYear, string $semester): array
|
||||
{
|
||||
$startYear = null;
|
||||
if (preg_match('/\b(20\d{2})\b/', $schoolYear, $m)) {
|
||||
$startYear = (int)$m[1];
|
||||
}
|
||||
if ($startYear === null) {
|
||||
$today = new \DateTime('today');
|
||||
return [$today, $today];
|
||||
}
|
||||
|
||||
$nextYear = $startYear + 1;
|
||||
$sem = strtolower(trim($semester));
|
||||
|
||||
$start = $end = null;
|
||||
try {
|
||||
if ($sem === 'spring') {
|
||||
$start = new \DateTime("{$nextYear}-01-25");
|
||||
$end = new \DateTime("{$nextYear}-05-31");
|
||||
} else {
|
||||
// default/fall
|
||||
$start = new \DateTime("{$startYear}-09-21");
|
||||
$end = new \DateTime("{$nextYear}-01-18");
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$today = new \DateTime('today');
|
||||
return [$today, $today];
|
||||
}
|
||||
|
||||
return [$start, $end];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use CodeIgniter\Controller;
|
||||
use App\Models\UserModel;
|
||||
use CodeIgniter\Database\Exceptions\DatabaseException;
|
||||
|
||||
class InfoIconController extends Controller
|
||||
{
|
||||
public function profileIcon()
|
||||
{
|
||||
$session = session();
|
||||
$userId = $session->get('user_id');
|
||||
|
||||
if (!$userId) {
|
||||
return view('errors/html/error_401', ['message' => 'User not logged in']);
|
||||
}
|
||||
|
||||
$userModel = new UserModel();
|
||||
$user = $userModel->getUserInfoById($userId);
|
||||
|
||||
if ($user) {
|
||||
$data = [
|
||||
'userName' => $user['firstname'] . ' ' . $user['lastname'],
|
||||
'userInitials' => strtoupper($user['firstname'][0] . $user['lastname'][0])
|
||||
];
|
||||
} else {
|
||||
$data = [
|
||||
'userName' => 'User not found',
|
||||
'userInitials' => '??'
|
||||
];
|
||||
}
|
||||
|
||||
return view('partials/header', $data);
|
||||
// return view('dashboard', ['content' => view('partials/user_icon', $data)]);
|
||||
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Models\IpAttemptModel;
|
||||
|
||||
class IpBanController extends BaseController
|
||||
{
|
||||
protected $ipAttemptModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->ipAttemptModel = new IpAttemptModel();
|
||||
helper(['url', 'form']);
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$now = utc_now();
|
||||
$status = strtolower(trim((string)($this->request->getGet('status') ?? 'all')));
|
||||
|
||||
$builder = $this->ipAttemptModel->orderBy('updated_at', 'DESC');
|
||||
if ($status === 'active') {
|
||||
$builder = $builder->where('blocked_until >', $now);
|
||||
}
|
||||
|
||||
$banned = $builder->findAll();
|
||||
|
||||
return view('administrator/ip_bans', [
|
||||
'banned' => $banned,
|
||||
'status' => $status,
|
||||
'now' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
public function unban()
|
||||
{
|
||||
$isAjax = $this->request->isAJAX();
|
||||
$json = function(array $p, int $code=200){
|
||||
$p['csrfTokenName'] = csrf_token();
|
||||
$p['csrfHash'] = csrf_hash();
|
||||
$p[csrf_token()] = csrf_hash();
|
||||
return $this->response->setStatusCode($code)->setJSON($p);
|
||||
};
|
||||
|
||||
$id = (int) $this->request->getPost('id');
|
||||
$ip = trim((string) $this->request->getPost('ip'));
|
||||
$all = (int) $this->request->getPost('all') === 1;
|
||||
|
||||
try {
|
||||
if ($all) {
|
||||
// Clear all active bans
|
||||
$now = utc_now();
|
||||
$builder = $this->ipAttemptModel->builder();
|
||||
$builder->where('blocked_until >', $now)
|
||||
->set(['blocked_until' => null, 'attempts' => 0])
|
||||
->update();
|
||||
|
||||
$cnt = $this->ipAttemptModel->db->affectedRows();
|
||||
$msg = $cnt . ' IP(s) unbanned.';
|
||||
return $isAjax ? $json(['ok'=>true,'message'=>$msg,'count'=>$cnt])
|
||||
: redirect()->to(site_url('administrator/ip_bans'))->with('success', $msg);
|
||||
}
|
||||
|
||||
$row = null;
|
||||
if ($id > 0) {
|
||||
$row = $this->ipAttemptModel->find($id);
|
||||
} elseif ($ip !== '') {
|
||||
$row = $this->ipAttemptModel->where('ip_address', $ip)->first();
|
||||
}
|
||||
|
||||
if (!$row) {
|
||||
$msg = 'IP record not found.';
|
||||
return $isAjax ? $json(['ok'=>false,'message'=>$msg],404)
|
||||
: redirect()->back()->with('error', $msg);
|
||||
}
|
||||
|
||||
$ok = $this->ipAttemptModel->update($row['id'], [
|
||||
'blocked_until' => null,
|
||||
'attempts' => 0,
|
||||
]);
|
||||
|
||||
if (!$ok) {
|
||||
throw new \RuntimeException('Failed to update record.');
|
||||
}
|
||||
|
||||
$msg = 'IP ' . $row['ip_address'] . ' unbanned.';
|
||||
return $isAjax ? $json(['ok'=>true,'message'=>$msg])
|
||||
: redirect()->to(site_url('administrator/ip_bans'))->with('success', $msg);
|
||||
} catch (\Throwable $e) {
|
||||
$msg = 'Unable to unban: ' . $e->getMessage();
|
||||
return $isAjax ? $json(['ok'=>false,'message'=>$msg],500)
|
||||
: redirect()->back()->with('error', $msg);
|
||||
}
|
||||
}
|
||||
|
||||
public function banNow()
|
||||
{
|
||||
$isAjax = $this->request->isAJAX();
|
||||
$json = function(array $p, int $code=200){
|
||||
$p['csrfTokenName'] = csrf_token();
|
||||
$p['csrfHash'] = csrf_hash();
|
||||
$p[csrf_token()] = csrf_hash();
|
||||
return $this->response->setStatusCode($code)->setJSON($p);
|
||||
};
|
||||
|
||||
// Accept both POST and GET params
|
||||
$id = (int) ($this->request->getPost('id') ?? $this->request->getGet('id') ?? 0);
|
||||
$ip = trim((string) ($this->request->getPost('ip') ?? $this->request->getGet('ip') ?? ''));
|
||||
$hours = (int) ($this->request->getPost('hours') ?? $this->request->getGet('hours') ?? 24);
|
||||
if ($hours <= 0) $hours = 24;
|
||||
|
||||
try {
|
||||
$row = null;
|
||||
if ($id > 0) {
|
||||
$row = $this->ipAttemptModel->find($id);
|
||||
} elseif ($ip !== '') {
|
||||
$row = $this->ipAttemptModel->where('ip_address', $ip)->first();
|
||||
}
|
||||
|
||||
if (!$row) {
|
||||
$msg = 'IP record not found.';
|
||||
return $isAjax ? $json(['ok'=>false,'message'=>$msg],404)
|
||||
: redirect()->to(site_url('administrator/ip_bans'))->with('error', $msg);
|
||||
}
|
||||
|
||||
$blockedUntil = date('Y-m-d H:i:s', strtotime("+{$hours} hours"));
|
||||
$ok = $this->ipAttemptModel->update($row['id'], [
|
||||
'blocked_until' => $blockedUntil,
|
||||
// Optionally set attempts to threshold for clarity
|
||||
'attempts' => max((int)$row['attempts'], 10),
|
||||
]);
|
||||
|
||||
if (!$ok) {
|
||||
throw new \RuntimeException('Failed to update record.');
|
||||
}
|
||||
|
||||
$msg = 'IP ' . $row['ip_address'] . ' banned for ' . $hours . 'h.';
|
||||
return $isAjax ? $json(['ok'=>true,'message'=>$msg])
|
||||
: redirect()->to(site_url('administrator/ip_bans'))->with('success', $msg);
|
||||
} catch (\Throwable $e) {
|
||||
$msg = 'Unable to ban: ' . $e->getMessage();
|
||||
return $isAjax ? $json(['ok'=>false,'message'=>$msg],500)
|
||||
: redirect()->back()->with('error', $msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,882 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Models\UserRoleModel;
|
||||
use App\Models\StudentClassModel;
|
||||
use App\Models\InvoiceModel;
|
||||
use App\Models\TeacherClassModel;
|
||||
use App\Models\ClassSectionModel;
|
||||
use App\Models\SemesterScoreModel;
|
||||
use App\Models\ScoreCommentModel;
|
||||
use App\Models\AttendanceRecordModel;
|
||||
use App\Models\AttendanceDayModel;
|
||||
use App\Models\CalendarModel;
|
||||
use \Config\Database;
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use App\Models\ConfigurationModel;
|
||||
|
||||
class LandingPageController extends BaseController
|
||||
{
|
||||
protected $classSectionId;
|
||||
protected $db;
|
||||
protected $schoolYear;
|
||||
protected $semester;
|
||||
protected $configModel;
|
||||
protected $lastDayOfRegistration;
|
||||
protected $refundDeadline;
|
||||
protected $studentClassModel;
|
||||
protected $invoiceModel;
|
||||
protected $userRoleModel;
|
||||
protected $teacherClassModel;
|
||||
protected $classSectionModel;
|
||||
protected $semesterScoreModel;
|
||||
protected $scoreCommentModel;
|
||||
protected $attendanceRecordModel;
|
||||
protected $attendanceDayModel;
|
||||
protected $calendarModel;
|
||||
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->db = \Config\Database::connect();
|
||||
$this->configModel = new ConfigurationModel();
|
||||
$this->studentClassModel = new StudentClassModel();
|
||||
$this->invoiceModel = new InvoiceModel();
|
||||
$this->userRoleModel = new UserRoleModel();
|
||||
$this->teacherClassModel = new TeacherClassModel();
|
||||
$this->semesterScoreModel = new SemesterScoreModel();
|
||||
$this->scoreCommentModel = new ScoreCommentModel();
|
||||
$this->attendanceRecordModel = new AttendanceRecordModel();
|
||||
$this->attendanceDayModel = new AttendanceDayModel();
|
||||
$this->classSectionModel = new ClassSectionModel();
|
||||
$this->calendarModel = new CalendarModel();
|
||||
|
||||
// Fetch Enrollment and Refund Deadlines from Configuration
|
||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||
$this->semester = $this->configModel->getConfig('semester');
|
||||
$this->lastDayOfRegistration = $this->configModel->getConfig('enrollment_deadline') ?? 'Not set';
|
||||
$this->refundDeadline = $this->configModel->getConfig('refund_deadline') ?? 'Not set';
|
||||
|
||||
// Get class_id and store it in session
|
||||
$this->classSectionId = $this->classSectionId();
|
||||
session()->set('class_section_id', $this->classSectionId);
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$userRole = $this->getUserRole(); // Assume this function gets the user role
|
||||
|
||||
switch (strtolower($userRole)) {
|
||||
case 'administrator':
|
||||
return $this->administrator();
|
||||
case 'admin':
|
||||
return $this->admin();
|
||||
case 'teacher':
|
||||
return $this->teacher();
|
||||
case 'student':
|
||||
return $this->student();
|
||||
case 'parent':
|
||||
case 'authorized_user':
|
||||
return $this->parentDashboard();
|
||||
default:
|
||||
return $this->guest();
|
||||
}
|
||||
}
|
||||
|
||||
public function classSectionId()
|
||||
{
|
||||
// Get user_id from the session
|
||||
$user_id = session()->get('user_id');
|
||||
|
||||
if (!$user_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get all class assignments for this teacher in the current term
|
||||
$assignments = $this->teacherClassModel->getClassAssignmentsByUserId(
|
||||
(int)$user_id,
|
||||
(string)$this->schoolYear,
|
||||
(string)$this->semester
|
||||
);
|
||||
|
||||
$ids = array_map(static fn($a) => (int)($a['class_section_id'] ?? 0), $assignments);
|
||||
$ids = array_values(array_filter(array_unique($ids)));
|
||||
|
||||
if (empty($ids)) {
|
||||
log_message('error', "No class section found for user ID: $user_id");
|
||||
return null;
|
||||
}
|
||||
|
||||
$current = (int)(session()->get('class_section_id') ?? 0);
|
||||
$chosen = in_array($current, $ids, true) ? $current : $ids[0];
|
||||
|
||||
session()->set([
|
||||
'class_section_id' => $chosen,
|
||||
'class_section_ids' => $ids,
|
||||
]);
|
||||
|
||||
return $chosen;
|
||||
}
|
||||
|
||||
|
||||
public function administrator()
|
||||
{
|
||||
return view('administrator/administratordashboard');
|
||||
}
|
||||
|
||||
public function admin()
|
||||
{
|
||||
return view('/landing_page/admin_dashboard');
|
||||
}
|
||||
|
||||
public function teacher()
|
||||
{
|
||||
$userId = (int)(session()->get('user_id') ?? 0);
|
||||
$assignments = $this->teacherClassModel->getClassAssignmentsByUserId(
|
||||
$userId,
|
||||
(string)$this->schoolYear,
|
||||
(string)$this->semester
|
||||
);
|
||||
|
||||
$availableIds = array_map(static fn($a) => (int)($a['class_section_id'] ?? 0), $assignments);
|
||||
$availableIds = array_values(array_filter(array_unique($availableIds)));
|
||||
|
||||
$requestedId = (int)($this->request->getGet('class_section_id') ?? 0);
|
||||
$activeId = null;
|
||||
|
||||
if ($requestedId && in_array($requestedId, $availableIds, true)) {
|
||||
$activeId = $requestedId;
|
||||
} elseif (!empty($availableIds)) {
|
||||
$activeId = in_array((int)$this->classSectionId, $availableIds, true)
|
||||
? (int)$this->classSectionId
|
||||
: $availableIds[0];
|
||||
}
|
||||
|
||||
if ($activeId) {
|
||||
session()->set('class_section_id', $activeId);
|
||||
}
|
||||
|
||||
$activeName = null;
|
||||
foreach ($assignments as $a) {
|
||||
if ((int)$a['class_section_id'] === (int)$activeId) {
|
||||
$activeName = $a['class_section_name'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$normalizedSemester = ucfirst(strtolower(trim((string)($this->semester ?? ''))));
|
||||
$classSectionIds = array_values(array_filter(array_unique(array_map(static fn($a) => (int)($a['class_section_id'] ?? 0), $assignments)), static fn($id) => $id > 0));
|
||||
$dashboardSummary = $this->buildTeacherDashboardSummary($classSectionIds, $normalizedSemester);
|
||||
|
||||
return view('/landing_page/teacher_dashboard', [
|
||||
'class_section_id' => $activeId,
|
||||
'active_class_name' => $activeName,
|
||||
'classes' => $assignments,
|
||||
'jobs' => $assignments,
|
||||
'jobCount' => count($assignments),
|
||||
'scoreSummary' => $dashboardSummary['scoreSummary'],
|
||||
'commentSummary' => $dashboardSummary['commentSummary'],
|
||||
'attendanceSummary' => $dashboardSummary['attendanceSummary'],
|
||||
'deadlineEvents' => $dashboardSummary['deadlineEvents'],
|
||||
'participationSummary' => $dashboardSummary['participationSummary'],
|
||||
'attendanceStatus' => $dashboardSummary['attendanceStatus'],
|
||||
'dashboardNotifications' => $dashboardSummary['notifications'],
|
||||
'school_year' => (string)$this->schoolYear,
|
||||
'semester' => (string)$this->semester,
|
||||
]);
|
||||
}
|
||||
|
||||
private function buildTeacherDashboardSummary(array $classSectionIds, string $semester): array
|
||||
{
|
||||
$normalizedSemester = ucfirst(strtolower(trim((string)$semester)));
|
||||
if ($normalizedSemester === '' || !in_array($normalizedSemester, ['Fall', 'Spring'], true)) {
|
||||
$normalizedSemester = 'Fall';
|
||||
}
|
||||
|
||||
$scoreField = ($normalizedSemester === 'Spring') ? 'final_exam_score' : 'midterm_exam_score';
|
||||
$scoreLabel = ($normalizedSemester === 'Spring') ? 'Final Exam' : 'Midterm';
|
||||
|
||||
$summary = [
|
||||
'scoreSummary' => [
|
||||
'completionPct' => 0,
|
||||
'missing' => 0,
|
||||
'expected' => 0,
|
||||
'filled' => 0,
|
||||
'fieldLabel' => $scoreLabel,
|
||||
],
|
||||
'commentSummary' => [
|
||||
'total' => 0,
|
||||
'pendingReview' => 0,
|
||||
'reviewed' => 0,
|
||||
'byType' => [],
|
||||
],
|
||||
'attendanceSummary' => [
|
||||
'recorded' => 0,
|
||||
'students' => 0,
|
||||
'completionPct' => 0,
|
||||
'avgAbsences' => 0,
|
||||
],
|
||||
'participationSummary' => [
|
||||
'filled' => 0,
|
||||
'missing' => 0,
|
||||
'completionPct' => 0,
|
||||
'expected' => 0,
|
||||
],
|
||||
'deadlineEvents' => [],
|
||||
];
|
||||
|
||||
if (!empty($classSectionIds)) {
|
||||
$studentRows = $this->db->table('student_class sc')
|
||||
->select('sc.student_id, sc.class_section_id')
|
||||
->join('students s', 's.id = sc.student_id', 'inner')
|
||||
->whereIn('sc.class_section_id', $classSectionIds)
|
||||
->where('s.is_active', 1)
|
||||
->where('sc.school_year', (string)$this->schoolYear)
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
$studentIds = [];
|
||||
foreach ($studentRows as $row) {
|
||||
$studentId = (int)($row['student_id'] ?? 0);
|
||||
$sectionId = (int)($row['class_section_id'] ?? 0);
|
||||
if ($studentId <= 0 || $sectionId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$studentIds[] = $studentId;
|
||||
}
|
||||
$uniqueStudentIds = array_values(array_unique($studentIds));
|
||||
} else {
|
||||
$uniqueStudentIds = [];
|
||||
}
|
||||
|
||||
$totalStudents = count($uniqueStudentIds);
|
||||
$summary['attendanceSummary']['students'] = $totalStudents;
|
||||
|
||||
$scoreRows = [];
|
||||
if (!empty($classSectionIds)) {
|
||||
$scoreRows = $this->semesterScoreModel
|
||||
->select('student_id, class_section_id, midterm_exam_score, final_exam_score, participation_score')
|
||||
->whereIn('class_section_id', $classSectionIds)
|
||||
->where('semester', $normalizedSemester)
|
||||
->where('school_year', (string)$this->schoolYear)
|
||||
->findAll();
|
||||
}
|
||||
|
||||
$scoreTypesConfig = [
|
||||
'participation' => ['label' => 'Participation', 'field' => 'participation_score'],
|
||||
];
|
||||
if ($normalizedSemester === 'Fall') {
|
||||
$scoreTypesConfig = array_merge([
|
||||
'midterm' => ['label' => 'Midterm', 'field' => 'midterm_exam_score'],
|
||||
], $scoreTypesConfig);
|
||||
}
|
||||
if ($normalizedSemester === 'Spring') {
|
||||
$scoreTypesConfig = array_merge([
|
||||
'final' => ['label' => 'Final exam', 'field' => 'final_exam_score'],
|
||||
], $scoreTypesConfig);
|
||||
}
|
||||
|
||||
$filledCounts = array_fill_keys(array_keys($scoreTypesConfig), 0);
|
||||
foreach ($scoreRows as $row) {
|
||||
foreach ($scoreTypesConfig as $key => $cfg) {
|
||||
$value = trim((string)($row[$cfg['field']] ?? ''));
|
||||
if ($value !== '') {
|
||||
$filledCounts[$key]++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$expectedScoreCount = $totalStudents > 0 ? $totalStudents : count($scoreRows);
|
||||
$scoreDetails = [];
|
||||
foreach ($scoreTypesConfig as $key => $cfg) {
|
||||
$filled = $filledCounts[$key] ?? 0;
|
||||
$missing = max(0, $expectedScoreCount - $filled);
|
||||
$completionPct = $expectedScoreCount > 0 ? (int)round(min(100, ($filled / $expectedScoreCount) * 100)) : 0;
|
||||
$scoreDetails[$key] = [
|
||||
'label' => $cfg['label'],
|
||||
'field' => $cfg['field'],
|
||||
'filled' => $filled,
|
||||
'missing' => $missing,
|
||||
'expected' => $expectedScoreCount,
|
||||
'completionPct' => $completionPct,
|
||||
];
|
||||
}
|
||||
|
||||
$scoreTypeByField = [];
|
||||
if ($normalizedSemester === 'Fall') {
|
||||
$scoreTypeByField['midterm_exam_score'] = 'midterm';
|
||||
}
|
||||
if ($normalizedSemester === 'Spring') {
|
||||
$scoreTypeByField['final_exam_score'] = 'final';
|
||||
}
|
||||
$activeScoreType = $scoreTypeByField[$scoreField] ?? 'midterm';
|
||||
$activeScoreDetails = $scoreDetails[$activeScoreType] ?? $scoreDetails['midterm'];
|
||||
|
||||
$summary['scoreSummary'] = [
|
||||
'completionPct' => $activeScoreDetails['completionPct'],
|
||||
'missing' => $activeScoreDetails['missing'],
|
||||
'expected' => $activeScoreDetails['expected'],
|
||||
'filled' => $activeScoreDetails['filled'],
|
||||
'fieldLabel' => $scoreLabel,
|
||||
'types' => $scoreDetails,
|
||||
];
|
||||
|
||||
$participationMetrics = $scoreDetails['participation'];
|
||||
$summary['participationSummary'] = [
|
||||
'filled' => $participationMetrics['filled'],
|
||||
'missing' => $participationMetrics['missing'],
|
||||
'completionPct' => $participationMetrics['completionPct'],
|
||||
'expected' => $participationMetrics['expected'],
|
||||
];
|
||||
|
||||
if (!empty($uniqueStudentIds)) {
|
||||
$commentRows = $this->scoreCommentModel
|
||||
->select('student_id, score_type, comment, comment_review')
|
||||
->whereIn('student_id', $uniqueStudentIds)
|
||||
->where('semester', $normalizedSemester)
|
||||
->where('school_year', (string)$this->schoolYear)
|
||||
->findAll();
|
||||
} else {
|
||||
$commentRows = [];
|
||||
}
|
||||
|
||||
$expectedCommentTypes = array_values(array_filter([
|
||||
'ptap',
|
||||
$normalizedSemester === 'Fall' ? 'midterm' : null,
|
||||
$normalizedSemester === 'Spring' ? 'final' : null,
|
||||
]));
|
||||
$commentStats = [];
|
||||
$commentsByStudent = [];
|
||||
|
||||
foreach ($commentRows as $row) {
|
||||
$sid = (int)($row['student_id'] ?? 0);
|
||||
$type = strtolower(trim((string)($row['score_type'] ?? '')));
|
||||
if ($type === '') {
|
||||
$type = 'general';
|
||||
}
|
||||
if (!isset($commentStats[$type])) {
|
||||
$commentStats[$type] = ['pending' => 0, 'reviewed' => 0];
|
||||
}
|
||||
|
||||
$commentText = trim((string)($row['comment'] ?? ''));
|
||||
$reviewValue = trim((string)($row['comment_review'] ?? ''));
|
||||
|
||||
if ($sid > 0 && $commentText !== '') {
|
||||
$commentsByStudent[$sid][$type] = $commentText;
|
||||
}
|
||||
|
||||
if ($commentText === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($reviewValue === '') {
|
||||
$commentStats[$type]['pending']++;
|
||||
} else {
|
||||
$commentStats[$type]['reviewed']++;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($expectedCommentTypes as $type) {
|
||||
if (!isset($commentStats[$type])) {
|
||||
$commentStats[$type] = ['pending' => 0, 'reviewed' => 0];
|
||||
}
|
||||
}
|
||||
|
||||
$missingComments = 0;
|
||||
$missingDetail = [];
|
||||
foreach ($uniqueStudentIds as $sid) {
|
||||
foreach ($expectedCommentTypes as $type) {
|
||||
$text = $commentsByStudent[$sid][$type] ?? '';
|
||||
if ($text === '') {
|
||||
$missingDetail[$type] = ($missingDetail[$type] ?? 0) + 1;
|
||||
$missingComments++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$commentTypes = [];
|
||||
foreach ($expectedCommentTypes as $type) {
|
||||
$stats = $commentStats[$type];
|
||||
$filled = $stats['pending'] + $stats['reviewed'];
|
||||
$completionPct = $totalStudents > 0 ? (int)round(min(100, ($filled / $totalStudents) * 100)) : 0;
|
||||
|
||||
$commentTypes[$type] = [
|
||||
'label' => ucfirst($type) . ' comments',
|
||||
'pending' => $stats['pending'],
|
||||
'reviewed' => $stats['reviewed'],
|
||||
'filled' => $filled,
|
||||
'missing' => $missingDetail[$type] ?? 0,
|
||||
'expected' => $totalStudents,
|
||||
'completionPct' => $completionPct,
|
||||
];
|
||||
}
|
||||
|
||||
$totalPending = array_sum(array_column($commentTypes, 'pending'));
|
||||
$totalReviewed = array_sum(array_column($commentTypes, 'reviewed'));
|
||||
$totalFilled = array_sum(array_column($commentTypes, 'filled'));
|
||||
$totalExpected = array_sum(array_column($commentTypes, 'expected'));
|
||||
$commentCompletionPct = $totalExpected > 0 ? (int)round(min(100, ($totalFilled / $totalExpected) * 100)) : 0;
|
||||
|
||||
$summary['commentSummary'] = [
|
||||
'total' => $totalFilled,
|
||||
'pendingReview' => $totalPending,
|
||||
'reviewed' => $totalReviewed,
|
||||
'missing' => $missingComments,
|
||||
'missingDetail' => $missingDetail,
|
||||
'completionPct' => $commentCompletionPct,
|
||||
'types' => $commentTypes,
|
||||
];
|
||||
|
||||
$attendanceRows = [];
|
||||
if (!empty($classSectionIds)) {
|
||||
$attendanceRows = $this->attendanceRecordModel
|
||||
->select('student_id, class_section_id, total_absence')
|
||||
->whereIn('class_section_id', $classSectionIds)
|
||||
->where('semester', $normalizedSemester)
|
||||
->where('school_year', (string)$this->schoolYear)
|
||||
->findAll();
|
||||
}
|
||||
|
||||
$attendanceSet = [];
|
||||
$totalAbsences = 0;
|
||||
foreach ($attendanceRows as $record) {
|
||||
$studentId = (int)($record['student_id'] ?? 0);
|
||||
$sectionId = (int)($record['class_section_id'] ?? 0);
|
||||
if ($studentId <= 0 || $sectionId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$key = "{$sectionId}:{$studentId}";
|
||||
$attendanceSet[$key] = true;
|
||||
$totalAbsences += (int)($record['total_absence'] ?? 0);
|
||||
}
|
||||
|
||||
$attendanceRecorded = count($attendanceSet);
|
||||
$attendancePct = $totalStudents > 0 ? (int)round(min(100, ($attendanceRecorded / $totalStudents) * 100)) : 0;
|
||||
$avgAbsence = $attendanceRecorded > 0 ? round($totalAbsences / $attendanceRecorded, 1) : 0;
|
||||
|
||||
$summary['attendanceSummary'] = [
|
||||
'recorded' => $attendanceRecorded,
|
||||
'students' => $totalStudents,
|
||||
'completionPct' => $attendancePct,
|
||||
'avgAbsences' => $avgAbsence,
|
||||
];
|
||||
$timezone = new DateTimeZone(date_default_timezone_get() ?: 'UTC');
|
||||
$today = (new DateTimeImmutable('now', $timezone))->format('Y-m-d');
|
||||
|
||||
$attendanceStatus = $this->buildAttendanceSubmissionStatus($classSectionIds, $normalizedSemester, $today);
|
||||
|
||||
$summary['deadlineEvents'] = $this->calendarModel
|
||||
->where('notify_teacher', 1)
|
||||
->where('school_year', (string)$this->schoolYear)
|
||||
->where('semester', $normalizedSemester)
|
||||
->where('date >=', $today)
|
||||
->orderBy('date', 'ASC')
|
||||
->limit(5)
|
||||
->findAll();
|
||||
|
||||
$summary['attendanceStatus'] = $attendanceStatus;
|
||||
if ($attendanceStatus['submitted'] === false && !empty($classSectionIds)) {
|
||||
$summary['attendanceSummary']['completionPct'] = 0;
|
||||
$summary['attendanceSummary']['recorded'] = 0;
|
||||
}
|
||||
|
||||
$summary['notifications'] = $this->buildDashboardNotifications($summary);
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
private function buildDashboardNotifications(array $summary): array
|
||||
{
|
||||
$notifications = [];
|
||||
$baseScoreLink = base_url('/teacher/scores');
|
||||
$buildScoreLink = static function (?string $focus) use ($baseScoreLink) {
|
||||
if (empty($focus)) {
|
||||
return $baseScoreLink;
|
||||
}
|
||||
return $baseScoreLink . '?focus=' . urlencode($focus);
|
||||
};
|
||||
$determineFocus = static function (array $event): string {
|
||||
$eventTypeText = strtolower(trim((string)($event['event_type'] ?? '')));
|
||||
if ($eventTypeText !== '' && str_contains($eventTypeText, 'draft')) {
|
||||
return 'comments';
|
||||
}
|
||||
$text = strtolower(trim(($event['title'] ?? '') . ' ' . ($event['description'] ?? '')));
|
||||
if ($text === '') {
|
||||
return 'scores';
|
||||
}
|
||||
if (str_contains($text, 'comment') || str_contains($text, 'ptap')) {
|
||||
return 'comments';
|
||||
}
|
||||
return 'scores';
|
||||
};
|
||||
$calendarLink = base_url('/teacher/calendar');
|
||||
$attendanceLink = base_url('/teacher/showupdate_attendance');
|
||||
$timezone = new DateTimeZone(date_default_timezone_get() ?: 'UTC');
|
||||
$todayDate = (new DateTimeImmutable('now', $timezone))->setTime(0, 0);
|
||||
$today = $todayDate->format('Y-m-d');
|
||||
|
||||
$calendarNotificationConfig = [
|
||||
'1st Semester Scores' => 14,
|
||||
'2nd Semester Scores' => 14,
|
||||
'Final' => 14,
|
||||
'Final Exam Draft' => 42,
|
||||
'Midterm' => 14,
|
||||
'Midterm Exam Draft' => 42,
|
||||
];
|
||||
|
||||
$activeDeadline = false;
|
||||
foreach ($summary['deadlineEvents'] ?? [] as $event) {
|
||||
$eventTypeRaw = trim((string)($event['event_type'] ?? ''));
|
||||
if ($eventTypeRaw === '') {
|
||||
continue;
|
||||
}
|
||||
$matchedType = null;
|
||||
$thresholdDays = null;
|
||||
foreach ($calendarNotificationConfig as $type => $days) {
|
||||
if (strcasecmp($eventTypeRaw, $type) === 0) {
|
||||
$matchedType = $type;
|
||||
$thresholdDays = $days;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($matchedType === null || $thresholdDays === null) {
|
||||
continue;
|
||||
}
|
||||
$rawDate = $event['date'] ?? '';
|
||||
if ($rawDate === '') {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$eventDateTime = new DateTimeImmutable($rawDate, $timezone);
|
||||
} catch (\Exception $e) {
|
||||
continue;
|
||||
}
|
||||
$eventDateTime = $eventDateTime->setTime(0, 0);
|
||||
$daysUntil = (int)$todayDate->diff($eventDateTime)->format('%r%a');
|
||||
|
||||
if ($daysUntil <= 0) {
|
||||
$activeDeadline = true;
|
||||
$eventFocus = $determineFocus($event);
|
||||
$notifications[] = [
|
||||
'type' => 'danger',
|
||||
'message' => sprintf(
|
||||
"Deadline today: %s is due. Submit the remaining entries before the day ends.",
|
||||
$matchedType
|
||||
),
|
||||
'link' => $buildScoreLink($eventFocus),
|
||||
'linkText' => 'Submit now',
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($daysUntil > $thresholdDays) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$relativeText = $daysUntil === 1 ? ' (tomorrow)' : " (in {$daysUntil} days)";
|
||||
$notifications[] = [
|
||||
'type' => 'warning',
|
||||
'message' => sprintf(
|
||||
"%s is due on %s%s. Review it on the calendar so you don’t miss it.",
|
||||
$matchedType,
|
||||
$eventDateTime->format('M j, Y'),
|
||||
$relativeText
|
||||
),
|
||||
'link' => $calendarLink,
|
||||
'linkText' => 'View deadline',
|
||||
];
|
||||
}
|
||||
|
||||
if (!$activeDeadline) {
|
||||
$scoreLabel = $summary['scoreSummary']['fieldLabel'] ?? 'Score';
|
||||
if (!empty($summary['scoreSummary']['missing'] ?? 0)) {
|
||||
$notifications[] = [
|
||||
'type' => 'warning',
|
||||
'message' => sprintf(
|
||||
"You have %s %s entries missing on the scores page. Please finish submitting them.",
|
||||
(int)$summary['scoreSummary']['missing'],
|
||||
$scoreLabel
|
||||
),
|
||||
'link' => $buildScoreLink('scores'),
|
||||
'linkText' => 'Score sheet',
|
||||
];
|
||||
}
|
||||
|
||||
foreach (['midterm', 'final', 'ptap'] as $type) {
|
||||
$count = (int)($summary['commentSummary']['missingDetail'][$type] ?? 0);
|
||||
if ($count <= 0) {
|
||||
continue;
|
||||
}
|
||||
$notifications[] = [
|
||||
'type' => 'warning',
|
||||
'message' => sprintf(
|
||||
"%s comment missing for %s student(s). Leave comments on the scores page.",
|
||||
ucfirst($type),
|
||||
$count
|
||||
),
|
||||
'link' => $buildScoreLink('comments'),
|
||||
'linkText' => ucfirst($type) . ' comments',
|
||||
];
|
||||
}
|
||||
|
||||
if (!empty($summary['participationSummary']['missing'] ?? 0)) {
|
||||
$notifications[] = [
|
||||
'type' => 'warning',
|
||||
'message' => sprintf(
|
||||
"Participation entries are still missing for %s student(s). Please add them from the scores page.",
|
||||
(int)$summary['participationSummary']['missing']
|
||||
),
|
||||
'link' => $buildScoreLink('scores'),
|
||||
'linkText' => 'Participation',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($summary['attendanceStatus']['missingSections'] ?? [])) {
|
||||
$missingNames = $summary['attendanceStatus']['missingNames'] ?? [];
|
||||
$label = '';
|
||||
if (!empty($missingNames)) {
|
||||
$label = ' (' . implode(', ', array_slice($missingNames, 0, 3)) . (count($missingNames) > 3 ? '…' : '') . ')';
|
||||
}
|
||||
$notifications[] = [
|
||||
'type' => 'danger',
|
||||
'message' => "Today's attendance is still unsubmitted for {$summary['attendanceStatus']['date']}{$label}. Please submit it now.",
|
||||
'link' => $attendanceLink,
|
||||
'linkText' => 'Record attendance',
|
||||
];
|
||||
}
|
||||
|
||||
return $notifications;
|
||||
}
|
||||
|
||||
private function buildAttendanceSubmissionStatus(array $classSectionIds, string $semester, string $date): array
|
||||
{
|
||||
if (empty($classSectionIds)) {
|
||||
return [
|
||||
'date' => $date,
|
||||
'missingSections' => [],
|
||||
'missingNames' => [],
|
||||
'submitted' => true,
|
||||
];
|
||||
}
|
||||
|
||||
$rows = $this->attendanceDayModel
|
||||
->select('class_section_id, status')
|
||||
->whereIn('class_section_id', $classSectionIds)
|
||||
->where('date', $date)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', (string)$this->schoolYear)
|
||||
->findAll();
|
||||
|
||||
$statusMap = [];
|
||||
foreach ($rows as $row) {
|
||||
$csId = (int)($row['class_section_id'] ?? 0);
|
||||
if ($csId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$statusMap[$csId] = strtolower(trim((string)($row['status'] ?? '')));
|
||||
}
|
||||
|
||||
$missingSections = [];
|
||||
foreach ($classSectionIds as $classSectionId) {
|
||||
$status = $statusMap[$classSectionId] ?? '';
|
||||
if (!in_array($status, ['submitted', 'published', 'finalized'], true)) {
|
||||
$missingSections[] = $classSectionId;
|
||||
}
|
||||
}
|
||||
|
||||
$missingNames = [];
|
||||
if (!empty($missingSections)) {
|
||||
$sectionRows = $this->classSectionModel
|
||||
->select('class_section_id, class_section_name')
|
||||
->whereIn('class_section_id', $missingSections)
|
||||
->findAll();
|
||||
foreach ($sectionRows as $row) {
|
||||
$name = trim((string)($row['class_section_name'] ?? ''));
|
||||
if ($name === '') {
|
||||
$name = (string)($row['class_section_id'] ?? '');
|
||||
}
|
||||
$missingNames[] = $name;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'date' => $date,
|
||||
'missingSections' => $missingSections,
|
||||
'missingNames' => $missingNames,
|
||||
'submitted' => empty($missingSections),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
public function student()
|
||||
{
|
||||
return view('/landing_page/student_dashboard');
|
||||
}
|
||||
|
||||
public function parentDashboard()
|
||||
{
|
||||
$parentId = session()->get('user_id');
|
||||
$userType = $_SESSION['user_type'];
|
||||
|
||||
// Map user type to roles
|
||||
if ($userType === 'primary') {
|
||||
// If user type is 'Primary', they are the firstparent
|
||||
$parentId = $parentId;
|
||||
} elseif ($userType === 'secondary') {
|
||||
// If user type is 'Secondary', find the parent_id from the parents table
|
||||
$parentData = $this->db->table('parents')
|
||||
->select('parent_id')
|
||||
->where('secondparent_user_id', $parentId)
|
||||
->get()
|
||||
->getRowArray();
|
||||
|
||||
if ($parentData) {
|
||||
$parentId = $parentData['parent_id'];
|
||||
}
|
||||
} elseif ($userType === 'tertiary') {
|
||||
// If user type is 'Tertiary', find the parent_id from the authorized_users table
|
||||
$authUserData = $this->db->table('authorized_users')
|
||||
->select('user_id as parent_id')
|
||||
->where('authorized_user_id', $parentId)
|
||||
->get()
|
||||
->getRowArray();
|
||||
|
||||
if ($authUserData) {
|
||||
$parentId = $authUserData['parent_id'];
|
||||
}
|
||||
}
|
||||
|
||||
// If no firstparent ID is found, show an error or redirect
|
||||
if (!$parentId) {
|
||||
return redirect()->back()->with(
|
||||
'error',
|
||||
'Unable to retrieve student data. Please contact support.'
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Fetch Notifications (only active, non-expired, non-deleted)
|
||||
$notifications = $this->db->table('notifications')
|
||||
->select([
|
||||
'notifications.id',
|
||||
'notifications.title',
|
||||
'notifications.message',
|
||||
'notifications.target_group',
|
||||
'notifications.created_at',
|
||||
'notifications.expires_at',
|
||||
'user_notifications.user_id',
|
||||
"CASE
|
||||
WHEN user_notifications.user_id IS NOT NULL THEN 'personal'
|
||||
ELSE 'broadcast'
|
||||
END as notification_type"
|
||||
])
|
||||
->join(
|
||||
'user_notifications',
|
||||
'user_notifications.notification_id = notifications.id AND user_notifications.user_id = ' . (int) $parentId,
|
||||
'left'
|
||||
)
|
||||
->groupStart()
|
||||
->where('notifications.target_group', 'parent')
|
||||
->orWhere('user_notifications.user_id', $parentId)
|
||||
->groupEnd()
|
||||
->where('notifications.deleted_at IS NULL') // Exclude soft-deleted notifications
|
||||
->groupStart()
|
||||
->where('notifications.expires_at IS NULL')
|
||||
->orWhere('notifications.expires_at > NOW()') // Exclude expired
|
||||
->groupEnd()
|
||||
->orderBy('notifications.created_at', 'DESC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
// Fetch Student Information (no filtering needed by school year or semester)
|
||||
|
||||
$students = $this->db->table('students')
|
||||
->where('parent_id', $parentId)
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
foreach ($students as &$student) {
|
||||
// Get class_section_name and treat it as grade
|
||||
$classSection = $this->studentClassModel->getClassSectionsByStudentId($student['id'], $this->schoolYear);
|
||||
$student['class_section'] = $classSection;
|
||||
$student['grade'] = $classSection; // grade same as section
|
||||
}
|
||||
unset($student);
|
||||
|
||||
|
||||
// Fetch Attendance Records (filtered by most recent school year and semester)
|
||||
$attendanceData = $this->db->table('attendance_data')
|
||||
->select('attendance_data.*, students.firstname, students.lastname')
|
||||
->join('students', 'students.id = attendance_data.student_id')
|
||||
->where('students.parent_id', $parentId)
|
||||
// ->orWhere('students.secondparent_user_id', $parentId)
|
||||
->where('attendance_data.school_year', $this->schoolYear)
|
||||
->where('attendance_data.semester', $this->semester)
|
||||
->orderBy('attendance_data.date', 'DESC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
// Fetch Grades (filtered by most recent school year and semester)
|
||||
$grades = $this->db->table('final_score') // Correct table name
|
||||
->select('final_score.*, students.firstname, students.lastname') // Ensure table name is correct
|
||||
->join('students', 'students.id = final_score.student_id')
|
||||
->where('students.parent_id', $parentId)
|
||||
// ->orWhere('students.secondparent_user_id', $parentId)
|
||||
->where('final_score.school_year', $this->schoolYear) // Ensure correct table column names
|
||||
->where('final_score.semester', $this->semester)
|
||||
->orderBy('final_score.created_at', 'DESC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
// Fetch Enrollments (filtered by most recent school year and semester)
|
||||
$enrollments = $this->db->table('enrollments')
|
||||
->select('enrollments.*, classes.class_name')
|
||||
->join('students', 'students.id = enrollments.student_id')
|
||||
->join('classes', 'classes.id = enrollments.class_section_id') // Ensure this join is correct
|
||||
->where('students.parent_id', $parentId)
|
||||
// ->orWhere('students.secondparent_user_id', $parentId)
|
||||
->where('enrollments.school_year', $this->schoolYear)
|
||||
->where('enrollments.semester', $this->semester)
|
||||
->orderBy('enrollments.enrollment_date', 'DESC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
// Fetch latest invoice balance
|
||||
$paymentBalance = $this->invoiceModel->getLatestInvoiceTotalAmount($parentId);
|
||||
|
||||
// Pass data to the view, including the deadlines
|
||||
return view('/landing_page/parent_dashboard', [
|
||||
'notifications' => $notifications,
|
||||
'students' => $students,
|
||||
'attendance' => $attendanceData,
|
||||
'grades' => $grades,
|
||||
'enrollments' => $enrollments,
|
||||
'lastDayOfRegistration' => $this->lastDayOfRegistration, // Add the enrollment deadline to the view
|
||||
'withdrawalDeadline' => $this->refundDeadline, // Add the refund deadline to the view
|
||||
'paymentBalance' => $paymentBalance, // 🔹 New
|
||||
]);
|
||||
}
|
||||
|
||||
public function guest()
|
||||
{
|
||||
return view('/landing_page/guest_dashboard');
|
||||
}
|
||||
|
||||
protected function getUserRole()
|
||||
{
|
||||
// Assuming you have a session variable storing the user role
|
||||
return session()->get('user_role', 'guest'); // Default to guest if not set
|
||||
}
|
||||
|
||||
protected function getUserRoleFromDatabase($user_id)
|
||||
{
|
||||
// Fetching user role from the database
|
||||
$role = $this->userRoleModel->getRoleByUserId($user_id);
|
||||
|
||||
return $role ?? 'guest';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Models\LateSlipLogModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
|
||||
class LateSlipLogsController extends BaseController
|
||||
{
|
||||
private LateSlipLogModel $logModel;
|
||||
private ConfigurationModel $configModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->logModel = new LateSlipLogModel();
|
||||
$this->configModel = new ConfigurationModel();
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$req = $this->request;
|
||||
|
||||
$defaultYear = (string) ($this->configModel->getConfig('school_year') ?? '');
|
||||
$defaultSem = (string) ($this->configModel->getConfig('semester') ?? '');
|
||||
$schoolYear = trim((string) ($req->getGet('school_year') ?? $defaultYear));
|
||||
$semester = trim((string) ($req->getGet('semester') ?? $defaultSem));
|
||||
$q = trim((string) ($req->getGet('q') ?? ''));
|
||||
$dateFrom = trim((string) ($req->getGet('date_from') ?? ''));
|
||||
$dateTo = trim((string) ($req->getGet('date_to') ?? ''));
|
||||
|
||||
// Normalize dates to Y-m-d for filtering
|
||||
$toDbDate = static function (?string $s): ?string {
|
||||
$s = trim((string) $s);
|
||||
if ($s === '') return null;
|
||||
$ts = strtotime($s);
|
||||
return $ts ? date('Y-m-d', $ts) : null;
|
||||
};
|
||||
$df = $toDbDate($dateFrom);
|
||||
$dt = $toDbDate($dateTo);
|
||||
|
||||
$model = $this->logModel;
|
||||
if ($schoolYear !== '') $model = $model->where('school_year', $schoolYear);
|
||||
if ($semester !== '') $model = $model->where('semester', $semester);
|
||||
if ($q !== '') $model = $model->like('student_name', $q);
|
||||
if ($df) $model = $model->where('slip_date >=', $df);
|
||||
if ($dt) $model = $model->where('slip_date <=', $dt);
|
||||
|
||||
// Limit to most recent 200 entries
|
||||
$logs = $model->orderBy('id', 'DESC')->findAll(200);
|
||||
|
||||
return view('administrator/late_slip_logs', [
|
||||
'schoolYear' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
'filters' => [
|
||||
'q' => $q,
|
||||
'date_from' => $dateFrom,
|
||||
'date_to' => $dateTo,
|
||||
],
|
||||
'logs' => $logs,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Models\UserModel;
|
||||
use App\Models\StudentModel;
|
||||
use App\Models\TeacherClassModel;
|
||||
use App\Models\StudentClassModel;
|
||||
use App\Models\UserRoleModel;
|
||||
use App\Models\MessageModel;
|
||||
use App\Controllers\BaseController;
|
||||
|
||||
class MessagesController extends BaseController
|
||||
{
|
||||
protected $messageModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
//$this->messageModel = new MessageModel();
|
||||
}
|
||||
|
||||
public function inbox()
|
||||
{
|
||||
$userId = session()->get('user_id');
|
||||
$receivedMessages = $this->messageModel->getInboxMessages($userId);
|
||||
|
||||
return view('messages/inbox', [
|
||||
'receivedMessages' => $receivedMessages
|
||||
]);
|
||||
}
|
||||
|
||||
public function sent()
|
||||
{
|
||||
$userId = session()->get('user_id');
|
||||
$sentMessages = $this->messageModel->getSentMessages($userId);
|
||||
|
||||
return view('messages/sent', [
|
||||
'sentMessages' => $sentMessages
|
||||
]);
|
||||
}
|
||||
|
||||
public function drafts()
|
||||
{
|
||||
$userId = session()->get('user_id');
|
||||
$draftMessages = $this->messageModel->getDraftMessages($userId);
|
||||
|
||||
return view('messages/drafts', [
|
||||
'draftMessages' => $draftMessages
|
||||
]);
|
||||
}
|
||||
|
||||
public function trash()
|
||||
{
|
||||
$userId = session()->get('user_id');
|
||||
$trashedMessages = $this->messageModel->getTrashedMessages($userId);
|
||||
|
||||
return view('messages/trash', [
|
||||
'trashedMessages' => $trashedMessages
|
||||
]);
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$userRoleModel = new UserRoleModel();
|
||||
//$role = session()->get('role');
|
||||
$userId = session()->get('user_id');
|
||||
// Fetch the user role from the user_roles and roles tables
|
||||
$role = $userRoleModel->select('roles.name')
|
||||
->join('roles', 'roles.id = user_roles.role_id')
|
||||
->where('user_roles.user_id', $userId)
|
||||
->get()
|
||||
->getRowArray();
|
||||
|
||||
// Fetch received messages based on role
|
||||
$db = \Config\Database::connect();
|
||||
$builder = $db->table('messages');
|
||||
$builder->select('messages.*, users.firstname AS sender_name');
|
||||
$builder->join('users', 'messages.sender_id = users.id');
|
||||
$builder->where('messages.recipient_id', $userId);
|
||||
$query = $builder->get();
|
||||
$receivedMessages = $query->getResultArray();
|
||||
|
||||
return view('/messages', [
|
||||
'receivedMessages' => $receivedMessages,
|
||||
'role' => $role['name']
|
||||
]);
|
||||
}
|
||||
|
||||
public function send()
|
||||
{
|
||||
$db = \Config\Database::connect();
|
||||
$userRoleModel = new UserRoleModel();
|
||||
//$role = session()->get('role');
|
||||
$userId = session()->get('user_id');
|
||||
// Fetch the user role from the user_roles and roles tables
|
||||
$role = $userRoleModel->select('roles.name')
|
||||
->join('roles', 'roles.id = user_roles.role_id')
|
||||
->where('user_roles.user_id', $userId)
|
||||
->get()
|
||||
->getRowArray();
|
||||
|
||||
// Handle file upload (if any)
|
||||
$attachmentPath = null;
|
||||
$file = $this->request->getFile('attachment');
|
||||
if ($file && $file->isValid() && !$file->hasMoved()) {
|
||||
$attachmentPath = $file->store();
|
||||
}
|
||||
|
||||
// Determine recipient based on role or input
|
||||
$recipientId = $this->getRecipientId($role);
|
||||
|
||||
// Prepare data for insertion
|
||||
$data = [
|
||||
'sender_id' => $userId,
|
||||
'recipient_id' => $recipientId,
|
||||
'subject' => $this->request->getPost('subject'),
|
||||
'message' => $this->request->getPost('message'),
|
||||
'sent_datetime' => utc_now(),
|
||||
'message_number' => $this->generateMessageNumber(),
|
||||
'priority' => $this->request->getPost('priority') ?: 'normal',
|
||||
'attachment' => $attachmentPath,
|
||||
'status' => 'sent'
|
||||
];
|
||||
|
||||
$builder = $db->table('messages');
|
||||
$builder->insert($data);
|
||||
|
||||
return redirect()->to(base_url('messages'))->with('status', 'Message sent successfully');
|
||||
}
|
||||
|
||||
public function receive()
|
||||
{
|
||||
$userId = session()->get('user_id');
|
||||
|
||||
$db = \Config\Database::connect();
|
||||
$builder = $db->table('messages');
|
||||
$builder->select('messages.*, users.firstname AS sender_name');
|
||||
$builder->join('users', 'messages.sender_id = users.id');
|
||||
$builder->where('messages.recipient_id', $userId);
|
||||
$builder->orderBy('sent_datetime', 'DESC');
|
||||
$query = $builder->get();
|
||||
$receivedMessages = $query->getResultArray();
|
||||
|
||||
// Mark messages as read
|
||||
foreach ($receivedMessages as $message) {
|
||||
if ($message['read_status'] == 0) {
|
||||
$this->markAsRead($message['id']);
|
||||
}
|
||||
}
|
||||
// Assuming $role['name'] contains the role name
|
||||
//$roleName = strtolower($role['name']); // Convert role name to lowercase if needed
|
||||
|
||||
// Construct the view path dynamically
|
||||
// $viewPath = '/' . $roleName . '/' . $roleName . '_messages';
|
||||
|
||||
// Return the view
|
||||
//return view($viewPath, ['receivedMessages' => $receivedMessages]);
|
||||
}
|
||||
|
||||
private function generateMessageNumber()
|
||||
{
|
||||
return 'MSG-' . date('YmdHis');
|
||||
}
|
||||
|
||||
private function markAsRead($messageId)
|
||||
{
|
||||
$db = \Config\Database::connect();
|
||||
$builder = $db->table('messages');
|
||||
$builder->where('id', $messageId);
|
||||
$builder->update([
|
||||
'read_status' => 1,
|
||||
'read_datetime' => utc_now()
|
||||
]);
|
||||
}
|
||||
|
||||
private function getRecipientId($role)
|
||||
{
|
||||
// You can customize this logic to retrieve recipient_id based on the role or input
|
||||
switch (strtolower($role['name'])) {
|
||||
case 'teacher':
|
||||
return 1; // Example: return teacher's ID
|
||||
case 'parent':
|
||||
return 2; // Example: return parent's ID
|
||||
case 'admin':
|
||||
return 3; // Example: return admin's ID
|
||||
case 'student':
|
||||
return 4; // Example: return student's ID
|
||||
case 'guest':
|
||||
return 5; // Example: return guest's ID
|
||||
case 'administrator':
|
||||
return 6; // Example: return administrator's ID
|
||||
default:
|
||||
throw new \Exception('Invalid role: ' . $role['name']);
|
||||
}
|
||||
}
|
||||
|
||||
public function getRecipients($type)
|
||||
{
|
||||
$recipients = [];
|
||||
$teacherClassModel = new TeacherClassModel();
|
||||
$studentClassModel = new StudentClassModel();
|
||||
$studentsModel = new StudentModel();
|
||||
$teacherClasses = $teacherClassModel->findAll();
|
||||
|
||||
if ($type === 'teacher') {
|
||||
$userModel = new UserModel(); // Assuming the UserModel is in the App\Models namespace
|
||||
|
||||
foreach ($teacherClasses as $teacher) {
|
||||
$user = $userModel->find($teacher['teacher_id']);
|
||||
if ($user) {
|
||||
$recipients[] = [
|
||||
'id' => $teacher['teacher_id'],
|
||||
'name' => $user['firstname'] . ' ' . $user['lastname']
|
||||
];
|
||||
}
|
||||
}
|
||||
} elseif ($type === 'parent') {
|
||||
foreach ($teacherClasses as $class) {
|
||||
$students = $studentClassModel
|
||||
->active()
|
||||
->where('student_class.class_section_id', $class['class_section_id'])
|
||||
->findAll();
|
||||
|
||||
foreach ($students as $student) {
|
||||
$studentData = $studentsModel
|
||||
->where('id', $student['student_id'])
|
||||
->where('is_active', 1)
|
||||
->first();
|
||||
if ($studentData) {
|
||||
if ($studentData['firstparent']) {
|
||||
$recipients[] = [
|
||||
'id' => $studentData['parent_id'],
|
||||
'name' => $studentData['firstparent']
|
||||
];
|
||||
}
|
||||
if ($studentData['secondparent']) {
|
||||
$recipients[] = [
|
||||
'id' => $studentData['secondparent_user_id'],
|
||||
'name' => $studentData['secondparent']
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $this->response->setJSON($recipients);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
use App\Controllers\BaseController;
|
||||
use CodeIgniter\Controller;
|
||||
use App\Models\TeacherClassModel;
|
||||
use App\Models\StudentClassModel;
|
||||
use App\Models\StudentModel;
|
||||
use App\Models\MidtermExamModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
use RuntimeException;
|
||||
use App\Services\SemesterScoreService;
|
||||
use Config\Services;
|
||||
use App\Models\GradingLockModel;
|
||||
use App\Models\MissingScoreOverrideModel;
|
||||
|
||||
|
||||
class MidtermController extends BaseController
|
||||
{
|
||||
protected $db;
|
||||
protected $semesterScoreService;
|
||||
protected $schoolYear; // Declare global variable for the controller
|
||||
protected $semester;
|
||||
protected $configModel;
|
||||
protected $studentModel;
|
||||
protected $teacherClassModel;
|
||||
protected $gradingLockModel;
|
||||
protected $missingScoreOverrideModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->db = \Config\Database::connect();
|
||||
|
||||
$this->configModel = new ConfigurationModel();
|
||||
$this->studentModel = new StudentModel();
|
||||
$this->teacherClassModel = new TeacherClassModel();
|
||||
$this->semesterScoreService = service('semesterScoreService');
|
||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||
$this->semester = $this->configModel->getConfig('semester');
|
||||
$this->gradingLockModel = new GradingLockModel();
|
||||
$this->missingScoreOverrideModel = new MissingScoreOverrideModel();
|
||||
}
|
||||
|
||||
public function add()
|
||||
{
|
||||
// 1) class_section_id from the view (POST/GET) with session fallback
|
||||
$incoming = $this->request->getPost('class_section_id')
|
||||
?? $this->request->getGet('class_section_id')
|
||||
?? session()->get('class_section_id');
|
||||
|
||||
$classSectionId = (int) ($incoming ?: 0);
|
||||
if ($classSectionId <= 0) {
|
||||
return redirect()->back()->with('status', 'Missing class section.');
|
||||
}
|
||||
|
||||
// (optional) persist for downstream use
|
||||
session()->set('class_section_id', $classSectionId);
|
||||
|
||||
// (optional) access check if you restrict by teacher assignments
|
||||
// if (!$this->canAccessClassSection((int)(session()->get('user_id') ?? 0), $classSectionId)) {
|
||||
// return redirect()->back()->with('status', 'You do not have access to that class section.');
|
||||
// }
|
||||
|
||||
$semester = $this->getSelectedSemesterFromRequest();
|
||||
$schoolYear = $this->getSelectedSchoolYearFromRequest() ?? $this->schoolYear;
|
||||
try {
|
||||
$result = $this->getSavedScores($classSectionId, $semester, $schoolYear);
|
||||
$missingOkMap = $this->missingScoreOverrideModel->getOverridesMap($classSectionId, $semester, $schoolYear, 'midterm_exam');
|
||||
|
||||
return view('teacher/add_midterm_exam', [
|
||||
'students' => $result['students'],
|
||||
'semester' => $result['semester'],
|
||||
'schoolYear' => $result['schoolYear'],
|
||||
'classSectionId' => $classSectionId,
|
||||
'missingOkMap' => $missingOkMap,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return redirect()->back()->with('status', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function update()
|
||||
{
|
||||
$updatedBy = session()->get('user_id');
|
||||
$scores = $this->request->getPost('final_score');
|
||||
$classSectionId = session()->get('class_section_id');
|
||||
|
||||
if (!is_array($scores)) {
|
||||
return redirect()->back()->with('error', 'No scores submitted.');
|
||||
}
|
||||
|
||||
$semester = $this->getSelectedSemesterFromRequest();
|
||||
$schoolYear = $this->getSelectedSchoolYearFromRequest() ?? $this->schoolYear;
|
||||
$classSectionId = (int) ($classSectionId ?? 0);
|
||||
if ($classSectionId > 0 && $this->isScoresLocked($classSectionId, $semester, $schoolYear)) {
|
||||
return redirect()->back()->with('error', 'Scores are locked for this class. Unlock to edit.');
|
||||
}
|
||||
$this->saveExamScores('midterm_exam', $scores, $updatedBy, $classSectionId, $semester, $schoolYear);
|
||||
|
||||
$missingOk = $this->request->getPost('missing_ok') ?? [];
|
||||
$studentIds = array_keys($scores);
|
||||
$checkedItems = [];
|
||||
if (is_array($missingOk)) {
|
||||
foreach ($missingOk as $studentId => $flags) {
|
||||
$checkedItems[] = [
|
||||
'student_id' => (int) $studentId,
|
||||
'item_index' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
$this->missingScoreOverrideModel->replaceOverrides(
|
||||
$classSectionId,
|
||||
(string) $semester,
|
||||
(string) $schoolYear,
|
||||
'midterm_exam',
|
||||
$studentIds,
|
||||
null,
|
||||
$checkedItems,
|
||||
$updatedBy,
|
||||
true
|
||||
);
|
||||
|
||||
$studentTeacherInfo = $this->studentModel->getStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear);
|
||||
// Call the updateScoresForStudents method
|
||||
try {
|
||||
|
||||
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo, $semester, $schoolYear);
|
||||
} catch (RuntimeException $e) {
|
||||
// Handle error
|
||||
}
|
||||
|
||||
return redirect()->to(base_url('/teacher/addMidtermExam'))
|
||||
->with('status', 'Midterm exam scores updated successfully.');
|
||||
}
|
||||
|
||||
public function updateMngt()
|
||||
{
|
||||
$scores = $this->request->getPost('final_score');
|
||||
$updatedBy = $this->request->getPost('teacher_id') ?? session()->get('user_id'); // default to session user
|
||||
$classSectionId = session()->get('class_section_id');
|
||||
|
||||
if (!is_array($scores)) {
|
||||
return redirect()->back()->with('error', 'No scores submitted.');
|
||||
}
|
||||
|
||||
$semester = $this->getSelectedSemesterFromRequest();
|
||||
$schoolYear = $this->getSelectedSchoolYearFromRequest() ?? $this->schoolYear;
|
||||
$classSectionId = (int) ($classSectionId ?? 0);
|
||||
if ($classSectionId > 0 && $this->isScoresLocked($classSectionId, $semester, $schoolYear)) {
|
||||
return redirect()->back()->with('error', 'Scores are locked for this class. Unlock to edit.');
|
||||
}
|
||||
$this->saveExamScores('midterm_exam', $scores, $updatedBy, $classSectionId, $semester, $schoolYear);
|
||||
|
||||
session()->setFlashdata('status', 'Midterm exam scores updated successfully.');
|
||||
|
||||
$studentTeacherInfo = $this->studentModel->getStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear);
|
||||
// Call the updateScoresForStudents method
|
||||
try {
|
||||
|
||||
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo, $semester, $schoolYear);
|
||||
} catch (RuntimeException $e) {
|
||||
// Handle error
|
||||
}
|
||||
// ⬇️ Directly call the method and return its view
|
||||
return $this->showMidtermMngt();
|
||||
}
|
||||
|
||||
|
||||
public function showMidtermMngt()
|
||||
{
|
||||
// 1) Get class_section_id from POST -> GET -> session
|
||||
$incoming = $this->request->getPost('class_section_id')
|
||||
?? $this->request->getGet('class_section_id')
|
||||
?? session()->get('class_section_id');
|
||||
|
||||
$classSectionId = (int) ($incoming ?: 0);
|
||||
if ($classSectionId <= 0) {
|
||||
return redirect()->back()->with('status', 'Missing class section.');
|
||||
}
|
||||
|
||||
try {
|
||||
// 2) Load roster + saved scores for this class/term
|
||||
$semester = $this->getSelectedSemesterFromRequest();
|
||||
$schoolYear = $this->getSelectedSchoolYearFromRequest() ?? $this->schoolYear;
|
||||
$result = $this->getSavedScores($classSectionId, $semester, $schoolYear);
|
||||
$scoresLocked = $this->isScoresLocked($classSectionId, $semester, $schoolYear);
|
||||
|
||||
// 3) Render view (no teacherId anymore)
|
||||
return view('grading/midterm', [
|
||||
'students' => $result['students'],
|
||||
'semester' => $result['semester'],
|
||||
'schoolYear' => $result['schoolYear'],
|
||||
'classSectionId' => $classSectionId,
|
||||
'scoresLocked' => $scoresLocked,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return redirect()->back()->with('status', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function isScoresLocked(int $classSectionId, string $semester, string $schoolYear): bool
|
||||
{
|
||||
return $this->gradingLockModel->isLocked($classSectionId, $semester, $schoolYear);
|
||||
}
|
||||
|
||||
|
||||
|
||||
private function saveExamScores(string $table, array $scores, int $updatedBy, int $classSectionId, string $semester, string $schoolYear): void
|
||||
{
|
||||
$builder = $this->db->table($table);
|
||||
$now = utc_now();
|
||||
|
||||
foreach ($scores as $studentId => $data) {
|
||||
if (!is_numeric($studentId) || !isset($data['score'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$score = is_numeric($data['score']) ? (float) $data['score'] : null;
|
||||
|
||||
$existing = $builder
|
||||
->where('student_id', $studentId)
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->get()
|
||||
->getRow();
|
||||
|
||||
$dataToSave = [
|
||||
'student_id' => $studentId,
|
||||
'class_section_id' => $classSectionId,
|
||||
'updated_by' => $updatedBy,
|
||||
'score' => $score,
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
if ($existing) {
|
||||
$builder->where('id', $existing->id)->update($dataToSave);
|
||||
} else {
|
||||
$dataToSave['created_at'] = $now;
|
||||
$builder->insert($dataToSave);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get roster + saved midterm scores for a specific classSection/term.
|
||||
*/
|
||||
private function getSavedScores(int $classSectionId, string $semester, string $schoolYear): array
|
||||
{
|
||||
if ($classSectionId <= 0) {
|
||||
throw new \InvalidArgumentException('Invalid class section id.');
|
||||
}
|
||||
|
||||
session()->set('class_section_id', $classSectionId);
|
||||
|
||||
// Subquery: pick ONE midterm row per student (latest by id).
|
||||
// If your table doesn't have 'id', swap to MAX(updated_at) or MAX(exam_date).
|
||||
$latestMidtermSub = $this->db->table('midterm_exam')
|
||||
->select('student_id, MAX(id) AS max_id')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->groupBy('student_id')
|
||||
->getCompiledSelect();
|
||||
|
||||
// Main query: roster for this class/term + LEFT JOIN the single midterm row
|
||||
$qb = $this->studentModel
|
||||
->select('
|
||||
s.id AS student_id,
|
||||
s.school_id,
|
||||
s.firstname,
|
||||
s.lastname,
|
||||
me.score
|
||||
')
|
||||
->from('students s')
|
||||
->join(
|
||||
'student_class sc',
|
||||
'sc.student_id = s.id
|
||||
AND sc.class_section_id = ' . (int) $classSectionId . '
|
||||
AND sc.school_year = ' . $this->db->escape($schoolYear),
|
||||
'inner',
|
||||
false
|
||||
)
|
||||
// Join the "latest midterm per student" subquery, then the actual midterm row
|
||||
->join('(' . $latestMidtermSub . ') ml', 'ml.student_id = s.id', 'left', false)
|
||||
->join('midterm_exam me', 'me.id = ml.max_id', 'left')
|
||||
->orderBy('s.lastname', 'ASC')
|
||||
->orderBy('s.firstname', 'ASC');
|
||||
|
||||
// DISTINCT protects against rare duplicate sc rows that match the same select columns
|
||||
$qb->distinct();
|
||||
|
||||
$rows = $qb->get()->getResultArray();
|
||||
|
||||
// Final safety net: ensure one row per student_id
|
||||
$unique = [];
|
||||
foreach ($rows as $r) {
|
||||
$unique[(int)$r['student_id']] = $r;
|
||||
}
|
||||
$students = array_values($unique);
|
||||
|
||||
return [
|
||||
'students' => $students,
|
||||
'semester' => $semester,
|
||||
'schoolYear' => $schoolYear,
|
||||
];
|
||||
}
|
||||
|
||||
private function getTeacherSelectedSemester(): string
|
||||
{
|
||||
helper('semester_selection_helper');
|
||||
return selected_teacher_semester();
|
||||
}
|
||||
|
||||
private function getSelectedSemesterFromRequest(): string
|
||||
{
|
||||
$semesterParam = $this->request->getPost('semester')
|
||||
?? $this->request->getGet('semester')
|
||||
?? $this->request->getGet('filter');
|
||||
$normalized = $this->normalizeSemesterSelection($semesterParam);
|
||||
if ($normalized !== '') {
|
||||
$label = ucfirst($normalized);
|
||||
$session = session();
|
||||
$session->set('teacher_scores_selected_semester', $label);
|
||||
$session->set('semester', $label);
|
||||
}
|
||||
|
||||
return $this->getTeacherSelectedSemester();
|
||||
}
|
||||
|
||||
private function getSelectedSchoolYearFromRequest(): ?string
|
||||
{
|
||||
$yearParam = $this->request->getPost('school_year') ?? $this->request->getGet('school_year');
|
||||
return $this->normalizeSchoolYear($yearParam);
|
||||
}
|
||||
|
||||
private function normalizeSemesterSelection(?string $input): string
|
||||
{
|
||||
$value = strtolower(trim((string) $input));
|
||||
if ($value === 'fall' || str_contains($value, '1')) {
|
||||
return 'fall';
|
||||
}
|
||||
if ($value === 'spring' || str_contains($value, '2')) {
|
||||
return 'spring';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private function normalizeSchoolYear(?string $input): ?string
|
||||
{
|
||||
$value = trim((string) $input);
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
if (preg_match('/^\\d{4}\\s*[-\\/]\\s*\\d{4}$/', $value)) {
|
||||
return preg_replace('/\\s+/', '', str_replace('/', '-', $value));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Models\NavItemModel;
|
||||
use App\Models\RoleNavItemModel;
|
||||
use App\Services\NavbarService;
|
||||
|
||||
class NavBuilderController extends BaseController
|
||||
{
|
||||
protected NavItemModel $items;
|
||||
protected RoleNavItemModel $maps;
|
||||
protected NavbarService $service;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->items = new NavItemModel();
|
||||
$this->maps = new RoleNavItemModel();
|
||||
$this->service = new NavbarService();
|
||||
}
|
||||
|
||||
protected function ensureAdmin(): void
|
||||
{
|
||||
$sessionRole = session()->get('role'); // could be a string or array in your app
|
||||
$roleNames = is_array($sessionRole) ? $sessionRole : [$sessionRole];
|
||||
$roleNames = array_values(array_filter(array_map('strval', $roleNames)));
|
||||
|
||||
if (empty($roleNames)) {
|
||||
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
|
||||
}
|
||||
|
||||
$db = \Config\Database::connect();
|
||||
|
||||
// Map role names -> ids
|
||||
$roleIdRows = $db->table('roles')->select('id')->whereIn('name', $roleNames)->get()->getResultArray();
|
||||
$roleIds = array_map('intval', array_column($roleIdRows, 'id'));
|
||||
if (empty($roleIds)) {
|
||||
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
|
||||
}
|
||||
|
||||
// Is this route allowed for any of the user's roles?
|
||||
$allowed = $db->table('role_nav_items AS rni')
|
||||
->select('1')
|
||||
->join('nav_items AS ni', 'ni.id = rni.nav_item_id')
|
||||
->where('ni.url', 'nav-builder') // IMPORTANT: your current route path
|
||||
->whereIn('rni.role_id', $roleIds)
|
||||
->get(1)->getFirstRow();
|
||||
|
||||
if (!$allowed) {
|
||||
// You can show a nicer "Access Denied" view if you prefer
|
||||
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
|
||||
}
|
||||
}
|
||||
|
||||
public function index(){
|
||||
$this->ensureAdmin();
|
||||
|
||||
helper(['url', 'form']);
|
||||
|
||||
return view('nav_builder/index');
|
||||
}
|
||||
|
||||
/** Case-insensitive, natural sort; ignores leading punctuation & articles (“a/an/the”) */
|
||||
private function labelKey(string $s): string
|
||||
{
|
||||
$s = trim(preg_replace('/\s+/', ' ', $s));
|
||||
$s = preg_replace('/^[^[:alnum:]]+/u', '', $s); // strip leading punctuation
|
||||
$s = preg_replace('/^(?i)(the|an|a)\s+/', '', $s); // drop leading articles
|
||||
return mb_strtolower($s, 'UTF-8');
|
||||
}
|
||||
|
||||
private function sortTreeAlpha(array &$nodes): void
|
||||
{
|
||||
usort($nodes, fn($a,$b) => strnatcasecmp(
|
||||
$this->labelKey($a['label'] ?? ''), $this->labelKey($b['label'] ?? '')
|
||||
));
|
||||
foreach ($nodes as &$n) {
|
||||
if (!empty($n['children'])) {
|
||||
$this->sortTreeAlpha($n['children']);
|
||||
}
|
||||
}
|
||||
unset($n);
|
||||
}
|
||||
|
||||
|
||||
public function save()
|
||||
{
|
||||
$this->ensureAdmin();
|
||||
|
||||
$id = (int) $this->request->getPost('id');
|
||||
|
||||
// Accept either "menu_parent_id" or "parent_id" from the form
|
||||
$menuParentRaw = $this->request->getPost('menu_parent_id');
|
||||
if ($menuParentRaw === null) {
|
||||
$menuParentRaw = $this->request->getPost('parent_id');
|
||||
}
|
||||
|
||||
$menuParentId = ($menuParentRaw === '' || $menuParentRaw === null) ? null : (int) $menuParentRaw;
|
||||
if ($id && $menuParentId === $id) {
|
||||
// item cannot be its own parent
|
||||
$menuParentId = null;
|
||||
}
|
||||
|
||||
// Validate parent exists (optional but helpful)
|
||||
if ($menuParentId !== null) {
|
||||
$parent = $this->items->select('id')->where('id', $menuParentId)->first();
|
||||
if (!$parent) {
|
||||
return redirect()->back()->with('error', 'Selected parent does not exist.')->withInput();
|
||||
}
|
||||
}
|
||||
|
||||
$data = [
|
||||
'menu_parent_id' => $menuParentId,
|
||||
'label' => trim((string) $this->request->getPost('label')),
|
||||
'url' => trim((string) $this->request->getPost('url')) ?: null,
|
||||
'icon_class' => trim((string) $this->request->getPost('icon_class')) ?: null,
|
||||
'target' => trim((string) $this->request->getPost('target')) ?: null,
|
||||
'sort_order' => (int) $this->request->getPost('sort_order'),
|
||||
'is_enabled' => (int) ($this->request->getPost('is_enabled') ? 1 : 0),
|
||||
];
|
||||
|
||||
// Save (force insert to return ID)
|
||||
if ($id) {
|
||||
$this->items->update($id, $data);
|
||||
} else {
|
||||
$id = (int) $this->items->insert($data, true); // ensure insertID is returned
|
||||
}
|
||||
|
||||
// Roles
|
||||
$roleIds = array_values(array_unique(array_filter(
|
||||
array_map('intval', (array) ($this->request->getPost('roles') ?? [])),
|
||||
fn ($v) => $v > 0
|
||||
)));
|
||||
|
||||
$this->maps->where('nav_item_id', $id)->delete();
|
||||
foreach ($roleIds as $rid) {
|
||||
$this->maps->insert(['role_id' => $rid, 'nav_item_id' => $id]);
|
||||
}
|
||||
|
||||
$this->service->clearCache();
|
||||
return redirect()->back()->with('success', 'Menu saved.');
|
||||
}
|
||||
|
||||
|
||||
public function delete($id)
|
||||
{
|
||||
$this->ensureAdmin();
|
||||
$id = (int) $id;
|
||||
|
||||
$this->items->delete($id); // children handled by FK (SET NULL on parent)
|
||||
$this->service->clearCache();
|
||||
|
||||
return redirect()->back()->with('success', 'Menu item deleted.');
|
||||
}
|
||||
|
||||
public function reorder()
|
||||
{
|
||||
$this->ensureAdmin();
|
||||
|
||||
$orders = $this->request->getPost('orders') ?? [];
|
||||
foreach ($orders as $id => $order) {
|
||||
$this->items->update((int) $id, ['sort_order' => (int) $order]);
|
||||
}
|
||||
$this->service->clearCache();
|
||||
return $this->response->setJSON(['ok' => true]);
|
||||
}
|
||||
|
||||
protected function distinctRoles(): array
|
||||
{
|
||||
$db = \Config\Database::connect();
|
||||
return $db->table('roles')
|
||||
->select('id, name')
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->getResultArray();
|
||||
}
|
||||
|
||||
public function data()
|
||||
{
|
||||
$this->ensureAdmin();
|
||||
return $this->response->setJSON($this->buildNavPayload());
|
||||
}
|
||||
|
||||
private function buildNavPayload(): array
|
||||
{
|
||||
$all = $this->items
|
||||
->orderBy('menu_parent_id', 'ASC')
|
||||
->orderBy('label', 'ASC')
|
||||
->orderBy('id', 'ASC')
|
||||
->findAll();
|
||||
|
||||
$byId = [];
|
||||
foreach ($all as $a) {
|
||||
$a['children'] = [];
|
||||
$byId[$a['id']] = $a;
|
||||
}
|
||||
|
||||
$tree = [];
|
||||
foreach ($byId as $id => &$node) {
|
||||
$pid = $node['menu_parent_id'] ?: null;
|
||||
if ($pid && isset($byId[$pid])) {
|
||||
$byId[$pid]['children'][] = &$node;
|
||||
} else {
|
||||
$tree[] = &$node;
|
||||
}
|
||||
}
|
||||
unset($node);
|
||||
|
||||
$this->sortTreeAlpha($tree);
|
||||
|
||||
$flatAlpha = $all;
|
||||
usort($flatAlpha, fn($a, $b) => strnatcasecmp(
|
||||
$this->labelKey($a['label'] ?? ''),
|
||||
$this->labelKey($b['label'] ?? '')
|
||||
));
|
||||
|
||||
$roleAssignments = [];
|
||||
$roleRows = $this->maps
|
||||
->select('role_nav_items.nav_item_id, roles.id AS role_id, roles.name AS role_name')
|
||||
->join('roles', 'roles.id = role_nav_items.role_id', 'left')
|
||||
->findAll();
|
||||
|
||||
foreach ($roleRows as $row) {
|
||||
$navId = (int) ($row['nav_item_id'] ?? 0);
|
||||
if ($navId <= 0) continue;
|
||||
|
||||
$roleId = (int) ($row['role_id'] ?? 0);
|
||||
$roleName = $row['role_name'] ?? ($roleId ? ('#' . $roleId) : null);
|
||||
|
||||
if ($roleId > 0) {
|
||||
$roleAssignments[$navId]['ids'][] = $roleId;
|
||||
}
|
||||
if ($roleName !== null) {
|
||||
$roleAssignments[$navId]['names'][] = $roleName;
|
||||
}
|
||||
}
|
||||
|
||||
$flattened = $this->flattenTreeForResponse($tree, $roleAssignments);
|
||||
|
||||
$parentOptions = array_map(static function ($row) {
|
||||
return [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'label' => (string) ($row['label'] ?? ''),
|
||||
];
|
||||
}, $flatAlpha);
|
||||
|
||||
$roles = array_map(static function ($role) {
|
||||
return [
|
||||
'id' => (int) ($role['id'] ?? 0),
|
||||
'name' => (string) ($role['name'] ?? ''),
|
||||
];
|
||||
}, $this->distinctRoles());
|
||||
|
||||
return [
|
||||
'items' => $flattened,
|
||||
'roles' => $roles,
|
||||
'parentOptions' => $parentOptions,
|
||||
];
|
||||
}
|
||||
|
||||
private function flattenTreeForResponse(array $nodes, array $roleAssignments, ?string $parentLabel = null, int $depth = 0, array &$rows = []): array
|
||||
{
|
||||
foreach ($nodes as $node) {
|
||||
$raw = $node;
|
||||
unset($raw['children']);
|
||||
|
||||
$navId = (int) ($raw['id'] ?? 0);
|
||||
$roles = $roleAssignments[$navId] ?? ['ids' => [], 'names' => []];
|
||||
|
||||
$rows[] = [
|
||||
'id' => $navId,
|
||||
'label' => (string) ($raw['label'] ?? ''),
|
||||
'url' => $raw['url'] ?? null,
|
||||
'parent_label' => $parentLabel ?? '—',
|
||||
'parent_id' => isset($raw['menu_parent_id']) && (int) $raw['menu_parent_id'] !== 0
|
||||
? (int) $raw['menu_parent_id']
|
||||
: null,
|
||||
'order' => (int) ($raw['sort_order'] ?? 0),
|
||||
'enabled' => (int) ($raw['is_enabled'] ?? 0) === 1,
|
||||
'target' => $raw['target'] ?? null,
|
||||
'depth' => $depth,
|
||||
'roles' => [
|
||||
'ids' => array_values(array_unique($roles['ids'] ?? [])),
|
||||
'names' => array_values(array_unique($roles['names'] ?? [])),
|
||||
],
|
||||
'raw' => $raw,
|
||||
];
|
||||
|
||||
if (!empty($node['children'])) {
|
||||
$this->flattenTreeForResponse(
|
||||
$node['children'],
|
||||
$roleAssignments,
|
||||
(string) ($raw['label'] ?? ''),
|
||||
$depth + 1,
|
||||
$rows
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
use App\Controllers\BaseController;
|
||||
use App\Controllers\View\EmailController;
|
||||
use App\Models\NotificationModel;
|
||||
use App\Models\UserNotificationModel;
|
||||
use App\Models\UserModel;
|
||||
|
||||
class NotificationsController extends BaseController
|
||||
{
|
||||
protected $notificationModel;
|
||||
protected $userNotificationModel;
|
||||
protected $userModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->notificationModel = new NotificationModel();
|
||||
$this->userNotificationModel = new UserNotificationModel();
|
||||
$this->userModel = new UserModel();
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$userId = session()->get('user_id');
|
||||
|
||||
$notifications = $this->userNotificationModel
|
||||
->select('notifications.*, user_notifications.is_read')
|
||||
->join('notifications', 'notifications.id = user_notifications.notification_id')
|
||||
->where('user_notifications.user_id', $userId)
|
||||
->orderBy('notifications.created_at', 'DESC')
|
||||
->findAll();
|
||||
|
||||
return view('notifications/index', ['notifications' => $notifications]);
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
return view('notifications/create');
|
||||
}
|
||||
|
||||
public function send()
|
||||
{
|
||||
$title = $this->request->getPost('title');
|
||||
$message = $this->request->getPost('message');
|
||||
$group = $this->request->getPost('target_group');
|
||||
$channels = $this->request->getPost('channels'); // ['in_app', 'email', 'sms']
|
||||
|
||||
$notifId = $this->notificationModel->insert([
|
||||
'title' => $title,
|
||||
'message' => $message,
|
||||
'target_group' => $group,
|
||||
'delivery_channels' => implode(',', $channels),
|
||||
'created_at' => utc_now()
|
||||
]);
|
||||
|
||||
$users = $this->userModel->where('role', $group)->findAll();
|
||||
|
||||
foreach ($users as $user) {
|
||||
$this->userNotificationModel->insert([
|
||||
'notification_id' => $notifId,
|
||||
'user_id' => $user['id'],
|
||||
'is_read' => false
|
||||
]);
|
||||
|
||||
if (in_array('email', $channels)) {
|
||||
$this->sendEmail($user['email'], $title, $message);
|
||||
}
|
||||
|
||||
if (in_array('sms', $channels) && !empty($user['phone'])) {
|
||||
$this->sendSMS($user['phone'], $message);
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', 'Notification sent successfully.');
|
||||
}
|
||||
|
||||
public function markAsRead($id)
|
||||
{
|
||||
$userId = session()->get('user_id');
|
||||
$this->userNotificationModel->where('notification_id', $id)
|
||||
->where('user_id', $userId)
|
||||
->set(['is_read' => 1])
|
||||
->update();
|
||||
|
||||
return redirect()->back();
|
||||
}
|
||||
|
||||
public function listActive()
|
||||
{
|
||||
helper('url');
|
||||
|
||||
$targetGroup = $this->request->getGet('target_group');
|
||||
|
||||
return view('notifications/list_active', [
|
||||
'notificationsEndpoint' => site_url('api/notifications/active'),
|
||||
'defaultTargetGroup' => $targetGroup,
|
||||
]);
|
||||
}
|
||||
|
||||
public function listDeleted()
|
||||
{
|
||||
helper(['url', 'form']);
|
||||
|
||||
return view('notifications/list_deleted', [
|
||||
'deletedNotificationsEndpoint' => site_url('api/notifications/deleted'),
|
||||
'restoreEndpoint' => site_url('notifications/restore'),
|
||||
'csrfTokenName' => csrf_token(),
|
||||
'csrfTokenValue' => csrf_hash(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function activeNotificationsData()
|
||||
{
|
||||
$targetGroup = $this->request->getGet('target_group');
|
||||
|
||||
return $this->response->setJSON($this->buildActiveNotificationsPayload($targetGroup));
|
||||
}
|
||||
|
||||
public function deletedNotificationsData()
|
||||
{
|
||||
return $this->response->setJSON($this->buildDeletedNotificationsPayload());
|
||||
}
|
||||
|
||||
public function restore($id)
|
||||
{
|
||||
if ($this->notificationModel->restoreNotification($id)) {
|
||||
return redirect()->to(base_url('notifications/deleted'))->with('success', 'Notification restored successfully.');
|
||||
}
|
||||
|
||||
return redirect()->to(base_url('notifications/deleted'))->with('error', 'Failed to restore notification.');
|
||||
}
|
||||
|
||||
protected function sendEmail($to, $subject, $message)
|
||||
{
|
||||
$mailer = new EmailController();
|
||||
$mailer->sendEmail($to, $subject, $message);
|
||||
}
|
||||
|
||||
protected function sendSMS($phone, $message)
|
||||
{
|
||||
log_message('info', "SMS to {$phone}: {$message}");
|
||||
}
|
||||
|
||||
private function buildActiveNotificationsPayload(?string $targetGroup): array
|
||||
{
|
||||
$targetGroup = $targetGroup !== null ? trim($targetGroup) : null;
|
||||
if ($targetGroup === '') {
|
||||
$targetGroup = null;
|
||||
}
|
||||
|
||||
$rows = $this->notificationModel->getActiveNotifications($targetGroup);
|
||||
if (!is_array($rows)) {
|
||||
$rows = [];
|
||||
}
|
||||
|
||||
$now = time();
|
||||
|
||||
$notifications = array_map(static function ($row) use ($now) {
|
||||
if (!is_array($row)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$expiresAt = $row['expires_at'] ?? null;
|
||||
$expiryTs = $expiresAt ? strtotime((string) $expiresAt) : false;
|
||||
$isExpired = $expiryTs !== false && $expiryTs <= $now;
|
||||
|
||||
return [
|
||||
'id' => $row['id'] ?? null,
|
||||
'title' => $row['title'] ?? null,
|
||||
'message' => $row['message'] ?? null,
|
||||
'priority' => $row['priority'] ?? null,
|
||||
'scheduled_at' => $row['scheduled_at'] ?? null,
|
||||
'expires_at' => $expiresAt,
|
||||
'created_at' => $row['created_at'] ?? null,
|
||||
'target_group' => $row['target_group'] ?? null,
|
||||
'isExpired' => $isExpired,
|
||||
];
|
||||
}, $rows);
|
||||
|
||||
return [
|
||||
'notifications' => $notifications,
|
||||
];
|
||||
}
|
||||
|
||||
private function buildDeletedNotificationsPayload(): array
|
||||
{
|
||||
$rows = $this->notificationModel->getDeletedNotifications();
|
||||
if (!is_array($rows)) {
|
||||
$rows = [];
|
||||
}
|
||||
|
||||
$notifications = array_map(static function ($row) {
|
||||
if (!is_array($row)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $row['id'] ?? null,
|
||||
'title' => $row['title'] ?? null,
|
||||
'message' => $row['message'] ?? null,
|
||||
'priority' => $row['priority'] ?? null,
|
||||
'scheduled_at' => $row['scheduled_at'] ?? null,
|
||||
'expires_at' => $row['expires_at'] ?? null,
|
||||
'deleted_at' => $row['deleted_at'] ?? null,
|
||||
];
|
||||
}, $rows);
|
||||
|
||||
return [
|
||||
'notifications' => $notifications,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Models\ContactUsModel;
|
||||
use App\Controllers\BaseController;
|
||||
|
||||
class PageController extends BaseController
|
||||
{
|
||||
public function privacyPolicy()
|
||||
{
|
||||
return $this->response->setBody(file_get_contents(ROOTPATH . 'public/html/privacy_policy.html'))
|
||||
->setContentType('text/html');
|
||||
}
|
||||
|
||||
public function termsOfService()
|
||||
{
|
||||
return $this->response->setBody(file_get_contents(ROOTPATH . 'public/html/terms_of_service.html'))
|
||||
->setContentType('text/html');
|
||||
}
|
||||
|
||||
public function helpCenter()
|
||||
{
|
||||
return $this->response->setBody(file_get_contents(ROOTPATH . 'public/html/help_center.html'))
|
||||
->setContentType('text/html');
|
||||
}
|
||||
|
||||
public function submitContactForm()
|
||||
{
|
||||
try {
|
||||
$email = strtolower($this->request->getPost('email'));
|
||||
$message = $this->request->getPost('message');
|
||||
|
||||
// Save the message to the database
|
||||
$contactModel = new ContactUsModel();
|
||||
$contactData = [
|
||||
'email' => $email,
|
||||
'message' => $message,
|
||||
];
|
||||
|
||||
if (!$contactModel->save($contactData)) {
|
||||
log_message('error', 'Failed to save contact data: ' . implode(', ', $contactModel->errors()));
|
||||
return redirect()->back()->with('error', 'Failed to save message. Please try again.');
|
||||
}
|
||||
|
||||
// Prepare the email content
|
||||
$recipient = 'alrahma.isgl@gmail.com'; // Change to the communication head's email
|
||||
$subject = 'Contact Us Form Submission';
|
||||
$emailMessage = "You have received a new message from $email:\n\n$message";
|
||||
|
||||
// Use EmailController to send the email
|
||||
$emailController = new \App\Controllers\View\EmailController();
|
||||
if (!$emailController->sendEmail($recipient, $subject, $emailMessage)) {
|
||||
log_message('error', 'Failed to send email.');
|
||||
return redirect()->back()->with('error', 'Failed to send message. Please try again.');
|
||||
}
|
||||
|
||||
return redirect()->to('/thank_you')->with('success', 'Message sent successfully.');
|
||||
} catch (\Exception $e) {
|
||||
log_message('error', 'Exception occurred: ' . $e->getMessage());
|
||||
return redirect()->back()->with('error', 'An unexpected error occurred. Please try again.');
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,355 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
use App\Controllers\BaseController;
|
||||
use CodeIgniter\Controller;
|
||||
use App\Models\TeacherClassModel;
|
||||
use App\Models\StudentClassModel;
|
||||
use App\Models\StudentModel;
|
||||
use App\Models\ParticipationModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
use RuntimeException;
|
||||
use App\Services\SemesterScoreService;
|
||||
use Config\Services;
|
||||
use App\Models\GradingLockModel;
|
||||
use App\Models\MissingScoreOverrideModel;
|
||||
|
||||
|
||||
class ParticipationController extends BaseController
|
||||
{
|
||||
protected $db;
|
||||
protected $semesterScoreService;
|
||||
protected $schoolYear; // Declare global variable for the controller
|
||||
protected $semester;
|
||||
protected $configModel;
|
||||
protected $studentModel;
|
||||
protected $teacherClassModel;
|
||||
protected $gradingLockModel;
|
||||
protected $missingScoreOverrideModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->db = \Config\Database::connect();
|
||||
|
||||
$this->configModel = new ConfigurationModel();
|
||||
$this->studentModel = new StudentModel();
|
||||
$this->teacherClassModel = new TeacherClassModel();
|
||||
|
||||
$this->semesterScoreService = service('semesterScoreService');
|
||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||
$this->semester = $this->configModel->getConfig('semester');
|
||||
$this->gradingLockModel = new GradingLockModel();
|
||||
$this->missingScoreOverrideModel = new MissingScoreOverrideModel();
|
||||
}
|
||||
|
||||
public function add()
|
||||
{
|
||||
// 1) Get class_section_id from the view (POST/GET) with session fallback
|
||||
$incoming = $this->request->getPost('class_section_id')
|
||||
?? $this->request->getGet('class_section_id')
|
||||
?? session()->get('class_section_id');
|
||||
|
||||
$classSectionId = (int) ($incoming ?: 0);
|
||||
if ($classSectionId <= 0) {
|
||||
return redirect()->back()->with('status', 'Missing class section.');
|
||||
}
|
||||
|
||||
// (Optional) persist for downstream pages
|
||||
session()->set('class_section_id', $classSectionId);
|
||||
|
||||
// (Optional) access check if you’re restricting by teacher/TA roles
|
||||
// if (!$this->canAccessClassSection((int)(session()->get('user_id') ?? 0), $classSectionId)) {
|
||||
// return redirect()->back()->with('status', 'You do not have access to that class section.');
|
||||
// }
|
||||
|
||||
$semester = $this->getSelectedSemesterFromRequest();
|
||||
$schoolYear = $this->getSelectedSchoolYearFromRequest() ?? $this->schoolYear;
|
||||
try {
|
||||
$result = $this->getSavedScores($classSectionId, $semester, $schoolYear);
|
||||
$missingOkMap = $this->missingScoreOverrideModel->getOverridesMap($classSectionId, $semester, $schoolYear, 'participation');
|
||||
|
||||
return view('teacher/add_participation', [
|
||||
'students' => $result['students'],
|
||||
'semester' => $result['semester'],
|
||||
'schoolYear' => $result['schoolYear'],
|
||||
'classSectionId' => $classSectionId,
|
||||
'missingOkMap' => $missingOkMap,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return redirect()->back()->with('status', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function update()
|
||||
{
|
||||
$updatedBy = session()->get('user_id');
|
||||
$scores = $this->request->getPost('final_score');
|
||||
$classSectionId = session()->get('class_section_id');
|
||||
|
||||
if (!is_array($scores)) {
|
||||
return redirect()->back()->with('error', 'No scores submitted.');
|
||||
}
|
||||
|
||||
$semester = $this->getSelectedSemesterFromRequest();
|
||||
$schoolYear = $this->getSelectedSchoolYearFromRequest() ?? $this->schoolYear;
|
||||
$classSectionId = (int) ($classSectionId ?? 0);
|
||||
if ($classSectionId > 0 && $this->isScoresLocked($classSectionId, $semester, $schoolYear)) {
|
||||
return redirect()->back()->with('error', 'Scores are locked for this class. Unlock to edit.');
|
||||
}
|
||||
$this->saveParticipationScores('participation', $scores, $updatedBy, $classSectionId, $semester, $schoolYear);
|
||||
|
||||
$missingOk = $this->request->getPost('missing_ok') ?? [];
|
||||
$studentIds = array_keys($scores);
|
||||
$checkedItems = [];
|
||||
if (is_array($missingOk)) {
|
||||
foreach ($missingOk as $studentId => $flags) {
|
||||
$checkedItems[] = [
|
||||
'student_id' => (int) $studentId,
|
||||
'item_index' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
$this->missingScoreOverrideModel->replaceOverrides(
|
||||
$classSectionId,
|
||||
(string) $semester,
|
||||
(string) $schoolYear,
|
||||
'participation',
|
||||
$studentIds,
|
||||
null,
|
||||
$checkedItems,
|
||||
$updatedBy,
|
||||
true
|
||||
);
|
||||
|
||||
$studentTeacherInfo = $this->studentModel->getStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear);
|
||||
// Call the updateScoresForStudents method
|
||||
try {
|
||||
|
||||
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo, $semester, $schoolYear);
|
||||
} catch (RuntimeException $e) {
|
||||
// Handle error
|
||||
}
|
||||
|
||||
return redirect()->to(base_url('/teacher/addParticipation'))
|
||||
->with('status', 'Participation scores updated successfully.');
|
||||
}
|
||||
|
||||
public function updateMngt()
|
||||
{
|
||||
$scores = $this->request->getPost('final_score');
|
||||
$updatedBy = $this->request->getPost('teacher_id') ?? session()->get('user_id'); // default to session user
|
||||
$classSectionId = session()->get('class_section_id');
|
||||
|
||||
if (!is_array($scores)) {
|
||||
return redirect()->back()->with('error', 'No scores submitted.');
|
||||
}
|
||||
|
||||
$semester = $this->getSelectedSemesterFromRequest();
|
||||
$schoolYear = $this->getSelectedSchoolYearFromRequest() ?? $this->schoolYear;
|
||||
$classSectionId = (int) ($classSectionId ?? 0);
|
||||
if ($classSectionId > 0 && $this->isScoresLocked($classSectionId, $semester, $schoolYear)) {
|
||||
return redirect()->back()->with('error', 'Scores are locked for this class. Unlock to edit.');
|
||||
}
|
||||
$this->saveParticipationScores('participation', $scores, $updatedBy, $classSectionId, $semester, $schoolYear);
|
||||
|
||||
session()->setFlashdata('status', 'Participation scores updated successfully.');
|
||||
|
||||
$studentTeacherInfo = $this->studentModel->getStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear);
|
||||
// Call the updateScoresForStudents method
|
||||
try {
|
||||
|
||||
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo, $semester, $schoolYear);
|
||||
} catch (RuntimeException $e) {
|
||||
// Handle error
|
||||
}
|
||||
// ⬇️ Directly call the method and return its view
|
||||
return $this->showParticipationMngt();
|
||||
}
|
||||
|
||||
|
||||
public function showParticipationMngt()
|
||||
{
|
||||
// Get class_section_id from view (POST/GET) with session fallback
|
||||
$incoming = $this->request->getPost('class_section_id')
|
||||
?? $this->request->getGet('class_section_id')
|
||||
?? session()->get('class_section_id');
|
||||
|
||||
$classSectionId = (int) ($incoming ?: 0);
|
||||
if ($classSectionId <= 0) {
|
||||
return redirect()->back()->with('status', 'Missing class section.');
|
||||
}
|
||||
|
||||
// OPTIONAL: enforce access like we did for homework/midterm
|
||||
// if (!$this->canAccessClassSection((int)(session()->get('user_id') ?? 0), $classSectionId)) {
|
||||
// return redirect()->back()->with('status', 'You do not have access to that class section.');
|
||||
// }
|
||||
|
||||
try {
|
||||
// Pull roster + saved (participation/midterm) scores for this section/term
|
||||
// If you have a dedicated participation table, call its getter here instead.
|
||||
$semester = $this->getSelectedSemesterFromRequest();
|
||||
$schoolYear = $this->getSelectedSchoolYearFromRequest() ?? $this->schoolYear;
|
||||
$result = $this->getSavedScores($classSectionId, $semester, $schoolYear);
|
||||
$scoresLocked = $this->isScoresLocked($classSectionId, $semester, $schoolYear);
|
||||
|
||||
return view('grading/participation', [
|
||||
'students' => $result['students'], // already includes school_id
|
||||
'semester' => $result['semester'],
|
||||
'schoolYear' => $result['schoolYear'],
|
||||
'classSectionId' => $classSectionId,
|
||||
'scoresLocked' => $scoresLocked,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return redirect()->back()->with('status', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function isScoresLocked(int $classSectionId, string $semester, string $schoolYear): bool
|
||||
{
|
||||
return $this->gradingLockModel->isLocked($classSectionId, $semester, $schoolYear);
|
||||
}
|
||||
|
||||
|
||||
|
||||
private function saveParticipationScores(string $table, array $scores, int $updatedBy, int $classSectionId, string $semester, string $schoolYear): void
|
||||
{
|
||||
$db = \Config\Database::connect();
|
||||
$builder = $db->table($table);
|
||||
$now = utc_now();
|
||||
|
||||
foreach ($scores as $studentId => $data) {
|
||||
if (!is_numeric($studentId) || !isset($data['score'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$score = is_numeric($data['score']) ? (float) $data['score'] : null;
|
||||
|
||||
$existing = $builder
|
||||
->where('student_id', $studentId)
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->get()
|
||||
->getRow();
|
||||
|
||||
$dataToSave = [
|
||||
'student_id' => $studentId,
|
||||
'class_section_id' => $classSectionId,
|
||||
'updated_by' => $updatedBy,
|
||||
'score' => $score,
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
if ($existing) {
|
||||
$builder->where('id', $existing->id)->update($dataToSave);
|
||||
} else {
|
||||
$dataToSave['created_at'] = $now;
|
||||
$builder->insert($dataToSave);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function getSavedScores(int $classSectionId, string $semester, string $schoolYear): array
|
||||
{
|
||||
if ($classSectionId <= 0) {
|
||||
throw new \InvalidArgumentException('Invalid class section id.');
|
||||
}
|
||||
|
||||
session()->set('class_section_id', $classSectionId);
|
||||
|
||||
// Build a DISTINCT roster subquery correctly (no 'DISTINCT' inside select string)
|
||||
$rosterSub = $this->db->table('student_class')
|
||||
->select('student_id') // <-- just the column
|
||||
->distinct() // <-- ask CI4 to add DISTINCT
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('school_year', $schoolYear)
|
||||
->getCompiledSelect();
|
||||
|
||||
// Main query: Students + LEFT JOIN participation for same section/term
|
||||
$students = $this->db->table('students s')
|
||||
->select('
|
||||
s.id AS student_id,
|
||||
s.school_id,
|
||||
s.firstname,
|
||||
s.lastname,
|
||||
p.score
|
||||
')
|
||||
->join("({$rosterSub}) sc", 'sc.student_id = s.id', 'inner', false)
|
||||
->join(
|
||||
'participation p',
|
||||
'p.student_id = s.id
|
||||
AND p.class_section_id = ' . (int)$classSectionId . '
|
||||
AND p.semester = ' . $this->db->escape($semester) . '
|
||||
AND p.school_year = ' . $this->db->escape($schoolYear),
|
||||
'left',
|
||||
false
|
||||
)
|
||||
->where('s.is_active', 1)
|
||||
->orderBy('s.lastname', 'ASC')
|
||||
->orderBy('s.firstname', 'ASC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
return [
|
||||
'students' => $students,
|
||||
'semester' => $semester,
|
||||
'schoolYear' => $schoolYear,
|
||||
];
|
||||
}
|
||||
|
||||
private function getTeacherSelectedSemester(): string
|
||||
{
|
||||
helper('semester_selection_helper');
|
||||
return selected_teacher_semester();
|
||||
}
|
||||
|
||||
private function getSelectedSemesterFromRequest(): string
|
||||
{
|
||||
$semesterParam = $this->request->getPost('semester')
|
||||
?? $this->request->getGet('semester')
|
||||
?? $this->request->getGet('filter');
|
||||
$normalized = $this->normalizeSemesterSelection($semesterParam);
|
||||
if ($normalized !== '') {
|
||||
$label = ucfirst($normalized);
|
||||
$session = session();
|
||||
$session->set('teacher_scores_selected_semester', $label);
|
||||
$session->set('semester', $label);
|
||||
}
|
||||
|
||||
return $this->getTeacherSelectedSemester();
|
||||
}
|
||||
|
||||
private function getSelectedSchoolYearFromRequest(): ?string
|
||||
{
|
||||
$yearParam = $this->request->getPost('school_year') ?? $this->request->getGet('school_year');
|
||||
return $this->normalizeSchoolYear($yearParam);
|
||||
}
|
||||
|
||||
private function normalizeSemesterSelection(?string $input): string
|
||||
{
|
||||
$value = strtolower(trim((string) $input));
|
||||
if ($value === 'fall' || str_contains($value, '1')) {
|
||||
return 'fall';
|
||||
}
|
||||
if ($value === 'spring' || str_contains($value, '2')) {
|
||||
return 'spring';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private function normalizeSchoolYear(?string $input): ?string
|
||||
{
|
||||
$value = trim((string) $input);
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
if (preg_match('/^\\d{4}\\s*[-\\/]\\s*\\d{4}$/', $value)) {
|
||||
return preg_replace('/\\s+/', '', str_replace('/', '-', $value));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,365 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
use App\Controllers\BaseController;
|
||||
use App\Models\PaymentNotificationLogModel;
|
||||
use App\Models\UserModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
use App\Models\InvoiceModel;
|
||||
use App\Models\PaymentModel;
|
||||
use App\Models\FamilyGuardianModel;
|
||||
use App\Services\EmailService;
|
||||
use App\Services\NotificationService;
|
||||
|
||||
class PaymentNotificationController extends BaseController
|
||||
{
|
||||
protected PaymentNotificationLogModel $logModel;
|
||||
protected UserModel $userModel;
|
||||
protected ConfigurationModel $configModel;
|
||||
protected InvoiceModel $invoiceModel;
|
||||
protected PaymentModel $paymentModel;
|
||||
protected FamilyGuardianModel $familyGuardianModel;
|
||||
protected EmailService $emailService;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->logModel = new PaymentNotificationLogModel();
|
||||
$this->userModel = new UserModel();
|
||||
$this->configModel = new ConfigurationModel();
|
||||
$this->invoiceModel = new InvoiceModel();
|
||||
$this->paymentModel = new PaymentModel();
|
||||
$this->familyGuardianModel = new FamilyGuardianModel();
|
||||
$this->emailService = new EmailService();
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
helper(['form', 'url']);
|
||||
|
||||
$from = $this->request->getGet('from');
|
||||
$to = $this->request->getGet('to');
|
||||
$type = $this->request->getGet('type');
|
||||
|
||||
$rows = $this->logModel->listLogs($from, $to, $type);
|
||||
|
||||
// Preload parent names
|
||||
$parents = [];
|
||||
foreach ($rows as $r) {
|
||||
$pid = (int) ($r['parent_id'] ?? 0);
|
||||
if ($pid && !isset($parents[$pid])) {
|
||||
$u = $this->userModel->select('firstname,lastname')->find($pid);
|
||||
$parents[$pid] = trim(($u['firstname'] ?? '') . ' ' . ($u['lastname'] ?? ''));
|
||||
}
|
||||
}
|
||||
|
||||
return view('payment/notification_management', [
|
||||
'logs' => $rows,
|
||||
'parentsById' => $parents,
|
||||
'filters' => ['from' => $from, 'to' => $to, 'type' => $type],
|
||||
]);
|
||||
}
|
||||
|
||||
private function wantsJson(): bool
|
||||
{
|
||||
$accept = strtolower((string)($this->request->getHeaderLine('Accept') ?? ''));
|
||||
$fmt = strtolower((string)($this->request->getGet('format') ?? $this->request->getPost('format') ?? ''));
|
||||
return $this->request->isAJAX() || str_contains($accept, 'application/json') || $fmt === 'json';
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/payments/notifications/send
|
||||
* Body: parent_id? (int), type? ('no_payment'|'installment'), school_year?
|
||||
* - When parent_id provided: send for that parent only (if balance > 0 or force=1)
|
||||
* - Otherwise: send to all parents with positive balance for selected school year
|
||||
* respecting idempotency (1 email per month/type) unless force=1.
|
||||
*/
|
||||
public function send()
|
||||
{
|
||||
$parentId = (int)($this->request->getPost('parent_id') ?? 0);
|
||||
$typeInput = (string)($this->request->getPost('type') ?? '');
|
||||
$force = (int)($this->request->getPost('force') ?? 0) === 1;
|
||||
$schoolYear = (string)($this->request->getPost('school_year') ?? ($this->configModel->getConfig('school_year') ?? date('Y')));
|
||||
$returnTo = (string)($this->request->getPost('return_to') ?? '');
|
||||
|
||||
$tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
|
||||
$tz = new \DateTimeZone($tzName);
|
||||
$now = new \DateTime('now', $tz);
|
||||
$year = (int)$now->format('Y');
|
||||
$month= (int)$now->format('n');
|
||||
|
||||
// Helper: compute current total balance across invoices for this parent/year
|
||||
$computeBalance = function (int $pid) use ($schoolYear): array {
|
||||
$db = \Config\Database::connect();
|
||||
$rows = $db->table('invoices')
|
||||
->select('id, total_amount')
|
||||
->where('parent_id', $pid)
|
||||
->where('school_year', $schoolYear)
|
||||
->get()->getResultArray();
|
||||
$paymentsTbl = 'payments';
|
||||
$hasStatus = $db->fieldExists('status', $paymentsTbl);
|
||||
$hasVoid = $db->fieldExists('is_void', $paymentsTbl);
|
||||
|
||||
$sumBalance = 0.0; $latestId = null;
|
||||
foreach ($rows as $ir) {
|
||||
$iid = (int)$ir['id'];
|
||||
if ($latestId === null) $latestId = $iid;
|
||||
$total = (float)($ir['total_amount'] ?? 0);
|
||||
|
||||
$qb = $db->table('payments')->select('COALESCE(SUM(paid_amount),0) AS tot')->where('invoice_id', $iid);
|
||||
if ($hasStatus) {
|
||||
$qb->groupStart()
|
||||
->whereNotIn('status', ['void','voided','refunded','failed','chargeback','declined','reversed','canceled','cancelled'])
|
||||
->orWhere('status IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
if ($hasVoid) {
|
||||
$qb->groupStart()
|
||||
->where('is_void', 0)
|
||||
->orWhere('is_void IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
$paid = (float)($qb->get()->getRowArray()['tot'] ?? 0);
|
||||
|
||||
$disc = (float)($db->table('discount_usages')->select('COALESCE(SUM(discount_amount),0) AS tot')->where('invoice_id', $iid)->get()->getRowArray()['tot'] ?? 0);
|
||||
$rfnd = (float)($db->table('refunds')->select('COALESCE(SUM(refund_paid_amount),0) AS tot')->where('invoice_id', $iid)->whereIn('status', ['Partial','Paid'])->get()->getRowArray()['tot'] ?? 0);
|
||||
|
||||
$sumBalance += max(0.0, round($total - $disc - $paid - $rfnd, 2));
|
||||
}
|
||||
return [$sumBalance, $latestId];
|
||||
};
|
||||
|
||||
// Helper: get secondary guardian email
|
||||
$getSecondary = function (int $primaryUserId): ?string {
|
||||
$row = $this->familyGuardianModel->where('user_id', $primaryUserId)->first();
|
||||
if (!$row || empty($row['family_id'])) return null;
|
||||
$others = $this->familyGuardianModel
|
||||
->where('family_id', (int)$row['family_id'])
|
||||
->where('user_id !=', $primaryUserId)
|
||||
->where('receive_emails', 1)
|
||||
->findAll();
|
||||
foreach ($others as $g) {
|
||||
$email = $this->userModel->select('email')->find((int)$g['user_id'])['email'] ?? null;
|
||||
if ($email && filter_var($email, FILTER_VALIDATE_EMAIL)) return $email;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Compose subject/body using same content as CLI command
|
||||
$compose = function (int $pid, float $balance) use ($schoolYear, $now): array {
|
||||
$u = $this->userModel->find($pid) ?: [];
|
||||
$parentName = trim(($u['firstname'] ?? '') . ' ' . ($u['lastname'] ?? ''));
|
||||
$greeting = $parentName !== '' ? "Dear {$parentName}," : 'Dear Parent,';
|
||||
$monthYear = $now->format('F Y');
|
||||
$subject = sprintf('Monthly Tuition Reminder — %s', $schoolYear);
|
||||
$balanceFmt = '$' . number_format($balance, 2);
|
||||
$intro = 'This is your monthly installment reminder for ' . $schoolYear . '.';
|
||||
// Compute remaining/max installments similar to Manual Payment UI
|
||||
$installmentEndRaw = (string)$this->configModel->getConfig('installment_date');
|
||||
$tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
|
||||
$tz = new \DateTimeZone($tzName);
|
||||
$today = new \DateTimeImmutable('today', $tz);
|
||||
$end = null; try { if ($installmentEndRaw) { $end = new \DateTimeImmutable($installmentEndRaw, $tz); } } catch (\Throwable $e) { $end = null; }
|
||||
$remMonths = 0;
|
||||
if ($end) {
|
||||
$y = (int)$end->format('Y') - (int)$today->format('Y');
|
||||
$m = (int)$end->format('n') - (int)$today->format('n');
|
||||
$remMonths = $y * 12 + $m;
|
||||
if ((int)$end->format('j') > (int)$today->format('j')) $remMonths += 1;
|
||||
if ($remMonths < 0) $remMonths = 0;
|
||||
}
|
||||
$remainingInst = max(1, $remMonths);
|
||||
$maxInst = max(($remMonths ?: 0), ($balance > 0 ? 2 : 0));
|
||||
$instDueFmt = '$' . number_format($remainingInst > 0 ? ($balance / $remainingInst) : 0, 2);
|
||||
$bodyHtml = <<<HTML
|
||||
<div style="font-family:Arial,Helvetica,sans-serif; font-size:14px; color:#212529; line-height:1.5;">
|
||||
<h2 style="margin:0 0 10px; font-size:18px;">Monthly Tuition Reminder</h2>
|
||||
<p>{$greeting}</p>
|
||||
<p>{$intro}</p>
|
||||
<p><strong>Current Outstanding Balance:</strong> {$balanceFmt}</p>
|
||||
<ul>
|
||||
<li><strong>Remaining installments:</strong> {$remainingInst}</li>
|
||||
<li><strong>Suggested installment this month:</strong> {$instDueFmt}</li>
|
||||
<li><strong>Maximum installments available:</strong> {$maxInst}</li>
|
||||
</ul>
|
||||
<p>As a friendly reminder, our tuition installments are due at the beginning of each month.</p>
|
||||
<p>You can review your invoice and payment options by logging in to your parent portal.</p>
|
||||
<p>Thank you for your prompt attention.</p>
|
||||
<p><em>Reminder Period:</em> {$monthYear}</p>
|
||||
</div>
|
||||
HTML;
|
||||
$body = view('emails/_wrap_layout', [ 'title' => 'Monthly Tuition Reminder', 'body_html' => $bodyHtml ], ['saveData' => true]);
|
||||
return [$subject, $body];
|
||||
};
|
||||
|
||||
$results = [ 'sent' => 0, 'skipped' => 0, 'failed' => 0, 'details' => [] ];
|
||||
|
||||
if ($parentId > 0) {
|
||||
[$balance, $latestInv] = $computeBalance($parentId);
|
||||
|
||||
// detect type if not provided
|
||||
if ($typeInput === 'no_payment' || $typeInput === 'installment') {
|
||||
$type = $typeInput;
|
||||
} else {
|
||||
$hasPayments = $this->paymentModel
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->countAllResults() > 0;
|
||||
$type = $hasPayments ? 'installment' : 'no_payment';
|
||||
}
|
||||
|
||||
// Idempotency: skip if already sent this month for this type
|
||||
if (!$force && $this->logModel->existsForPeriod($parentId, $year, $month, $type)) {
|
||||
$results['skipped']++;
|
||||
$results['details'][] = ['parent_id' => $parentId, 'result' => 'already_sent'];
|
||||
} else {
|
||||
|
||||
if ($balance <= 0 && !$force) {
|
||||
$results['skipped']++;
|
||||
$results['details'][] = ['parent_id' => $parentId, 'result' => 'no_balance'];
|
||||
// For HTML submit, redirect back with details
|
||||
if (!$this->wantsJson()) {
|
||||
$dest = $returnTo !== '' ? $returnTo : site_url('payment/unpaid-parents?school_year=' . rawurlencode($schoolYear));
|
||||
return redirect()->to($dest)->with('status', 'No balance to remind this parent.');
|
||||
}
|
||||
} else {
|
||||
// Compute current-month installment suggestion
|
||||
$installmentEndRaw = (string)$this->configModel->getConfig('installment_date');
|
||||
$tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
|
||||
$tz = new \DateTimeZone($tzName);
|
||||
$nowD = new \DateTimeImmutable('today', $tz);
|
||||
$endD = null; try { if ($installmentEndRaw) $endD = new \DateTimeImmutable($installmentEndRaw, $tz); } catch (\Throwable $e) { $endD = null; }
|
||||
$remMonths = 0;
|
||||
if ($endD) {
|
||||
$y = (int)$endD->format('Y') - (int)$nowD->format('Y');
|
||||
$m = (int)$endD->format('n') - (int)$nowD->format('n');
|
||||
$remMonths = $y * 12 + $m;
|
||||
if ((int)$endD->format('j') > (int)$nowD->format('j')) $remMonths += 1;
|
||||
if ($remMonths < 0) $remMonths = 0;
|
||||
}
|
||||
$remInst = max(1, $remMonths);
|
||||
$instDue = round($balance / $remInst, 2);
|
||||
$toEmail = $this->userModel->select('email')->find($parentId)['email'] ?? null;
|
||||
$toEmail = $toEmail && filter_var($toEmail, FILTER_VALIDATE_EMAIL) ? $toEmail : null;
|
||||
$ccEmail = $getSecondary($parentId);
|
||||
[$subject, $body] = $compose($parentId, $balance);
|
||||
|
||||
$ok = $toEmail ? $this->emailService->send($toEmail, $subject, $body, 'finance') : false;
|
||||
if ($ok && $ccEmail && strcasecmp($ccEmail, (string)$toEmail) !== 0) {
|
||||
$this->emailService->send($ccEmail, $subject, $body, 'finance');
|
||||
}
|
||||
|
||||
$this->logModel->insert([
|
||||
'parent_id' => $parentId,
|
||||
'invoice_id' => $latestInv,
|
||||
'school_year' => $schoolYear,
|
||||
'period_year' => $year,
|
||||
'period_month' => $month,
|
||||
'type' => $type,
|
||||
'to_email' => $toEmail,
|
||||
'cc_email' => $ccEmail,
|
||||
'head_fa_notified' => 0,
|
||||
'subject' => $subject,
|
||||
'body' => $body,
|
||||
'status' => $ok ? 'sent' : 'failed',
|
||||
'error_message' => $ok ? null : 'Missing/invalid email or send failed',
|
||||
'balance_snapshot' => $balance,
|
||||
'sent_at' => $now->format('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
if ($ok) { $results['sent']++; $results['details'][] = ['parent_id' => $parentId, 'result' => 'sent', 'balance' => $balance, 'installment_due' => $instDue, 'remaining' => $remInst]; }
|
||||
else { $results['failed']++; $results['details'][] = ['parent_id' => $parentId, 'result' => 'failed']; }
|
||||
|
||||
// For HTML submit, redirect back with detailed message
|
||||
if (!$this->wantsJson()) {
|
||||
$dest = $returnTo !== '' ? $returnTo : site_url('payment/unpaid-parents?school_year=' . rawurlencode($schoolYear));
|
||||
$msg = sprintf('Reminder sent. Remaining balance: $%s. Current installment due: $%s. Remaining installments: %d',
|
||||
number_format($balance, 2), number_format($instDue, 2), (int)$remInst);
|
||||
return redirect()->to($dest)->with('status', $msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Single-parent branch handled; otherwise do bulk
|
||||
} else {
|
||||
|
||||
// Bulk: parents with positive balance in selected year
|
||||
$db = \Config\Database::connect();
|
||||
$rows = $db->table('invoices')
|
||||
->select('DISTINCT parent_id', false)
|
||||
->where('school_year', $schoolYear)
|
||||
->get()->getResultArray();
|
||||
$pids = array_values(array_unique(array_map(static fn($r) => (int)$r['parent_id'], $rows)));
|
||||
|
||||
foreach ($pids as $pid) {
|
||||
[$balance, $latestInv] = $computeBalance($pid);
|
||||
if ($balance <= 0 && !$force) { $results['skipped']++; continue; }
|
||||
|
||||
$hasPayments = $this->paymentModel
|
||||
->where('parent_id', $pid)
|
||||
->where('school_year', $schoolYear)
|
||||
->countAllResults() > 0;
|
||||
$type = $hasPayments ? 'installment' : 'no_payment';
|
||||
|
||||
if (!$force && $this->logModel->existsForPeriod($pid, $year, $month, $type)) { $results['skipped']++; continue; }
|
||||
|
||||
$toEmail = $this->userModel->select('email')->find($pid)['email'] ?? null;
|
||||
$toEmail = $toEmail && filter_var($toEmail, FILTER_VALIDATE_EMAIL) ? $toEmail : null;
|
||||
$ccEmail = $getSecondary($pid);
|
||||
[$subject, $body] = $compose($pid, $balance);
|
||||
|
||||
$ok = $toEmail ? $this->emailService->send($toEmail, $subject, $body, 'finance') : false;
|
||||
if ($ok && $ccEmail && strcasecmp($ccEmail, (string)$toEmail) !== 0) {
|
||||
$this->emailService->send($ccEmail, $subject, $body, 'finance');
|
||||
}
|
||||
|
||||
// Optional: notify head of finance in-app
|
||||
foreach ($this->getHeadOfFinanceUsers() as $u) {
|
||||
NotificationService::toUser((int)$u['id'], 'Payment Reminder Sent',
|
||||
sprintf('A %s reminder was sent to parent #%d (balance: $%0.2f).', $type, $pid, $balance),
|
||||
['in_app']
|
||||
);
|
||||
}
|
||||
|
||||
$this->logModel->insert([
|
||||
'parent_id' => $pid,
|
||||
'invoice_id' => $latestInv,
|
||||
'school_year' => $schoolYear,
|
||||
'period_year' => $year,
|
||||
'period_month' => $month,
|
||||
'type' => $type,
|
||||
'to_email' => $toEmail,
|
||||
'cc_email' => $ccEmail,
|
||||
'head_fa_notified' => 1,
|
||||
'subject' => $subject,
|
||||
'body' => $body,
|
||||
'status' => $ok ? 'sent' : 'failed',
|
||||
'error_message' => $ok ? null : 'Missing/invalid email or send failed',
|
||||
'balance_snapshot' => $balance,
|
||||
'sent_at' => $now->format('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
if ($ok) $results['sent']++; else $results['failed']++;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Respond
|
||||
if ($this->wantsJson()) {
|
||||
return $this->response->setJSON(['ok' => true] + $results + [
|
||||
'csrf_token' => csrf_token(), 'csrf_hash' => csrf_hash(),
|
||||
]);
|
||||
}
|
||||
|
||||
$msg = sprintf('Reminders — sent: %d, skipped: %d, failed: %d', (int)$results['sent'], (int)$results['skipped'], (int)$results['failed']);
|
||||
$dest = $returnTo !== '' ? $returnTo : site_url('payment/unpaid-parents?school_year=' . rawurlencode($schoolYear));
|
||||
return redirect()->to($dest)->with('status', $msg);
|
||||
}
|
||||
|
||||
private function getHeadOfFinanceUsers(): array
|
||||
{
|
||||
$heads = $this->userModel->getUsersByRole('head of department (finance)');
|
||||
if (!empty($heads)) return $heads;
|
||||
$acct = $this->userModel->getUsersByRole('accountant');
|
||||
return $acct ?: [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Models\PaymentTransactionModel;
|
||||
use CodeIgniter\RESTful\ResourceController;
|
||||
|
||||
class PaymentTransactionController extends ResourceController
|
||||
{
|
||||
protected $paymentTransactionModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->paymentTransactionModel = new PaymentTransactionModel();
|
||||
}
|
||||
|
||||
// API: Create a new payment transaction (installment)
|
||||
public function createAPI()
|
||||
{
|
||||
$data = [
|
||||
'transaction_id' => $this->request->getPost('transaction_id'),
|
||||
'payment_id' => $this->request->getPost('payment_id'),
|
||||
'transaction_date' => $this->request->getPost('transaction_date'),
|
||||
'amount' => $this->request->getPost('amount'),
|
||||
'payment_method' => $this->request->getPost('payment_method'),
|
||||
'payment_status' => 'Pending',
|
||||
'transaction_fee' => $this->request->getPost('transaction_fee'),
|
||||
'payment_reference' => $this->request->getPost('payment_reference'),
|
||||
'is_full_payment' => $this->request->getPost('is_full_payment') ? 1 : 0,
|
||||
];
|
||||
|
||||
if ($this->paymentTransactionModel->save($data)) {
|
||||
return $this->respondCreated($data);
|
||||
} else {
|
||||
return $this->failValidationErrors($this->paymentTransactionModel->errors());
|
||||
}
|
||||
}
|
||||
|
||||
// API: Get all transactions for a specific payment
|
||||
public function getByPaymentAPI($paymentId)
|
||||
{
|
||||
$transactions = $this->paymentTransactionModel->getTransactionsByPaymentId($paymentId);
|
||||
if ($transactions) {
|
||||
return $this->respond($transactions);
|
||||
} else {
|
||||
return $this->failNotFound('Transactions not found for the payment.');
|
||||
}
|
||||
}
|
||||
|
||||
// View: Get all transactions for a specific payment (for web views)
|
||||
public function getByPayment($paymentId)
|
||||
{
|
||||
$transactions = $this->paymentTransactionModel->getTransactionsByPaymentId($paymentId);
|
||||
return view('payment_transaction_list', ['transactions' => $transactions]);
|
||||
}
|
||||
|
||||
// View: Create a new payment transaction
|
||||
public function create()
|
||||
{
|
||||
return view('payment_transaction_create');
|
||||
}
|
||||
|
||||
// API: Update payment status by transaction ID
|
||||
public function updateStatusAPI($transactionId)
|
||||
{
|
||||
$status = $this->request->getPost('status');
|
||||
if ($this->paymentTransactionModel->updateTransactionStatus($transactionId, $status)) {
|
||||
return $this->respond(['status' => 'success']);
|
||||
} else {
|
||||
return $this->failNotFound('Transaction not found.');
|
||||
}
|
||||
}
|
||||
|
||||
// View: Update payment status by transaction ID
|
||||
public function updateStatus($transactionId)
|
||||
{
|
||||
$status = $this->request->getPost('status');
|
||||
if ($this->paymentTransactionModel->updateTransactionStatus($transactionId, $status)) {
|
||||
return redirect()->to('/payment_transactions');
|
||||
} else {
|
||||
return redirect()->back()->with('error', 'Failed to update status.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Models\PayPalPaymentModel;
|
||||
use App\Models\PaymentModel;
|
||||
use App\Models\UserModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
use App\Models\InvoiceModel;
|
||||
|
||||
class PaypalTransactionsController extends BaseController
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$model = new PayPalPaymentModel();
|
||||
|
||||
$keyword = $this->request->getGet('q');
|
||||
$perPage = 10;
|
||||
|
||||
if ($keyword) {
|
||||
$transactions = $model
|
||||
->groupStart()
|
||||
->like('transaction_id', $keyword)
|
||||
->orLike('payer_email', $keyword)
|
||||
->orLike('event_type', $keyword)
|
||||
->orLike('order_id', $keyword)
|
||||
->orLike('parent_school_id', $keyword)
|
||||
->groupEnd()
|
||||
->orderBy('created_at', 'DESC')
|
||||
->paginate($perPage);
|
||||
} else {
|
||||
$transactions = $model
|
||||
->orderBy('created_at', 'DESC')
|
||||
->paginate($perPage);
|
||||
}
|
||||
|
||||
return view('administrator/paypal_transactions', [
|
||||
'transactions' => $transactions,
|
||||
'pager' => $model->pager,
|
||||
'keyword' => $keyword
|
||||
]);
|
||||
}
|
||||
|
||||
public function exportCsv()
|
||||
{
|
||||
$model = new PayPalPaymentModel();
|
||||
$keyword = $this->request->getGet('q');
|
||||
|
||||
if ($keyword) {
|
||||
$transactions = $model
|
||||
->groupStart()
|
||||
->like('transaction_id', $keyword)
|
||||
->orLike('payer_email', $keyword)
|
||||
->orLike('event_type', $keyword)
|
||||
->orLike('order_id', $keyword)
|
||||
->orLike('parent_school_id', $keyword)
|
||||
->groupEnd()
|
||||
->orderBy('created_at', 'DESC')
|
||||
->findAll();
|
||||
} else {
|
||||
$transactions = $model->orderBy('created_at', 'DESC')->findAll();
|
||||
}
|
||||
|
||||
$filename = 'paypal_transactions_' . date('Ymd_His') . '.csv';
|
||||
|
||||
header('Content-Type: text/csv');
|
||||
header("Content-Disposition: attachment; filename=\"$filename\"");
|
||||
|
||||
$output = fopen('php://output', 'w');
|
||||
|
||||
// CSV headers
|
||||
fputcsv($output, [
|
||||
'ID', 'Transaction ID', 'Order ID', 'Parent School ID',
|
||||
'Email', 'Amount', 'Net Amount', 'Currency',
|
||||
'Status', 'Event Type', 'Created At'
|
||||
]);
|
||||
|
||||
foreach ($transactions as $t) {
|
||||
fputcsv($output, [
|
||||
$t['id'],
|
||||
$t['transaction_id'],
|
||||
$t['order_id'],
|
||||
$t['parent_school_id'],
|
||||
$t['payer_email'],
|
||||
$t['amount'],
|
||||
$t['net_amount'],
|
||||
$t['currency'],
|
||||
$t['status'],
|
||||
$t['event_type'],
|
||||
$t['created_at'],
|
||||
]);
|
||||
}
|
||||
|
||||
fclose($output);
|
||||
exit;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
|
||||
|
||||
helper('document');
|
||||
|
||||
class PolicyController extends BaseController
|
||||
{
|
||||
|
||||
|
||||
public function index()
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
public function schoolPicturePolicy()
|
||||
{
|
||||
// FIXED path: correct spelling of "policy"
|
||||
$content = include(APPPATH . 'Views/policy/picture_policy_partial.php');
|
||||
|
||||
// Pass the content to the view
|
||||
return view('/policy/picture_policy', $content);
|
||||
}
|
||||
|
||||
public function schoolPolicy()
|
||||
{
|
||||
// FIXED path: correct spelling of "policy"
|
||||
$content = include(APPPATH . 'Views/policy/school_policy_partial.php');
|
||||
|
||||
return view('policy/school_policy', $content);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Models\PreferencesModel;
|
||||
|
||||
class PreferencesController extends BaseController
|
||||
{
|
||||
protected $preferencesModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->preferencesModel = new PreferencesModel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Display preferences page
|
||||
* GET /preferences/{userId}
|
||||
*/
|
||||
public function index($userId = null)
|
||||
{
|
||||
// Get user ID from parameter or session
|
||||
$userId = $userId ?? (int) session()->get('user_id');
|
||||
|
||||
if (!$userId) {
|
||||
return redirect()->to('/login')->with('error', 'Please log in to view preferences');
|
||||
}
|
||||
|
||||
// Fetch preferences for the current user
|
||||
$preferences = $this->preferencesModel->where('user_id', $userId)->first();
|
||||
|
||||
// If preferences do not exist, set default preferences
|
||||
if (!$preferences) {
|
||||
$preferences = [
|
||||
'receive_email_notifications' => 1,
|
||||
'receive_sms_notifications' => 1,
|
||||
'theme' => 'light',
|
||||
'language' => 'en',
|
||||
];
|
||||
}
|
||||
|
||||
// Append style/menu selections from session or defaults
|
||||
$styleConfig = config('Style');
|
||||
$preferences['style_color'] = $preferences['style_color'] ?? (session()->get('style_color') ?? ($styleConfig->defaultStyle ?? 'blue'));
|
||||
$preferences['menu_color'] = $preferences['menu_color'] ?? (session()->get('menu_color') ?? ($styleConfig->defaultMenu ?? 'white'));
|
||||
|
||||
// Options for selectors
|
||||
$styleOptions = array_keys($styleConfig->stylePalettes ?? []);
|
||||
$menuOptions = array_keys($styleConfig->menuPalettes ?? []);
|
||||
|
||||
// Load the view with the user's preferences
|
||||
return view('/preferences', [
|
||||
'preferences' => $preferences,
|
||||
'styleOptions' => $styleOptions,
|
||||
'menuOptions' => $menuOptions,
|
||||
'userId' => $userId,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update preferences
|
||||
* POST /preferences/update/{userId}
|
||||
*/
|
||||
public function updatePreferences($userId = null)
|
||||
{
|
||||
// Get user ID from parameter or session
|
||||
$userId = $userId ?? (int) session()->get('user_id');
|
||||
|
||||
if (!$userId) {
|
||||
return redirect()->to('/login')->with('error', 'Please log in to update preferences');
|
||||
}
|
||||
|
||||
// Validation rules
|
||||
$validation = \Config\Services::validation();
|
||||
|
||||
$styleConfig = config('Style');
|
||||
$styleOptions = implode(',', array_keys($styleConfig->stylePalettes ?? []));
|
||||
$menuOptions = implode(',', array_keys($styleConfig->menuPalettes ?? []));
|
||||
|
||||
$validation->setRules([
|
||||
'receive_email_notifications' => 'permit_empty|in_list[0,1]',
|
||||
'receive_sms_notifications' => 'permit_empty|in_list[0,1]',
|
||||
'theme' => 'permit_empty|in_list[light,dark]',
|
||||
'language' => 'permit_empty|in_list[en,fr,es]',
|
||||
'style_color' => $styleOptions ? 'permit_empty|in_list[' . $styleOptions . ']' : 'permit_empty',
|
||||
'menu_color' => $menuOptions ? 'permit_empty|in_list[' . $menuOptions . ']' : 'permit_empty',
|
||||
]);
|
||||
|
||||
if (!$validation->run($this->request->getPost())) {
|
||||
$preferences = $this->preferencesModel->where('user_id', $userId)->first();
|
||||
if (!$preferences) {
|
||||
$preferences = [
|
||||
'receive_email_notifications' => 1,
|
||||
'receive_sms_notifications' => 1,
|
||||
'theme' => 'light',
|
||||
'language' => 'en',
|
||||
];
|
||||
}
|
||||
$preferences['style_color'] = $this->request->getPost('style_color') ?? ($preferences['style_color'] ?? (session()->get('style_color') ?? ($styleConfig->defaultStyle ?? 'blue')));
|
||||
$preferences['menu_color'] = $this->request->getPost('menu_color') ?? ($preferences['menu_color'] ?? (session()->get('menu_color') ?? ($styleConfig->defaultMenu ?? 'white')));
|
||||
|
||||
return view('/preferences', [
|
||||
'preferences' => $preferences,
|
||||
'styleOptions' => array_keys($styleConfig->stylePalettes ?? []),
|
||||
'menuOptions' => array_keys($styleConfig->menuPalettes ?? []),
|
||||
'validation' => $validation,
|
||||
'userId' => $userId,
|
||||
]);
|
||||
}
|
||||
|
||||
// Prepare updated data (map form fields to database fields)
|
||||
$updateData = [
|
||||
'receive_email_notifications' => $this->request->getPost('receive_email_notifications') ?? $this->request->getPost('notification_email') ?? 1,
|
||||
'receive_sms_notifications' => $this->request->getPost('receive_sms_notifications') ?? $this->request->getPost('notification_sms') ?? 1,
|
||||
'theme' => $this->request->getPost('theme') ?? 'light',
|
||||
'language' => $this->request->getPost('language') ?? 'en',
|
||||
];
|
||||
|
||||
// Store style/menu selections in session (no DB column dependency)
|
||||
$styleColor = $this->request->getPost('style_color');
|
||||
$menuColor = $this->request->getPost('menu_color');
|
||||
if ($styleColor && isset(($styleConfig->stylePalettes ?? [])[$styleColor])) {
|
||||
session()->set('style_color', $styleColor);
|
||||
$updateData['style_color'] = $styleColor;
|
||||
}
|
||||
if ($menuColor && isset(($styleConfig->menuPalettes ?? [])[$menuColor])) {
|
||||
session()->set('menu_color', $menuColor);
|
||||
$updateData['menu_color'] = $menuColor;
|
||||
}
|
||||
|
||||
// Check if preferences already exist, update if they do, otherwise insert new preferences
|
||||
$existing = $this->preferencesModel->where('user_id', $userId)->first();
|
||||
if ($existing) {
|
||||
$this->preferencesModel->update($existing['id'], $updateData);
|
||||
} else {
|
||||
$updateData['user_id'] = $userId;
|
||||
$this->preferencesModel->insert($updateData);
|
||||
}
|
||||
|
||||
// Redirect back to preferences page with success message
|
||||
return redirect()->to('/preferences/' . $userId)->with('success', 'Preferences updated successfully');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Models\AttendanceRecordModel;
|
||||
use App\Models\BadgePrintLogModel;
|
||||
use App\Models\ClassSectionModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
use App\Models\ScoreCommentModel;
|
||||
use App\Models\SemesterScoreModel;
|
||||
use App\Models\StaffModel;
|
||||
use App\Models\StudentClassModel;
|
||||
use App\Models\StudentModel;
|
||||
use App\Models\TeacherClassModel;
|
||||
use App\Models\UserModel;
|
||||
|
||||
require_once APPPATH . 'ThirdParty/fpdf/fpdf.php';
|
||||
|
||||
class PrintablesBaseController extends BaseController
|
||||
{
|
||||
protected $userModel;
|
||||
protected $studentModel;
|
||||
protected $db;
|
||||
protected $classSectionModel;
|
||||
protected $configModel;
|
||||
protected $schoolYear;
|
||||
protected $semester;
|
||||
protected $staffModel;
|
||||
protected $teacherClassModel;
|
||||
protected $semesterScoreModel;
|
||||
protected $studentClassModel;
|
||||
protected $stickerWidth;
|
||||
protected $stickerHeight;
|
||||
protected $pageW;
|
||||
protected $pageH;
|
||||
protected $marginL;
|
||||
protected $marginT;
|
||||
protected $gapX;
|
||||
protected $gapY;
|
||||
protected $badgePrintLogModel;
|
||||
protected $scoreCommentModel;
|
||||
protected $attendanceRecordModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->db = \Config\Database::connect();
|
||||
$this->userModel = new UserModel();
|
||||
$this->studentModel = new StudentModel();
|
||||
$this->classSectionModel = new ClassSectionModel();
|
||||
$this->configModel = new ConfigurationModel();
|
||||
$this->semesterScoreModel = new SemesterScoreModel();
|
||||
$this->studentClassModel = new StudentClassModel();
|
||||
$this->teacherClassModel = new TeacherClassModel();
|
||||
$this->staffModel = new StaffModel();
|
||||
$this->badgePrintLogModel = new BadgePrintLogModel();
|
||||
$this->scoreCommentModel = new ScoreCommentModel();
|
||||
$this->attendanceRecordModel = new AttendanceRecordModel();
|
||||
|
||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||
$this->semester = $this->configModel->getConfig('semester');
|
||||
$this->stickerWidth = $this->configModel->getConfig('stickerWidth');
|
||||
$this->stickerHeight = $this->configModel->getConfig('stickerHeight');
|
||||
$this->pageW = $this->configModel->getConfig('pageWidth');
|
||||
$this->pageH = $this->configModel->getConfig('pageHeight');
|
||||
$this->marginL = $this->configModel->getConfig('leftMargin');
|
||||
$this->marginT = $this->configModel->getConfig('topMargin');
|
||||
$this->gapX = $this->configModel->getConfig('horizontalGap');
|
||||
$this->gapY = $this->configModel->getConfig('verticalGap');
|
||||
}
|
||||
|
||||
protected function firstExisting(array $paths)
|
||||
{
|
||||
foreach ($paths as $p) {
|
||||
if (is_string($p) && file_exists($p)) {
|
||||
return $p;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function resolveClassName($db, $classId)
|
||||
{
|
||||
$tables = [
|
||||
['classSection', 'class_section_id'],
|
||||
['classSection', 'id'],
|
||||
['class_sections', 'class_section_id'],
|
||||
['class_sections', 'id'],
|
||||
];
|
||||
|
||||
foreach ($tables as [$table, $pk]) {
|
||||
$result = $db->table($table)
|
||||
->select('class_section_name')
|
||||
->where($pk, $classId)
|
||||
->get()
|
||||
->getRowArray();
|
||||
|
||||
if ($result) {
|
||||
return $result['class_section_name'] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function fetchStudentsByClass(int $sectionId, ?string $schoolYear)
|
||||
{
|
||||
$b = $this->studentClassModel
|
||||
->select('s.id, s.firstname, s.lastname, s.registration_grade, s.gender')
|
||||
->join('students s', 's.id = student_class.student_id', 'inner')
|
||||
->where('student_class.class_section_id', $sectionId)
|
||||
->orderBy('s.lastname', 'ASC');
|
||||
|
||||
if (!empty($schoolYear)) {
|
||||
$b->where('student_class.school_year', $schoolYear);
|
||||
}
|
||||
|
||||
return $b->findAll();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,540 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Models\ProjectModel;
|
||||
use App\Models\StudentModel;
|
||||
use App\Models\StudentClassModel;
|
||||
use CodeIgniter\Controller;
|
||||
use App\Models\TeacherClassModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
use RuntimeException;
|
||||
use App\Services\SemesterScoreService;
|
||||
use Config\Services;
|
||||
use App\Models\GradingLockModel;
|
||||
use App\Models\MissingScoreOverrideModel;
|
||||
|
||||
|
||||
class ProjectController extends Controller
|
||||
{
|
||||
protected $db;
|
||||
protected ConfigurationModel $configModel;
|
||||
protected string $schoolYear;
|
||||
protected $semesterScoreService;
|
||||
protected $gradingLockModel;
|
||||
protected $missingScoreOverrideModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->semesterScoreService = service('semesterScoreService');
|
||||
$this->db = \Config\Database::connect();
|
||||
$this->configModel = new ConfigurationModel();
|
||||
$this->schoolYear = (string) ($this->configModel->getConfig('school_year') ?? '');
|
||||
$this->gradingLockModel = new GradingLockModel();
|
||||
$this->missingScoreOverrideModel = new MissingScoreOverrideModel();
|
||||
}
|
||||
|
||||
public function addProject()
|
||||
{
|
||||
$updatedBy = session()->get('user_id');
|
||||
$teacherClassModel = new TeacherClassModel();
|
||||
$studentClassModel = new StudentClassModel();
|
||||
$studentModel = new StudentModel();
|
||||
$projectModel = new ProjectModel();
|
||||
|
||||
$teacherClass = $teacherClassModel->where('teacher_id', $updatedBy)->first();
|
||||
if (!$teacherClass) {
|
||||
return redirect()->back()->with('status', 'No class section found for the current teacher.');
|
||||
}
|
||||
|
||||
$classSectionId = $teacherClass['class_section_id'];
|
||||
session()->set('class_section_id', $classSectionId);
|
||||
|
||||
$semester = $this->getTeacherSelectedSemester();
|
||||
$schoolYear = $this->getTeacherSchoolYear();
|
||||
|
||||
$projectRows = $projectModel
|
||||
->select('id, project_index')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->orderBy('project_index', 'ASC')
|
||||
->findAll();
|
||||
|
||||
$projectHeaders = [];
|
||||
foreach ($projectRows as $row) {
|
||||
$projectHeaders[$row['project_index']][] = $row['id'];
|
||||
}
|
||||
|
||||
$headerLabels = array_keys($projectHeaders);
|
||||
|
||||
$studentsClasses = $studentClassModel
|
||||
->active()
|
||||
->where('student_class.class_section_id', $classSectionId)
|
||||
->findAll();
|
||||
$students = [];
|
||||
|
||||
foreach ($studentsClasses as $studentClass) {
|
||||
$studentId = $studentClass['student_id'];
|
||||
$student = $studentModel
|
||||
->where('id', $studentId)
|
||||
->where('is_active', 1)
|
||||
->first();
|
||||
|
||||
if ($student) {
|
||||
$scores = [];
|
||||
foreach ($projectHeaders as $index => $ids) {
|
||||
$entry = $projectModel
|
||||
->where('student_id', $studentId)
|
||||
->where('project_index', $index)
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->first();
|
||||
|
||||
$scores[$index] = $entry['score'] ?? '';
|
||||
}
|
||||
|
||||
$students[] = [
|
||||
'student_id' => $studentId,
|
||||
'firstname' => $student['firstname'],
|
||||
'lastname' => $student['lastname'],
|
||||
'scores' => $scores
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
usort($students, fn($a, $b) => strcmp($a['lastname'], $b['lastname']) ?: strcmp($a['firstname'], $b['firstname']));
|
||||
|
||||
return view('/teacher/add_project', [
|
||||
'students' => $students,
|
||||
'projectHeaders' => $headerLabels,
|
||||
'semester' => $semester,
|
||||
'schoolYear' => $schoolYear,
|
||||
'missingOkMap' => $this->missingScoreOverrideModel->getOverridesMap($classSectionId, $semester, $schoolYear, 'project'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateProjectScores(array $scores = null, int $updatedBy = null, int $classSectionId = null)
|
||||
{
|
||||
$scores = $this->request->getPost('scores');
|
||||
$projectModel = new ProjectModel();
|
||||
$studentModel = new StudentModel();
|
||||
|
||||
if ($updatedBy === null) {
|
||||
$updatedBy = session()->get('user_id');
|
||||
}
|
||||
$classSectionId = session()->get('class_section_id');
|
||||
$semester = $this->getTeacherSelectedSemester();
|
||||
$schoolYear = $this->getTeacherSchoolYear();
|
||||
$classSectionId = (int) ($classSectionId ?? 0);
|
||||
if ($classSectionId > 0 && $this->isScoresLocked($classSectionId, $semester, $schoolYear)) {
|
||||
return redirect()->back()->with('error', 'Scores are locked for this class. Unlock to edit.');
|
||||
}
|
||||
|
||||
if (!is_array($scores)) {
|
||||
return redirect()->back()->with('error', 'No project scores submitted.');
|
||||
}
|
||||
|
||||
$missingOk = $this->request->getPost('missing_ok') ?? [];
|
||||
$studentIds = array_keys($scores);
|
||||
$indexSet = [];
|
||||
foreach ($scores as $studentId => $projects) {
|
||||
if (!is_array($projects)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($projects as $index => $score) {
|
||||
if (is_numeric($index)) {
|
||||
$indexSet[(int)$index] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
$checkedItems = [];
|
||||
if (is_array($missingOk)) {
|
||||
foreach ($missingOk as $studentId => $indexes) {
|
||||
if (!is_array($indexes)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($indexes as $index => $flag) {
|
||||
if (!is_numeric($index)) {
|
||||
continue;
|
||||
}
|
||||
$checkedItems[] = [
|
||||
'student_id' => (int) $studentId,
|
||||
'item_index' => (int) $index,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
$this->missingScoreOverrideModel->replaceOverrides(
|
||||
$classSectionId,
|
||||
(string) $semester,
|
||||
(string) $schoolYear,
|
||||
'project',
|
||||
$studentIds,
|
||||
array_keys($indexSet),
|
||||
$checkedItems,
|
||||
$updatedBy
|
||||
);
|
||||
|
||||
foreach ($scores as $studentId => $projects) {
|
||||
if (!is_numeric($studentId)) continue;
|
||||
|
||||
$student = $studentModel->find($studentId);
|
||||
if (!$student) continue;
|
||||
|
||||
foreach ($projects as $index => $score) {
|
||||
$normalizedScore = is_numeric($score) ? (float) $score : null;
|
||||
|
||||
$existing = $projectModel->where([
|
||||
'student_id' => $studentId,
|
||||
'project_index' => $index,
|
||||
'class_section_id' => $classSectionId,
|
||||
'semester' => $semester
|
||||
])->first();
|
||||
|
||||
$data = [
|
||||
'student_id' => $studentId,
|
||||
'school_id' => $student['school_id'],
|
||||
'class_section_id' => $classSectionId,
|
||||
'updated_by' => $updatedBy,
|
||||
'project_index' => $index,
|
||||
'score' => $normalizedScore,
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
'updated_at' => utc_now(),
|
||||
];
|
||||
|
||||
if ($existing) {
|
||||
$projectModel->update($existing['id'], $data);
|
||||
} else {
|
||||
$data['created_at'] = utc_now();
|
||||
$projectModel->insert($data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$studentTeacherInfo = $studentModel->getStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear);
|
||||
// Call the updateScoresForStudents method
|
||||
try {
|
||||
|
||||
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo, $semester, $schoolYear);
|
||||
} catch (RuntimeException $e) {
|
||||
// Handle error
|
||||
}
|
||||
return redirect()->to(base_url('/teacher/addProject'))->with('status', 'Project scores updated successfully.');
|
||||
}
|
||||
|
||||
public function addNextProjectColumn()
|
||||
{
|
||||
$updatedBy = session()->get('user_id');
|
||||
$classSectionId = session()->get('class_section_id');
|
||||
$semester = $this->getTeacherSelectedSemester();
|
||||
$schoolYear = $this->getTeacherSchoolYear();
|
||||
|
||||
$projectModel = new ProjectModel();
|
||||
$studentClassModel = new StudentClassModel();
|
||||
|
||||
$existingIndexes = $projectModel
|
||||
->select('project_index')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('updated_by', $updatedBy)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->groupBy('project_index')
|
||||
->orderBy('project_index', 'DESC')
|
||||
->findAll();
|
||||
|
||||
$maxIndex = 0;
|
||||
foreach ($existingIndexes as $row) {
|
||||
if (isset($row['project_index']) && is_numeric($row['project_index'])) {
|
||||
$maxIndex = max($maxIndex, (int)$row['project_index']);
|
||||
}
|
||||
}
|
||||
|
||||
$nextIndex = $maxIndex + 1;
|
||||
|
||||
$students = $studentClassModel
|
||||
->active()
|
||||
->where('student_class.class_section_id', $classSectionId)
|
||||
->findAll();
|
||||
|
||||
foreach ($students as $student) {
|
||||
$exists = $projectModel->where([
|
||||
'student_id' => $student['student_id'],
|
||||
'project_index' => $nextIndex,
|
||||
'class_section_id' => $classSectionId,
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
])->first();
|
||||
|
||||
if (!$exists) {
|
||||
$projectModel->insert([
|
||||
'student_id' => $student['student_id'],
|
||||
'class_section_id' => $classSectionId,
|
||||
'updated_by' => $updatedBy,
|
||||
'project_index' => $nextIndex,
|
||||
'score' => null,
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
'created_at' => utc_now(),
|
||||
'updated_at' => utc_now()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->response->setJSON([
|
||||
'status' => 'success',
|
||||
'project_index' => $nextIndex
|
||||
]);
|
||||
}
|
||||
|
||||
public function showProjectMngt()
|
||||
{
|
||||
// Accept POST first, then GET (query string), then session fallback
|
||||
$incomingId = $this->request->getPost('class_section_id');
|
||||
if (!$incomingId) {
|
||||
$incomingId = $this->request->getGet('class_section_id');
|
||||
}
|
||||
|
||||
$classSectionId = (int) ($incomingId ?: (session()->get('class_section_id') ?? 0));
|
||||
|
||||
if ($classSectionId <= 0) {
|
||||
return redirect()->back()->with('status', 'No class section found for the current teacher or selection.');
|
||||
}
|
||||
|
||||
session()->set('class_section_id', $classSectionId);
|
||||
|
||||
$semester = $this->getTeacherSelectedSemester();
|
||||
$schoolYear = $this->getTeacherSchoolYear();
|
||||
$projectScores = $this->getProjectScoresByStudent($classSectionId, $semester, $schoolYear);
|
||||
$students = $this->getStudentsByClassSectionAndYear($classSectionId, $schoolYear);
|
||||
$projectHeaders = $this->getProjectHeaders($classSectionId, $semester, $schoolYear);
|
||||
$scoresLocked = $this->isScoresLocked($classSectionId, $semester, $schoolYear);
|
||||
|
||||
return view('grading/project', [
|
||||
'projectScores' => $projectScores,
|
||||
'students' => $students,
|
||||
'semester' => $semester,
|
||||
'schoolYear' => $schoolYear,
|
||||
'projectHeaders' => $projectHeaders,
|
||||
'classSectionId' => $classSectionId,
|
||||
'scoresLocked' => $scoresLocked,
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateProject()
|
||||
{
|
||||
$studentModel = new StudentModel();
|
||||
$scores = $this->request->getPost('scores');
|
||||
$studentIds = $this->request->getPost('student_ids');
|
||||
$semester = $this->request->getPost('semester') ?? $this->getTeacherSelectedSemester();
|
||||
$schoolYear = $this->request->getPost('school_year') ?? $this->getTeacherSchoolYear();
|
||||
|
||||
$updatedBy = $this->request->getPost('teacher_id') ?? session()->get('user_id');
|
||||
$classSectionId = $this->request->getPost('class_section_id') ?? session()->get('class_section_id'); // already stored in showProjectMngt
|
||||
$classSectionId = (int) ($classSectionId ?? 0);
|
||||
if ($classSectionId > 0 && $this->isScoresLocked($classSectionId, $semester, $schoolYear)) {
|
||||
return redirect()->back()->with('error', 'Scores are locked for this class. Unlock to edit.');
|
||||
}
|
||||
|
||||
if ($scores && $studentIds && $semester && $schoolYear && $classSectionId) {
|
||||
$this->updateProjectScores($scores, $updatedBy, $classSectionId);
|
||||
session()->setFlashdata('status', 'Project scores updated successfully.');
|
||||
|
||||
// ⬇️ Directly call the method and return its view
|
||||
return $this->showProjectMngt($updatedBy);
|
||||
}
|
||||
|
||||
$studentTeacherInfo = $studentModel->getStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear);
|
||||
// Call the updateScoresForStudents method
|
||||
try {
|
||||
|
||||
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo, $semester, $schoolYear);
|
||||
} catch (RuntimeException $e) {
|
||||
// Handle error
|
||||
}
|
||||
return redirect()->back()->with('status', 'Failed to update scores.');
|
||||
}
|
||||
|
||||
private function isScoresLocked(int $classSectionId, string $semester, string $schoolYear): bool
|
||||
{
|
||||
return $this->gradingLockModel->isLocked($classSectionId, $semester, $schoolYear);
|
||||
}
|
||||
|
||||
/**____________________helpers________________________ */
|
||||
|
||||
private function saveProjectScores(array $scores, array $studentIds, string $semester, string $schoolYear, int $updatedBy, int $classSectionId)
|
||||
{
|
||||
$projectModel = new \App\Models\ProjectModel();
|
||||
$now = utc_now();
|
||||
|
||||
foreach ($studentIds as $studentId) {
|
||||
if (!isset($scores[$studentId])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($scores[$studentId] as $index => $score) {
|
||||
// Check if the record exists
|
||||
$existing = $projectModel
|
||||
->where('student_id', $studentId)
|
||||
->where('project_index', $index)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('updated_by', $updatedBy)
|
||||
->first();
|
||||
|
||||
$data = [
|
||||
'student_id' => $studentId,
|
||||
'class_section_id' => $classSectionId,
|
||||
'updated_by' => $updatedBy,
|
||||
'project_index' => $index,
|
||||
'score' => is_numeric($score) ? floatval($score) : null,
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
if ($existing && isset($existing['id'])) {
|
||||
$projectModel->update($existing['id'], $data);
|
||||
} else {
|
||||
$data['created_at'] = $now;
|
||||
$data['school_id'] = ''; // Set if needed
|
||||
$projectModel->insert($data);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function getProjectHeaders($classSectionId, $semester, $schoolYear)
|
||||
{
|
||||
$projectModel = new ProjectModel();
|
||||
$rows = $projectModel
|
||||
->select('project_index')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->orderBy('project_index', 'ASC')
|
||||
->findAll();
|
||||
|
||||
$headers = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$index = $row['project_index'];
|
||||
if (!is_array($index)) {
|
||||
$headers[$index] = true;
|
||||
}
|
||||
}
|
||||
|
||||
ksort($headers);
|
||||
return array_keys($headers);
|
||||
}
|
||||
private function getStudentsByClassSectionAndYear($classSectionId, $schoolYear)
|
||||
{
|
||||
$studentClassModel = new \App\Models\StudentClassModel();
|
||||
$studentModel = new \App\Models\StudentModel();
|
||||
|
||||
// Step 1: Get student IDs from student_class table
|
||||
$studentClassRows = $studentClassModel
|
||||
->select('student_id')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('school_year', $schoolYear)
|
||||
->findAll();
|
||||
|
||||
$studentIds = array_column($studentClassRows, 'student_id');
|
||||
|
||||
if (empty($studentIds)) {
|
||||
return []; // No students found
|
||||
}
|
||||
|
||||
// Step 2: Get student data from students table
|
||||
$students = $studentModel
|
||||
->whereIn('id', $studentIds)
|
||||
->where('is_active', 1)
|
||||
->orderBy('lastname', 'ASC')
|
||||
->findAll();
|
||||
|
||||
return $students;
|
||||
}
|
||||
|
||||
private function getClassSectionIdForTeacher($updatedBy)
|
||||
{
|
||||
$teacherClassModel = new TeacherClassModel();
|
||||
$class = $teacherClassModel->where('teacher_id', $updatedBy)->first();
|
||||
return $class['class_section_id'] ?? null;
|
||||
}
|
||||
|
||||
private function getProjectScoresByStudent($classSectionId, $semester, $schoolYear)
|
||||
{
|
||||
$projectModel = new ProjectModel();
|
||||
|
||||
$rows = $projectModel
|
||||
->select('student_id, project_index, score')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->findAll();
|
||||
|
||||
$studentScores = [];
|
||||
foreach ($rows as $row) {
|
||||
$studentId = $row['student_id'];
|
||||
$index = $row['project_index'];
|
||||
$score = $row['score'];
|
||||
|
||||
$studentScores[$studentId]['scores'][$index] = $score;
|
||||
}
|
||||
|
||||
return $studentScores;
|
||||
}
|
||||
|
||||
private function getStudentsWithProjectScores($classSectionId, $projectHeaders)
|
||||
{
|
||||
$studentClassModel = new StudentClassModel();
|
||||
$studentModel = new StudentModel();
|
||||
$projectModel = new ProjectModel();
|
||||
|
||||
$studentClasses = $studentClassModel
|
||||
->active()
|
||||
->where('student_class.class_section_id', $classSectionId)
|
||||
->findAll();
|
||||
$students = [];
|
||||
|
||||
foreach ($studentClasses as $sc) {
|
||||
$student = $studentModel
|
||||
->where('id', $sc['student_id'])
|
||||
->where('is_active', 1)
|
||||
->first();
|
||||
if (!$student) continue;
|
||||
|
||||
$scores = [];
|
||||
foreach ($projectHeaders as $index => $ids) {
|
||||
$entry = $projectModel->where('project_index', $index)
|
||||
->where('student_id', $sc['student_id'])
|
||||
->first();
|
||||
$scores[$index] = $entry['score'] ?? '';
|
||||
}
|
||||
|
||||
$students[] = [
|
||||
'student_id' => $sc['student_id'],
|
||||
'firstname' => $student['firstname'],
|
||||
'lastname' => $student['lastname'],
|
||||
'scores' => $scores
|
||||
];
|
||||
}
|
||||
|
||||
usort($students, fn($a, $b) => strcmp($a['lastname'], $b['lastname']) ?: strcmp($a['firstname'], $b['firstname']));
|
||||
return $students;
|
||||
}
|
||||
|
||||
private function getTeacherSelectedSemester(): string
|
||||
{
|
||||
helper('semester_selection_helper');
|
||||
return selected_teacher_semester();
|
||||
}
|
||||
|
||||
private function getTeacherSchoolYear(): string
|
||||
{
|
||||
return (string) $this->schoolYear;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Models\PurchaseOrderModel;
|
||||
use App\Models\PurchaseOrderItemModel;
|
||||
use App\Models\SupplierModel;
|
||||
use App\Models\SupplyModel;
|
||||
use App\Models\SupplyTransactionModel;
|
||||
|
||||
class PurchaseOrderController extends BaseController
|
||||
{
|
||||
protected $poModel;
|
||||
protected $itemModel;
|
||||
protected $supplierModel;
|
||||
protected $supplyModel;
|
||||
protected $txnModel;
|
||||
protected $db;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->poModel = new PurchaseOrderModel();
|
||||
$this->itemModel = new PurchaseOrderItemModel();
|
||||
$this->supplierModel = new SupplierModel();
|
||||
$this->supplyModel = new SupplyModel();
|
||||
$this->txnModel = new SupplyTransactionModel();
|
||||
$this->db = \Config\Database::connect();
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$q = trim($this->request->getGet('q') ?? '');
|
||||
$builder = $this->poModel->select('purchase_orders.*, suppliers.name AS supplier_name')
|
||||
->join('suppliers', 'suppliers.id = purchase_orders.supplier_id', 'left');
|
||||
|
||||
if ($q !== '') {
|
||||
$builder->groupStart()
|
||||
->like('po_number', $q)
|
||||
->orLike('suppliers.name', $q)
|
||||
->groupEnd();
|
||||
}
|
||||
|
||||
$orders = $builder->orderBy('purchase_orders.created_at', 'DESC')->paginate(20);
|
||||
return view('inventory/po_index', [
|
||||
'orders' => $orders,
|
||||
'pager' => $this->poModel->pager,
|
||||
'q' => $q,
|
||||
]);
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
return view('inventory/po_form', [
|
||||
'title' => 'Create Purchase Order',
|
||||
'action' => site_url('inventory/po/store'),
|
||||
'suppliers' => $this->supplierModel->orderBy('name')->findAll(),
|
||||
'supplies' => $this->supplyModel->orderBy('name')->findAll(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store()
|
||||
{
|
||||
$po = $this->request->getPost([
|
||||
'po_number','supplier_id','order_date','expected_date','notes'
|
||||
]);
|
||||
$status = $this->request->getPost('status') ?? 'ordered';
|
||||
|
||||
$supply_ids = $this->request->getPost('item_supply_id') ?? [];
|
||||
$descs = $this->request->getPost('item_description') ?? [];
|
||||
$qtys = $this->request->getPost('item_quantity') ?? [];
|
||||
$unit_costs = $this->request->getPost('item_unit_cost') ?? [];
|
||||
|
||||
if (empty($supply_ids)) {
|
||||
return redirect()->back()->withInput()->with('error', 'Add at least one line item.');
|
||||
}
|
||||
|
||||
// compute totals
|
||||
$subtotal = 0.0;
|
||||
$items = [];
|
||||
foreach ($supply_ids as $i => $sid) {
|
||||
$q = max(0, (int)($qtys[$i] ?? 0));
|
||||
$uc = (float)($unit_costs[$i] ?? 0);
|
||||
if ($sid && $q > 0) {
|
||||
$line = $q * $uc;
|
||||
$subtotal += $line;
|
||||
$items[] = [
|
||||
'supply_id' => (int)$sid,
|
||||
'description' => trim($descs[$i] ?? ''),
|
||||
'quantity' => $q,
|
||||
'unit_cost' => $uc,
|
||||
'received_qty'=> 0,
|
||||
];
|
||||
}
|
||||
}
|
||||
if (!$items) {
|
||||
return redirect()->back()->withInput()->with('error', 'Valid line items required.');
|
||||
}
|
||||
$tax = 0.00;
|
||||
$total = $subtotal + $tax;
|
||||
|
||||
$this->db->transStart();
|
||||
|
||||
$po['status'] = in_array($status, ['draft','ordered'], true) ? $status : 'ordered';
|
||||
$po['subtotal'] = $subtotal;
|
||||
$po['tax'] = $tax;
|
||||
$po['total'] = $total;
|
||||
|
||||
if (!$this->poModel->save($po)) {
|
||||
$this->db->transRollback();
|
||||
return redirect()->back()->withInput()->with('error', implode("\n", $this->poModel->errors()));
|
||||
}
|
||||
$poId = $this->poModel->getInsertID();
|
||||
|
||||
foreach ($items as $it) {
|
||||
$it['purchase_order_id'] = $poId;
|
||||
if (!$this->itemModel->insert($it)) {
|
||||
$this->db->transRollback();
|
||||
return redirect()->back()->withInput()->with('error', 'Failed to save line items.');
|
||||
}
|
||||
}
|
||||
|
||||
$this->db->transComplete();
|
||||
if ($this->db->transStatus() === false) {
|
||||
return redirect()->back()->withInput()->with('error', 'Failed to save purchase order.');
|
||||
}
|
||||
|
||||
return redirect()->to(site_url('inventory/po/show/'.$poId))->with('success', 'PO created.');
|
||||
}
|
||||
|
||||
public function show($id)
|
||||
{
|
||||
$po = $this->poModel->select('purchase_orders.*, suppliers.name AS supplier_name')
|
||||
->join('suppliers', 'suppliers.id = purchase_orders.supplier_id', 'left')
|
||||
->find($id);
|
||||
if (!$po) return redirect()->to('inventory/po')->with('error', 'PO not found.');
|
||||
|
||||
$items = $this->itemModel->select('purchase_order_items.*, supplies.name AS supply_name, supplies.unit as supply_unit')
|
||||
->join('supplies', 'supplies.id = purchase_order_items.supply_id', 'left')
|
||||
->where('purchase_order_id', $id)->findAll();
|
||||
|
||||
return view('inventory/po_show', [
|
||||
'po' => $po,
|
||||
'items' => $items,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Receive items (partial or full)
|
||||
* POST body: received[item_id] = qty_to_receive
|
||||
*/
|
||||
public function receive($id)
|
||||
{
|
||||
$po = $this->poModel->find($id);
|
||||
if (!$po || in_array($po['status'], ['canceled','received'], true)) {
|
||||
return redirect()->to('inventory/po/show/'.$id)->with('error', 'PO not receivable.');
|
||||
}
|
||||
|
||||
$received = $this->request->getPost('received') ?? []; // [itemId => qty]
|
||||
if (!$received) {
|
||||
return redirect()->to('inventory/po/show/'.$id)->with('error', 'No items to receive.');
|
||||
}
|
||||
|
||||
$issuedBy = (string) (session('user.email') ?? session('user.username') ?? 'system');
|
||||
|
||||
$this->db->transStart();
|
||||
|
||||
$completed = true;
|
||||
foreach ($received as $itemId => $qty) {
|
||||
$qty = (int)$qty;
|
||||
if ($qty <= 0) continue;
|
||||
|
||||
$item = $this->itemModel->where('purchase_order_id', $id)->find($itemId);
|
||||
if (!$item) { $completed = false; continue; }
|
||||
|
||||
$remaining = (int)$item['quantity'] - (int)$item['received_qty'];
|
||||
$toReceive = min($remaining, $qty);
|
||||
if ($toReceive <= 0) continue;
|
||||
|
||||
// Update item received qty
|
||||
$this->itemModel->update($itemId, [
|
||||
'received_qty' => (int)$item['received_qty'] + $toReceive
|
||||
]);
|
||||
|
||||
// Update supply on hand
|
||||
$supply = $this->supplyModel->find($item['supply_id']);
|
||||
if (!$supply) { $completed = false; continue; }
|
||||
$newQty = (int)$supply['qty_on_hand'] + $toReceive;
|
||||
$this->supplyModel->update($supply['id'], ['qty_on_hand' => $newQty]);
|
||||
|
||||
// Log transaction IN
|
||||
$this->txnModel->insert([
|
||||
'supply_id' => $supply['id'],
|
||||
'type' => 'in',
|
||||
'quantity' => $toReceive,
|
||||
'ref' => 'PO ' . $po['po_number'],
|
||||
'issued_to' => 'Inventory',
|
||||
'issued_by' => $issuedBy,
|
||||
'notes' => 'Received against PO',
|
||||
]);
|
||||
|
||||
if (($item['received_qty'] + $toReceive) < $item['quantity']) {
|
||||
$completed = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Set PO status
|
||||
$this->poModel->update($id, ['status' => $completed ? 'received' : 'ordered']);
|
||||
|
||||
$this->db->transComplete();
|
||||
if ($this->db->transStatus() === false) {
|
||||
return redirect()->to('inventory/po/show/'.$id)->with('error', 'Failed to receive items.');
|
||||
}
|
||||
|
||||
return redirect()->to('inventory/po/show/'.$id)->with('success', $completed ? 'PO fully received.' : 'PO partially received.');
|
||||
}
|
||||
|
||||
public function cancel($id)
|
||||
{
|
||||
$po = $this->poModel->find($id);
|
||||
if (!$po || $po['status'] === 'received') {
|
||||
return redirect()->to('inventory/po/show/'.$id)->with('error', 'Cannot cancel this PO.');
|
||||
}
|
||||
$this->poModel->update($id, ['status' => 'canceled']);
|
||||
return redirect()->to('inventory/po/show/'.$id)->with('success', 'PO canceled.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,577 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use CodeIgniter\Controller;
|
||||
use App\Models\TeacherClassModel;
|
||||
use App\Models\StudentClassModel;
|
||||
use App\Models\StudentModel;
|
||||
use App\Models\QuizModel;
|
||||
use App\Models\HomeworkModel;
|
||||
use App\Models\UserModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
use App\Controllers\View\GradingController;
|
||||
use RuntimeException;
|
||||
use App\Services\SemesterScoreService;
|
||||
use Config\Services;
|
||||
use App\Models\GradingLockModel;
|
||||
use App\Models\MissingScoreOverrideModel;
|
||||
|
||||
class QuizController extends Controller
|
||||
{
|
||||
protected $db;
|
||||
protected $semesterScoreService;
|
||||
protected $quizModel;
|
||||
protected $configModel;
|
||||
protected $homeworkModel;
|
||||
protected $userModel;
|
||||
protected $schoolYear;
|
||||
protected $semester;
|
||||
protected $studentClassModel;
|
||||
protected $studentModel;
|
||||
protected $teacherClassModel;
|
||||
protected $gradingLockModel;
|
||||
protected $missingScoreOverrideModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->db = \Config\Database::connect();
|
||||
$this->teacherClassModel = new TeacherClassModel();
|
||||
$this->homeworkModel = new HomeworkModel();
|
||||
$this->userModel = new UserModel();
|
||||
$this->studentClassModel = new StudentClassModel();
|
||||
$this->studentModel = new StudentModel();
|
||||
$this->configModel = new ConfigurationModel();
|
||||
$this->quizModel = new QuizModel();
|
||||
$this->semesterScoreService = service('semesterScoreService');
|
||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||
$this->semester = $this->configModel->getConfig('semester');
|
||||
$this->gradingLockModel = new GradingLockModel();
|
||||
$this->missingScoreOverrideModel = new MissingScoreOverrideModel();
|
||||
}
|
||||
|
||||
public function updateQuizScores(array $scores = null, int $updatedBy = null, int $classSectionId = null)
|
||||
{
|
||||
$scores = $this->request->getPost('scores');
|
||||
log_message('error', '✅ Raw Scores: ' . print_r($scores, true));
|
||||
|
||||
if ($updatedBy === null) {
|
||||
$updatedBy = session()->get('user_id');
|
||||
}
|
||||
$classSectionId = $this->request->getPost('class_section_id') ?? session()->get('class_section_id');
|
||||
$semester = $this->request->getPost('semester') ?? $this->getTeacherSelectedSemester();
|
||||
$schoolYear = $this->request->getPost('school_year') ?? $this->schoolYear;
|
||||
$classSectionId = (int) ($classSectionId ?? 0);
|
||||
if ($classSectionId > 0 && $this->isScoresLocked($classSectionId, $semester, $schoolYear)) {
|
||||
return redirect()->back()->with('error', 'Scores are locked for this class. Unlock to edit.');
|
||||
}
|
||||
|
||||
$missingOk = $this->request->getPost('missing_ok') ?? [];
|
||||
$studentIds = is_array($scores) ? array_keys($scores) : array_keys((array) $missingOk);
|
||||
$indexSet = [];
|
||||
if (is_array($scores)) {
|
||||
foreach ($scores as $studentId => $quizData) {
|
||||
if (!is_array($quizData)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($quizData as $index => $score) {
|
||||
if (is_numeric($index)) {
|
||||
$indexSet[(int)$index] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$checkedItems = [];
|
||||
if (is_array($missingOk)) {
|
||||
foreach ($missingOk as $studentId => $indexes) {
|
||||
if (!is_array($indexes)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($indexes as $index => $flag) {
|
||||
if (!is_numeric($index)) {
|
||||
continue;
|
||||
}
|
||||
$checkedItems[] = [
|
||||
'student_id' => (int) $studentId,
|
||||
'item_index' => (int) $index,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
$this->missingScoreOverrideModel->replaceOverrides(
|
||||
$classSectionId,
|
||||
(string) $semester,
|
||||
(string) $schoolYear,
|
||||
'quiz',
|
||||
$studentIds,
|
||||
array_keys($indexSet),
|
||||
$checkedItems,
|
||||
$updatedBy
|
||||
);
|
||||
|
||||
if (is_array($scores)) {
|
||||
foreach ($scores as $studentId => $quizData) {
|
||||
if (!is_numeric($studentId) || $studentId <= 0) continue;
|
||||
|
||||
// Normalize: if single input submitted
|
||||
if (!is_array($quizData)) {
|
||||
$quizData = [1 => $quizData];
|
||||
}
|
||||
|
||||
foreach ($quizData as $quizNumber => $score) {
|
||||
if (!is_numeric($quizNumber)) continue;
|
||||
|
||||
$isBlank = $score === null || (is_string($score) && trim($score) === '');
|
||||
$normalizedScore = (!$isBlank && is_numeric($score)) ? (float) $score : null;
|
||||
|
||||
$existing = $this->quizModel->where([
|
||||
'student_id' => $studentId,
|
||||
'quiz_index' => $quizNumber,
|
||||
'class_section_id' => $classSectionId,
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
])->first();
|
||||
|
||||
if ($isBlank) {
|
||||
if ($existing) {
|
||||
$this->quizModel->update($existing['id'], [
|
||||
'score' => null,
|
||||
'updated_at' => utc_now(),
|
||||
]);
|
||||
} else {
|
||||
$this->quizModel->insert([
|
||||
'student_id' => $studentId,
|
||||
'school_id' => '', // Optional
|
||||
'class_section_id' => $classSectionId,
|
||||
'updated_by' => $updatedBy,
|
||||
'quiz_index' => $quizNumber,
|
||||
'score' => null,
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
'created_at' => utc_now(),
|
||||
'updated_at' => utc_now()
|
||||
]);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($existing) {
|
||||
$this->quizModel->update($existing['id'], [
|
||||
'score' => $normalizedScore,
|
||||
'updated_at' => utc_now()
|
||||
]);
|
||||
log_message('debug', "✅ Updated quiz ID {$existing['id']} with score {$normalizedScore}");
|
||||
} else {
|
||||
$this->quizModel->insert([
|
||||
'student_id' => $studentId,
|
||||
'school_id' => '', // Optional
|
||||
'class_section_id' => $classSectionId,
|
||||
'updated_by' => $updatedBy,
|
||||
'quiz_index' => $quizNumber,
|
||||
'score' => $normalizedScore,
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
'created_at' => utc_now(),
|
||||
'updated_at' => utc_now()
|
||||
]);
|
||||
log_message('debug', "✅ Inserted quiz for student $studentId quiz $quizNumber with score $score");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$studentTeacherInfo = $this->studentModel->getStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear);
|
||||
// Call the updateScoresForStudents method
|
||||
try {
|
||||
|
||||
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo, $semester, $schoolYear);
|
||||
} catch (RuntimeException $e) {
|
||||
// Handle error
|
||||
}
|
||||
return redirect()->to(base_url('/teacher/addQuiz'))->with('status', 'Quiz scores updated successfully.');
|
||||
}
|
||||
|
||||
public function addQuiz()
|
||||
{
|
||||
// 1) class_section_id from view (POST/GET) with session fallback
|
||||
$incoming = $this->request->getPost('class_section_id')
|
||||
?? $this->request->getGet('class_section_id')
|
||||
?? session()->get('class_section_id');
|
||||
|
||||
$classSectionId = (int) ($incoming ?: 0);
|
||||
if ($classSectionId <= 0) {
|
||||
return redirect()->back()->with('status', 'Missing class section.');
|
||||
}
|
||||
session()->set('class_section_id', $classSectionId);
|
||||
|
||||
// 3) Headers: distinct quiz_index for this class/term
|
||||
$semester = $this->getTeacherSelectedSemester();
|
||||
$schoolYear = $this->schoolYear;
|
||||
$quizHeaderRows = $this->quizModel->select('quiz_index')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('semester', $semester)
|
||||
->groupBy('quiz_index')
|
||||
->orderBy('quiz_index', 'ASC')
|
||||
->get()->getResultArray();
|
||||
|
||||
$quizHeaders = array_map(static fn($r) => (int)$r['quiz_index'], $quizHeaderRows); // [1,2,3,...]
|
||||
|
||||
// 4) Roster (distinct students for this section/term)
|
||||
$roster = $this->db->table('student_class sc')
|
||||
->select('s.id AS student_id, s.firstname, s.lastname, s.school_id')
|
||||
->distinct() // ✅ apply DISTINCT correctly
|
||||
->join('students s', 's.id = sc.student_id', 'inner')
|
||||
->where('sc.class_section_id', $classSectionId)
|
||||
->where('sc.school_year', $schoolYear)
|
||||
->orderBy('s.lastname', 'ASC')
|
||||
->orderBy('s.firstname', 'ASC')
|
||||
->get()->getResultArray();
|
||||
|
||||
|
||||
// 5) All existing quiz scores (single query) -> pivot
|
||||
$scoreRows = $this->quizModel->select('id, student_id, quiz_index, score')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('semester', $semester)
|
||||
->get()->getResultArray();
|
||||
|
||||
$scoresMap = []; // [student_id][quiz_index] => score
|
||||
$idMap = []; // [student_id][quiz_index] => id (if you need to update existing rows)
|
||||
|
||||
foreach ($scoreRows as $r) {
|
||||
$sid = (int)$r['student_id'];
|
||||
$qi = (int)$r['quiz_index'];
|
||||
$scoresMap[$sid][$qi] = $r['score'];
|
||||
$idMap[$sid][$qi] = (int)$r['id'];
|
||||
}
|
||||
|
||||
// 6) Build students array with per-quiz scores
|
||||
$students = [];
|
||||
foreach ($roster as $st) {
|
||||
$sid = (int)$st['student_id'];
|
||||
|
||||
$quizScores = [];
|
||||
foreach ($quizHeaders as $qi) {
|
||||
$quizScores[$qi] = $scoresMap[$sid][$qi] ?? ''; // empty if none yet
|
||||
}
|
||||
|
||||
$students[] = [
|
||||
'student_id' => $sid,
|
||||
'firstname' => $st['firstname'],
|
||||
'lastname' => $st['lastname'],
|
||||
'school_id' => $st['school_id'] ?? null,
|
||||
'scores' => $quizScores,
|
||||
// If your view needs existing row ids to decide update vs insert:
|
||||
'existingIds' => $idMap[$sid] ?? [], // optional
|
||||
];
|
||||
}
|
||||
|
||||
// 7) Render
|
||||
return view('teacher/add_quiz', [
|
||||
'students' => $students,
|
||||
'quizHeaders' => $quizHeaders, // e.g., [1,2,3]
|
||||
'semester' => $semester,
|
||||
'schoolYear' => $this->schoolYear,
|
||||
'classSectionId' => $classSectionId,
|
||||
'missingOkMap' => $this->missingScoreOverrideModel->getOverridesMap($classSectionId, $semester, $this->schoolYear, 'quiz'),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
public function addNextQuizColumn()
|
||||
{
|
||||
$updatedBy = session()->get('user_id');
|
||||
$classSectionId = session()->get('class_section_id');
|
||||
|
||||
if (!$updatedBy || !$classSectionId) {
|
||||
return $this->response->setJSON([
|
||||
'status' => 'error',
|
||||
'message' => 'Missing teacher or class section data.'
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
$semester = $this->getTeacherSelectedSemester();
|
||||
$schoolYear = $this->schoolYear;
|
||||
$existingQuizNumbers = $this->quizModel
|
||||
->select('quiz_index')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->groupBy('quiz_index')
|
||||
->orderBy('quiz_index', 'DESC')
|
||||
->findAll();
|
||||
|
||||
$maxQuizNumber = 0;
|
||||
foreach ($existingQuizNumbers as $row) {
|
||||
if (is_numeric($row['quiz_index'])) {
|
||||
$maxQuizNumber = max($maxQuizNumber, (int)$row['quiz_index']);
|
||||
}
|
||||
}
|
||||
|
||||
$nextQuizNumber = $maxQuizNumber + 1;
|
||||
|
||||
if ($nextQuizNumber <= 0) {
|
||||
return $this->response->setJSON([
|
||||
'status' => 'error',
|
||||
'message' => '🚫 Invalid quiz number',
|
||||
'calculated' => $nextQuizNumber
|
||||
]);
|
||||
}
|
||||
|
||||
$students = $this->studentClassModel
|
||||
->where('class_section_id', $classSectionId)
|
||||
->findAll();
|
||||
|
||||
if (empty($students)) {
|
||||
return $this->response->setJSON([
|
||||
'status' => 'error',
|
||||
'message' => 'No students found in this class section.'
|
||||
]);
|
||||
}
|
||||
|
||||
$firstInsertedId = null;
|
||||
|
||||
foreach ($students as $i => $student) {
|
||||
$exists = $this->quizModel
|
||||
->where([
|
||||
'student_id' => $student['student_id'],
|
||||
'quiz_index' => $nextQuizNumber,
|
||||
'class_section_id' => $classSectionId,
|
||||
'updated_by' => $updatedBy,
|
||||
'semester' => $this->semester,
|
||||
'school_year' => $this->schoolYear,
|
||||
])
|
||||
->first();
|
||||
|
||||
if ($exists) continue;
|
||||
|
||||
$insertData = [
|
||||
'student_id' => $student['student_id'],
|
||||
'class_section_id' => $classSectionId,
|
||||
'updated_by' => $updatedBy,
|
||||
'quiz_index' => $nextQuizNumber,
|
||||
'score' => null,
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
'created_at' => utc_now(),
|
||||
'updated_at' => utc_now(),
|
||||
];
|
||||
|
||||
log_message('error', '✅ Inserting quiz row: ' . json_encode($insertData));
|
||||
|
||||
try {
|
||||
$id = $this->quizModel->insert($insertData, true);
|
||||
if ($i === 0) $firstInsertedId = $id;
|
||||
} catch (\Throwable $e) {
|
||||
return $this->response->setJSON([
|
||||
'status' => 'error',
|
||||
'message' => '❌ Insert failed: ' . $e->getMessage(),
|
||||
'debug' => $insertData
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->response->setJSON([
|
||||
'status' => 'success',
|
||||
'quiz_index' => $nextQuizNumber,
|
||||
'new_quiz_id' => $firstInsertedId
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->response->setJSON([
|
||||
'status' => 'error',
|
||||
'message' => '❌ Unexpected error: ' . $e->getMessage()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function showQuizMngt()
|
||||
{
|
||||
// Accept POST first, then GET (query string), then session fallback
|
||||
$incomingId = $this->request->getPost('class_section_id');
|
||||
if (!$incomingId) {
|
||||
$incomingId = $this->request->getGet('class_section_id');
|
||||
}
|
||||
|
||||
$classSectionId = (int) ($incomingId ?: (session()->get('class_section_id') ?? 0));
|
||||
|
||||
if ($classSectionId <= 0) {
|
||||
return redirect()->back()->with('status', 'No class section found for the current teacher or selection.');
|
||||
}
|
||||
session()->set('class_section_id', $classSectionId);
|
||||
$semester = $this->getTeacherSelectedSemester();
|
||||
$schoolYear = $this->schoolYear;
|
||||
$quizScores = $this->getquizScoresByStudent($classSectionId, $semester, $schoolYear);
|
||||
$students = $this->getStudentsByClassSectionAndYear($classSectionId, $schoolYear);
|
||||
$quizHeaders = $this->getquizHeaders($classSectionId, $semester, $schoolYear);
|
||||
$scoresLocked = $this->isScoresLocked($classSectionId, $semester, $schoolYear);
|
||||
|
||||
return view('grading/quiz', [
|
||||
'quizScores' => $quizScores,
|
||||
'students' => $students,
|
||||
'semester' => $semester,
|
||||
'schoolYear' => $schoolYear,
|
||||
'quizHeaders' => $quizHeaders,
|
||||
'classSectionId' => $classSectionId,
|
||||
'scoresLocked' => $scoresLocked,
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateQuiz()
|
||||
{
|
||||
$scores = $this->request->getPost('scores');
|
||||
$studentIds = $this->request->getPost('student_ids');
|
||||
$semester = $this->request->getPost('semester') ?? $this->getTeacherSelectedSemester();
|
||||
$schoolYear = $this->request->getPost('school_year') ?? $this->schoolYear;
|
||||
|
||||
$updatedBy = $this->request->getPost('teacher_id') ?? session()->get('user_id');
|
||||
$classSectionId = $this->request->getPost('class_section_id') ?? session()->get('class_section_id'); // or query it
|
||||
$classSectionId = (int) ($classSectionId ?? 0);
|
||||
if ($classSectionId > 0 && $this->isScoresLocked($classSectionId, $semester, $schoolYear)) {
|
||||
return redirect()->back()->with('error', 'Scores are locked for this class. Unlock to edit.');
|
||||
}
|
||||
|
||||
if ($scores && $studentIds && $semester && $schoolYear && $classSectionId) {
|
||||
$this->updateQuizScores($scores, $updatedBy, $classSectionId);
|
||||
session()->setFlashdata('status', 'quiz scores updated successfully.');
|
||||
|
||||
// ⬇️ Directly call the method and return its view
|
||||
return $this->showQuizMngt();
|
||||
}
|
||||
|
||||
$studentTeacherInfo = $this->studentModel->getStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear);
|
||||
// Call the updateScoresForStudents method
|
||||
try {
|
||||
|
||||
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo, $semester, $schoolYear);
|
||||
} catch (RuntimeException $e) {
|
||||
// Handle error
|
||||
}
|
||||
return redirect()->back()->with('status', 'Failed to update scores.');
|
||||
}
|
||||
|
||||
private function isScoresLocked(int $classSectionId, string $semester, string $schoolYear): bool
|
||||
{
|
||||
return $this->gradingLockModel->isLocked($classSectionId, $semester, $schoolYear);
|
||||
}
|
||||
|
||||
private function getStudentsByClassSectionAndYear($classSectionId, $schoolYear)
|
||||
{
|
||||
// Step 1: Get student IDs from student_class table
|
||||
$studentClassRows = $this->studentClassModel
|
||||
->select('student_id')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('school_year', $schoolYear)
|
||||
->findAll();
|
||||
|
||||
$studentIds = array_column($studentClassRows, 'student_id');
|
||||
|
||||
if (empty($studentIds)) {
|
||||
return []; // No students found
|
||||
}
|
||||
|
||||
// Step 2: Get student data from students table
|
||||
$students = $this->studentModel
|
||||
->whereIn('id', $studentIds)
|
||||
->where('is_active', 1)
|
||||
->orderBy('lastname', 'ASC')
|
||||
->findAll();
|
||||
|
||||
return $students;
|
||||
}
|
||||
|
||||
private function getquizHeaders($classSectionId, $semester, $schoolYear)
|
||||
{
|
||||
$rows = $this->quizModel
|
||||
->select('quiz_index')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->orderBy('quiz_index', 'ASC')
|
||||
->findAll();
|
||||
|
||||
$headers = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$index = $row['quiz_index'];
|
||||
if (!is_array($index)) {
|
||||
$headers[$index] = true;
|
||||
}
|
||||
}
|
||||
|
||||
ksort($headers);
|
||||
return array_keys($headers);
|
||||
}
|
||||
|
||||
private function getClassSectionIdForTeacher($updatedBy)
|
||||
{
|
||||
$teacherClassModel = new TeacherClassModel();
|
||||
$class = $teacherClassModel->where('teacher_id', $updatedBy)->first();
|
||||
return $class['class_section_id'] ?? null;
|
||||
}
|
||||
|
||||
|
||||
private function getquizScoresByStudent($classSectionId, $semester, $schoolYear)
|
||||
{
|
||||
$rows = $this->quizModel
|
||||
->select('student_id, quiz_index, score')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->findAll();
|
||||
|
||||
$studentScores = [];
|
||||
foreach ($rows as $row) {
|
||||
$studentId = $row['student_id'];
|
||||
$index = $row['quiz_index'];
|
||||
$score = $row['score'];
|
||||
|
||||
$studentScores[$studentId]['scores'][$index] = $score;
|
||||
}
|
||||
|
||||
return $studentScores;
|
||||
}
|
||||
|
||||
private function getStudentsWithquizScores($classSectionId, $quizHeaders)
|
||||
{
|
||||
$studentClasses = $this->studentClassModel
|
||||
->active()
|
||||
->where('student_class.class_section_id', $classSectionId)
|
||||
->findAll();
|
||||
$students = [];
|
||||
|
||||
foreach ($studentClasses as $sc) {
|
||||
$student = $this->studentModel
|
||||
->where('id', $sc['student_id'])
|
||||
->where('is_active', 1)
|
||||
->first();
|
||||
if (!$student) continue;
|
||||
|
||||
$scores = [];
|
||||
foreach ($quizHeaders as $index => $ids) {
|
||||
$entry = $this->quizModel->where('quiz_index', $ids)
|
||||
->where('student_id', $sc['student_id'])
|
||||
->first();
|
||||
$scores[$index] = $entry['score'] ?? '';
|
||||
}
|
||||
|
||||
$students[] = [
|
||||
'student_id' => $sc['student_id'],
|
||||
'firstname' => $student['firstname'],
|
||||
'lastname' => $student['lastname'],
|
||||
'scores' => $scores
|
||||
];
|
||||
}
|
||||
|
||||
usort($students, fn($a, $b) => strcmp($a['lastname'], $b['lastname']) ?: strcmp($a['firstname'], $b['firstname']));
|
||||
return $students;
|
||||
}
|
||||
|
||||
private function getTeacherSelectedSemester(): string
|
||||
{
|
||||
helper('semester_selection_helper');
|
||||
return selected_teacher_semester();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
use App\Controllers\BaseController;
|
||||
use App\Models\UserModel;
|
||||
use App\Models\StudentModel;
|
||||
|
||||
use CodeIgniter\I18n\Time;
|
||||
|
||||
class RFIDController extends BaseController
|
||||
{
|
||||
public function process()
|
||||
{
|
||||
$rfidTag = $this->request->getPost('rfid');
|
||||
|
||||
// Load the user model
|
||||
$userModel = new UserModel();
|
||||
|
||||
// Find the user by RFID tag
|
||||
$user = $userModel->where('rfid_tag', $rfidTag)->first();
|
||||
|
||||
if ($user) {
|
||||
// Log the scan (optional: store in a 'scan_log' table)
|
||||
$db = \Config\Database::connect();
|
||||
$db->table('scan_log')->insert([
|
||||
'user_id' => $user->id,
|
||||
'card_id' => $rfidTag,
|
||||
'scan_time' => Time::now(),
|
||||
]);
|
||||
|
||||
// Redirect with success message
|
||||
return redirect()->to('/rfid/log')->with('message', 'RFID recognized: ' . $user->name);
|
||||
} else {
|
||||
// Redirect with error message if RFID not found
|
||||
return redirect()->to('/')->with('error', 'RFID tag not recognized.');
|
||||
}
|
||||
}
|
||||
|
||||
public function log()
|
||||
{
|
||||
$db = \Config\Database::connect();
|
||||
|
||||
// Retrieve scan logs and join with users table to display user names
|
||||
$query = $db->table('scan_log')
|
||||
->select('users.firstname as user_firstname, users.lastname as user_lastname, students.firstname as student_firstname, students.lastname as student_lastname, scan_log.card_id, scan_log.scan_time')
|
||||
->join('users', 'users.id = scan_log.user_id', 'left')
|
||||
->join('students', 'students.rfid_tag = scan_log.card_id', 'left') // Join students table by RFID tag
|
||||
->orderBy('scan_time', 'DESC')
|
||||
->get();
|
||||
|
||||
// Fetch user and student data for display purposes
|
||||
$userModel = new UserModel();
|
||||
$studentModel = new StudentModel();
|
||||
|
||||
// Optional: Fetch all users and students (if needed elsewhere in the view)
|
||||
$users = $userModel->findAll();
|
||||
$students = $studentModel->findAll();
|
||||
|
||||
// Pass scan logs, users, and students data to the view
|
||||
$data['logs'] = $query->getResult(); // Logs with user and student info
|
||||
$data['users'] = $users; // All users data
|
||||
$data['students'] = $students; // All students data
|
||||
|
||||
return view('/rfid/rfid_coming_soon', $data);
|
||||
}
|
||||
|
||||
public function rfidComingSoon()
|
||||
{
|
||||
return view('rfid/rfid_coming_soon');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,785 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Models\RefundModel;
|
||||
use App\Models\UserModel;
|
||||
use App\Models\PaymentModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
use App\Models\InvoiceModel;
|
||||
use App\Models\EnrollmentModel;
|
||||
|
||||
class RefundController extends BaseController
|
||||
{
|
||||
protected RefundModel $refundModel;
|
||||
protected UserModel $userModel;
|
||||
protected PaymentModel $paymentModel;
|
||||
protected ConfigurationModel $configModel;
|
||||
protected InvoiceModel $invoiceModel;
|
||||
protected EnrollmentModel $enrollmentModel;
|
||||
protected $db;
|
||||
|
||||
// Allowed request types (mapped to your `refunds.request` column)
|
||||
private const REQUEST_TYPES = ['tuition','overpayment','duplicate','extra'];
|
||||
// Allowed finalization decisions
|
||||
private const DECISIONS = ['Approved','Rejected'];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->refundModel = new RefundModel();
|
||||
$this->userModel = new UserModel();
|
||||
$this->paymentModel = new PaymentModel();
|
||||
$this->configModel = new ConfigurationModel();
|
||||
$this->invoiceModel = new InvoiceModel();
|
||||
$this->enrollmentModel = new EnrollmentModel();
|
||||
$this->db = \Config\Database::connect();
|
||||
}
|
||||
|
||||
/** Get current term (school_year, semester) from configuration */
|
||||
private function getCurrentTerm(): array
|
||||
{
|
||||
$rows = $this->configModel
|
||||
->select('config_key, config_value')
|
||||
->whereIn('config_key', ['school_year','semester'])
|
||||
->findAll();
|
||||
|
||||
$map = [];
|
||||
foreach ($rows as $r) {
|
||||
$map[$r['config_key']] = $r['config_value'];
|
||||
}
|
||||
return [
|
||||
'school_year' => $map['school_year'] ?? date('Y') . '-' . (date('Y') + 1),
|
||||
'semester' => $map['semester'] ?? 'Fall',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect overpayments per parent for current school_year and create/update Pending refunds.
|
||||
* When $triggerEvents is true, emits refundPending for newly created rows only.
|
||||
*
|
||||
* @return array [created_ids => int[], updated_ids => int[]]
|
||||
*/
|
||||
private function recalcOverpayments(bool $triggerEvents = false): array
|
||||
{
|
||||
$created = [];
|
||||
$updated = [];
|
||||
$term = $this->getCurrentTerm();
|
||||
$year = (string)$term['school_year'];
|
||||
$sem = (string)$term['semester'];
|
||||
|
||||
// Sum payments by parent for the term
|
||||
$pm = $this->paymentModel;
|
||||
$payTable = $pm->table;
|
||||
$hasStatus = $this->paymentModel->db->fieldExists('status', $payTable);
|
||||
$hasVoid = $this->paymentModel->db->fieldExists('is_void', $payTable);
|
||||
|
||||
$qbPaid = $pm->select('parent_id, COALESCE(SUM(paid_amount),0) AS total_paid')
|
||||
->where('school_year', $year)
|
||||
->groupBy('parent_id');
|
||||
if ($hasStatus) {
|
||||
$qbPaid->groupStart()
|
||||
->whereNotIn('status', ['void','voided','refunded','failed','chargeback','declined','reversed','canceled','cancelled'])
|
||||
->orWhere('status IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
if ($hasVoid) {
|
||||
$qbPaid->groupStart()
|
||||
->where('is_void', 0)
|
||||
->orWhere('is_void IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
$paidRows = $qbPaid->findAll();
|
||||
|
||||
// Sum invoices (charges) by parent for the term
|
||||
$invRows = $this->invoiceModel
|
||||
->select('parent_id, COALESCE(SUM(total_amount),0) AS total_invoiced')
|
||||
->where('school_year', $year)
|
||||
->groupBy('parent_id')
|
||||
->findAll();
|
||||
|
||||
// Sum refunds PAID (cash out) by parent for the term
|
||||
$refRows = $this->refundModel
|
||||
->select('parent_id, COALESCE(SUM(refund_paid_amount),0) AS total_refunded')
|
||||
->where('school_year', $year)
|
||||
->whereIn('status', ['Partial','Paid'])
|
||||
->groupBy('parent_id')
|
||||
->findAll();
|
||||
|
||||
$paidMap = [];
|
||||
foreach ($paidRows as $r) $paidMap[(int)$r['parent_id']] = (float)($r['total_paid'] ?? 0);
|
||||
$invMap = [];
|
||||
foreach ($invRows as $r) $invMap[(int)$r['parent_id']] = (float)($r['total_invoiced'] ?? 0);
|
||||
$refMap = [];
|
||||
foreach ($refRows as $r) $refMap[(int)$r['parent_id']] = (float)($r['total_refunded'] ?? 0);
|
||||
|
||||
$parentIds = array_unique(array_merge(array_keys($paidMap), array_keys($invMap)));
|
||||
|
||||
foreach ($parentIds as $pid) {
|
||||
$paid = (float)($paidMap[$pid] ?? 0);
|
||||
$invd = (float)($invMap[$pid] ?? 0);
|
||||
$rfnd = (float)($refMap[$pid] ?? 0);
|
||||
$over = round($paid - $invd - $rfnd, 2);
|
||||
if ($over > 0.00 + 0.0001) {
|
||||
// Parent-level policy: DO NOT create new overpayment rows; only keep existing parent-level
|
||||
// overpayment rows in sync if they are still open (Pending/Partial). Prefer per-invoice rows.
|
||||
$existing = $this->refundModel
|
||||
->where('parent_id', $pid)
|
||||
->where('school_year', $year)
|
||||
->where('request', 'overpayment')
|
||||
->orderBy('id', 'DESC')
|
||||
->first();
|
||||
|
||||
if ($existing && in_array($existing['status'], ['Pending','Partial'], true)) {
|
||||
$this->refundModel->update((int)$existing['id'], [
|
||||
'refund_amount' => $over,
|
||||
'updated_at' => utc_now(),
|
||||
'updated_by' => session()->get('user_id'),
|
||||
]);
|
||||
$updated[] = (int)$existing['id'];
|
||||
}
|
||||
// else: no parent-level row created
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Stage 2: Per-invoice overpayment detection (current school year) =====
|
||||
try {
|
||||
$invRows = $this->invoiceModel
|
||||
->select('id, parent_id, total_amount, school_year')
|
||||
->where('school_year', $year)
|
||||
->findAll();
|
||||
if ($invRows) {
|
||||
$invoiceIds = array_map(static fn($r) => (int)$r['id'], $invRows);
|
||||
|
||||
// payments by invoice
|
||||
$qbInvPaid = $this->paymentModel
|
||||
->select('invoice_id, COALESCE(SUM(paid_amount),0) AS total_paid')
|
||||
->whereIn('invoice_id', $invoiceIds)
|
||||
->groupBy('invoice_id');
|
||||
$payTable2 = $this->paymentModel->table;
|
||||
$hasStatus2 = $this->paymentModel->db->fieldExists('status', $payTable2);
|
||||
$hasVoid2 = $this->paymentModel->db->fieldExists('is_void', $payTable2);
|
||||
if ($hasStatus2) {
|
||||
$qbInvPaid->groupStart()
|
||||
->whereNotIn('status', ['void','voided','refunded','failed','chargeback','declined','reversed','canceled','cancelled'])
|
||||
->orWhere('status IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
if ($hasVoid2) {
|
||||
$qbInvPaid->groupStart()
|
||||
->where('is_void', 0)
|
||||
->orWhere('is_void IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
$invPaidRows = $qbInvPaid->findAll();
|
||||
|
||||
// discounts by invoice
|
||||
$invDiscRows = $this->db->table('discount_usages')
|
||||
->select('invoice_id, COALESCE(SUM(discount_amount),0) AS total_disc')
|
||||
->whereIn('invoice_id', $invoiceIds)
|
||||
->groupBy('invoice_id')
|
||||
->get()->getResultArray();
|
||||
|
||||
// refunds paid by invoice
|
||||
$invRefRows = $this->refundModel
|
||||
->select('invoice_id, COALESCE(SUM(refund_paid_amount),0) AS total_ref')
|
||||
->whereIn('invoice_id', $invoiceIds)
|
||||
->whereIn('status', ['Partial','Paid'])
|
||||
->groupBy('invoice_id')
|
||||
->findAll();
|
||||
|
||||
$paidByInv = [];
|
||||
foreach ($invPaidRows as $r) { $paidByInv[(int)$r['invoice_id']] = (float)($r['total_paid'] ?? 0); }
|
||||
$discByInv = [];
|
||||
foreach ($invDiscRows as $r) { $discByInv[(int)$r['invoice_id']] = (float)($r['total_disc'] ?? 0); }
|
||||
$refByInv = [];
|
||||
foreach ($invRefRows as $r) { $refByInv[(int)$r['invoice_id']] = (float)($r['total_ref'] ?? 0); }
|
||||
|
||||
foreach ($invRows as $inv) {
|
||||
$iid = (int)$inv['id'];
|
||||
$pid = (int)$inv['parent_id'];
|
||||
$total = (float)($inv['total_amount'] ?? 0);
|
||||
$paid = (float)($paidByInv[$iid] ?? 0);
|
||||
$disc = (float)($discByInv[$iid] ?? 0);
|
||||
$rfnd = (float)($refByInv[$iid] ?? 0);
|
||||
|
||||
$rawBalance = round($total - $disc - $paid - $rfnd, 2);
|
||||
if ($rawBalance < -0.0001) {
|
||||
$over = abs($rawBalance);
|
||||
|
||||
// If ANY open refund exists for this invoice, update it instead of creating another line
|
||||
$openRow = $this->refundModel
|
||||
->where('invoice_id', $iid)
|
||||
->whereIn('status', ['Pending','Approved','Partial'])
|
||||
->orderBy('id', 'DESC')
|
||||
->first();
|
||||
if ($openRow) {
|
||||
// Prefer merging into 'overpayment' if present, else update the open row
|
||||
$this->refundModel->update((int)$openRow['id'], [
|
||||
'refund_amount' => $over,
|
||||
'updated_at' => utc_now(),
|
||||
'updated_by' => session()->get('user_id'),
|
||||
]);
|
||||
$updated[] = (int)$openRow['id'];
|
||||
continue;
|
||||
}
|
||||
|
||||
// If there is an open PARENT-LEVEL overpayment (invoice_id IS NULL), migrate it to this invoice
|
||||
$parentLevelOpen = $this->refundModel
|
||||
->where('parent_id', $pid)
|
||||
->where('school_year', $year)
|
||||
->where('invoice_id', null)
|
||||
->where('request', 'overpayment')
|
||||
->whereIn('status', ['Pending','Approved','Partial'])
|
||||
->orderBy('id', 'DESC')
|
||||
->first();
|
||||
if ($parentLevelOpen) {
|
||||
$this->refundModel->update((int)$parentLevelOpen['id'], [
|
||||
'invoice_id' => $iid,
|
||||
'refund_amount' => $over,
|
||||
'updated_at' => utc_now(),
|
||||
'updated_by' => session()->get('user_id'),
|
||||
]);
|
||||
$updated[] = (int)$parentLevelOpen['id'];
|
||||
continue; // merged instead of creating new
|
||||
}
|
||||
|
||||
// Existing overpayment refund for this invoice?
|
||||
$existing = $this->refundModel
|
||||
->where('invoice_id', $iid)
|
||||
->where('request', 'overpayment')
|
||||
->whereIn('status', ['Pending','Approved','Partial','Paid'])
|
||||
->orderBy('id', 'DESC')
|
||||
->first();
|
||||
|
||||
if (!$existing || $existing['status'] === 'Paid') {
|
||||
$ok = $this->refundModel->insert([
|
||||
'parent_id' => $pid,
|
||||
'school_year' => $year,
|
||||
'semester' => $sem,
|
||||
'invoice_id' => $iid,
|
||||
'refund_amount' => $over,
|
||||
'refund_paid_amount' => 0.00,
|
||||
'status' => 'Pending',
|
||||
'request' => 'overpayment',
|
||||
'reason' => 'Auto-detected per-invoice overpayment',
|
||||
'updated_by' => session()->get('user_id'),
|
||||
'created_at' => utc_now(),
|
||||
'updated_at' => utc_now(),
|
||||
]);
|
||||
if ($ok) {
|
||||
$created[] = (int)$this->refundModel->getInsertID();
|
||||
if ($triggerEvents) {
|
||||
$user = (new UserModel())->select('id, email, firstname, lastname')->find($pid) ?: [];
|
||||
$eventData = [
|
||||
'user_id' => (int)($user['id'] ?? $pid),
|
||||
'email' => $user['email'] ?? null,
|
||||
'firstname' => $user['firstname'] ?? null,
|
||||
'lastname' => $user['lastname'] ?? null,
|
||||
'amount' => $over,
|
||||
'portalLink'=> base_url('/login'),
|
||||
];
|
||||
\CodeIgniter\Events\Events::trigger('refundPending', $eventData, []);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Keep Pending/Partial in sync with current overpayment
|
||||
if (in_array($existing['status'], ['Pending','Partial'], true)) {
|
||||
$this->refundModel->update((int)$existing['id'], [
|
||||
'refund_amount' => $over,
|
||||
'updated_at' => utc_now(),
|
||||
'updated_by' => session()->get('user_id'),
|
||||
]);
|
||||
$updated[] = (int)$existing['id'];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'recalcOverpayments per-invoice failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return ['created_ids' => $created, 'updated_ids' => $updated];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parent financial summary for *current term* (adjust if you want all-time).
|
||||
* Assumes invoices.total_amount and payments.amount (positive=in, negative=out).
|
||||
*/
|
||||
private function getParentFinancialSummary(int $parentId, string $schoolYear, string $semester): array
|
||||
{
|
||||
// invoices total for term
|
||||
$inv = $this->invoiceModel
|
||||
->select('COALESCE(SUM(total_amount),0) AS total_invoiced')
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('semester', $semester)
|
||||
->first();
|
||||
|
||||
// payments in (amount > 0) for term
|
||||
$payIn = $this->paymentModel
|
||||
->select('COALESCE(SUM(amount),0) AS paid_in')
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('semester', $semester)
|
||||
->where('amount >', 0)
|
||||
->first();
|
||||
|
||||
// refunds already paid out (your refunds table)
|
||||
$refPaid = $this->refundModel
|
||||
->select('COALESCE(SUM(refund_paid_amount),0) AS refunded_cash')
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('semester', $semester)
|
||||
->whereIn('status', ['Partial','Paid'])
|
||||
->first();
|
||||
|
||||
$totalInvoiced = (float)($inv['total_invoiced'] ?? 0);
|
||||
$paidIn = (float)($payIn['paid_in'] ?? 0);
|
||||
$refundedCash = (float)($refPaid['refunded_cash'] ?? 0);
|
||||
|
||||
// Unapplied balance = money in − invoices − cash refunds out
|
||||
$unapplied = $paidIn - $totalInvoiced - $refundedCash;
|
||||
|
||||
return [
|
||||
'total_invoiced' => $totalInvoiced,
|
||||
'total_paid_in' => $paidIn,
|
||||
'total_refunded' => $refundedCash,
|
||||
'unapplied_balance' => $unapplied,
|
||||
];
|
||||
}
|
||||
|
||||
/** Optional helper if you want a quick API for balances in UI */
|
||||
public function parentBalances(int $parentId)
|
||||
{
|
||||
$t = $this->getCurrentTerm();
|
||||
return $this->response->setJSON($this->getParentFinancialSummary($parentId, $t['school_year'], $t['semester']));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create refund request.
|
||||
* Stores source/type in `refunds.request` as: tuition|overpayment|duplicate|extra
|
||||
*
|
||||
* @param int $parentId
|
||||
* @param float $amount
|
||||
* @param string|null $requestType tuition|overpayment|duplicate|extra
|
||||
* @param int|null $invoiceId required for tuition; useful for duplicate
|
||||
* @param int|null $paymentId useful for duplicate
|
||||
* @param string|null $reason
|
||||
*/
|
||||
public function requestRefund(
|
||||
int $parentId,
|
||||
float $amount,
|
||||
?string $requestType = 'tuition',
|
||||
?int $invoiceId = null,
|
||||
?int $paymentId = null,
|
||||
?string $reason = null
|
||||
) {
|
||||
$requestType = strtolower((string)$requestType);
|
||||
if (!in_array($requestType, self::REQUEST_TYPES, true)) {
|
||||
return $this->response->setJSON(['error' => 'Invalid refund request type.']);
|
||||
}
|
||||
|
||||
$parent = $this->userModel->find($parentId);
|
||||
if (!$parent) {
|
||||
return $this->response->setJSON(['error' => 'Parent not found.']);
|
||||
}
|
||||
if ($amount <= 0) {
|
||||
return $this->response->setJSON(['error' => 'Refund amount must be > 0.']);
|
||||
}
|
||||
|
||||
$term = $this->getCurrentTerm();
|
||||
$schoolYear = $term['school_year'];
|
||||
$semester = $term['semester'];
|
||||
|
||||
// Tuition requires an invoice check
|
||||
if ($requestType === 'tuition') {
|
||||
if (empty($invoiceId)) {
|
||||
return $this->response->setJSON(['error' => 'invoice_id is required for tuition refunds.']);
|
||||
}
|
||||
$inv = $this->invoiceModel->find($invoiceId);
|
||||
if (!$inv || (int)$inv['parent_id'] !== $parentId) {
|
||||
return $this->response->setJSON(['error' => 'Invoice not found for parent.']);
|
||||
}
|
||||
// Optionally cap to eligible amount per your policy.
|
||||
}
|
||||
|
||||
// Overpayment/extra must not exceed unapplied balance
|
||||
if (in_array($requestType, ['overpayment','extra'], true)) {
|
||||
$sum = $this->getParentFinancialSummary($parentId, $schoolYear, $semester);
|
||||
if ($sum['unapplied_balance'] <= 0) {
|
||||
return $this->response->setJSON(['error' => 'No unapplied balance available.']);
|
||||
}
|
||||
if ($amount > $sum['unapplied_balance'] + 0.0001) {
|
||||
return $this->response->setJSON([
|
||||
'error' => 'Amount exceeds available unapplied balance.',
|
||||
'available' => number_format($sum['unapplied_balance'], 2)
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Duplicate: optionally tie to payment; otherwise your staff can fill details in reason/note
|
||||
if ($requestType === 'duplicate' && empty($paymentId) && empty($invoiceId)) {
|
||||
// Not hard-failing; but better to guide:
|
||||
// return $this->response->setJSON(['error' => 'Provide payment_id or invoice_id for duplicate refunds.']);
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'parent_id' => $parentId,
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
'invoice_id' => $invoiceId,
|
||||
'refund_amount' => $amount,
|
||||
'refund_paid_amount' => 0.00, // IMPORTANT: your column is NOT NULL
|
||||
'status' => 'Pending',
|
||||
'reason' => $reason,
|
||||
'request' => $requestType, // <- store the source/type here
|
||||
'note' => $paymentId ? ('duplicate-of-payment#' . $paymentId) : null,
|
||||
'created_at' => utc_now(),
|
||||
'updated_at' => utc_now(),
|
||||
];
|
||||
$ok = $this->refundModel->insert($payload);
|
||||
|
||||
if (!$ok) {
|
||||
return $this->response->setJSON(['error' => 'Failed to create refund request.']);
|
||||
}
|
||||
|
||||
// Fire refundPending notification/event for newly created refunds
|
||||
try {
|
||||
$user = $this->userModel->select('id, email, firstname, lastname')->find($parentId) ?: [];
|
||||
$eventData = [
|
||||
'user_id' => (int)($user['id'] ?? $parentId),
|
||||
'email' => $user['email'] ?? null,
|
||||
'firstname' => $user['firstname'] ?? null,
|
||||
'lastname' => $user['lastname'] ?? null,
|
||||
'amount' => (float)$amount,
|
||||
'portalLink'=> base_url('/login'),
|
||||
];
|
||||
\CodeIgniter\Events\Events::trigger('refundPending', $eventData, []);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'requestRefund: failed to trigger refundPending: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return $this->response->setJSON(['success' => 'Refund requested']);
|
||||
}
|
||||
|
||||
// Approve refund (no money movement)
|
||||
public function approveRefund(int $refundId)
|
||||
{
|
||||
$ok = $this->refundModel->update($refundId, [
|
||||
'status' => 'Approved',
|
||||
'approved_at' => utc_now(),
|
||||
'approved_by' => session()->get('user_id'),
|
||||
'updated_at' => utc_now(),
|
||||
'updated_by' => session()->get('user_id'),
|
||||
]);
|
||||
|
||||
return $this->response->setJSON($ok ? ['success' => 'Refund approved'] : ['error' => 'Approve failed']);
|
||||
}
|
||||
|
||||
// Reject refund
|
||||
public function rejectRefund(int $refundId)
|
||||
{
|
||||
$ok = $this->refundModel->update($refundId, [
|
||||
'status' => 'Rejected',
|
||||
'approved_at' => utc_now(),
|
||||
'approved_by' => session()->get('user_id'),
|
||||
'updated_at' => utc_now(),
|
||||
'updated_by' => session()->get('user_id'),
|
||||
]);
|
||||
|
||||
return $this->response->setJSON($ok ? ['success' => 'Refund rejected'] : ['error' => 'Reject failed']);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST: refund_id, paid_amount, payment_method (Check|Online|Cash), check_number, check_file (optional)
|
||||
* Marks payout (can be partial) and inserts a negative Payment row to keep balances correct.
|
||||
*/
|
||||
public function updatePayment()
|
||||
{
|
||||
log_message('debug', 'POST data: ' . print_r($_POST, true));
|
||||
|
||||
$refundId = (int)$this->request->getPost('refund_id');
|
||||
$newPaidAmount = (float)$this->request->getPost('paid_amount');
|
||||
$refundMethod = $this->request->getPost('payment_method'); // Check|Online|Cash
|
||||
$checkNbr = $this->request->getPost('check_number');
|
||||
|
||||
if ($refundId <= 0) return $this->response->setJSON(['error' => 'Missing refund ID.']);
|
||||
if ($newPaidAmount <= 0) return $this->response->setJSON(['error' => 'Valid paid amount is required.']);
|
||||
|
||||
$refund = $this->refundModel->find($refundId);
|
||||
if (!$refund) return $this->response->setJSON(['error' => 'Refund not found.']);
|
||||
|
||||
if (!in_array($refund['status'], ['Approved','Partial'], true)) {
|
||||
return $this->response->setJSON(['error' => 'Refund must be Approved/Partial to pay.']);
|
||||
}
|
||||
|
||||
$oldPaid = (float)$refund['refund_paid_amount'];
|
||||
$target = (float)$refund['refund_amount'];
|
||||
$total = $oldPaid + $newPaidAmount;
|
||||
|
||||
if ($total > $target + 0.0001) {
|
||||
return $this->response->setJSON(['error' => 'Total paid cannot exceed refund amount.']);
|
||||
}
|
||||
|
||||
// Optional file upload for Check
|
||||
$checkFileName = $refund['check_file'] ?? null;
|
||||
if ($refundMethod === 'Check') {
|
||||
$checkFile = $this->request->getFile('check_file');
|
||||
if ($checkFile && $checkFile->isValid() && !$checkFile->hasMoved()) {
|
||||
$checkFileName = $checkFile->getRandomName();
|
||||
$checkFile->move(WRITEPATH . 'uploads/checks/', $checkFileName);
|
||||
}
|
||||
}
|
||||
|
||||
$newStatus = ($total < $target) ? 'Partial' : 'Paid';
|
||||
|
||||
$db = db_connect();
|
||||
$db->transStart();
|
||||
|
||||
// 0) If this refund is not tied to an invoice yet, assign it to the most overpaid invoice
|
||||
$assignInvoiceId = null;
|
||||
if (empty($refund['invoice_id'])) {
|
||||
try {
|
||||
// Gather invoices for this parent/year
|
||||
$invRows = $this->invoiceModel
|
||||
->select('id, total_amount')
|
||||
->where('parent_id', (int)$refund['parent_id'])
|
||||
->where('school_year', $refund['school_year'])
|
||||
->findAll();
|
||||
|
||||
if ($invRows) {
|
||||
$invoiceIds = array_map(static fn($r) => (int)$r['id'], $invRows);
|
||||
|
||||
// Payments per invoice
|
||||
$payRows = $this->paymentModel
|
||||
->select('invoice_id, COALESCE(SUM(paid_amount),0) AS total_paid')
|
||||
->whereIn('invoice_id', $invoiceIds)
|
||||
->groupBy('invoice_id')
|
||||
->findAll();
|
||||
$paidBy = [];
|
||||
foreach ($payRows as $r) { $paidBy[(int)$r['invoice_id']] = (float)($r['total_paid'] ?? 0); }
|
||||
|
||||
// Discounts per invoice
|
||||
$discRows = $this->db->table('discount_usages')
|
||||
->select('invoice_id, COALESCE(SUM(discount_amount),0) AS total_disc')
|
||||
->whereIn('invoice_id', $invoiceIds)
|
||||
->groupBy('invoice_id')
|
||||
->get()->getResultArray();
|
||||
$discBy = [];
|
||||
foreach ($discRows as $r) { $discBy[(int)$r['invoice_id']] = (float)($r['total_disc'] ?? 0); }
|
||||
|
||||
// Choose invoice with most negative raw (total - disc - paid)
|
||||
$minRaw = 0.0; $minId = null;
|
||||
foreach ($invRows as $ir) {
|
||||
$iid = (int)$ir['id'];
|
||||
$raw = (float)($ir['total_amount'] ?? 0) - (float)($discBy[$iid] ?? 0) - (float)($paidBy[$iid] ?? 0);
|
||||
if ($raw < $minRaw) { $minRaw = $raw; $minId = $iid; }
|
||||
}
|
||||
if ($minId !== null) {
|
||||
$assignInvoiceId = $minId;
|
||||
} else {
|
||||
// fallback: latest invoice in this year
|
||||
$row = $this->invoiceModel
|
||||
->select('id')
|
||||
->where('parent_id', (int)$refund['parent_id'])
|
||||
->where('school_year', $refund['school_year'])
|
||||
->orderBy('created_at', 'DESC')
|
||||
->first();
|
||||
if ($row && !empty($row['id'])) $assignInvoiceId = (int)$row['id'];
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'updatePayment: invoice assignment failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// 1) Update refund row
|
||||
$this->refundModel->update($refundId, [
|
||||
'refund_paid_amount' => $total,
|
||||
'status' => $newStatus,
|
||||
'refunded_at' => utc_now(),
|
||||
'updated_at' => utc_now(),
|
||||
'updated_by' => session()->get('user_id'),
|
||||
'refund_method' => $refundMethod,
|
||||
'check_nbr' => $checkNbr,
|
||||
'check_file' => $checkFileName,
|
||||
// tie to invoice if determined
|
||||
'invoice_id' => $assignInvoiceId ?? $refund['invoice_id'],
|
||||
]);
|
||||
|
||||
// 2) Optional: If you want an accounting journal entry for payouts, write to a dedicated table.
|
||||
// We no longer insert a negative row into payments to avoid schema/validation conflicts.
|
||||
|
||||
$db->transComplete();
|
||||
|
||||
if ($db->transStatus() === false) {
|
||||
return $this->response->setJSON(['error' => 'Failed to update payment.']);
|
||||
}
|
||||
|
||||
return $this->response->setJSON(['success' => 'Payment updated successfully.']);
|
||||
}
|
||||
|
||||
/** Keep your listing; added extra fields for clarity */
|
||||
public function listRefunds()
|
||||
{
|
||||
// NOTE: We no longer auto-create/adjust refunds on page load to avoid duplicate lines
|
||||
// when staff are recording payouts. Use the "Recalculate" buttons to run detection on demand.
|
||||
|
||||
// 2) List refunds with joins
|
||||
$refunds = $this->refundModel
|
||||
->select('refunds.*,
|
||||
u.firstname, u.lastname, u.school_id,
|
||||
a.firstname AS approved_by_firstname,
|
||||
a.lastname AS approved_by_lastname')
|
||||
->join('users u', 'refunds.parent_id = u.id')
|
||||
->join('users a', 'refunds.approved_by = a.id', 'left')
|
||||
->orderBy('refunds.created_at', 'DESC')
|
||||
->findAll();
|
||||
|
||||
foreach ($refunds as &$r) {
|
||||
$r['approved_by_name'] = trim(($r['approved_by_firstname'] ?? '') . ' ' . ($r['approved_by_lastname'] ?? '')) ?: '-';
|
||||
}
|
||||
|
||||
$parentId = !empty($refunds) ? $refunds[0]['parent_id'] : null;
|
||||
|
||||
return view('refunds/list', [
|
||||
'refunds' => $refunds,
|
||||
'parentId' => $parentId
|
||||
]);
|
||||
}
|
||||
|
||||
/** Manual endpoint to recalculate/sync overpayments and optionally notify newly created entries. */
|
||||
public function recalculateOverpayments()
|
||||
{
|
||||
// Optional targeted invoice_number to handle cross-year cases
|
||||
$invoiceNumber = (string)($this->request->getPost('invoice_number') ?? '');
|
||||
if ($invoiceNumber !== '') {
|
||||
try {
|
||||
$inv = $this->invoiceModel->where('invoice_number', $invoiceNumber)->first();
|
||||
if ($inv) {
|
||||
$pid = (int)$inv['parent_id'];
|
||||
$iid = (int)$inv['id'];
|
||||
$year = (string)$inv['school_year'];
|
||||
|
||||
// Compute per-invoice overpayment now (independent of current term)
|
||||
$paid = (float)($this->paymentModel
|
||||
->select('COALESCE(SUM(paid_amount),0) AS tot')
|
||||
->where('invoice_id', $iid)
|
||||
->first()['tot'] ?? 0);
|
||||
$disc = (float)($this->db->table('discount_usages')
|
||||
->select('COALESCE(SUM(discount_amount),0) AS tot')
|
||||
->where('invoice_id', $iid)
|
||||
->get()->getRowArray()['tot'] ?? 0);
|
||||
$rfnd = (float)($this->refundModel
|
||||
->select('COALESCE(SUM(refund_paid_amount),0) AS tot')
|
||||
->where('invoice_id', $iid)
|
||||
->whereIn('status', ['Partial','Paid'])
|
||||
->first()['tot'] ?? 0);
|
||||
$total = (float)($inv['total_amount'] ?? 0);
|
||||
$raw = round($total - $disc - $paid - $rfnd, 2);
|
||||
if ($raw < -0.0001) {
|
||||
$over = abs($raw);
|
||||
// If there is ANY open refund for this invoice (any request), update it rather than create
|
||||
$openRow = $this->refundModel
|
||||
->where('invoice_id', $iid)
|
||||
->whereIn('status', ['Pending','Approved','Partial'])
|
||||
->orderBy('id', 'DESC')
|
||||
->first();
|
||||
if ($openRow) {
|
||||
$this->refundModel->update((int)$openRow['id'], [
|
||||
'refund_amount' => $over,
|
||||
'request' => 'overpayment',
|
||||
'updated_at' => utc_now(),
|
||||
'updated_by' => session()->get('user_id'),
|
||||
]);
|
||||
return redirect()->to(site_url('refunds/list'))->with('success', 'Overpayment updated for invoice ' . esc($invoiceNumber));
|
||||
}
|
||||
|
||||
// Else, if no open rows exist, either update existing overpayment (Paid) — skip creating new
|
||||
$existing = $this->refundModel
|
||||
->where('invoice_id', $iid)
|
||||
->where('request', 'overpayment')
|
||||
->orderBy('id', 'DESC')
|
||||
->first();
|
||||
if ($existing) {
|
||||
// If it's Paid, do not create a new duplicate line
|
||||
return redirect()->to(site_url('refunds/list'))->with('info', 'Overpayment was already resolved for this invoice.');
|
||||
}
|
||||
// As a last resort, create a single overpayment row
|
||||
$this->refundModel->insert([
|
||||
'parent_id' => $pid,
|
||||
'school_year' => $year,
|
||||
'semester' => $inv['semester'] ?? null,
|
||||
'invoice_id' => $iid,
|
||||
'refund_amount' => $over,
|
||||
'refund_paid_amount' => 0.00,
|
||||
'status' => 'Pending',
|
||||
'request' => 'overpayment',
|
||||
'reason' => 'Auto-detected per-invoice overpayment (manual)',
|
||||
'updated_by' => session()->get('user_id'),
|
||||
'created_at' => utc_now(),
|
||||
'updated_at' => utc_now(),
|
||||
]);
|
||||
$user = $this->userModel->select('id, email, firstname, lastname')->find($pid) ?: [];
|
||||
$eventData = [
|
||||
'user_id' => (int)($user['id'] ?? $pid),
|
||||
'email' => $user['email'] ?? null,
|
||||
'firstname' => $user['firstname'] ?? null,
|
||||
'lastname' => $user['lastname'] ?? null,
|
||||
'amount' => $over,
|
||||
'portalLink'=> base_url('/login'),
|
||||
];
|
||||
\CodeIgniter\Events\Events::trigger('refundPending', $eventData, []);
|
||||
return redirect()->to(site_url('refunds/list'))->with('success', 'Overpayment detected and refund created for invoice ' . esc($invoiceNumber));
|
||||
}
|
||||
return redirect()->to(site_url('refunds/list'))->with('info', 'No overpayment detected for invoice ' . esc($invoiceNumber));
|
||||
}
|
||||
return redirect()->to(site_url('refunds/list'))->with('error', 'Invoice not found: ' . esc($invoiceNumber));
|
||||
} catch (\Throwable $e) {
|
||||
return redirect()->to(site_url('refunds/list'))->with('error', 'Failed to recalc for invoice: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$res = $this->recalcOverpayments(true);
|
||||
$nNew = count($res['created_ids'] ?? []);
|
||||
$nUpd = count($res['updated_ids'] ?? []);
|
||||
return redirect()->to(site_url('refunds/list'))
|
||||
->with('success', "Recalculated overpayments: created {$nNew}, updated {$nUpd}.");
|
||||
}
|
||||
|
||||
/** Decision endpoint (Approved/Rejected) with reason */
|
||||
public function updateStatus()
|
||||
{
|
||||
log_message('debug', 'POST data: ' . print_r($_POST, true));
|
||||
|
||||
$refundId = (int)$this->request->getPost('refund_id');
|
||||
$status = $this->request->getPost('status');
|
||||
$reason = $this->request->getPost('reason');
|
||||
|
||||
if ($refundId <= 0 || !in_array($status, self::DECISIONS, true)) {
|
||||
return $this->response->setJSON(['error' => 'Invalid status update request.']);
|
||||
}
|
||||
if (empty($reason)) {
|
||||
return $this->response->setJSON(['error' => 'Reason is required for approval or rejection.']);
|
||||
}
|
||||
|
||||
$refund = $this->refundModel->find($refundId);
|
||||
if (!$refund) {
|
||||
return $this->response->setJSON(['error' => 'Refund not found.']);
|
||||
}
|
||||
|
||||
$ok = $this->refundModel->update($refundId, [
|
||||
'status' => $status,
|
||||
'reason' => $reason,
|
||||
'approved_at' => utc_now(),
|
||||
'approved_by' => session()->get('user_id'),
|
||||
'updated_at' => utc_now(),
|
||||
'updated_by' => session()->get('user_id'),
|
||||
]);
|
||||
|
||||
return $this->response->setJSON($ok ? ['success' => 'Refund status updated successfully.']
|
||||
: ['error' => 'Failed to update refund status.']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use CodeIgniter\Controller;
|
||||
use App\Models\ConfigurationModel;
|
||||
use App\Models\UserModel;
|
||||
use App\Models\AuthorizedUserModel;
|
||||
use App\Models\RoleModel;
|
||||
use App\Models\ParentModel;
|
||||
use App\Models\UserRoleModel;
|
||||
use App\Services\EmailService;
|
||||
use App\Services\SchoolIdService;
|
||||
use App\Services\PhoneFormatterService;
|
||||
|
||||
class RegisterController extends Controller
|
||||
{
|
||||
protected $db;
|
||||
protected $configModel;
|
||||
protected $semester;
|
||||
protected $schoolYear;
|
||||
protected $userRoleModel;
|
||||
protected $roleModel;
|
||||
protected $userModel;
|
||||
protected $parentModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// Load the database service
|
||||
$this->db = \Config\Database::connect();
|
||||
// Check if the database connection is established
|
||||
if (!$this->db->connect()) {
|
||||
log_message('error', 'Database connection failed.');
|
||||
throw new \Exception('Database connection failed.');
|
||||
} else {
|
||||
log_message('info', 'Database connection successful.');
|
||||
}
|
||||
$this->configModel = new ConfigurationModel();
|
||||
$this->userRoleModel = new UserRoleModel();
|
||||
$this->roleModel = new RoleModel();
|
||||
$this->userModel = new UserModel();
|
||||
$this->parentModel = new ParentModel();
|
||||
|
||||
$this->semester = $this->configModel->getConfig('semester');
|
||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
log_message('debug', 'CAPTCHA session value: ' . session()->get('captcha_answer'));
|
||||
|
||||
// Only generate CAPTCHA if not already set (first visit or after submission)
|
||||
if (!session()->has('captcha_answer')) {
|
||||
$length = rand(4, 8);
|
||||
$captchaText = $this->generateCaptchaText($length);
|
||||
session()->set('captcha_answer', $captchaText);
|
||||
}
|
||||
|
||||
$data['captchaQuestion'] = session()->get('captcha_answer');
|
||||
|
||||
// ✅ NO need to check policy_accepted here
|
||||
return view('user/register', $data);
|
||||
}
|
||||
|
||||
private function generateCaptchaText($length)
|
||||
{
|
||||
$characters = 'ABCDEFGHJKLMNOPQRSTUVWXYZabcdefghjkmnopqrstuvwxyz0123456789';
|
||||
$captchaText = '';
|
||||
|
||||
for ($i = 0; $i < $length; $i++) {
|
||||
$captchaText .= $characters[rand(0, strlen($characters) - 1)];
|
||||
}
|
||||
|
||||
return $captchaText;
|
||||
}
|
||||
|
||||
public function success()
|
||||
{
|
||||
// Start session
|
||||
$session = session();
|
||||
|
||||
// Check if the session has 'user_email'
|
||||
if (!$session->has('user_email')) {
|
||||
// If not, redirect to the registration page
|
||||
return redirect()->to('/register');
|
||||
}
|
||||
|
||||
// Get 'user_email' from session
|
||||
$email = $session->get('user_email');
|
||||
|
||||
// Pass the 'email' variable to the success view
|
||||
return view('success', ['email' => $email]);
|
||||
}
|
||||
|
||||
|
||||
public function register()
|
||||
{
|
||||
helper(['url', 'form']);
|
||||
log_message('debug', 'CAPTCHA session value: ' . session()->get('captcha_answer'));
|
||||
|
||||
/* ───────────── 1. Load services ───────────── */
|
||||
$schoolIdService = new SchoolIdService();
|
||||
|
||||
/* ───────────── 2. Sanitize & raw data ───────────── */
|
||||
$post = $this->formatUserInput($this->request); // safe / formatted
|
||||
$rawPost = $this->request->getPost(); // raw (for flags etc.)
|
||||
$captcha = $rawPost['captcha'] ?? '';
|
||||
|
||||
/* ───────────── 3. Validation rules ───────────── */
|
||||
$rules = [
|
||||
'firstname' => 'required|regex_match[/^[a-zA-Z\s-]+$/]|min_length[2]|max_length[30]',
|
||||
'lastname' => 'required|regex_match[/^[a-zA-Z\s-]+$/]|min_length[2]|max_length[30]',
|
||||
'gender' => 'required|in_list[Male,Female]',
|
||||
'email' => 'required|valid_email|max_length[50]',
|
||||
'confirm_email' => 'required|matches[email]',
|
||||
'cellphone' => 'required|regex_match[/^[\d\s\-\(\)\.]+$/]|min_length[10]|max_length[20]',
|
||||
'address_street' => 'permit_empty|regex_match[/^[a-zA-Z0-9\s\-]+$/]|max_length[150]',
|
||||
'apt' => 'permit_empty|regex_match[/^[a-zA-Z0-9\s-]+$/]|max_length[10]',
|
||||
'city' => 'required|alpha_space|min_length[2]|max_length[30]',
|
||||
'state' => 'required|in_list[CT,ME,MA,NH,NY,RI,VT]',
|
||||
'zip' => 'required|regex_match[/^\d{5}$/]',
|
||||
'accept_school_policy' => 'required',
|
||||
'captcha' => 'required|alpha_numeric|min_length[4]|max_length[10]',
|
||||
];
|
||||
|
||||
// 1. Check if "I am a parent" checkbox is ticked
|
||||
$isParent = isset($rawPost['is_parent']) && $rawPost['is_parent'] == '1';
|
||||
|
||||
// 2. If parent, handle second parent logic
|
||||
if ($isParent) {
|
||||
$noSecondParent = isset($rawPost['no_second_parent_info']) && $rawPost['no_second_parent_info'] == '1';
|
||||
|
||||
$secondParentProvided = !empty($rawPost['second_firstname']) ||
|
||||
!empty($rawPost['second_lastname']) ||
|
||||
!empty($rawPost['second_gender']) ||
|
||||
!empty($rawPost['second_email']) ||
|
||||
!empty($rawPost['second_cellphone']);
|
||||
|
||||
// 3. Error: must provide second parent or check "no second parent"
|
||||
if (!$noSecondParent && !$secondParentProvided) {
|
||||
return redirect()->back()->withInput()->with('errors', [
|
||||
'second_parent_info' => 'As a parent, please provide second parent information or check "No second parent info".'
|
||||
]);
|
||||
}
|
||||
|
||||
// 4. Add second parent field rules only if "no second parent" is NOT checked
|
||||
if (!$noSecondParent) {
|
||||
$rules += [
|
||||
'second_firstname' => 'required|regex_match[/^[a-zA-Z\s\-]+$/]|min_length[2]|max_length[30]',
|
||||
'second_lastname' => 'required|regex_match[/^[a-zA-Z\s\-]+$/]|min_length[2]|max_length[30]',
|
||||
'second_gender' => 'required|in_list[Male,Female]',
|
||||
'second_email' => 'required|valid_email|max_length[50]',
|
||||
'second_cellphone' => 'required|regex_match[/^[\d\s\-\(\)\.]+$/]|min_length[10]|max_length[20]',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
log_message('debug', '🧪 Starting validation check...');
|
||||
if (!$this->validate($rules)) {
|
||||
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
||||
}
|
||||
|
||||
/* ───────────── 4. CAPTCHA check ───────────── */
|
||||
if ($captcha !== session()->get('captcha_answer')) {
|
||||
return redirect()->back()
|
||||
->withInput()
|
||||
->with('errors', ['captcha' => '']) // keeps input marked as invalid
|
||||
->with('top_error', 'Incorrect CAPTCHA answer. Please try again.');
|
||||
}
|
||||
|
||||
/* ───────────── 5. Unique e-mail ───────────── */
|
||||
// Step 1: Check if a user with this email exists
|
||||
$existingUser = $this->userModel->where('email', $post['email'])->first();
|
||||
|
||||
if ($existingUser) {
|
||||
// Step 2: Check if the user has a token (i.e., not verified yet)
|
||||
if (!empty($existingUser['token']) && $existingUser['is_verified'] == 0) {
|
||||
// User exists and is unverified
|
||||
return redirect()->back()->withInput()->with('error',
|
||||
'This email address is already registered and is pending activation. Please check your email to activate your account.');
|
||||
} else {
|
||||
// User exists and is already active or has no token
|
||||
return redirect()->back()->withInput()->with('error',
|
||||
'The email address you entered is already in use. Please try a different one.');
|
||||
}
|
||||
}
|
||||
|
||||
/* ───────────── 6. Determine role ───────────── */
|
||||
$roleName = !empty($rawPost['is_parent']) ? 'parent' : 'guest';
|
||||
$role = $this->roleModel->where('name', $roleName)->first();
|
||||
if (!$role) {
|
||||
return redirect()->back()->withInput()->with('errors', ['role' => 'Role not found']);
|
||||
}
|
||||
|
||||
/* ───────────── 7. Build & insert user ───────────── */
|
||||
$token = bin2hex(random_bytes(48));
|
||||
$userData = [
|
||||
'firstname' => $post['firstname'],
|
||||
'lastname' => $post['lastname'],
|
||||
'gender' => $post['gender'],
|
||||
'email' => $post['email'],
|
||||
'cellphone' => $post['cellphone'],
|
||||
'address_street' => $post['address_street'] ?? null,
|
||||
'apt' => $post['apt'] ?? null,
|
||||
'city' => $post['city'],
|
||||
'state' => $post['state'],
|
||||
'zip' => $post['zip'],
|
||||
'token' => $token,
|
||||
'is_verified'=> 0,
|
||||
'accept_school_policy' => (int) $post['accept_school_policy'],
|
||||
'status' => 'Inactive',
|
||||
'school_id' => $schoolIdService->generateUserSchoolId(),
|
||||
'semester' => $this->semester,
|
||||
'school_year'=> $this->schoolYear,
|
||||
];
|
||||
|
||||
$this->db->transStart();
|
||||
|
||||
if (!$this->userModel->insert($userData)) {
|
||||
$this->db->transRollback();
|
||||
return redirect()->back()->withInput()->with('errors', $this->userModel->errors());
|
||||
}
|
||||
$firstParentId = $this->userModel->getInsertID();
|
||||
|
||||
$this->userRoleModel->insert([
|
||||
'user_id' => $firstParentId,
|
||||
'role_id' => $role['id'],
|
||||
'created_at' => utc_now(),
|
||||
]);
|
||||
|
||||
/* ───────────── 8. Optional second-parent insert ───────────── */
|
||||
if ($isParent) {
|
||||
if (empty($rawPost['no_second_parent_info'])) {
|
||||
$this->parentModel->insert([
|
||||
'secondparent_firstname' => $post['second_firstname'],
|
||||
'secondparent_lastname' => $post['second_lastname'],
|
||||
'secondparent_gender' => $post['second_gender'],
|
||||
'secondparent_email' => $post['second_email'],
|
||||
'secondparent_phone' => $post['second_cellphone'],
|
||||
'firstparent_id' => $firstParentId,
|
||||
'semester' => $this->semester,
|
||||
'school_year' => $this->schoolYear,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->db->transComplete();
|
||||
if (!$this->db->transStatus()) {
|
||||
return redirect()->back()->withInput()->with('error', 'Registration failed, please try again.');
|
||||
}
|
||||
|
||||
/* ───────────── 9. Confirmation e-mail ───────────── */
|
||||
$recipientName = $post['firstname'] . ' ' . $post['lastname'];
|
||||
$activationLink = site_url('/user/confirm/' . $token);
|
||||
$orgName = 'Al Rahma Sunday School';
|
||||
$contactInfo = 'alrahma.isgl@gmail.com';
|
||||
$logoUrl = 'https://alrahmaisgl.org/assets/images/alrahma_logo.png';
|
||||
|
||||
$emailData = [
|
||||
'recipientName' => $recipientName,
|
||||
'activationLink' => $activationLink,
|
||||
'orgName' => $orgName,
|
||||
'contactInfo' => $contactInfo,
|
||||
'logoUrl' => $logoUrl,
|
||||
'subject' => 'Email Confirmation'
|
||||
];
|
||||
|
||||
if ($isParent) {
|
||||
$html = view('emails/welcome_parent', $emailData);
|
||||
} else {
|
||||
$html = view('emails/welcome_staff', $emailData);
|
||||
}
|
||||
|
||||
(new EmailService())->send($post['email'], 'Email Confirmation', $html, 'general');
|
||||
|
||||
|
||||
/* ───────────── 10. Redirect to success ───────────── */
|
||||
session()->set('user_email', $post['email']);
|
||||
session()->remove('captcha_answer'); // Clear CAPTCHA after success
|
||||
return redirect()->to('/register/success');
|
||||
}
|
||||
|
||||
private function formatName(string $name): string
|
||||
{
|
||||
$name = trim($name);
|
||||
return ucwords(strtolower($name), " -");
|
||||
}
|
||||
|
||||
private function formatEmail(?string $email): string
|
||||
{
|
||||
$email = strtolower(trim($email));
|
||||
/*
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
throw new \InvalidArgumentException('Please enter a valid email address.');
|
||||
}*/
|
||||
return $email;
|
||||
}
|
||||
|
||||
private function formatAddress(string $address): string
|
||||
{
|
||||
return ucwords(strtolower(trim($address)));
|
||||
}
|
||||
|
||||
private function formatUserInput($request): array
|
||||
{
|
||||
$formatter = new PhoneFormatterService();
|
||||
$post = (array) $request->getPost();
|
||||
|
||||
// tiny helper: safe getter + trim + cast to string
|
||||
$g = static function (string $key) use ($post): string {
|
||||
return trim((string) ($post[$key] ?? ''));
|
||||
};
|
||||
|
||||
$data = [
|
||||
'firstname' => $this->formatName($g('firstname')),
|
||||
'lastname' => $this->formatName($g('lastname')),
|
||||
'email' => $this->formatEmail($g('email')),
|
||||
'gender' => $g('gender'),
|
||||
'city' => $this->formatName($g('city')),
|
||||
'cellphone' => $formatter->formatPhoneNumber($g('cellphone')), // ✅ safe
|
||||
'address_street' => $this->formatAddress($g('address_street')),
|
||||
'apt' => strtoupper($g('apt')),
|
||||
'state' => strtoupper($g('state')),
|
||||
'zip' => $g('zip'),
|
||||
'accept_school_policy' => (int) ($post['accept_school_policy'] ?? 0),
|
||||
];
|
||||
|
||||
// if user says "no second parent info", skip entirely
|
||||
$noSecondInfo = !empty($post['no_second_parent_info']);
|
||||
|
||||
// collect (safely) and see if anything was provided
|
||||
$second_firstname = $this->formatName($g('second_firstname'));
|
||||
$second_lastname = $this->formatName($g('second_lastname'));
|
||||
$second_gender = $g('second_gender');
|
||||
$second_email = $this->formatEmail($g('second_email'));
|
||||
$second_cellphone = $formatter->formatPhoneNumber($g('second_cellphone')); // ✅ safe
|
||||
|
||||
$secondProvided = !$noSecondInfo && (
|
||||
$second_firstname !== '' ||
|
||||
$second_lastname !== '' ||
|
||||
$second_gender !== '' ||
|
||||
$second_email !== '' ||
|
||||
preg_replace('/\D/', '', $second_cellphone) !== ''
|
||||
);
|
||||
|
||||
if ($secondProvided) {
|
||||
$data['second_firstname'] = $second_firstname;
|
||||
$data['second_lastname'] = $second_lastname;
|
||||
$data['second_gender'] = $second_gender;
|
||||
$data['second_email'] = $second_email;
|
||||
$data['second_cellphone'] = $second_cellphone;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,723 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Models\RoleModel;
|
||||
use App\Models\PermissionModel;
|
||||
use App\Models\RolePermissionModel;
|
||||
use App\Models\UserModel;
|
||||
use App\Models\UserRoleModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
use App\Models\StaffModel;
|
||||
use CodeIgniter\Controller;
|
||||
|
||||
class RolePermissionController extends Controller
|
||||
{
|
||||
protected $roleModel;
|
||||
protected $userModel;
|
||||
protected $userRoleModel;
|
||||
protected $staffModel;
|
||||
protected $configModel;
|
||||
protected $permissionModel;
|
||||
protected $rolePermissionModel;
|
||||
protected $request;
|
||||
protected $db;
|
||||
protected $schoolYear;
|
||||
protected $semester;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->roleModel = new RoleModel();
|
||||
$this->userModel = new UserModel();
|
||||
$this->userRoleModel = new UserRoleModel();
|
||||
$this->staffModel = new StaffModel();
|
||||
$this->configModel = new ConfigurationModel();
|
||||
$this->permissionModel = new PermissionModel();
|
||||
$this->rolePermissionModel = new RolePermissionModel();
|
||||
|
||||
$this->request = \Config\Services::request();
|
||||
$this->db = \Config\Database::connect();
|
||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||
$this->semester = $this->configModel->getConfig('semester');
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
helper('url');
|
||||
return view('rolepermission/list_roles', [
|
||||
'rolesEndpoint' => site_url('api/rolepermission/roles'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function assignRole()
|
||||
{
|
||||
helper('url');
|
||||
return view('rolepermission/assign_role', [
|
||||
'assignRoleEndpoint' => site_url('api/rolepermission/users'),
|
||||
'rolesEndpoint' => site_url('api/rolepermission/roles'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function saveRole()
|
||||
{
|
||||
if ($this->request->getMethod(true) === 'POST') {
|
||||
$userId = (int) $this->request->getPost('user_id');
|
||||
$roleIdsRaw = $this->request->getPost('role_ids');
|
||||
$roleIdsArray = is_array($roleIdsRaw) ? $roleIdsRaw : ($roleIdsRaw !== null ? [$roleIdsRaw] : []);
|
||||
$roleIds = array_values(array_unique(array_filter(array_map('intval', $roleIdsArray), fn($id) => $id > 0)));
|
||||
$adminId = session()->get('user_id');
|
||||
|
||||
if ($userId <= 0) {
|
||||
return redirect()->to('rolepermission/assign_role')->with('error', 'Invalid user selection.');
|
||||
}
|
||||
|
||||
try {
|
||||
$this->db->transStart();
|
||||
|
||||
// Remove all existing roles for the user
|
||||
$this->userRoleModel->where('user_id', $userId)->delete();
|
||||
|
||||
// Assign new roles
|
||||
if (!empty($roleIds)) {
|
||||
foreach ($roleIds as $roleId) {
|
||||
$this->userRoleModel->insert([
|
||||
'user_id' => $userId,
|
||||
'role_id' => $roleId,
|
||||
'created_at' => utc_now(),
|
||||
'updated_by' => $adminId
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->db->transComplete();
|
||||
|
||||
if ($this->db->transStatus() === false) {
|
||||
throw new \RuntimeException('Transaction failed.');
|
||||
}
|
||||
|
||||
// ✅ Load role names after saving
|
||||
$assignedRoles = !empty($roleIds)
|
||||
? $this->roleModel->whereIn('id', $roleIds)->findAll()
|
||||
: [];
|
||||
$roleNames = array_map(fn($r) => strtolower($r['name']), $assignedRoles ?? []);
|
||||
|
||||
// ✅ Update staff record if needed
|
||||
$this->updateStaffRecord($userId, $roleNames);
|
||||
|
||||
return redirect()->to('rolepermission/assign_role')->with('success', 'Roles updated successfully.');
|
||||
} catch (\Exception $e) {
|
||||
$this->db->transRollback();
|
||||
log_message('error', 'Error updating roles: ' . $e->getMessage());
|
||||
return redirect()->to('rolepermission/assign_role')->with('error', 'Failed to update roles.');
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()->to('rolepermission/assign_role')->with('error', 'Invalid request.');
|
||||
}
|
||||
|
||||
public function usersData()
|
||||
{
|
||||
return $this->response->setJSON($this->buildUsersWithRolesPayload());
|
||||
}
|
||||
|
||||
public function rolesData()
|
||||
{
|
||||
$rows = $this->roleModel
|
||||
->select('roles.id, roles.name, roles.description, COUNT(DISTINCT user_roles.user_id) AS user_count')
|
||||
->join('user_roles', 'user_roles.role_id = roles.id', 'left')
|
||||
->groupBy('roles.id, roles.name, roles.description')
|
||||
->orderBy('roles.name', 'ASC')
|
||||
->findAll();
|
||||
|
||||
$normalized = array_map(static function ($row) {
|
||||
if (!is_array($row)) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'name' => (string) ($row['name'] ?? ''),
|
||||
'description' => $row['description'] ?? null,
|
||||
'user_count' => (int) ($row['user_count'] ?? 0),
|
||||
];
|
||||
}, $rows ?? []);
|
||||
|
||||
return $this->response->setJSON(['roles' => $normalized]);
|
||||
}
|
||||
|
||||
private function buildUsersWithRolesPayload(): array
|
||||
{
|
||||
$rows = $this->userModel
|
||||
->select('users.id, users.firstname, users.lastname, users.email, users.account_id, roles.name AS role_name')
|
||||
->join('user_roles', 'users.id = user_roles.user_id', 'left')
|
||||
->join('roles', 'user_roles.role_id = roles.id', 'left')
|
||||
->orderBy('users.id', 'ASC')
|
||||
->findAll();
|
||||
|
||||
$users = [];
|
||||
foreach ($rows as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$id = (int) ($row['id'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isset($users[$id])) {
|
||||
$users[$id] = [
|
||||
'id' => $id,
|
||||
'firstname' => $row['firstname'] ?? '',
|
||||
'lastname' => $row['lastname'] ?? '',
|
||||
'email' => $row['email'] ?? '',
|
||||
'account_id' => $row['account_id'] ?? '',
|
||||
'roles' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$roleName = $row['role_name'] ?? null;
|
||||
if ($roleName) {
|
||||
$users[$id]['roles'][] = $roleName;
|
||||
}
|
||||
}
|
||||
|
||||
$normalized = array_map(static function ($user) {
|
||||
$roles = array_values(array_unique(array_filter($user['roles'] ?? [], 'strlen')));
|
||||
return [
|
||||
'id' => $user['id'],
|
||||
'firstname' => $user['firstname'] ?? '',
|
||||
'lastname' => $user['lastname'] ?? '',
|
||||
'email' => $user['email'] ?? '',
|
||||
'account_id' => $user['account_id'] ?? '',
|
||||
'roles' => $roles,
|
||||
];
|
||||
}, array_values($users));
|
||||
|
||||
return ['users' => $normalized];
|
||||
}
|
||||
|
||||
private function updateStaffRecord(int $userId, array $newRoleNames): void
|
||||
{
|
||||
$excludedRoles = ['parent', 'student', 'guest'];
|
||||
$loweredNewRoles = array_map('strtolower', $newRoleNames);
|
||||
|
||||
$user = $this->userModel->find($userId);
|
||||
if (!$user) {
|
||||
log_message('error', "updateStaffRecord: user_id {$userId} not found");
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch existing staff record if it exists
|
||||
$existingStaff = $this->staffModel->where('user_id', $userId)->first();
|
||||
|
||||
// Extract existing roles from DB if any
|
||||
$existingRoles = [];
|
||||
if (!empty($existingStaff['role_name'])) {
|
||||
$existingRoles = array_map('strtolower', array_map('trim', explode(',', $existingStaff['role_name'])));
|
||||
}
|
||||
|
||||
// Merge and deduplicate roles
|
||||
$allRoles = array_unique(array_merge($existingRoles, $loweredNewRoles));
|
||||
|
||||
// Determine staff roles (excluding parent/student/guest)
|
||||
$staffRoles = array_filter($newRoleNames, fn($role) => !in_array($role, $excludedRoles));
|
||||
|
||||
// Determine active role
|
||||
$activeRole = !empty($staffRoles) ? $staffRoles[0] : 'inactive';
|
||||
|
||||
$email = $this->generateUniqueStaffEmail($user['firstname'], $user['lastname'], $userId);
|
||||
$now = utc_now();
|
||||
|
||||
$row = [
|
||||
'user_id' => $userId,
|
||||
'firstname' => $user['firstname'],
|
||||
'lastname' => $user['lastname'],
|
||||
'email' => $email,
|
||||
'phone' => $user['cellphone'],
|
||||
'role_name' => implode(', ', $allRoles), // historical roles
|
||||
'active_role' => $activeRole,
|
||||
'school_year' => $this->schoolYear,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
$this->staffModel->upsert($row);
|
||||
}
|
||||
|
||||
private function generateUniqueStaffEmail(string $firstname, string $lastname, int $userId): string
|
||||
{
|
||||
$domain = '@alrahmaisgl.org';
|
||||
|
||||
// Sanitize names: lowercase, letters only
|
||||
$firstname = strtolower(preg_replace('/[^a-z]/i', '', $firstname));
|
||||
$lastname = strtolower(preg_replace('/[^a-z]/i', '', $lastname));
|
||||
|
||||
// Use at least 1 letter from firstname
|
||||
$min = 1;
|
||||
$max = strlen($firstname);
|
||||
$base = '';
|
||||
$email = '';
|
||||
|
||||
for ($i = $min; $i <= $max; $i++) {
|
||||
$base = substr($firstname, 0, $i) . $lastname;
|
||||
$email = $base . $domain;
|
||||
|
||||
// If email not taken or belongs to same user, use it
|
||||
$exists = $this->staffModel->where('email', $email)
|
||||
->where('user_id !=', $userId)
|
||||
->first();
|
||||
if (!$exists) {
|
||||
return $email;
|
||||
}
|
||||
}
|
||||
|
||||
// If all combinations used, fallback to adding user ID
|
||||
return $base . $userId . $domain;
|
||||
}
|
||||
|
||||
public function listRoles()
|
||||
{
|
||||
helper('url');
|
||||
return view('rolepermission/list_roles', [
|
||||
'rolesEndpoint' => site_url('api/rolepermission/roles'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function editRolePermissions($roleId)
|
||||
{
|
||||
// Fetch roles, permissions, and role permissions data
|
||||
$roles = $this->roleModel->findAll();
|
||||
$permissions = $this->permissionModel->findAll();
|
||||
$rolePermissions = $this->rolePermissionModel->where('role_id', $roleId)->findAll();
|
||||
|
||||
// Convert existing role permissions to a simple array for comparison
|
||||
$existingPermissions = [];
|
||||
foreach ($rolePermissions as $rolePermission) {
|
||||
$existingPermissions[$rolePermission['permission_id']] = [
|
||||
'can_create' => $rolePermission['can_create'],
|
||||
'can_read' => $rolePermission['can_read'],
|
||||
'can_update' => $rolePermission['can_update'],
|
||||
'can_delete' => $rolePermission['can_delete'],
|
||||
];
|
||||
}
|
||||
|
||||
// Handle POST request
|
||||
if (strtolower($this->request->getMethod()) === 'post') {
|
||||
// Get the posted permissions data
|
||||
$postPermissions = $this->request->getPost('permissions');
|
||||
|
||||
// Call saveRolePermissions with the correct data
|
||||
return $this->saveRolePermissions($roleId, $postPermissions);
|
||||
}
|
||||
|
||||
return view('rolepermission/edit_role_permissions', [
|
||||
'roles' => $roles,
|
||||
'roleId' => $roleId,
|
||||
'permissions' => $permissions,
|
||||
'rolePermissions' => $rolePermissions,
|
||||
'existingPermissions' => $existingPermissions
|
||||
]);
|
||||
}
|
||||
|
||||
public function saveRolePermissions($roleId = null, $permissions = null)
|
||||
{
|
||||
// If $roleId and $permissions are not passed, retrieve them from POST
|
||||
if (!$roleId) {
|
||||
$roleId = $this->request->getPost('role_id');
|
||||
}
|
||||
|
||||
if (!$permissions) {
|
||||
$permissions = $this->request->getPost('permissions');
|
||||
}
|
||||
|
||||
if (!$roleId) {
|
||||
return redirect()->back()->with('error', 'Role ID is missing.');
|
||||
}
|
||||
|
||||
// Fetch existing permissions for the role
|
||||
$rolePermissions = $this->rolePermissionModel->where('role_id', $roleId)->findAll();
|
||||
|
||||
$existingPermissions = [];
|
||||
foreach ($rolePermissions as $rolePermission) {
|
||||
$existingPermissions[$rolePermission['permission_id']] = $rolePermission;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->rolePermissionModel->transStart();
|
||||
|
||||
// Process the new permissions if provided
|
||||
if ($permissions && is_array($permissions)) {
|
||||
foreach ($permissions as $permissionId => $crudValues) {
|
||||
// Ensure CRUD values are correctly set, defaulting to 0 if not provided
|
||||
$permissionData = [
|
||||
'can_create' => isset($crudValues['create']) ? 1 : 0,
|
||||
'can_read' => isset($crudValues['read']) ? 1 : 0,
|
||||
'can_update' => isset($crudValues['update']) ? 1 : 0,
|
||||
'can_delete' => isset($crudValues['delete']) ? 1 : 0,
|
||||
'updated_at' => utc_now(),
|
||||
];
|
||||
|
||||
if (isset($existingPermissions[$permissionId])) {
|
||||
// Update existing permission
|
||||
$this->rolePermissionModel->where('role_id', $roleId)
|
||||
->where('permission_id', $permissionId)
|
||||
->set($permissionData)
|
||||
->update();
|
||||
} else {
|
||||
// Insert new permission
|
||||
$permissionData['role_id'] = $roleId;
|
||||
$permissionData['permission_id'] = $permissionId;
|
||||
$permissionData['created_at'] = utc_now();
|
||||
$this->rolePermissionModel->insert($permissionData);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove permissions that are no longer present
|
||||
foreach ($existingPermissions as $permissionId => $permissionData) {
|
||||
if (!isset($permissions[$permissionId])) {
|
||||
$this->rolePermissionModel->where('role_id', $roleId)
|
||||
->where('permission_id', $permissionId)
|
||||
->delete();
|
||||
}
|
||||
}
|
||||
|
||||
$this->rolePermissionModel->transComplete();
|
||||
|
||||
if ($this->rolePermissionModel->transStatus() === false) {
|
||||
throw new \RuntimeException('Transaction failed.');
|
||||
}
|
||||
|
||||
return redirect()->to('/rolepermission/edit_role_permissions/' . $roleId)
|
||||
->with('status', 'Permissions saved successfully.');
|
||||
} catch (\Exception $e) {
|
||||
$this->rolePermissionModel->transRollback();
|
||||
log_message('error', 'Failed to save permissions: ' . $e->getMessage());
|
||||
return redirect()->back()->with('error', 'Failed to save permissions: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Role management functions
|
||||
public function rolesList()
|
||||
{
|
||||
return $this->listRoles();
|
||||
}
|
||||
|
||||
|
||||
public function addRole()
|
||||
{
|
||||
return view('rolepermission/add_role');
|
||||
}
|
||||
|
||||
public function storeRole()
|
||||
{
|
||||
$validation = \Config\Services::validation();
|
||||
|
||||
// Validation rules
|
||||
$validation->setRules([
|
||||
'name' => 'required|min_length[3]|max_length[255]|is_unique[roles.name]',
|
||||
'slug' => 'permit_empty|max_length[64]|is_unique[roles.slug]',
|
||||
'description' => 'permit_empty|max_length[500]',
|
||||
'dashboard_route' => [
|
||||
'rules' => 'required|min_length[1]|max_length[255]|regex_match[/^[a-z0-9_\\/\\-]+$/]',
|
||||
'errors' => [
|
||||
'regex_match' => 'Route may contain lowercase letters, numbers, /, -, and _.'
|
||||
]
|
||||
],
|
||||
'priority' => 'required|integer|greater_than_equal_to[0]|less_than_equal_to[100000]',
|
||||
'is_active' => 'required|in_list[0,1]',
|
||||
]);
|
||||
|
||||
if (!$validation->withRequest($this->request)->run()) {
|
||||
return redirect()->back()->withInput()->with('errors', $validation->getErrors());
|
||||
}
|
||||
|
||||
// Normalize + sanitize inputs
|
||||
$rawName = trim((string) $this->request->getPost('name'));
|
||||
$rawSlug = trim((string) $this->request->getPost('slug'));
|
||||
$route = trim((string) $this->request->getPost('dashboard_route'));
|
||||
$desc = trim((string) $this->request->getPost('description'));
|
||||
$priority = (int) $this->request->getPost('priority');
|
||||
$isActive = (string) $this->request->getPost('is_active') === '1' ? 1 : 0;
|
||||
|
||||
// Force lowercase for consistency
|
||||
$name = strtolower($rawName);
|
||||
$dashboard_route = strtolower($route);
|
||||
$description = strtolower($desc);
|
||||
|
||||
// Build slug: from input if provided, else from name
|
||||
$slug = $rawSlug !== '' ? $rawSlug : $name;
|
||||
// convert spaces / invalid chars to underscores and lowercase
|
||||
$slug = strtolower(preg_replace('/[^a-z0-9]+/i', '_', $slug));
|
||||
$slug = trim($slug, '_');
|
||||
|
||||
// Ensure slug uniqueness (in case collision after normalization)
|
||||
$slug = $this->ensureUniqueSlug($slug);
|
||||
|
||||
$data = [
|
||||
'name' => $name,
|
||||
'slug' => $slug,
|
||||
'description' => $description,
|
||||
'dashboard_route' => $dashboard_route,
|
||||
'priority' => $priority,
|
||||
'is_active' => $isActive,
|
||||
'created_at' => utc_now(),
|
||||
'updated_at' => utc_now(),
|
||||
];
|
||||
|
||||
if ($this->roleModel->insert($data)) {
|
||||
return redirect()->to('rolepermission/roles')->with('success', 'Role created successfully!');
|
||||
}
|
||||
|
||||
return redirect()->back()->withInput()->with('error', 'Failed to create role.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure slug is unique in roles.slug; if exists, append -2, -3, ...
|
||||
*/
|
||||
/**
|
||||
* Ensure slug is unique in the roles table.
|
||||
* - If $ignoreId is provided, that record is excluded (for updates).
|
||||
* - Appends _2, _3, ... if duplicates exist.
|
||||
*/
|
||||
private function ensureUniqueSlug(string $baseSlug, ?int $ignoreId = null): string
|
||||
{
|
||||
$slug = $baseSlug;
|
||||
$i = 2;
|
||||
|
||||
while (true) {
|
||||
$builder = $this->roleModel->where('slug', $slug);
|
||||
if ($ignoreId !== null) {
|
||||
$builder->where('id !=', $ignoreId);
|
||||
}
|
||||
|
||||
$exists = $builder->countAllResults() > 0;
|
||||
|
||||
if (!$exists) {
|
||||
break;
|
||||
}
|
||||
|
||||
$slug = $baseSlug . '_' . $i;
|
||||
$i++;
|
||||
|
||||
if ($i > 100000) { // safety stop
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $slug;
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function editRole($id)
|
||||
{
|
||||
if (!$role = $this->roleModel->find($id)) {
|
||||
return redirect()->to('rolepermission/roles')->with('error', 'Role not found.');
|
||||
}
|
||||
|
||||
return view('rolepermission/edit_role', ['role' => $role]);
|
||||
}
|
||||
|
||||
public function updateRole($id)
|
||||
{
|
||||
$role = $this->roleModel->find($id);
|
||||
if (!$role) {
|
||||
return redirect()->to('rolepermission/roles')->with('error', 'Role not found.');
|
||||
}
|
||||
|
||||
$validation = \Config\Services::validation();
|
||||
// CI4 is_unique format: is_unique[table.field,ignore_field,ignore_value]
|
||||
$validation->setRules([
|
||||
'name' => "required|min_length[3]|max_length[255]|is_unique[roles.name,id,{$id}]",
|
||||
'slug' => "permit_empty|max_length[64]|is_unique[roles.slug,id,{$id}]",
|
||||
'description' => 'permit_empty|max_length[500]',
|
||||
'dashboard_route' => [
|
||||
'rules' => 'required|min_length[1]|max_length[255]|regex_match[/^[a-z0-9_\\/\\-]+$/]',
|
||||
'errors' => [
|
||||
'regex_match' => 'Route may contain lowercase letters, numbers, /, -, and _.'
|
||||
]
|
||||
],
|
||||
'priority' => 'required|integer|greater_than_equal_to[0]|less_than_equal_to[100000]',
|
||||
'is_active' => 'required|in_list[0,1]',
|
||||
]);
|
||||
|
||||
if (!$validation->withRequest($this->request)->run()) {
|
||||
return redirect()->back()->withInput()->with('errors', $validation->getErrors());
|
||||
}
|
||||
|
||||
// Normalize inputs (lowercase storage)
|
||||
$rawName = trim((string) $this->request->getPost('name'));
|
||||
$rawSlug = trim((string) $this->request->getPost('slug'));
|
||||
$route = trim((string) $this->request->getPost('dashboard_route'));
|
||||
$desc = trim((string) $this->request->getPost('description'));
|
||||
$priority = (int) $this->request->getPost('priority');
|
||||
$isActive = (string) $this->request->getPost('is_active') === '1' ? 1 : 0;
|
||||
|
||||
$name = strtolower($rawName);
|
||||
$dashboard_route = strtolower($route);
|
||||
$description = strtolower($desc);
|
||||
|
||||
// slug: if blank, derive from name; else normalize
|
||||
$slug = $rawSlug !== '' ? $rawSlug : $name;
|
||||
$slug = strtolower(preg_replace('/[^a-z0-9]+/i', '_', $slug));
|
||||
$slug = trim($slug, '_');
|
||||
|
||||
// Ensure unique slug if changed or blank turned into new value
|
||||
if ($slug !== $role['slug']) {
|
||||
$slug = $this->ensureUniqueSlug($slug, (int) $id);
|
||||
}
|
||||
|
||||
$data = [
|
||||
'name' => $name,
|
||||
'slug' => $slug,
|
||||
'description' => $description,
|
||||
'dashboard_route' => $dashboard_route,
|
||||
'priority' => $priority,
|
||||
'is_active' => $isActive,
|
||||
'updated_at' => utc_now(),
|
||||
];
|
||||
|
||||
if ($this->roleModel->update($id, $data)) {
|
||||
return redirect()->to('rolepermission/roles')->with('success', 'Role updated successfully!');
|
||||
}
|
||||
|
||||
return redirect()->back()->withInput()->with('error', 'Failed to update role.');
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
public function deleteRole($id)
|
||||
{
|
||||
// Check if role exists
|
||||
$role = $this->roleModel->find($id);
|
||||
if (!$role) {
|
||||
return redirect()->to(site_url('rolepermission/roles'))
|
||||
->with('error', 'Role not found.');
|
||||
}
|
||||
|
||||
// Try deleting
|
||||
if ($this->roleModel->delete($id)) {
|
||||
return redirect()->to(site_url('rolepermission/roles'))
|
||||
->with('success', 'Role deleted successfully.');
|
||||
} else {
|
||||
return redirect()->to(site_url('rolepermission/roles'))
|
||||
->with('error', 'Failed to delete role.');
|
||||
}
|
||||
}
|
||||
|
||||
// ADD: show form + handle submit
|
||||
public function addPermission()
|
||||
{
|
||||
if (strtolower($this->request->getMethod()) === 'post') {
|
||||
$name = trim((string) $this->request->getPost('name'));
|
||||
$desc = trim((string) $this->request->getPost('description'));
|
||||
|
||||
// Basic guard
|
||||
if ($name === '') {
|
||||
return redirect()->back()
|
||||
->with('error', 'Permission name is required.')
|
||||
->withInput();
|
||||
}
|
||||
|
||||
// Check existence (collation is case-insensitive: utf8mb4_unicode_ci)
|
||||
$existing = $this->permissionModel->where('name', $name)->first();
|
||||
|
||||
if ($existing) {
|
||||
// Idempotent: do nothing if already exists
|
||||
return redirect()->to(site_url('rolepermission/list_permissions'))
|
||||
->with('success', 'Permission already exists. No changes made.');
|
||||
}
|
||||
|
||||
// Create new
|
||||
$data = [
|
||||
'name' => $name,
|
||||
'description' => $desc,
|
||||
];
|
||||
|
||||
if (!$this->permissionModel->save($data)) {
|
||||
return redirect()->back()
|
||||
->with('error', implode(' ', $this->permissionModel->errors()))
|
||||
->withInput();
|
||||
}
|
||||
|
||||
return redirect()->to(site_url('rolepermission/list_permissions'))
|
||||
->with('success', 'Permission added successfully.');
|
||||
}
|
||||
|
||||
// GET -> form
|
||||
return view('rolepermission/add_permission');
|
||||
}
|
||||
|
||||
|
||||
// EDIT: show form + handle submit
|
||||
public function editPermission($id = null)
|
||||
{
|
||||
$permission = $this->permissionModel->find($id);
|
||||
if (!$permission) {
|
||||
return redirect()->to(site_url('roles'))->with('error', 'Permission not found.');
|
||||
}
|
||||
|
||||
if (strtolower($this->request->getMethod()) === 'post') {
|
||||
$data = [
|
||||
'id' => $id, // needed for is_unique ignore
|
||||
'name' => trim($this->request->getPost('name')),
|
||||
'description' => trim($this->request->getPost('description')),
|
||||
];
|
||||
|
||||
if (!$this->permissionModel->save($data)) {
|
||||
return redirect()->back()
|
||||
->with('error', implode(' ', $this->permissionModel->errors()))
|
||||
->withInput();
|
||||
}
|
||||
|
||||
return redirect()->to(site_url('rolepermission/list_permissions'))
|
||||
->with('success', 'Permission updated successfully.');
|
||||
}
|
||||
|
||||
return view('rolepermission/edit_permission', ['permission' => $permission]);
|
||||
}
|
||||
|
||||
// Optional delete
|
||||
public function deletePermission($id = null)
|
||||
{
|
||||
if (!$this->permissionModel->find($id)) {
|
||||
return redirect()->to(site_url('rolepermission/list_permissions'))->with('error', 'Permission not found.');
|
||||
}
|
||||
|
||||
$this->permissionModel->delete($id);
|
||||
return redirect()->to(site_url('rolepermission/list_permissions'))->with('success', 'Permission deleted.');
|
||||
}
|
||||
|
||||
public function listPermissions()
|
||||
{
|
||||
helper('url');
|
||||
return view('rolepermission/permissionList', [
|
||||
'permissionsEndpoint' => site_url('api/rolepermission/permissions'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function permissionsData()
|
||||
{
|
||||
$rows = $this->permissionModel
|
||||
->select('id, name, description, created_at, updated_at')
|
||||
->orderBy('id', 'ASC')
|
||||
->findAll();
|
||||
|
||||
$normalized = array_map(static function ($row) {
|
||||
if (!is_array($row)) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'name' => (string) ($row['name'] ?? ''),
|
||||
'description' => $row['description'] ?? null,
|
||||
'created_at' => $row['created_at'] ?? null,
|
||||
'updated_at' => $row['updated_at'] ?? null,
|
||||
];
|
||||
}, $rows ?? []);
|
||||
|
||||
return $this->response->setJSON(['permissions' => $normalized]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
use App\Controllers\BaseController;
|
||||
use App\Models\UserRoleModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
use CodeIgniter\Controller;
|
||||
use App\Models\RoleModel;
|
||||
|
||||
class RoleSwitcherController extends BaseController
|
||||
{
|
||||
protected $db;
|
||||
protected $configModel;
|
||||
protected $semester;
|
||||
protected $schoolYear;
|
||||
protected $userRoleModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// Load the database service
|
||||
$this->db = \Config\Database::connect();
|
||||
// Check if the database connection is established
|
||||
if (!$this->db->connect()) {
|
||||
log_message('error', 'Database connection failed.');
|
||||
throw new \Exception('Database connection failed.');
|
||||
} else {
|
||||
log_message('info', 'Database connection successful.');
|
||||
}
|
||||
$this->configModel = new ConfigurationModel();
|
||||
$this->userRoleModel = new UserRoleModel();
|
||||
$this->semester = $this->configModel->getConfig('semester');
|
||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$userId = session()->get('user_id');
|
||||
|
||||
if (!$userId) {
|
||||
return redirect()->to('/login')->with('error', 'Please log in first.');
|
||||
}
|
||||
$roles = $this->userRoleModel
|
||||
->select('roles.name')
|
||||
->join('roles', 'roles.id = user_roles.role_id')
|
||||
->where('user_roles.user_id', $userId)
|
||||
->findAll();
|
||||
|
||||
if (empty($roles)) {
|
||||
return redirect()->back()->with('error', 'No roles assigned to this user.');
|
||||
}
|
||||
|
||||
$roleNames = array_column($roles, 'name');
|
||||
|
||||
if (count($roleNames) === 1) {
|
||||
return $this->redirectToDashboard($roleNames[0]);
|
||||
}
|
||||
|
||||
return view('account/choose_account', ['roles' => $roleNames]);
|
||||
}
|
||||
|
||||
public function switchTo($role)
|
||||
{
|
||||
session()->set('active_role', $role);
|
||||
return $this->redirectToDashboard($role);
|
||||
}
|
||||
|
||||
/*
|
||||
private function redirectToDashboard($role)
|
||||
{
|
||||
return match ($role) {
|
||||
'administrator' => redirect()->to('administrator/administratordashboard'),
|
||||
'administrative staff' => redirect()->to('/landing_page/admin_dashboard'),
|
||||
'teacher' => redirect()->to('/teacher_dashboard'),
|
||||
'student' => redirect()->to('/landing_page/student_dashboard'),
|
||||
'parent' => redirect()->to('/parent_dashboard'),
|
||||
'head_of_department' => redirect()->to('/hod/dashboard'),
|
||||
default => redirect()->to('/')->with('error', 'Unknown role.'),
|
||||
};
|
||||
}
|
||||
*/
|
||||
|
||||
|
||||
private function redirectToDashboard($role)
|
||||
{
|
||||
$key = is_string($role) ? $role : (string) $role;
|
||||
|
||||
$route = (new RoleModel())->getRouteByNameOrSlug($key)
|
||||
?? '/landing_page/guest_dashboard';
|
||||
|
||||
log_message('debug', 'Redirecting user to: ' . $route);
|
||||
return redirect()->to($route);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,658 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
use App\Controllers\BaseController;
|
||||
|
||||
use App\Models\CalendarModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
use App\Models\TeacherModel;
|
||||
use App\Models\UserModel;
|
||||
use App\Models\ParentMeetingScheduleModel;
|
||||
use App\Services\EmailService;
|
||||
|
||||
|
||||
class SchoolCalendarController extends BaseController
|
||||
{
|
||||
protected $calendarModel;
|
||||
protected $configModel;
|
||||
protected $semester;
|
||||
protected $schoolYear;
|
||||
protected $db;
|
||||
protected $eventTypes = [
|
||||
'1st Semester Scores',
|
||||
'2nd Semester Scores',
|
||||
'Break',
|
||||
'Event',
|
||||
'Final',
|
||||
'Final Exam Draft',
|
||||
'Midterm',
|
||||
'Midterm Exam Draft',
|
||||
];
|
||||
|
||||
protected EmailService $emailService;
|
||||
protected UserModel $userModel;
|
||||
protected TeacherModel $teacherModel;
|
||||
protected ParentMeetingScheduleModel $meetingModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// Load the models
|
||||
$this->calendarModel = new CalendarModel();
|
||||
$this->configModel = new ConfigurationModel();
|
||||
$this->userModel = new UserModel();
|
||||
$this->teacherModel = new TeacherModel();
|
||||
$this->emailService = new EmailService();
|
||||
$this->meetingModel = new ParentMeetingScheduleModel();
|
||||
$this->db = \Config\Database::connect();
|
||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||
$this->semester = $this->configModel->getConfig('semester');
|
||||
|
||||
// Load helpers
|
||||
helper(['form', 'url']);
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
// Get school_year / semester from query string or defaults
|
||||
$syParam = $this->request->getGet('school_year');
|
||||
$semParam = $this->request->getGet('semester');
|
||||
$schoolYear = $syParam ?: $this->schoolYear;
|
||||
$semester = $semParam ?: '';
|
||||
|
||||
if ($semester !== '') {
|
||||
$events = $this->calendarModel->getEventsBySchoolYearAndSemester($schoolYear, $semester);
|
||||
} else {
|
||||
$events = $this->calendarModel->getEventsBySchoolYear($schoolYear);
|
||||
}
|
||||
|
||||
return view('administrator/calendar_view', [
|
||||
'events' => $events,
|
||||
'schoolYear' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
public function edit($id)
|
||||
{
|
||||
$event = $this->calendarModel->find($id);
|
||||
|
||||
if (!$event) {
|
||||
return redirect()->to('/administrator/calendar_view')->with('error', 'Event not found');
|
||||
}
|
||||
|
||||
return view('administrator/calendar_edit', [
|
||||
'event' => $event,
|
||||
'eventTypes' => $this->eventTypes,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
public function update($id)
|
||||
{
|
||||
// Fetch the event by ID
|
||||
$event = $this->calendarModel->find($id);
|
||||
|
||||
if (!$event) {
|
||||
return redirect()->to('/administrator/calendar_view')->with('error', 'Event not found.');
|
||||
}
|
||||
|
||||
if (strtolower($this->request->getMethod()) === 'post') {
|
||||
$validation = $this->validate([
|
||||
'title' => 'required|min_length[3]',
|
||||
'start' => 'required|valid_date[Y-m-d]',
|
||||
]);
|
||||
|
||||
if (!$validation) {
|
||||
return view('administrator/calendar_edit', [
|
||||
'validation' => $this->validator,
|
||||
'event' => $event,
|
||||
'eventTypes' => $this->eventTypes,
|
||||
]);
|
||||
}
|
||||
|
||||
$emailTargets = $this->emailNotificationTargetsFromRequest();
|
||||
|
||||
$data = [
|
||||
'title' => $this->request->getPost('title'),
|
||||
'description' => $this->request->getPost('description'),
|
||||
'event_type' => $this->request->getPost('event_type') ?: null,
|
||||
'date' => $this->request->getPost('start'),
|
||||
'notify_parent' => $this->request->getPost('notify_parent') ? 1 : 0,
|
||||
'notify_teacher' => $this->request->getPost('notify_teacher') ? 1 : 0,
|
||||
'notify_admin' => $this->request->getPost('notify_admin') ? 1 : 0,
|
||||
'no_school' => $this->request->getPost('no_school') ? 1 : 0,
|
||||
'school_year' => $this->request->getPost('school_year') ?: $this->schoolYear,
|
||||
'semester' => $this->request->getPost('semester') ?: $this->semester,
|
||||
];
|
||||
if (!$this->calendarModel->supportsEventType()) {
|
||||
unset($data['event_type']);
|
||||
}
|
||||
|
||||
if ($this->calendarModel->update($id, $data)) {
|
||||
$updatedEvent = $this->calendarModel->find($id);
|
||||
$emailStatus = $this->dispatchCalendarNotificationEmails($updatedEvent, $emailTargets);
|
||||
$filters = [
|
||||
'school_year' => $data['school_year'],
|
||||
'semester' => $data['semester'],
|
||||
];
|
||||
$redirect = redirect()->to($this->buildCalendarViewUrl($filters))->with('success', 'Event updated successfully');
|
||||
if ($emailFlash = $this->buildEmailFlash($emailStatus)) {
|
||||
$redirect = $redirect
|
||||
->with('email_status', $emailFlash['message'])
|
||||
->with('email_status_class', $emailFlash['class']);
|
||||
}
|
||||
return $redirect;
|
||||
} else {
|
||||
return redirect()->back()->with('error', 'Failed to update the event.');
|
||||
}
|
||||
}
|
||||
|
||||
return view('administrator/calendar_edit', [
|
||||
'event' => $event,
|
||||
'eventTypes' => $this->eventTypes,
|
||||
]);
|
||||
}
|
||||
|
||||
public function addEvent()
|
||||
{
|
||||
if (strtolower($this->request->getMethod()) === 'post') {
|
||||
$validation = $this->validate([
|
||||
'title' => 'required|min_length[3]',
|
||||
'start' => 'required|valid_date[Y-m-d]',
|
||||
]);
|
||||
|
||||
if (!$validation) {
|
||||
return view('administrator/calendar_add_event', [
|
||||
'validation' => $this->validator,
|
||||
'eventTypes' => $this->eventTypes,
|
||||
]);
|
||||
}
|
||||
|
||||
$emailTargets = $this->emailNotificationTargetsFromRequest();
|
||||
|
||||
// Prepare data
|
||||
$data = [
|
||||
'title' => $this->request->getPost('title'),
|
||||
'description' => $this->request->getPost('description'),
|
||||
'event_type' => $this->request->getPost('event_type') ?: null,
|
||||
'date' => $this->request->getPost('start'),
|
||||
'notify_parent' => $this->request->getPost('notify_parent') ? 1 : 0,
|
||||
'notify_teacher' => $this->request->getPost('notify_teacher') ? 1 : 0,
|
||||
'notify_admin' => $this->request->getPost('notify_admin') ? 1 : 0,
|
||||
'no_school' => $this->request->getPost('no_school') ? 1 : 0,
|
||||
'school_year' => $this->request->getPost('school_year') ?: $this->schoolYear,
|
||||
'semester' => $this->request->getPost('semester') ?: $this->semester,
|
||||
];
|
||||
if (!$this->calendarModel->supportsEventType()) {
|
||||
unset($data['event_type']);
|
||||
}
|
||||
|
||||
if ($this->calendarModel->save($data)) {
|
||||
$insertedId = $this->calendarModel->getInsertID();
|
||||
$savedEvent = $this->calendarModel->find($insertedId);
|
||||
$emailStatus = $this->dispatchCalendarNotificationEmails($savedEvent, $emailTargets);
|
||||
log_message('info', 'Event saved successfully: ' . print_r($data, true));
|
||||
$filters = [
|
||||
'school_year' => $data['school_year'],
|
||||
'semester' => $data['semester'],
|
||||
];
|
||||
$redirect = redirect()->to($this->buildCalendarViewUrl($filters))->with('success', 'Event added successfully');
|
||||
if ($emailFlash = $this->buildEmailFlash($emailStatus)) {
|
||||
$redirect = $redirect
|
||||
->with('email_status', $emailFlash['message'])
|
||||
->with('email_status_class', $emailFlash['class']);
|
||||
}
|
||||
return $redirect;
|
||||
} else {
|
||||
log_message('error', 'Failed to save event: ' . print_r($data, true));
|
||||
return redirect()->back()->with('error', 'Failed to save the event.');
|
||||
}
|
||||
}
|
||||
|
||||
return view('administrator/calendar_add_event', [
|
||||
'eventTypes' => $this->eventTypes,
|
||||
]);
|
||||
}
|
||||
|
||||
protected function emailNotificationTargetsFromRequest(): array
|
||||
{
|
||||
return [
|
||||
'parents' => (bool) $this->request->getPost('send_email_parent'),
|
||||
'teachers' => (bool) $this->request->getPost('send_email_teacher'),
|
||||
'admins' => (bool) $this->request->getPost('send_email_admin'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function dispatchCalendarNotificationEmails(array $event = null, array $targets = []): array
|
||||
{
|
||||
if (empty($event) || empty(array_filter($targets))) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$subject = $this->buildCalendarEmailSubject($event);
|
||||
$innerBody = view('emails/calendar_notification', [
|
||||
'event' => $event,
|
||||
'calendarUrl' => base_url('administrator/calendar_view'),
|
||||
]);
|
||||
$body = view('emails/_wrap_layout', [
|
||||
'title' => $subject,
|
||||
'subject' => $subject,
|
||||
'body_html' => $innerBody,
|
||||
]);
|
||||
|
||||
$results = [];
|
||||
foreach ($targets as $group => $enabled) {
|
||||
if (!$enabled) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$emails = $this->collectEmailsForGroup($group);
|
||||
if (empty($emails)) {
|
||||
log_message('warning', "SchoolCalendarController: no recipient emails for {$group}.");
|
||||
$results[$group] = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
$ok = $this->emailService->sendBcc($emails, $subject, $body, 'general');
|
||||
if (!$ok) {
|
||||
log_message('warning', "SchoolCalendarController: failed to send calendar notification to {$group}.");
|
||||
}
|
||||
$results[$group] = $ok;
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
protected function collectEmailsForGroup(string $group): array
|
||||
{
|
||||
switch ($group) {
|
||||
case 'parents':
|
||||
return $this->normalizeEmailAddresses($this->userModel->getParents());
|
||||
case 'teachers':
|
||||
return $this->normalizeEmailAddresses($this->teacherModel->getAllTeachers());
|
||||
case 'admins':
|
||||
return $this->normalizeEmailAddresses($this->fetchAdminRows());
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
protected function normalizeEmailAddresses(array $rows): array
|
||||
{
|
||||
$emails = [];
|
||||
foreach ($rows as $row) {
|
||||
$email = trim((string) ($row['email'] ?? ''));
|
||||
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
continue;
|
||||
}
|
||||
$emails[$email] = true;
|
||||
}
|
||||
return array_values(array_keys($emails));
|
||||
}
|
||||
|
||||
protected function fetchAdminRows(): array
|
||||
{
|
||||
$excluded = ['guest', 'teacher', 'teacher_assistant', 'parent'];
|
||||
$escaped = array_map(static fn($value) => "'" . addslashes($value) . "'", $excluded);
|
||||
$excludedList = implode(', ', $escaped);
|
||||
|
||||
$builder = $this->db->table('users u')
|
||||
->select("u.id, u.firstname, u.lastname, u.email, MAX(CASE WHEN LOWER(COALESCE(r.slug, r.name)) NOT IN({$excludedList}) THEN 1 ELSE 0 END) AS is_admin", false)
|
||||
->join('user_roles ur', 'ur.user_id = u.id')
|
||||
->join('roles r', 'r.id = ur.role_id')
|
||||
->where('r.is_active', 1)
|
||||
->where('u.email IS NOT NULL')
|
||||
->where('u.email !=', '')
|
||||
->groupBy('u.id, u.firstname, u.lastname, u.email')
|
||||
->having('is_admin', 1);
|
||||
|
||||
return $builder->get()->getResultArray();
|
||||
}
|
||||
|
||||
protected function buildCalendarEmailSubject(array $event): string
|
||||
{
|
||||
$title = trim((string) ($event['title'] ?? 'School Calendar Update'));
|
||||
$typeSegment = !empty($event['event_type']) ? ' (' . $event['event_type'] . ')' : '';
|
||||
return 'School Calendar: ' . $title . $typeSegment;
|
||||
}
|
||||
|
||||
protected function buildEmailFlash(array $status): ?array
|
||||
{
|
||||
if (empty($status)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$sent = [];
|
||||
$failed = [];
|
||||
$missing = [];
|
||||
|
||||
foreach ($status as $group => $result) {
|
||||
if ($result === true) {
|
||||
$sent[] = $group;
|
||||
} elseif ($result === false) {
|
||||
$failed[] = $group;
|
||||
} elseif ($result === null) {
|
||||
$missing[] = $group;
|
||||
}
|
||||
}
|
||||
|
||||
$parts = [];
|
||||
if (!empty($sent)) {
|
||||
$parts[] = 'Email sent to ' . $this->humanizeGroupList($sent) . '.';
|
||||
}
|
||||
if (!empty($failed)) {
|
||||
$parts[] = 'Sending failed for ' . $this->humanizeGroupList($failed) . '.';
|
||||
}
|
||||
if (!empty($missing)) {
|
||||
$parts[] = 'No recipient emails for ' . $this->humanizeGroupList($missing) . '.';
|
||||
}
|
||||
|
||||
if (empty($parts)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'message' => implode(' ', $parts),
|
||||
'class' => empty($failed) && empty($missing) ? 'info' : 'warning',
|
||||
];
|
||||
}
|
||||
|
||||
protected function humanizeGroupList(array $groups): string
|
||||
{
|
||||
$groups = array_map('strval', $groups);
|
||||
if (empty($groups)) {
|
||||
return '';
|
||||
}
|
||||
if (count($groups) === 1) {
|
||||
return $groups[0];
|
||||
}
|
||||
$last = array_pop($groups);
|
||||
return implode(', ', $groups) . ' and ' . $last;
|
||||
}
|
||||
|
||||
protected function buildCalendarViewUrl(array $filters = []): string
|
||||
{
|
||||
$allowed = ['school_year', 'semester'];
|
||||
$params = [];
|
||||
foreach ($allowed as $key) {
|
||||
$value = $filters[$key] ?? '';
|
||||
if ($value !== '') {
|
||||
$params[$key] = $value;
|
||||
}
|
||||
}
|
||||
$query = http_build_query($params);
|
||||
return '/administrator/calendar_view' . ($query !== '' ? ('?' . $query) : '');
|
||||
}
|
||||
|
||||
public function delete($id)
|
||||
{
|
||||
|
||||
// Check if the event exists
|
||||
$event = $this->calendarModel->find($id);
|
||||
if (!$event) {
|
||||
return redirect()->to('/administrator/calendar_view')->with('error', 'Event not found.');
|
||||
}
|
||||
|
||||
// Delete the event
|
||||
if ($this->calendarModel->delete($id)) {
|
||||
return redirect()->to('/administrator/calendar_view')->with('success', 'Event deleted successfully');
|
||||
} else {
|
||||
return redirect()->to('/administrator/calendar_view')->with('error', 'Failed to delete the event.');
|
||||
}
|
||||
}
|
||||
|
||||
public function calendarParentView()
|
||||
{
|
||||
// Transform the events into the format FullCalendar expects
|
||||
$formattedEvents = $this->calendarView('parent');
|
||||
|
||||
// Pass the formatted events to the view
|
||||
return view('/parent/calendar', ['events' => $formattedEvents]);
|
||||
}
|
||||
|
||||
public function calendarTeacherView()
|
||||
{
|
||||
// Transform the events into the format FullCalendar expects
|
||||
$formattedEvents = $this->calendarView('teacher');
|
||||
|
||||
// Pass the formatted events to the view
|
||||
return view('/teacher/calendar', ['events' => $formattedEvents]);
|
||||
}
|
||||
|
||||
public function calendarAdminView()
|
||||
{
|
||||
// Transform the events into the format FullCalendar expects
|
||||
$formattedEvents = $this->calendarView('admin');
|
||||
|
||||
// Pass the formatted events to the view
|
||||
return view('administrator/calendar', ['events' => $formattedEvents]);
|
||||
}
|
||||
|
||||
private function calendarView(?string $audience = null)
|
||||
{
|
||||
// Filter events by school year
|
||||
$events = $this->calendarModel->getEventsBySchoolYear($this->schoolYear);
|
||||
|
||||
// Apply audience visibility rules:
|
||||
// - If no checkboxes selected (all notify_* = 0) => visible to everyone
|
||||
// - Otherwise visible only to the selected audience
|
||||
if (in_array($audience, ['parent', 'teacher', 'admin'], true)) {
|
||||
$events = array_values(array_filter($events, function ($event) use ($audience) {
|
||||
$p = (int) ($event['notify_parent'] ?? 0);
|
||||
$t = (int) ($event['notify_teacher'] ?? 0);
|
||||
$a = (int) ($event['notify_admin'] ?? 0);
|
||||
$ns = (int) ($event['no_school'] ?? 0);
|
||||
$hasAny = ($p + $t + $a) > 0;
|
||||
|
||||
// If marked as no_school, it should be visible to all audiences
|
||||
if ($ns === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!$hasAny) {
|
||||
// No audience explicitly selected: show to everyone
|
||||
return true;
|
||||
}
|
||||
|
||||
// Audience explicitly selected: restrict to matching audience
|
||||
if ($audience === 'parent') { return $p === 1; }
|
||||
if ($audience === 'teacher') { return $t === 1; }
|
||||
if ($audience === 'admin') { return $a === 1; }
|
||||
return true;
|
||||
}));
|
||||
}
|
||||
|
||||
// Format for FullCalendar
|
||||
$aud = $audience; // capture for closure
|
||||
$formattedEvents = array_map(function ($event) use ($aud) {
|
||||
$p = (int) ($event['notify_parent'] ?? 0);
|
||||
$t = (int) ($event['notify_teacher'] ?? 0);
|
||||
$a = (int) ($event['notify_admin'] ?? 0);
|
||||
$ns = (int) ($event['no_school'] ?? 0);
|
||||
|
||||
$backgroundColor = null;
|
||||
// Color no-school events yellow on teacher and parent calendars
|
||||
if (in_array($aud, ['teacher', 'parent'], true) && $ns === 1) {
|
||||
$backgroundColor = '#ffc107'; // yellow
|
||||
}
|
||||
|
||||
// Highlight events dedicated to staff (teachers/admins) only in green
|
||||
// Definition: visible to staff but not to parents, and not a no-school marker
|
||||
if (in_array($aud, ['teacher', 'admin'], true) && $backgroundColor === null) {
|
||||
$isStaffOnly = ($p === 0 && ($t === 1 || $a === 1) && $ns === 0);
|
||||
if ($isStaffOnly) {
|
||||
// Use a distinct green to differentiate from the default blue
|
||||
$backgroundColor = '#28a745';
|
||||
}
|
||||
}
|
||||
|
||||
// Title-casing for "no school" phrase when marked as no_school
|
||||
$title = $event['title'] ?? 'Untitled';
|
||||
if ($ns === 1 && is_string($title)) {
|
||||
// Replace common variants with Title Case
|
||||
$title = preg_replace('/\bno\s*[- ]\s*school\b/i', 'No School', $title);
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $event['id'],
|
||||
'title' => $title,
|
||||
'start' => $event['date'] ?? '', // FullCalendar expects 'start'
|
||||
'description' => $event['description'] ?? '',
|
||||
'extendedProps' => [
|
||||
'semester' => $event['semester'] ?? '',
|
||||
'school_year' => $event['school_year'] ?? '',
|
||||
'notify_parent' => $p,
|
||||
'notify_teacher' => $t,
|
||||
'notify_admin' => $a,
|
||||
'no_school' => $ns,
|
||||
'backgroundColor' => $backgroundColor,
|
||||
]
|
||||
];
|
||||
}, $events);
|
||||
|
||||
// Append parent meeting schedules
|
||||
$meetingEvents = $this->formatMeetingEvents($audience);
|
||||
if (!empty($meetingEvents)) {
|
||||
$formattedEvents = array_values(array_merge($formattedEvents, $meetingEvents));
|
||||
}
|
||||
|
||||
return $formattedEvents;
|
||||
}
|
||||
|
||||
private function formatMeetingEvents(?string $audience = null): array
|
||||
{
|
||||
$aud = $audience ?? '';
|
||||
$builder = $this->meetingModel
|
||||
->where('school_year', $this->schoolYear)
|
||||
->groupStart()
|
||||
->where('status', 'scheduled')
|
||||
->orWhere('status', 'Scheduled')
|
||||
->orWhere('status', '')
|
||||
->orWhere('status', null)
|
||||
->groupEnd();
|
||||
|
||||
if ($aud === 'parent') {
|
||||
$parentUserId = (int) (session()->get('user_id') ?? 0);
|
||||
if ($parentUserId <= 0) {
|
||||
return [];
|
||||
}
|
||||
$studentIds = $this->getStudentIdsForParent($parentUserId);
|
||||
if (!empty($studentIds)) {
|
||||
$builder = $builder->groupStart()
|
||||
->where('parent_user_id', $parentUserId)
|
||||
->orWhereIn('student_id', $studentIds)
|
||||
->groupEnd();
|
||||
} else {
|
||||
$builder = $builder->where('parent_user_id', $parentUserId);
|
||||
}
|
||||
}
|
||||
|
||||
$rows = $builder->orderBy('date', 'ASC')->findAll();
|
||||
if (empty($rows)) {
|
||||
// Fallback: show any meetings if school_year was missing/mismatched
|
||||
$fallback = $this->meetingModel
|
||||
->groupStart()
|
||||
->where('status', 'scheduled')
|
||||
->orWhere('status', 'Scheduled')
|
||||
->orWhere('status', '')
|
||||
->orWhere('status', null)
|
||||
->groupEnd();
|
||||
|
||||
if ($aud === 'parent') {
|
||||
$parentUserId = (int) (session()->get('user_id') ?? 0);
|
||||
if ($parentUserId > 0) {
|
||||
$studentIds = $this->getStudentIdsForParent($parentUserId);
|
||||
if (!empty($studentIds)) {
|
||||
$fallback = $fallback->groupStart()
|
||||
->where('parent_user_id', $parentUserId)
|
||||
->orWhereIn('student_id', $studentIds)
|
||||
->groupEnd();
|
||||
} else {
|
||||
$fallback = $fallback->where('parent_user_id', $parentUserId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$rows = $fallback->orderBy('date', 'ASC')->findAll();
|
||||
}
|
||||
if (empty($rows)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_map(function ($row) use ($aud) {
|
||||
$parentName = (string)($row['parent_name'] ?? 'Parent');
|
||||
$studentName = (string)($row['student_name'] ?? 'Student');
|
||||
$time = trim((string)($row['time'] ?? ''));
|
||||
$timeLabel = $time !== '' ? (' ' . $time) : '';
|
||||
|
||||
if ($aud === 'admin') {
|
||||
$title = 'Admin Meeting';
|
||||
} else {
|
||||
$title = 'Parent Meeting';
|
||||
}
|
||||
|
||||
$descParts = [];
|
||||
$descParts[] = 'Student: ' . $studentName;
|
||||
$descParts[] = 'Parent: ' . $parentName;
|
||||
if (!empty($row['class_section_name'])) {
|
||||
$descParts[] = 'Section: ' . $row['class_section_name'];
|
||||
}
|
||||
$descParts[] = 'Date/Time: ' . ($row['date'] ?? '') . $timeLabel;
|
||||
if (!empty($row['notes'])) {
|
||||
$descParts[] = 'Notes: ' . $row['notes'];
|
||||
}
|
||||
|
||||
$start = (string)($row['date'] ?? '');
|
||||
if ($start !== '' && $time !== '') {
|
||||
$start = $start . 'T' . $time;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => 'pm-' . (int)($row['id'] ?? 0),
|
||||
'title' => $title,
|
||||
'start' => $start,
|
||||
'description' => implode("\n", $descParts),
|
||||
'extendedProps' => [
|
||||
'semester' => $row['semester'] ?? '',
|
||||
'school_year' => $row['school_year'] ?? '',
|
||||
'notify_parent' => 1,
|
||||
'notify_admin' => 1,
|
||||
'notify_teacher' => 0,
|
||||
'no_school' => 0,
|
||||
'backgroundColor' => '#6f42c1',
|
||||
],
|
||||
];
|
||||
}, $rows);
|
||||
}
|
||||
|
||||
private function getStudentIdsForParent(int $parentUserId): array
|
||||
{
|
||||
try {
|
||||
$rows = $this->db->query(
|
||||
"SELECT DISTINCT fs.student_id
|
||||
FROM family_guardians fg
|
||||
JOIN family_students fs ON fs.family_id = fg.family_id
|
||||
WHERE fg.user_id = ?",
|
||||
[$parentUserId]
|
||||
)->getResultArray();
|
||||
$ids = array_map(static fn($r) => (int)($r['student_id'] ?? 0), $rows);
|
||||
|
||||
// Legacy fallback: students.parent_id
|
||||
$rows2 = $this->db->query(
|
||||
"SELECT id AS student_id
|
||||
FROM students
|
||||
WHERE parent_id = ?",
|
||||
[$parentUserId]
|
||||
)->getResultArray();
|
||||
foreach ($rows2 as $r) {
|
||||
$ids[] = (int)($r['student_id'] ?? 0);
|
||||
}
|
||||
|
||||
return array_values(array_filter($ids, static fn($id) => $id > 0));
|
||||
} catch (\Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Models\ScoreCommentModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
use App\Models\StudentModel;
|
||||
use App\Models\SemesterScoreModel;
|
||||
use App\Models\GradingLockModel;
|
||||
use App\Models\MissingScoreOverrideModel;
|
||||
|
||||
class ScoreCommentController extends BaseController
|
||||
{
|
||||
protected $db;
|
||||
protected $configModel;
|
||||
protected $semester;
|
||||
protected $schoolYear;
|
||||
protected $scoreCommentModel;
|
||||
protected $studentModel;
|
||||
protected $semesterScoreModel;
|
||||
protected $gradingLockModel;
|
||||
protected $missingScoreOverrideModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// Load the database service
|
||||
$this->db = \Config\Database::connect();
|
||||
// Check if the database connection is established
|
||||
if (!$this->db->connect()) {
|
||||
log_message('error', 'Database connection failed.');
|
||||
throw new \Exception('Database connection failed.');
|
||||
} else {
|
||||
log_message('info', 'Database connection successful.');
|
||||
}
|
||||
$this->configModel = new ConfigurationModel();
|
||||
$this->scoreCommentModel = new ScoreCommentModel();
|
||||
$this->studentModel = new StudentModel();
|
||||
$this->semesterScoreModel = new SemesterScoreModel();
|
||||
$this->gradingLockModel = new GradingLockModel();
|
||||
$this->missingScoreOverrideModel = new MissingScoreOverrideModel();
|
||||
$this->semester = $this->configModel->getConfig('semester');
|
||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||
}
|
||||
|
||||
|
||||
public function saveComments()
|
||||
{
|
||||
$comments = $this->request->getPost('comments');
|
||||
$classSectionId = (int)($this->request->getPost('class_section_id') ?? session()->get('class_section_id') ?? 0);
|
||||
$schoolYear = (string) ($this->request->getPost('school_year') ?? $this->schoolYear);
|
||||
$rawSelectedSemester = $this->request->getPost('selected_semester');
|
||||
$normalizedSelected = $this->normalizeSemesterSelection($rawSelectedSemester);
|
||||
if ($normalizedSelected !== '') {
|
||||
$semester = ucfirst($normalizedSelected);
|
||||
} else {
|
||||
$semester = session()->get('teacher_scores_selected_semester')
|
||||
?? session()->get('semester')
|
||||
?? $this->semester;
|
||||
}
|
||||
$teacherId = session()->get('user_id');
|
||||
|
||||
if ($classSectionId <= 0) {
|
||||
return redirect()->back()->with('error', 'Missing class section.');
|
||||
}
|
||||
if ($this->gradingLockModel->isLocked($classSectionId, $semester, $schoolYear)) {
|
||||
return redirect()->back()->with('error', 'Scores are locked for this class. Unlock to edit.');
|
||||
}
|
||||
|
||||
if (!is_array($comments)) {
|
||||
return redirect()->back()->with('error', 'No comments were submitted.');
|
||||
}
|
||||
|
||||
$missingOk = $this->request->getPost('missing_ok') ?? [];
|
||||
|
||||
$studentIds = array_map('intval', array_keys($comments));
|
||||
$studentNames = $this->getStudentFirstNames($studentIds);
|
||||
|
||||
$isFall = strtolower((string) $semester) === 'fall';
|
||||
$isSpring = strtolower((string) $semester) === 'spring';
|
||||
$commentTypes = ['ptap_comment'];
|
||||
if ($isFall) {
|
||||
$commentTypes[] = 'midterm_comment';
|
||||
}
|
||||
if ($isSpring) {
|
||||
$commentTypes[] = 'final_comment';
|
||||
}
|
||||
foreach ($commentTypes as $type) {
|
||||
$checkedItems = [];
|
||||
if (is_array($missingOk)) {
|
||||
foreach ($missingOk as $studentId => $types) {
|
||||
if (!is_array($types) || empty($types[$type])) {
|
||||
continue;
|
||||
}
|
||||
$checkedItems[] = [
|
||||
'student_id' => (int) $studentId,
|
||||
'item_index' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
$this->missingScoreOverrideModel->replaceOverrides(
|
||||
$classSectionId,
|
||||
(string) $semester,
|
||||
(string) $schoolYear,
|
||||
$type,
|
||||
$studentIds,
|
||||
null,
|
||||
$checkedItems,
|
||||
$teacherId,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
$errors = [];
|
||||
$payload = [];
|
||||
|
||||
foreach ($comments as $studentId => $commentTypes) {
|
||||
foreach ($commentTypes as $scoreType => $comment) {
|
||||
$normalizedScoreType = strtolower((string)$scoreType);
|
||||
$trimmedComment = trim((string)$comment);
|
||||
if ($trimmedComment === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$validationError = $this->validateComment($trimmedComment, (int)$studentId, $normalizedScoreType, $studentNames);
|
||||
if ($validationError !== null) {
|
||||
$errors[] = $validationError;
|
||||
log_message(
|
||||
'warning',
|
||||
"ScoreComment validation failed: teacher_id={$teacherId}, student_id={$studentId}, score_type={$normalizedScoreType}, error={$validationError}, length=" . mb_strlen($trimmedComment, 'UTF-8')
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
$payload[] = [
|
||||
'student_id' => $studentId,
|
||||
'class_section_id' => $classSectionId,
|
||||
'score_type' => $normalizedScoreType, // 'ptap', 'midterm', 'final'
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
'comment' => $trimmedComment,
|
||||
'commented_by' => $teacherId,
|
||||
'created_at' => utc_now(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($errors)) {
|
||||
return redirect()->back()->with('errors', $errors)->withInput();
|
||||
}
|
||||
|
||||
foreach ($payload as $data) {
|
||||
$existing = $this->scoreCommentModel
|
||||
->where('student_id', $data['student_id'])
|
||||
->groupStart()
|
||||
->where('class_section_id', $data['class_section_id'])
|
||||
->orWhere('class_section_id', null)
|
||||
->groupEnd()
|
||||
->where('score_type', $data['score_type'])
|
||||
->where('semester', $data['semester'])
|
||||
->where('school_year', $data['school_year'])
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
$this->scoreCommentModel->update($existing['id'], $data);
|
||||
} else {
|
||||
$this->scoreCommentModel->insert($data);
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()->back()->with('status', 'Comments saved successfully.');
|
||||
}
|
||||
|
||||
|
||||
public function viewCommentsMngt()
|
||||
{
|
||||
$rawSelectedSemester = $this->request->getGet('semester')
|
||||
?? $this->request->getPost('semester')
|
||||
?? session()->get('teacher_scores_selected_semester')
|
||||
?? session()->get('semester')
|
||||
?? $this->semester;
|
||||
$normalizedSelected = $this->normalizeSemesterSelection($rawSelectedSemester);
|
||||
if ($normalizedSelected !== '') {
|
||||
$activeSemester = ucfirst($normalizedSelected);
|
||||
} else {
|
||||
$activeSemester = $this->semester;
|
||||
}
|
||||
|
||||
$incoming = $this->request->getPost('class_section_id')
|
||||
?? $this->request->getGet('class_section_id')
|
||||
?? session()->get('class_section_id');
|
||||
|
||||
$isAllSections = is_string($incoming) && strtolower(trim($incoming)) === 'allsections';
|
||||
$classSectionId = $isAllSections ? null : (int) ($incoming ?: 0);
|
||||
|
||||
if (!$isAllSections && $classSectionId <= 0) {
|
||||
return redirect()->back()->with('status', 'Missing class section.');
|
||||
}
|
||||
session()->set('class_section_id', $isAllSections ? 'allsections' : $classSectionId);
|
||||
|
||||
$builder = $this->db->table('student_class sc')
|
||||
->distinct('SELECT s.id AS student_id, s.firstname, s.lastname, s.school_id')
|
||||
->join('students s', 's.id = sc.student_id', 'inner')
|
||||
->where('sc.school_year', $this->schoolYear);
|
||||
|
||||
if (!$isAllSections) {
|
||||
$builder->where('sc.class_section_id', $classSectionId);
|
||||
}
|
||||
|
||||
$students = $builder
|
||||
->orderBy('s.lastname', 'ASC')
|
||||
->orderBy('s.firstname', 'ASC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
// 3) Load only comments for these students in this term (ptap, midterm, final)
|
||||
$comments = [];
|
||||
if (!empty($students)) {
|
||||
$studentIds = array_map(static fn($r) => (int)$r['student_id'], $students);
|
||||
|
||||
$rawCommentsBuilder = $this->scoreCommentModel
|
||||
->whereIn('student_id', $studentIds)
|
||||
->whereIn('score_type', ['midterm', 'final', 'ptap', 'attendance'])
|
||||
->where('semester', $activeSemester)
|
||||
->where('school_year', $this->schoolYear);
|
||||
|
||||
if (!$isAllSections) {
|
||||
$rawCommentsBuilder->groupStart()
|
||||
->where('class_section_id', $classSectionId)
|
||||
->orWhere('class_section_id', null); // legacy rows without class_section_id
|
||||
$rawCommentsBuilder->groupEnd();
|
||||
}
|
||||
|
||||
$rawComments = $rawCommentsBuilder->findAll();
|
||||
|
||||
$existingAttendance = [];
|
||||
foreach ($rawComments as $c) {
|
||||
$sid = (int)$c['student_id'];
|
||||
$typ = (string)$c['score_type'];
|
||||
$comments[$sid][$typ] = [
|
||||
'comment' => $c['comment'] ?? null,
|
||||
'comment_review' => $c['comment_review'] ?? null,
|
||||
'commented_by' => $c['commented_by'] ?? null,
|
||||
'reviewed_by' => $c['reviewed_by'] ?? null,
|
||||
];
|
||||
if ($typ === 'attendance') {
|
||||
$existingAttendance[$sid] = $c;
|
||||
}
|
||||
}
|
||||
|
||||
helper('attendance_comment');
|
||||
// Autogenerate attendance comments for display when missing
|
||||
$attendanceScores = [];
|
||||
$scoreRows = $this->semesterScoreModel
|
||||
->select(['student_id', 'attendance_score'])
|
||||
->whereIn('student_id', $studentIds)
|
||||
->where('semester', $activeSemester)
|
||||
->where('school_year', $this->schoolYear)
|
||||
->findAll();
|
||||
foreach ($scoreRows as $row) {
|
||||
$attendanceScores[(int)$row['student_id']] = isset($row['attendance_score'])
|
||||
? (float)$row['attendance_score']
|
||||
: null;
|
||||
}
|
||||
$attendanceInserts = [];
|
||||
$attendanceUpdates = [];
|
||||
foreach ($students as $student) {
|
||||
$sid = (int)$student['student_id'];
|
||||
$hasComment = isset($comments[$sid]['attendance']['comment']) && trim((string)$comments[$sid]['attendance']['comment']) !== '';
|
||||
if ($hasComment) {
|
||||
continue;
|
||||
}
|
||||
$score = $attendanceScores[$sid] ?? null;
|
||||
if ($score === null) {
|
||||
continue;
|
||||
}
|
||||
$auto = attendance_comment_from_score($score, $student['firstname'] ?? '');
|
||||
if ($auto !== null) {
|
||||
$comments[$sid]['attendance'] = [
|
||||
'comment' => $auto,
|
||||
'comment_review' => $comments[$sid]['attendance']['comment_review'] ?? null,
|
||||
'commented_by' => $comments[$sid]['attendance']['commented_by'] ?? null,
|
||||
'reviewed_by' => $comments[$sid]['attendance']['reviewed_by'] ?? null,
|
||||
];
|
||||
if (isset($existingAttendance[$sid])) {
|
||||
$attendanceUpdates[] = [
|
||||
'id' => $existingAttendance[$sid]['id'],
|
||||
'comment' => $auto,
|
||||
'commented_by' => $existingAttendance[$sid]['commented_by'] ?? null,
|
||||
'class_section_id' => $classSectionId,
|
||||
];
|
||||
} else {
|
||||
$attendanceInserts[] = [
|
||||
'student_id' => $sid,
|
||||
'score_type' => 'attendance',
|
||||
'semester' => $activeSemester,
|
||||
'school_year' => $this->schoolYear,
|
||||
'comment' => $auto,
|
||||
'commented_by'=> null,
|
||||
'class_section_id' => $classSectionId,
|
||||
'created_at' => utc_now(),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!empty($attendanceUpdates)) {
|
||||
foreach ($attendanceUpdates as $row) {
|
||||
$id = $row['id'];
|
||||
unset($row['id']);
|
||||
$this->scoreCommentModel->update($id, $row);
|
||||
}
|
||||
}
|
||||
if (!empty($attendanceInserts)) {
|
||||
$this->scoreCommentModel->insertBatch($attendanceInserts);
|
||||
}
|
||||
}
|
||||
|
||||
// 4) Render
|
||||
return view('grading/comments', [
|
||||
'students' => $students,
|
||||
'semester' => $activeSemester,
|
||||
'schoolYear' => $this->schoolYear,
|
||||
'classSectionId' => $isAllSections ? 'allsections' : $classSectionId,
|
||||
'comments' => $comments,
|
||||
'scoresLocked' => (!$isAllSections && $classSectionId > 0)
|
||||
? $this->gradingLockModel->isLocked($classSectionId, $activeSemester, $this->schoolYear)
|
||||
: false,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
public function updateComments()
|
||||
{
|
||||
$semester = $this->request->getPost('semester');
|
||||
$schoolYear = $this->request->getPost('school_year');
|
||||
$teacherId = $this->request->getPost('teacher_id') ?? session()->get('user_id');
|
||||
$classSectionId = $this->request->getPost('class_section_id') ?? session()->get('class_section_id');
|
||||
$comments = $this->request->getPost('comments') ?? [];
|
||||
$reviews = $this->request->getPost('reviews') ?? [];
|
||||
|
||||
$studentIds = array_unique(array_map(
|
||||
'intval',
|
||||
array_merge(array_keys($comments), array_keys($reviews))
|
||||
));
|
||||
$studentNames = $this->getStudentFirstNames($studentIds);
|
||||
$isReviewer = $this->isReviewer();
|
||||
$classSectionId = is_numeric($classSectionId) ? (int)$classSectionId : $classSectionId;
|
||||
if (is_int($classSectionId) && $classSectionId > 0) {
|
||||
if ($this->gradingLockModel->isLocked($classSectionId, $semester, $schoolYear)) {
|
||||
return redirect()->back()->with('error', 'Scores are locked for this class. Unlock to edit.');
|
||||
}
|
||||
}
|
||||
|
||||
// Pull attendance scores once for auto-generated attendance comments
|
||||
$attendanceScores = [];
|
||||
if (!empty($studentIds)) {
|
||||
$attendanceBuilder = $this->semesterScoreModel
|
||||
->select(['student_id', 'attendance_score'])
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->whereIn('student_id', $studentIds);
|
||||
|
||||
if (is_int($classSectionId) && $classSectionId > 0) {
|
||||
$attendanceBuilder->where('class_section_id', $classSectionId);
|
||||
}
|
||||
|
||||
$rows = $attendanceBuilder->findAll();
|
||||
foreach ($rows as $row) {
|
||||
$attendanceScores[(int)$row['student_id']] = isset($row['attendance_score'])
|
||||
? (float)$row['attendance_score']
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
helper('attendance_comment');
|
||||
|
||||
$errors = [];
|
||||
$operations = [];
|
||||
|
||||
foreach ($comments as $studentId => $types) {
|
||||
foreach ($types as $scoreType => $commentText) {
|
||||
$normalizedScoreType = strtolower((string)$scoreType);
|
||||
$commentText = trim((string)$commentText);
|
||||
|
||||
// Auto-generate attendance comment when empty based on the student's attendance score
|
||||
if ($normalizedScoreType === 'attendance' && $commentText === '') {
|
||||
$attendanceScore = $attendanceScores[$studentId] ?? null;
|
||||
$auto = attendance_comment_from_score($attendanceScore, $studentNames[$studentId] ?? '');
|
||||
if ($auto !== null) {
|
||||
$commentText = $auto;
|
||||
}
|
||||
}
|
||||
|
||||
$reviewText = isset($reviews[$studentId][$scoreType])
|
||||
? trim((string)$reviews[$studentId][$scoreType])
|
||||
: '';
|
||||
// Skip empty comment & empty review to avoid null inserts
|
||||
if ($commentText === '' && $reviewText === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($commentText !== '') {
|
||||
$validationError = $this->validateComment($commentText, (int)$studentId, $normalizedScoreType, $studentNames);
|
||||
if ($validationError !== null) {
|
||||
$errors[] = $validationError;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$operations[] = [
|
||||
'student_id' => $studentId,
|
||||
'class_section_id' => is_int($classSectionId) ? $classSectionId : null,
|
||||
'score_type' => $normalizedScoreType,
|
||||
'comment' => $commentText === '' ? null : $commentText,
|
||||
'review' => $reviewText === '' ? null : $reviewText,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($errors)) {
|
||||
return redirect()->back()->with('errors', $errors)->withInput();
|
||||
}
|
||||
|
||||
foreach ($operations as $operation) {
|
||||
$existingQuery = $this->scoreCommentModel
|
||||
->where('student_id', $operation['student_id'])
|
||||
->where('score_type', $operation['score_type'])
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear);
|
||||
|
||||
if (is_int($classSectionId) && $classSectionId > 0) {
|
||||
$existingQuery->groupStart()
|
||||
->where('class_section_id', $classSectionId)
|
||||
->orWhere('class_section_id', null); // pick up legacy rows
|
||||
$existingQuery->groupEnd();
|
||||
}
|
||||
|
||||
$existing = $existingQuery->first();
|
||||
|
||||
$data = [
|
||||
'comment' => $operation['comment'],
|
||||
'commented_by' => $teacherId,
|
||||
'class_section_id' => $operation['class_section_id'],
|
||||
];
|
||||
|
||||
if ($isReviewer && $operation['review'] !== null) {
|
||||
$data['comment_review'] = $operation['review'];
|
||||
$data['reviewed_by'] = $teacherId;
|
||||
}
|
||||
|
||||
if ($existing) {
|
||||
// Update
|
||||
$this->scoreCommentModel->update($existing['id'], $data);
|
||||
} else {
|
||||
// Insert
|
||||
$this->scoreCommentModel->insert(array_merge([
|
||||
'student_id' => $operation['student_id'],
|
||||
'score_type' => $operation['score_type'],
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
'class_section_id' => $operation['class_section_id'],
|
||||
], $data));
|
||||
}
|
||||
}
|
||||
return redirect()->back()->with('status', 'Comments updated successfully!');
|
||||
}
|
||||
|
||||
// Helper function to check if current user is a reviewer
|
||||
private function isReviewer()
|
||||
{
|
||||
$currentUserRole = session()->get('role'); // Make sure this matches your session role key
|
||||
|
||||
// Get reviewer roles from configuration table
|
||||
$reviewerRoles = $this->configModel->getConfig('comment_reviewer');
|
||||
|
||||
if (!$reviewerRoles) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Convert comma-separated string to array and trim whitespace
|
||||
$allowedRoles = array_map('trim', explode(',', $reviewerRoles));
|
||||
|
||||
// Check if current user role is in the allowed roles
|
||||
return in_array($currentUserRole, $allowedRoles);
|
||||
}
|
||||
|
||||
private function getStudentFirstNames(array $studentIds): array
|
||||
{
|
||||
$ids = array_values(array_filter(array_unique(array_map('intval', $studentIds)), static function ($id) {
|
||||
return $id > 0;
|
||||
}));
|
||||
|
||||
if (empty($ids)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = $this->studentModel
|
||||
->select(['id', 'firstname'])
|
||||
->whereIn('id', $ids)
|
||||
->findAll();
|
||||
|
||||
$names = [];
|
||||
foreach ($rows as $row) {
|
||||
$names[(int)($row['id'] ?? 0)] = trim((string)($row['firstname'] ?? ''));
|
||||
}
|
||||
|
||||
return $names;
|
||||
}
|
||||
|
||||
private function validateComment(string $comment, int $studentId, string $scoreType, array $studentNames): ?string
|
||||
{
|
||||
$trimmed = trim($comment);
|
||||
if ($trimmed === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$firstName = $studentNames[$studentId] ?? '';
|
||||
if ($firstName === '') {
|
||||
return "Missing student first name for student ID {$studentId}.";
|
||||
}
|
||||
|
||||
$length = mb_strlen($trimmed, 'UTF-8');
|
||||
if ($length < 100) {
|
||||
return "{$firstName}'s {$scoreType} comment must be at least 100 characters.";
|
||||
}
|
||||
if ($length > 400) {
|
||||
return "{$firstName}'s {$scoreType} comment must be at most 400 characters.";
|
||||
}
|
||||
|
||||
$pattern = '/^' . preg_quote($firstName, '/') . '\\b/i';
|
||||
if (!preg_match($pattern, $trimmed)) {
|
||||
return "{$firstName}'s {$scoreType} comment must start with the student's first name.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function normalizeSemesterSelection(?string $input): string
|
||||
{
|
||||
$value = strtolower(trim((string)$input));
|
||||
if ($value === 'fall' || str_contains($value, '1')) {
|
||||
return 'fall';
|
||||
}
|
||||
if ($value === 'spring' || str_contains($value, '2')) {
|
||||
return 'spring';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,260 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Models\ConfigurationModel;
|
||||
use App\Models\ClassSectionModel;
|
||||
|
||||
use CodeIgniter\Controller;
|
||||
|
||||
class ScorePredictor extends Controller
|
||||
{
|
||||
protected $db;
|
||||
protected $configModel;
|
||||
protected $semester;
|
||||
protected $schoolYear;
|
||||
protected $classSectionModel;
|
||||
protected $studentClassModel;
|
||||
protected $teacherModel;
|
||||
protected $userModel;
|
||||
protected $targetTrophy;
|
||||
protected $targetLow;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// Load the database service
|
||||
$this->db = \Config\Database::connect();
|
||||
// Check if the database connection is established
|
||||
if (!$this->db->connect()) {
|
||||
log_message('error', 'Database connection failed.');
|
||||
throw new \Exception('Database connection failed.');
|
||||
} else {
|
||||
log_message('info', 'Database connection successful.');
|
||||
}
|
||||
|
||||
// Initialize the config model
|
||||
$this->configModel = new ConfigurationModel();
|
||||
$this->classSectionModel = new ClassSectionModel();
|
||||
|
||||
// Retrieve the configuration values
|
||||
$this->semester = $this->configModel->getConfig('semester');
|
||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||
$this->targetTrophy = $this->configModel->getConfig('trophy_score');
|
||||
$this->targetLow = $this->configModel->getConfig('pass_score');
|
||||
}
|
||||
|
||||
// Calculate the normal cumulative distribution function (CDF)
|
||||
public static function normalCDF($z)
|
||||
{
|
||||
$t = 1 / (1 + 0.3275911 * abs($z));
|
||||
$a1 = 0.254829592;
|
||||
$a2 = -0.284496736;
|
||||
$a3 = 1.421413741;
|
||||
$a4 = -1.453152027;
|
||||
$a5 = 1.061405429;
|
||||
$erf = 1 - ((((($a5 * $t + $a4) * $t + $a3) * $t + $a2) * $t + $a1) * $t) * exp(-$z * $z);
|
||||
$sign = $z < 0 ? -1 : 1;
|
||||
return 0.5 * (1 + $sign * $erf);
|
||||
}
|
||||
|
||||
public function combinedReport()
|
||||
{
|
||||
$request = service('request');
|
||||
$currentSchoolYear = $this->schoolYear;
|
||||
|
||||
$selectedYear = $request->getVar('school_year') ?? $currentSchoolYear;
|
||||
$classSectionId = $request->getVar('class_section_id');
|
||||
// Only show class sections that have at least one student in the selected year
|
||||
$classSections = $this->db->table('classSection cs')
|
||||
->select('cs.*')
|
||||
->join('student_class sc', 'sc.class_section_id = cs.class_section_id', 'inner')
|
||||
->where('sc.school_year', $selectedYear)
|
||||
->notLike('cs.class_section_name', 'KG', 'after')
|
||||
->groupBy('cs.id')
|
||||
->orderBy('cs.class_section_name', 'ASC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
$builder = $this->db->table('students s');
|
||||
$builder->select('
|
||||
s.id as student_id,
|
||||
s.school_id,
|
||||
s.firstname,
|
||||
s.lastname,
|
||||
fall.semester_score as fall_score,
|
||||
spring.semester_score as spring_score');
|
||||
// Also select class section for per-class trophy decision
|
||||
$builder->select('sc.class_section_id as class_section_id');
|
||||
$yearEsc = $this->db->escape($selectedYear);
|
||||
$builder->join('student_class sc', 'sc.student_id = s.id AND sc.school_year = ' . $yearEsc, 'left');
|
||||
$builder->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left');
|
||||
$builder->join('semester_scores fall', 'fall.student_id = s.id AND fall.semester = "fall" AND fall.school_year = "' . $selectedYear . '"', 'left');
|
||||
$builder->join('semester_scores spring', 'spring.student_id = s.id AND spring.semester = "spring" AND spring.school_year = "' . $selectedYear . '"', 'left');
|
||||
$builder->where('s.is_active', 1);
|
||||
$builder->groupStart()
|
||||
->where('cs.class_section_name IS NULL')
|
||||
->orNotLike('cs.class_section_name', 'KG', 'after')
|
||||
->groupEnd();
|
||||
|
||||
if (!empty($classSectionId)) {
|
||||
$builder->where('sc.class_section_id', $classSectionId);
|
||||
}
|
||||
|
||||
$builder->groupBy('s.id');
|
||||
$students = $builder->get()->getResultArray();
|
||||
|
||||
// Stats for spring
|
||||
$springStatsQuery = $this->db->table('semester_scores')
|
||||
->select('AVG(semester_scores.semester_score) as mean, STDDEV(semester_scores.semester_score) as std')
|
||||
->join('student_class', 'student_class.student_id = semester_scores.student_id')
|
||||
->join('classSection cs', 'cs.class_section_id = student_class.class_section_id', 'left')
|
||||
->where('semester_scores.semester', 'spring')
|
||||
->where('semester_scores.school_year', $selectedYear)
|
||||
->where('student_class.school_year', $selectedYear)
|
||||
->groupStart()
|
||||
->where('cs.class_section_name IS NULL')
|
||||
->orNotLike('cs.class_section_name', 'KG', 'after')
|
||||
->groupEnd();
|
||||
|
||||
if (!empty($classSectionId)) {
|
||||
$springStatsQuery->where('student_class.class_section_id', $classSectionId);
|
||||
}
|
||||
|
||||
$springStats = $springStatsQuery->get()->getRow();
|
||||
$mean = floatval($springStats->mean ?? 70);
|
||||
$std = floatval($springStats->std ?? 0);
|
||||
|
||||
$results = [];
|
||||
// Use explicit trophy parameters per new requirements
|
||||
// Trophy threshold: minimum 94.0; allow higher configured value if set
|
||||
$trophyThreshold = max(94.0, (float)($this->targetTrophy ?? 94.0));
|
||||
foreach ($students as $s) {
|
||||
$fallScore = is_numeric($s['fall_score']) ? (float)$s['fall_score'] : null;
|
||||
$springScore = is_numeric($s['spring_score']) ? (float)$s['spring_score'] : null;
|
||||
|
||||
$scoreParts = array_values(array_filter([$fallScore, $springScore], static function ($v) {
|
||||
return $v !== null;
|
||||
}));
|
||||
$finalAvg = !empty($scoreParts) ? round(array_sum($scoreParts) / count($scoreParts), 1) : null;
|
||||
|
||||
// When we already have a spring score, the year is effectively finished for trophy/pass logic.
|
||||
$waitingForSpring = ($fallScore !== null && $springScore === null);
|
||||
|
||||
// Compute required spring score for trophy/pass only if spring is missing.
|
||||
$requiredHigh = $waitingForSpring ? round(2 * $trophyThreshold - $fallScore, 2) : null;
|
||||
$requiredLow = $waitingForSpring ? round(2 * $this->targetLow - $fallScore, 2) : null;
|
||||
|
||||
$zHigh = ($requiredHigh !== null && $std > 0) ? ($requiredHigh - $mean) / $std : null;
|
||||
$zLow = ($requiredLow !== null && $std > 0) ? ($requiredLow - $mean) / $std : null;
|
||||
|
||||
$probHigh = $zHigh !== null ? 1 - self::normalCDF($zHigh) : null;
|
||||
$probLow = $zLow !== null ? self::normalCDF($zLow) : null;
|
||||
|
||||
// Trophy determination: only award when the student's final/available average meets the threshold.
|
||||
$riskHigh = match (true) {
|
||||
$finalAvg !== null && !$waitingForSpring && $finalAvg >= $trophyThreshold => 'Achieved Trophy',
|
||||
$finalAvg !== null && !$waitingForSpring => 'Unreachable',
|
||||
$requiredHigh !== null && $requiredHigh > 100 => 'Unreachable',
|
||||
is_null($probHigh) => 'New student',
|
||||
$probHigh >= 0.66 => 'Low',
|
||||
$probHigh >= 0.33 => 'Medium',
|
||||
default => 'High'
|
||||
};
|
||||
|
||||
// Fail risk
|
||||
$riskLow = match (true) {
|
||||
$finalAvg !== null && !$waitingForSpring && $finalAvg < $this->targetLow => 'High',
|
||||
$finalAvg !== null && !$waitingForSpring => 'Low',
|
||||
$requiredLow !== null && $requiredLow > 100 => 'Unlikely to pass',
|
||||
is_null($probLow) => 'New student',
|
||||
$probLow >= 0.66 => 'High',
|
||||
$probLow >= 0.33 => 'Medium',
|
||||
default => 'Low'
|
||||
};
|
||||
|
||||
if ($finalAvg === null) {
|
||||
$status = 'Undetermined';
|
||||
} elseif ($waitingForSpring) {
|
||||
$status = in_array($riskLow, ['High', 'Unlikely to pass'], true) ? 'May fail' : 'Can pass';
|
||||
} else {
|
||||
$status = $finalAvg >= 60 ? 'Passed' : 'Failed';
|
||||
}
|
||||
|
||||
$results[] = [
|
||||
'student_id' => (int)($s['student_id'] ?? 0),
|
||||
'school_id' => $s['school_id'],
|
||||
'firstname' => $s['firstname'],
|
||||
'lastname' => $s['lastname'],
|
||||
'class_section_id' => $s['class_section_id'] ?? null,
|
||||
'fall_score' => $s['fall_score'] ?? 'N/A',
|
||||
'spring_score' => $s['spring_score'] ?? 'N/A',
|
||||
'final_average' => $finalAvg ?? 'N/A',
|
||||
'required_spring_score' => $requiredHigh ?? 'N/A',
|
||||
'winning_risk' => $riskHigh,
|
||||
'required_spring_to_pass' => $requiredLow ?? 'N/A',
|
||||
'failure_risk' => $riskLow,
|
||||
'status' => $status,
|
||||
'trophy_awarded' => ($riskHigh === 'Achieved Trophy'),
|
||||
'trophy_reason' => ($riskHigh === 'Achieved Trophy') ? 'threshold' : '',
|
||||
];
|
||||
}
|
||||
|
||||
// If a class section has zero threshold trophies, award trophies to top 3 ranked students
|
||||
$bySection = [];
|
||||
$hasThresholdTrophy = [];
|
||||
foreach ($results as $idx => $row) {
|
||||
$cid = (string)($row['class_section_id'] ?? '');
|
||||
$bySection[$cid][] = $idx;
|
||||
if (($row['trophy_reason'] ?? '') === 'threshold') {
|
||||
$hasThresholdTrophy[$cid] = true;
|
||||
}
|
||||
}
|
||||
foreach ($bySection as $cid => $indices) {
|
||||
if (!empty($hasThresholdTrophy[$cid])) {
|
||||
continue;
|
||||
}
|
||||
$candidates = [];
|
||||
foreach ($indices as $idx) {
|
||||
$val = $results[$idx]['final_average'] ?? null;
|
||||
if (!is_numeric($val)) {
|
||||
$fall = $results[$idx]['fall_score'] ?? null;
|
||||
$spring = $results[$idx]['spring_score'] ?? null;
|
||||
$parts = [];
|
||||
if (is_numeric($fall)) $parts[] = (float)$fall;
|
||||
if (is_numeric($spring)) $parts[] = (float)$spring;
|
||||
if (!empty($parts)) {
|
||||
$val = array_sum($parts) / count($parts);
|
||||
}
|
||||
}
|
||||
if (is_numeric($val)) {
|
||||
$candidates[] = ['idx' => $idx, 'avg' => (float)$val];
|
||||
}
|
||||
}
|
||||
if (empty($candidates)) {
|
||||
continue;
|
||||
}
|
||||
usort($candidates, static fn($a, $b) => $b['avg'] <=> $a['avg']);
|
||||
$top = array_slice($candidates, 0, 3);
|
||||
foreach ($top as $entry) {
|
||||
$i = $entry['idx'];
|
||||
$results[$i]['trophy_awarded'] = true;
|
||||
$results[$i]['trophy_reason'] = 'top3';
|
||||
$results[$i]['winning_risk'] = 'Top 3 Trophy';
|
||||
}
|
||||
}
|
||||
|
||||
// Sort students by final average descending
|
||||
usort($results, function ($a, $b) {
|
||||
return ($b['final_average'] ?? 0) <=> ($a['final_average'] ?? 0);
|
||||
});
|
||||
|
||||
return view('score_analysis/score_prediction', [
|
||||
'students' => $results,
|
||||
'school_year' => $selectedYear,
|
||||
'classSections' => $classSections,
|
||||
'selectedClassSectionId' => $classSectionId,
|
||||
'semester' => $this->semester,
|
||||
]);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
Major U.S. Providers:
|
||||
|
||||
AT&T
|
||||
SMS: <10-digit-number>@txt.att.net
|
||||
MMS: <10-digit-number>@mms.att.net
|
||||
|
||||
Verizon
|
||||
SMS: <10-digit-number>@vtext.com
|
||||
MMS: <10-digit-number>@vzwpix.com
|
||||
|
||||
T-Mobile
|
||||
SMS: <10-digit-number>@tmomail.net
|
||||
MMS: <10-digit-number>@tmomail.net
|
||||
|
||||
Sprint (now merged with T-Mobile, but some legacy users may still use Sprint domains)
|
||||
SMS: <10-digit-number>@messaging.sprintpcs.com
|
||||
MMS: <10-digit-number>@pm.sprint.com
|
||||
|
||||
Boost Mobile
|
||||
SMS: <10-digit-number>@sms.myboostmobile.com
|
||||
MMS: <10-digit-number>@myboostmobile.com
|
||||
|
||||
U.S. Cellular
|
||||
SMS: <10-digit-number>@email.uscc.net
|
||||
MMS: <10-digit-number>@mms.uscc.net
|
||||
|
||||
Metro by T-Mobile (formerly MetroPCS)
|
||||
SMS: <10-digit-number>@mymetropcs.com
|
||||
MMS: <10-digit-number>@mymetropcs.com
|
||||
|
||||
Cricket Wireless
|
||||
SMS: <10-digit-number>@sms.cricketwireless.net
|
||||
MMS: <10-digit-number>@mms.cricketwireless.net
|
||||
|
||||
Google Fi
|
||||
SMS/MMS: <10-digit-number>@msg.fi.google.com
|
||||
|
||||
Virgin Mobile USA
|
||||
SMS: <10-digit-number>@vmobl.com
|
||||
MMS: <10-digit-number>@vmpix.com
|
||||
|
||||
|
||||
|
||||
Carrier SMS Gateway MMS Gateway
|
||||
AT&T number@txt.att.net number@mms.att.net
|
||||
Verizon number@vtext.com number@vzwpix.com
|
||||
T-Mobile number@tmomail.net number@tmomail.net
|
||||
Sprint number@messaging.sprintpcs.com number@pm.sprint.com
|
||||
Boost Mobile number@sms.myboostmobile.com number@myboostmobile.com
|
||||
Cricket number@sms.cricketwireless.net number@mms.cricketwireless.net
|
||||
US Cellular number@email.uscc.net number@mms.uscc.net
|
||||
Google Fi number@msg.fi.google.com number@msg.fi.google.com
|
||||
Virgin Mobile number@vmobl.com number@vmpix.com
|
||||
Metro by T-Mobile number@mymetropcs.com number@mymetropcs.com
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
use App\Controllers\BaseController;
|
||||
use Config\SessionTimeout;
|
||||
use CodeIgniter\Controller;
|
||||
use CodeIgniter\API\ResponseTrait;
|
||||
|
||||
class SessionTimeoutController extends BaseController
|
||||
{
|
||||
use ResponseTrait;
|
||||
|
||||
public function getTimeoutConfig()
|
||||
{
|
||||
return $this->response->setJSON([
|
||||
'success' => true,
|
||||
'timeout' => SessionTimeout::TIMEOUT_DURATION,
|
||||
'warning_time' => SessionTimeout::WARNING_THRESHOLD,
|
||||
'check_interval' => SessionTimeout::CHECK_INTERVAL,
|
||||
'logout_url' => site_url('auth/logout'),
|
||||
'keep_alive_url' => site_url('session/pingActivity'),
|
||||
'check_url' => site_url('session/checkTimeout')
|
||||
]);
|
||||
}
|
||||
|
||||
public function checkTimeout()
|
||||
{
|
||||
$session = session();
|
||||
|
||||
// Verify session exists and has last_activity
|
||||
if (!$session->has('last_activity')) {
|
||||
return $this->expireSession();
|
||||
}
|
||||
|
||||
$lastActivity = $session->get('last_activity');
|
||||
$elapsed = time() - $lastActivity;
|
||||
|
||||
if ($elapsed > SessionTimeout::TIMEOUT_DURATION) {
|
||||
return $this->expireSession();
|
||||
} elseif ($elapsed > SessionTimeout::WARNING_THRESHOLD) {
|
||||
return $this->response->setJSON([
|
||||
'status' => 'warning',
|
||||
'time_remaining' => SessionTimeout::TIMEOUT_DURATION - $elapsed
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->response->setJSON([
|
||||
'status' => 'active',
|
||||
'time_remaining' => SessionTimeout::TIMEOUT_DURATION - $elapsed
|
||||
]);
|
||||
}
|
||||
|
||||
public function pingActivity()
|
||||
{
|
||||
$session = session();
|
||||
|
||||
// Only update if session is still valid
|
||||
if (!$session->has('last_activity') ||
|
||||
(time() - $session->get('last_activity') > SessionTimeout::TIMEOUT_DURATION)) {
|
||||
return $this->expireSession();
|
||||
}
|
||||
|
||||
$session->set('last_activity', time());
|
||||
return $this->response->setJSON([
|
||||
'status' => 'active',
|
||||
'time_remaining' => SessionTimeout::TIMEOUT_DURATION
|
||||
]);
|
||||
}
|
||||
|
||||
private function expireSession()
|
||||
{
|
||||
$this->destroySession();
|
||||
return $this->response->setJSON([
|
||||
'status' => 'expired',
|
||||
'redirect' => site_url('login'),
|
||||
'message' => 'Your session has expired due to inactivity.'
|
||||
]);
|
||||
}
|
||||
|
||||
private function destroySession()
|
||||
{
|
||||
$session = session();
|
||||
$session->setFlashdata('error', 'Your session has expired due to inactivity.');
|
||||
|
||||
// Clear session data
|
||||
$session->remove('last_activity');
|
||||
$session->destroy();
|
||||
|
||||
// Regenerate session ID for security
|
||||
session_regenerate_id(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
use App\Controllers\BaseController;
|
||||
use App\Models\SettingsModel;
|
||||
|
||||
class SettingsController extends BaseController
|
||||
{
|
||||
protected $settingsModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->settingsModel = new SettingsModel();
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
// Retrieve current settings from the database
|
||||
$settings = $this->settingsModel->getSettings();
|
||||
$timezones = timezone_identifiers_list();
|
||||
|
||||
// Pass the settings and timezones to the view
|
||||
return view('administrator/settings', ['settings' => $settings, 'timezones' => $timezones]);
|
||||
}
|
||||
|
||||
public function update()
|
||||
{
|
||||
// Handle the form submission to update settings
|
||||
$data = [
|
||||
'site_name' => $this->request->getPost('site_name'),
|
||||
'administrator_email' => strtolower($this->request->getPost('administrator_email')),
|
||||
'timezone' => $this->request->getPost('timezone'),
|
||||
];
|
||||
$this->settingsModel->updateSettings($data);
|
||||
return redirect()->to('/administrator/settings');
|
||||
}
|
||||
}
|
||||
?>
|
||||
@@ -0,0 +1,691 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
use App\Models\UserModel;
|
||||
use App\Models\LateSlipLogModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
use Mike42\Escpos\PrintConnectors\WindowsPrintConnector;
|
||||
|
||||
|
||||
// ESC/POS
|
||||
use Mike42\Escpos\Printer;
|
||||
use Mike42\Escpos\EscposImage;
|
||||
use Mike42\Escpos\PrintConnectors\NetworkPrintConnector;
|
||||
use Mike42\Escpos\PrintConnectors\UsbPrintConnector;
|
||||
use Dompdf\Dompdf;
|
||||
use Dompdf\Options;
|
||||
|
||||
class SlipPrinterController extends BaseController
|
||||
{
|
||||
protected $configModel;
|
||||
protected $semester;
|
||||
protected $schoolYear;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
helper(['form', 'url']);
|
||||
$this->configModel = new ConfigurationModel();
|
||||
$this->semester = $this->configModel->getConfig('semester');
|
||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||
}
|
||||
/**
|
||||
* POST /slips/print
|
||||
* Expected fields (POST): school_year, student_name, date, time_in, grade, reason, admin_name
|
||||
* You can wire this to your own form/data source; all fields are strings.
|
||||
*/
|
||||
public function print(): ResponseInterface
|
||||
{
|
||||
$req = $this->request;
|
||||
|
||||
$data = [
|
||||
'school_year' => trim((string) $req->getVar('school_year') ?: ''),
|
||||
'student_name' => trim((string) $req->getVar('student_name') ?: ''),
|
||||
'date' => trim((string) $req->getVar('date') ?: date('m/d/Y')),
|
||||
'time_in' => trim((string) $req->getVar('time_in') ?: date('h:i A')),
|
||||
'grade' => trim((string) $req->getVar('grade') ?: ''),
|
||||
'reason' => trim((string) $req->getVar('reason') ?: ''),
|
||||
'admin_name' => trim((string) $req->getVar('admin_name') ?: ''),
|
||||
];
|
||||
|
||||
// Always prefer current logged-in user's full name from DB
|
||||
$adminFromDb = $this->currentAdminName();
|
||||
if ($adminFromDb !== '') {
|
||||
$data['admin_name'] = $adminFromDb;
|
||||
}
|
||||
|
||||
// Validate minimal fields
|
||||
if ($data['student_name'] === '') {
|
||||
return $this->response->setStatusCode(422)->setJSON(['error' => 'Student name is required.']);
|
||||
}
|
||||
|
||||
try {
|
||||
// Persist a log (best-effort) before generating PDF
|
||||
try {
|
||||
$printedBy = (int) (session()->get('user_id') ?? 0) ?: null;
|
||||
$cfg = new ConfigurationModel();
|
||||
$semester = (string)($cfg->getConfig('semester') ?? '');
|
||||
$logData = [
|
||||
'school_year' => $data['school_year'],
|
||||
'semester' => $semester,
|
||||
'student_name' => $data['student_name'],
|
||||
'slip_date' => $this->toDbDate($data['date']),
|
||||
'time_in' => $this->toDbTime($data['time_in']),
|
||||
'grade' => $data['grade'],
|
||||
'reason' => $data['reason'],
|
||||
'admin_name' => $data['admin_name'],
|
||||
];
|
||||
(new LateSlipLogModel())->logSlip($logData, $printedBy);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Late slip log insert failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// Generate and stream a PDF instead of direct printer output
|
||||
return $this->renderSlipPdfResponse($data);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Late slip PDF generation failed: ' . $e->getMessage());
|
||||
return $this->response->setStatusCode(500)->setJSON([
|
||||
'error' => 'Printing failed.',
|
||||
'details' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show or print preview of late slips.
|
||||
*/
|
||||
public function preview(): ResponseInterface
|
||||
{
|
||||
$req = $this->request;
|
||||
|
||||
try {
|
||||
// ---- Step 1: Resolve current school year ----
|
||||
$currentYear = trim((string) ($this->schoolYear ?? ''));
|
||||
if ($currentYear === '') {
|
||||
$latest = (new LateSlipLogModel())
|
||||
->select('school_year')
|
||||
->orderBy('id', 'DESC')
|
||||
->first();
|
||||
$currentYear = $latest['school_year'] ?? (date('Y') . '-' . (date('Y') + 1));
|
||||
}
|
||||
|
||||
$selectedYear = trim((string) $req->getVar('school_year') ?: $currentYear);
|
||||
$selectedSemester = strtolower(trim((string) $req->getVar('semester') ?: ''));
|
||||
|
||||
$data = [
|
||||
'school_year' => $selectedYear,
|
||||
'student_name' => trim((string) $req->getVar('student_name') ?: ''),
|
||||
'date' => trim((string) $req->getVar('date') ?: date('m/d/Y')),
|
||||
'time_in' => trim((string) $req->getVar('time_in') ?: date('h:i A')),
|
||||
'grade' => trim((string) $req->getVar('grade') ?: ''),
|
||||
'reason' => trim((string) $req->getVar('reason') ?: ''),
|
||||
'admin_name' => trim((string) $req->getVar('admin_name') ?: ''),
|
||||
];
|
||||
|
||||
// Prefer logged-in admin name
|
||||
$adminFromDb = $this->currentAdminName();
|
||||
if ($adminFromDb !== '') {
|
||||
$data['admin_name'] = $adminFromDb;
|
||||
}
|
||||
|
||||
// ---- Handle GET (HTML preview) ----
|
||||
if (strtolower($req->getMethod()) === 'get') {
|
||||
$rows = [];
|
||||
$logModel = new LateSlipLogModel();
|
||||
$builder = $logModel;
|
||||
|
||||
// Filter by school year if not empty
|
||||
if ($selectedYear !== '') {
|
||||
$builder->where('school_year', $selectedYear);
|
||||
}
|
||||
|
||||
// Normalize and apply semester filter
|
||||
$hasFilter = false;
|
||||
if ($selectedSemester !== '') {
|
||||
$hasFilter = true;
|
||||
if ($selectedSemester === 'fall') {
|
||||
$builder->groupStart()
|
||||
->where('semester', 'fall')
|
||||
->orWhere('semester', '1')
|
||||
->orWhere('semester', 1)
|
||||
->groupEnd();
|
||||
} elseif ($selectedSemester === 'spring') {
|
||||
$builder->groupStart()
|
||||
->where('semester', 'spring')
|
||||
->orWhere('semester', '2')
|
||||
->orWhere('semester', 2)
|
||||
->groupEnd();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Execute query ----
|
||||
$logs = $builder->orderBy('id', 'DESC')->findAll(50);
|
||||
|
||||
// 🟢 Only use fallback when *no filters are selected*
|
||||
if (empty($logs) && !$hasFilter && $selectedYear === '') {
|
||||
$logs = (new LateSlipLogModel())->orderBy('id', 'DESC')->findAll(50);
|
||||
}
|
||||
|
||||
// ---- Format rows ----
|
||||
foreach ($logs as $r) {
|
||||
$dispDate = '';
|
||||
$slipDateRaw = trim((string)($r['slip_date'] ?? ''));
|
||||
if ($slipDateRaw !== '' && $slipDateRaw !== '0000-00-00') {
|
||||
$ts = strtotime($slipDateRaw);
|
||||
$dispDate = $ts ? date('m/d/Y', $ts) : '';
|
||||
}
|
||||
if ($dispDate === '') {
|
||||
$printedAt = trim((string)($r['printed_at'] ?? ''));
|
||||
if ($printedAt !== '') {
|
||||
try {
|
||||
$dispDate = local_date($printedAt, 'm/d/Y');
|
||||
} catch (\Throwable $e) {
|
||||
$ts = strtotime($printedAt);
|
||||
$dispDate = $ts ? date('m/d/Y', $ts) : '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$dispTime = '';
|
||||
if (!empty($r['time_in'])) {
|
||||
$t = strtotime($r['time_in']) ?: strtotime('today ' . $r['time_in']);
|
||||
$dispTime = $t ? date('h:i A', $t) : '';
|
||||
}
|
||||
|
||||
$rows[] = [
|
||||
'school_year' => (string)($r['school_year'] ?? ''),
|
||||
'semester' => (string)($r['semester'] ?? ''),
|
||||
'student_name' => (string)($r['student_name'] ?? ''),
|
||||
'date' => $dispDate,
|
||||
'time_in' => $dispTime,
|
||||
'grade' => (string)($r['grade'] ?? ''),
|
||||
'reason' => (string)($r['reason'] ?? ''),
|
||||
'admin_name' => (string)($r['admin_name'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
// ---- Render the view ----
|
||||
$html = view('slips/preview_list', [
|
||||
'rows' => $rows,
|
||||
'school_year' => $selectedYear,
|
||||
'semester' => $selectedSemester,
|
||||
]);
|
||||
|
||||
return $this->response
|
||||
->setStatusCode(200)
|
||||
->setHeader('Content-Type', 'text/html; charset=UTF-8')
|
||||
->setBody($html);
|
||||
}
|
||||
|
||||
// ---- POST mode (JSON preview for printer) ----
|
||||
$cfg = $this->printerConfig();
|
||||
$lineWidth = (int) ($cfg['chars_per_line'] ?? 48);
|
||||
$feedLines = (int) ($cfg['feed_lines'] ?? 3);
|
||||
|
||||
$header = "Al Rahma School at ISGL\nSchool Year: {$data['school_year']}\n\n";
|
||||
$body = implode("\n", $this->buildSlipLines($data, $lineWidth));
|
||||
$footer = str_repeat("\n", $feedLines);
|
||||
$full = $header . $body . $footer;
|
||||
|
||||
return $this->response->setJSON([
|
||||
'ok' => true,
|
||||
'text' => $full,
|
||||
'width' => $lineWidth,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Late slip preview failed: ' . $e->getMessage());
|
||||
return $this->response->setStatusCode(500)->setJSON([
|
||||
'ok' => false,
|
||||
'error' => 'Preview failed.',
|
||||
'details' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /administrator/late_slips/reprint/{id}
|
||||
* Reprint a late slip from a saved log row, then redirect back with status.
|
||||
*/
|
||||
public function reprint($id = null): ResponseInterface
|
||||
{
|
||||
$id = (int) ($id ?? 0);
|
||||
if ($id <= 0) {
|
||||
return redirect()->back()->with('error', 'Invalid slip id.');
|
||||
}
|
||||
|
||||
$row = null;
|
||||
try {
|
||||
$row = (new LateSlipLogModel())->find($id);
|
||||
} catch (\Throwable $e) {
|
||||
$row = null;
|
||||
}
|
||||
if (!$row) {
|
||||
return redirect()->back()->with('error', 'Slip not found.');
|
||||
}
|
||||
|
||||
// Map DB row to print payload
|
||||
$dateDisplay = '';
|
||||
if (!empty($row['slip_date'])) {
|
||||
$ts = strtotime($row['slip_date']);
|
||||
$dateDisplay = $ts ? date('m/d/Y', $ts) : '';
|
||||
}
|
||||
if ($dateDisplay === '') $dateDisplay = date('m/d/Y');
|
||||
|
||||
$timeDisplay = '';
|
||||
if (!empty($row['time_in'])) {
|
||||
$ts = strtotime($row['time_in']);
|
||||
if ($ts === false) {
|
||||
// Try combining with today
|
||||
$ts = strtotime('today ' . $row['time_in']);
|
||||
}
|
||||
$timeDisplay = $ts ? date('h:i A', $ts) : '';
|
||||
}
|
||||
if ($timeDisplay === '') $timeDisplay = date('h:i A');
|
||||
|
||||
$data = [
|
||||
'school_year' => (string) ($row['school_year'] ?? ''),
|
||||
'student_name' => (string) ($row['student_name'] ?? ''),
|
||||
'date' => $dateDisplay,
|
||||
'time_in' => $timeDisplay,
|
||||
'grade' => (string) ($row['grade'] ?? ''),
|
||||
'reason' => (string) ($row['reason'] ?? ''),
|
||||
'admin_name' => (string) ($row['admin_name'] ?? ''),
|
||||
];
|
||||
|
||||
// Always prefer current logged-in user's full name from DB
|
||||
$adminFromDb = $this->currentAdminName();
|
||||
if ($adminFromDb !== '') {
|
||||
$data['admin_name'] = $adminFromDb;
|
||||
}
|
||||
|
||||
try {
|
||||
// Format lines and print
|
||||
$lines = $this->buildSlipLines($data);
|
||||
$printer = $this->makePrinter();
|
||||
|
||||
$printer->setJustification(Printer::JUSTIFY_CENTER);
|
||||
$printer->setEmphasis(true);
|
||||
$printer->text("Al Rahma School at ISGL\n");
|
||||
$printer->setEmphasis(false);
|
||||
$printer->text("School Year: {$data['school_year']}\n");
|
||||
$printer->feed();
|
||||
|
||||
$printer->setJustification(Printer::JUSTIFY_LEFT);
|
||||
foreach ($lines as $ln) {
|
||||
$printer->text($ln . "\n");
|
||||
}
|
||||
|
||||
$printer->feed((int) ($this->printerConfig()['feed_lines'] ?? 3));
|
||||
$printer->cut();
|
||||
$printer->close();
|
||||
|
||||
// Log again (best-effort) for audit trail
|
||||
try {
|
||||
$printedBy = (int) (session()->get('user_id') ?? 0) ?: null;
|
||||
$cfg = new ConfigurationModel();
|
||||
$semester = (string)($cfg->getConfig('semester') ?? '');
|
||||
$logData = [
|
||||
'school_year' => $data['school_year'],
|
||||
'semester' => $semester,
|
||||
'student_name' => $data['student_name'],
|
||||
'slip_date' => $this->toDbDate($data['date']),
|
||||
'time_in' => $this->toDbTime($data['time_in']),
|
||||
'grade' => $data['grade'],
|
||||
'reason' => $data['reason'],
|
||||
'admin_name' => $data['admin_name'],
|
||||
];
|
||||
(new LateSlipLogModel())->logSlip($logData, $printedBy);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Late slip reprint log insert failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', 'Late slip sent to printer.');
|
||||
} catch (\Throwable $e) {
|
||||
return redirect()->back()->with('error', 'Reprint failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build monospaced slip lines for an 80mm (typically 48 chars) receipt.
|
||||
*
|
||||
* Layout required:
|
||||
* Al Rahma School at ISGL "SchoolYear"
|
||||
* Student Name ___________________________
|
||||
* Date ______________ Time In ______________
|
||||
* Grade _________________________________
|
||||
* Reason _________________________________
|
||||
* _______________________________________
|
||||
* Admin Name ____________________________
|
||||
*/
|
||||
private function buildSlipLines(array $data, ?int $lineWidth = null): array
|
||||
{
|
||||
// Choose a sane default for 58mm rolls (~24–32 chars). Fall back to env config.
|
||||
$cfgWidth = (int) ($this->printerConfig()['chars_per_line'] ?? 0);
|
||||
$w = $lineWidth ?? ($cfgWidth > 0 ? $cfgWidth : 32);
|
||||
|
||||
$lines = [];
|
||||
$lines[] = $this->fitLine('Student Name: ' . (string)($data['student_name'] ?? ''), $w);
|
||||
$lines[] = $this->fitLine('Date: ' . (string)($data['date'] ?? '') . ' ' . 'Time In: ' . (string)($data['time_in'] ?? ''), $w);
|
||||
$lines[] = $this->fitLine('Grade: ' . (string)($data['grade'] ?? ''), $w);
|
||||
$lines[] = $this->fitLine('Reason: ' . (string)($data['reason'] ?? ''), $w);
|
||||
$lines[] = $this->fitLine('Admin: ' . (string)($data['admin_name'] ?? ''), $w);
|
||||
|
||||
return $lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and return a configured Mike42\Escpos\Printer instance.
|
||||
*/
|
||||
private function makePrinter(): Printer
|
||||
{
|
||||
$cfg = $this->printerConfig();
|
||||
$mode = strtolower((string) ($cfg['mode'] ?? 'network'));
|
||||
|
||||
if ($mode === 'windows') {
|
||||
if (stripos(PHP_OS_FAMILY, 'Windows') !== false) {
|
||||
$name = (string) ($cfg['windows_name'] ?? 'POS-80');
|
||||
$connector = new \Mike42\Escpos\PrintConnectors\WindowsPrintConnector($name);
|
||||
return new Printer($connector);
|
||||
}
|
||||
log_message('warning', 'PRINTER_MODE=windows on non-Windows OS; falling back to network.');
|
||||
$mode = 'network';
|
||||
}
|
||||
|
||||
if ($mode === 'usb') {
|
||||
if (stripos(PHP_OS_FAMILY, 'Windows') !== false) {
|
||||
$name = (string) ($cfg['windows_name'] ?? 'POS-80');
|
||||
$connector = new \Mike42\Escpos\PrintConnectors\WindowsPrintConnector($name);
|
||||
return new Printer($connector);
|
||||
}
|
||||
$vidStr = (string) ($cfg['usb_vid'] ?? '');
|
||||
$pidStr = (string) ($cfg['usb_pid'] ?? '');
|
||||
if ($vidStr === '' || $pidStr === '') {
|
||||
throw new \RuntimeException('USB mode requires PRINTER_USB_VID and PRINTER_USB_PID');
|
||||
}
|
||||
$vid = $this->hexToInt($vidStr);
|
||||
$pid = $this->hexToInt($pidStr);
|
||||
$connector = new \Mike42\Escpos\PrintConnectors\UsbPrintConnector($vid, $pid);
|
||||
return new Printer($connector);
|
||||
}
|
||||
|
||||
// Default: network
|
||||
$host = (string) ($cfg['host'] ?? '192.168.1.100');
|
||||
$port = (int) ($cfg['port'] ?? 9100);
|
||||
$connector = new \Mike42\Escpos\PrintConnectors\NetworkPrintConnector($host, $port);
|
||||
return new Printer($connector);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Build a "Label: ________VALUE" style line, using underscores to fill remaining space.
|
||||
* If $value is empty, just render a long blank line after the label.
|
||||
*/
|
||||
private function labelWithLine(string $label, string $value, int $width, ?int $fillCount = null): string
|
||||
{
|
||||
$label = rtrim($label, ':') . ': ';
|
||||
$maxFill = $fillCount ?? max(0, $width - strlen($label));
|
||||
$filled = $value !== ''
|
||||
? $this->fixedField($value, $maxFill, '_')
|
||||
: str_repeat('_', $maxFill);
|
||||
|
||||
$line = $label . $filled;
|
||||
|
||||
// Ensure total width, pad or trim
|
||||
return $this->fitLine($line, $width);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a single field like "Date: ________" (or with value).
|
||||
*/
|
||||
private function fieldWithBlanks(string $label, string $value, int $fieldWidth): string
|
||||
{
|
||||
$label = rtrim($label, ':') . ': ';
|
||||
$fill = max(0, $fieldWidth - strlen($label));
|
||||
$content = $value !== '' ? $this->fixedField($value, $fill, '_') : str_repeat('_', $fill);
|
||||
return $this->fitLine($label . $content, $fieldWidth);
|
||||
}
|
||||
|
||||
/**
|
||||
* Join two fixed-width fields into one line (e.g., Date field + Time In field).
|
||||
*/
|
||||
private function joinTwoFields(string $left, string $right, int $totalWidth): string
|
||||
{
|
||||
$gap = ' ';
|
||||
$line = $left . $gap . $right;
|
||||
return $this->fitLine($line, $totalWidth);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pad/truncate a value to an exact width using a fill character (for underlines).
|
||||
*/
|
||||
private function fixedField(string $val, int $width, string $fillChar = '_'): string
|
||||
{
|
||||
$val = (string) $val;
|
||||
if (strlen($val) > $width) {
|
||||
return substr($val, 0, $width);
|
||||
}
|
||||
return $val . str_repeat($fillChar, max(0, $width - strlen($val)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure any line is exactly $width characters (trim or pad spaces).
|
||||
*/
|
||||
private function fitLine(string $line, int $width): string
|
||||
{
|
||||
$line = (string) $line;
|
||||
if (strlen($line) > $width) {
|
||||
return substr($line, 0, $width);
|
||||
}
|
||||
return $line . str_repeat(' ', $width - strlen($line));
|
||||
}
|
||||
|
||||
/**
|
||||
* "0x1a86" -> 0x1a86 (int). Accepts plain decimal too.
|
||||
*/
|
||||
private function hexToInt(string $hexOrDec): int
|
||||
{
|
||||
$hexOrDec = trim($hexOrDec);
|
||||
if ($hexOrDec === '') return 0;
|
||||
if (stripos($hexOrDec, '0x') === 0) {
|
||||
return intval($hexOrDec, 16);
|
||||
}
|
||||
return (int) $hexOrDec;
|
||||
}
|
||||
|
||||
/**
|
||||
* Consolidated printer configuration. Supports both dotted keys (printer.*)
|
||||
* and uppercase PRINTER_* keys from .env.
|
||||
*/
|
||||
private function printerConfig(): array
|
||||
{
|
||||
$get = function ($keys, $default = null) {
|
||||
$keys = is_array($keys) ? $keys : [$keys];
|
||||
foreach ($keys as $k) {
|
||||
$v = env($k);
|
||||
if ($v !== null && $v !== '') return $v;
|
||||
}
|
||||
return $default;
|
||||
};
|
||||
|
||||
return [
|
||||
'mode' => strtolower((string) $get(['printer.mode', 'PRINTER_MODE'], 'network')),
|
||||
'host' => (string) $get(['printer.host', 'PRINTER_HOST'], '192.168.1.100'),
|
||||
'port' => (int) $get(['printer.port', 'PRINTER_PORT'], 9100),
|
||||
'usb_vid' => (string) $get(['printer.usb.vid', 'PRINTER_USB_VID'], ''),
|
||||
'usb_pid' => (string) $get(['printer.usb.pid', 'PRINTER_USB_PID'], ''),
|
||||
'windows_name' => (string) $get(['printer.windows.name', 'PRINTER_WINDOWS_NAME'], 'POS-80'),
|
||||
'chars_per_line' => (int) $get(['printer.chars_per_line', 'PRINTER_CHARS_PER_LINE'], 48),
|
||||
'feed_lines' => (int) $get(['printer.feed_lines', 'PRINTER_FEED_LINES'], 3),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert user-provided date/time strings into DB formats.
|
||||
*/
|
||||
private function toDbDate(?string $s): ?string
|
||||
{
|
||||
$s = trim((string)$s);
|
||||
if ($s === '') return null;
|
||||
// Explicitly parse common US formats to avoid day/month swaps.
|
||||
if (preg_match('/^\\d{4}-\\d{2}-\\d{2}$/', $s)) {
|
||||
return $s;
|
||||
}
|
||||
if (preg_match('/^\\d{1,2}\\/\\d{1,2}\\/\\d{4}$/', $s)) {
|
||||
$dt = \DateTime::createFromFormat('!m/d/Y', $s);
|
||||
return $dt ? $dt->format('Y-m-d') : null;
|
||||
}
|
||||
if (preg_match('/^\\d{1,2}-\\d{1,2}-\\d{4}$/', $s)) {
|
||||
$dt = \DateTime::createFromFormat('!m-d-Y', $s);
|
||||
return $dt ? $dt->format('Y-m-d') : null;
|
||||
}
|
||||
$ts = strtotime($s);
|
||||
return $ts ? date('Y-m-d', $ts) : null;
|
||||
}
|
||||
|
||||
private function toDbTime(?string $s): ?string
|
||||
{
|
||||
$s = trim((string)$s);
|
||||
if ($s === '') return null;
|
||||
$ts = strtotime($s);
|
||||
return $ts ? date('H:i:s', $ts) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute current admin full name using session user_id and users table.
|
||||
* Falls back to session firstname/lastname, then user_name, else ''.
|
||||
*/
|
||||
private function currentAdminName(): string
|
||||
{
|
||||
try {
|
||||
$uid = (int) (session()->get('user_id') ?? 0);
|
||||
if ($uid > 0) {
|
||||
$user = (new UserModel())
|
||||
->select('firstname, lastname')
|
||||
->find($uid);
|
||||
if ($user) {
|
||||
$full = trim((string)($user['firstname'] ?? '') . ' ' . (string)($user['lastname'] ?? ''));
|
||||
if ($full !== '') return $full;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// ignore and fall back
|
||||
}
|
||||
|
||||
$first = trim((string)(session()->get('firstname') ?? ''));
|
||||
$last = trim((string)(session()->get('lastname') ?? ''));
|
||||
$full = trim($first . ' ' . $last);
|
||||
if ($full !== '') return $full;
|
||||
return (string)(session()->get('user_name') ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the slip as a PDF and stream it inline to the browser.
|
||||
*/
|
||||
private function renderSlipPdfResponse(array $data): ResponseInterface
|
||||
{
|
||||
// Build HTML from a dedicated view
|
||||
// Determine paper size (mm)
|
||||
$paper = strtolower((string) ($this->request->getVar('paper') ?: env('SLIP_PAPER', 'card')));
|
||||
// Estimate dynamic height based on the PDF view font and line-height
|
||||
// Title + seven content lines = 8 rows total
|
||||
// Defaults match the current view: 13pt font, 2.0 line-height
|
||||
$fontPt = (float) ($this->request->getVar('font_pt') ?? env('SLIP_FONT_PT', 13.0));
|
||||
$lineH = (float) ($this->request->getVar('line_h') ?? env('SLIP_LINE_H', 2.0));
|
||||
$linesCount = (int) ($this->request->getVar('rows') ?? 8);
|
||||
if ($linesCount < 1) {
|
||||
$linesCount = 8;
|
||||
}
|
||||
$ptPerLine = $fontPt * $lineH;
|
||||
$mmPerPt = 25.4 / 72.0; // 1pt in mm
|
||||
$contentMm = $ptPerLine * $linesCount * $mmPerPt; // content only
|
||||
$pageMarginsMm = 3.0 * 2; // top+bottom from CSS
|
||||
// Allow caller to increase safety buffer if their viewer/driver adds spacing
|
||||
// Slightly bigger safety buffer to avoid second-page spillovers from drivers
|
||||
$fudgeMm = (float) ($this->request->getVar('fudge_mm') ?? env('SLIP_FUDGE_MM', 12.0));
|
||||
$dynHeightMm = $contentMm + $pageMarginsMm + $fudgeMm;
|
||||
|
||||
switch ($paper) {
|
||||
case 'cardp':
|
||||
case 'card-portrait':
|
||||
$wMm = 53.98;
|
||||
$hMm = max($dynHeightMm, 40.0); // portrait width, dynamic height
|
||||
$wChars = 30;
|
||||
break;
|
||||
case 'receipt80':
|
||||
$wMm = 72.0;
|
||||
$hMm = 90.0; // 80mm roll (printable ~72mm) — taller to fit larger font
|
||||
$wChars = 30;
|
||||
break;
|
||||
case 'receipt58':
|
||||
$wMm = 58.0;
|
||||
$hMm = 80.0; // 58mm roll — taller to fit larger font
|
||||
$wChars = 24;
|
||||
break;
|
||||
case 'card':
|
||||
default:
|
||||
$wMm = 85.60;
|
||||
$hMm = max($dynHeightMm, 40.0); // credit card width, dynamic height
|
||||
$wChars = 40;
|
||||
}
|
||||
|
||||
// Optional manual override via query: height_mm (or h)
|
||||
$hOverride = $this->request->getVar('height_mm') ?? $this->request->getVar('h');
|
||||
if (is_numeric($hOverride)) {
|
||||
$hMm = max(30.0, min(200.0, (float) $hOverride));
|
||||
}
|
||||
|
||||
// Build exactly six lines in monospace formatting
|
||||
$w = $wChars; // characters per line for layout
|
||||
$lines = [];
|
||||
$lines[] = $this->fitLine('Al Rahma School at ISGL ' . ($data['school_year'] ?? ''), $w);
|
||||
// Helper to compose "Label: value_____" without truncating value first
|
||||
$compose = function (string $label, string $value, int $padUnderscore = 0) use ($w) {
|
||||
$prefix = rtrim($label, ':') . ': ';
|
||||
$line = $prefix . $value;
|
||||
$len = strlen($line);
|
||||
if ($padUnderscore > 0 && $len < $w) {
|
||||
$line .= str_repeat('_', min($padUnderscore, $w - $len));
|
||||
}
|
||||
return $this->fitLine($line, $w);
|
||||
};
|
||||
// Student Name (no trailing underscores)
|
||||
$lines[] = $compose('Student Name', (string)($data['student_name'] ?? ''), 0);
|
||||
// Date and Time In on separate lines
|
||||
$lines[] = $this->fitLine('Date: ' . (string)($data['date'] ?? ''), $w);
|
||||
$lines[] = $this->fitLine('Time In: ' . (string)($data['time_in'] ?? ''), $w);
|
||||
// Grade and Reason (no trailing underscores)
|
||||
$lines[] = $compose('Grade', (string)($data['grade'] ?? ''), 0);
|
||||
$lines[] = $compose('Reason', (string)($data['reason'] ?? ''), 0);
|
||||
// Admin: do not underline, just value
|
||||
$lines[] = $this->fitLine('Admin: ' . (string)($data['admin_name'] ?? ''), $w);
|
||||
|
||||
$html = view('slips/slip_pdf', ['entry' => $data, 'lines' => $lines, 'page' => ['w_mm' => $wMm, 'h_mm' => $hMm]]);
|
||||
|
||||
$options = new Options();
|
||||
$options->set('isRemoteEnabled', true);
|
||||
$options->set('defaultFont', 'Courier');
|
||||
|
||||
$dompdf = new Dompdf($options);
|
||||
$dompdf->loadHtml($html, 'UTF-8');
|
||||
// Ensure minimum height is enough for current content calculation
|
||||
if ($hMm < $dynHeightMm) {
|
||||
$hMm = $dynHeightMm;
|
||||
}
|
||||
|
||||
// Set paper size from selected mm values -> points
|
||||
$mmToPt = 72 / 25.4;
|
||||
$wPt = $wMm * $mmToPt;
|
||||
$hPt = $hMm * $mmToPt;
|
||||
$dompdf->setPaper([0, 0, $wPt, $hPt]);
|
||||
$dompdf->render();
|
||||
|
||||
$filename = 'LateSlip_' . preg_replace('/[^A-Za-z0-9]+/', '_', (string)($data['student_name'] ?? 'slip')) . '_' . date('Ymd_His') . '.pdf';
|
||||
|
||||
return $this->response
|
||||
->setHeader('Content-Type', 'application/pdf')
|
||||
->setHeader('Content-Disposition', 'inline; filename="' . $filename . '"')
|
||||
->setBody($dompdf->output());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Models\StaffModel;
|
||||
use App\Models\UserModel;
|
||||
use App\Models\TeacherClassModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
|
||||
class StaffController extends BaseController
|
||||
{
|
||||
protected $configModel;
|
||||
protected $semester;
|
||||
protected $schoolYear;
|
||||
protected $teacherClassModel;
|
||||
protected $userModel;
|
||||
protected $staffModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// Initialize the config model
|
||||
$this->configModel = new ConfigurationModel(); // Assuming ConfigModel is the model handling configurations
|
||||
$this->teacherClassModel = new TeacherClassModel();
|
||||
$this->userModel = new UserModel();
|
||||
$this->staffModel = new StaffModel();
|
||||
|
||||
// Retrieve the configuration values
|
||||
$this->semester = $this->configModel->getConfig('semester');
|
||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
// roles we never show
|
||||
$excludedRoles = ['student', 'parent', 'guest', 'inactive']; // ← add inactive here
|
||||
|
||||
$staffList = $this->staffModel
|
||||
->whereNotIn('LOWER(active_role)', array_map('strtolower', $excludedRoles))
|
||||
->orderBy('created_at', 'DESC')
|
||||
->findAll();
|
||||
|
||||
// Preload assignments for the selected school year (across all semesters),
|
||||
// mirroring the logic used in teacher_class_assignment.
|
||||
$assignRows = $this->teacherClassModel
|
||||
->select('teacher_class.teacher_id, teacher_class.position, classSection.class_section_name')
|
||||
->join('classSection', 'classSection.class_section_id = teacher_class.class_section_id', 'left')
|
||||
->where('teacher_class.school_year', (string)$this->schoolYear)
|
||||
->findAll();
|
||||
|
||||
$assignByTeacher = [];
|
||||
foreach ($assignRows as $r) {
|
||||
$tid = (int)($r['teacher_id'] ?? 0);
|
||||
if ($tid <= 0) { continue; }
|
||||
$name = trim((string)($r['class_section_name'] ?? ''));
|
||||
if ($name === '') { continue; }
|
||||
$pos = strtolower((string)($r['position'] ?? ''));
|
||||
if (!in_array($pos, ['main','ta'], true)) { continue; }
|
||||
$assignByTeacher[$tid][] = $name . ' (' . $pos . ')';
|
||||
}
|
||||
|
||||
$issuesCount = 0;
|
||||
foreach ($staffList as &$staff) {
|
||||
// attach school_id
|
||||
$staff['school_id'] = $this->userModel->getSchoolIdByUserId($staff['user_id'] ?? null);
|
||||
|
||||
// Verify and attach class assignments for teacher/TA roles for the selected school year
|
||||
$role = strtolower((string)($staff['active_role'] ?? ''));
|
||||
if (in_array($role, ['teacher', 'teacher_assistant'], true)) {
|
||||
$tid = (int)($staff['user_id'] ?? 0);
|
||||
$labels = $assignByTeacher[$tid] ?? [];
|
||||
if (!empty($labels)) {
|
||||
$staff['class_section'] = implode(', ', array_unique($labels));
|
||||
$staff['verification_issue'] = false;
|
||||
} else {
|
||||
$staff['class_section'] = 'No class assigned';
|
||||
$staff['verification_issue'] = true;
|
||||
$issuesCount++;
|
||||
}
|
||||
} else {
|
||||
$staff['class_section'] = '—';
|
||||
$staff['verification_issue'] = false;
|
||||
}
|
||||
}
|
||||
unset($staff); // break reference
|
||||
|
||||
return view('staff/index', [
|
||||
'staff' => $staffList,
|
||||
'issues_count' => $issuesCount,
|
||||
'semester' => $this->semester,
|
||||
'school_year' => $this->schoolYear,
|
||||
]);
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
return view('staff/create');
|
||||
}
|
||||
|
||||
public function store()
|
||||
{
|
||||
$now = utc_now();
|
||||
$payload = [
|
||||
'firstname' => $this->request->getPost('firstname'),
|
||||
'lastname' => $this->request->getPost('lastname'),
|
||||
'email' => $this->request->getPost('email'),
|
||||
'phone' => $this->request->getPost('phone'),
|
||||
'role_name' => $this->request->getPost('role_name'),
|
||||
'school_year' => $this->request->getPost('school_year') ?: $this->schoolYear,
|
||||
'status' => $this->request->getPost('status') ?? 'Active',
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
if (!$this->staffModel->insert($payload)) {
|
||||
return redirect()->back()->withInput()->with('error', 'Failed to create staff.');
|
||||
}
|
||||
|
||||
return redirect()->to('/staff')->with('success', 'Staff member added.');
|
||||
}
|
||||
|
||||
public function edit($id)
|
||||
{
|
||||
$user = $this->staffModel->find($id);
|
||||
|
||||
if (!$user) {
|
||||
return redirect()->to('staff')->with('error', 'Staff member not found.');
|
||||
}
|
||||
|
||||
return view('staff/edit', ['user' => $user]);
|
||||
}
|
||||
|
||||
public function update($id)
|
||||
{
|
||||
// Only update fields provided by the form to avoid wiping data
|
||||
$data = [];
|
||||
foreach (['firstname','lastname','email','phone','role_name','school_year','status'] as $field) {
|
||||
$val = $this->request->getPost($field);
|
||||
if ($val !== null && $val !== '') {
|
||||
$data[$field] = $val;
|
||||
}
|
||||
}
|
||||
// Always bump updated_at
|
||||
$data['updated_at'] = utc_now();
|
||||
|
||||
if (empty($data)) {
|
||||
return redirect()->back()->with('error', 'No changes detected.');
|
||||
}
|
||||
|
||||
if (!$this->staffModel->update($id, $data)) {
|
||||
return redirect()->back()->withInput()->with('error', 'Failed to update staff.');
|
||||
}
|
||||
|
||||
return redirect()->to('/staff')->with('success', 'Staff member updated.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Models\StatsModel;
|
||||
use App\Controllers\BaseController;
|
||||
|
||||
class StatsController extends BaseController
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$model = new StatsModel();
|
||||
$data['stats'] = $model->getStats();
|
||||
|
||||
return view('stats_view', $data);
|
||||
}
|
||||
|
||||
public function calendar()
|
||||
{
|
||||
return view('/calendars');
|
||||
}
|
||||
|
||||
public function support()
|
||||
{
|
||||
return view('/support');
|
||||
}
|
||||
|
||||
public function contactUs()
|
||||
{
|
||||
return view('/contact_us');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
class StickersController extends PrintablesBaseController
|
||||
{
|
||||
public function stickerForm()
|
||||
{
|
||||
$data['students'] = $this->studentModel->findAll();
|
||||
$data['classes'] = $this->classSectionModel->findAll();
|
||||
$data['stickers'] = $this->stickerPresets();
|
||||
return view('printables_reports/sticker_form', $data);
|
||||
}
|
||||
|
||||
public function getByClass($classId)
|
||||
{
|
||||
$builder = $this->db->table('student_class sc')
|
||||
->select('s.id, s.firstname, s.lastname, s.registration_grade, s.gender')
|
||||
->join('students s', 's.id = sc.student_id')
|
||||
->where('sc.class_section_id', $classId)
|
||||
->where('sc.school_year', $this->schoolYear)
|
||||
->orderBy('s.lastname', 'asc');
|
||||
|
||||
$students = $builder->get()->getResultArray();
|
||||
|
||||
return $this->response->setJSON($students);
|
||||
}
|
||||
|
||||
/** Preset sticker sizes for the view (robust keys) */
|
||||
private function stickerPresets(): array
|
||||
{
|
||||
$rows = [
|
||||
['value' => '100x24.5', 'label' => 'Xsmall - 100x24.5 mm', 'ppg' => 20],
|
||||
['value' => '60x30', 'label' => 'Small - 60x30 mm', 'ppg' => 24],
|
||||
['value' => '102x34', 'label' => 'Medium - 102x34 mm', 'ppg' => 14],
|
||||
['value' => '90x45', 'label' => 'Large - 90x45 mm', 'ppg' => 10],
|
||||
];
|
||||
|
||||
// Add aliases so different views will not break (title/text/per_page)
|
||||
return array_map(static function ($r) {
|
||||
$r['title'] = $r['title'] ?? ($r['label'] ?? ($r['value'] . ' mm'));
|
||||
$r['text'] = $r['text'] ?? $r['title'];
|
||||
$r['per_page'] = $r['per_page'] ?? ($r['ppg'] ?? null);
|
||||
return $r;
|
||||
}, $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate name stickers as a PDF.
|
||||
*
|
||||
* Can be used for different sticker sizes/sheets by passing parameters
|
||||
* via function args (from routes) or via GET/POST form fields.
|
||||
*
|
||||
* Accepted request params (POST takes precedence over GET):
|
||||
* - class_id : int
|
||||
* - student_id : int
|
||||
* - print_all : 0|1
|
||||
* - sticker_size : string like "102x34" (accepts upper/lower case X or the unicode multiply sign)
|
||||
* - sticker_width : number (mm)
|
||||
* - sticker_height : number (mm)
|
||||
* - stickers_per_page : int (optional cap; grid still derived from size/margins)
|
||||
* - page_size : "A4" (default) | "Letter" | any FPDF size token
|
||||
* - orientation : "P" (default) | "L"
|
||||
* - margin_left : number (mm)
|
||||
* - margin_top : number (mm)
|
||||
* - margin_right : number (mm; defaults to margin_left)
|
||||
* - margin_bottom : number (mm; default 10)
|
||||
* - gap_x : number (mm; horizontal gap between stickers)
|
||||
* - gap_y : number (mm; vertical gap between stickers)
|
||||
* - copies[<student_id>] : int per-student copies in batch mode
|
||||
* - single_copies : int copies for single student mode
|
||||
*/
|
||||
public function generateStickers(
|
||||
?int $studentId = null,
|
||||
?float $stickerWidthArg = null,
|
||||
?float $stickerHeightArg = null,
|
||||
?int $stickersPerPageArg = null,
|
||||
?string $pageSizeArg = null,
|
||||
?string $orientationArg = null,
|
||||
?float $marginLeftArg = null,
|
||||
?float $marginTopArg = null,
|
||||
?float $marginRightArg = null,
|
||||
?float $marginBottomArg = null,
|
||||
?float $gapXArg = null,
|
||||
?float $gapYArg = null
|
||||
) {
|
||||
$request = service('request');
|
||||
$method = strtolower($request->getMethod());
|
||||
$classId = $request->getPost('class_id') ?: $request->getGet('class_id');
|
||||
$studentId = $studentId ?? ($request->getPost('student_id') ?: $request->getGet('student_id'));
|
||||
|
||||
// Safety cast
|
||||
$classId = $classId !== null ? (int) $classId : null;
|
||||
$studentId = $studentId !== null ? (int) $studentId : null;
|
||||
|
||||
// Sanity log for school year
|
||||
if (empty($this->schoolYear)) {
|
||||
log_message('error', 'generateStickers(): $this->schoolYear is empty. Load it from ConfigurationModel.');
|
||||
} else {
|
||||
log_message('debug', 'generateStickers(): schoolYear=' . $this->schoolYear);
|
||||
}
|
||||
|
||||
// Load classes that actually have students in this school year
|
||||
$classes = $this->classSectionModel
|
||||
->select('classSection.class_section_id, classSection.class_section_name')
|
||||
->join('student_class', 'student_class.class_section_id = classSection.class_section_id', 'inner')
|
||||
->where('student_class.school_year', $this->schoolYear)
|
||||
->groupBy('classSection.class_section_id, classSection.class_section_name')
|
||||
->orderBy('classSection.class_section_name', 'ASC')
|
||||
->findAll();
|
||||
|
||||
// ---------- GET: render form ----------
|
||||
if ($method === 'get') {
|
||||
$students = [];
|
||||
|
||||
if (!empty($classId)) {
|
||||
$students = $this->fetchStudentsByClass((int) $classId, $this->schoolYear);
|
||||
} else {
|
||||
$students = $this->studentModel
|
||||
->select('students.id, students.firstname, students.lastname')
|
||||
->join('student_class', 'student_class.student_id = students.id', 'inner')
|
||||
->where('student_class.school_year', $this->schoolYear)
|
||||
->groupBy('students.id, students.firstname, students.lastname')
|
||||
->orderBy('students.firstname', 'ASC')
|
||||
->orderBy('students.lastname', 'ASC')
|
||||
->findAll(500);
|
||||
}
|
||||
|
||||
// Presets (now with robust keys expected by the view)
|
||||
$presetRows = [
|
||||
['value' => '60x30', 'label' => 'Small - 60x30 mm', 'ppg' => 24],
|
||||
['value' => '102x34', 'label' => 'Medium - 102x34 mm', 'ppg' => 14],
|
||||
['value' => '90x45', 'label' => 'Large - 90x45 mm', 'ppg' => 10],
|
||||
];
|
||||
// Normalize to include 'title', 'text', and 'per_page' so views using any of those will not error.
|
||||
$stickers = array_map(static function (array $r): array {
|
||||
$r['title'] = $r['title'] ?? ($r['label'] ?? ($r['value'] . ' mm'));
|
||||
$r['text'] = $r['text'] ?? $r['title'];
|
||||
$r['per_page'] = $r['per_page'] ?? ($r['ppg'] ?? null);
|
||||
return $r;
|
||||
}, $presetRows);
|
||||
|
||||
return view('printables_reports/sticker_form', [
|
||||
'classes' => $classes,
|
||||
'students' => $students,
|
||||
'stickers' => $stickers,
|
||||
]);
|
||||
}
|
||||
|
||||
// ---------- POST: build student list ----------
|
||||
$printAll = (int) ($request->getPost('print_all') ?? $request->getGet('print_all') ?? 0) === 1;
|
||||
|
||||
$students = [];
|
||||
if (!empty($studentId)) {
|
||||
$student = $this->studentModel->find((int) $studentId);
|
||||
if (!$student) {
|
||||
return 'Student not found.';
|
||||
}
|
||||
$students = [$student];
|
||||
} elseif (!empty($classId)) {
|
||||
$students = $this->fetchStudentsByClass((int) $classId, $this->schoolYear);
|
||||
if (empty($students)) {
|
||||
return 'No students found in this class.';
|
||||
}
|
||||
} elseif ($printAll) {
|
||||
$students = $this->studentModel
|
||||
->select('students.id, students.firstname, students.lastname, sc.class_section_id, cs.class_section_name, c.id AS class_id')
|
||||
->join('student_class sc', 'sc.student_id = students.id', 'inner')
|
||||
->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'inner')
|
||||
->join('classes c', 'c.id = cs.class_id', 'inner')
|
||||
->where('sc.school_year', $this->schoolYear)
|
||||
->groupBy('students.id, students.firstname, students.lastname, sc.class_section_id, cs.class_section_name, c.id')
|
||||
->orderBy('c.id', 'ASC')
|
||||
->orderBy('cs.class_section_name', 'ASC')
|
||||
->orderBy('students.firstname', 'ASC')
|
||||
->orderBy('students.lastname', 'ASC')
|
||||
->findAll(5000);
|
||||
|
||||
// Exclude Youth and KG
|
||||
$students = array_values(array_filter($students, static function ($s) {
|
||||
$g = trim((string) ($s['class_section_name'] ?? ''));
|
||||
return $g !== '' && !preg_match('/^(youth|kg)(?:\b|-)/i', $g);
|
||||
}));
|
||||
|
||||
if (empty($students)) {
|
||||
return 'No students found for this school year.';
|
||||
}
|
||||
} else {
|
||||
return 'Please select a student or class.';
|
||||
}
|
||||
|
||||
// ---- Expand list according to per-student copy counts ----
|
||||
$copiesPosted = $request->getPost('copies'); // array|null
|
||||
$singleCopies = (int) ($request->getPost('single_copies') ?? $request->getGet('single_copies') ?? 1);
|
||||
if ($singleCopies < 0) $singleCopies = 0;
|
||||
|
||||
$printList = [];
|
||||
if (!empty($studentId)) {
|
||||
$qty = max(0, $singleCopies);
|
||||
for ($k = 0; $k < $qty; $k++) {
|
||||
$printList[] = $students[0];
|
||||
}
|
||||
} else {
|
||||
$hasCopies = is_array($copiesPosted);
|
||||
$defaultQty = $hasCopies ? 0 : 1; // if copies[] present, only print IDs provided
|
||||
foreach ($students as $stu) {
|
||||
$id = (int) ($stu['id'] ?? 0);
|
||||
$qty = $defaultQty;
|
||||
if ($hasCopies && array_key_exists((string)$id, $copiesPosted)) {
|
||||
$qty = (int) $copiesPosted[(string)$id];
|
||||
}
|
||||
$qty = max(0, $qty);
|
||||
for ($k = 0; $k < $qty; $k++) {
|
||||
$printList[] = $stu;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (empty($printList)) {
|
||||
return 'Nothing to print. All quantities are zero.';
|
||||
}
|
||||
|
||||
// ---------- Layout parameters (request/args/defaults) ----------
|
||||
$sizeStrReq = trim((string) ($request->getPost('sticker_size') ?? $request->getGet('sticker_size') ?? ''));
|
||||
$stickerWidth = $stickerWidthArg ?? (float) ($request->getPost('sticker_width') ?? $request->getGet('sticker_width') ?? 0);
|
||||
$stickerHeight = $stickerHeightArg ?? (float) ($request->getPost('sticker_height') ?? $request->getGet('sticker_height') ?? 0);
|
||||
|
||||
// parse size string if provided (supports decimals; accepts x/X/unicode multiply)
|
||||
if (($stickerWidth <= 0 || $stickerHeight <= 0) && $sizeStrReq !== '') {
|
||||
if (preg_match('/^\\s*(\\d+(?:\\.\\d+)?)\\s*[xX\\x{00D7}]\\s*(\\d+(?:\\.\\d+)?)\\s*$/u', $sizeStrReq, $m)) {
|
||||
$stickerWidth = (float) $m[1];
|
||||
$stickerHeight = (float) $m[2];
|
||||
}
|
||||
}
|
||||
|
||||
// fallbacks to controller properties or sane defaults (mm)
|
||||
if ($stickerWidth <= 0) {
|
||||
$stickerWidth = isset($this->stickerWidth) ? (float)$this->stickerWidth : 102.0;
|
||||
}
|
||||
if ($stickerHeight <= 0) {
|
||||
$stickerHeight = isset($this->stickerHeight) ? (float)$this->stickerHeight : 34.0;
|
||||
}
|
||||
|
||||
$stickersPerPageReq = $stickersPerPageArg ?? (int) ($request->getPost('stickers_per_page') ?? $request->getGet('stickers_per_page') ?? 0);
|
||||
$pageSize = $pageSizeArg ?? (string) ($request->getPost('page_size') ?? $request->getGet('page_size') ?? 'A4');
|
||||
$orientation = $orientationArg ?? (string) ($request->getPost('orientation') ?? $request->getGet('orientation') ?? 'P');
|
||||
|
||||
$marginLeftProvided = $marginLeftArg !== null || $request->getPost('margin_left') !== null || $request->getGet('margin_left') !== null;
|
||||
$marginTopProvided = $marginTopArg !== null || $request->getPost('margin_top') !== null || $request->getGet('margin_top') !== null;
|
||||
$marginRightProvided = $marginRightArg !== null || $request->getPost('margin_right') !== null || $request->getGet('margin_right') !== null;
|
||||
$marginBottomProvided = $marginBottomArg !== null || $request->getPost('margin_bottom') !== null || $request->getGet('margin_bottom') !== null;
|
||||
$gapXProvided = $gapXArg !== null || $request->getPost('gap_x') !== null || $request->getGet('gap_x') !== null;
|
||||
$gapYProvided = $gapYArg !== null || $request->getPost('gap_y') !== null || $request->getGet('gap_y') !== null;
|
||||
|
||||
$marginLeft = $marginLeftArg ?? (float) ($request->getPost('margin_left') ?? $request->getGet('margin_left') ?? ($this->marginL ?? 6));
|
||||
$marginTop = $marginTopArg ?? (float) ($request->getPost('margin_top') ?? $request->getGet('margin_top') ?? ($this->marginT ?? 6));
|
||||
$marginRight = $marginRightArg ?? (float) ($request->getPost('margin_right') ?? $request->getGet('margin_right') ?? $marginLeft);
|
||||
$marginBottom = $marginBottomArg ?? (float) ($request->getPost('margin_bottom') ?? $request->getGet('margin_bottom') ?? 10);
|
||||
|
||||
$gapX = $gapXArg ?? (float) ($request->getPost('gap_x') ?? $request->getGet('gap_x') ?? ($this->gapX ?? 0));
|
||||
$gapY = $gapYArg ?? (float) ($request->getPost('gap_y') ?? $request->getGet('gap_y') ?? ($this->gapY ?? 0));
|
||||
|
||||
// If using the 100x24.5 preset (small labels) and nothing custom was provided,
|
||||
// default to tight margins and zero gaps to fit 20 per page (2 columns * 10+ rows).
|
||||
$normalizedSize = strtolower(str_replace(['×', ' '], ['x', ''], $sizeStrReq));
|
||||
$isXSmallPreset = $normalizedSize === '100x24.5' || (abs($stickerWidth - 100.0) < 0.6 && abs($stickerHeight - 24.5) < 0.6);
|
||||
if ($isXSmallPreset) {
|
||||
if (!$marginLeftProvided) {
|
||||
$marginLeft = 4.0;
|
||||
if (!$marginRightProvided) {
|
||||
$marginRight = 4.0;
|
||||
}
|
||||
}
|
||||
if (!$marginRightProvided && $marginRight < 4.0) {
|
||||
$marginRight = 4.0;
|
||||
}
|
||||
if (!$gapXProvided) {
|
||||
$gapX = 0.0;
|
||||
}
|
||||
if (!$gapYProvided) {
|
||||
$gapY = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- FPDF setup ----------
|
||||
$pdf = new \FPDF($orientation, 'mm', $pageSize);
|
||||
$pdf->SetMargins($marginLeft, $marginTop, $marginRight);
|
||||
$pdf->SetAutoPageBreak(true, $marginBottom);
|
||||
$pdf->AddPage();
|
||||
$pdf->SetFont('Arial', '', 12);
|
||||
|
||||
// Printable area
|
||||
$pageW = (float) $pdf->GetPageWidth();
|
||||
$pageH = (float) $pdf->GetPageHeight();
|
||||
|
||||
$usableW = max(0.0, $pageW - ($marginLeft + $marginRight));
|
||||
$usableH = max(0.0, $pageH - ($marginTop + $marginBottom));
|
||||
|
||||
// Compute columns & rows from physical dimensions and gaps.
|
||||
$cols = (int) floor(($usableW + max(0.0, $gapX)) / (max(0.1, $stickerWidth) + max(0.0, $gapX)));
|
||||
$rows = (int) floor(($usableH + max(0.0, $gapY)) / (max(0.1, $stickerHeight) + max(0.0, $gapY)));
|
||||
|
||||
$cols = max(1, $cols);
|
||||
$rows = max(1, $rows);
|
||||
|
||||
// Optional cap: stickers_per_page
|
||||
$capacity = $cols * $rows;
|
||||
if ($stickersPerPageReq > 0 && $stickersPerPageReq < $capacity) {
|
||||
$rows = (int) ceil($stickersPerPageReq / $cols);
|
||||
$maxRowsFit = (int) floor(($usableH + max(0.0, $gapY)) / (max(0.1, $stickerHeight) + max(0.0, $gapY)));
|
||||
$rows = max(1, min($rows, max(1, $maxRowsFit)));
|
||||
$capacity = $cols * $rows;
|
||||
}
|
||||
|
||||
// Starting positions
|
||||
$xStart = $marginLeft;
|
||||
$yStart = $marginTop;
|
||||
$x = $xStart;
|
||||
$y = $yStart;
|
||||
|
||||
$pt2mm = static function (float $pt): float {
|
||||
return $pt * 0.352778;
|
||||
};
|
||||
|
||||
// Draw loop
|
||||
$i = 0;
|
||||
$perPage = $capacity;
|
||||
|
||||
foreach ($printList as $stu) {
|
||||
if ($i > 0 && $i % $perPage === 0) {
|
||||
$pdf->AddPage();
|
||||
$x = $xStart;
|
||||
$y = $yStart;
|
||||
}
|
||||
|
||||
// Background sticker rectangle (optional; fill white)
|
||||
$pdf->SetFillColor(255, 255, 255);
|
||||
$pdf->Rect($x, $y, $stickerWidth, $stickerHeight, 'F');
|
||||
|
||||
// Centered name with dynamic font size
|
||||
$fontFamily = 'Arial';
|
||||
$fontStyle = 'B';
|
||||
$maxPt = 16;
|
||||
$minPt = 8;
|
||||
$hPad = 2;
|
||||
|
||||
$fullName = trim(($stu['firstname'] ?? '') . ' ' . ($stu['lastname'] ?? '')) ?: 'Student';
|
||||
|
||||
$bestPt = $maxPt;
|
||||
$textW = 0.0;
|
||||
while ($bestPt >= $minPt) {
|
||||
$pdf->SetFont($fontFamily, $fontStyle, $bestPt);
|
||||
$textW = (float) $pdf->GetStringWidth($fullName);
|
||||
if ($textW <= ($stickerWidth - 2 * $hPad)) break;
|
||||
$bestPt -= 0.5;
|
||||
}
|
||||
if ($bestPt < $minPt) {
|
||||
$bestPt = $minPt;
|
||||
$pdf->SetFont($fontFamily, $fontStyle, $bestPt);
|
||||
$textW = (float) $pdf->GetStringWidth($fullName);
|
||||
}
|
||||
$textH = $pt2mm($bestPt);
|
||||
|
||||
$cellX = $x + (($stickerWidth - $textW) / 2);
|
||||
$cellY = $y + (($stickerHeight - $textH) / 2);
|
||||
|
||||
$pdf->SetXY($cellX, $cellY);
|
||||
$pdf->Cell($textW, $textH, $fullName, 0, 0, 'L');
|
||||
|
||||
// Advance cell position within the grid
|
||||
$i++;
|
||||
$posInRow = ($i - 1) % $cols;
|
||||
if ($posInRow === ($cols - 1)) {
|
||||
$x = $xStart;
|
||||
$y += $stickerHeight + $gapY;
|
||||
} else {
|
||||
$x += $stickerWidth + $gapX;
|
||||
}
|
||||
}
|
||||
|
||||
// Output
|
||||
$pdfContent = $pdf->Output('S');
|
||||
return $this->response
|
||||
->setHeader('Content-Type', 'application/pdf')
|
||||
->setHeader('Content-Disposition', 'inline; filename="Student_Stickers.pdf"')
|
||||
->setHeader('Cache-Control', 'private, max-age=0, must-revalidate')
|
||||
->setHeader('Pragma', 'public')
|
||||
->setHeader('Content-Length', (string) strlen($pdfContent))
|
||||
->setBody($pdfContent);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Models\ClassModel;
|
||||
use App\Models\SubjectCurriculumModel;
|
||||
use CodeIgniter\Exceptions\PageNotFoundException;
|
||||
|
||||
class SubjectCurriculumController extends BaseController
|
||||
{
|
||||
private const SUBJECT_OPTIONS = [
|
||||
'islamic' => 'Islamic Studies',
|
||||
'quran' => 'Quran/Arabic',
|
||||
];
|
||||
|
||||
protected SubjectCurriculumModel $curriculumModel;
|
||||
protected ClassModel $classModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
helper(['form']);
|
||||
$this->curriculumModel = new SubjectCurriculumModel();
|
||||
$this->classModel = new ClassModel();
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$data = $this->buildViewData();
|
||||
$data['editEntry'] = null;
|
||||
return view('administrator/subject_curriculum', $data);
|
||||
}
|
||||
|
||||
public function store()
|
||||
{
|
||||
if (! $this->validate($this->getValidationRules())) {
|
||||
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
||||
}
|
||||
|
||||
$data = $this->buildEntryDataFromInput();
|
||||
$insertedId = $this->curriculumModel->insert($data);
|
||||
if ($insertedId === false) {
|
||||
return redirect()->back()->withInput()->with('error', 'Unable to save the curriculum entry. Please try again.');
|
||||
}
|
||||
|
||||
return redirect()->to('administrator/subject-curriculum')->with('success', 'Curriculum entry added.');
|
||||
}
|
||||
|
||||
public function edit(int $id)
|
||||
{
|
||||
$entry = $this->curriculumModel->find($id);
|
||||
if (! $entry) {
|
||||
throw new PageNotFoundException('Curriculum entry not found.');
|
||||
}
|
||||
|
||||
$data = $this->buildViewData();
|
||||
$data['editEntry'] = $entry;
|
||||
return view('administrator/subject_curriculum', $data);
|
||||
}
|
||||
|
||||
public function update(int $id)
|
||||
{
|
||||
$entry = $this->curriculumModel->find($id);
|
||||
if (! $entry) {
|
||||
throw new PageNotFoundException('Curriculum entry not found.');
|
||||
}
|
||||
|
||||
$redirectBack = redirect()->to('administrator/subject-curriculum/edit/' . $id)->withInput();
|
||||
if (! $this->validate($this->getValidationRules())) {
|
||||
return $redirectBack->with('errors', $this->validator->getErrors());
|
||||
}
|
||||
|
||||
$data = $this->buildEntryDataFromInput();
|
||||
if ($this->curriculumModel->update($id, $data) === false) {
|
||||
return $redirectBack->with('error', 'Unable to update the curriculum entry.');
|
||||
}
|
||||
|
||||
return redirect()->to('administrator/subject-curriculum')->with('success', 'Curriculum entry updated.');
|
||||
}
|
||||
|
||||
public function delete(int $id)
|
||||
{
|
||||
$entry = $this->curriculumModel->find($id);
|
||||
if (! $entry) {
|
||||
throw new PageNotFoundException('Curriculum entry not found.');
|
||||
}
|
||||
|
||||
$this->curriculumModel->delete($id);
|
||||
return redirect()->to('administrator/subject-curriculum')->with('success', 'Curriculum entry deleted.');
|
||||
}
|
||||
|
||||
private function buildViewData(): array
|
||||
{
|
||||
$classes = $this->classModel->orderBy('class_name', 'ASC')->findAll();
|
||||
$entries = $this->curriculumModel
|
||||
->builder()
|
||||
->select('subject_curriculum_items.*, classes.class_name')
|
||||
->join('classes', 'classes.id = subject_curriculum_items.class_id', 'left')
|
||||
->orderBy('classes.class_name', 'ASC')
|
||||
->orderBy('subject', 'ASC')
|
||||
->orderBy('unit_number', 'ASC')
|
||||
->orderBy('chapter_name', 'ASC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
return [
|
||||
'classes' => $classes,
|
||||
'entries' => $entries,
|
||||
'subjectLabels' => self::SUBJECT_OPTIONS,
|
||||
];
|
||||
}
|
||||
|
||||
private function buildEntryDataFromInput(): array
|
||||
{
|
||||
$unitNumber = $this->request->getPost('unit_number');
|
||||
$unitNumber = is_numeric($unitNumber) ? (int) $unitNumber : null;
|
||||
if ($unitNumber === 0) {
|
||||
$unitNumber = null;
|
||||
}
|
||||
|
||||
$unitTitle = trim((string) $this->request->getPost('unit_title'));
|
||||
$chapterName = trim((string) $this->request->getPost('chapter_name'));
|
||||
|
||||
return [
|
||||
'class_id' => (int) $this->request->getPost('class_id'),
|
||||
'subject' => (string) $this->request->getPost('subject'),
|
||||
'unit_number' => $unitNumber,
|
||||
'unit_title' => $unitTitle !== '' ? $unitTitle : null,
|
||||
'chapter_name' => $chapterName,
|
||||
];
|
||||
}
|
||||
|
||||
private function getValidationRules(): array
|
||||
{
|
||||
return [
|
||||
'class_id' => 'required|is_natural_no_zero',
|
||||
'subject' => 'required|in_list[islamic,quran]',
|
||||
'chapter_name' => 'required|string|max_length[255]',
|
||||
'unit_number' => 'permit_empty|integer',
|
||||
'unit_title' => 'permit_empty|string|max_length[255]',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Models\SupplierModel;
|
||||
|
||||
class SupplierController extends BaseController
|
||||
{
|
||||
protected $model;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->model = new SupplierModel();
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$q = trim($this->request->getGet('q') ?? '');
|
||||
$builder = $this->model;
|
||||
if ($q !== '') {
|
||||
$builder = $builder->groupStart()
|
||||
->like('name', $q)->orLike('email', $q)->orLike('phone', $q)
|
||||
->groupEnd();
|
||||
}
|
||||
return view('inventory/suppliers_index', [
|
||||
'suppliers' => $builder->orderBy('name')->paginate(20),
|
||||
'pager' => $this->model->pager,
|
||||
'q' => $q,
|
||||
]);
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
return view('inventory/suppliers_form', [
|
||||
'title' => 'Add Supplier',
|
||||
'action' => site_url('inventory/suppliers/store'),
|
||||
'supplier' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store()
|
||||
{
|
||||
$data = $this->request->getPost(['name','email','phone','address','notes']);
|
||||
if (!$this->model->save($data)) {
|
||||
return redirect()->back()->withInput()->with('error', implode("\n", $this->model->errors()));
|
||||
}
|
||||
return redirect()->to(site_url('inventory/suppliers'))->with('success', 'Supplier saved.');
|
||||
}
|
||||
|
||||
public function edit($id)
|
||||
{
|
||||
$s = $this->model->find($id);
|
||||
if (!$s) return redirect()->to('inventory/suppliers')->with('error', 'Not found.');
|
||||
return view('inventory/suppliers_form', [
|
||||
'title' => 'Edit Supplier',
|
||||
'action' => site_url('inventory/suppliers/update/'.$id),
|
||||
'supplier' => $s,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update($id)
|
||||
{
|
||||
$data = $this->request->getPost(['name','email','phone','address','notes']);
|
||||
$data['id'] = $id;
|
||||
if (!$this->model->save($data)) {
|
||||
return redirect()->back()->withInput()->with('error', implode("\n", $this->model->errors()));
|
||||
}
|
||||
return redirect()->to(site_url('inventory/suppliers'))->with('success', 'Supplier updated.');
|
||||
}
|
||||
|
||||
public function delete($id)
|
||||
{
|
||||
$this->model->delete($id);
|
||||
return redirect()->to(site_url('inventory/suppliers'))->with('success', 'Supplier deleted.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Models\SupplyCategoryModel;
|
||||
|
||||
|
||||
class SupplyCategoryController extends BaseController
|
||||
{
|
||||
protected $model;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->model = new SupplyCategoryModel();
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
return view('inventory/categories_index', [
|
||||
'categories' => $this->model->orderBy('name')->findAll(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
return view('inventory/categories_form', [
|
||||
'title' => 'Add Category',
|
||||
'action' => site_url('inventory/categories/store'),
|
||||
'category' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store()
|
||||
{
|
||||
$data = $this->request->getPost(['name']);
|
||||
if (!$this->model->save($data)) {
|
||||
return redirect()->back()->withInput()->with('error', implode("\n", $this->model->errors()));
|
||||
}
|
||||
return redirect()->to(site_url('inventory/categories'))->with('success', 'Category saved.');
|
||||
}
|
||||
|
||||
public function edit($id)
|
||||
{
|
||||
$cat = $this->model->find($id);
|
||||
if (!$cat) return redirect()->to('inventory/categories')->with('error', 'Not found.');
|
||||
return view('inventory/categories_form', [
|
||||
'title' => 'Edit Category',
|
||||
'action' => site_url('inventory/categories/update/'.$id),
|
||||
'category' => $cat,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update($id)
|
||||
{
|
||||
$data = $this->request->getPost(['name']);
|
||||
$data['id'] = (int) $id;
|
||||
if (!$this->model->save($data)) {
|
||||
return redirect()->back()->withInput()->with('error', implode("\n", $this->model->errors()));
|
||||
}
|
||||
return redirect()->to(site_url('inventory/categories'))->with('success', 'Category updated.');
|
||||
}
|
||||
|
||||
public function delete($id)
|
||||
{
|
||||
$this->model->delete($id);
|
||||
return redirect()->to(site_url('inventory/categories'))->with('success', 'Category deleted.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Controllers\View\EmailController;
|
||||
use App\Models\SupportRequestModel;
|
||||
use App\Models\UserModel;
|
||||
|
||||
class SupportController extends BaseController
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
helper(['form']);
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
return view('/support');
|
||||
}
|
||||
|
||||
|
||||
public function submit()
|
||||
{
|
||||
// Retrieve user_id from session
|
||||
$session = session();
|
||||
$user_id = $session->get('user_id');
|
||||
|
||||
// If no user_id is found in the session, redirect with an error
|
||||
if (!$user_id) {
|
||||
return redirect()->back()->with('error', 'You must be logged in to submit a support request.');
|
||||
}
|
||||
|
||||
// Load the UserModel to retrieve user information
|
||||
$userModel = new UserModel();
|
||||
$user = $userModel->find($user_id);
|
||||
|
||||
// If the user does not exist, redirect with an error
|
||||
if (!$user) {
|
||||
return redirect()->back()->with('error', 'User not found. Please contact support.');
|
||||
}
|
||||
|
||||
// Retrieve form data
|
||||
$subject = $this->request->getPost('subject');
|
||||
$message = $this->request->getPost('message');
|
||||
|
||||
// Get the user's full name using firstname and lastname
|
||||
$full_name = $user['firstname'] . ' ' . $user['lastname'];
|
||||
|
||||
// Call the function to send an email using SMTP (configured via Email.php or .env)
|
||||
$sendStatus = $this->sendSupportEmail($user['email'], $full_name, $subject, $message);
|
||||
|
||||
if ($sendStatus === true) {
|
||||
return redirect()->back()->with('success', 'Your support request has been submitted successfully.');
|
||||
} else {
|
||||
// If email fails, log the error and display a message
|
||||
log_message('error', $sendStatus);
|
||||
return redirect()->back()->with('error', 'Failed to send your support request. Please try again later.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to send an email via SMTP from user to support@domain.org
|
||||
*
|
||||
* @param string $user_email - The email address of the user sending the request.
|
||||
* @param string $user_name - The name of the user sending the request.
|
||||
* @param string $subject - The subject of the support request.
|
||||
* @param string $message - The body of the support request.
|
||||
* @return bool|string - Returns true if email sent successfully, otherwise error message.
|
||||
*/
|
||||
public function sendSupportEmail($user_email, $full_name, $subject, $message)
|
||||
{
|
||||
$mailer = new EmailController();
|
||||
$ok = $mailer->sendEmail(
|
||||
'support@alrahmaisgl.org',
|
||||
$subject,
|
||||
nl2br($message),
|
||||
null,
|
||||
$user_email,
|
||||
$full_name
|
||||
);
|
||||
|
||||
return $ok === true ? true : 'Failed to send support email.';
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function requests()
|
||||
{
|
||||
$supportModel = new SupportRequestModel();
|
||||
$userId = session()->get('user_id');
|
||||
$data['requests'] = $supportModel->where('user_id', $userId)->findAll();
|
||||
|
||||
return view('/support_requests', $data);
|
||||
}
|
||||
|
||||
public function supportTeacher()
|
||||
{
|
||||
return view('/teacher/teacher_support');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
public function submit()
|
||||
{
|
||||
$validation = \Config\Services::validation();
|
||||
|
||||
// Define validation rules
|
||||
$validation->setRules([
|
||||
'subject' => 'required|min_length[3]',
|
||||
'message' => 'required|min_length[10]',
|
||||
]);
|
||||
|
||||
if (!$validation->withRequest($this->request)->run()) {
|
||||
return view('/support', [
|
||||
'validation' => $validation
|
||||
]);
|
||||
}
|
||||
|
||||
$supportModel = new SupportRequestModel();
|
||||
$supportModel->save([
|
||||
'user_id' => session()->get('user_id'),
|
||||
'subject' => $this->request->getPost('subject'),
|
||||
'message' => $this->request->getPost('message'),
|
||||
'status' => 'open'
|
||||
]);
|
||||
|
||||
session()->setFlashdata('success', 'Your support request has been submitted.');
|
||||
|
||||
return redirect()->to('/support');
|
||||
}
|
||||
*/
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use CodeIgniter\Controller;
|
||||
use CodeIgniter\Database\Exceptions\DatabaseException;
|
||||
|
||||
class TestDBController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$db = \Config\Database::connect();
|
||||
|
||||
try {
|
||||
$query = $db->query('SELECT 1');
|
||||
$result = $query->getResult();
|
||||
|
||||
if ($result) {
|
||||
echo "Database connection is successful.";
|
||||
} else {
|
||||
echo "Database connection failed.";
|
||||
}
|
||||
} catch (DatabaseException $e) {
|
||||
echo "Database connection failed: " . $e->getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use CodeIgniter\Controller;
|
||||
use App\Models\PreferencesModel;
|
||||
|
||||
class UiController extends Controller
|
||||
{
|
||||
public function style()
|
||||
{
|
||||
$styleCfg = config('Style');
|
||||
$accent = (string) $this->request->getGet('accent');
|
||||
$menu = (string) $this->request->getGet('menu');
|
||||
$menuBg = (string) $this->request->getGet('menu_bg');
|
||||
$menuTx = (string) $this->request->getGet('menu_text');
|
||||
$menuMd = (string) $this->request->getGet('menu_mode'); // optional: 'light'|'dark'|'auto'
|
||||
|
||||
$updates = [];
|
||||
if ($accent && isset(($styleCfg->stylePalettes ?? [])[$accent])) {
|
||||
session()->set('style_color', $accent);
|
||||
$updates['style_color'] = $accent;
|
||||
}
|
||||
if ($menu && isset(($styleCfg->menuPalettes ?? [])[$menu])) {
|
||||
session()->set('menu_color', $menu);
|
||||
$updates['menu_color'] = $menu;
|
||||
$updates['menu_custom_bg'] = null;
|
||||
$updates['menu_custom_text'] = null;
|
||||
$updates['menu_custom_mode'] = null;
|
||||
} elseif (strtolower($menu) === 'custom' || ($menuBg !== '' || $menuTx !== '')) {
|
||||
// Allow custom menu colors via GET: menu=custom&menu_bg=#hex&menu_text=#hex&menu_mode=dark|light|auto
|
||||
$bg = $this->normalizeHex($menuBg);
|
||||
$tx = $this->normalizeHex($menuTx);
|
||||
if ($bg === '' && $tx === '') {
|
||||
// no valid values; ignore
|
||||
} else {
|
||||
if ($bg === '') $bg = '#0f172a';
|
||||
if ($tx === '') $tx = '#ffffff';
|
||||
$mode = in_array($menuMd, ['light','dark','auto'], true) ? $menuMd : 'auto';
|
||||
session()->set('menu_color', 'custom');
|
||||
session()->set('menu_custom_bg', $bg);
|
||||
session()->set('menu_custom_text', $tx);
|
||||
session()->set('menu_custom_mode', $mode);
|
||||
$updates['menu_color'] = 'custom';
|
||||
$updates['menu_custom_bg'] = $bg;
|
||||
$updates['menu_custom_text'] = $tx;
|
||||
$updates['menu_custom_mode'] = $mode;
|
||||
}
|
||||
}
|
||||
|
||||
$userId = (int) session()->get('user_id');
|
||||
if ($userId && !empty($updates)) {
|
||||
$prefsModel = new PreferencesModel();
|
||||
$existing = $prefsModel->where('user_id', $userId)->first();
|
||||
if ($existing) {
|
||||
$prefsModel->update($existing['id'], $updates);
|
||||
} else {
|
||||
$updates['user_id'] = $userId;
|
||||
$prefsModel->insert($updates);
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect back to referrer or home
|
||||
$back = (string) $this->request->getGet('back');
|
||||
if ($back) return redirect()->to($back);
|
||||
$ref = $this->request->getServer('HTTP_REFERER');
|
||||
if ($ref) return redirect()->to($ref);
|
||||
return redirect()->to(site_url('/'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a CSS hex color (#RGB or #RRGGBB). Returns '' on failure.
|
||||
*/
|
||||
private function normalizeHex(string $hex): string
|
||||
{
|
||||
$h = trim($hex);
|
||||
if ($h === '') return '';
|
||||
if ($h[0] !== '#') $h = '#' . $h;
|
||||
if (preg_match('/^#([0-9a-fA-F]{3})$/', $h, $m)) {
|
||||
// Expand #RGB to #RRGGBB
|
||||
$r = $m[1][0]; $g = $m[1][1]; $b = $m[1][2];
|
||||
return '#' . $r . $r . $g . $g . $b . $b;
|
||||
}
|
||||
if (preg_match('/^#([0-9a-fA-F]{6})$/', $h)) return strtoupper($h);
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose navbar mode based on background luminance: 'dark' for dark BG, else 'light'.
|
||||
*/
|
||||
private function autoModeForBg(string $hexBg): string
|
||||
{
|
||||
$hex = ltrim($hexBg, '#');
|
||||
if (strlen($hex) !== 6) return 'dark';
|
||||
$r = hexdec(substr($hex,0,2));
|
||||
$g = hexdec(substr($hex,2,2));
|
||||
$b = hexdec(substr($hex,4,2));
|
||||
// Relative luminance approximation
|
||||
$lum = (0.2126*$r + 0.7152*$g + 0.0722*$b) / 255.0;
|
||||
return ($lum < 0.5) ? 'dark' : 'light';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,874 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use CodeIgniter\Events\Events;
|
||||
use App\Controllers\BaseController;
|
||||
use App\Models\UserModel; // Assuming you have a UserModel
|
||||
use App\Models\PasswordResetModel; // Model for the password_resets table
|
||||
use CodeIgniter\I18n\Time;
|
||||
use App\Models\RoleModel;
|
||||
use App\Models\UserRoleModel;
|
||||
use App\Models\PasswordResetRequestModel;
|
||||
use App\Models\RolePermissionModel;
|
||||
use App\Models\IpAttemptModel;
|
||||
use CodeIgniter\Controller;
|
||||
use App\Controllers\View\EmailController;
|
||||
use App\Models\LoginActivityModel; // Make sure this import is present
|
||||
require_once APPPATH . 'Helpers/pbkdf2_helper.php';
|
||||
|
||||
|
||||
|
||||
class UserController extends BaseController
|
||||
{
|
||||
protected $userModel;
|
||||
protected $roleModel;
|
||||
protected $userRoleModel;
|
||||
protected $db;
|
||||
protected $passwordResetModel;
|
||||
protected $loginActivityModel;
|
||||
protected $resetRequestModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// Load the database service
|
||||
$this->db = \Config\Database::connect();
|
||||
// Check if the database connection is established
|
||||
if (!$this->db->connect()) {
|
||||
log_message('error', 'Database connection failed.');
|
||||
throw new \Exception('Database connection failed.');
|
||||
} else {
|
||||
log_message('info', 'Database connection successful.');
|
||||
}
|
||||
helper('auth');
|
||||
$this->userModel = new UserModel();
|
||||
$this->roleModel = new RoleModel();
|
||||
$this->userRoleModel = new UserRoleModel();
|
||||
$this->passwordResetModel = new PasswordResetModel();
|
||||
$this->loginActivityModel = new LoginActivityModel();
|
||||
$this->resetRequestModel = new PasswordResetRequestModel();
|
||||
}
|
||||
|
||||
// Method to show the home page
|
||||
public function home()
|
||||
{
|
||||
return view('/index');
|
||||
}
|
||||
|
||||
// Method to show the about page
|
||||
public function about()
|
||||
{
|
||||
return view('/about');
|
||||
}
|
||||
|
||||
// Method to show the classes page
|
||||
public function classes()
|
||||
{
|
||||
return view('/classes');
|
||||
}
|
||||
|
||||
// Method to show the contact page
|
||||
public function contact()
|
||||
{
|
||||
return view('/contact');
|
||||
}
|
||||
|
||||
public function userList()
|
||||
{
|
||||
helper('url');
|
||||
|
||||
return view('user/user_list', [
|
||||
'userListEndpoint' => site_url('api/users'),
|
||||
]);
|
||||
}
|
||||
|
||||
// Method to show the list of users
|
||||
public function index()
|
||||
{
|
||||
// Fetch users along with their assigned roles
|
||||
$builder = $this->db->table('users');
|
||||
$builder->select('users.id, users.firstname, users.lastname, users.email, user_roles.role_id, roles.name as role, users.status, users.updated_at');
|
||||
$builder->join('user_roles', 'users.id = user_roles.user_id', 'left');
|
||||
$builder->join('roles', 'user_roles.role_id = roles.id', 'left');
|
||||
|
||||
$sort = $this->request->getGet('sort');
|
||||
$order = $this->request->getGet('order') === 'desc' ? 'desc' : 'asc';
|
||||
|
||||
// Define allowed fields for sorting
|
||||
$allowedFields = ['id', 'firstname', 'lastname', 'email', 'role'];
|
||||
|
||||
if (in_array($sort, $allowedFields)) {
|
||||
$sort_column = $sort === 'role' ? 'roles.name' : 'users.' . $sort;
|
||||
$builder->orderBy($sort_column, $order);
|
||||
} else {
|
||||
$builder->orderBy('users.id', 'asc');
|
||||
}
|
||||
|
||||
$query = $builder->get();
|
||||
$users = $query->getResultArray();
|
||||
|
||||
// Fetch available roles
|
||||
$roleBuilder = $this->db->table('roles');
|
||||
$roleQuery = $roleBuilder->get();
|
||||
$roles = $roleQuery->getResultArray();
|
||||
|
||||
return view('user/user_list', [
|
||||
'users' => $users,
|
||||
'roles' => $roles,
|
||||
'sort' => $sort,
|
||||
'order' => $order,
|
||||
]);
|
||||
}
|
||||
|
||||
public function userListData()
|
||||
{
|
||||
return $this->response->setJSON([
|
||||
'users' => $this->buildUsersWithRoles(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function buildUsersWithRoles(): array
|
||||
{
|
||||
// Fetch all users
|
||||
$users = $this->userModel->findAll();
|
||||
if (!is_array($users)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Aggregate guardian types (primary/secondary) by user_id
|
||||
$gmap = [];
|
||||
try {
|
||||
$rows = $this->db->table('family_guardians')
|
||||
->select('user_id, MAX(is_primary) AS max_primary, COUNT(*) AS cnt', false)
|
||||
->groupBy('user_id')
|
||||
->get()->getResultArray();
|
||||
foreach ($rows as $r) {
|
||||
$uid = (int)($r['user_id'] ?? 0);
|
||||
if ($uid <= 0) continue;
|
||||
$maxp = (int)($r['max_primary'] ?? 0);
|
||||
$cnt = (int)($r['cnt'] ?? 0);
|
||||
if ($cnt > 0) {
|
||||
$gmap[$uid] = $maxp > 0 ? 'Primary' : 'Secondary';
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// ignore if table missing; leave map empty
|
||||
}
|
||||
|
||||
// Aggregate "second parent presence" from legacy parents table
|
||||
$secondByUserId = [];
|
||||
$secondByEmail = [];
|
||||
try {
|
||||
$parentFields = $this->db->getFieldNames('parents');
|
||||
$hasSecondUid = in_array('secondparent_user_id', $parentFields, true);
|
||||
$hasSecondEmail = in_array('secondparent_email', $parentFields, true);
|
||||
|
||||
if ($hasSecondUid) {
|
||||
// Map: user_id present in parents.secondparent_user_id
|
||||
$uidRows = $this->db->table('parents')
|
||||
->select('secondparent_user_id AS uid, COUNT(*) AS cnt', false)
|
||||
->where('secondparent_user_id !=', 0)
|
||||
->groupBy('secondparent_user_id')
|
||||
->get()->getResultArray();
|
||||
foreach ($uidRows as $r) {
|
||||
$u = (int)($r['uid'] ?? 0);
|
||||
if ($u > 0) {
|
||||
$secondByUserId[$u] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($hasSecondEmail) {
|
||||
// Map: email present in parents.secondparent_email
|
||||
$emailRows = $this->db->table('parents')
|
||||
->select('LOWER(TRIM(secondparent_email)) AS em, COUNT(*) AS cnt', false)
|
||||
->where('secondparent_email !=', '')
|
||||
->groupBy('secondparent_email')
|
||||
->get()->getResultArray();
|
||||
foreach ($emailRows as $r) {
|
||||
$em = trim(strtolower((string)($r['em'] ?? '')));
|
||||
if ($em !== '') {
|
||||
$secondByEmail[$em] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// ignore if legacy table or fields are missing
|
||||
}
|
||||
|
||||
// Prepare an array to store user data along with their roles
|
||||
$usersWithRoles = [];
|
||||
|
||||
// Loop through each user and get their roles
|
||||
foreach ($users as $user) {
|
||||
if (!is_array($user)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get the user's roles from the user_roles table
|
||||
$userRoles = $this->userRoleModel->where('user_id', $user['id'])->findAll() ?? [];
|
||||
|
||||
// Initialize arrays to hold role names/ids for this user
|
||||
$roleNames = [];
|
||||
$roleIds = [];
|
||||
|
||||
// Fetch role names from the roles table
|
||||
foreach ($userRoles as $userRole) {
|
||||
if (!is_array($userRole) || empty($userRole['role_id'])) {
|
||||
continue;
|
||||
}
|
||||
$role = $this->roleModel->find($userRole['role_id']);
|
||||
if ($role) {
|
||||
$roleIds[] = (int) $role['id'];
|
||||
$roleNames[] = $role['name'];
|
||||
}
|
||||
}
|
||||
|
||||
// Parent type if user is a guardian in any family
|
||||
$parentType = $gmap[(int)($user['id'] ?? 0)] ?? '';
|
||||
|
||||
// Second-from-parents flag: by secondparent_user_id or secondparent_email
|
||||
$isSecond = false;
|
||||
$uid = (int)($user['id'] ?? 0);
|
||||
if ($uid > 0 && !empty($secondByUserId[$uid])) {
|
||||
$isSecond = true;
|
||||
} else {
|
||||
$em = trim(strtolower((string)($user['email'] ?? '')));
|
||||
if ($em !== '' && !empty($secondByEmail[$em])) $isSecond = true;
|
||||
}
|
||||
|
||||
// Add the user data along with role names to the result array
|
||||
$usersWithRoles[] = [
|
||||
'user' => $user,
|
||||
'roles' => $roleNames,
|
||||
'role_ids' => $roleIds,
|
||||
'parent_type' => $parentType, // 'Primary', 'Secondary', or ''
|
||||
'second_from_parents' => $isSecond ? 'Yes' : '',
|
||||
];
|
||||
}
|
||||
|
||||
return $usersWithRoles;
|
||||
}
|
||||
|
||||
// Method to store a new user
|
||||
public function store()
|
||||
{
|
||||
// Validate input data
|
||||
$validation = \Config\Services::validation();
|
||||
$validation->setRules([
|
||||
'username' => 'required',
|
||||
'password' => 'required|min_length[6]',
|
||||
'lastname' => 'required',
|
||||
'firstname' => 'required',
|
||||
'cellphone' => 'required',
|
||||
'email' => 'required|valid_email',
|
||||
'address_street' => 'required',
|
||||
'city' => 'required',
|
||||
'state' => 'required',
|
||||
'zip' => 'required',
|
||||
'accept_school_policy' => 'required',
|
||||
'role_id' => 'required|integer'
|
||||
]);
|
||||
|
||||
if (!$this->validate($validation->getRules())) {
|
||||
$errors = $validation->getErrors();
|
||||
return redirect()->back()->withInput()->with('errors', $errors);
|
||||
}
|
||||
|
||||
// Prepare user data for insertion
|
||||
$userData = [
|
||||
'username' => $this->request->getPost('username'),
|
||||
'password' => pbkdf2_hash($this->request->getPost('password')),
|
||||
'lastname' => ucfirst($this->request->getPost('lastname')),
|
||||
'firstname' => ucfirst($this->request->getPost('firstname')),
|
||||
'cellphone' => $this->request->getPost('cellphone'),
|
||||
'email' => strtolower($this->request->getPost('email')),
|
||||
'address_street' => $this->request->getPost('address_street'),
|
||||
'city' => ucfirst($this->request->getPost('city')),
|
||||
'state' => $this->request->getPost('state'),
|
||||
'zip' => $this->request->getPost('zip'),
|
||||
'accept_school_policy' => $this->request->getPost('accept_school_policy'),
|
||||
];
|
||||
|
||||
// Insert user data into the database
|
||||
if ($this->userModel->save($userData) === false) {
|
||||
return redirect()->back()->withInput()->with('errors', $this->userModel->errors());
|
||||
}
|
||||
|
||||
// Retrieve the newly created user ID
|
||||
$userId = $this->userModel->getInsertID();
|
||||
|
||||
// Assign the role to the user in the user_roles table
|
||||
$roleData = [
|
||||
'user_id' => $userId,
|
||||
'role_id' => $this->request->getPost('role_id'),
|
||||
'created_at' => utc_now(),
|
||||
];
|
||||
|
||||
if ($this->userRoleModel->save($roleData) === false) {
|
||||
return redirect()->back()->withInput()->with('errors', $this->userRoleModel->errors());
|
||||
}
|
||||
|
||||
return redirect()->to('/user');
|
||||
}
|
||||
|
||||
// Method to show the form for editing an existing user
|
||||
public function edit($id)
|
||||
{
|
||||
$data['user'] = $this->userModel->find($id);
|
||||
$data['roles'] = $this->roleModel->findAll();
|
||||
$userRoles = $this->userRoleModel->where('user_id', $id)->findAll();
|
||||
$data['assignedRoles'] = array_column($userRoles, 'role_id');
|
||||
|
||||
return view('user/edit', $data);
|
||||
}
|
||||
|
||||
|
||||
// Method to delete an existing user
|
||||
public function delete($id)
|
||||
{
|
||||
$this->userModel->delete($id);
|
||||
|
||||
// Delete the user's roles from the user_roles table
|
||||
$this->userRoleModel->where('user_id', $id)->delete();
|
||||
|
||||
return redirect()->to('/user');
|
||||
}
|
||||
|
||||
|
||||
//This function is necessary to display the initial forgot password form where the user enters their email address.
|
||||
public function forgotPassword()
|
||||
{
|
||||
return view('/user/forgot_password');
|
||||
}
|
||||
|
||||
//This function is crucial for processing the forgot password request, generating a reset token, storing it in the database, and sending an email with the reset link.
|
||||
public function processForgotPassword()
|
||||
{
|
||||
log_message('debug', 'Entered processForgotPassword');
|
||||
|
||||
$ip = $this->request->getIPAddress();
|
||||
|
||||
$recentAttempts = $this->resetRequestModel
|
||||
->where('ip_address', $ip)
|
||||
->where('requested_at >=', Time::now()->subHours(24)->toDateTimeString())
|
||||
->countAllResults();
|
||||
|
||||
if ($recentAttempts >= 3) {
|
||||
log_message('warning', "IP {$ip} blocked due to too many reset attempts.");
|
||||
session()->setFlashdata('show_block_modal', true);
|
||||
return redirect()->back();
|
||||
}
|
||||
|
||||
// Log this attempt
|
||||
$this->resetRequestModel->insert([
|
||||
'ip_address' => $ip,
|
||||
'requested_at' => Time::now(),
|
||||
]);
|
||||
|
||||
$email = strtolower($this->request->getPost('email'));
|
||||
$user = $this->userModel->where('email', $email)->first();
|
||||
|
||||
// --- Handle unknown email ---
|
||||
if (!$user) {
|
||||
session()->setFlashdata('error', 'If this email is registered, you will receive a reset link.');
|
||||
log_message('info', "Password reset requested for non-existing user {$email}");
|
||||
return redirect()->back();
|
||||
}
|
||||
|
||||
// --- Handle unverified accounts ---
|
||||
if ((int) $user['is_verified'] === 0) {
|
||||
session()->setFlashdata('error', 'Please check your email and complete the account activation process before resetting your password.');
|
||||
log_message('info', "Password reset blocked for unverified user {$email}");
|
||||
return redirect()->back();
|
||||
}
|
||||
|
||||
// --- Verified user: continue with reset ---
|
||||
$token = bin2hex(random_bytes(48));
|
||||
$expires_at = Time::now()->addHours(1);
|
||||
|
||||
$this->passwordResetModel->insert([
|
||||
'email' => $email,
|
||||
'token' => $token,
|
||||
'created_at' => Time::now(),
|
||||
'expires_at' => $expires_at,
|
||||
]);
|
||||
|
||||
$this->sendResetEmail($email, $token);
|
||||
|
||||
session()->setFlashdata('success', 'A password reset link has been sent to your email.');
|
||||
log_message('info', "Password reset email sent to {$email}");
|
||||
|
||||
return redirect()->back();
|
||||
}
|
||||
|
||||
|
||||
public function blocked()
|
||||
{
|
||||
session()->setFlashdata('show_block_modal', true);
|
||||
return redirect()->back(); // Show modal on the previous page
|
||||
}
|
||||
|
||||
//This function is needed to display a confirmation page after the reset email has been sent, informing the user to check their email.
|
||||
public function passwordResetConfirmation()
|
||||
{
|
||||
$email = session()->getFlashdata('reset_email');
|
||||
|
||||
return view('user/password_reset_confirmation', ['email' => $email]);
|
||||
}
|
||||
|
||||
//This is a private helper function that handles sending the reset email. It’s separated out to keep the processForgotPassword function clean and focused.
|
||||
public function sendResetEmail($email, $token)
|
||||
{
|
||||
$resetLink = site_url('/reset_password?token=' . $token);
|
||||
|
||||
// Render email template with data
|
||||
$message = view('emails/reset_password', ['resetLink' => $resetLink]);
|
||||
|
||||
$emailController = new EmailController();
|
||||
$subject = 'Password Reset Request';
|
||||
|
||||
if ($emailController->sendEmail($email, $subject, $message)) {
|
||||
log_message('info', 'Password reset email successfully sent to ' . $email);
|
||||
} else {
|
||||
log_message('error', 'Failed to send password reset email to ' . $email);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//This function is necessary to display the form where the user can enter a new password, after clicking on the reset link in their email.
|
||||
public function resetPassword()
|
||||
{
|
||||
// Retrieve the token from the query string
|
||||
$token = $this->request->getGet('token');
|
||||
|
||||
if (!$token) {
|
||||
return redirect()->to('/')->with('error', 'Invalid password reset link.');
|
||||
}
|
||||
|
||||
// You may want to validate the token here
|
||||
$resetEntry = $this->passwordResetModel->where('token', $token)
|
||||
->where('expires_at >=', Time::now())
|
||||
->first();
|
||||
|
||||
if (!$resetEntry) {
|
||||
return redirect()->to('/')->with('error', 'Invalid or expired reset link.');
|
||||
}
|
||||
|
||||
// Load the reset password view with the token
|
||||
return view('user/reset_password', ['token' => $token]);
|
||||
}
|
||||
|
||||
//This function processes the new password submission, validating the token, updating the user's password, and cleaning up the reset entry.
|
||||
public function processResetPassword()
|
||||
{
|
||||
$token = $this->request->getPost('token');
|
||||
$newPassword = $this->request->getPost('password');
|
||||
$passConfirm = $this->request->getPost('pass_confirm');
|
||||
|
||||
// Validate the input
|
||||
if (!$this->validate([
|
||||
'password' => [
|
||||
'label' => 'Password',
|
||||
'rules' => 'required|min_length[8]|regex_match[/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@\-=\+*#$%&!?])[A-Za-z\d@\-=\+*#$%&!?]{8,}$/]',
|
||||
'errors' => [
|
||||
'required' => 'Password is required.',
|
||||
'min_length' => 'Password must be at least 8 characters.',
|
||||
'regex_match' => 'Password must contain at least one lowercase letter, one uppercase letter, one number, and one special character (@, -, =, +, *, #, $, %, &, !, ?).'
|
||||
]
|
||||
],
|
||||
'pass_confirm' => [
|
||||
'label' => 'Confirm Password',
|
||||
'rules' => 'required|matches[password]',
|
||||
'errors' => [
|
||||
'required' => 'Password confirmation is required.',
|
||||
'matches' => 'Passwords do not match.'
|
||||
]
|
||||
]
|
||||
])) {
|
||||
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
||||
}
|
||||
|
||||
// Find the password reset entry
|
||||
$resetEntry = $this->passwordResetModel->where('token', $token)
|
||||
->where('expires_at >=', Time::now())
|
||||
->first();
|
||||
|
||||
if (!$resetEntry) {
|
||||
return redirect()->to('/')->with('error', 'Invalid or expired reset link.');
|
||||
}
|
||||
|
||||
// Find the user by email
|
||||
$user = $this->userModel->where('email', $resetEntry['email'])->first();
|
||||
|
||||
if (!$user) {
|
||||
return redirect()->to('/')->with('error', 'User not found.');
|
||||
}
|
||||
|
||||
// 🛠 Hash the password using PBKDF2
|
||||
$hashedPassword = pbkdf2_hash($newPassword);
|
||||
|
||||
// Update the password in the users table and set the account status to 'Active'
|
||||
// Reset failed login attempts, suspension status, and last failed login time
|
||||
$this->userModel->update($user['id'], [
|
||||
'failed_attempts' => 0, // Reset failed attempts
|
||||
'is_suspended' => 0, // Unsuspend the account
|
||||
'last_failed_at' => null, // Reset the last failed login timestamp
|
||||
'password' => $hashedPassword,
|
||||
'status' => 'Active'
|
||||
]);
|
||||
|
||||
// Delete the used token from the password reset table
|
||||
$this->passwordResetModel->where('token', $token)->delete();
|
||||
|
||||
// Retrieve the user's IP address from the request
|
||||
$ipAddress = $this->request->getIPAddress();
|
||||
|
||||
// Delete the corresponding entry from the ip_attempts table, only if it exists
|
||||
$ipAttemptModel = new IpAttemptModel();
|
||||
$existingAttempt = $ipAttemptModel->where('ip_address', $ipAddress)->first();
|
||||
|
||||
if ($existingAttempt) {
|
||||
// IP address entry exists, so delete it
|
||||
$ipAttemptModel->where('ip_address', $ipAddress)->delete();
|
||||
}
|
||||
|
||||
// Redirect to a success page with a success message
|
||||
return view('user/password_set_success');
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function passwordSetSuccess()
|
||||
{
|
||||
return view('user/password_set_success');
|
||||
}
|
||||
|
||||
public function confirm($token)
|
||||
{
|
||||
log_message('info', 'Processing email confirmation with token: ' . $token);
|
||||
|
||||
|
||||
$user = $this->userModel->where('token', $token)->first();
|
||||
|
||||
if (!$user || $user['is_verified'] == 1) {
|
||||
return redirect()->to('/invalid_token');
|
||||
}
|
||||
|
||||
|
||||
// Mark the user as verified and generate an account ID
|
||||
$account_id = 'ACC' . str_pad($user['id'], 8, '0', STR_PAD_LEFT); // Example: ACC00000001
|
||||
$this->userModel->update($user['id'], ['is_verified' => 1, 'token' => null, 'account_id' => $account_id]);
|
||||
|
||||
log_message('info', 'User verified and account ID generated: ' . $account_id);
|
||||
|
||||
// Redirect to the set password page
|
||||
return redirect()->to('/set_password/' . $user['id']);
|
||||
}
|
||||
|
||||
public function setPassword($token)
|
||||
{
|
||||
//echo "Reached setPassword with token: " . esc($token);
|
||||
//echo "Token received: " . $token;
|
||||
$user = $this->userModel->where('token', $token)->first();
|
||||
|
||||
if (!$user || $user['is_verified'] == 1) {
|
||||
return redirect()->to('/invalid_token');
|
||||
}
|
||||
|
||||
return view('user/set_password', [
|
||||
'userId' => $user['id'],
|
||||
'token' => $token
|
||||
]);
|
||||
}
|
||||
|
||||
public function savePassword()
|
||||
{
|
||||
$validation = \Config\Services::validation();
|
||||
$validation->setRules([
|
||||
'password' => [
|
||||
'label' => 'Password',
|
||||
'rules' => 'required|min_length[8]|regex_match[/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@\-=\+*#$%&!?])[A-Za-z\d@\-=\+*#$%&!?]{8,}$/]',
|
||||
'errors' => [
|
||||
'required' => 'Password is required.',
|
||||
'min_length' => 'Password must be at least 8 characters long.',
|
||||
'regex_match' => 'Password must contain at least one lowercase letter, one uppercase letter, one number, and one special character (@, -, =, +, *, #, $, %, &, !, ?).'
|
||||
]
|
||||
],
|
||||
'password_confirm' => [
|
||||
'label' => 'Confirm Password',
|
||||
'rules' => 'required|matches[password]',
|
||||
'errors' => [
|
||||
'required' => 'Password confirmation is required.',
|
||||
'matches' => 'Passwords do not match.'
|
||||
]
|
||||
],
|
||||
'user_id' => 'required|integer',
|
||||
'token' => 'required'
|
||||
]);
|
||||
|
||||
if (!$this->validate($validation->getRules())) {
|
||||
return redirect()->back()->withInput()->with('errors', $validation->getErrors());
|
||||
}
|
||||
|
||||
$userId = $this->request->getPost('user_id');
|
||||
$token = $this->request->getPost('token');
|
||||
$password = $this->request->getPost('password');
|
||||
|
||||
$user = $this->userModel->where('id', $userId)->where('token', $token)->first();
|
||||
|
||||
log_message('debug', "Attempting to set password for user $userId with token $token");
|
||||
|
||||
if (!$user || $user['is_verified'] == 1) {
|
||||
return redirect()->to('/invalid_token');
|
||||
}
|
||||
|
||||
$hashedPassword = pbkdf2_hash($password); // Or use password_hash()
|
||||
|
||||
// Generate account_id only if it's not already set
|
||||
$account_id = $user['account_id'] ?? null;
|
||||
if (!$account_id) {
|
||||
$account_id = 'ACC' . str_pad($userId, 8, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
$this->userModel->update($userId, [
|
||||
'password' => $hashedPassword,
|
||||
'status' => 'Active',
|
||||
'is_verified' => 1,
|
||||
'token' => null,
|
||||
'account_id' => $account_id,
|
||||
]);
|
||||
|
||||
// Optional: trigger event
|
||||
$userData = $this->userModel->getUserInfoById($userId);
|
||||
if (!is_array($userData)) {
|
||||
$userData = (array) $userData;
|
||||
}
|
||||
$userData['id'] = $userId;
|
||||
\CodeIgniter\Events\Events::trigger('user_registered', $userData);
|
||||
|
||||
return redirect()->to('/user/password_set_success');
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function invalidToken()
|
||||
{
|
||||
return view('errors/invalid_token');
|
||||
}
|
||||
|
||||
|
||||
public function selectRole()
|
||||
{
|
||||
log_message('info', 'Processing setRole form submission.');
|
||||
|
||||
if (!session()->get('is_logged_in')) {
|
||||
log_message('error', 'User is not logged in.');
|
||||
return redirect()->to('/user/login');
|
||||
}
|
||||
|
||||
$roleKey = (string) $this->request->getPost('role');
|
||||
log_message('info', 'Role selected: ' . $roleKey);
|
||||
|
||||
$roleModel = new RoleModel();
|
||||
$route = $roleModel->getRouteByNameOrSlug($roleKey);
|
||||
|
||||
if ($route === null) {
|
||||
log_message('error', 'Invalid or inactive role selected: ' . $roleKey);
|
||||
return redirect()->back()->with('error', 'Invalid role selected.');
|
||||
}
|
||||
|
||||
$userId = (int) session()->get('user_id');
|
||||
log_message('info', 'User ID: ' . $userId);
|
||||
|
||||
// Persist the *exact* role name or slug—choose your convention.
|
||||
// If you want to store the canonical name, fetch the row and use $row['name'].
|
||||
$this->userModel->update($userId, ['role' => $roleKey]);
|
||||
log_message('info', 'Role updated in database.');
|
||||
|
||||
log_message('info', 'Redirecting to role dashboard: ' . $route);
|
||||
return redirect()->to($route);
|
||||
}
|
||||
|
||||
|
||||
public function welcomeBack()
|
||||
{
|
||||
if (!session()->get('is_logged_in')) {
|
||||
return redirect()->to('/user/login');
|
||||
}
|
||||
|
||||
return view('user/welcome_back');
|
||||
}
|
||||
|
||||
public function delete_role($roleId)
|
||||
{
|
||||
// Fetch the role to be deleted
|
||||
$role = $this->roleModel->find($roleId);
|
||||
if (!$role) {
|
||||
return redirect()->back()->with('error', 'Role not found.');
|
||||
}
|
||||
|
||||
// Fetch the "Guest" role
|
||||
$guestRole = $this->roleModel->where('name', 'Guest')->first();
|
||||
if (!$guestRole) {
|
||||
return redirect()->back()->with('error', 'Guest role not found.');
|
||||
}
|
||||
|
||||
// Update users with the deleted role to have the "Guest" role
|
||||
$this->userModel->where('role_id', $roleId)->set(['role_id' => $guestRole['id'], 'role' => 'Guest', 'status' => 'Inactive'])->update();
|
||||
|
||||
// Verify that users have been updated
|
||||
$updatedUsers = $this->userModel->where('role_id', $guestRole['id'])->findAll();
|
||||
log_message('debug', 'Updated users: ' . print_r($updatedUsers, true));
|
||||
|
||||
// Delete the role
|
||||
if ($this->roleModel->delete($roleId)) {
|
||||
return redirect()->to('/administrator/user_roles')->with('success', 'Role deleted successfully. Users with this role have been assigned the Guest role.');
|
||||
} else {
|
||||
return redirect()->back()->with('error', 'Failed to delete role.');
|
||||
}
|
||||
}
|
||||
|
||||
public function loginActivity()
|
||||
{
|
||||
helper('url');
|
||||
|
||||
$perPage = (int) ($this->request->getGet('per_page') ?? 25);
|
||||
|
||||
return view('user/login_activity', [
|
||||
'loginActivityEndpoint' => site_url('api/login-activity'),
|
||||
'defaultPerPage' => $perPage > 0 ? $perPage : 25,
|
||||
]);
|
||||
}
|
||||
|
||||
public function loginActivityData()
|
||||
{
|
||||
$perPage = (int) ($this->request->getGet('per_page') ?? 25);
|
||||
$page = (int) ($this->request->getGet('page') ?? 1);
|
||||
|
||||
return $this->response->setJSON($this->buildLoginActivityPayload($perPage, $page));
|
||||
}
|
||||
|
||||
// Method to update an existing user
|
||||
public function updateUser()
|
||||
{
|
||||
if (strtolower($this->request->getMethod()) !== 'post') {
|
||||
return redirect()->to(site_url('user/user_list'))->with('error', 'Invalid request.');
|
||||
}
|
||||
|
||||
$id = (int) $this->request->getPost('id');
|
||||
if (!$id) {
|
||||
return redirect()->to(site_url('user/user_list'))->with('error', 'User ID is missing.');
|
||||
}
|
||||
|
||||
$user = $this->userModel->find($id);
|
||||
if (!$user) {
|
||||
return redirect()->to(site_url('user/user_list'))->with('error', 'User not found.');
|
||||
}
|
||||
|
||||
// Helpers
|
||||
$toBool = fn($k) => $this->request->getPost($k) ? 1 : 0;
|
||||
$toDT = function ($key) {
|
||||
$v = trim((string)$this->request->getPost($key));
|
||||
if ($v === '') return null;
|
||||
// Expecting HTML datetime-local "YYYY-MM-DDTHH:MM"
|
||||
$ts = strtotime($v);
|
||||
return $ts ? date('Y-m-d H:i:s', $ts) : null;
|
||||
};
|
||||
|
||||
$data = [
|
||||
'id' => $id,
|
||||
'account_id' => trim((string)$this->request->getPost('account_id')),
|
||||
'lastname' => trim((string)$this->request->getPost('lastname')),
|
||||
'firstname' => trim((string)$this->request->getPost('firstname')),
|
||||
'gender' => trim((string)$this->request->getPost('gender')),
|
||||
'cellphone' => trim((string)$this->request->getPost('cellphone')),
|
||||
'email' => trim((string)$this->request->getPost('email')),
|
||||
'address_street' => trim((string)$this->request->getPost('address_street')),
|
||||
'apt' => trim((string)$this->request->getPost('apt')),
|
||||
'city' => trim((string)$this->request->getPost('city')),
|
||||
'state' => trim((string)$this->request->getPost('state')),
|
||||
'zip' => trim((string)$this->request->getPost('zip')),
|
||||
'accept_school_policy' => $toBool('accept_school_policy'),
|
||||
'user_type' => trim((string)$this->request->getPost('user_type')),
|
||||
'rfid_tag' => trim((string)$this->request->getPost('rfid_tag')),
|
||||
'school_id' => trim((string)$this->request->getPost('school_id')),
|
||||
'failed_attempts' => (string)$this->request->getPost('failed_attempts') === '' ? null : (int)$this->request->getPost('failed_attempts'),
|
||||
'last_failed_at' => $toDT('last_failed_at'),
|
||||
'semester' => trim((string)$this->request->getPost('semester')),
|
||||
'school_year' => trim((string)$this->request->getPost('school_year')),
|
||||
'status' => trim((string)$this->request->getPost('status')),
|
||||
'is_suspended' => $toBool('is_suspended'),
|
||||
'is_verified' => $toBool('is_verified'),
|
||||
'token' => trim((string)$this->request->getPost('token')),
|
||||
'updated_at' => $toDT('updated_at'),
|
||||
'created_at' => $toDT('created_at'),
|
||||
];
|
||||
|
||||
// Validation
|
||||
$rules = [
|
||||
'firstname' => 'required|min_length[2]|max_length[100]',
|
||||
'lastname' => 'required|min_length[2]|max_length[100]',
|
||||
'email' => "required|valid_email|is_unique[users.email,id,{$id}]",
|
||||
'state' => 'permit_empty|max_length[2]',
|
||||
'zip' => 'permit_empty|max_length[20]',
|
||||
'cellphone' => 'permit_empty|max_length[50]',
|
||||
'failed_attempts' => 'permit_empty|integer',
|
||||
'gender' => 'permit_empty|in_list[male,female,MALE,FEMALE]', // adjust as needed
|
||||
];
|
||||
|
||||
if (!$this->validate($rules)) {
|
||||
return redirect()->back()
|
||||
->with('error', implode(' ', $this->validator->getErrors()))
|
||||
->withInput();
|
||||
}
|
||||
|
||||
if (!$this->userModel->save($data)) {
|
||||
return redirect()->back()->with('error', 'Failed to update user.')->withInput();
|
||||
}
|
||||
|
||||
return redirect()->to(site_url('user/user_list'))->with('success', 'User updated successfully.');
|
||||
}
|
||||
|
||||
private function buildLoginActivityPayload(int $perPage, int $page): array
|
||||
{
|
||||
$perPage = max(1, min($perPage, 200));
|
||||
$page = max(1, $page);
|
||||
|
||||
$totalActivities = (int) $this->loginActivityModel->countAll();
|
||||
$activities = $this->loginActivityModel
|
||||
->orderBy('login_time', 'DESC')
|
||||
->paginate($perPage, 'loginActivity', $page) ?? [];
|
||||
|
||||
$pager = $this->loginActivityModel->pager;
|
||||
$currentPage = $pager ? (int) $pager->getCurrentPage('loginActivity') : $page;
|
||||
$pageCount = $pager ? (int) $pager->getPageCount('loginActivity') : (int) max(1, ceil($totalActivities / $perPage));
|
||||
|
||||
$normalized = array_map(static function ($activity) {
|
||||
if (!is_array($activity)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
'user_id' => $activity['user_id'] ?? null,
|
||||
'email' => $activity['email'] ?? null,
|
||||
'login_time' => $activity['login_time'] ?? null,
|
||||
'logout_time' => $activity['logout_time'] ?? null,
|
||||
'ip_address' => $activity['ip_address'] ?? null,
|
||||
'user_agent' => $activity['user_agent'] ?? null,
|
||||
];
|
||||
}, $activities);
|
||||
|
||||
return [
|
||||
'activities' => $normalized,
|
||||
'pagination' => [
|
||||
'total' => $totalActivities,
|
||||
'perPage' => $perPage,
|
||||
'currentPage' => $currentPage,
|
||||
'pageCount' => $pageCount,
|
||||
'hasNext' => $currentPage < $pageCount,
|
||||
'hasPrevious' => $currentPage > 1,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user