Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b2dd4bdf8 | |||
| b5e719382d | |||
| ac19226bf0 | |||
| 36e8ffe56d | |||
| 2d5b151234 | |||
| ede7fd947a | |||
| 485341875a | |||
| 2ad7dc1170 | |||
| 9e2f858343 | |||
| c70f6bdc6e | |||
| 6936a822c8 | |||
| 228824182a | |||
| 1d964c79de | |||
| 02fd6e4863 | |||
| 46769e8b27 | |||
| 88772c3ea0 | |||
| 8d644a2c85 | |||
| e81a1832ad | |||
| dbfd72c2f9 | |||
| 5b11e2d859 | |||
| 6ae90d757b | |||
| 849a4579e9 | |||
| 8d83cf84ab | |||
| e1fa1ded64 | |||
| 2eae9819fe | |||
| cf5314eaf6 | |||
| b689570052 | |||
| 332b0d1007 | |||
| 140be9922d | |||
| 361d0c0d3a | |||
| 0f8ad86b4f |
@@ -55,6 +55,7 @@ Thumbs.db
|
|||||||
/build/
|
/build/
|
||||||
/build.tar.gz
|
/build.tar.gz
|
||||||
/builds
|
/builds
|
||||||
|
/_chunks/
|
||||||
/phpunit.xml.cache
|
/phpunit.xml.cache
|
||||||
/.phpunit.result.cache
|
/.phpunit.result.cache
|
||||||
/writable/reports/*
|
/writable/reports/*
|
||||||
|
|||||||
Binary file not shown.
@@ -11,7 +11,7 @@ class DeleteInactiveUsers extends BaseCommand
|
|||||||
{
|
{
|
||||||
protected $group = 'Maintenance';
|
protected $group = 'Maintenance';
|
||||||
protected $name = 'users:delete-inactive-users';
|
protected $name = 'users:delete-inactive-users';
|
||||||
protected $description = 'Delete users that are inactive and created more than 15 minutes ago, along with their entries in the parents table and user_roles table if applicable.';
|
protected $description = 'Delete unverified inactive registrations created more than 15 minutes ago, along with their entries in the parents table and user_roles table if applicable.';
|
||||||
|
|
||||||
public function run(array $params)
|
public function run(array $params)
|
||||||
{
|
{
|
||||||
@@ -24,11 +24,12 @@ class DeleteInactiveUsers extends BaseCommand
|
|||||||
log_message('debug', 'Cutoff time for deletion: ' . $cutoffTime);
|
log_message('debug', 'Cutoff time for deletion: ' . $cutoffTime);
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────
|
||||||
// 1 Fetch inactive users older than 15 min
|
// 1 Fetch unfinished registrations older than 15 min
|
||||||
// ─────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────
|
||||||
$users = $db->table('users')
|
$users = $db->table('users')
|
||||||
->select('id, firstname, lastname, email, created_at')
|
->select('id, firstname, lastname, email, created_at')
|
||||||
->where('status', 'Inactive')
|
->where('status', 'Inactive')
|
||||||
|
->where('is_verified', 0)
|
||||||
->where('created_at <', $cutoffTime)
|
->where('created_at <', $cutoffTime)
|
||||||
->get()
|
->get()
|
||||||
->getResultArray();
|
->getResultArray();
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Commands;
|
||||||
|
|
||||||
|
use CodeIgniter\CLI\BaseCommand;
|
||||||
|
use CodeIgniter\CLI\CLI;
|
||||||
|
|
||||||
|
final class RepairSchoolYearCarryForward extends BaseCommand
|
||||||
|
{
|
||||||
|
protected $group = 'School Year';
|
||||||
|
protected $name = 'school-year:repair-carry-forward';
|
||||||
|
protected $description = 'Find or repair family balances omitted from an executed school-year closing batch.';
|
||||||
|
protected $usage = 'php spark school-year:repair-carry-forward --source-year-id=1 [--actor-id=1 --commit]';
|
||||||
|
protected $options = [
|
||||||
|
'--source-year-id' => 'Required source school-year record ID.',
|
||||||
|
'--actor-id' => 'Administrator user ID recorded in the repair audit log.',
|
||||||
|
'--commit' => 'Create missing closing items and target-year opening-balance invoices.',
|
||||||
|
'--json' => 'Print machine-readable output.',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function run(array $params)
|
||||||
|
{
|
||||||
|
$sourceYearId = (int) (CLI::getOption('source-year-id') ?? 0);
|
||||||
|
if ($sourceYearId <= 0) {
|
||||||
|
CLI::error('--source-year-id must be a positive integer.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$closing = service('schoolYearClosing');
|
||||||
|
$batch = $closing->latestBatch($sourceYearId);
|
||||||
|
if ($batch === null) {
|
||||||
|
throw new \RuntimeException('No closing batch exists for the source school year.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$preview = $closing->preview($sourceYearId, (int) ($batch['target_school_year_id'] ?? 0));
|
||||||
|
$db = \Config\Database::connect();
|
||||||
|
$existingRows = $db->table('school_year_closing_items')
|
||||||
|
->select('family_id')
|
||||||
|
->where('closing_batch_id', (int) $batch['id'])
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
$existing = array_fill_keys(array_map(
|
||||||
|
static fn (array $row): int => (int) ($row['family_id'] ?? 0),
|
||||||
|
$existingRows
|
||||||
|
), true);
|
||||||
|
$missing = array_values(array_filter(
|
||||||
|
$preview['carry_forward'] ?? [],
|
||||||
|
static fn (array $row): bool => ! isset($existing[(int) ($row['family_id'] ?? 0)])
|
||||||
|
));
|
||||||
|
|
||||||
|
$result = [
|
||||||
|
'mode' => CLI::getOption('commit') !== null ? 'commit' : 'dry-run',
|
||||||
|
'closing_batch_id' => (int) $batch['id'],
|
||||||
|
'missing_count' => count($missing),
|
||||||
|
'missing_amount' => round(array_sum(array_map(
|
||||||
|
static fn (array $row): float => (float) ($row['carry_forward_amount'] ?? 0),
|
||||||
|
$missing
|
||||||
|
)), 2),
|
||||||
|
'missing_items' => array_map(static fn (array $row): array => [
|
||||||
|
'family_id' => (int) ($row['family_id'] ?? 0),
|
||||||
|
'parent' => (string) ($row['parent'] ?? ''),
|
||||||
|
'carry_forward_amount' => round((float) ($row['carry_forward_amount'] ?? 0), 2),
|
||||||
|
], $missing),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (CLI::getOption('commit') !== null) {
|
||||||
|
$actorId = (int) (CLI::getOption('actor-id') ?? 0);
|
||||||
|
if ($actorId <= 0) {
|
||||||
|
throw new \InvalidArgumentException('--actor-id must be a positive administrator user ID when using --commit.');
|
||||||
|
}
|
||||||
|
$result['repair'] = $closing->repairMissingCarryForward($sourceYearId, $actorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (CLI::getOption('json') !== null) {
|
||||||
|
CLI::write(json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
CLI::write(sprintf(
|
||||||
|
'%s: %d missing family balance(s), $%0.2f total.',
|
||||||
|
strtoupper((string) $result['mode']),
|
||||||
|
(int) $result['missing_count'],
|
||||||
|
(float) $result['missing_amount']
|
||||||
|
));
|
||||||
|
foreach ($result['missing_items'] as $item) {
|
||||||
|
CLI::write(sprintf(
|
||||||
|
'Family #%d (%s): $%0.2f',
|
||||||
|
(int) $item['family_id'],
|
||||||
|
(string) $item['parent'],
|
||||||
|
(float) $item['carry_forward_amount']
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if (isset($result['repair'])) {
|
||||||
|
CLI::write('Repair completed and audited.', 'green');
|
||||||
|
} elseif ($missing !== []) {
|
||||||
|
CLI::write('Run again with --actor-id=<admin id> --commit to apply the repair.', 'yellow');
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
CLI::error($e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+75
-6
@@ -222,6 +222,30 @@ $routes->post('/user/store', 'View\UserController::store');
|
|||||||
$routes->get('/thankyou', 'View\UserController::thankyou'); // Thank you page route
|
$routes->get('/thankyou', 'View\UserController::thankyou'); // Thank you page route
|
||||||
$routes->get('/', 'View\UserController::home'); // Home page route
|
$routes->get('/', 'View\UserController::home'); // Home page route
|
||||||
$routes->get('/about', 'View\UserController::about'); // About page route
|
$routes->get('/about', 'View\UserController::about'); // About page route
|
||||||
|
$routes->get('/careers', 'View\JobPostingController::publicIndex'); // Careers page route
|
||||||
|
$routes->get('/careers/application-received', 'View\JobPostingController::applicationReceived');
|
||||||
|
$routes->get('/careers/(:segment)/details', 'View\JobPostingController::trackDetailsClick/$1');
|
||||||
|
$routes->get('/careers/(:segment)', 'View\JobPostingController::show/$1');
|
||||||
|
$routes->get('/careers/(:segment)/apply', 'View\JobPostingController::apply/$1');
|
||||||
|
$routes->post('/careers/(:segment)/apply', 'View\JobPostingController::submitApplication/$1');
|
||||||
|
$routes->group('administrator/job-postings', ['filter' => 'auth:administrator|administrative staff|principal'], static function ($routes) {
|
||||||
|
$routes->get('', 'View\JobPostingController::positions');
|
||||||
|
$routes->get('templates', 'View\JobPostingController::templates');
|
||||||
|
$routes->get('templates/new', 'View\JobPostingController::newTemplate');
|
||||||
|
$routes->post('templates', 'View\JobPostingController::createTemplate');
|
||||||
|
$routes->get('templates/(:segment)/edit', 'View\JobPostingController::editTemplate/$1');
|
||||||
|
$routes->post('templates/(:segment)', 'View\JobPostingController::updateTemplate/$1');
|
||||||
|
$routes->post('templates/(:segment)/archive', 'View\JobPostingController::archiveTemplate/$1');
|
||||||
|
$routes->post('templates/versions/(:segment)/restore', 'View\JobPostingController::restoreTemplateVersion/$1');
|
||||||
|
$routes->get('positions', 'View\JobPostingController::positions');
|
||||||
|
$routes->get('positions/new', 'View\JobPostingController::newPosition');
|
||||||
|
$routes->post('positions', 'View\JobPostingController::createPosition');
|
||||||
|
$routes->get('positions/(:segment)/edit', 'View\JobPostingController::editPosition/$1');
|
||||||
|
$routes->post('positions/(:segment)', 'View\JobPostingController::updatePosition/$1');
|
||||||
|
$routes->get('applications', 'View\JobPostingController::applications');
|
||||||
|
$routes->post('applications/(:segment)', 'View\JobPostingController::updateApplication/$1');
|
||||||
|
$routes->get('applications/(:segment)/resume', 'View\JobPostingController::resume/$1');
|
||||||
|
});
|
||||||
$routes->get('/classes', 'View\UserController::classes'); // Classes page route
|
$routes->get('/classes', 'View\UserController::classes'); // Classes page route
|
||||||
$routes->get('/contact', 'View\UserController::contact'); // Contact Us page route
|
$routes->get('/contact', 'View\UserController::contact'); // Contact Us page route
|
||||||
$routes->post('/user/login', 'AuthController::login');
|
$routes->post('/user/login', 'AuthController::login');
|
||||||
@@ -917,11 +941,12 @@ $routes->post('/parent/edit_emergency_contact/(:num)', 'View\ParentController::e
|
|||||||
|
|
||||||
|
|
||||||
/*management navigation bar*/
|
/*management navigation bar*/
|
||||||
$routes->get('nav-builder', 'View\NavBuilderController::index', ['filter' => 'auth']);
|
$routes->get('nav-builder', 'View\NavBuilderController::index', ['filter' => 'auth:administrator']);
|
||||||
$routes->get('api/nav-builder', 'View\NavBuilderController::data', ['filter' => 'auth']);
|
$routes->get('api/nav-builder', 'View\NavBuilderController::data', ['filter' => 'auth:administrator']);
|
||||||
$routes->post('nav-builder/save', 'View\NavBuilderController::save', ['filter' => 'auth']);
|
$routes->post('nav-builder/save', 'View\NavBuilderController::save', ['filter' => 'auth:administrator']);
|
||||||
$routes->get('nav-builder/delete/(:num)', 'View\NavBuilderController::delete/$1', ['filter' => 'auth']);
|
$routes->get('nav-builder/delete/(:num)', 'View\NavBuilderController::delete/$1', ['filter' => 'auth:administrator']);
|
||||||
$routes->post('nav-builder/reorder', 'View\NavBuilderController::reorder', ['filter' => 'auth']);
|
$routes->post('nav-builder/reorder', 'View\NavBuilderController::reorder', ['filter' => 'auth:administrator']);
|
||||||
|
$routes->post('nav-builder/role-access', 'View\NavBuilderController::roleAccess', ['filter' => 'auth:administrator']);
|
||||||
|
|
||||||
|
|
||||||
// Teacher book distribution is the only inventory path teachers may access directly.
|
// Teacher book distribution is the only inventory path teachers may access directly.
|
||||||
@@ -1049,10 +1074,14 @@ $routes->group('family', ['filter' => 'auth:admin|principal'], static function (
|
|||||||
$routes->get('', 'View\FamilyAdminController::index');
|
$routes->get('', 'View\FamilyAdminController::index');
|
||||||
$routes->get('index', 'View\FamilyAdminController::index');
|
$routes->get('index', 'View\FamilyAdminController::index');
|
||||||
$routes->get('search', 'View\FamilyAdminController::search');
|
$routes->get('search', 'View\FamilyAdminController::search');
|
||||||
$routes->get('card', 'View\FamilyAdminController::card');
|
|
||||||
$routes->get('compose-email', 'View\FamilyAdminController::composeEmail');
|
$routes->get('compose-email', 'View\FamilyAdminController::composeEmail');
|
||||||
$routes->post('compose-email/send', 'View\FamilyAdminController::sendComposeEmail');
|
$routes->post('compose-email/send', 'View\FamilyAdminController::sendComposeEmail');
|
||||||
});
|
});
|
||||||
|
// Teachers and TAs may inspect family/student details from their student lists.
|
||||||
|
// Financial data remains protected inside FamilyAdminController::card().
|
||||||
|
$routes->get('family/card', 'View\FamilyAdminController::card', [
|
||||||
|
'filter' => 'auth:admin|administrator|administrative staff|principal|teacher|teacher_assistant',
|
||||||
|
]);
|
||||||
// Convenience alias
|
// Convenience alias
|
||||||
$routes->get('family', 'View\FamilyAdminController::index', ['filter' => 'auth:admin|principal']);
|
$routes->get('family', 'View\FamilyAdminController::index', ['filter' => 'auth:admin|principal']);
|
||||||
//////////////////////////////////////////////////////////
|
//////////////////////////////////////////////////////////
|
||||||
@@ -1267,6 +1296,7 @@ $routes->get('/landing_page/admin_dashboard', 'View\LandingPageController::admin
|
|||||||
$routes->get('/teacher_dashboard', 'View\LandingPageController::teacher', ['filter' => 'auth:teacher_dashboard,read']);
|
$routes->get('/teacher_dashboard', 'View\LandingPageController::teacher', ['filter' => 'auth:teacher_dashboard,read']);
|
||||||
$routes->get('/landing_page/student_dashboard', 'View\LandingPageController::student', ['filter' => 'auth:student_dashboard,read']);
|
$routes->get('/landing_page/student_dashboard', 'View\LandingPageController::student', ['filter' => 'auth:student_dashboard,read']);
|
||||||
$routes->get('/parent_dashboard', 'View\LandingPageController::parentDashboard', ['filter' => 'auth:parent_dashboard,read']);
|
$routes->get('/parent_dashboard', 'View\LandingPageController::parentDashboard', ['filter' => 'auth:parent_dashboard,read']);
|
||||||
|
$routes->post('/parent_dashboard/job-openings-popup', 'View\LandingPageController::hideJobOpeningsPopup', ['filter' => 'auth:parent_dashboard,read']);
|
||||||
$routes->get('/landing_page/guest_dashboard', 'View\LandingPageController::guest', ['filter' => 'auth:guest_dashboard,read']);
|
$routes->get('/landing_page/guest_dashboard', 'View\LandingPageController::guest', ['filter' => 'auth:guest_dashboard,read']);
|
||||||
$routes->get('/dashboard', 'View\LandingPageController::index');
|
$routes->get('/dashboard', 'View\LandingPageController::index');
|
||||||
$routes->get('/access_denied', 'ErrorController::accessDenied');
|
$routes->get('/access_denied', 'ErrorController::accessDenied');
|
||||||
@@ -1274,6 +1304,45 @@ $routes->get('/access_denied', 'ErrorController::accessDenied');
|
|||||||
|
|
||||||
$routes->post('administrator/students/update', 'View\StudentController::editStudentData', ['filter' => 'auth:edit_student,update']);
|
$routes->post('administrator/students/update', 'View\StudentController::editStudentData', ['filter' => 'auth:edit_student,update']);
|
||||||
|
|
||||||
|
// Shared assessment management (all administrative roles).
|
||||||
|
$assessmentAdminFilter = 'auth:admin_category';
|
||||||
|
$routes->group('administrator/assessments', ['filter' => $assessmentAdminFilter], static function ($routes) {
|
||||||
|
$routes->get('', 'View\AssessmentController::forms');
|
||||||
|
$routes->get('pools', 'View\AssessmentController::pools');
|
||||||
|
$routes->get('pools/(:num)', 'View\AssessmentController::pool/$1');
|
||||||
|
$routes->post('pools/(:num)/questions', 'View\AssessmentController::storeQuestion/$1');
|
||||||
|
$routes->post('questions/(:num)/update', 'View\AssessmentController::updateQuestion/$1');
|
||||||
|
$routes->post('questions/(:num)/delete', 'View\AssessmentController::deleteQuestion/$1');
|
||||||
|
$routes->post('questions/(:num)/move', 'View\AssessmentController::moveQuestion/$1');
|
||||||
|
$routes->get('forms', 'View\AssessmentController::forms');
|
||||||
|
$routes->get('forms/new', 'View\AssessmentController::newForm');
|
||||||
|
$routes->post('forms', 'View\AssessmentController::storeForm');
|
||||||
|
$routes->get('forms/(:num)/edit', 'View\AssessmentController::editForm/$1');
|
||||||
|
$routes->post('forms/(:num)', 'View\AssessmentController::updateForm/$1');
|
||||||
|
$routes->post('forms/(:num)/publish', 'View\AssessmentController::publishForm/$1');
|
||||||
|
$routes->get('forms/(:num)/preview', 'View\AssessmentController::preview/$1');
|
||||||
|
$routes->get('students', 'View\AssessmentController::newStudentRoster');
|
||||||
|
$routes->get('students/(:num)', 'View\AssessmentController::studentAssignments/$1');
|
||||||
|
$routes->post('students/(:num)/start', 'View\AssessmentController::startInterview/$1');
|
||||||
|
$routes->post('students/(:num)/assign', 'View\AssessmentController::assign/$1');
|
||||||
|
$routes->post('attempts/(:num)/progress', 'View\AssessmentController::saveAdminProgress/$1');
|
||||||
|
$routes->post('attempts/(:num)/note', 'View\AssessmentController::saveAdminNote/$1');
|
||||||
|
$routes->post('attempts/(:num)/complete', 'View\AssessmentController::completeAdminInterview/$1');
|
||||||
|
$routes->get('review/(:num)', 'View\AssessmentController::grade/$1');
|
||||||
|
$routes->post('review/(:num)', 'View\AssessmentController::saveGrade/$1');
|
||||||
|
$routes->get('grade/(:num)', 'View\AssessmentController::grade/$1');
|
||||||
|
$routes->post('grade/(:num)', 'View\AssessmentController::saveGrade/$1');
|
||||||
|
$routes->get('results/(:num)', 'View\AssessmentController::results/$1');
|
||||||
|
});
|
||||||
|
|
||||||
|
$routes->group('student/assessments', ['filter' => 'auth:student|parent'], static function ($routes) {
|
||||||
|
$routes->get('', 'View\AssessmentController::myAssessments');
|
||||||
|
$routes->get('(:num)', 'View\AssessmentController::take/$1');
|
||||||
|
$routes->post('(:num)/progress', 'View\AssessmentController::saveProgress/$1');
|
||||||
|
$routes->post('(:num)/submit', 'View\AssessmentController::submit/$1');
|
||||||
|
$routes->get('(:num)/results', 'View\AssessmentController::myResults/$1');
|
||||||
|
});
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* --------------------------------------------------------------------
|
* --------------------------------------------------------------------
|
||||||
* routes for View\SupportController
|
* routes for View\SupportController
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Controllers;
|
|||||||
use App\Models\LoginActivityModel;
|
use App\Models\LoginActivityModel;
|
||||||
use App\Models\UserModel;
|
use App\Models\UserModel;
|
||||||
use App\Models\UserRoleModel;
|
use App\Models\UserRoleModel;
|
||||||
|
use App\Models\UserAccessProfileModel;
|
||||||
use CodeIgniter\Events\Events;
|
use CodeIgniter\Events\Events;
|
||||||
use App\Models\IpAttemptModel;
|
use App\Models\IpAttemptModel;
|
||||||
use App\Models\PasswordResetModel;
|
use App\Models\PasswordResetModel;
|
||||||
@@ -212,6 +213,7 @@ class AuthController extends BaseController
|
|||||||
|
|
||||||
// Fetch roles
|
// Fetch roles
|
||||||
$roleNames = $this->getUserRoleNames((int) $user['id']);
|
$roleNames = $this->getUserRoleNames((int) $user['id']);
|
||||||
|
$accessProfile = $this->accessProfileForUser((int) $user['id'], $roleNames);
|
||||||
|
|
||||||
// Build roles map (object with keys per example)
|
// Build roles map (object with keys per example)
|
||||||
$rolesMap = [];
|
$rolesMap = [];
|
||||||
@@ -248,6 +250,10 @@ class AuthController extends BaseController
|
|||||||
'id' => (int) $user['id'],
|
'id' => (int) $user['id'],
|
||||||
'name' => $payload['name'],
|
'name' => $payload['name'],
|
||||||
'roles' => (object) $rolesMap,
|
'roles' => (object) $rolesMap,
|
||||||
|
'primary_category' => $accessProfile['primary_category'],
|
||||||
|
'is_admin' => $accessProfile['is_admin'],
|
||||||
|
'is_teacher' => $accessProfile['is_teacher'],
|
||||||
|
'is_parent' => $accessProfile['is_parent'],
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -274,6 +280,10 @@ class AuthController extends BaseController
|
|||||||
'type' => session()->get('user_type'),
|
'type' => session()->get('user_type'),
|
||||||
'roles' => $roles,
|
'roles' => $roles,
|
||||||
'role' => $activeRole,
|
'role' => $activeRole,
|
||||||
|
'primary_category' => session()->get('primary_category'),
|
||||||
|
'is_admin' => (bool) session()->get('is_admin'),
|
||||||
|
'is_teacher' => (bool) session()->get('is_teacher'),
|
||||||
|
'is_parent' => (bool) session()->get('is_parent'),
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -371,6 +381,7 @@ class AuthController extends BaseController
|
|||||||
'iat' => $now,
|
'iat' => $now,
|
||||||
'exp' => $exp,
|
'exp' => $exp,
|
||||||
];
|
];
|
||||||
|
$accessProfile = $this->accessProfileForUser((int) $userId, $payload['roles']);
|
||||||
|
|
||||||
$secret = require_env('JWT_SECRET');
|
$secret = require_env('JWT_SECRET');
|
||||||
$token = jwt_encode($payload, $secret, 'HS256');
|
$token = jwt_encode($payload, $secret, 'HS256');
|
||||||
@@ -384,6 +395,10 @@ class AuthController extends BaseController
|
|||||||
'name' => $payload['name'],
|
'name' => $payload['name'],
|
||||||
'email' => $userData['email'],
|
'email' => $userData['email'],
|
||||||
'roles' => $payload['roles'],
|
'roles' => $payload['roles'],
|
||||||
|
'primary_category' => $accessProfile['primary_category'],
|
||||||
|
'is_admin' => $accessProfile['is_admin'],
|
||||||
|
'is_teacher' => $accessProfile['is_teacher'],
|
||||||
|
'is_parent' => $accessProfile['is_parent'],
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
@@ -477,11 +492,17 @@ class AuthController extends BaseController
|
|||||||
protected function getUserRoleNames(int $userId): array
|
protected function getUserRoleNames(int $userId): array
|
||||||
{
|
{
|
||||||
$userRoleModel = new UserRoleModel();
|
$userRoleModel = new UserRoleModel();
|
||||||
$rolesRows = $userRoleModel->select('roles.name')
|
$db = \Config\Database::connect();
|
||||||
|
$builder = $userRoleModel->select('roles.name')
|
||||||
->join('roles', 'roles.id = user_roles.role_id')
|
->join('roles', 'roles.id = user_roles.role_id')
|
||||||
->where('user_roles.user_id', $userId)
|
->where('user_roles.user_id', $userId)
|
||||||
->get()
|
->where('COALESCE(roles.is_active, 1) = 1', null, false);
|
||||||
->getResultArray();
|
|
||||||
|
if ($db->fieldExists('deleted_at', 'user_roles')) {
|
||||||
|
$builder->where('user_roles.deleted_at', null);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rolesRows = $builder->get()->getResultArray();
|
||||||
|
|
||||||
return array_column($rolesRows, 'name');
|
return array_column($rolesRows, 'name');
|
||||||
}
|
}
|
||||||
@@ -565,11 +586,17 @@ class AuthController extends BaseController
|
|||||||
private function loginUser($user, ?string $redirectTo = null)
|
private function loginUser($user, ?string $redirectTo = null)
|
||||||
{
|
{
|
||||||
$userRoleModel = new UserRoleModel();
|
$userRoleModel = new UserRoleModel();
|
||||||
$roles = $userRoleModel->select('roles.name')
|
$db = \Config\Database::connect();
|
||||||
|
$rolesBuilder = $userRoleModel->select('roles.name')
|
||||||
->join('roles', 'roles.id = user_roles.role_id')
|
->join('roles', 'roles.id = user_roles.role_id')
|
||||||
->where('user_roles.user_id', $user['id'])
|
->where('user_roles.user_id', $user['id'])
|
||||||
->get()
|
->where('COALESCE(roles.is_active, 1) = 1', null, false);
|
||||||
->getResultArray();
|
|
||||||
|
if ($db->fieldExists('deleted_at', 'user_roles')) {
|
||||||
|
$rolesBuilder->where('user_roles.deleted_at', null);
|
||||||
|
}
|
||||||
|
|
||||||
|
$roles = $rolesBuilder->get()->getResultArray();
|
||||||
|
|
||||||
if (empty($roles)) {
|
if (empty($roles)) {
|
||||||
log_message('error', 'No roles found for user ID: ' . $user['id']);
|
log_message('error', 'No roles found for user ID: ' . $user['id']);
|
||||||
@@ -577,6 +604,7 @@ class AuthController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$roleNames = array_column($roles, 'name');
|
$roleNames = array_column($roles, 'name');
|
||||||
|
$accessProfile = $this->accessProfileForUser((int) $user['id'], $roleNames);
|
||||||
|
|
||||||
session()->regenerate(true);
|
session()->regenerate(true);
|
||||||
session()->set([
|
session()->set([
|
||||||
@@ -588,6 +616,10 @@ class AuthController extends BaseController
|
|||||||
'login_time' => time(),
|
'login_time' => time(),
|
||||||
'last_activity' => time(),
|
'last_activity' => time(),
|
||||||
'roles' => $roleNames,
|
'roles' => $roleNames,
|
||||||
|
'primary_category' => $accessProfile['primary_category'],
|
||||||
|
'is_admin' => $accessProfile['is_admin'],
|
||||||
|
'is_teacher' => $accessProfile['is_teacher'],
|
||||||
|
'is_parent' => $accessProfile['is_parent'],
|
||||||
'semester' => $this->semester,
|
'semester' => $this->semester,
|
||||||
'school_year' => $this->schoolYear,
|
'school_year' => $this->schoolYear,
|
||||||
]);
|
]);
|
||||||
@@ -612,6 +644,32 @@ class AuthController extends BaseController
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function accessProfileForUser(int $userId, array $roleNames): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$profile = model(UserAccessProfileModel::class)->getForUser($userId);
|
||||||
|
if ($profile !== null) {
|
||||||
|
return [
|
||||||
|
'primary_category' => (string) ($profile['primary_category'] ?? UserAccessProfileModel::CATEGORY_GUEST),
|
||||||
|
'is_admin' => (bool) ($profile['is_admin'] ?? false),
|
||||||
|
'is_teacher' => (bool) ($profile['is_teacher'] ?? false),
|
||||||
|
'is_parent' => (bool) ($profile['is_parent'] ?? false),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
log_message('warning', 'Unable to load user access profile: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
$flags = UserAccessProfileModel::flagsForRoles($roleNames);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'primary_category' => UserAccessProfileModel::primaryCategory($flags),
|
||||||
|
'is_admin' => (bool) $flags['is_admin'],
|
||||||
|
'is_teacher' => (bool) $flags['is_teacher'],
|
||||||
|
'is_parent' => (bool) $flags['is_parent'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
private function applyStylePreferences(int $userId): void
|
private function applyStylePreferences(int $userId): void
|
||||||
{
|
{
|
||||||
if (!$userId) {
|
if (!$userId) {
|
||||||
|
|||||||
@@ -619,9 +619,12 @@ class AdministratorController extends BaseController
|
|||||||
{
|
{
|
||||||
$selectedYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
|
$selectedYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
|
||||||
|
|
||||||
|
$data = service('administratorDirectory')->studentProfiles($selectedYear);
|
||||||
|
$data['assessmentByStudent'] = $this->assessmentActionsForStudents(array_column($data['students'] ?? [], 'id'), $selectedYear);
|
||||||
|
|
||||||
return view(
|
return view(
|
||||||
'administrator/student_profiles',
|
'administrator/student_profiles',
|
||||||
service('administratorDirectory')->studentProfiles($selectedYear)
|
$data
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -676,10 +679,9 @@ class AdministratorController extends BaseController
|
|||||||
|
|
||||||
public function showNewStudents()
|
public function showNewStudents()
|
||||||
{
|
{
|
||||||
return view(
|
$data = service('enrollmentWithdrawal')->newStudents((string) $this->schoolYear);
|
||||||
'enroll_withdraw/new-students',
|
$data['assessmentByStudent'] = $this->assessmentActionsForStudents(array_column($data['new_students'] ?? [], 'id'), (string) $this->schoolYear);
|
||||||
service('enrollmentWithdrawal')->newStudents((string) $this->schoolYear)
|
return view('enroll_withdraw/new-students', $data);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function adminEnrollmentWithdrawalHandler()
|
public function adminEnrollmentWithdrawalHandler()
|
||||||
@@ -694,4 +696,31 @@ class AdministratorController extends BaseController
|
|||||||
return redirect()->to(base_url('enroll_withdraw/enrollment_withdrawal'))
|
return redirect()->to(base_url('enroll_withdraw/enrollment_withdrawal'))
|
||||||
->with(!empty($result['ok']) ? 'success' : 'error', (string) ($result['message'] ?? ''));
|
->with(!empty($result['ok']) ? 'success' : 'error', (string) ($result['message'] ?? ''));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function assessmentActionsForStudents(array $studentIds, string $schoolYear): array
|
||||||
|
{
|
||||||
|
$studentIds = array_values(array_unique(array_filter(array_map('intval', $studentIds))));
|
||||||
|
$db = db_connect();
|
||||||
|
if ($studentIds === [] || ! $db->tableExists('student_assessments')) return [];
|
||||||
|
|
||||||
|
$rows = $db->table('student_assessments sa')
|
||||||
|
->select('sa.id, sa.student_id, sa.form_id, sa.status, sa.assigned_at, f.name AS form_name')
|
||||||
|
->join('assessment_forms f', 'f.id = sa.form_id')
|
||||||
|
->whereIn('sa.student_id', $studentIds)
|
||||||
|
->where('f.school_year', $schoolYear)
|
||||||
|
->orderBy('sa.assigned_at', 'DESC')->orderBy('sa.id', 'DESC')->get()->getResultArray();
|
||||||
|
$map = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$studentId = (int) $row['student_id'];
|
||||||
|
$priority = match ((string) $row['status']) {
|
||||||
|
'completed' => 3, 'not_started', 'in_progress' => 2, 'graded' => 1, default => 0,
|
||||||
|
};
|
||||||
|
$current = $map[$studentId]['status'] ?? null;
|
||||||
|
$currentPriority = match ($current) {
|
||||||
|
'completed' => 3, 'not_started', 'in_progress' => 2, 'graded' => 1, default => -1,
|
||||||
|
};
|
||||||
|
if ($priority > $currentPriority) $map[$studentId] = $row;
|
||||||
|
}
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,681 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controllers\View;
|
||||||
|
|
||||||
|
use App\Controllers\BaseController;
|
||||||
|
use App\Models\AssessmentFormModel;
|
||||||
|
use App\Models\AssessmentQuestionModel;
|
||||||
|
use App\Models\QuestionPoolModel;
|
||||||
|
use App\Models\StudentAssessmentModel;
|
||||||
|
use App\Models\StudentModel;
|
||||||
|
use CodeIgniter\Exceptions\PageNotFoundException;
|
||||||
|
|
||||||
|
class AssessmentController extends BaseController
|
||||||
|
{
|
||||||
|
private $db;
|
||||||
|
private QuestionPoolModel $poolModel;
|
||||||
|
private AssessmentQuestionModel $questionModel;
|
||||||
|
private AssessmentFormModel $formModel;
|
||||||
|
private StudentAssessmentModel $assessmentModel;
|
||||||
|
private StudentModel $studentModel;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
helper(['form']);
|
||||||
|
$this->db = db_connect();
|
||||||
|
$this->poolModel = new QuestionPoolModel();
|
||||||
|
$this->questionModel = new AssessmentQuestionModel();
|
||||||
|
$this->formModel = new AssessmentFormModel();
|
||||||
|
$this->assessmentModel = new StudentAssessmentModel();
|
||||||
|
$this->studentModel = new StudentModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pools()
|
||||||
|
{
|
||||||
|
$pool = $this->primaryPool();
|
||||||
|
if (! $pool) {
|
||||||
|
throw new PageNotFoundException('The New Student Assessment pool is not installed. Run the database migrations first.');
|
||||||
|
}
|
||||||
|
$questions = $this->questionModel->where('pool_id', $pool['id'])->orderBy('order_index', 'ASC')->orderBy('id', 'ASC')->findAll();
|
||||||
|
return view('assessments/pool', ['pool' => $pool, 'questions' => $questions]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pool(int $id)
|
||||||
|
{
|
||||||
|
$pool = $this->primaryPool();
|
||||||
|
if (! $pool || (int) $pool['id'] !== $id) {
|
||||||
|
return redirect()->to('administrator/assessments/pools');
|
||||||
|
}
|
||||||
|
$questions = $this->questionModel->where('pool_id', $id)->orderBy('order_index', 'ASC')->orderBy('id', 'ASC')->findAll();
|
||||||
|
return view('assessments/pool', ['pool' => $pool, 'questions' => $questions]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function storeQuestion(int $poolId)
|
||||||
|
{
|
||||||
|
$this->requirePool($poolId);
|
||||||
|
if (! $this->validateQuestion()) {
|
||||||
|
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
||||||
|
}
|
||||||
|
$max = $this->db->table('assessment_questions')->selectMax('order_index')->where('pool_id', $poolId)->get()->getRowArray();
|
||||||
|
$this->questionModel->insert($this->questionPayload($poolId, ((int) ($max['order_index'] ?? 0)) + 1));
|
||||||
|
return redirect()->to('administrator/assessments/pools')->with('success', 'Question added.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateQuestion(int $id)
|
||||||
|
{
|
||||||
|
$question = $this->requireQuestion($id);
|
||||||
|
if (! $this->validateQuestion()) {
|
||||||
|
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
||||||
|
}
|
||||||
|
$this->questionModel->update($id, $this->questionPayload((int) $question['pool_id'], (int) $question['order_index']));
|
||||||
|
return redirect()->to('administrator/assessments/pools')->with('success', 'Question updated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteQuestion(int $id)
|
||||||
|
{
|
||||||
|
$question = $this->requireQuestion($id);
|
||||||
|
if ($this->db->table('assessment_form_questions')->where('question_id', $id)->countAllResults() > 0) {
|
||||||
|
return redirect()->back()->with('error', 'This question is used by a form and cannot be deleted.');
|
||||||
|
}
|
||||||
|
$this->questionModel->delete($id);
|
||||||
|
$this->normalizeQuestionOrder((int) $question['pool_id']);
|
||||||
|
return redirect()->to('administrator/assessments/pools')->with('success', 'Question deleted.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function moveQuestion(int $id)
|
||||||
|
{
|
||||||
|
$question = $this->requireQuestion($id);
|
||||||
|
$direction = (string) $this->request->getPost('direction');
|
||||||
|
$operator = $direction === 'up' ? '<' : '>';
|
||||||
|
$sort = $direction === 'up' ? 'DESC' : 'ASC';
|
||||||
|
$other = $this->db->table('assessment_questions')
|
||||||
|
->where('pool_id', $question['pool_id'])->where('order_index ' . $operator, $question['order_index'])
|
||||||
|
->orderBy('order_index', $sort)->limit(1)->get()->getRowArray();
|
||||||
|
if ($other) {
|
||||||
|
$this->db->transStart();
|
||||||
|
$this->questionModel->update($id, ['order_index' => $other['order_index']]);
|
||||||
|
$this->questionModel->update((int) $other['id'], ['order_index' => $question['order_index']]);
|
||||||
|
$this->db->transComplete();
|
||||||
|
}
|
||||||
|
return redirect()->to('administrator/assessments/pools');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forms()
|
||||||
|
{
|
||||||
|
$forms = $this->db->table('assessment_forms f')
|
||||||
|
->select('f.*, p.name AS pool_name, COUNT(DISTINCT fq.question_id) AS question_count, COUNT(DISTINCT sa.id) AS assignment_count')
|
||||||
|
->join('question_pools p', 'p.id = f.pool_id')
|
||||||
|
->join('assessment_form_questions fq', 'fq.form_id = f.id', 'left')
|
||||||
|
->join('student_assessments sa', 'sa.form_id = f.id', 'left')
|
||||||
|
->groupBy('f.id')->orderBy('f.created_at', 'DESC')->get()->getResultArray();
|
||||||
|
return view('assessments/forms', ['forms' => $forms]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function newForm()
|
||||||
|
{
|
||||||
|
$pool = $this->primaryPool();
|
||||||
|
$selectedSchoolYear = $this->currentSchoolYearName((string) session()->get('school_year'));
|
||||||
|
return view('assessments/form_builder', [
|
||||||
|
'form' => null,
|
||||||
|
'pools' => $pool ? [$pool] : [],
|
||||||
|
'questions' => $pool ? $this->questionModel->where('pool_id', $pool['id'])->orderBy('order_index', 'ASC')->findAll() : [],
|
||||||
|
'selectedIds' => [], 'locked' => false, 'schoolYears' => $this->schoolYearOptions(),
|
||||||
|
'selectedSchoolYear' => $selectedSchoolYear,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function storeForm()
|
||||||
|
{
|
||||||
|
return $this->saveForm(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function editForm(int $id)
|
||||||
|
{
|
||||||
|
$form = $this->requireForm($id);
|
||||||
|
$questions = $this->questionModel->where('pool_id', $form['pool_id'])->orderBy('order_index', 'ASC')->findAll();
|
||||||
|
$selectedRows = $this->db->table('assessment_form_questions')->where('form_id', $id)->orderBy('order_index', 'ASC')->get()->getResultArray();
|
||||||
|
return view('assessments/form_builder', [
|
||||||
|
'form' => $form,
|
||||||
|
'pools' => $this->poolModel->orderBy('name', 'ASC')->findAll(),
|
||||||
|
'questions' => $questions,
|
||||||
|
'selectedIds' => array_map('intval', array_column($selectedRows, 'question_id')),
|
||||||
|
'locked' => $this->db->table('student_assessments')->where('form_id', $id)->countAllResults() > 0,
|
||||||
|
'schoolYears' => $this->schoolYearOptions(),
|
||||||
|
'selectedSchoolYear' => (string) ($form['school_year'] ?? ''),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateForm(int $id)
|
||||||
|
{
|
||||||
|
$this->requireForm($id);
|
||||||
|
return $this->saveForm($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function publishForm(int $id)
|
||||||
|
{
|
||||||
|
$form = $this->requireForm($id);
|
||||||
|
$questionCount = $this->db->table('assessment_form_questions')->where('form_id', $id)->countAllResults();
|
||||||
|
if ($questionCount === 0) {
|
||||||
|
return redirect()->to('administrator/assessments/forms')->with('error', 'Add at least one question before publishing this form.');
|
||||||
|
}
|
||||||
|
if (! preg_match('/^\d{4}-\d{4}$/', (string) ($form['school_year'] ?? ''))) {
|
||||||
|
return redirect()->to('administrator/assessments/forms/' . $id . '/edit')
|
||||||
|
->with('error', 'Select a school year before publishing this form.');
|
||||||
|
}
|
||||||
|
if ($form['status'] !== 'published') {
|
||||||
|
$this->formModel->update($id, ['status' => 'published']);
|
||||||
|
}
|
||||||
|
return redirect()->to('administrator/assessments/forms')->with('success', 'Assessment form published.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function preview(int $id)
|
||||||
|
{
|
||||||
|
$data = $this->formWithQuestions($id);
|
||||||
|
$data['preview'] = true;
|
||||||
|
return view('assessments/take', $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function newStudentRoster()
|
||||||
|
{
|
||||||
|
$schoolYear = $this->currentSchoolYearName((string) session()->get('school_year'));
|
||||||
|
$data = service('enrollmentWithdrawal')->newStudents($schoolYear);
|
||||||
|
$data['schoolYear'] = $schoolYear;
|
||||||
|
$data['assessmentByStudent'] = $this->assessmentActionsForStudents(array_column($data['new_students'] ?? [], 'id'), $schoolYear);
|
||||||
|
return view('assessments/new_students', $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function studentAssignments(int $studentId)
|
||||||
|
{
|
||||||
|
$student = $this->studentModel->find($studentId);
|
||||||
|
if (! $student) {
|
||||||
|
throw new PageNotFoundException('Student not found.');
|
||||||
|
}
|
||||||
|
$schoolYear = $this->currentSchoolYearName((string) session()->get('school_year'));
|
||||||
|
$active = $this->activeAssessmentForStudent($studentId, $schoolYear);
|
||||||
|
if ($active) {
|
||||||
|
return view('assessments/admin_take', $this->assessmentDetails((int) $active['id']));
|
||||||
|
}
|
||||||
|
$forms = $this->db->table('assessment_forms f')
|
||||||
|
->select('f.*, p.name AS pool_name, COUNT(fq.question_id) AS question_count')
|
||||||
|
->join('question_pools p', 'p.id = f.pool_id')
|
||||||
|
->join('assessment_form_questions fq', 'fq.form_id = f.id', 'left')
|
||||||
|
->where('f.status', 'published')->where('f.school_year', $schoolYear)
|
||||||
|
->groupBy('f.id')->orderBy('f.name', 'ASC')->get()->getResultArray();
|
||||||
|
$assignments = $this->db->table('student_assessments sa')
|
||||||
|
->select('sa.*, f.name AS form_name, f.school_year')->join('assessment_forms f', 'f.id = sa.form_id')
|
||||||
|
->where('sa.student_id', $studentId)->orderBy('sa.assigned_at', 'DESC')->get()->getResultArray();
|
||||||
|
return view('assessments/student_assignments', ['student' => $student, 'forms' => $forms, 'assignments' => $assignments]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function startInterview(int $studentId)
|
||||||
|
{
|
||||||
|
if (! $this->studentModel->find($studentId)) {
|
||||||
|
throw new PageNotFoundException('Student not found.');
|
||||||
|
}
|
||||||
|
$schoolYear = $this->currentSchoolYearName((string) session()->get('school_year'));
|
||||||
|
$active = $this->activeAssessmentForStudent($studentId, $schoolYear);
|
||||||
|
if ($active) return redirect()->to("administrator/assessments/students/{$studentId}");
|
||||||
|
|
||||||
|
$assignedFormIds = array_map('intval', array_column(
|
||||||
|
$this->assessmentModel->select('form_id')->where('student_id', $studentId)->findAll(),
|
||||||
|
'form_id'
|
||||||
|
));
|
||||||
|
$formBuilder = $this->formModel->where('status', 'published')->where('school_year', $schoolYear)
|
||||||
|
->orderBy('created_at', 'DESC')->orderBy('id', 'DESC');
|
||||||
|
if ($assignedFormIds !== []) $formBuilder->whereNotIn('id', $assignedFormIds);
|
||||||
|
$form = $formBuilder->first();
|
||||||
|
if (! $form) {
|
||||||
|
return redirect()->to('administrator/assessments/students/' . $studentId)
|
||||||
|
->with('error', 'No unassigned published assessment form is available for this student.');
|
||||||
|
}
|
||||||
|
$this->assessmentModel->insert([
|
||||||
|
'form_id' => (int) $form['id'], 'student_id' => $studentId, 'status' => 'not_started',
|
||||||
|
'assigned_at' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
return redirect()->to("administrator/assessments/students/{$studentId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function assign(int $studentId)
|
||||||
|
{
|
||||||
|
if (! $this->studentModel->find($studentId)) {
|
||||||
|
throw new PageNotFoundException('Student not found.');
|
||||||
|
}
|
||||||
|
$formId = (int) $this->request->getPost('form_id');
|
||||||
|
$schoolYear = $this->currentSchoolYearName((string) session()->get('school_year'));
|
||||||
|
$form = $this->formModel->where('id', $formId)->where('status', 'published')->where('school_year', $schoolYear)->first();
|
||||||
|
if (! $form) {
|
||||||
|
return redirect()->back()->with('error', 'Choose a published assessment form.');
|
||||||
|
}
|
||||||
|
if ($this->assessmentModel->where(['form_id' => $formId, 'student_id' => $studentId])->first()) {
|
||||||
|
return redirect()->back()->with('error', 'This form is already assigned to this student.');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$inserted = $this->assessmentModel->insert([
|
||||||
|
'form_id' => $formId, 'student_id' => $studentId, 'status' => 'not_started',
|
||||||
|
'assigned_at' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
log_message('warning', 'Assessment assignment rejected: ' . $e->getMessage());
|
||||||
|
$inserted = false;
|
||||||
|
}
|
||||||
|
if ($inserted === false) {
|
||||||
|
return redirect()->back()->with('error', 'This form is already assigned to this student.');
|
||||||
|
}
|
||||||
|
return redirect()->to("administrator/assessments/students/{$studentId}")->with('success', 'Assessment assigned.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function grade(int $assessmentId)
|
||||||
|
{
|
||||||
|
$data = $this->assessmentDetails($assessmentId);
|
||||||
|
if (! in_array($data['assessment']['status'], ['completed', 'graded'], true)) {
|
||||||
|
return redirect()->back()->with('error', 'The student must complete the assessment before it can be reviewed.');
|
||||||
|
}
|
||||||
|
return view('assessments/grade', $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveGrade(int $assessmentId)
|
||||||
|
{
|
||||||
|
$data = $this->assessmentDetails($assessmentId);
|
||||||
|
if (! in_array($data['assessment']['status'], ['completed', 'graded'], true)) {
|
||||||
|
return redirect()->back()->with('error', 'The assessment is not ready for review.');
|
||||||
|
}
|
||||||
|
$this->persistEducationCommitteeNote($assessmentId);
|
||||||
|
$this->db->transStart();
|
||||||
|
$this->db->table('student_answers')->where('student_assessment_id', $assessmentId)->update([
|
||||||
|
'is_correct' => null, 'points_awarded' => null, 'updated_at' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
$this->assessmentModel->update($assessmentId, [
|
||||||
|
'status' => 'graded', 'score' => null, 'graded_at' => date('Y-m-d H:i:s'),
|
||||||
|
'graded_by' => (int) session()->get('user_id') ?: null,
|
||||||
|
]);
|
||||||
|
$this->db->transComplete();
|
||||||
|
return redirect()->to("administrator/assessments/results/{$assessmentId}")->with('success', 'Assessment review completed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function results(int $assessmentId)
|
||||||
|
{
|
||||||
|
$data = $this->assessmentDetails($assessmentId);
|
||||||
|
$data['adminView'] = true;
|
||||||
|
return view('assessments/results', $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveAdminProgress(int $assessmentId)
|
||||||
|
{
|
||||||
|
$assessment = $this->assessmentModel->find($assessmentId);
|
||||||
|
if (! $assessment) throw new PageNotFoundException('Assessment not found.');
|
||||||
|
if (! in_array($assessment['status'], ['not_started', 'in_progress'], true)) {
|
||||||
|
return $this->response->setStatusCode(409)->setJSON(['status' => false, 'message' => 'This assessment can no longer be changed.']);
|
||||||
|
}
|
||||||
|
$this->persistEducationCommitteeNote($assessmentId);
|
||||||
|
$this->persistAnswers($assessment, (array) $this->request->getPost('answers'));
|
||||||
|
return $this->response->setJSON([
|
||||||
|
'status' => true, 'message' => 'Saved', 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveAdminNote(int $assessmentId)
|
||||||
|
{
|
||||||
|
if (! $this->assessmentModel->find($assessmentId)) {
|
||||||
|
throw new PageNotFoundException('Assessment not found.');
|
||||||
|
}
|
||||||
|
$this->persistEducationCommitteeNote($assessmentId);
|
||||||
|
return $this->response->setJSON([
|
||||||
|
'status' => true, 'message' => 'Note saved', 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function completeAdminInterview(int $assessmentId)
|
||||||
|
{
|
||||||
|
$assessment = $this->assessmentModel->find($assessmentId);
|
||||||
|
if (! $assessment) throw new PageNotFoundException('Assessment not found.');
|
||||||
|
if (! in_array($assessment['status'], ['not_started', 'in_progress'], true)) {
|
||||||
|
return redirect()->to('administrator/assessments/students/' . $assessment['student_id'])
|
||||||
|
->with('error', 'This assessment has already been completed.');
|
||||||
|
}
|
||||||
|
$this->persistEducationCommitteeNote($assessmentId);
|
||||||
|
$this->persistAnswers($assessment, (array) $this->request->getPost('answers'));
|
||||||
|
$this->assessmentModel->update($assessmentId, ['status' => 'completed', 'submitted_at' => date('Y-m-d H:i:s')]);
|
||||||
|
return redirect()->to("administrator/assessments/review/{$assessmentId}")
|
||||||
|
->with('success', 'Assessment completed. Review the responses below.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function myAssessments()
|
||||||
|
{
|
||||||
|
$studentIds = $this->accessibleStudentIds();
|
||||||
|
$assignments = [];
|
||||||
|
if ($studentIds) {
|
||||||
|
$assignments = $this->db->table('student_assessments sa')
|
||||||
|
->select('sa.*, f.name AS form_name, f.school_year, s.firstname, s.lastname')
|
||||||
|
->join('assessment_forms f', 'f.id = sa.form_id')
|
||||||
|
->join('students s', 's.id = sa.student_id')
|
||||||
|
->whereIn('sa.student_id', $studentIds)->orderBy('sa.assigned_at', 'DESC')->get()->getResultArray();
|
||||||
|
}
|
||||||
|
return view('assessments/my_assessments', ['assignments' => $assignments]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function take(int $assessmentId)
|
||||||
|
{
|
||||||
|
$assessment = $this->requireAccessibleAssessment($assessmentId);
|
||||||
|
if ($assessment['status'] === 'graded') {
|
||||||
|
return redirect()->to("student/assessments/{$assessmentId}/results");
|
||||||
|
}
|
||||||
|
if ($assessment['status'] === 'completed') {
|
||||||
|
return redirect()->to('student/assessments')->with('success', 'This assessment has already been submitted and is awaiting review.');
|
||||||
|
}
|
||||||
|
$data = $this->formWithQuestions((int) $assessment['form_id']);
|
||||||
|
$answerRows = $this->db->table('student_answers')->where('student_assessment_id', $assessmentId)->get()->getResultArray();
|
||||||
|
$data['answers'] = array_column($answerRows, 'answer_value', 'question_id');
|
||||||
|
$data['assessment'] = $assessment;
|
||||||
|
$data['preview'] = false;
|
||||||
|
return view('assessments/take', $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveProgress(int $assessmentId)
|
||||||
|
{
|
||||||
|
$assessment = $this->requireAccessibleAssessment($assessmentId);
|
||||||
|
if (! in_array($assessment['status'], ['not_started', 'in_progress'], true)) {
|
||||||
|
return $this->response->setStatusCode(409)->setJSON(['status' => false, 'message' => 'This assessment can no longer be changed.']);
|
||||||
|
}
|
||||||
|
$answers = (array) $this->request->getPost('answers');
|
||||||
|
$this->persistAnswers($assessment, $answers);
|
||||||
|
if ($this->request->isAJAX()) {
|
||||||
|
return $this->response->setJSON([
|
||||||
|
'status' => true, 'message' => 'Progress saved.',
|
||||||
|
'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return redirect()->back()->with('success', 'Progress saved.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function submit(int $assessmentId)
|
||||||
|
{
|
||||||
|
$assessment = $this->requireAccessibleAssessment($assessmentId);
|
||||||
|
if (! in_array($assessment['status'], ['not_started', 'in_progress'], true)) {
|
||||||
|
return redirect()->to('student/assessments')->with('error', 'This assessment was already submitted.');
|
||||||
|
}
|
||||||
|
$this->persistAnswers($assessment, (array) $this->request->getPost('answers'));
|
||||||
|
$this->assessmentModel->update($assessmentId, ['status' => 'completed', 'submitted_at' => date('Y-m-d H:i:s')]);
|
||||||
|
return redirect()->to('student/assessments')->with('success', 'Assessment submitted. Your responses will appear after review.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function myResults(int $assessmentId)
|
||||||
|
{
|
||||||
|
$assessment = $this->requireAccessibleAssessment($assessmentId);
|
||||||
|
if ($assessment['status'] !== 'graded') {
|
||||||
|
return redirect()->to('student/assessments')->with('error', 'Results are not available yet.');
|
||||||
|
}
|
||||||
|
$data = $this->assessmentDetails($assessmentId);
|
||||||
|
$data['adminView'] = false;
|
||||||
|
return view('assessments/results', $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function saveForm(?int $id)
|
||||||
|
{
|
||||||
|
$rules = [
|
||||||
|
'name' => 'required|string|max_length[150]', 'pool_id' => 'required|is_natural_no_zero',
|
||||||
|
'school_year' => 'required|regex_match[/^\d{4}-\d{4}$/]',
|
||||||
|
'status' => 'required|in_list[draft,published,archived]',
|
||||||
|
'education_committee_note' => 'permit_empty|string|max_length[10000]',
|
||||||
|
];
|
||||||
|
if (! $this->validate($rules)) {
|
||||||
|
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
||||||
|
}
|
||||||
|
$existingForm = $id !== null ? $this->requireForm($id) : null;
|
||||||
|
$submittedSchoolYear = trim((string) $this->request->getPost('school_year'));
|
||||||
|
if ($this->db->tableExists('school_years')
|
||||||
|
&& $this->db->table('school_years')->where('name', $submittedSchoolYear)->countAllResults() === 0) {
|
||||||
|
return redirect()->back()->withInput()->with('error', 'Select a valid school year.');
|
||||||
|
}
|
||||||
|
$poolId = $existingForm ? (int) $existingForm['pool_id'] : (int) $this->request->getPost('pool_id');
|
||||||
|
if (! $this->poolModel->find($poolId)) {
|
||||||
|
return redirect()->back()->withInput()->with('error', 'Question pool not found.');
|
||||||
|
}
|
||||||
|
$locked = $id !== null && $this->db->table('student_assessments')->where('form_id', $id)->countAllResults() > 0;
|
||||||
|
$selected = array_values(array_unique(array_map('intval', (array) $this->request->getPost('question_ids'))));
|
||||||
|
if ($this->request->getPost('use_all')) {
|
||||||
|
$selected = array_map('intval', array_column($this->questionModel->where('pool_id', $poolId)->orderBy('order_index', 'ASC')->findAll(), 'id'));
|
||||||
|
}
|
||||||
|
if (! $locked) {
|
||||||
|
$validRows = empty($selected) ? [] : $this->questionModel->where('pool_id', $poolId)->whereIn('id', $selected)->findAll();
|
||||||
|
$validIds = array_map('intval', array_column($validRows, 'id'));
|
||||||
|
$selected = array_values(array_filter($selected, static fn ($qid) => in_array($qid, $validIds, true)));
|
||||||
|
usort($selected, function ($a, $b) {
|
||||||
|
return ((int) $this->request->getPost('order_' . $a)) <=> ((int) $this->request->getPost('order_' . $b));
|
||||||
|
});
|
||||||
|
if (empty($selected)) {
|
||||||
|
return redirect()->back()->withInput()->with('error', 'Select at least one question.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$payload = [
|
||||||
|
'name' => trim((string) $this->request->getPost('name')),
|
||||||
|
'school_year' => $locked ? (string) $existingForm['school_year'] : $submittedSchoolYear,
|
||||||
|
'status' => $this->request->getPost('status'),
|
||||||
|
'education_committee_note' => $this->nullableString($this->request->getPost('education_committee_note')),
|
||||||
|
];
|
||||||
|
if ($id === null) $payload['pool_id'] = $poolId;
|
||||||
|
$this->db->transStart();
|
||||||
|
if ($id === null) {
|
||||||
|
$payload['created_by'] = (int) session()->get('user_id') ?: null;
|
||||||
|
$id = (int) $this->formModel->insert($payload, true);
|
||||||
|
} else {
|
||||||
|
$this->formModel->update($id, $payload);
|
||||||
|
}
|
||||||
|
if (! $locked) {
|
||||||
|
$this->db->table('assessment_form_questions')->where('form_id', $id)->delete();
|
||||||
|
foreach ($selected as $index => $questionId) {
|
||||||
|
$this->db->table('assessment_form_questions')->insert(['form_id' => $id, 'question_id' => $questionId, 'order_index' => $index + 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->db->transComplete();
|
||||||
|
return redirect()->to('administrator/assessments/forms')->with('success', 'Assessment form saved.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validateQuestion(): bool
|
||||||
|
{
|
||||||
|
$valid = $this->validate([
|
||||||
|
'type' => 'required|in_list[multiple_choice,short_answer,true_false,essay]',
|
||||||
|
'text' => 'required|string|max_length[10000]', 'options_text' => 'permit_empty|string|max_length[10000]',
|
||||||
|
'correct_answer' => 'permit_empty|string|max_length[10000]',
|
||||||
|
]);
|
||||||
|
if ($valid && $this->request->getPost('type') === 'multiple_choice') {
|
||||||
|
$choices = preg_split('/\r\n|\r|\n/', trim((string) $this->request->getPost('options_text'))) ?: [];
|
||||||
|
$choices = array_filter(array_map('trim', $choices), static fn ($choice) => $choice !== '');
|
||||||
|
if (count($choices) < 2) {
|
||||||
|
$this->validator->setError('options_text', 'Multiple-choice questions need at least two choices.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $valid;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function questionPayload(int $poolId, int $order): array
|
||||||
|
{
|
||||||
|
$type = (string) $this->request->getPost('type');
|
||||||
|
$options = null;
|
||||||
|
if ($type === 'true_false') {
|
||||||
|
$options = json_encode(['True', 'False']);
|
||||||
|
} elseif ($type === 'multiple_choice') {
|
||||||
|
$values = preg_split('/\r\n|\r|\n/', trim((string) $this->request->getPost('options_text')));
|
||||||
|
$values = array_values(array_filter(array_map('trim', $values ?: []), static fn ($v) => $v !== ''));
|
||||||
|
$options = $values ? json_encode($values, JSON_UNESCAPED_UNICODE) : null;
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'pool_id' => $poolId, 'type' => $type, 'text' => trim((string) $this->request->getPost('text')),
|
||||||
|
'options' => $options, 'correct_answer' => $this->nullableString($this->request->getPost('correct_answer')),
|
||||||
|
'points' => 0, 'order_index' => $order,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function formWithQuestions(int $id): array
|
||||||
|
{
|
||||||
|
$form = $this->requireForm($id);
|
||||||
|
$questions = $this->db->table('assessment_form_questions fq')
|
||||||
|
->select('q.*, fq.order_index AS form_order')->join('assessment_questions q', 'q.id = fq.question_id')
|
||||||
|
->where('fq.form_id', $id)->orderBy('fq.order_index', 'ASC')->get()->getResultArray();
|
||||||
|
return ['form' => $form, 'questions' => $questions, 'answers' => []];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assessmentDetails(int $id): array
|
||||||
|
{
|
||||||
|
$assessment = $this->db->table('student_assessments sa')
|
||||||
|
->select('sa.*, f.name AS form_name, f.id AS assessment_form_id, f.school_year, f.education_committee_note AS form_education_committee_note, s.firstname, s.lastname, s.school_id')
|
||||||
|
->join('assessment_forms f', 'f.id = sa.form_id')->join('students s', 's.id = sa.student_id')
|
||||||
|
->where('sa.id', $id)->get()->getRowArray();
|
||||||
|
if (! $assessment) {
|
||||||
|
throw new PageNotFoundException('Assessment not found.');
|
||||||
|
}
|
||||||
|
$questions = $this->db->table('assessment_form_questions fq')
|
||||||
|
->select('q.*, fq.order_index AS form_order, a.answer_value')
|
||||||
|
->join('assessment_questions q', 'q.id = fq.question_id')
|
||||||
|
->join('student_answers a', 'a.question_id = q.id AND a.student_assessment_id = ' . (int) $id, 'left')
|
||||||
|
->where('fq.form_id', $assessment['form_id'])->orderBy('fq.order_index', 'ASC')->get()->getResultArray();
|
||||||
|
return ['assessment' => $assessment, 'questions' => $questions];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function requirePool(int $id): array
|
||||||
|
{
|
||||||
|
$row = $this->poolModel->find($id);
|
||||||
|
if (! $row) throw new PageNotFoundException('Question pool not found.');
|
||||||
|
return $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function primaryPool(): ?array
|
||||||
|
{
|
||||||
|
$pool = $this->poolModel->where('name', 'New Student Assessment')->first();
|
||||||
|
if ($pool) return $pool;
|
||||||
|
|
||||||
|
return $this->poolModel->orderBy('id', 'ASC')->first() ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function schoolYearOptions(): array
|
||||||
|
{
|
||||||
|
if (! $this->db->tableExists('school_years')) return [];
|
||||||
|
return $this->db->table('school_years')->select('name, status')->orderBy('name', 'DESC')->get()->getResultArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function requireQuestion(int $id): array
|
||||||
|
{
|
||||||
|
$row = $this->questionModel->find($id);
|
||||||
|
if (! $row) throw new PageNotFoundException('Question not found.');
|
||||||
|
return $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function requireForm(int $id): array
|
||||||
|
{
|
||||||
|
$row = $this->formModel->find($id);
|
||||||
|
if (! $row) throw new PageNotFoundException('Assessment form not found.');
|
||||||
|
return $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeQuestionOrder(int $poolId): void
|
||||||
|
{
|
||||||
|
$rows = $this->questionModel->where('pool_id', $poolId)->orderBy('order_index', 'ASC')->orderBy('id', 'ASC')->findAll();
|
||||||
|
foreach ($rows as $index => $row) $this->questionModel->update($row['id'], ['order_index' => $index + 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function nullableString($value): ?string
|
||||||
|
{
|
||||||
|
$value = trim((string) $value);
|
||||||
|
return $value === '' ? null : $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assessmentActionsForStudents(array $studentIds, string $schoolYear): array
|
||||||
|
{
|
||||||
|
$studentIds = array_values(array_unique(array_filter(array_map('intval', $studentIds))));
|
||||||
|
if ($studentIds === []) return [];
|
||||||
|
$rows = $this->db->table('student_assessments sa')
|
||||||
|
->select('sa.*, f.name AS form_name')->join('assessment_forms f', 'f.id = sa.form_id')
|
||||||
|
->where('f.school_year', $schoolYear)
|
||||||
|
->whereIn('sa.student_id', $studentIds)->orderBy('sa.assigned_at', 'DESC')->orderBy('sa.id', 'DESC')
|
||||||
|
->get()->getResultArray();
|
||||||
|
$map = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$studentId = (int) $row['student_id'];
|
||||||
|
$priority = match ((string) $row['status']) {
|
||||||
|
'completed' => 3, 'not_started', 'in_progress' => 2, 'graded' => 1, default => 0,
|
||||||
|
};
|
||||||
|
$currentStatus = $map[$studentId]['status'] ?? null;
|
||||||
|
$currentPriority = match ($currentStatus) {
|
||||||
|
'completed' => 3, 'not_started', 'in_progress' => 2, 'graded' => 1, default => -1,
|
||||||
|
};
|
||||||
|
if ($priority > $currentPriority) $map[$studentId] = $row;
|
||||||
|
}
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function activeAssessmentForStudent(int $studentId, string $schoolYear): ?array
|
||||||
|
{
|
||||||
|
return $this->db->table('student_assessments sa')
|
||||||
|
->select('sa.*')->join('assessment_forms f', 'f.id = sa.form_id')
|
||||||
|
->where('sa.student_id', $studentId)->where('f.school_year', $schoolYear)
|
||||||
|
->whereIn('sa.status', ['not_started', 'in_progress'])
|
||||||
|
->orderBy('sa.assigned_at', 'DESC')->orderBy('sa.id', 'DESC')->get()->getRowArray() ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function persistAnswers(array $assessment, array $answers): void
|
||||||
|
{
|
||||||
|
$questionIds = array_map('intval', array_column(
|
||||||
|
$this->db->table('assessment_form_questions')->select('question_id')->where('form_id', $assessment['form_id'])->get()->getResultArray(),
|
||||||
|
'question_id'
|
||||||
|
));
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
$this->db->transStart();
|
||||||
|
foreach ($questionIds as $questionId) {
|
||||||
|
if (! array_key_exists($questionId, $answers)) continue;
|
||||||
|
$value = is_array($answers[$questionId]) ? implode(', ', $answers[$questionId]) : trim((string) $answers[$questionId]);
|
||||||
|
$existing = $this->db->table('student_answers')->where([
|
||||||
|
'student_assessment_id' => $assessment['id'], 'question_id' => $questionId,
|
||||||
|
])->get()->getRowArray();
|
||||||
|
$payload = ['answer_value' => $value, 'updated_at' => $now];
|
||||||
|
if ($existing) {
|
||||||
|
$this->db->table('student_answers')->where('id', $existing['id'])->update($payload);
|
||||||
|
} else {
|
||||||
|
$payload += ['student_assessment_id' => $assessment['id'], 'question_id' => $questionId, 'created_at' => $now];
|
||||||
|
$this->db->table('student_answers')->insert($payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($assessment['status'] === 'not_started') {
|
||||||
|
$this->assessmentModel->update($assessment['id'], ['status' => 'in_progress', 'started_at' => $now]);
|
||||||
|
}
|
||||||
|
$this->db->transComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function persistEducationCommitteeNote(int $assessmentId): void
|
||||||
|
{
|
||||||
|
$note = trim((string) $this->request->getPost('education_committee_note'));
|
||||||
|
if (mb_strlen($note) > 10000) $note = mb_substr($note, 0, 10000);
|
||||||
|
$this->assessmentModel->update($assessmentId, [
|
||||||
|
'education_committee_note' => $note === '' ? null : $note,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function requireAccessibleAssessment(int $id): array
|
||||||
|
{
|
||||||
|
$assessment = $this->assessmentModel->find($id);
|
||||||
|
if (! $assessment || ! in_array((int) $assessment['student_id'], $this->accessibleStudentIds(), true)) {
|
||||||
|
throw new PageNotFoundException('Assessment not found.');
|
||||||
|
}
|
||||||
|
return $assessment;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function accessibleStudentIds(): array
|
||||||
|
{
|
||||||
|
$userId = (int) session()->get('user_id');
|
||||||
|
if ($userId <= 0) return [];
|
||||||
|
$ids = [];
|
||||||
|
$user = $this->db->table('users')->select('school_id')->where('id', $userId)->get()->getRowArray();
|
||||||
|
$schoolId = trim((string) ($user['school_id'] ?? ''));
|
||||||
|
if ($schoolId !== '') {
|
||||||
|
$row = $this->db->table('students')->select('id')->where('school_id', $schoolId)->get()->getRowArray();
|
||||||
|
if ($row) $ids[] = (int) $row['id'];
|
||||||
|
}
|
||||||
|
$parentRows = $this->db->table('students')->select('id')->where('parent_id', $userId)->get()->getResultArray();
|
||||||
|
$ids = array_merge($ids, array_map('intval', array_column($parentRows, 'id')));
|
||||||
|
if ($this->db->tableExists('family_guardians') && $this->db->tableExists('family_students')) {
|
||||||
|
$familyRows = $this->db->table('family_guardians fg')->select('fs.student_id')
|
||||||
|
->join('family_students fs', 'fs.family_id = fg.family_id')->where('fg.user_id', $userId)->get()->getResultArray();
|
||||||
|
$ids = array_merge($ids, array_map('intval', array_column($familyRows, 'student_id')));
|
||||||
|
}
|
||||||
|
return array_values(array_unique(array_filter($ids)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ use App\Models\StudentModel;
|
|||||||
use App\Models\TeacherModel;
|
use App\Models\TeacherModel;
|
||||||
use App\Models\ClassSectionModel;
|
use App\Models\ClassSectionModel;
|
||||||
use App\Models\ConfigurationModel;
|
use App\Models\ConfigurationModel;
|
||||||
|
use App\Support\Enrollment\EnrollmentEligibility;
|
||||||
use Config\Database;
|
use Config\Database;
|
||||||
|
|
||||||
class AssignmentController extends BaseController
|
class AssignmentController extends BaseController
|
||||||
@@ -177,11 +178,13 @@ class AssignmentController extends BaseController
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$calculatedAge = EnrollmentEligibility::ageOnSeptemberFirst($student['dob'] ?? null, $year);
|
||||||
|
|
||||||
$students[] = [
|
$students[] = [
|
||||||
'id' => (int)$student['id'],
|
'id' => (int)$student['id'],
|
||||||
'firstname' => esc($student['firstname']),
|
'firstname' => esc($student['firstname']),
|
||||||
'lastname' => esc($student['lastname']),
|
'lastname' => esc($student['lastname']),
|
||||||
'age' => esc($student['age']),
|
'age' => esc((string)($calculatedAge ?? ($student['age'] ?? ''))),
|
||||||
'gender' => esc($student['gender']),
|
'gender' => esc($student['gender']),
|
||||||
'registration_grade' => esc($student['registration_grade']),
|
'registration_grade' => esc($student['registration_grade']),
|
||||||
'photo_consent' => esc($student['photo_consent'] ? 'Yes' : 'No'),
|
'photo_consent' => esc($student['photo_consent'] ? 'Yes' : 'No'),
|
||||||
|
|||||||
@@ -122,6 +122,10 @@ class BadgesController extends PrintablesBaseController
|
|||||||
$roleResolved = $postedRole !== null ? $postedRole : $resolveRole($info);
|
$roleResolved = $postedRole !== null ? $postedRole : $resolveRole($info);
|
||||||
$roleResolved = $formatRole((string)$roleResolved);
|
$roleResolved = $formatRole((string)$roleResolved);
|
||||||
$info['role_resolved'] = $roleResolved;
|
$info['role_resolved'] = $roleResolved;
|
||||||
|
if ($postedRole !== null && trim((string)$postedRole) !== '') {
|
||||||
|
$info['roles_raw'] = $roleResolved;
|
||||||
|
$info['role_name_raw'] = $roleResolved;
|
||||||
|
}
|
||||||
|
|
||||||
// Prefer posted class name if provided (keeps what the user saw)
|
// Prefer posted class name if provided (keeps what the user saw)
|
||||||
if (!empty($classesMap[$userId])) {
|
if (!empty($classesMap[$userId])) {
|
||||||
|
|||||||
@@ -239,6 +239,7 @@ class FamilyAdminController extends BaseController
|
|||||||
public function card(): ResponseInterface
|
public function card(): ResponseInterface
|
||||||
{
|
{
|
||||||
$db = \Config\Database::connect();
|
$db = \Config\Database::connect();
|
||||||
|
$canViewInvoices = $this->canViewFamilyInvoices();
|
||||||
$studentId = (int) ($this->request->getGet('student_id') ?? 0);
|
$studentId = (int) ($this->request->getGet('student_id') ?? 0);
|
||||||
$guardianId = (int) ($this->request->getGet('guardian_id') ?? 0);
|
$guardianId = (int) ($this->request->getGet('guardian_id') ?? 0);
|
||||||
$familyId = (int) ($this->request->getGet('family_id') ?? 0);
|
$familyId = (int) ($this->request->getGet('family_id') ?? 0);
|
||||||
@@ -338,9 +339,8 @@ class FamilyAdminController extends BaseController
|
|||||||
// Hydrate with guardians, students (+grades), invoices, payments
|
// Hydrate with guardians, students (+grades), invoices, payments
|
||||||
$invoiceModel = new \App\Models\InvoiceModel();
|
$invoiceModel = new \App\Models\InvoiceModel();
|
||||||
$paymentModel = new \App\Models\PaymentModel();
|
$paymentModel = new \App\Models\PaymentModel();
|
||||||
$studentClassModel = new \App\Models\StudentClassModel();
|
|
||||||
$configModel = new \App\Models\ConfigurationModel();
|
$configModel = new \App\Models\ConfigurationModel();
|
||||||
$schoolYear = (string) ($configModel->getConfig('school_year') ?? '');
|
$schoolYear = $this->currentSchoolYearName((string) ($configModel->getConfig('school_year') ?? ''));
|
||||||
|
|
||||||
// Guardians
|
// Guardians
|
||||||
$guardians = $db->query(
|
$guardians = $db->query(
|
||||||
@@ -354,10 +354,11 @@ class FamilyAdminController extends BaseController
|
|||||||
[$familyId]
|
[$familyId]
|
||||||
)->getResultArray();
|
)->getResultArray();
|
||||||
$family['guardians'] = $guardians;
|
$family['guardians'] = $guardians;
|
||||||
|
$family['can_view_invoices'] = $canViewInvoices;
|
||||||
|
|
||||||
// Students
|
// Students
|
||||||
$studentsRows = $db->query(
|
$studentsRows = $db->query(
|
||||||
"SELECT s.id, s.firstname, s.lastname
|
"SELECT s.*
|
||||||
FROM family_students fs
|
FROM family_students fs
|
||||||
JOIN students s ON s.id = fs.student_id
|
JOIN students s ON s.id = fs.student_id
|
||||||
WHERE fs.family_id = ?
|
WHERE fs.family_id = ?
|
||||||
@@ -369,7 +370,7 @@ class FamilyAdminController extends BaseController
|
|||||||
$studentIds = array_map(static fn(array $row): int => (int) ($row['id'] ?? 0), $studentsRows);
|
$studentIds = array_map(static fn(array $row): int => (int) ($row['id'] ?? 0), $studentsRows);
|
||||||
if (!in_array($studentId, $studentIds, true)) {
|
if (!in_array($studentId, $studentIds, true)) {
|
||||||
$selectedStudent = $db->query(
|
$selectedStudent = $db->query(
|
||||||
"SELECT id, firstname, lastname
|
"SELECT *
|
||||||
FROM students
|
FROM students
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
LIMIT 1",
|
LIMIT 1",
|
||||||
@@ -383,13 +384,101 @@ class FamilyAdminController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!empty($studentsRows)) {
|
if (!empty($studentsRows)) {
|
||||||
|
$studentIds = array_values(array_filter(array_map(
|
||||||
|
static fn(array $row): int => (int) ($row['id'] ?? 0),
|
||||||
|
$studentsRows
|
||||||
|
)));
|
||||||
|
$allergiesByStudent = [];
|
||||||
|
$conditionsByStudent = [];
|
||||||
|
$classAssignmentsByStudent = [];
|
||||||
|
$enrollmentByStudent = [];
|
||||||
|
$scoreHistoryByStudent = [];
|
||||||
|
|
||||||
|
if (!empty($studentIds)) {
|
||||||
|
if ($schoolYear !== '') {
|
||||||
|
$classSectionJoin = 'cs.class_section_id = sc.class_section_id';
|
||||||
|
if ($db->fieldExists('school_year', 'classSection')) {
|
||||||
|
$classSectionJoin .= ' AND (cs.school_year = sc.school_year OR cs.school_year IS NULL)';
|
||||||
|
}
|
||||||
|
|
||||||
|
$classAssignmentRows = $db->table('student_class sc')
|
||||||
|
->select('sc.student_id, cs.class_section_name, c.class_name')
|
||||||
|
->join('classSection cs', $classSectionJoin, 'left')
|
||||||
|
->join('classes c', 'c.id = cs.class_id', 'left')
|
||||||
|
->whereIn('sc.student_id', $studentIds)
|
||||||
|
->where('sc.school_year', $schoolYear)
|
||||||
|
->where('sc.class_section_id IS NOT NULL', null, false)
|
||||||
|
->orderBy('cs.class_section_name', 'ASC')
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
foreach ($classAssignmentRows as $classAssignmentRow) {
|
||||||
|
$assignmentStudentId = (int) ($classAssignmentRow['student_id'] ?? 0);
|
||||||
|
$className = trim((string) ($classAssignmentRow['class_name'] ?? ''));
|
||||||
|
$sectionName = trim((string) ($classAssignmentRow['class_section_name'] ?? ''));
|
||||||
|
$label = $className !== '' && $sectionName !== '' && strcasecmp($className, $sectionName) !== 0
|
||||||
|
? $className . ' / ' . $sectionName
|
||||||
|
: ($sectionName !== '' ? $sectionName : $className);
|
||||||
|
|
||||||
|
if ($assignmentStudentId > 0 && $label !== '') {
|
||||||
|
$classAssignmentsByStudent[$assignmentStudentId][$label] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$enrollmentRows = $db->table('enrollments e')
|
||||||
|
->select('e.student_id, e.enrollment_status')
|
||||||
|
->whereIn('e.student_id', $studentIds)
|
||||||
|
->where('e.school_year', $schoolYear)
|
||||||
|
->orderBy('e.updated_at', 'DESC')
|
||||||
|
->orderBy('e.enrollment_date', 'DESC')
|
||||||
|
->orderBy('e.id', 'DESC')
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
|
||||||
|
foreach ($enrollmentRows as $enrollmentRow) {
|
||||||
|
$enrollmentStudentId = (int) ($enrollmentRow['student_id'] ?? 0);
|
||||||
|
if ($enrollmentStudentId > 0 && !isset($enrollmentByStudent[$enrollmentStudentId])) {
|
||||||
|
$enrollmentByStudent[$enrollmentStudentId] = $enrollmentRow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$allergyRows = $db->table('student_allergies')
|
||||||
|
->select('student_id, allergy')
|
||||||
|
->whereIn('student_id', $studentIds)
|
||||||
|
->orderBy('allergy', 'ASC')
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
foreach ($allergyRows as $allergyRow) {
|
||||||
|
$allergiesByStudent[(int) ($allergyRow['student_id'] ?? 0)][] = (string) ($allergyRow['allergy'] ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
$conditionRows = $db->table('student_medical_conditions')
|
||||||
|
->select('student_id, condition_name')
|
||||||
|
->whereIn('student_id', $studentIds)
|
||||||
|
->orderBy('condition_name', 'ASC')
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
foreach ($conditionRows as $conditionRow) {
|
||||||
|
$conditionsByStudent[(int) ($conditionRow['student_id'] ?? 0)][] = (string) ($conditionRow['condition_name'] ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
$scoreHistoryByStudent = (new \App\Services\StudentScoreHistoryService($db))
|
||||||
|
->forStudents($studentIds);
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($studentsRows as &$sr) {
|
foreach ($studentsRows as &$sr) {
|
||||||
$sid = (int) ($sr['id'] ?? 0);
|
$sid = (int) ($sr['id'] ?? 0);
|
||||||
$sr['grade'] = $sid ? (string) ($studentClassModel->getClassSectionsByStudentId($sid, $schoolYear) ?? '') : '';
|
$enrollment = $enrollmentByStudent[$sid] ?? [];
|
||||||
|
$sr['grade'] = implode(', ', array_keys($classAssignmentsByStudent[$sid] ?? []));
|
||||||
|
$sr['enrollment_status'] = (string) ($enrollment['enrollment_status'] ?? '');
|
||||||
|
$sr['allergies'] = $allergiesByStudent[$sid] ?? [];
|
||||||
|
$sr['medical_conditions'] = $conditionsByStudent[$sid] ?? [];
|
||||||
|
$sr['score_history'] = $scoreHistoryByStudent[$sid] ?? [];
|
||||||
}
|
}
|
||||||
unset($sr);
|
unset($sr);
|
||||||
}
|
}
|
||||||
$family['students'] = $studentsRows;
|
$family['students'] = $studentsRows;
|
||||||
|
$family['selected_student_id'] = $studentId;
|
||||||
|
|
||||||
// Financials
|
// Financials
|
||||||
$parentIds = array_map(static fn($g) => (int)($g['user_id'] ?? 0), $guardians);
|
$parentIds = array_map(static fn($g) => (int)($g['user_id'] ?? 0), $guardians);
|
||||||
@@ -427,13 +516,21 @@ class FamilyAdminController extends BaseController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!empty($parentIds)) {
|
if ($canViewInvoices && !empty($parentIds)) {
|
||||||
// Invoices
|
// Invoices
|
||||||
$invRows = $db->table('invoices')
|
$invoiceBuilder = $db->table('invoices')
|
||||||
->select('id, parent_id, invoice_number, status, total_amount, paid_amount, balance, issue_date, due_date')
|
->select('id, parent_id, invoice_number, status, total_amount, paid_amount, balance, issue_date, due_date')
|
||||||
->whereIn('parent_id', $parentIds)
|
->whereIn('parent_id', $parentIds)
|
||||||
->orderBy('issue_date', 'DESC')
|
->orderBy('issue_date', 'DESC');
|
||||||
->get()->getResultArray();
|
if ($schoolYear !== '') {
|
||||||
|
$invoiceBuilder->where('school_year', $schoolYear);
|
||||||
|
}
|
||||||
|
$invRows = $invoiceBuilder->get()->getResultArray();
|
||||||
|
foreach ($invRows as &$invoiceRow) {
|
||||||
|
$invoiceParentId = (int) ($invoiceRow['parent_id'] ?? 0);
|
||||||
|
$invoiceRow['parent_name'] = $gmap[$invoiceParentId] ?? ('Parent #' . $invoiceParentId);
|
||||||
|
}
|
||||||
|
unset($invoiceRow);
|
||||||
$family['invoices'] = $invRows;
|
$family['invoices'] = $invRows;
|
||||||
$invoiceMap = [];
|
$invoiceMap = [];
|
||||||
foreach ($invRows as $ir) {
|
foreach ($invRows as $ir) {
|
||||||
@@ -449,20 +546,43 @@ class FamilyAdminController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Payments
|
// Payments
|
||||||
$payRows = $db->table('payments p')
|
$paymentBuilder = $db->table('payments p')
|
||||||
->select('p.id, p.parent_id, p.invoice_id, p.paid_amount, p.payment_method, p.payment_date, p.status AS payment_status, p.installment_seq, p.number_of_installments, i.invoice_number, i.balance AS invoice_current_balance, i.status AS invoice_status, i.school_year')
|
->select('p.id, p.parent_id, p.invoice_id, p.paid_amount, p.payment_method, p.payment_date, p.status AS payment_status, p.installment_seq, p.number_of_installments, i.invoice_number, i.balance AS invoice_current_balance, i.status AS invoice_status, i.school_year')
|
||||||
->join('invoices i', 'i.id = p.invoice_id', 'inner')
|
->join('invoices i', 'i.id = p.invoice_id', 'inner')
|
||||||
->whereIn('p.parent_id', $parentIds)
|
->whereIn('p.parent_id', $parentIds)
|
||||||
->orderBy('p.payment_date', 'DESC')
|
->orderBy('p.payment_date', 'DESC')
|
||||||
->orderBy('p.id', 'DESC')
|
->orderBy('p.id', 'DESC')
|
||||||
->limit(10)
|
->limit(10);
|
||||||
->get()->getResultArray();
|
if ($schoolYear !== '') {
|
||||||
|
$paymentBuilder->where('i.school_year', $schoolYear);
|
||||||
|
}
|
||||||
|
$payRows = $paymentBuilder->get()->getResultArray();
|
||||||
|
foreach ($payRows as &$paymentRow) {
|
||||||
|
$paymentParentId = (int) ($paymentRow['parent_id'] ?? 0);
|
||||||
|
$paymentRow['parent_name'] = $gmap[$paymentParentId] ?? ('Parent #' . $paymentParentId);
|
||||||
|
}
|
||||||
|
unset($paymentRow);
|
||||||
$family['payments'] = $payRows;
|
$family['payments'] = $payRows;
|
||||||
}
|
}
|
||||||
|
|
||||||
return service('response')->setBody(view('family/card', ['f' => $family]));
|
return service('response')->setBody(view('family/card', ['f' => $family]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function canViewFamilyInvoices(): bool
|
||||||
|
{
|
||||||
|
$roles = array_map(
|
||||||
|
static fn($role): string => strtolower(trim((string) $role)),
|
||||||
|
array_filter(array_merge((array) session()->get('roles'), [session()->get('role')]))
|
||||||
|
);
|
||||||
|
|
||||||
|
return (bool) array_intersect(array_unique($roles), [
|
||||||
|
'admin',
|
||||||
|
'administrator',
|
||||||
|
'administrative staff',
|
||||||
|
'principal',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function composeEmail()
|
public function composeEmail()
|
||||||
{
|
{
|
||||||
$to = trim((string)$this->request->getGet('to'));
|
$to = trim((string)$this->request->getGet('to'));
|
||||||
|
|||||||
@@ -522,7 +522,7 @@ class FilesController extends Controller
|
|||||||
$roles[] = $activeRole;
|
$roles[] = $activeRole;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (['administrator', 'administrative staff', 'principal', 'teacher', 'teacher_assistant'] as $role) {
|
foreach (['administrator', 'administrative staff', 'principal', 'teacher', 'teacher_assistant', 'head fa', 'head of fa', 'head_of_fa', 'financial_contributor'] as $role) {
|
||||||
if (in_array($role, $roles, true)) {
|
if (in_array($role, $roles, true)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1064,13 +1064,66 @@ class InvoiceController extends ResourceController
|
|||||||
$description = $this->invoiceLedgerService->carryForwardDisplayDescription($invoice);
|
$description = $this->invoiceLedgerService->carryForwardDisplayDescription($invoice);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$ledger = null;
|
||||||
|
if ($invoiceId !== null && $invoiceId > 0) {
|
||||||
|
try {
|
||||||
|
$ledger = $isCarryForward
|
||||||
|
? $this->invoiceLedgerService->storedInvoiceLedger($invoiceId)
|
||||||
|
: $this->invoiceLedgerService->calculateInvoice($invoiceId);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
log_message('warning', 'Unable to calculate invoice management projection for invoice {id}: {message}', [
|
||||||
|
'id' => $invoiceId,
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$invoiceAmount = $ledger !== null
|
||||||
|
? (float) ($ledger['total_amount'] ?? 0)
|
||||||
|
: ($invoice !== null ? (float) ($invoice['total_amount'] ?? 0) : 0.0);
|
||||||
|
|
||||||
|
if (! $isCarryForward && $invoice !== null) {
|
||||||
|
$snapshotTuition = array_reduce(
|
||||||
|
array_merge($enrolledKids, $withdrawnKids),
|
||||||
|
static fn (float $sum, array $kid): float => $sum + (float)($kid['tuition_fee'] ?? 0.0),
|
||||||
|
0.0
|
||||||
|
);
|
||||||
|
|
||||||
|
if (abs($snapshotTuition) > 0.00001) {
|
||||||
|
$eventTotal = array_reduce(
|
||||||
|
$this->eventChargesForInvoice($invoice),
|
||||||
|
static fn (float $sum, array $charge): float => $sum + (float)($charge['charged'] ?? 0.0),
|
||||||
|
0.0
|
||||||
|
);
|
||||||
|
$additionalRows = $this->additionalChargeModel
|
||||||
|
->select('charge_type, amount')
|
||||||
|
->where('invoice_id', $invoiceId)
|
||||||
|
->where('status', FinancialStatus::ADDITIONAL_CHARGE_APPLIED)
|
||||||
|
->findAll();
|
||||||
|
$additionalTotal = array_reduce(
|
||||||
|
$additionalRows,
|
||||||
|
static function (float $sum, array $charge): float {
|
||||||
|
$signedAmount = InvoiceLedgerService::signedAdditionalChargeAmount($charge);
|
||||||
|
// The management column is gross charges. Deductions are
|
||||||
|
// applied to Balance Due but are not themselves charges.
|
||||||
|
return $signedAmount > 0 ? $sum + $signedAmount : $sum;
|
||||||
|
},
|
||||||
|
0.0
|
||||||
|
);
|
||||||
|
|
||||||
|
$invoiceAmount = round($snapshotTuition + $eventTotal + $additionalTotal, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'parent_name' => trim((string) ($parent['firstname'] ?? '') . ' ' . (string) ($parent['lastname'] ?? '')),
|
'parent_name' => trim((string) ($parent['firstname'] ?? '') . ' ' . (string) ($parent['lastname'] ?? '')),
|
||||||
'parent_id' => $parentId,
|
'parent_id' => $parentId,
|
||||||
'enrolledKids' => $enrolledKids,
|
'enrolledKids' => $enrolledKids,
|
||||||
'withdrawnKids' => $withdrawnKids,
|
'withdrawnKids' => $withdrawnKids,
|
||||||
'invoice_amount' => $invoice !== null ? (float) ($invoice['total_amount'] ?? 0) : 0.0,
|
'invoice_amount' => $invoiceAmount,
|
||||||
'invoice_balance' => $invoice !== null ? (float) ($invoice['balance'] ?? 0) : 0.0,
|
'invoice_balance' => $ledger !== null
|
||||||
|
? (float) ($ledger['balance'] ?? 0)
|
||||||
|
: ($invoice !== null ? (float) ($invoice['balance'] ?? 0) : 0.0),
|
||||||
'refund_amount' => (float) ($refundSummary['amount'] ?? 0.0),
|
'refund_amount' => (float) ($refundSummary['amount'] ?? 0.0),
|
||||||
'refund_details' => $refundSummary['details'] ?? [],
|
'refund_details' => $refundSummary['details'] ?? [],
|
||||||
'last_updated' => $invoice['updated_at'] ?? null,
|
'last_updated' => $invoice['updated_at'] ?? null,
|
||||||
@@ -1078,7 +1131,9 @@ class InvoiceController extends ResourceController
|
|||||||
'invoice_id' => $invoiceId,
|
'invoice_id' => $invoiceId,
|
||||||
'invoice_number' => $invoice !== null ? (string) ($invoice['invoice_number'] ?? '') : '',
|
'invoice_number' => $invoice !== null ? (string) ($invoice['invoice_number'] ?? '') : '',
|
||||||
'invoice_description' => $description,
|
'invoice_description' => $description,
|
||||||
'invoice_status' => $invoice !== null ? (string) ($invoice['status'] ?? '') : '',
|
'invoice_status' => $ledger !== null
|
||||||
|
? (string) ($ledger['status'] ?? '')
|
||||||
|
: ($invoice !== null ? (string) ($invoice['status'] ?? '') : ''),
|
||||||
'is_carry_forward' => $isCarryForward,
|
'is_carry_forward' => $isCarryForward,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -1352,7 +1407,10 @@ class InvoiceController extends ResourceController
|
|||||||
return ['error' => "Parent associated with the invoice was not found."];
|
return ['error' => "Parent associated with the invoice was not found."];
|
||||||
}
|
}
|
||||||
|
|
||||||
$ledger = $this->invoiceLedgerService->storedInvoiceLedger((int) $invoiceId);
|
// Build the PDF from the canonical calculation so its summary matches the
|
||||||
|
// itemized tuition, event, additional-charge, and payment rows. Stored
|
||||||
|
// projections can be stale until the next write-side recalculation.
|
||||||
|
$ledger = $this->invoiceLedgerService->calculateInvoice((int) $invoiceId);
|
||||||
$invoiceLines = [];
|
$invoiceLines = [];
|
||||||
|
|
||||||
$registeredKids = [];
|
$registeredKids = [];
|
||||||
@@ -1409,12 +1467,12 @@ class InvoiceController extends ResourceController
|
|||||||
/* ============================================================
|
/* ============================================================
|
||||||
* ADDITIONAL CHARGES (itemized) for this invoice
|
* ADDITIONAL CHARGES (itemized) for this invoice
|
||||||
* - uses the additional_charges table for line items
|
* - uses the additional_charges table for line items
|
||||||
* - uses invoice.additional_charge as the authoritative total (Strategy B)
|
* - includes only applied rows, matching InvoiceLedgerService
|
||||||
* ============================================================ */
|
* ============================================================ */
|
||||||
$acRows = $this->additionalChargeModel
|
$acRows = $this->additionalChargeModel
|
||||||
->select('id, charge_type, title, description, amount, due_date, status, created_at')
|
->select('id, charge_type, title, description, amount, due_date, status, created_at')
|
||||||
->where('invoice_id', $invoiceId)
|
->where('invoice_id', $invoiceId)
|
||||||
->where('status !=', 'void')
|
->where('status', FinancialStatus::ADDITIONAL_CHARGE_APPLIED)
|
||||||
->orderBy('created_at', 'ASC')
|
->orderBy('created_at', 'ASC')
|
||||||
->orderBy('id', 'ASC')
|
->orderBy('id', 'ASC')
|
||||||
->findAll();
|
->findAll();
|
||||||
@@ -1423,26 +1481,21 @@ class InvoiceController extends ResourceController
|
|||||||
$additionalChargesTotal = 0.0;
|
$additionalChargesTotal = 0.0;
|
||||||
|
|
||||||
foreach ($acRows as $ac) {
|
foreach ($acRows as $ac) {
|
||||||
$signed = (float)($ac['amount'] ?? 0);
|
$signed = InvoiceLedgerService::signedAdditionalChargeAmount($ac);
|
||||||
$ctype = strtolower((string)($ac['charge_type'] ?? ''));
|
$ctype = strtolower((string)($ac['charge_type'] ?? ''));
|
||||||
|
|
||||||
if (in_array($ctype, ['deduct'], true) && $signed > 0) {
|
|
||||||
$signed = -$signed;
|
|
||||||
} elseif (in_array($ctype, ['add'], true) && $signed < 0) {
|
|
||||||
$signed = abs($signed);
|
|
||||||
}
|
|
||||||
|
|
||||||
$lineDate = !empty($ac['created_at'])
|
$lineDate = !empty($ac['created_at'])
|
||||||
? date('Y-m-d', strtotime($ac['created_at']))
|
? date('Y-m-d', strtotime($ac['created_at']))
|
||||||
: (!empty($invoice['created_at']) ? local_date($invoice['created_at'], 'Y-m-d') : local_date(utc_now(), 'Y-m-d'));
|
: (!empty($invoice['created_at']) ? local_date($invoice['created_at'], 'Y-m-d') : local_date(utc_now(), 'Y-m-d'));
|
||||||
|
|
||||||
$typeLabel = in_array($ctype, ['deduct'], true) ? 'Deduct' : 'Add';
|
$typeLabel = $signed < 0 ? 'Deduct' : 'Add';
|
||||||
$title = ''; //trim((string)($ac['title'] ?? 'Additional Charge'));
|
$title = trim((string)($ac['title'] ?? ''));
|
||||||
$desc = $typeLabel . ': ';
|
$description = trim((string)($ac['description'] ?? ''));
|
||||||
|
$desc = $title !== '' ? $title : 'Additional charge';
|
||||||
if (!empty($ac['description'])) {
|
if ($description !== '' && $description !== $title) {
|
||||||
$desc = $ac['description'];
|
$desc .= ' - ' . $description;
|
||||||
}
|
}
|
||||||
|
$desc = $typeLabel . ': ' . $desc;
|
||||||
|
|
||||||
$additionalChargesTotal += $signed;
|
$additionalChargesTotal += $signed;
|
||||||
|
|
||||||
@@ -1912,6 +1965,20 @@ class InvoiceController extends ResourceController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Additional charges are stored separately from the base invoice rows. They
|
||||||
|
// must be added explicitly to the PDF timeline; previously they were only
|
||||||
|
// used to calculate the fallback tuition amount and summary subtotal.
|
||||||
|
foreach ($additionalChargeLines as $line) {
|
||||||
|
$amount = (float)($line['amount'] ?? 0.0);
|
||||||
|
if (abs($amount) < 0.00001) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dt = $toLocal($line['date'] ?? ($invoice['created_at'] ?? null), false);
|
||||||
|
$description = trim((string)($line['description'] ?? 'Additional charge'));
|
||||||
|
$push($dt, $description !== '' ? $description : 'Additional charge', $amount, 'additional');
|
||||||
|
}
|
||||||
|
|
||||||
// --- Payments (negative) — stored in local time
|
// --- Payments (negative) — stored in local time
|
||||||
foreach ($payments as $payment) {
|
foreach ($payments as $payment) {
|
||||||
$dt = $toLocal($payment['payment_date'] ?? null, false /* local */);
|
$dt = $toLocal($payment['payment_date'] ?? null, false /* local */);
|
||||||
@@ -1996,12 +2063,31 @@ class InvoiceController extends ResourceController
|
|||||||
|
|
||||||
// ======== SUMMARY (bottom) ========
|
// ======== SUMMARY (bottom) ========
|
||||||
$ledger = $data['ledger'] ?? [];
|
$ledger = $data['ledger'] ?? [];
|
||||||
$totalAmount = (float) ($ledger['total_amount'] ?? 0.0);
|
$chargeCategories = ['registration', 'event', 'additional', 'other'];
|
||||||
|
$totalAmount = round(array_reduce(
|
||||||
|
$transactions,
|
||||||
|
static function (float $sum, array $transaction) use ($chargeCategories): float {
|
||||||
|
$amount = (float)($transaction['amount'] ?? 0.0);
|
||||||
|
$isCharge = in_array((string)($transaction['cat'] ?? 'other'), $chargeCategories, true);
|
||||||
|
|
||||||
|
// Total Charges is gross: only positive charge rows belong here.
|
||||||
|
// Negative adjustments remain visible and reduce Balance Due.
|
||||||
|
return $isCharge && $amount > 0 ? $sum + $amount : $sum;
|
||||||
|
},
|
||||||
|
0.0
|
||||||
|
), 2);
|
||||||
$totalDiscount = (float) ($ledger['discount_total'] ?? $totalDiscount);
|
$totalDiscount = (float) ($ledger['discount_total'] ?? $totalDiscount);
|
||||||
$totalPaid = (float) ($ledger['paid_amount'] ?? $totalPaid);
|
$totalPaid = (float) ($ledger['paid_amount'] ?? $totalPaid);
|
||||||
$totalRefund = (float) ($ledger['refund_paid_total'] ?? 0.0);
|
$totalRefund = (float) ($ledger['refund_paid_total'] ?? 0.0);
|
||||||
$displayBalance = (float) ($ledger['balance'] ?? 0.0);
|
// The PDF balance must reconcile exactly to its visible rows: positive
|
||||||
$creditOverpay = (float) ($ledger['customer_credit'] ?? 0.0);
|
// amounts add to the balance and negative amounts deduct from it.
|
||||||
|
$signedRowBalance = round(array_reduce(
|
||||||
|
$transactions,
|
||||||
|
static fn (float $sum, array $transaction): float => $sum + (float)($transaction['amount'] ?? 0.0),
|
||||||
|
0.0
|
||||||
|
), 2);
|
||||||
|
$displayBalance = max(0.0, $signedRowBalance);
|
||||||
|
$creditOverpay = max(0.0, -$signedRowBalance);
|
||||||
|
|
||||||
$pdf->Ln(5);
|
$pdf->Ln(5);
|
||||||
$labelWidth = 165;
|
$labelWidth = 165;
|
||||||
@@ -2293,6 +2379,10 @@ private function getGradeLevel($grade): array
|
|||||||
'administrative staff',
|
'administrative staff',
|
||||||
'principal',
|
'principal',
|
||||||
'admin',
|
'admin',
|
||||||
|
'head fa',
|
||||||
|
'head of fa',
|
||||||
|
'head_of_fa',
|
||||||
|
'financial_contributor',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if ($userId <= 0 || (! $isStaff && $userId !== $parentId)) {
|
if ($userId <= 0 || (! $isStaff && $userId !== $parentId)) {
|
||||||
|
|||||||
@@ -0,0 +1,534 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controllers\View;
|
||||||
|
|
||||||
|
use App\Controllers\BaseController;
|
||||||
|
use App\Models\ApplicationModel;
|
||||||
|
use App\Models\JobPositionModel;
|
||||||
|
use App\Models\JobTemplateModel;
|
||||||
|
use App\Models\JobTemplateVersionModel;
|
||||||
|
use App\Services\EmailService;
|
||||||
|
use App\Services\PhoneFormatterService;
|
||||||
|
|
||||||
|
class JobPostingController extends BaseController
|
||||||
|
{
|
||||||
|
private const ADMIN_FILTER_ROUTE = 'administrator/job-postings';
|
||||||
|
private array $positionStatuses = ['draft', 'open', 'closed', 'filled'];
|
||||||
|
private array $applicationStatuses = ['new', 'reviewed', 'contacted', 'rejected', 'hired'];
|
||||||
|
|
||||||
|
protected JobTemplateModel $templates;
|
||||||
|
protected JobTemplateVersionModel $templateVersions;
|
||||||
|
protected JobPositionModel $positions;
|
||||||
|
protected ApplicationModel $applications;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
helper(['form', 'url', 'text']);
|
||||||
|
$this->templates = new JobTemplateModel();
|
||||||
|
$this->templateVersions = new JobTemplateVersionModel();
|
||||||
|
$this->positions = new JobPositionModel();
|
||||||
|
$this->applications = new ApplicationModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function publicIndex()
|
||||||
|
{
|
||||||
|
return view('careers', [
|
||||||
|
'positions' => $this->positions->openPositions(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(string $positionId)
|
||||||
|
{
|
||||||
|
$position = $this->positions->where('position_id', $positionId)->where('status', 'open')->first();
|
||||||
|
if (!$position) {
|
||||||
|
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound('Position not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('jobs/show', ['position' => $position]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function trackDetailsClick(string $positionId)
|
||||||
|
{
|
||||||
|
$position = $this->positions->where('position_id', $positionId)->where('status', 'open')->first();
|
||||||
|
if (!$position) {
|
||||||
|
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound('Position not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->positions->recordDetailsClick($positionId);
|
||||||
|
|
||||||
|
return redirect()->to(site_url('careers/' . rawurlencode($positionId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function apply(string $positionId)
|
||||||
|
{
|
||||||
|
$position = $this->positions->where('position_id', $positionId)->where('status', 'open')->first();
|
||||||
|
if (!$position) {
|
||||||
|
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound('Position not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('jobs/apply', ['position' => $position]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function submitApplication(string $positionId)
|
||||||
|
{
|
||||||
|
$position = $this->positions->where('position_id', $positionId)->where('status', 'open')->first();
|
||||||
|
if (!$position) {
|
||||||
|
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound('Position not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
$rules = [
|
||||||
|
'first_name' => 'required|regex_match[/^[a-zA-Z\s-]+$/]|min_length[2]|max_length[30]',
|
||||||
|
'last_name' => 'required|regex_match[/^[a-zA-Z\s-]+$/]|min_length[2]|max_length[30]',
|
||||||
|
'email' => 'required|valid_email|max_length[50]',
|
||||||
|
'phone' => 'required|regex_match[/^[\d\s\-\(\)\.]+$/]|min_length[10]|max_length[20]',
|
||||||
|
'resume' => 'uploaded[resume]|max_size[resume,5120]|ext_in[resume,pdf,doc,docx]',
|
||||||
|
];
|
||||||
|
$messages = [
|
||||||
|
'first_name' => [
|
||||||
|
'regex_match' => 'First name may only contain letters, spaces, and dashes.',
|
||||||
|
'min_length' => 'First name must be at least 2 characters.',
|
||||||
|
'max_length' => 'First name must be 30 characters or fewer.',
|
||||||
|
],
|
||||||
|
'last_name' => [
|
||||||
|
'regex_match' => 'Last name may only contain letters, spaces, and dashes.',
|
||||||
|
'min_length' => 'Last name must be at least 2 characters.',
|
||||||
|
'max_length' => 'Last name must be 30 characters or fewer.',
|
||||||
|
],
|
||||||
|
'email' => [
|
||||||
|
'valid_email' => 'Please enter a valid email address.',
|
||||||
|
'max_length' => 'Email must be 50 characters or fewer.',
|
||||||
|
],
|
||||||
|
'phone' => [
|
||||||
|
'regex_match' => 'Please enter a valid 10-digit phone number, for example 123-456-7890.',
|
||||||
|
'min_length' => 'Please enter a valid 10-digit phone number, for example 123-456-7890.',
|
||||||
|
'max_length' => 'Please enter a valid 10-digit phone number, for example 123-456-7890.',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!$this->validate($rules, $messages)) {
|
||||||
|
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
||||||
|
}
|
||||||
|
|
||||||
|
$formattedPhone = (new PhoneFormatterService())->formatPhoneNumber((string) $this->request->getPost('phone'));
|
||||||
|
if ($formattedPhone === null) {
|
||||||
|
return redirect()->back()->withInput()->with('errors', [
|
||||||
|
'phone' => 'Please enter a valid 10-digit phone number, for example 123-456-7890.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$file = $this->request->getFile('resume');
|
||||||
|
$uploadDir = WRITEPATH . 'uploads/job_applications';
|
||||||
|
if (!is_dir($uploadDir)) {
|
||||||
|
mkdir($uploadDir, 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$newName = $file->getRandomName();
|
||||||
|
$file->move($uploadDir, $newName);
|
||||||
|
$relativePath = 'job_applications/' . $newName;
|
||||||
|
|
||||||
|
$application = [
|
||||||
|
'application_id' => $this->newUuid(),
|
||||||
|
'position_id' => $position['position_id'],
|
||||||
|
'first_name' => trim((string) $this->request->getPost('first_name')),
|
||||||
|
'last_name' => trim((string) $this->request->getPost('last_name')),
|
||||||
|
'email' => trim((string) $this->request->getPost('email')),
|
||||||
|
'phone' => $formattedPhone,
|
||||||
|
'resume_file_url' => $relativePath,
|
||||||
|
'status' => 'new',
|
||||||
|
'submitted_at' => utc_now(),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!$this->applications->insert($application)) {
|
||||||
|
return redirect()->back()->withInput()->with('error', 'Unable to submit application.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->sendApplicationEmails($application, $position);
|
||||||
|
|
||||||
|
return redirect()->to(site_url('careers/application-received'))->with('success', 'Application submitted.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function applicationReceived()
|
||||||
|
{
|
||||||
|
return view('jobs/received');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function templates()
|
||||||
|
{
|
||||||
|
return view('jobs/admin/templates', [
|
||||||
|
'templates' => $this->templates->orderBy('updated_at', 'DESC')->findAll(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function newTemplate()
|
||||||
|
{
|
||||||
|
return view('jobs/admin/template_form', ['template' => null, 'versions' => []]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createTemplate()
|
||||||
|
{
|
||||||
|
$payload = $this->templatePayload();
|
||||||
|
if ($payload === null) {
|
||||||
|
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload['template_id'] = $this->newUuid();
|
||||||
|
$payload['version'] = 1;
|
||||||
|
$payload['is_active'] = 1;
|
||||||
|
$payload['created_by'] = $this->currentUserId();
|
||||||
|
|
||||||
|
if (!$this->templates->insert($payload)) {
|
||||||
|
return redirect()->back()->withInput()->with('error', 'Unable to create template.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->snapshotTemplate($payload);
|
||||||
|
return redirect()->to(site_url(self::ADMIN_FILTER_ROUTE . '/templates'))->with('success', 'Template created.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function editTemplate(string $templateId)
|
||||||
|
{
|
||||||
|
$template = $this->templates->find($templateId);
|
||||||
|
if (!$template) {
|
||||||
|
return redirect()->to(site_url(self::ADMIN_FILTER_ROUTE . '/templates'))->with('error', 'Template not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('jobs/admin/template_form', [
|
||||||
|
'template' => $template,
|
||||||
|
'versions' => $this->templateVersions->where('template_id', $templateId)->orderBy('version', 'DESC')->findAll(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateTemplate(string $templateId)
|
||||||
|
{
|
||||||
|
$template = $this->templates->find($templateId);
|
||||||
|
if (!$template) {
|
||||||
|
return redirect()->to(site_url(self::ADMIN_FILTER_ROUTE . '/templates'))->with('error', 'Template not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = $this->templatePayload();
|
||||||
|
if ($payload === null) {
|
||||||
|
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->request->getPost('save_mode') === 'new_version') {
|
||||||
|
$payload['version'] = ((int) ($template['version'] ?? 1)) + 1;
|
||||||
|
} else {
|
||||||
|
$payload['version'] = (int) ($template['version'] ?? 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->templates->update($templateId, $payload)) {
|
||||||
|
return redirect()->back()->withInput()->with('error', 'Unable to update template.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload['template_id'] = $templateId;
|
||||||
|
$this->snapshotTemplate($payload);
|
||||||
|
return redirect()->to(site_url(self::ADMIN_FILTER_ROUTE . '/templates'))->with('success', 'Template updated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function archiveTemplate(string $templateId)
|
||||||
|
{
|
||||||
|
$this->templates->update($templateId, ['is_active' => 0]);
|
||||||
|
return redirect()->to(site_url(self::ADMIN_FILTER_ROUTE . '/templates'))->with('success', 'Template archived.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function restoreTemplateVersion(string $versionId)
|
||||||
|
{
|
||||||
|
$version = $this->templateVersions->find($versionId);
|
||||||
|
if (!$version) {
|
||||||
|
return redirect()->back()->with('error', 'Template version not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = $this->templateVersionPayload($version);
|
||||||
|
$payload['version'] = (int) $version['version'];
|
||||||
|
$this->templates->update($version['template_id'], $payload);
|
||||||
|
|
||||||
|
return redirect()->to(site_url(self::ADMIN_FILTER_ROUTE . '/templates/' . $version['template_id'] . '/edit'))->with('success', 'Template version restored.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function positions()
|
||||||
|
{
|
||||||
|
return view('jobs/admin/positions', [
|
||||||
|
'positions' => $this->positions->adminPositions(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function newPosition()
|
||||||
|
{
|
||||||
|
$template = null;
|
||||||
|
$templateId = (string) $this->request->getGet('template_id');
|
||||||
|
if ($templateId !== '') {
|
||||||
|
$template = $this->templates->find($templateId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('jobs/admin/position_form', [
|
||||||
|
'position' => $template ? $this->templateVersionPayload($template) + ['template_id' => $templateId, 'status' => 'draft'] : null,
|
||||||
|
'statuses' => $this->positionStatuses,
|
||||||
|
'templates' => $this->templates->where('is_active', 1)->orderBy('title', 'ASC')->findAll(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createPosition()
|
||||||
|
{
|
||||||
|
$payload = $this->positionPayload();
|
||||||
|
if ($payload === null) {
|
||||||
|
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload['position_id'] = $this->newUuid();
|
||||||
|
$payload['posted_by'] = $this->currentUserId();
|
||||||
|
if ($payload['status'] === 'open') {
|
||||||
|
$payload['posted_at'] = utc_now();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->positions->insert($payload)) {
|
||||||
|
return redirect()->back()->withInput()->with('error', 'Unable to create position.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->to(site_url(self::ADMIN_FILTER_ROUTE . '/positions'))->with('success', 'Position created.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function editPosition(string $positionId)
|
||||||
|
{
|
||||||
|
$position = $this->positions->find($positionId);
|
||||||
|
if (!$position) {
|
||||||
|
return redirect()->to(site_url(self::ADMIN_FILTER_ROUTE . '/positions'))->with('error', 'Position not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('jobs/admin/position_form', [
|
||||||
|
'position' => $position,
|
||||||
|
'statuses' => $this->positionStatuses,
|
||||||
|
'templates' => $this->templates->where('is_active', 1)->orderBy('title', 'ASC')->findAll(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatePosition(string $positionId)
|
||||||
|
{
|
||||||
|
$position = $this->positions->find($positionId);
|
||||||
|
if (!$position) {
|
||||||
|
return redirect()->to(site_url(self::ADMIN_FILTER_ROUTE . '/positions'))->with('error', 'Position not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = $this->positionPayload();
|
||||||
|
if ($payload === null) {
|
||||||
|
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($payload['status'] === 'open' && (($position['status'] ?? '') !== 'open' || empty($position['posted_at']))) {
|
||||||
|
$payload['posted_at'] = utc_now();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->positions->update($positionId, $payload)) {
|
||||||
|
return redirect()->back()->withInput()->with('error', 'Unable to update position.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->to(site_url(self::ADMIN_FILTER_ROUTE . '/positions'))->with('success', 'Position updated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function applications()
|
||||||
|
{
|
||||||
|
$builder = $this->applications
|
||||||
|
->select('applications.*, job_positions.title AS position_title, job_positions.department')
|
||||||
|
->join('job_positions', 'job_positions.position_id = applications.position_id', 'left');
|
||||||
|
|
||||||
|
$status = (string) $this->request->getGet('status');
|
||||||
|
if (in_array($status, $this->applicationStatuses, true)) {
|
||||||
|
$builder->where('applications.status', $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
$positionId = (string) $this->request->getGet('position_id');
|
||||||
|
if ($positionId !== '') {
|
||||||
|
$builder->where('applications.position_id', $positionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('jobs/admin/applications', [
|
||||||
|
'applications' => $builder->orderBy('submitted_at', 'DESC')->findAll(),
|
||||||
|
'positions' => $this->positions->orderBy('title', 'ASC')->findAll(),
|
||||||
|
'statuses' => $this->applicationStatuses,
|
||||||
|
'selectedStatus' => $status,
|
||||||
|
'selectedPosition' => $positionId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateApplication(string $applicationId)
|
||||||
|
{
|
||||||
|
$status = (string) $this->request->getPost('status');
|
||||||
|
if (!in_array($status, $this->applicationStatuses, true)) {
|
||||||
|
return redirect()->back()->with('error', 'Invalid application status.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->applications->update($applicationId, [
|
||||||
|
'status' => $status,
|
||||||
|
'admin_notes' => (string) $this->request->getPost('admin_notes'),
|
||||||
|
])) {
|
||||||
|
return redirect()->back()->with('error', 'Unable to update application.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$application = $this->applications
|
||||||
|
->select('applications.*, job_positions.title AS position_title')
|
||||||
|
->join('job_positions', 'job_positions.position_id = applications.position_id', 'left')
|
||||||
|
->find($applicationId);
|
||||||
|
|
||||||
|
if ($application) {
|
||||||
|
$this->sendApplicationStatusEmail($application);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->back()->with('success', 'Application updated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resume(string $applicationId)
|
||||||
|
{
|
||||||
|
$application = $this->applications->find($applicationId);
|
||||||
|
if (!$application) {
|
||||||
|
return redirect()->back()->with('error', 'Application not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = WRITEPATH . 'uploads/' . ltrim((string) $application['resume_file_url'], '/');
|
||||||
|
if (!is_file($path)) {
|
||||||
|
return redirect()->back()->with('error', 'Resume file not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->response->download($path, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function templatePayload(): ?array
|
||||||
|
{
|
||||||
|
if (!$this->validate(['title' => 'required|max_length[255]'])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->sharedPostingPayload();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function positionPayload(): ?array
|
||||||
|
{
|
||||||
|
if (!$this->validate([
|
||||||
|
'title' => 'required|max_length[255]',
|
||||||
|
'status' => 'required|in_list[draft,open,closed,filled]',
|
||||||
|
])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = $this->sharedPostingPayload();
|
||||||
|
$payload['template_id'] = $this->request->getPost('template_id') ?: null;
|
||||||
|
$payload['status'] = (string) $this->request->getPost('status');
|
||||||
|
|
||||||
|
return $payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sharedPostingPayload(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'title' => trim((string) $this->request->getPost('title')),
|
||||||
|
'description' => trim((string) $this->request->getPost('description')),
|
||||||
|
'department' => trim((string) $this->request->getPost('department')),
|
||||||
|
'location' => trim((string) $this->request->getPost('location')),
|
||||||
|
'employment_type' => trim((string) $this->request->getPost('employment_type')),
|
||||||
|
'responsibilities' => trim((string) $this->request->getPost('responsibilities')),
|
||||||
|
'requirements' => trim((string) $this->request->getPost('requirements')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function snapshotTemplate(array $template): void
|
||||||
|
{
|
||||||
|
$this->templateVersions
|
||||||
|
->where('template_id', $template['template_id'])
|
||||||
|
->where('version', (int) ($template['version'] ?? 1))
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
$snapshot = $this->templateVersionPayload($template);
|
||||||
|
$snapshot['version_id'] = $this->newUuid();
|
||||||
|
$snapshot['template_id'] = $template['template_id'];
|
||||||
|
$snapshot['version'] = (int) ($template['version'] ?? 1);
|
||||||
|
$snapshot['saved_by'] = $this->currentUserId();
|
||||||
|
$snapshot['saved_at'] = utc_now();
|
||||||
|
|
||||||
|
$this->templateVersions->insert($snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function templateVersionPayload(array $data): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'title' => (string) ($data['title'] ?? ''),
|
||||||
|
'description' => (string) ($data['description'] ?? ''),
|
||||||
|
'department' => (string) ($data['department'] ?? ''),
|
||||||
|
'location' => (string) ($data['location'] ?? ''),
|
||||||
|
'employment_type' => (string) ($data['employment_type'] ?? ''),
|
||||||
|
'responsibilities' => (string) ($data['responsibilities'] ?? ''),
|
||||||
|
'requirements' => (string) ($data['requirements'] ?? ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sendApplicationEmails(array $application, array $position): void
|
||||||
|
{
|
||||||
|
$fullName = trim($application['first_name'] . ' ' . $application['last_name']);
|
||||||
|
$body = view('emails/job_application_confirmation', [
|
||||||
|
'name' => $fullName,
|
||||||
|
'position' => $position,
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
(new EmailService())->send($application['email'], 'Application received: ' . $position['title'], $body, 'general');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
log_message('error', 'Job application confirmation email failed: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
$adminRecipients = array_unique(array_filter([
|
||||||
|
trim((string) env('JOBS_ADMIN_EMAIL', '')),
|
||||||
|
trim((string) env('PRINCIPAL_EMAIL', '')),
|
||||||
|
], static fn (string $email): bool => $email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)));
|
||||||
|
|
||||||
|
if ($adminRecipients !== []) {
|
||||||
|
$adminBody = '<p>New application received for <strong>' . esc($position['title']) . '</strong>.</p>'
|
||||||
|
. '<p>Applicant: ' . esc($fullName) . '<br>Email: ' . esc($application['email']) . '<br>Phone: ' . esc($application['phone']) . '</p>';
|
||||||
|
|
||||||
|
$emailService = new EmailService();
|
||||||
|
try {
|
||||||
|
foreach ($adminRecipients as $adminEmail) {
|
||||||
|
$emailService->send($adminEmail, 'New job application: ' . $position['title'], $adminBody, 'general');
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
log_message('error', 'Job application admin email failed: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sendApplicationStatusEmail(array $application): void
|
||||||
|
{
|
||||||
|
$email = trim((string) ($application['email'] ?? ''));
|
||||||
|
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = (string) ($application['status'] ?? '');
|
||||||
|
$statusLabel = ucfirst(str_replace('_', ' ', $status));
|
||||||
|
$positionTitle = (string) ($application['position_title'] ?? 'the volunteer position');
|
||||||
|
$fullName = trim((string) (($application['first_name'] ?? '') . ' ' . ($application['last_name'] ?? '')));
|
||||||
|
|
||||||
|
$body = view('emails/job_application_status_update', [
|
||||||
|
'name' => $fullName !== '' ? $fullName : 'Applicant',
|
||||||
|
'positionTitle' => $positionTitle,
|
||||||
|
'statusLabel' => $statusLabel,
|
||||||
|
'adminNotes' => trim((string) ($application['admin_notes'] ?? '')),
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
(new EmailService())->send($email, 'Application status update: ' . $positionTitle, $body, 'general');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
log_message('error', 'Job application status email failed: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function currentUserId(): ?int
|
||||||
|
{
|
||||||
|
$userId = (int) session()->get('user_id');
|
||||||
|
return $userId > 0 ? $userId : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function newUuid(): string
|
||||||
|
{
|
||||||
|
$bytes = random_bytes(16);
|
||||||
|
$bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40);
|
||||||
|
$bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80);
|
||||||
|
|
||||||
|
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,8 @@ use App\Models\ScoreCommentModel;
|
|||||||
use App\Models\AttendanceRecordModel;
|
use App\Models\AttendanceRecordModel;
|
||||||
use App\Models\AttendanceDayModel;
|
use App\Models\AttendanceDayModel;
|
||||||
use App\Models\CalendarModel;
|
use App\Models\CalendarModel;
|
||||||
|
use App\Models\JobPositionModel;
|
||||||
|
use App\Models\PreferencesModel;
|
||||||
use \Config\Database;
|
use \Config\Database;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use DateTimeZone;
|
use DateTimeZone;
|
||||||
@@ -774,39 +776,6 @@ class LandingPageController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// 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)
|
// Fetch Student Information (no filtering needed by school year or semester)
|
||||||
|
|
||||||
$students = $this->db->table('students')
|
$students = $this->db->table('students')
|
||||||
@@ -822,6 +791,8 @@ class LandingPageController extends BaseController
|
|||||||
}
|
}
|
||||||
unset($student);
|
unset($student);
|
||||||
|
|
||||||
|
$notifications = $this->parentDashboardNotifications((int) $parentId, (int) session()->get('user_id'));
|
||||||
|
|
||||||
|
|
||||||
// Fetch Attendance Records (filtered by most recent school year and semester)
|
// Fetch Attendance Records (filtered by most recent school year and semester)
|
||||||
$attendanceData = $this->db->table('attendance_data')
|
$attendanceData = $this->db->table('attendance_data')
|
||||||
@@ -867,6 +838,17 @@ class LandingPageController extends BaseController
|
|||||||
->get()
|
->get()
|
||||||
->getRowArray();
|
->getRowArray();
|
||||||
$paymentBalance = (float) ($paymentRow['account_balance'] ?? 0);
|
$paymentBalance = (float) ($paymentRow['account_balance'] ?? 0);
|
||||||
|
$openPositions = [];
|
||||||
|
|
||||||
|
if (! $this->parentHidesJobOpeningsPopup($parentId)) {
|
||||||
|
try {
|
||||||
|
if ($this->db->tableExists('job_positions')) {
|
||||||
|
$openPositions = (new JobPositionModel())->openPositions();
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
log_message('error', 'Unable to load parent dashboard job positions: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Pass data to the view, including the deadlines
|
// Pass data to the view, including the deadlines
|
||||||
return view('/landing_page/parent_dashboard', [
|
return view('/landing_page/parent_dashboard', [
|
||||||
@@ -878,9 +860,259 @@ class LandingPageController extends BaseController
|
|||||||
'lastDayOfRegistration' => $this->lastDayOfRegistration, // Add the enrollment deadline to the view
|
'lastDayOfRegistration' => $this->lastDayOfRegistration, // Add the enrollment deadline to the view
|
||||||
'withdrawalDeadline' => $this->refundDeadline, // Add the refund deadline to the view
|
'withdrawalDeadline' => $this->refundDeadline, // Add the refund deadline to the view
|
||||||
'paymentBalance' => $paymentBalance, // 🔹 New
|
'paymentBalance' => $paymentBalance, // 🔹 New
|
||||||
|
'openPositions' => $openPositions,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function hideJobOpeningsPopup()
|
||||||
|
{
|
||||||
|
$userId = (int) (session()->get('user_id') ?? 0);
|
||||||
|
if ($userId <= 0) {
|
||||||
|
return $this->response->setStatusCode(401)->setJSON([
|
||||||
|
'ok' => false,
|
||||||
|
'error' => 'Please log in to update this setting.',
|
||||||
|
'csrf_token' => csrf_token(),
|
||||||
|
'csrf_hash' => csrf_hash(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
! $this->db->tableExists('user_preferences')
|
||||||
|
|| ! $this->db->fieldExists('hide_job_openings_popup', 'user_preferences')
|
||||||
|
) {
|
||||||
|
return $this->response->setStatusCode(500)->setJSON([
|
||||||
|
'ok' => false,
|
||||||
|
'error' => 'Preference storage is not ready.',
|
||||||
|
'csrf_token' => csrf_token(),
|
||||||
|
'csrf_hash' => csrf_hash(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$hide = (string) $this->request->getPost('hide_job_openings_popup') === '1' ? 1 : 0;
|
||||||
|
$preferencesModel = new PreferencesModel();
|
||||||
|
$existing = $preferencesModel->where('user_id', $userId)->first();
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
$preferencesModel->update((int) $existing['id'], [
|
||||||
|
'hide_job_openings_popup' => $hide,
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
$preferencesModel->insert([
|
||||||
|
'user_id' => $userId,
|
||||||
|
'hide_job_openings_popup' => $hide,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->response->setJSON([
|
||||||
|
'ok' => true,
|
||||||
|
'hide_job_openings_popup' => $hide,
|
||||||
|
'csrf_token' => csrf_token(),
|
||||||
|
'csrf_hash' => csrf_hash(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parentHidesJobOpeningsPopup(int $parentId): bool
|
||||||
|
{
|
||||||
|
if ($parentId <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (
|
||||||
|
! $this->db->tableExists('user_preferences')
|
||||||
|
|| ! $this->db->fieldExists('hide_job_openings_popup', 'user_preferences')
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$preferences = (new PreferencesModel())->where('user_id', $parentId)->first();
|
||||||
|
|
||||||
|
return ! empty($preferences['hide_job_openings_popup']);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
log_message('error', 'Unable to load parent job openings popup preference: ' . $e->getMessage());
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parentDashboardNotifications(int $parentId, int $userId): array
|
||||||
|
{
|
||||||
|
$notifications = array_merge(
|
||||||
|
$this->parentAttendanceNotifications($parentId),
|
||||||
|
$this->activeParentBroadcastNotifications($parentId, $userId)
|
||||||
|
);
|
||||||
|
|
||||||
|
usort($notifications, static function (array $a, array $b): int {
|
||||||
|
return strtotime((string) ($b['created_at'] ?? '')) <=> strtotime((string) ($a['created_at'] ?? ''));
|
||||||
|
});
|
||||||
|
|
||||||
|
return array_slice($notifications, 0, 25);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parentAttendanceNotifications(int $parentId): array
|
||||||
|
{
|
||||||
|
if (
|
||||||
|
$parentId <= 0
|
||||||
|
|| ! $this->db->tableExists('parent_notifications')
|
||||||
|
|| ! $this->db->tableExists('students')
|
||||||
|
) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = $this->db->table('parent_notifications pn')
|
||||||
|
->select([
|
||||||
|
'pn.id',
|
||||||
|
'pn.student_id',
|
||||||
|
'pn.code',
|
||||||
|
'pn.incident_date',
|
||||||
|
'pn.channel',
|
||||||
|
'pn.subject',
|
||||||
|
'pn.status',
|
||||||
|
'pn.response',
|
||||||
|
'pn.semester',
|
||||||
|
'pn.school_year',
|
||||||
|
'pn.created_at',
|
||||||
|
'pn.updated_at',
|
||||||
|
'students.firstname',
|
||||||
|
'students.lastname',
|
||||||
|
])
|
||||||
|
->join('students', 'students.id = pn.student_id')
|
||||||
|
->where('students.parent_id', $parentId)
|
||||||
|
->where('pn.school_year', (string) $this->schoolYear)
|
||||||
|
->where('pn.semester', (string) $this->semester)
|
||||||
|
->orderBy('COALESCE(pn.updated_at, pn.created_at)', 'DESC', false)
|
||||||
|
->orderBy('pn.id', 'DESC')
|
||||||
|
->limit(100)
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
|
||||||
|
$notifications = [];
|
||||||
|
$seen = [];
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$key = implode('|', [
|
||||||
|
(string) ($row['student_id'] ?? ''),
|
||||||
|
(string) ($row['code'] ?? ''),
|
||||||
|
(string) ($row['incident_date'] ?? ''),
|
||||||
|
(string) ($row['subject'] ?? ''),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (isset($seen[$key])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$seen[$key] = true;
|
||||||
|
$studentName = trim((string) ($row['firstname'] ?? '') . ' ' . (string) ($row['lastname'] ?? ''));
|
||||||
|
$code = strtoupper((string) ($row['code'] ?? ''));
|
||||||
|
$incidentDate = (string) ($row['incident_date'] ?? '');
|
||||||
|
$subject = trim((string) ($row['subject'] ?? ''));
|
||||||
|
|
||||||
|
$title = $subject !== '' ? $subject : $this->parentNotificationCodeLabel($code);
|
||||||
|
if ($studentName !== '') {
|
||||||
|
$title .= ' - ' . $studentName;
|
||||||
|
}
|
||||||
|
|
||||||
|
$messageParts = [];
|
||||||
|
if ($incidentDate !== '') {
|
||||||
|
$messageParts[] = 'Incident date: ' . $incidentDate;
|
||||||
|
}
|
||||||
|
if (!empty($row['status'])) {
|
||||||
|
$messageParts[] = 'Status: ' . ucfirst((string) $row['status']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$notifications[] = [
|
||||||
|
'id' => $row['id'] ?? null,
|
||||||
|
'title' => $title,
|
||||||
|
'message' => implode(' | ', $messageParts),
|
||||||
|
'created_at' => $row['updated_at'] ?: ($row['created_at'] ?? null),
|
||||||
|
'notification_type' => $this->parentNotificationCodeLabel($code),
|
||||||
|
'status' => $row['status'] ?? null,
|
||||||
|
'code' => $code,
|
||||||
|
'incident_date' => $incidentDate,
|
||||||
|
'student_name' => $studentName,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (count($notifications) >= 25) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $notifications;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function activeParentBroadcastNotifications(int $parentId, int $userId): array
|
||||||
|
{
|
||||||
|
if ($parentId <= 0 || ! $this->db->tableExists('notifications')) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$builder = $this->db->table('notifications')
|
||||||
|
->select([
|
||||||
|
'notifications.id',
|
||||||
|
'notifications.title',
|
||||||
|
'notifications.message',
|
||||||
|
'notifications.target_group',
|
||||||
|
'notifications.created_at',
|
||||||
|
'notifications.expires_at',
|
||||||
|
"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 IN (' . implode(',', array_unique([$parentId, $userId])) . ')',
|
||||||
|
'left'
|
||||||
|
)
|
||||||
|
->groupStart()
|
||||||
|
->whereIn('notifications.target_group', ['parent', 'everyone'])
|
||||||
|
->orWhere('user_notifications.user_id IS NOT NULL')
|
||||||
|
->groupEnd()
|
||||||
|
->where('notifications.deleted_at IS NULL')
|
||||||
|
->groupStart()
|
||||||
|
->where('notifications.scheduled_at IS NULL')
|
||||||
|
->orWhere('notifications.scheduled_at <=', utc_now())
|
||||||
|
->groupEnd()
|
||||||
|
->groupStart()
|
||||||
|
->where('notifications.expires_at IS NULL')
|
||||||
|
->orWhere('notifications.expires_at >', utc_now())
|
||||||
|
->groupEnd();
|
||||||
|
|
||||||
|
if ($this->db->fieldExists('school_year', 'notifications')) {
|
||||||
|
$builder->where('notifications.school_year', (string) $this->schoolYear);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->db->fieldExists('semester', 'notifications')) {
|
||||||
|
$builder->groupStart()
|
||||||
|
->where('notifications.semester', null)
|
||||||
|
->orWhere('notifications.semester', '')
|
||||||
|
->orWhere('notifications.semester', (string) $this->semester)
|
||||||
|
->groupEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $builder
|
||||||
|
->groupBy('notifications.id')
|
||||||
|
->orderBy('notifications.created_at', 'DESC')
|
||||||
|
->limit(25)
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parentNotificationCodeLabel(string $code): string
|
||||||
|
{
|
||||||
|
return match ($code) {
|
||||||
|
'ABS_1' => 'Unreported absence',
|
||||||
|
'ABS_2' => 'Repeated absences',
|
||||||
|
'ABS_3' => 'Attendance warning',
|
||||||
|
'ABS_4' => 'Attendance review',
|
||||||
|
'LATE_2' => 'Repeated lateness',
|
||||||
|
'LATE_3' => 'Lateness warning',
|
||||||
|
'LATE_4' => 'Lateness review',
|
||||||
|
'MIX_L2A1' => 'Attendance warning',
|
||||||
|
default => 'Parent notice',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
public function guest()
|
public function guest()
|
||||||
{
|
{
|
||||||
return view('/landing_page/guest_dashboard');
|
return view('/landing_page/guest_dashboard');
|
||||||
|
|||||||
@@ -22,33 +22,14 @@ class NavBuilderController extends BaseController
|
|||||||
|
|
||||||
protected function ensureAdmin(): void
|
protected function ensureAdmin(): void
|
||||||
{
|
{
|
||||||
$sessionRole = session()->get('role'); // could be a string or array in your app
|
$session = session();
|
||||||
$roleNames = is_array($sessionRole) ? $sessionRole : [$sessionRole];
|
$roles = array_filter(array_merge(
|
||||||
$roleNames = array_values(array_filter(array_map('strval', $roleNames)));
|
(array) $session->get('roles'),
|
||||||
|
(array) $session->get('role')
|
||||||
|
));
|
||||||
|
|
||||||
if (empty($roleNames)) {
|
$normalizedRoles = array_map(static fn ($role) => strtolower(trim((string) $role)), $roles);
|
||||||
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
|
if (!in_array('administrator', $normalizedRoles, true)) {
|
||||||
}
|
|
||||||
|
|
||||||
$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();
|
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,6 +87,15 @@ public function save()
|
|||||||
if ($menuParentId !== null) {
|
if ($menuParentId !== null) {
|
||||||
$parent = $this->items->select('id')->where('id', $menuParentId)->first();
|
$parent = $this->items->select('id')->where('id', $menuParentId)->first();
|
||||||
if (!$parent) {
|
if (!$parent) {
|
||||||
|
if ($this->wantsJson()) {
|
||||||
|
return $this->response
|
||||||
|
->setStatusCode(422)
|
||||||
|
->setJSON([
|
||||||
|
'ok' => false,
|
||||||
|
'message' => 'Selected parent does not exist.',
|
||||||
|
'csrf' => $this->csrfPayload(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
return redirect()->back()->with('error', 'Selected parent does not exist.')->withInput();
|
return redirect()->back()->with('error', 'Selected parent does not exist.')->withInput();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -139,9 +129,33 @@ public function save()
|
|||||||
}
|
}
|
||||||
|
|
||||||
$this->service->clearCache();
|
$this->service->clearCache();
|
||||||
|
if ($this->wantsJson()) {
|
||||||
|
return $this->response->setJSON([
|
||||||
|
'ok' => true,
|
||||||
|
'message' => 'Menu saved.',
|
||||||
|
'id' => $id,
|
||||||
|
'csrf' => $this->csrfPayload(),
|
||||||
|
'payload' => $this->buildNavPayload(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
return redirect()->back()->with('success', 'Menu saved.');
|
return redirect()->back()->with('success', 'Menu saved.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function wantsJson(): bool
|
||||||
|
{
|
||||||
|
return $this->request->isAJAX()
|
||||||
|
|| str_contains(strtolower($this->request->getHeaderLine('Accept')), 'application/json');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function csrfPayload(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => csrf_token(),
|
||||||
|
'hash' => csrf_hash(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
public function delete($id)
|
public function delete($id)
|
||||||
{
|
{
|
||||||
@@ -158,12 +172,182 @@ public function save()
|
|||||||
{
|
{
|
||||||
$this->ensureAdmin();
|
$this->ensureAdmin();
|
||||||
|
|
||||||
|
$structure = $this->request->getPost('structure') ?? [];
|
||||||
|
if (is_array($structure) && !empty($structure)) {
|
||||||
|
$updates = $this->normalizeStructureUpdates($structure);
|
||||||
|
foreach ($updates as $row) {
|
||||||
|
$this->items->update($row['id'], [
|
||||||
|
'menu_parent_id' => $row['menu_parent_id'],
|
||||||
|
'sort_order' => $row['sort_order'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->service->clearCache();
|
||||||
|
return $this->response->setJSON(['ok' => true, 'csrf' => $this->csrfPayload()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backward-compatible payload used by the previous builder.
|
||||||
$orders = $this->request->getPost('orders') ?? [];
|
$orders = $this->request->getPost('orders') ?? [];
|
||||||
foreach ($orders as $id => $order) {
|
foreach ($orders as $id => $order) {
|
||||||
$this->items->update((int) $id, ['sort_order' => (int) $order]);
|
$this->items->update((int) $id, ['sort_order' => (int) $order]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->service->clearCache();
|
$this->service->clearCache();
|
||||||
return $this->response->setJSON(['ok' => true]);
|
return $this->response->setJSON(['ok' => true, 'csrf' => $this->csrfPayload()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function roleAccess()
|
||||||
|
{
|
||||||
|
$this->ensureAdmin();
|
||||||
|
|
||||||
|
$action = strtolower(trim((string) $this->request->getPost('action')));
|
||||||
|
$navItemId = (int) $this->request->getPost('nav_item_id');
|
||||||
|
$sourceRoleId = (int) $this->request->getPost('source_role_id');
|
||||||
|
$targetRoleId = (int) $this->request->getPost('target_role_id');
|
||||||
|
|
||||||
|
if ($navItemId <= 0 || !$this->items->select('id')->find($navItemId)) {
|
||||||
|
return $this->response
|
||||||
|
->setStatusCode(422)
|
||||||
|
->setJSON(['ok' => false, 'message' => 'Selected page does not exist.', 'csrf' => $this->csrfPayload()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'remove') {
|
||||||
|
if (!$this->roleExists($sourceRoleId)) {
|
||||||
|
return $this->response
|
||||||
|
->setStatusCode(422)
|
||||||
|
->setJSON(['ok' => false, 'message' => 'Selected role does not exist.', 'csrf' => $this->csrfPayload()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->maps
|
||||||
|
->where('nav_item_id', $navItemId)
|
||||||
|
->where('role_id', $sourceRoleId)
|
||||||
|
->delete();
|
||||||
|
} elseif ($action === 'move') {
|
||||||
|
if (!$this->roleExists($sourceRoleId) || !$this->roleExists($targetRoleId)) {
|
||||||
|
return $this->response
|
||||||
|
->setStatusCode(422)
|
||||||
|
->setJSON(['ok' => false, 'message' => 'Selected role does not exist.', 'csrf' => $this->csrfPayload()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($sourceRoleId !== $targetRoleId) {
|
||||||
|
$this->maps
|
||||||
|
->where('nav_item_id', $navItemId)
|
||||||
|
->where('role_id', $sourceRoleId)
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
$exists = $this->maps
|
||||||
|
->where('nav_item_id', $navItemId)
|
||||||
|
->where('role_id', $targetRoleId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (!$exists) {
|
||||||
|
$this->maps->insert([
|
||||||
|
'role_id' => $targetRoleId,
|
||||||
|
'nav_item_id' => $navItemId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return $this->response
|
||||||
|
->setStatusCode(422)
|
||||||
|
->setJSON(['ok' => false, 'message' => 'Unsupported role access action.', 'csrf' => $this->csrfPayload()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->service->clearCache();
|
||||||
|
|
||||||
|
return $this->response->setJSON([
|
||||||
|
'ok' => true,
|
||||||
|
'message' => 'Role access updated.',
|
||||||
|
'csrf' => $this->csrfPayload(),
|
||||||
|
'payload' => $this->buildNavPayload(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function roleExists(int $roleId): bool
|
||||||
|
{
|
||||||
|
if ($roleId <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = \Config\Database::connect();
|
||||||
|
return (bool) $db->table('roles')
|
||||||
|
->select('id')
|
||||||
|
->where('id', $roleId)
|
||||||
|
->get(1)
|
||||||
|
->getFirstRow();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeStructureUpdates(array $structure): array
|
||||||
|
{
|
||||||
|
$existingRows = $this->items->select('id, menu_parent_id')->findAll();
|
||||||
|
$existingIds = array_map('intval', array_column($existingRows, 'id'));
|
||||||
|
$existingIdLookup = array_fill_keys($existingIds, true);
|
||||||
|
|
||||||
|
$updates = [];
|
||||||
|
foreach ($structure as $row) {
|
||||||
|
if (!is_array($row)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = (int) ($row['id'] ?? 0);
|
||||||
|
if ($id <= 0 || !isset($existingIdLookup[$id])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$parentId = $row['parent_id'] ?? null;
|
||||||
|
$parentId = ($parentId === '' || $parentId === null) ? null : (int) $parentId;
|
||||||
|
if ($parentId !== null && (!isset($existingIdLookup[$parentId]) || $parentId === $id)) {
|
||||||
|
$parentId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$updates[] = [
|
||||||
|
'id' => $id,
|
||||||
|
'menu_parent_id' => $parentId,
|
||||||
|
'sort_order' => max(0, (int) ($row['sort_order'] ?? 0)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$updatesById = [];
|
||||||
|
foreach ($updates as $row) {
|
||||||
|
$updatesById[$row['id']] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($updatesById as $id => &$row) {
|
||||||
|
if ($row['menu_parent_id'] !== null && $this->wouldCreateCycle($id, $row['menu_parent_id'], $updatesById, $existingRows)) {
|
||||||
|
$row['menu_parent_id'] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($row);
|
||||||
|
|
||||||
|
return array_values($updatesById);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function wouldCreateCycle(int $id, int $parentId, array $updatesById, array $existingRows): bool
|
||||||
|
{
|
||||||
|
$parentById = [];
|
||||||
|
foreach ($existingRows as $row) {
|
||||||
|
$parentById[(int) ($row['id'] ?? 0)] = isset($row['menu_parent_id']) && (int) $row['menu_parent_id'] !== 0
|
||||||
|
? (int) $row['menu_parent_id']
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
foreach ($updatesById as $row) {
|
||||||
|
$parentById[$row['id']] = $row['menu_parent_id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$seen = [];
|
||||||
|
$current = $parentId;
|
||||||
|
while ($current !== null) {
|
||||||
|
if ($current === $id) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (isset($seen[$current])) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
$seen[$current] = true;
|
||||||
|
$current = $parentById[$current] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function distinctRoles(): array
|
protected function distinctRoles(): array
|
||||||
@@ -256,9 +440,59 @@ public function save()
|
|||||||
'items' => $flattened,
|
'items' => $flattened,
|
||||||
'roles' => $roles,
|
'roles' => $roles,
|
||||||
'parentOptions' => $parentOptions,
|
'parentOptions' => $parentOptions,
|
||||||
|
'routeOptions' => $this->routeOptions(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function routeOptions(): array
|
||||||
|
{
|
||||||
|
$routes = service('routes');
|
||||||
|
$getRoutes = $routes->getRoutes('GET', false);
|
||||||
|
if (empty($getRoutes)) {
|
||||||
|
$routes = $routes->loadRoutes();
|
||||||
|
$getRoutes = $routes->getRoutes('GET', false);
|
||||||
|
}
|
||||||
|
$options = [];
|
||||||
|
|
||||||
|
foreach (array_keys($getRoutes) as $route) {
|
||||||
|
$route = trim((string) $route, '/');
|
||||||
|
if ($route === '' || $this->shouldHideRouteOption($route)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$options[] = [
|
||||||
|
'value' => $route,
|
||||||
|
'label' => $this->routeLabel($route),
|
||||||
|
'needs_params' => str_contains($route, '(') || str_contains($route, '[') || str_contains($route, '{'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($options, static function ($a, $b) {
|
||||||
|
return strnatcasecmp($a['label'] ?? '', $b['label'] ?? '');
|
||||||
|
});
|
||||||
|
|
||||||
|
return $options;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function shouldHideRouteOption(string $route): bool
|
||||||
|
{
|
||||||
|
if (str_starts_with($route, 'api/') || str_starts_with($route, 'docs/') || str_starts_with($route, 'debugbar/')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (bool) preg_match('#(^|/)(csrf-token|file|attachment|download|delete)(/|$)#i', $route);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function routeLabel(string $route): string
|
||||||
|
{
|
||||||
|
$label = preg_replace('#\(\?:[^)]+\)|\(\[\^/\]\+\)|\(\[0-9\]\+\)|\(:[a-z_]+\)|\{[^}]+\}#i', '{value}', $route) ?? $route;
|
||||||
|
$label = str_replace(['_', '-'], ' ', $label);
|
||||||
|
$label = preg_replace('#/+#', ' / ', $label) ?? $label;
|
||||||
|
$label = preg_replace('/\s+/', ' ', $label) ?? $label;
|
||||||
|
|
||||||
|
return ucwords(trim($label));
|
||||||
|
}
|
||||||
|
|
||||||
private function flattenTreeForResponse(array $nodes, array $roleAssignments, ?string $parentLabel = null, int $depth = 0, array &$rows = []): array
|
private function flattenTreeForResponse(array $nodes, array $roleAssignments, ?string $parentLabel = null, int $depth = 0, array &$rows = []): array
|
||||||
{
|
{
|
||||||
foreach ($nodes as $node) {
|
foreach ($nodes as $node) {
|
||||||
@@ -300,4 +534,28 @@ public function save()
|
|||||||
|
|
||||||
return $rows;
|
return $rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function sortTreeByOrder(array &$nodes): void
|
||||||
|
{
|
||||||
|
usort($nodes, function ($a, $b) {
|
||||||
|
$order = ((int) ($a['sort_order'] ?? 0)) <=> ((int) ($b['sort_order'] ?? 0));
|
||||||
|
if ($order !== 0) {
|
||||||
|
return $order;
|
||||||
|
}
|
||||||
|
|
||||||
|
$label = strnatcasecmp($this->labelKey($a['label'] ?? ''), $this->labelKey($b['label'] ?? ''));
|
||||||
|
if ($label !== 0) {
|
||||||
|
return $label;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ((int) ($a['id'] ?? 0)) <=> ((int) ($b['id'] ?? 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
foreach ($nodes as &$node) {
|
||||||
|
if (!empty($node['children'])) {
|
||||||
|
$this->sortTreeByOrder($node['children']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($node);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -442,7 +442,7 @@ class ParentAttendanceReportController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ✅ Build formatted success message
|
// ✅ Build formatted success message
|
||||||
$msg = '✅ <strong>Submission received successfully for ' . count($successNames) . ' item' . (count($successNames) > 1 ? 's' : '') . '.</strong><br>';
|
$msg = '✅ <strong>Submission received successfully for ' . count($successNames) . ' request' . (count($successNames) > 1 ? 's' : '') . '.</strong><br>';
|
||||||
$msg .= '<ul style="margin-top:5px;">';
|
$msg .= '<ul style="margin-top:5px;">';
|
||||||
foreach ($successNames as $s) {
|
foreach ($successNames as $s) {
|
||||||
$dateLabel = $s['date_label'] ?? ($s['date'] ?? '');
|
$dateLabel = $s['date_label'] ?? ($s['date'] ?? '');
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1685,7 +1685,7 @@ class PaymentController extends ResourceController
|
|||||||
$roles[] = $activeRole;
|
$roles[] = $activeRole;
|
||||||
}
|
}
|
||||||
|
|
||||||
$staffRoles = ['administrator', 'administrative staff', 'principal', 'teacher', 'teacher_assistant'];
|
$staffRoles = ['administrator', 'administrative staff', 'principal', 'teacher', 'teacher_assistant', 'head fa', 'head of fa', 'head_of_fa', 'financial_contributor'];
|
||||||
foreach ($staffRoles as $role) {
|
foreach ($staffRoles as $role) {
|
||||||
if (in_array($role, $roles, true)) {
|
if (in_array($role, $roles, true)) {
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -1414,7 +1414,7 @@ class RefundController extends BaseController
|
|||||||
$roles[] = $activeRole;
|
$roles[] = $activeRole;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (['administrator', 'administrative staff', 'principal', 'teacher', 'teacher_assistant'] as $role) {
|
foreach (['administrator', 'administrative staff', 'principal', 'teacher', 'teacher_assistant', 'head fa', 'head of fa', 'head_of_fa', 'financial_contributor'] as $role) {
|
||||||
if (in_array($role, $roles, true)) {
|
if (in_array($role, $roles, true)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use App\Models\UserRoleModel;
|
|||||||
use App\Models\PasswordResetRequestModel;
|
use App\Models\PasswordResetRequestModel;
|
||||||
use App\Models\RolePermissionModel;
|
use App\Models\RolePermissionModel;
|
||||||
use App\Models\IpAttemptModel;
|
use App\Models\IpAttemptModel;
|
||||||
|
use App\Models\JobPositionModel;
|
||||||
use CodeIgniter\Controller;
|
use CodeIgniter\Controller;
|
||||||
use App\Controllers\View\EmailController;
|
use App\Controllers\View\EmailController;
|
||||||
use App\Models\LoginActivityModel; // Make sure this import is present
|
use App\Models\LoginActivityModel; // Make sure this import is present
|
||||||
@@ -22,7 +23,7 @@ require_once APPPATH . 'Helpers/pbkdf2_helper.php';
|
|||||||
|
|
||||||
class UserController extends BaseController
|
class UserController extends BaseController
|
||||||
{
|
{
|
||||||
private const ACTIVATION_TTL_HOURS = 48;
|
private const ACTIVATION_TTL_MINUTES = 15;
|
||||||
protected $userModel;
|
protected $userModel;
|
||||||
protected $roleModel;
|
protected $roleModel;
|
||||||
protected $userRoleModel;
|
protected $userRoleModel;
|
||||||
@@ -108,7 +109,18 @@ class UserController extends BaseController
|
|||||||
// Method to show the home page
|
// Method to show the home page
|
||||||
public function home()
|
public function home()
|
||||||
{
|
{
|
||||||
return view('/index');
|
$openPositions = [];
|
||||||
|
try {
|
||||||
|
if ($this->db->tableExists('job_positions')) {
|
||||||
|
$openPositions = (new JobPositionModel())->openPositions();
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
log_message('error', 'Unable to load home page job positions: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('/index', [
|
||||||
|
'openPositions' => $openPositions,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Method to show the about page
|
// Method to show the about page
|
||||||
@@ -117,6 +129,12 @@ class UserController extends BaseController
|
|||||||
return view('/about');
|
return view('/about');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Method to show the careers page
|
||||||
|
public function careers()
|
||||||
|
{
|
||||||
|
return view('/careers');
|
||||||
|
}
|
||||||
|
|
||||||
// Method to show the classes page
|
// Method to show the classes page
|
||||||
public function classes()
|
public function classes()
|
||||||
{
|
{
|
||||||
@@ -654,30 +672,7 @@ class UserController extends BaseController
|
|||||||
|
|
||||||
public function confirm($token)
|
public function confirm($token)
|
||||||
{
|
{
|
||||||
log_message('info', 'Processing email confirmation.');
|
return $this->setPassword($token);
|
||||||
|
|
||||||
$tokenHash = $this->hashToken($token);
|
|
||||||
$user = $this->userModel
|
|
||||||
->groupStart()
|
|
||||||
->where('token', $tokenHash)
|
|
||||||
->orWhere('token', $token)
|
|
||||||
->groupEnd()
|
|
||||||
->where('created_at >=', Time::now()->subHours(self::ACTIVATION_TTL_HOURS)->toDateTimeString())
|
|
||||||
->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)
|
public function setPassword($token)
|
||||||
@@ -690,7 +685,7 @@ class UserController extends BaseController
|
|||||||
->where('token', $tokenHash)
|
->where('token', $tokenHash)
|
||||||
->orWhere('token', $token)
|
->orWhere('token', $token)
|
||||||
->groupEnd()
|
->groupEnd()
|
||||||
->where('created_at >=', Time::now()->subHours(self::ACTIVATION_TTL_HOURS)->toDateTimeString())
|
->where('created_at >=', Time::now()->subMinutes(self::ACTIVATION_TTL_MINUTES)->toDateTimeString())
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if (!$user || $user['is_verified'] == 1) {
|
if (!$user || $user['is_verified'] == 1) {
|
||||||
@@ -747,7 +742,7 @@ class UserController extends BaseController
|
|||||||
->where('token', $tokenHash)
|
->where('token', $tokenHash)
|
||||||
->orWhere('token', $token)
|
->orWhere('token', $token)
|
||||||
->groupEnd()
|
->groupEnd()
|
||||||
->where('created_at >=', Time::now()->subHours(self::ACTIVATION_TTL_HOURS)->toDateTimeString())
|
->where('created_at >=', Time::now()->subMinutes(self::ACTIVATION_TTL_MINUTES)->toDateTimeString())
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
log_message('debug', "Attempting to set password for user $userId");
|
log_message('debug', "Attempting to set password for user $userId");
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Database\Migrations;
|
||||||
|
|
||||||
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
||||||
|
final class CreateUserAccessProfiles extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
if (! $this->db->tableExists('user_access_profiles')) {
|
||||||
|
$this->forge->addField([
|
||||||
|
'id' => [
|
||||||
|
'type' => 'INT',
|
||||||
|
'constraint' => 11,
|
||||||
|
'unsigned' => true,
|
||||||
|
'auto_increment' => true,
|
||||||
|
],
|
||||||
|
'user_id' => [
|
||||||
|
'type' => 'INT',
|
||||||
|
'constraint' => 11,
|
||||||
|
'unsigned' => true,
|
||||||
|
],
|
||||||
|
'primary_category' => [
|
||||||
|
'type' => 'VARCHAR',
|
||||||
|
'constraint' => 20,
|
||||||
|
'default' => 'guest',
|
||||||
|
],
|
||||||
|
'is_admin' => [
|
||||||
|
'type' => 'TINYINT',
|
||||||
|
'constraint' => 1,
|
||||||
|
'default' => 0,
|
||||||
|
],
|
||||||
|
'is_teacher' => [
|
||||||
|
'type' => 'TINYINT',
|
||||||
|
'constraint' => 1,
|
||||||
|
'default' => 0,
|
||||||
|
],
|
||||||
|
'is_parent' => [
|
||||||
|
'type' => 'TINYINT',
|
||||||
|
'constraint' => 1,
|
||||||
|
'default' => 0,
|
||||||
|
],
|
||||||
|
'role_names' => [
|
||||||
|
'type' => 'TEXT',
|
||||||
|
'null' => true,
|
||||||
|
],
|
||||||
|
'created_at' => [
|
||||||
|
'type' => 'DATETIME',
|
||||||
|
'null' => true,
|
||||||
|
],
|
||||||
|
'updated_at' => [
|
||||||
|
'type' => 'DATETIME',
|
||||||
|
'null' => true,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$this->forge->addKey('id', true);
|
||||||
|
$this->forge->addKey('user_id', false, true);
|
||||||
|
$this->forge->addKey('primary_category');
|
||||||
|
$this->forge->addKey('is_admin');
|
||||||
|
$this->forge->addKey('is_teacher');
|
||||||
|
$this->forge->addKey('is_parent');
|
||||||
|
$this->forge->createTable('user_access_profiles', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->backfillProfiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
$this->forge->dropTable('user_access_profiles', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function backfillProfiles(): void
|
||||||
|
{
|
||||||
|
if (
|
||||||
|
! $this->db->tableExists('users')
|
||||||
|
|| ! $this->db->tableExists('roles')
|
||||||
|
|| ! $this->db->tableExists('user_roles')
|
||||||
|
|| ! $this->db->tableExists('user_access_profiles')
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
$deletedFilter = $this->db->fieldExists('deleted_at', 'user_roles')
|
||||||
|
? 'AND ur.deleted_at IS NULL'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
$sql = "
|
||||||
|
INSERT INTO user_access_profiles (
|
||||||
|
user_id,
|
||||||
|
primary_category,
|
||||||
|
is_admin,
|
||||||
|
is_teacher,
|
||||||
|
is_parent,
|
||||||
|
role_names,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
u.id AS user_id,
|
||||||
|
CASE
|
||||||
|
WHEN MAX(CASE WHEN LOWER(REPLACE(REPLACE(COALESCE(r.slug, r.name), ' ', '_'), '-', '_')) NOT IN ('guest', 'parent', 'student', 'teacher', 'teacher_assistant', 'assistant_teacher', 'ta') THEN 1 ELSE 0 END) = 1 THEN 'admin'
|
||||||
|
WHEN MAX(CASE WHEN LOWER(REPLACE(REPLACE(COALESCE(r.slug, r.name), ' ', '_'), '-', '_')) IN ('teacher', 'teacher_assistant', 'assistant_teacher', 'ta') THEN 1 ELSE 0 END) = 1 THEN 'teacher'
|
||||||
|
WHEN MAX(CASE WHEN LOWER(REPLACE(REPLACE(COALESCE(r.slug, r.name), ' ', '_'), '-', '_')) = 'parent' THEN 1 ELSE 0 END) = 1 THEN 'parent'
|
||||||
|
ELSE 'guest'
|
||||||
|
END AS primary_category,
|
||||||
|
MAX(CASE WHEN LOWER(REPLACE(REPLACE(COALESCE(r.slug, r.name), ' ', '_'), '-', '_')) NOT IN ('guest', 'parent', 'student', 'teacher', 'teacher_assistant', 'assistant_teacher', 'ta') THEN 1 ELSE 0 END) AS is_admin,
|
||||||
|
MAX(CASE WHEN LOWER(REPLACE(REPLACE(COALESCE(r.slug, r.name), ' ', '_'), '-', '_')) IN ('teacher', 'teacher_assistant', 'assistant_teacher', 'ta') THEN 1 ELSE 0 END) AS is_teacher,
|
||||||
|
MAX(CASE WHEN LOWER(REPLACE(REPLACE(COALESCE(r.slug, r.name), ' ', '_'), '-', '_')) = 'parent' THEN 1 ELSE 0 END) AS is_parent,
|
||||||
|
GROUP_CONCAT(DISTINCT r.name ORDER BY COALESCE(r.priority, 999), r.name SEPARATOR ', ') AS role_names,
|
||||||
|
? AS created_at,
|
||||||
|
? AS updated_at
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN user_roles ur ON ur.user_id = u.id {$deletedFilter}
|
||||||
|
LEFT JOIN roles r ON r.id = ur.role_id AND COALESCE(r.is_active, 1) = 1
|
||||||
|
GROUP BY u.id
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
primary_category = VALUES(primary_category),
|
||||||
|
is_admin = VALUES(is_admin),
|
||||||
|
is_teacher = VALUES(is_teacher),
|
||||||
|
is_parent = VALUES(is_parent),
|
||||||
|
role_names = VALUES(role_names),
|
||||||
|
updated_at = VALUES(updated_at)
|
||||||
|
";
|
||||||
|
|
||||||
|
$this->db->query($sql, [$now, $now]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Database\Migrations;
|
||||||
|
|
||||||
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
||||||
|
class CreateJobPostings extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
if (!$this->db->tableExists('job_templates')) {
|
||||||
|
$this->forge->addField([
|
||||||
|
'template_id' => ['type' => 'VARCHAR', 'constraint' => 36],
|
||||||
|
'title' => ['type' => 'VARCHAR', 'constraint' => 255],
|
||||||
|
'description' => ['type' => 'TEXT', 'null' => true],
|
||||||
|
'department' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true],
|
||||||
|
'location' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true],
|
||||||
|
'employment_type' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => true],
|
||||||
|
'responsibilities' => ['type' => 'TEXT', 'null' => true],
|
||||||
|
'requirements' => ['type' => 'TEXT', 'null' => true],
|
||||||
|
'version' => ['type' => 'INT', 'constraint' => 11, 'default' => 1],
|
||||||
|
'is_active' => ['type' => 'TINYINT', 'constraint' => 1, 'default' => 1],
|
||||||
|
'created_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
|
||||||
|
'created_at' => ['type' => 'DATETIME', 'null' => true],
|
||||||
|
'updated_at' => ['type' => 'DATETIME', 'null' => true],
|
||||||
|
]);
|
||||||
|
$this->forge->addKey('template_id', true);
|
||||||
|
$this->forge->addKey('is_active', false, false, 'idx_job_templates_active');
|
||||||
|
$this->forge->createTable('job_templates', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->db->tableExists('job_template_versions')) {
|
||||||
|
$this->forge->addField([
|
||||||
|
'version_id' => ['type' => 'VARCHAR', 'constraint' => 36],
|
||||||
|
'template_id' => ['type' => 'VARCHAR', 'constraint' => 36],
|
||||||
|
'version' => ['type' => 'INT', 'constraint' => 11],
|
||||||
|
'title' => ['type' => 'VARCHAR', 'constraint' => 255],
|
||||||
|
'description' => ['type' => 'TEXT', 'null' => true],
|
||||||
|
'department' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true],
|
||||||
|
'location' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true],
|
||||||
|
'employment_type' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => true],
|
||||||
|
'responsibilities' => ['type' => 'TEXT', 'null' => true],
|
||||||
|
'requirements' => ['type' => 'TEXT', 'null' => true],
|
||||||
|
'saved_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
|
||||||
|
'saved_at' => ['type' => 'DATETIME', 'null' => true],
|
||||||
|
]);
|
||||||
|
$this->forge->addKey('version_id', true);
|
||||||
|
$this->forge->addKey('template_id', false, false, 'idx_template_versions_template_id');
|
||||||
|
$this->forge->createTable('job_template_versions', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->db->tableExists('job_positions')) {
|
||||||
|
$this->forge->addField([
|
||||||
|
'position_id' => ['type' => 'VARCHAR', 'constraint' => 36],
|
||||||
|
'template_id' => ['type' => 'VARCHAR', 'constraint' => 36, 'null' => true],
|
||||||
|
'title' => ['type' => 'VARCHAR', 'constraint' => 255],
|
||||||
|
'description' => ['type' => 'TEXT', 'null' => true],
|
||||||
|
'department' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true],
|
||||||
|
'location' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true],
|
||||||
|
'employment_type' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => true],
|
||||||
|
'responsibilities' => ['type' => 'TEXT', 'null' => true],
|
||||||
|
'requirements' => ['type' => 'TEXT', 'null' => true],
|
||||||
|
'status' => ['type' => 'VARCHAR', 'constraint' => 20, 'default' => 'draft'],
|
||||||
|
'posted_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
|
||||||
|
'posted_at' => ['type' => 'DATETIME', 'null' => true],
|
||||||
|
'details_click_count' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'default' => 0],
|
||||||
|
'created_at' => ['type' => 'DATETIME', 'null' => true],
|
||||||
|
'updated_at' => ['type' => 'DATETIME', 'null' => true],
|
||||||
|
]);
|
||||||
|
$this->forge->addKey('position_id', true);
|
||||||
|
$this->forge->addKey('status', false, false, 'idx_job_positions_status');
|
||||||
|
$this->forge->createTable('job_positions', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->db->tableExists('applications')) {
|
||||||
|
$this->forge->addField([
|
||||||
|
'application_id' => ['type' => 'VARCHAR', 'constraint' => 36],
|
||||||
|
'position_id' => ['type' => 'VARCHAR', 'constraint' => 36],
|
||||||
|
'first_name' => ['type' => 'VARCHAR', 'constraint' => 100],
|
||||||
|
'last_name' => ['type' => 'VARCHAR', 'constraint' => 100],
|
||||||
|
'email' => ['type' => 'VARCHAR', 'constraint' => 255],
|
||||||
|
'phone' => ['type' => 'VARCHAR', 'constraint' => 30],
|
||||||
|
'resume_file_url' => ['type' => 'TEXT'],
|
||||||
|
'status' => ['type' => 'VARCHAR', 'constraint' => 20, 'default' => 'new'],
|
||||||
|
'admin_notes' => ['type' => 'TEXT', 'null' => true],
|
||||||
|
'submitted_at' => ['type' => 'DATETIME', 'null' => true],
|
||||||
|
]);
|
||||||
|
$this->forge->addKey('application_id', true);
|
||||||
|
$this->forge->addKey('position_id', false, false, 'idx_applications_position_id');
|
||||||
|
$this->forge->addKey('status', false, false, 'idx_applications_status');
|
||||||
|
$this->forge->addKey('email', false, false, 'idx_applications_email');
|
||||||
|
$this->forge->createTable('applications', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down()
|
||||||
|
{
|
||||||
|
$this->forge->dropTable('applications', true);
|
||||||
|
$this->forge->dropTable('job_positions', true);
|
||||||
|
$this->forge->dropTable('job_template_versions', true);
|
||||||
|
$this->forge->dropTable('job_templates', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Database\Migrations;
|
||||||
|
|
||||||
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
||||||
|
class SeedVolunteerJobPostings extends Migration
|
||||||
|
{
|
||||||
|
private array $postings = [
|
||||||
|
[
|
||||||
|
'template_id' => 'f9df54cc-0173-4c5d-a3b6-d7ef64821e01',
|
||||||
|
'position_id' => 'cc23227b-82b1-4bcb-8976-fcd21f0a1e01',
|
||||||
|
'version_id' => '6a408f24-88db-4c0e-b7f2-26cc2da51e01',
|
||||||
|
'title' => 'Grade 6 Teacher Volunteer',
|
||||||
|
'description' => "Al Rahma Sunday School is looking for a dedicated Grade 6 Teacher Volunteer to teach and mentor students in a positive Islamic learning environment. The teacher will help students strengthen their understanding of Islamic studies, Quran, Arabic, manners, and character development.",
|
||||||
|
'responsibilities' => "- Teach Grade 6 Sunday school lessons based on the school curriculum\n- Prepare weekly lesson plans and classroom activities\n- Help students understand Islamic values and apply them in daily life\n- Manage classroom behavior in a respectful and positive way\n- Encourage student participation, discussion, and teamwork\n- Track attendance and student progress\n- Communicate with school administration and parents when needed\n- Support school events, exams, and activities when requested",
|
||||||
|
'requirements' => "- Strong commitment to Islamic education and community service\n- Comfortable teaching and guiding middle school students\n- Patient, responsible, organized, and dependable\n- Good communication skills\n- Prior teaching, tutoring, or youth mentoring experience preferred\n- Must be available during Sunday school hours",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'template_id' => 'f9df54cc-0173-4c5d-a3b6-d7ef64821e02',
|
||||||
|
'position_id' => 'cc23227b-82b1-4bcb-8976-fcd21f0a1e02',
|
||||||
|
'version_id' => '6a408f24-88db-4c0e-b7f2-26cc2da51e02',
|
||||||
|
'title' => 'Grade 8 Teacher Volunteer',
|
||||||
|
'description' => "Al Rahma Sunday School is seeking a Grade 8 Teacher Volunteer to support students as they continue developing their Islamic knowledge, personal responsibility, and connection to the Muslim community.\n\nThe Grade 8 teacher will lead classroom lessons, encourage meaningful discussion, and help students build confidence in their faith and character.",
|
||||||
|
'responsibilities' => "- Teach Grade 8 Sunday school curriculum\n- Prepare engaging lessons, discussions, and activities\n- Help students understand Islamic teachings in age-appropriate ways\n- Encourage respectful dialogue, critical thinking, and positive behavior\n- Support students with Quran, Islamic studies, Arabic, and character development\n- Maintain classroom order and a safe learning environment\n- Monitor attendance and student participation\n- Communicate with administration and parents as needed\n- Assist with school programs, projects, or events when requested",
|
||||||
|
'requirements' => "- Commitment to Islamic values and youth education\n- Ability to connect with middle school students\n- Responsible, patient, respectful, and organized\n- Strong communication and classroom management skills\n- Teaching, tutoring, halaqa, or mentoring experience preferred\n- Must be dependable and available on Sundays",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'template_id' => 'f9df54cc-0173-4c5d-a3b6-d7ef64821e03',
|
||||||
|
'position_id' => 'cc23227b-82b1-4bcb-8976-fcd21f0a1e03',
|
||||||
|
'version_id' => '6a408f24-88db-4c0e-b7f2-26cc2da51e03',
|
||||||
|
'title' => 'Youth Teacher / Mentor Volunteer',
|
||||||
|
'description' => "Al Rahma Sunday School is looking for a Youth Teacher / Mentor Volunteer to work with students ages 16-17. This role focuses on teaching, mentoring, and guiding older teens as they strengthen their Islamic identity, personal responsibility, leadership skills, and connection to the Muslim community.\n\nThe Youth Teacher will lead age-appropriate lessons and discussions that connect Islamic values to real-life topics students face at this stage of life.",
|
||||||
|
'responsibilities' => "- Teach and mentor students ages 16-17\n- Lead discussions on Islamic identity, character, Quran, Seerah, manners, leadership, and real-life challenges\n- Create a respectful classroom environment where students feel comfortable asking questions\n- Encourage critical thinking, responsibility, positive decision-making, and community involvement\n- Prepare weekly lessons, activities, or discussion topics\n- Support students in developing confidence in their faith and values\n- Maintain appropriate teacher-student boundaries and a safe learning environment\n- Track attendance and student participation\n- Communicate with Sunday school administration and parents when needed\n- Support youth projects, events, service activities, or school programs when requested",
|
||||||
|
'requirements' => "- Strong commitment to Islamic education and youth development\n- Ability to relate respectfully and effectively with older teens\n- Comfortable leading meaningful discussions with students ages 16-17\n- Patient, dependable, trustworthy, and organized\n- Good communication, leadership, and classroom management skills\n- Prior experience in teaching, mentoring, halaqas, youth programs, or community service preferred\n- Must be available during Sunday school hours",
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
if (!$this->db->tableExists('job_templates') || !$this->db->tableExists('job_positions')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = gmdate('Y-m-d H:i:s');
|
||||||
|
|
||||||
|
foreach ($this->postings as $posting) {
|
||||||
|
$shared = [
|
||||||
|
'title' => $posting['title'],
|
||||||
|
'description' => $posting['description'],
|
||||||
|
'department' => 'Sunday School',
|
||||||
|
'location' => 'Al Rahma Sunday School',
|
||||||
|
'employment_type' => 'Volunteer',
|
||||||
|
'responsibilities' => $posting['responsibilities'],
|
||||||
|
'requirements' => $posting['requirements'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$template = $this->db->table('job_templates')
|
||||||
|
->where('template_id', $posting['template_id'])
|
||||||
|
->orWhere('title', $posting['title'])
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
|
||||||
|
$templateId = (string) ($template['template_id'] ?? $posting['template_id']);
|
||||||
|
|
||||||
|
if (!$template) {
|
||||||
|
$this->db->table('job_templates')->insert($shared + [
|
||||||
|
'template_id' => $templateId,
|
||||||
|
'version' => 1,
|
||||||
|
'is_active' => 1,
|
||||||
|
'created_by' => null,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->db->tableExists('job_template_versions')) {
|
||||||
|
$versionExists = $this->db->table('job_template_versions')
|
||||||
|
->where('version_id', $posting['version_id'])
|
||||||
|
->countAllResults() > 0;
|
||||||
|
|
||||||
|
if (!$versionExists) {
|
||||||
|
$this->db->table('job_template_versions')->insert($shared + [
|
||||||
|
'version_id' => $posting['version_id'],
|
||||||
|
'template_id' => $templateId,
|
||||||
|
'version' => 1,
|
||||||
|
'saved_by' => null,
|
||||||
|
'saved_at' => $now,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$positionExists = $this->db->table('job_positions')
|
||||||
|
->where('position_id', $posting['position_id'])
|
||||||
|
->orWhere('title', $posting['title'])
|
||||||
|
->countAllResults() > 0;
|
||||||
|
|
||||||
|
if (!$positionExists) {
|
||||||
|
$this->db->table('job_positions')->insert($shared + [
|
||||||
|
'position_id' => $posting['position_id'],
|
||||||
|
'template_id' => $templateId,
|
||||||
|
'status' => 'draft',
|
||||||
|
'posted_by' => null,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down()
|
||||||
|
{
|
||||||
|
foreach ($this->postings as $posting) {
|
||||||
|
if ($this->db->tableExists('job_positions')) {
|
||||||
|
$this->db->table('job_positions')->where('position_id', $posting['position_id'])->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->db->tableExists('job_template_versions')) {
|
||||||
|
$this->db->table('job_template_versions')->where('version_id', $posting['version_id'])->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->db->tableExists('job_templates')) {
|
||||||
|
$this->db->table('job_templates')->where('template_id', $posting['template_id'])->delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Database\Migrations;
|
||||||
|
|
||||||
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
||||||
|
class AddHideJobOpeningsPopupToPreferences extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
if (! $this->db->tableExists('user_preferences')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->db->fieldExists('hide_job_openings_popup', 'user_preferences')) {
|
||||||
|
$definition = [
|
||||||
|
'type' => 'TINYINT',
|
||||||
|
'constraint' => 1,
|
||||||
|
'default' => 0,
|
||||||
|
'null' => false,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($this->db->fieldExists('menu_custom_mode', 'user_preferences')) {
|
||||||
|
$definition['after'] = 'menu_custom_mode';
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->forge->addColumn('user_preferences', [
|
||||||
|
'hide_job_openings_popup' => $definition,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down()
|
||||||
|
{
|
||||||
|
if (
|
||||||
|
$this->db->tableExists('user_preferences')
|
||||||
|
&& $this->db->fieldExists('hide_job_openings_popup', 'user_preferences')
|
||||||
|
) {
|
||||||
|
$this->forge->dropColumn('user_preferences', 'hide_job_openings_popup');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Database\Migrations;
|
||||||
|
|
||||||
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
||||||
|
class AddResponsibilitiesToJobPostings extends Migration
|
||||||
|
{
|
||||||
|
private array $tables = [
|
||||||
|
'job_templates',
|
||||||
|
'job_template_versions',
|
||||||
|
'job_positions',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
foreach ($this->tables as $table) {
|
||||||
|
if (!$this->db->tableExists($table) || $this->db->fieldExists('responsibilities', $table)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->forge->addColumn($table, [
|
||||||
|
'responsibilities' => [
|
||||||
|
'type' => 'TEXT',
|
||||||
|
'null' => true,
|
||||||
|
'after' => 'employment_type',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->backfillResponsibilities();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down()
|
||||||
|
{
|
||||||
|
foreach ($this->tables as $table) {
|
||||||
|
if ($this->db->tableExists($table) && $this->db->fieldExists('responsibilities', $table)) {
|
||||||
|
$this->forge->dropColumn($table, 'responsibilities');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function backfillResponsibilities(): void
|
||||||
|
{
|
||||||
|
$primaryKeys = [
|
||||||
|
'job_templates' => 'template_id',
|
||||||
|
'job_template_versions' => 'version_id',
|
||||||
|
'job_positions' => 'position_id',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($primaryKeys as $table => $primaryKey) {
|
||||||
|
if (!$this->db->tableExists($table) || !$this->db->fieldExists('responsibilities', $table)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = $this->db->table($table)
|
||||||
|
->select($primaryKey . ', description, responsibilities')
|
||||||
|
->groupStart()
|
||||||
|
->where('responsibilities', null)
|
||||||
|
->orWhere('responsibilities', '')
|
||||||
|
->groupEnd()
|
||||||
|
->like('description', 'Responsibilities:')
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$description = (string) ($row['description'] ?? '');
|
||||||
|
$parts = preg_split('/\R\RResponsibilities:\R/', $description, 2);
|
||||||
|
|
||||||
|
if (!is_array($parts) || count($parts) !== 2) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->db->table($table)
|
||||||
|
->where($primaryKey, $row[$primaryKey])
|
||||||
|
->update([
|
||||||
|
'description' => trim($parts[0]),
|
||||||
|
'responsibilities' => trim($parts[1]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Database\Migrations;
|
||||||
|
|
||||||
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
||||||
|
class BackfillJobPostingResponsibilities extends Migration
|
||||||
|
{
|
||||||
|
private array $primaryKeys = [
|
||||||
|
'job_templates' => 'template_id',
|
||||||
|
'job_template_versions' => 'version_id',
|
||||||
|
'job_positions' => 'position_id',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
foreach ($this->primaryKeys as $table => $primaryKey) {
|
||||||
|
if (!$this->db->tableExists($table) || !$this->db->fieldExists('responsibilities', $table)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = $this->db->table($table)
|
||||||
|
->select($primaryKey . ', description, responsibilities')
|
||||||
|
->groupStart()
|
||||||
|
->where('responsibilities', null)
|
||||||
|
->orWhere('responsibilities', '')
|
||||||
|
->groupEnd()
|
||||||
|
->like('description', 'Responsibilities:')
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$parts = preg_split('/\R\RResponsibilities:\R/', (string) ($row['description'] ?? ''), 2);
|
||||||
|
|
||||||
|
if (!is_array($parts) || count($parts) !== 2) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->db->table($table)
|
||||||
|
->where($primaryKey, $row[$primaryKey])
|
||||||
|
->update([
|
||||||
|
'description' => trim($parts[0]),
|
||||||
|
'responsibilities' => trim($parts[1]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down()
|
||||||
|
{
|
||||||
|
// Data-only migration; do not merge responsibilities back into descriptions on rollback.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Database\Migrations;
|
||||||
|
|
||||||
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
||||||
|
class AddPostedAtToJobPositions extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
if (!$this->db->tableExists('job_positions')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->db->fieldExists('posted_at', 'job_positions')) {
|
||||||
|
$this->forge->addColumn('job_positions', [
|
||||||
|
'posted_at' => [
|
||||||
|
'type' => 'DATETIME',
|
||||||
|
'null' => true,
|
||||||
|
'after' => 'posted_by',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->db->table('job_positions')
|
||||||
|
->whereIn('status', ['open', 'closed', 'filled'])
|
||||||
|
->where('posted_at', null)
|
||||||
|
->set('posted_at', 'COALESCE(created_at, updated_at)', false)
|
||||||
|
->update();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down()
|
||||||
|
{
|
||||||
|
if ($this->db->tableExists('job_positions') && $this->db->fieldExists('posted_at', 'job_positions')) {
|
||||||
|
$this->forge->dropColumn('job_positions', 'posted_at');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Database\Migrations;
|
||||||
|
|
||||||
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
||||||
|
class BackfillPostedAtForClosedFilledJobPositions extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
if (!$this->db->tableExists('job_positions') || !$this->db->fieldExists('posted_at', 'job_positions')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->db->table('job_positions')
|
||||||
|
->whereIn('status', ['closed', 'filled'])
|
||||||
|
->where('posted_at', null)
|
||||||
|
->set('posted_at', 'COALESCE(created_at, updated_at)', false)
|
||||||
|
->update();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down()
|
||||||
|
{
|
||||||
|
// Data-only fallback for legacy rows.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Database\Migrations;
|
||||||
|
|
||||||
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
||||||
|
class AddDetailsClickCountToJobPositions extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
if (!$this->db->tableExists('job_positions') || $this->db->fieldExists('details_click_count', 'job_positions')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->forge->addColumn('job_positions', [
|
||||||
|
'details_click_count' => [
|
||||||
|
'type' => 'INT',
|
||||||
|
'constraint' => 11,
|
||||||
|
'unsigned' => true,
|
||||||
|
'default' => 0,
|
||||||
|
'after' => 'posted_at',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down()
|
||||||
|
{
|
||||||
|
if ($this->db->tableExists('job_positions') && $this->db->fieldExists('details_click_count', 'job_positions')) {
|
||||||
|
$this->forge->dropColumn('job_positions', 'details_click_count');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Database\Migrations;
|
||||||
|
|
||||||
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
||||||
|
class GrantRequestedRolePageAccess extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
$this->grantNavAccess();
|
||||||
|
$this->grantNamedPermissions();
|
||||||
|
cache()->clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
private function grantNavAccess(): void
|
||||||
|
{
|
||||||
|
if (! $this->db->tableExists('nav_items') || ! $this->db->tableExists('role_nav_items')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->grantNavItemsToRoles(
|
||||||
|
['head of csm', 'head_of_department_communication', 'csm_contributor'],
|
||||||
|
[
|
||||||
|
['label' => 'Communication'],
|
||||||
|
['url' => 'admin/enrollment/new-students'],
|
||||||
|
['url' => 'whatsapp/'],
|
||||||
|
['url' => 'admin/print-requests'],
|
||||||
|
['url' => '/administrator/absence'],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$financialNavItems = array_merge(
|
||||||
|
[['label' => 'Financial']],
|
||||||
|
$this->findChildNavSpecsForParentLabel('Financial'),
|
||||||
|
[
|
||||||
|
['label' => 'Event Management'],
|
||||||
|
['url' => 'administrator/events'],
|
||||||
|
['url' => 'admin/print-requests'],
|
||||||
|
['url' => '/administrator/absence'],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->grantNavItemsToRoles(
|
||||||
|
['head fa', 'head of fa', 'head_of_fa', 'head of department (finance)', 'head of department finance'],
|
||||||
|
array_merge($financialNavItems, [
|
||||||
|
['url' => 'admin/enrollment/new-students'],
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->grantNavItemsToRoles(
|
||||||
|
['financial_contributor'],
|
||||||
|
[
|
||||||
|
['label' => 'Financial'],
|
||||||
|
['url' => 'payment/manual_pay'],
|
||||||
|
['url' => '/payment/manual'],
|
||||||
|
['url' => 'admin/print-requests'],
|
||||||
|
['url' => '/administrator/absence'],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function grantNamedPermissions(): void
|
||||||
|
{
|
||||||
|
if (
|
||||||
|
! $this->db->tableExists('roles')
|
||||||
|
|| ! $this->db->tableExists('permissions')
|
||||||
|
|| ! $this->db->tableExists('role_permissions')
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->grantPermissionsToRoles(
|
||||||
|
['head of csm', 'head_of_department_communication', 'csm_contributor'],
|
||||||
|
[
|
||||||
|
'view_new_students' => ['read' => true],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->grantPermissionsToRoles(
|
||||||
|
['head fa', 'head of fa', 'head_of_fa', 'head of department (finance)', 'head of department finance'],
|
||||||
|
[
|
||||||
|
'view_new_students' => ['read' => true],
|
||||||
|
'view_invoice' => ['read' => true],
|
||||||
|
'view_payment' => ['read' => true],
|
||||||
|
'view_financial_reports' => ['create' => true, 'read' => true, 'update' => true],
|
||||||
|
'create_invoice' => ['create' => true, 'read' => true],
|
||||||
|
'update_invoice' => ['read' => true, 'update' => true],
|
||||||
|
'create_payment' => ['create' => true, 'read' => true],
|
||||||
|
'update_payment' => ['read' => true, 'update' => true],
|
||||||
|
'oversee_financial_aid' => ['create' => true, 'read' => true, 'update' => true],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->grantPermissionsToRoles(
|
||||||
|
['financial_contributor'],
|
||||||
|
[
|
||||||
|
'view_invoice' => ['read' => true],
|
||||||
|
'view_payment' => ['read' => true],
|
||||||
|
'create_payment' => ['create' => true, 'read' => true],
|
||||||
|
'update_payment' => ['read' => true, 'update' => true],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $roleKeys
|
||||||
|
* @param list<array{label?: string, url?: string}> $navSpecs
|
||||||
|
*/
|
||||||
|
private function grantNavItemsToRoles(array $roleKeys, array $navSpecs): void
|
||||||
|
{
|
||||||
|
$navIds = $this->resolveNavItemIds($navSpecs);
|
||||||
|
if ($navIds === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
|
||||||
|
if ($this->db->fieldExists('role_id', 'role_nav_items') && $this->db->tableExists('roles')) {
|
||||||
|
foreach ($this->resolveRoleIds($roleKeys) as $roleId) {
|
||||||
|
foreach ($navIds as $navId) {
|
||||||
|
$exists = $this->db->table('role_nav_items')
|
||||||
|
->where('role_id', $roleId)
|
||||||
|
->where('nav_item_id', $navId)
|
||||||
|
->countAllResults() > 0;
|
||||||
|
|
||||||
|
if (! $exists) {
|
||||||
|
$this->db->table('role_nav_items')->insert([
|
||||||
|
'role_id' => $roleId,
|
||||||
|
'nav_item_id' => $navId,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->db->fieldExists('role', 'role_nav_items')) {
|
||||||
|
foreach ($roleKeys as $role) {
|
||||||
|
foreach ($navIds as $navId) {
|
||||||
|
$exists = $this->db->table('role_nav_items')
|
||||||
|
->where('LOWER(role)', strtolower($role))
|
||||||
|
->where('nav_item_id', $navId)
|
||||||
|
->countAllResults() > 0;
|
||||||
|
|
||||||
|
if (! $exists) {
|
||||||
|
$insert = [
|
||||||
|
'role' => strtolower($role),
|
||||||
|
'nav_item_id' => $navId,
|
||||||
|
'created_at' => $now,
|
||||||
|
];
|
||||||
|
if ($this->db->fieldExists('updated_at', 'role_nav_items')) {
|
||||||
|
$insert['updated_at'] = $now;
|
||||||
|
}
|
||||||
|
$this->db->table('role_nav_items')->insert($insert);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $roleKeys
|
||||||
|
* @param array<string, array{create?: bool, read?: bool, update?: bool, delete?: bool}> $permissions
|
||||||
|
*/
|
||||||
|
private function grantPermissionsToRoles(array $roleKeys, array $permissions): void
|
||||||
|
{
|
||||||
|
$roleIds = $this->resolveRoleIds($roleKeys);
|
||||||
|
if ($roleIds === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
|
||||||
|
foreach ($permissions as $permissionName => $flags) {
|
||||||
|
$permissionId = $this->resolvePermissionId($permissionName);
|
||||||
|
if ($permissionId <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($roleIds as $roleId) {
|
||||||
|
$existing = $this->db->table('role_permissions')
|
||||||
|
->where('role_id', $roleId)
|
||||||
|
->where('permission_id', $permissionId)
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
|
||||||
|
$grant = [
|
||||||
|
'can_create' => ! empty($flags['create']) ? 1 : 0,
|
||||||
|
'can_read' => ! empty($flags['read']) ? 1 : 0,
|
||||||
|
'can_update' => ! empty($flags['update']) ? 1 : 0,
|
||||||
|
'can_delete' => ! empty($flags['delete']) ? 1 : 0,
|
||||||
|
'updated_at' => $now,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($this->db->fieldExists('can_manage', 'role_permissions')) {
|
||||||
|
$grant['can_manage'] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($existing === null) {
|
||||||
|
$grant['role_id'] = $roleId;
|
||||||
|
$grant['permission_id'] = $permissionId;
|
||||||
|
$grant['created_at'] = $now;
|
||||||
|
$this->db->table('role_permissions')->insert($grant);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->db->table('role_permissions')
|
||||||
|
->where('id', (int) $existing['id'])
|
||||||
|
->update([
|
||||||
|
'can_create' => max((int) ($existing['can_create'] ?? 0), $grant['can_create']),
|
||||||
|
'can_read' => max((int) ($existing['can_read'] ?? 0), $grant['can_read']),
|
||||||
|
'can_update' => max((int) ($existing['can_update'] ?? 0), $grant['can_update']),
|
||||||
|
'can_delete' => max((int) ($existing['can_delete'] ?? 0), $grant['can_delete']),
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $roleKeys
|
||||||
|
* @return list<int>
|
||||||
|
*/
|
||||||
|
private function resolveRoleIds(array $roleKeys): array
|
||||||
|
{
|
||||||
|
if (! $this->db->tableExists('roles')) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = array_values(array_unique(array_map('strtolower', $roleKeys)));
|
||||||
|
if ($normalized === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$builder = $this->db->table('roles')->select('id');
|
||||||
|
$builder->groupStart()->whereIn('LOWER(name)', $normalized);
|
||||||
|
if ($this->db->fieldExists('slug', 'roles')) {
|
||||||
|
$builder->orWhereIn('LOWER(slug)', $normalized);
|
||||||
|
}
|
||||||
|
$rows = $builder->groupEnd()->get()->getResultArray();
|
||||||
|
|
||||||
|
return array_values(array_unique(array_map(static fn (array $row): int => (int) $row['id'], $rows)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolvePermissionId(string $permissionName): int
|
||||||
|
{
|
||||||
|
$permission = $this->db->table('permissions')
|
||||||
|
->select('id')
|
||||||
|
->where('LOWER(name)', strtolower($permissionName))
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
|
||||||
|
if ($permission !== null) {
|
||||||
|
return (int) $permission['id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
$insert = [
|
||||||
|
'name' => $permissionName,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
];
|
||||||
|
if ($this->db->fieldExists('description', 'permissions')) {
|
||||||
|
$insert['description'] = 'Seeded route permission.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->db->table('permissions')->insert($insert);
|
||||||
|
|
||||||
|
return (int) $this->db->insertID();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array{label?: string, url?: string}> $navSpecs
|
||||||
|
* @return list<int>
|
||||||
|
*/
|
||||||
|
private function resolveNavItemIds(array $navSpecs): array
|
||||||
|
{
|
||||||
|
$ids = [];
|
||||||
|
foreach ($navSpecs as $spec) {
|
||||||
|
if (isset($spec['url'])) {
|
||||||
|
$url = $this->normalizePath($spec['url']);
|
||||||
|
$rows = $this->db->table('nav_items')
|
||||||
|
->select('id, url')
|
||||||
|
->where('url IS NOT NULL', null, false)
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
if ($this->normalizePath((string) ($row['url'] ?? '')) === $url) {
|
||||||
|
$ids[] = (int) $row['id'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
} elseif (isset($spec['label'])) {
|
||||||
|
$builder = $this->db->table('nav_items')->select('id');
|
||||||
|
$builder->where('LOWER(label)', strtolower($spec['label']));
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($builder->get()->getResultArray() as $row) {
|
||||||
|
$ids[] = (int) $row['id'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique(array_filter($ids)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{url: string}>
|
||||||
|
*/
|
||||||
|
private function findChildNavSpecsForParentLabel(string $parentLabel): array
|
||||||
|
{
|
||||||
|
$parentColumn = $this->parentColumn();
|
||||||
|
if ($parentColumn === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$parents = $this->db->table('nav_items')
|
||||||
|
->select('id')
|
||||||
|
->where('LOWER(label)', strtolower($parentLabel))
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
|
||||||
|
$parentIds = array_values(array_filter(array_map(static fn (array $row): int => (int) $row['id'], $parents)));
|
||||||
|
if ($parentIds === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$children = $this->db->table('nav_items')
|
||||||
|
->select('url')
|
||||||
|
->whereIn($parentColumn, $parentIds)
|
||||||
|
->where('url IS NOT NULL', null, false)
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
|
||||||
|
return array_values(array_filter(array_map(
|
||||||
|
static fn (array $row): array => ['url' => (string) $row['url']],
|
||||||
|
$children
|
||||||
|
), static fn (array $spec): bool => trim($spec['url']) !== ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parentColumn(): ?string
|
||||||
|
{
|
||||||
|
if ($this->db->fieldExists('menu_parent_id', 'nav_items')) {
|
||||||
|
return 'menu_parent_id';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->db->fieldExists('parent_id', 'nav_items')) {
|
||||||
|
return 'parent_id';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizePath(string $path): string
|
||||||
|
{
|
||||||
|
return trim(preg_replace('#/+#', '/', $path), '/');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Database\Migrations;
|
||||||
|
|
||||||
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
||||||
|
class BackfillHeadFaNewStudentsAccess extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
$roleIds = $this->resolveRoleIds([
|
||||||
|
'head fa',
|
||||||
|
'head of fa',
|
||||||
|
'head_of_fa',
|
||||||
|
'head of department (finance)',
|
||||||
|
'head of department finance',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($roleIds === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->grantPermission($roleIds, 'view_new_students', ['read' => true]);
|
||||||
|
$this->grantNavUrl($roleIds, 'admin/enrollment/new-students');
|
||||||
|
cache()->clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $roleKeys
|
||||||
|
* @return list<int>
|
||||||
|
*/
|
||||||
|
private function resolveRoleIds(array $roleKeys): array
|
||||||
|
{
|
||||||
|
if (! $this->db->tableExists('roles')) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = array_values(array_unique(array_map('strtolower', $roleKeys)));
|
||||||
|
$builder = $this->db->table('roles')->select('id');
|
||||||
|
$builder->groupStart()->whereIn('LOWER(name)', $normalized);
|
||||||
|
if ($this->db->fieldExists('slug', 'roles')) {
|
||||||
|
$builder->orWhereIn('LOWER(slug)', $normalized);
|
||||||
|
}
|
||||||
|
$rows = $builder->groupEnd()->get()->getResultArray();
|
||||||
|
|
||||||
|
return array_values(array_unique(array_map(static fn (array $row): int => (int) $row['id'], $rows)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<int> $roleIds
|
||||||
|
* @param array{create?: bool, read?: bool, update?: bool, delete?: bool} $flags
|
||||||
|
*/
|
||||||
|
private function grantPermission(array $roleIds, string $permissionName, array $flags): void
|
||||||
|
{
|
||||||
|
if (! $this->db->tableExists('permissions') || ! $this->db->tableExists('role_permissions')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$permissionId = $this->resolvePermissionId($permissionName);
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
|
||||||
|
foreach ($roleIds as $roleId) {
|
||||||
|
$existing = $this->db->table('role_permissions')
|
||||||
|
->where('role_id', $roleId)
|
||||||
|
->where('permission_id', $permissionId)
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
|
||||||
|
$grant = [
|
||||||
|
'can_create' => ! empty($flags['create']) ? 1 : 0,
|
||||||
|
'can_read' => ! empty($flags['read']) ? 1 : 0,
|
||||||
|
'can_update' => ! empty($flags['update']) ? 1 : 0,
|
||||||
|
'can_delete' => ! empty($flags['delete']) ? 1 : 0,
|
||||||
|
'updated_at' => $now,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($existing === null) {
|
||||||
|
$grant['role_id'] = $roleId;
|
||||||
|
$grant['permission_id'] = $permissionId;
|
||||||
|
$grant['created_at'] = $now;
|
||||||
|
if ($this->db->fieldExists('can_manage', 'role_permissions')) {
|
||||||
|
$grant['can_manage'] = 0;
|
||||||
|
}
|
||||||
|
$this->db->table('role_permissions')->insert($grant);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->db->table('role_permissions')
|
||||||
|
->where('id', (int) $existing['id'])
|
||||||
|
->update([
|
||||||
|
'can_create' => max((int) ($existing['can_create'] ?? 0), $grant['can_create']),
|
||||||
|
'can_read' => max((int) ($existing['can_read'] ?? 0), $grant['can_read']),
|
||||||
|
'can_update' => max((int) ($existing['can_update'] ?? 0), $grant['can_update']),
|
||||||
|
'can_delete' => max((int) ($existing['can_delete'] ?? 0), $grant['can_delete']),
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolvePermissionId(string $permissionName): int
|
||||||
|
{
|
||||||
|
$permission = $this->db->table('permissions')
|
||||||
|
->select('id')
|
||||||
|
->where('LOWER(name)', strtolower($permissionName))
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
|
||||||
|
if ($permission !== null) {
|
||||||
|
return (int) $permission['id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
$insert = [
|
||||||
|
'name' => $permissionName,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
];
|
||||||
|
if ($this->db->fieldExists('description', 'permissions')) {
|
||||||
|
$insert['description'] = 'Seeded route permission.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->db->table('permissions')->insert($insert);
|
||||||
|
|
||||||
|
return (int) $this->db->insertID();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<int> $roleIds
|
||||||
|
*/
|
||||||
|
private function grantNavUrl(array $roleIds, string $url): void
|
||||||
|
{
|
||||||
|
if (
|
||||||
|
! $this->db->tableExists('nav_items')
|
||||||
|
|| ! $this->db->tableExists('role_nav_items')
|
||||||
|
|| ! $this->db->fieldExists('role_id', 'role_nav_items')
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$navIds = $this->resolveNavItemIdsByUrl($url);
|
||||||
|
if ($navIds === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
foreach ($roleIds as $roleId) {
|
||||||
|
foreach ($navIds as $navId) {
|
||||||
|
$exists = $this->db->table('role_nav_items')
|
||||||
|
->where('role_id', $roleId)
|
||||||
|
->where('nav_item_id', $navId)
|
||||||
|
->countAllResults() > 0;
|
||||||
|
|
||||||
|
if (! $exists) {
|
||||||
|
$this->db->table('role_nav_items')->insert([
|
||||||
|
'role_id' => $roleId,
|
||||||
|
'nav_item_id' => $navId,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<int>
|
||||||
|
*/
|
||||||
|
private function resolveNavItemIdsByUrl(string $url): array
|
||||||
|
{
|
||||||
|
$targetUrl = $this->normalizePath($url);
|
||||||
|
$rows = $this->db->table('nav_items')
|
||||||
|
->select('id, url')
|
||||||
|
->where('url IS NOT NULL', null, false)
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
|
||||||
|
$ids = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
if ($this->normalizePath((string) ($row['url'] ?? '')) === $targetUrl) {
|
||||||
|
$ids[] = (int) $row['id'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique(array_filter($ids)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizePath(string $path): string
|
||||||
|
{
|
||||||
|
return trim(preg_replace('#/+#', '/', $path), '/');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Database\Migrations;
|
||||||
|
|
||||||
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
||||||
|
class CreateStudentAssessmentTables extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
if (! $this->db->tableExists('question_pools')) {
|
||||||
|
$this->forge->addField([
|
||||||
|
'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true],
|
||||||
|
'name' => ['type' => 'VARCHAR', 'constraint' => 150],
|
||||||
|
'subject_tag' => ['type' => 'VARCHAR', 'constraint' => 100, 'null' => true],
|
||||||
|
'created_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
|
||||||
|
'created_at' => ['type' => 'DATETIME', 'null' => true],
|
||||||
|
'updated_at' => ['type' => 'DATETIME', 'null' => true],
|
||||||
|
]);
|
||||||
|
$this->forge->addKey('id', true);
|
||||||
|
$this->forge->addKey('subject_tag');
|
||||||
|
$this->forge->createTable('question_pools', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->db->tableExists('assessment_questions')) {
|
||||||
|
$this->forge->addField([
|
||||||
|
'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true],
|
||||||
|
'pool_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true],
|
||||||
|
'type' => ['type' => 'ENUM', 'constraint' => ['multiple_choice', 'short_answer', 'true_false', 'essay']],
|
||||||
|
'text' => ['type' => 'TEXT'],
|
||||||
|
'options' => ['type' => 'TEXT', 'null' => true],
|
||||||
|
'correct_answer' => ['type' => 'TEXT', 'null' => true],
|
||||||
|
'points' => ['type' => 'DECIMAL', 'constraint' => '8,2', 'default' => 0],
|
||||||
|
'order_index' => ['type' => 'INT', 'constraint' => 11, 'default' => 0],
|
||||||
|
'created_at' => ['type' => 'DATETIME', 'null' => true],
|
||||||
|
'updated_at' => ['type' => 'DATETIME', 'null' => true],
|
||||||
|
]);
|
||||||
|
$this->forge->addKey('id', true);
|
||||||
|
$this->forge->addKey(['pool_id', 'order_index']);
|
||||||
|
$this->forge->addForeignKey('pool_id', 'question_pools', 'id', 'CASCADE', 'CASCADE');
|
||||||
|
$this->forge->createTable('assessment_questions', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->db->tableExists('assessment_forms')) {
|
||||||
|
$this->forge->addField([
|
||||||
|
'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true],
|
||||||
|
'name' => ['type' => 'VARCHAR', 'constraint' => 150],
|
||||||
|
'pool_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true],
|
||||||
|
'created_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
|
||||||
|
'status' => ['type' => 'ENUM', 'constraint' => ['draft', 'published', 'archived'], 'default' => 'draft'],
|
||||||
|
'created_at' => ['type' => 'DATETIME', 'null' => true],
|
||||||
|
'updated_at' => ['type' => 'DATETIME', 'null' => true],
|
||||||
|
]);
|
||||||
|
$this->forge->addKey('id', true);
|
||||||
|
$this->forge->addKey(['pool_id', 'status']);
|
||||||
|
$this->forge->addForeignKey('pool_id', 'question_pools', 'id', 'RESTRICT', 'CASCADE');
|
||||||
|
$this->forge->createTable('assessment_forms', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->db->tableExists('assessment_form_questions')) {
|
||||||
|
$this->forge->addField([
|
||||||
|
'form_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true],
|
||||||
|
'question_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true],
|
||||||
|
'order_index' => ['type' => 'INT', 'constraint' => 11, 'default' => 0],
|
||||||
|
]);
|
||||||
|
$this->forge->addKey(['form_id', 'question_id'], true);
|
||||||
|
$this->forge->addKey(['form_id', 'order_index']);
|
||||||
|
$this->forge->addForeignKey('form_id', 'assessment_forms', 'id', 'CASCADE', 'CASCADE');
|
||||||
|
$this->forge->addForeignKey('question_id', 'assessment_questions', 'id', 'RESTRICT', 'CASCADE');
|
||||||
|
$this->forge->createTable('assessment_form_questions', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->db->tableExists('student_assessments')) {
|
||||||
|
$this->forge->addField([
|
||||||
|
'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true],
|
||||||
|
'form_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true],
|
||||||
|
'student_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true],
|
||||||
|
'status' => ['type' => 'ENUM', 'constraint' => ['not_started', 'in_progress', 'completed', 'graded'], 'default' => 'not_started'],
|
||||||
|
'assigned_at' => ['type' => 'DATETIME', 'null' => true],
|
||||||
|
'started_at' => ['type' => 'DATETIME', 'null' => true],
|
||||||
|
'submitted_at' => ['type' => 'DATETIME', 'null' => true],
|
||||||
|
'graded_at' => ['type' => 'DATETIME', 'null' => true],
|
||||||
|
'graded_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
|
||||||
|
'score' => ['type' => 'DECIMAL', 'constraint' => '10,2', 'null' => true],
|
||||||
|
'education_committee_note' => ['type' => 'TEXT', 'null' => true],
|
||||||
|
'created_at' => ['type' => 'DATETIME', 'null' => true],
|
||||||
|
'updated_at' => ['type' => 'DATETIME', 'null' => true],
|
||||||
|
]);
|
||||||
|
$this->forge->addKey('id', true);
|
||||||
|
$this->forge->addUniqueKey(['form_id', 'student_id'], 'uq_assessment_form_student');
|
||||||
|
$this->forge->addKey(['student_id', 'status']);
|
||||||
|
$this->forge->addForeignKey('form_id', 'assessment_forms', 'id', 'RESTRICT', 'CASCADE');
|
||||||
|
$this->forge->addForeignKey('student_id', 'students', 'id', 'CASCADE', 'CASCADE');
|
||||||
|
$this->forge->createTable('student_assessments', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->db->tableExists('student_answers')) {
|
||||||
|
$this->forge->addField([
|
||||||
|
'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true],
|
||||||
|
'student_assessment_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true],
|
||||||
|
'question_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true],
|
||||||
|
'answer_value' => ['type' => 'TEXT', 'null' => true],
|
||||||
|
'is_correct' => ['type' => 'TINYINT', 'constraint' => 1, 'null' => true],
|
||||||
|
'points_awarded' => ['type' => 'DECIMAL', 'constraint' => '8,2', 'null' => true],
|
||||||
|
'created_at' => ['type' => 'DATETIME', 'null' => true],
|
||||||
|
'updated_at' => ['type' => 'DATETIME', 'null' => true],
|
||||||
|
]);
|
||||||
|
$this->forge->addKey('id', true);
|
||||||
|
$this->forge->addUniqueKey(['student_assessment_id', 'question_id'], 'uq_student_answer_question');
|
||||||
|
$this->forge->addForeignKey('student_assessment_id', 'student_assessments', 'id', 'CASCADE', 'CASCADE');
|
||||||
|
$this->forge->addForeignKey('question_id', 'assessment_questions', 'id', 'RESTRICT', 'CASCADE');
|
||||||
|
$this->forge->createTable('student_answers', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down()
|
||||||
|
{
|
||||||
|
foreach (['student_answers', 'student_assessments', 'assessment_form_questions', 'assessment_forms', 'assessment_questions', 'question_pools'] as $table) {
|
||||||
|
$this->forge->dropTable($table, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Database\Migrations;
|
||||||
|
|
||||||
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
||||||
|
class AddAssessmentNavigation extends Migration
|
||||||
|
{
|
||||||
|
private const ITEMS = [
|
||||||
|
'administrator/assessments' => 'Assessment Management',
|
||||||
|
'student/assessments' => 'My Assessments',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
if (! $this->db->tableExists('nav_items')) return;
|
||||||
|
$parentColumn = $this->parentColumn();
|
||||||
|
$studentAffairsId = null;
|
||||||
|
if ($parentColumn !== null) {
|
||||||
|
$parent = $this->db->table('nav_items')->select('id')->where('label', 'Student-Affairs')->where($parentColumn, null)->get()->getRowArray();
|
||||||
|
$studentAffairsId = $parent ? (int) $parent['id'] : null;
|
||||||
|
}
|
||||||
|
$adminId = $this->upsertItem(self::ITEMS['administrator/assessments'], 'administrator/assessments', $studentAffairsId, 7);
|
||||||
|
$studentId = $this->upsertItem(self::ITEMS['student/assessments'], 'student/assessments', null, 80);
|
||||||
|
if ($this->db->tableExists('role_nav_items') && $this->db->tableExists('roles')) {
|
||||||
|
$roles = $this->db->table('roles')->select('id, name')->get()->getResultArray();
|
||||||
|
foreach ($roles as $role) {
|
||||||
|
$token = strtolower(str_replace([' ', '-'], '_', trim((string) ($role['name'] ?? ''))));
|
||||||
|
if (in_array($token, ['parent', 'student'], true)) {
|
||||||
|
$this->grant((int) $role['id'], $studentId);
|
||||||
|
} elseif (! in_array($token, ['guest', 'teacher', 'teacher_assistant', 'assistant_teacher', 'ta'], true)) {
|
||||||
|
$this->grant((int) $role['id'], $adminId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cache()->clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
if (! $this->db->tableExists('nav_items')) return;
|
||||||
|
foreach (array_keys(self::ITEMS) as $url) {
|
||||||
|
$row = $this->db->table('nav_items')->select('id')->where('url', $url)->get()->getRowArray();
|
||||||
|
if ($row && $this->db->tableExists('role_nav_items')) {
|
||||||
|
$this->db->table('role_nav_items')->where('nav_item_id', (int) $row['id'])->delete();
|
||||||
|
}
|
||||||
|
$this->db->table('nav_items')->where('url', $url)->delete();
|
||||||
|
}
|
||||||
|
cache()->clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function upsertItem(string $label, string $url, ?int $parentId, int $sort): int
|
||||||
|
{
|
||||||
|
$existing = $this->db->table('nav_items')->select('id')->where('url', $url)->get()->getRowArray();
|
||||||
|
$data = ['label' => $label, 'is_enabled' => 1, 'sort_order' => $sort, 'updated_at' => date('Y-m-d H:i:s')];
|
||||||
|
$parentColumn = $this->parentColumn();
|
||||||
|
if ($parentColumn !== null) $data[$parentColumn] = $parentId;
|
||||||
|
if ($this->db->fieldExists('icon_class', 'nav_items')) $data['icon_class'] = 'bi bi-ui-checks-grid';
|
||||||
|
if ($existing) {
|
||||||
|
$this->db->table('nav_items')->where('id', (int) $existing['id'])->update($data);
|
||||||
|
return (int) $existing['id'];
|
||||||
|
}
|
||||||
|
$data += ['url' => $url, 'created_at' => date('Y-m-d H:i:s')];
|
||||||
|
$this->db->table('nav_items')->insert($data);
|
||||||
|
return (int) $this->db->insertID();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function grant(int $roleId, int $navItemId): void
|
||||||
|
{
|
||||||
|
if ($roleId <= 0 || $navItemId <= 0) return;
|
||||||
|
$builder = $this->db->table('role_nav_items');
|
||||||
|
if ($builder->where(['role_id' => $roleId, 'nav_item_id' => $navItemId])->countAllResults() > 0) return;
|
||||||
|
$data = ['role_id' => $roleId, 'nav_item_id' => $navItemId];
|
||||||
|
if ($this->db->fieldExists('created_at', 'role_nav_items')) $data['created_at'] = date('Y-m-d H:i:s');
|
||||||
|
if ($this->db->fieldExists('updated_at', 'role_nav_items')) $data['updated_at'] = date('Y-m-d H:i:s');
|
||||||
|
$this->db->table('role_nav_items')->insert($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parentColumn(): ?string
|
||||||
|
{
|
||||||
|
if ($this->db->fieldExists('menu_parent_id', 'nav_items')) return 'menu_parent_id';
|
||||||
|
if ($this->db->fieldExists('parent_id', 'nav_items')) return 'parent_id';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Database\Migrations;
|
||||||
|
|
||||||
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
||||||
|
class SeedNewStudentAssessmentQuestions extends Migration
|
||||||
|
{
|
||||||
|
private const POOL_NAME = 'New Student Assessment';
|
||||||
|
|
||||||
|
private const QUESTIONS = [
|
||||||
|
[
|
||||||
|
'type' => 'essay',
|
||||||
|
'text' => 'What is the student’s Islamic education background?',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'type' => 'short_answer',
|
||||||
|
'text' => 'What is the student’s Arabic competency level?',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'type' => 'short_answer',
|
||||||
|
'text' => 'What is the student’s English competency level?',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'type' => 'essay',
|
||||||
|
'text' => 'Has the student attended any Sunday/Islamic school in the past? If YES, which one?',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'type' => 'essay',
|
||||||
|
'text' => 'Has the student memorized any Surahs? If yes, which one(s)?',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'type' => 'essay',
|
||||||
|
'text' => 'Why have the parents chosen AlRahma Sunday School for their child(ren)?',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'type' => 'essay',
|
||||||
|
'text' => 'Any serious/special medical condition about the student which we have to be aware of?',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'type' => 'essay',
|
||||||
|
'text' => 'If surnames of enrolled students are different, please confirm if they are all siblings; if NOT, then please specify their relationship to each other:',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'type' => 'essay',
|
||||||
|
'text' => 'Any other questions / concerns that the parents may have?',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
if (! $this->db->tableExists('question_pools') || ! $this->db->tableExists('assessment_questions')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pool = $this->db->table('question_pools')
|
||||||
|
->select('id')
|
||||||
|
->where('name', self::POOL_NAME)
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
|
||||||
|
if ($pool === null) {
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
$this->db->table('question_pools')->insert([
|
||||||
|
'name' => self::POOL_NAME,
|
||||||
|
'subject_tag' => 'New Student Intake',
|
||||||
|
'created_by' => null,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
$poolId = (int) $this->db->insertID();
|
||||||
|
} else {
|
||||||
|
$poolId = (int) $pool['id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($poolId <= 0) return;
|
||||||
|
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
$maxOrder = $this->db->table('assessment_questions')->selectMax('order_index')->where('pool_id', $poolId)->get()->getRowArray();
|
||||||
|
$nextOrder = ((int) ($maxOrder['order_index'] ?? 0)) + 1;
|
||||||
|
foreach (self::QUESTIONS as $question) {
|
||||||
|
$exists = $this->db->table('assessment_questions')
|
||||||
|
->where('pool_id', $poolId)
|
||||||
|
->where('text', $question['text'])
|
||||||
|
->countAllResults() > 0;
|
||||||
|
if ($exists) continue;
|
||||||
|
|
||||||
|
$this->db->table('assessment_questions')->insert([
|
||||||
|
'pool_id' => $poolId,
|
||||||
|
'type' => $question['type'],
|
||||||
|
'text' => $question['text'],
|
||||||
|
'options' => null,
|
||||||
|
'correct_answer' => null,
|
||||||
|
'points' => 0,
|
||||||
|
'order_index' => $nextOrder++,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
if (! $this->db->tableExists('question_pools') || ! $this->db->tableExists('assessment_questions')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pool = $this->db->table('question_pools')->select('id')->where('name', self::POOL_NAME)->get()->getRowArray();
|
||||||
|
if ($pool === null) return;
|
||||||
|
|
||||||
|
$poolId = (int) $pool['id'];
|
||||||
|
foreach (array_column(self::QUESTIONS, 'text') as $text) {
|
||||||
|
$question = $this->db->table('assessment_questions')->select('id')->where(['pool_id' => $poolId, 'text' => $text])->get()->getRowArray();
|
||||||
|
if ($question === null) continue;
|
||||||
|
if ($this->db->tableExists('assessment_form_questions')
|
||||||
|
&& $this->db->table('assessment_form_questions')->where('question_id', (int) $question['id'])->countAllResults() > 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$this->db->table('assessment_questions')->where('id', (int) $question['id'])->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
$hasQuestions = $this->db->table('assessment_questions')->where('pool_id', $poolId)->countAllResults() > 0;
|
||||||
|
$hasForms = $this->db->tableExists('assessment_forms')
|
||||||
|
&& $this->db->table('assessment_forms')->where('pool_id', $poolId)->countAllResults() > 0;
|
||||||
|
if (! $hasQuestions && ! $hasForms) {
|
||||||
|
$this->db->table('question_pools')->where('id', $poolId)->delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Database\Migrations;
|
||||||
|
|
||||||
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
||||||
|
class AddEducationCommitteeNoteToAssessmentForms extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
if (! $this->db->tableExists('assessment_forms')
|
||||||
|
|| $this->db->fieldExists('education_committee_note', 'assessment_forms')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->forge->addColumn('assessment_forms', [
|
||||||
|
'education_committee_note' => [
|
||||||
|
'type' => 'TEXT',
|
||||||
|
'null' => true,
|
||||||
|
'after' => 'status',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
if ($this->db->tableExists('assessment_forms')
|
||||||
|
&& $this->db->fieldExists('education_committee_note', 'assessment_forms')) {
|
||||||
|
$this->forge->dropColumn('assessment_forms', 'education_committee_note');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Database\Migrations;
|
||||||
|
|
||||||
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
||||||
|
class AddSchoolYearToAssessmentForms extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
if (! $this->db->tableExists('assessment_forms')) return;
|
||||||
|
|
||||||
|
if (! $this->db->fieldExists('school_year', 'assessment_forms')) {
|
||||||
|
$this->forge->addColumn('assessment_forms', [
|
||||||
|
'school_year' => [
|
||||||
|
'type' => 'VARCHAR',
|
||||||
|
'constraint' => 9,
|
||||||
|
'null' => true,
|
||||||
|
'after' => 'pool_id',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$schoolYear = $this->activeSchoolYear();
|
||||||
|
if ($schoolYear !== null) {
|
||||||
|
$this->db->table('assessment_forms')
|
||||||
|
->groupStart()->where('school_year', null)->orWhere('school_year', '')->groupEnd()
|
||||||
|
->update(['school_year' => $schoolYear]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
if ($this->db->tableExists('assessment_forms') && $this->db->fieldExists('school_year', 'assessment_forms')) {
|
||||||
|
$this->forge->dropColumn('assessment_forms', 'school_year');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function activeSchoolYear(): ?string
|
||||||
|
{
|
||||||
|
if ($this->db->tableExists('school_years')) {
|
||||||
|
$row = $this->db->table('school_years')->select('name')->where('status', 'active')->orderBy('id', 'DESC')->get()->getRowArray();
|
||||||
|
$name = trim((string) ($row['name'] ?? ''));
|
||||||
|
if (preg_match('/^\d{4}-\d{4}$/', $name)) return $name;
|
||||||
|
}
|
||||||
|
if ($this->db->tableExists('configuration')) {
|
||||||
|
$row = $this->db->table('configuration')->select('config_value')->where('config_key', 'school_year')->orderBy('id', 'DESC')->get()->getRowArray();
|
||||||
|
$name = trim((string) ($row['config_value'] ?? ''));
|
||||||
|
if (preg_match('/^\d{4}-\d{4}$/', $name)) return $name;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Database\Migrations;
|
||||||
|
|
||||||
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
||||||
|
class AddEducationCommitteeNoteToStudentAssessments extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
if (! $this->db->tableExists('student_assessments')
|
||||||
|
|| $this->db->fieldExists('education_committee_note', 'student_assessments')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->forge->addColumn('student_assessments', [
|
||||||
|
'education_committee_note' => [
|
||||||
|
'type' => 'TEXT',
|
||||||
|
'null' => true,
|
||||||
|
'after' => 'score',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
if ($this->db->tableExists('student_assessments')
|
||||||
|
&& $this->db->fieldExists('education_committee_note', 'student_assessments')) {
|
||||||
|
$this->forge->dropColumn('student_assessments', 'education_committee_note');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -81,6 +81,18 @@ class AuthFilter implements FilterInterface
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($this->isAllowedByGrantedNavItem($request, $roleIds)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->isAllowedFamilyCardRequest($request, $roleIds)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->isAllowedPrintRequestSupportRequest($request, $roleIds)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
return $this->deny($request, "You don't have permission to use this feature.");
|
return $this->deny($request, "You don't have permission to use this feature.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,6 +163,9 @@ class AuthFilter implements FilterInterface
|
|||||||
|
|
||||||
foreach ($alternatives as $alternative) {
|
foreach ($alternatives as $alternative) {
|
||||||
$candidate = strtolower($alternative);
|
$candidate = strtolower($alternative);
|
||||||
|
if ($candidate === 'admin_category' && (bool) session()->get('is_admin')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (in_array($candidate, $normalizedRoles, true)) {
|
if (in_array($candidate, $normalizedRoles, true)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -271,4 +286,128 @@ class AuthFilter implements FilterInterface
|
|||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function isAllowedByGrantedNavItem(RequestInterface $request, array $roleIds): bool
|
||||||
|
{
|
||||||
|
if (
|
||||||
|
empty($roleIds)
|
||||||
|
|| ! $this->db->tableExists('role_nav_items')
|
||||||
|
|| ! $this->db->tableExists('nav_items')
|
||||||
|
|| ! $this->db->fieldExists('role_id', 'role_nav_items')
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = $this->normalizePath($request->getUri()->getPath());
|
||||||
|
if ($path === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = $this->db->table('role_nav_items rni')
|
||||||
|
->select('ni.url')
|
||||||
|
->join('nav_items ni', 'ni.id = rni.nav_item_id')
|
||||||
|
->whereIn('rni.role_id', $roleIds)
|
||||||
|
->where('ni.url IS NOT NULL', null, false)
|
||||||
|
->where('ni.is_enabled', 1)
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$url = $this->normalizePath((string) ($row['url'] ?? ''));
|
||||||
|
if ($url === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($path === $url || str_starts_with($path . '/', rtrim($url, '/') . '/')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($url, 'admin/')) {
|
||||||
|
$withoutAdminPrefix = substr($url, 6);
|
||||||
|
if ($path === $withoutAdminPrefix || str_starts_with($path . '/', rtrim($withoutAdminPrefix, '/') . '/')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isAllowedFamilyCardRequest(RequestInterface $request, array $roleIds): bool
|
||||||
|
{
|
||||||
|
if ($this->normalizePath($request->getUri()->getPath()) !== 'family/card') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (['view_new_students', 'view_financial_reports'] as $permissionName) {
|
||||||
|
if ($this->userHasNamedPermission($roleIds, $permissionName, 'read')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isAllowedPrintRequestSupportRequest(RequestInterface $request, array $roleIds): bool
|
||||||
|
{
|
||||||
|
$path = $this->normalizePath($request->getUri()->getPath());
|
||||||
|
$printRequestPaths = [
|
||||||
|
'print-requests/update/',
|
||||||
|
'print-requests/delete/',
|
||||||
|
'print-requests/file/',
|
||||||
|
'uploads/print_requests/',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($printRequestPaths as $prefix) {
|
||||||
|
if (str_starts_with($path, $prefix)) {
|
||||||
|
return $this->hasGrantedNavUrl($roleIds, 'admin/print-requests');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hasGrantedNavUrl(array $roleIds, string $url): bool
|
||||||
|
{
|
||||||
|
if (
|
||||||
|
empty($roleIds)
|
||||||
|
|| ! $this->db->tableExists('role_nav_items')
|
||||||
|
|| ! $this->db->tableExists('nav_items')
|
||||||
|
|| ! $this->db->fieldExists('role_id', 'role_nav_items')
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$targetUrl = $this->normalizePath($url);
|
||||||
|
$rows = $this->db->table('role_nav_items rni')
|
||||||
|
->select('ni.url')
|
||||||
|
->join('nav_items ni', 'ni.id = rni.nav_item_id')
|
||||||
|
->whereIn('rni.role_id', $roleIds)
|
||||||
|
->where('ni.url IS NOT NULL', null, false)
|
||||||
|
->where('ni.is_enabled', 1)
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
if ($this->normalizePath((string) ($row['url'] ?? '')) === $targetUrl) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizePath(string $path): string
|
||||||
|
{
|
||||||
|
$path = trim(preg_replace('#/+#', '/', $path), '/');
|
||||||
|
if ($path === 'index.php') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($path, 'index.php/')) {
|
||||||
|
return substr($path, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,6 @@ class CleanupScheduler implements FilterInterface
|
|||||||
{
|
{
|
||||||
// Call the cleanup controller method
|
// Call the cleanup controller method
|
||||||
\CodeIgniter\CLI\CLI::init();
|
\CodeIgniter\CLI\CLI::init();
|
||||||
command('cleanup:unverified_users');
|
command('users:delete-inactive-users');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ final class SchoolYearWritableFilter implements FilterInterface
|
|||||||
'api/register',
|
'api/register',
|
||||||
'user/select_role',
|
'user/select_role',
|
||||||
'set-role',
|
'set-role',
|
||||||
|
'parent_dashboard/job-openings-popup',
|
||||||
|
// Assessments are globally shared and are not tied to a school-year record.
|
||||||
|
'administrator/assessments',
|
||||||
|
'student/assessments',
|
||||||
'processForgotPassword',
|
'processForgotPassword',
|
||||||
'user/forgot_password',
|
'user/forgot_password',
|
||||||
'user/processResetPassword',
|
'user/processResetPassword',
|
||||||
|
|||||||
@@ -452,6 +452,29 @@ class InvoiceLedgerService
|
|||||||
|
|
||||||
protected function calculateTuitionTotal(array $invoice): float
|
protected function calculateTuitionTotal(array $invoice): float
|
||||||
{
|
{
|
||||||
|
$invoiceId = (int) ($invoice['id'] ?? 0);
|
||||||
|
if (
|
||||||
|
$invoiceId > 0
|
||||||
|
&& $this->invoiceStudentListModel->db->tableExists('invoice_students_list')
|
||||||
|
&& $this->invoiceStudentListModel->db->fieldExists('tuition_fee', 'invoice_students_list')
|
||||||
|
) {
|
||||||
|
$snapshot = $this->invoiceStudentListModel
|
||||||
|
->select(
|
||||||
|
'COALESCE(SUM(tuition_fee),0) AS total_amount, '
|
||||||
|
. 'COALESCE(SUM(CASE WHEN ABS(tuition_fee) > 0 THEN 1 ELSE 0 END),0) AS priced_rows',
|
||||||
|
false
|
||||||
|
)
|
||||||
|
->where('invoice_id', $invoiceId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
// A priced snapshot is the amount actually issued to the family. It
|
||||||
|
// intentionally excludes later live configuration changes (including
|
||||||
|
// book fees that were not part of this invoice).
|
||||||
|
if ((int) ($snapshot['priced_rows'] ?? 0) > 0) {
|
||||||
|
return (float) ($snapshot['total_amount'] ?? 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$parentId = (int) ($invoice['parent_id'] ?? 0);
|
$parentId = (int) ($invoice['parent_id'] ?? 0);
|
||||||
$schoolYear = (string) ($invoice['school_year'] ?? '');
|
$schoolYear = (string) ($invoice['school_year'] ?? '');
|
||||||
if ($parentId <= 0 || $schoolYear === '') {
|
if ($parentId <= 0 || $schoolYear === '') {
|
||||||
@@ -480,11 +503,14 @@ class InvoiceLedgerService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InvoiceController enforces one invoice per parent and school year, and
|
||||||
|
// invoice generation includes the full year's event charges. Keep the
|
||||||
|
// ledger on that same scope so recalculation cannot drop another term's
|
||||||
|
// event charges from the invoice total.
|
||||||
$rows = $this->eventChargesModel
|
$rows = $this->eventChargesModel
|
||||||
->select('COALESCE(SUM(charged),0) AS total_amount')
|
->select('COALESCE(SUM(charged),0) AS total_amount')
|
||||||
->where('parent_id', (int) ($invoice['parent_id'] ?? 0))
|
->where('parent_id', (int) ($invoice['parent_id'] ?? 0))
|
||||||
->where('school_year', (string) ($invoice['school_year'] ?? ''))
|
->where('school_year', (string) ($invoice['school_year'] ?? ''))
|
||||||
->where('semester', (string) ($invoice['semester'] ?? ''))
|
|
||||||
->findAll();
|
->findAll();
|
||||||
|
|
||||||
return (float) ($rows[0]['total_amount'] ?? 0);
|
return (float) ($rows[0]['total_amount'] ?? 0);
|
||||||
@@ -493,12 +519,28 @@ class InvoiceLedgerService
|
|||||||
protected function calculateAdditionalCharges(int $invoiceId): float
|
protected function calculateAdditionalCharges(int $invoiceId): float
|
||||||
{
|
{
|
||||||
$rows = $this->additionalChargeModel
|
$rows = $this->additionalChargeModel
|
||||||
->select("COALESCE(SUM(CASE WHEN charge_type = 'deduct' THEN -ABS(amount) ELSE ABS(amount) END),0) AS total_amount", false)
|
->select('charge_type, amount')
|
||||||
->where('invoice_id', $invoiceId)
|
->where('invoice_id', $invoiceId)
|
||||||
->where('status', FinancialStatus::ADDITIONAL_CHARGE_APPLIED)
|
->where('status', FinancialStatus::ADDITIONAL_CHARGE_APPLIED)
|
||||||
->findAll();
|
->findAll();
|
||||||
|
|
||||||
return (float) ($rows[0]['total_amount'] ?? 0);
|
return array_reduce(
|
||||||
|
$rows,
|
||||||
|
static fn (float $sum, array $charge): float => $sum + self::signedAdditionalChargeAmount($charge),
|
||||||
|
0.0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function signedAdditionalChargeAmount(array $charge): float
|
||||||
|
{
|
||||||
|
$amount = (float) ($charge['amount'] ?? 0.0);
|
||||||
|
if ($amount < 0) {
|
||||||
|
return $amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string) ($charge['charge_type'] ?? '') === 'deduct'
|
||||||
|
? -abs($amount)
|
||||||
|
: $amount;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function calculateDiscounts(int $invoiceId): float
|
protected function calculateDiscounts(int $invoiceId): float
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use CodeIgniter\Model;
|
||||||
|
|
||||||
|
class ApplicationModel extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'applications';
|
||||||
|
protected $primaryKey = 'application_id';
|
||||||
|
protected $useAutoIncrement = false;
|
||||||
|
protected $returnType = 'array';
|
||||||
|
protected $allowedFields = [
|
||||||
|
'application_id',
|
||||||
|
'position_id',
|
||||||
|
'first_name',
|
||||||
|
'last_name',
|
||||||
|
'email',
|
||||||
|
'phone',
|
||||||
|
'resume_file_url',
|
||||||
|
'status',
|
||||||
|
'admin_notes',
|
||||||
|
'submitted_at',
|
||||||
|
];
|
||||||
|
protected $useTimestamps = false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use CodeIgniter\Model;
|
||||||
|
|
||||||
|
class AssessmentFormModel extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'assessment_forms';
|
||||||
|
protected $primaryKey = 'id';
|
||||||
|
protected $returnType = 'array';
|
||||||
|
protected $allowedFields = ['name', 'pool_id', 'school_year', 'created_by', 'status', 'education_committee_note'];
|
||||||
|
protected $useTimestamps = true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use CodeIgniter\Model;
|
||||||
|
|
||||||
|
class AssessmentFormQuestionModel extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'assessment_form_questions';
|
||||||
|
// CI's Model API accepts one primary-key field; the database enforces the
|
||||||
|
// actual composite key (form_id, question_id).
|
||||||
|
protected $primaryKey = 'form_id';
|
||||||
|
protected $useAutoIncrement = false;
|
||||||
|
protected $returnType = 'array';
|
||||||
|
protected $allowedFields = ['form_id', 'question_id', 'order_index'];
|
||||||
|
protected $useTimestamps = false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use CodeIgniter\Model;
|
||||||
|
|
||||||
|
class AssessmentQuestionModel extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'assessment_questions';
|
||||||
|
protected $primaryKey = 'id';
|
||||||
|
protected $returnType = 'array';
|
||||||
|
protected $allowedFields = ['pool_id', 'type', 'text', 'options', 'correct_answer', 'points', 'order_index'];
|
||||||
|
protected $useTimestamps = true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use CodeIgniter\Model;
|
||||||
|
|
||||||
|
class JobPositionModel extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'job_positions';
|
||||||
|
protected $primaryKey = 'position_id';
|
||||||
|
protected $useAutoIncrement = false;
|
||||||
|
protected $returnType = 'array';
|
||||||
|
protected $allowedFields = [
|
||||||
|
'position_id',
|
||||||
|
'template_id',
|
||||||
|
'title',
|
||||||
|
'description',
|
||||||
|
'department',
|
||||||
|
'location',
|
||||||
|
'employment_type',
|
||||||
|
'responsibilities',
|
||||||
|
'requirements',
|
||||||
|
'status',
|
||||||
|
'posted_by',
|
||||||
|
'posted_at',
|
||||||
|
'details_click_count',
|
||||||
|
'created_at',
|
||||||
|
'updated_at',
|
||||||
|
];
|
||||||
|
protected $useTimestamps = true;
|
||||||
|
protected $createdField = 'created_at';
|
||||||
|
protected $updatedField = 'updated_at';
|
||||||
|
|
||||||
|
public function openPositions(): array
|
||||||
|
{
|
||||||
|
return $this
|
||||||
|
->where('status', 'open')
|
||||||
|
->orderBy('posted_at', 'DESC')
|
||||||
|
->orderBy('created_at', 'DESC')
|
||||||
|
->findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function recordDetailsClick(string $positionId): bool
|
||||||
|
{
|
||||||
|
return $this->builder()
|
||||||
|
->set('details_click_count', 'COALESCE(details_click_count, 0) + 1', false)
|
||||||
|
->where('position_id', $positionId)
|
||||||
|
->where('status', 'open')
|
||||||
|
->update();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function adminPositions(): array
|
||||||
|
{
|
||||||
|
return $this
|
||||||
|
->orderBy('updated_at', 'DESC')
|
||||||
|
->orderBy('created_at', 'DESC')
|
||||||
|
->findAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use CodeIgniter\Model;
|
||||||
|
|
||||||
|
class JobTemplateModel extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'job_templates';
|
||||||
|
protected $primaryKey = 'template_id';
|
||||||
|
protected $useAutoIncrement = false;
|
||||||
|
protected $returnType = 'array';
|
||||||
|
protected $allowedFields = [
|
||||||
|
'template_id',
|
||||||
|
'title',
|
||||||
|
'description',
|
||||||
|
'department',
|
||||||
|
'location',
|
||||||
|
'employment_type',
|
||||||
|
'responsibilities',
|
||||||
|
'requirements',
|
||||||
|
'version',
|
||||||
|
'is_active',
|
||||||
|
'created_by',
|
||||||
|
'created_at',
|
||||||
|
'updated_at',
|
||||||
|
];
|
||||||
|
protected $useTimestamps = true;
|
||||||
|
protected $createdField = 'created_at';
|
||||||
|
protected $updatedField = 'updated_at';
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use CodeIgniter\Model;
|
||||||
|
|
||||||
|
class JobTemplateVersionModel extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'job_template_versions';
|
||||||
|
protected $primaryKey = 'version_id';
|
||||||
|
protected $useAutoIncrement = false;
|
||||||
|
protected $returnType = 'array';
|
||||||
|
protected $allowedFields = [
|
||||||
|
'version_id',
|
||||||
|
'template_id',
|
||||||
|
'version',
|
||||||
|
'title',
|
||||||
|
'description',
|
||||||
|
'department',
|
||||||
|
'location',
|
||||||
|
'employment_type',
|
||||||
|
'responsibilities',
|
||||||
|
'requirements',
|
||||||
|
'saved_by',
|
||||||
|
'saved_at',
|
||||||
|
];
|
||||||
|
protected $useTimestamps = false;
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ class PreferencesModel extends Model
|
|||||||
'menu_custom_bg', // Custom menu background color
|
'menu_custom_bg', // Custom menu background color
|
||||||
'menu_custom_text', // Custom menu text color
|
'menu_custom_text', // Custom menu text color
|
||||||
'menu_custom_mode', // Custom menu mode: light|dark
|
'menu_custom_mode', // Custom menu mode: light|dark
|
||||||
|
'hide_job_openings_popup', // Parent dismissed volunteer openings popup
|
||||||
'created_at', // Timestamp of when the record was created
|
'created_at', // Timestamp of when the record was created
|
||||||
'updated_at' // Timestamp of when the record was last updated
|
'updated_at' // Timestamp of when the record was last updated
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use CodeIgniter\Model;
|
||||||
|
|
||||||
|
class QuestionPoolModel extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'question_pools';
|
||||||
|
protected $primaryKey = 'id';
|
||||||
|
protected $returnType = 'array';
|
||||||
|
protected $allowedFields = ['name', 'subject_tag', 'created_by'];
|
||||||
|
protected $useTimestamps = true;
|
||||||
|
}
|
||||||
@@ -63,8 +63,16 @@ class RoleModel extends Model
|
|||||||
$names = array_values(array_filter(array_map('strval', $names)));
|
$names = array_values(array_filter(array_map('strval', $names)));
|
||||||
if (empty($names)) return [];
|
if (empty($names)) return [];
|
||||||
|
|
||||||
// collation is usually case-insensitive; if not, add LOWER() both sides
|
$lower = array_values(array_unique(array_map('strtolower', $names)));
|
||||||
$ids = $this->select('id')->whereIn('name', $names)->findColumn('id');
|
$builder = $this->select('id')
|
||||||
|
->groupStart()
|
||||||
|
->whereIn('LOWER(name)', $lower);
|
||||||
|
|
||||||
|
if ($this->db->fieldExists('slug', $this->table)) {
|
||||||
|
$builder->orWhereIn('LOWER(slug)', $lower);
|
||||||
|
}
|
||||||
|
|
||||||
|
$ids = $builder->groupEnd()->findColumn('id');
|
||||||
return array_map('intval', $ids ?? []);
|
return array_map('intval', $ids ?? []);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use CodeIgniter\Model;
|
||||||
|
|
||||||
|
class StudentAnswerModel extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'student_answers';
|
||||||
|
protected $primaryKey = 'id';
|
||||||
|
protected $returnType = 'array';
|
||||||
|
protected $allowedFields = ['student_assessment_id', 'question_id', 'answer_value', 'is_correct', 'points_awarded'];
|
||||||
|
protected $useTimestamps = true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use CodeIgniter\Model;
|
||||||
|
|
||||||
|
class StudentAssessmentModel extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'student_assessments';
|
||||||
|
protected $primaryKey = 'id';
|
||||||
|
protected $returnType = 'array';
|
||||||
|
protected $allowedFields = ['form_id', 'student_id', 'status', 'assigned_at', 'started_at', 'submitted_at', 'graded_at', 'graded_by', 'score', 'education_committee_note'];
|
||||||
|
protected $useTimestamps = true;
|
||||||
|
}
|
||||||
@@ -492,18 +492,6 @@ class StudentModel extends Model
|
|||||||
(
|
(
|
||||||
student_class.student_id IS NOT NULL
|
student_class.student_id IS NOT NULL
|
||||||
OR enrollments.student_id IS NOT NULL
|
OR enrollments.student_id IS NOT NULL
|
||||||
OR (
|
|
||||||
NOT EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM student_class sc_history
|
|
||||||
WHERE sc_history.student_id = students.id
|
|
||||||
)
|
|
||||||
AND NOT EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM enrollments e_history
|
|
||||||
WHERE e_history.student_id = students.id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
";
|
";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use CodeIgniter\Model;
|
||||||
|
|
||||||
|
class UserAccessProfileModel extends Model
|
||||||
|
{
|
||||||
|
public const CATEGORY_ADMIN = 'admin';
|
||||||
|
public const CATEGORY_TEACHER = 'teacher';
|
||||||
|
public const CATEGORY_PARENT = 'parent';
|
||||||
|
public const CATEGORY_GUEST = 'guest';
|
||||||
|
|
||||||
|
private const TEACHER_ROLE_TOKENS = ['teacher', 'teacher_assistant', 'teacher assistant', 'assistant_teacher', 'ta'];
|
||||||
|
private const NON_ADMIN_ROLE_TOKENS = ['guest', 'parent', 'student', 'teacher', 'teacher_assistant', 'teacher assistant', 'assistant_teacher', 'ta'];
|
||||||
|
|
||||||
|
protected $table = 'user_access_profiles';
|
||||||
|
protected $primaryKey = 'id';
|
||||||
|
protected $returnType = 'array';
|
||||||
|
protected $allowedFields = [
|
||||||
|
'user_id',
|
||||||
|
'primary_category',
|
||||||
|
'is_admin',
|
||||||
|
'is_teacher',
|
||||||
|
'is_parent',
|
||||||
|
'role_names',
|
||||||
|
'created_at',
|
||||||
|
'updated_at',
|
||||||
|
];
|
||||||
|
protected $useTimestamps = true;
|
||||||
|
protected $createdField = 'created_at';
|
||||||
|
protected $updatedField = 'updated_at';
|
||||||
|
|
||||||
|
public function syncUser(int $userId): ?array
|
||||||
|
{
|
||||||
|
if ($userId <= 0 || ! $this->db->tableExists($this->table)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$roles = $this->rolesForUser($userId);
|
||||||
|
$flags = self::flagsForRoles($roles);
|
||||||
|
$now = utc_now();
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'user_id' => $userId,
|
||||||
|
'primary_category' => self::primaryCategory($flags),
|
||||||
|
'is_admin' => $flags['is_admin'] ? 1 : 0,
|
||||||
|
'is_teacher' => $flags['is_teacher'] ? 1 : 0,
|
||||||
|
'is_parent' => $flags['is_parent'] ? 1 : 0,
|
||||||
|
'role_names' => implode(', ', array_values(array_unique(array_map(
|
||||||
|
static fn (array $role): string => (string) ($role['name'] ?? ''),
|
||||||
|
$roles
|
||||||
|
)))),
|
||||||
|
'updated_at' => $now,
|
||||||
|
];
|
||||||
|
|
||||||
|
$existing = $this->where('user_id', $userId)->first();
|
||||||
|
if ($existing) {
|
||||||
|
$this->update((int) $existing['id'], $data);
|
||||||
|
return $this->find((int) $existing['id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data['created_at'] = $now;
|
||||||
|
$id = $this->insert($data);
|
||||||
|
|
||||||
|
return $id ? $this->find((int) $id) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function syncAll(): void
|
||||||
|
{
|
||||||
|
if (! $this->db->tableExists('users') || ! $this->db->tableExists($this->table)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = $this->db->table('users')->select('id')->get()->getResultArray();
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$this->syncUser((int) ($row['id'] ?? 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getForUser(int $userId): ?array
|
||||||
|
{
|
||||||
|
if ($userId <= 0 || ! $this->db->tableExists($this->table)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$profile = $this->where('user_id', $userId)->first();
|
||||||
|
|
||||||
|
return $profile ?: $this->syncUser($userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUsersByCategory(string $category): array
|
||||||
|
{
|
||||||
|
$field = match (self::normalizeRoleToken($category)) {
|
||||||
|
self::CATEGORY_ADMIN => 'is_admin',
|
||||||
|
self::CATEGORY_TEACHER => 'is_teacher',
|
||||||
|
self::CATEGORY_PARENT => 'is_parent',
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if ($field === null || ! $this->db->tableExists($this->table)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->select('users.*, user_access_profiles.primary_category, user_access_profiles.role_names')
|
||||||
|
->join('users', 'users.id = user_access_profiles.user_id', 'inner')
|
||||||
|
->where($field, 1)
|
||||||
|
->orderBy('users.lastname', 'ASC')
|
||||||
|
->orderBy('users.firstname', 'ASC')
|
||||||
|
->findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function flagsForRoles(array $roles): array
|
||||||
|
{
|
||||||
|
$tokens = [];
|
||||||
|
foreach ($roles as $role) {
|
||||||
|
if (is_array($role)) {
|
||||||
|
$tokens[] = self::normalizeRoleToken((string) ($role['slug'] ?? ''));
|
||||||
|
$tokens[] = self::normalizeRoleToken((string) ($role['name'] ?? ''));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tokens[] = self::normalizeRoleToken((string) $role);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tokens = array_values(array_unique(array_filter($tokens)));
|
||||||
|
$isParent = in_array('parent', $tokens, true);
|
||||||
|
$isTeacher = count(array_intersect($tokens, array_map([self::class, 'normalizeRoleToken'], self::TEACHER_ROLE_TOKENS))) > 0;
|
||||||
|
$isAdmin = false;
|
||||||
|
|
||||||
|
foreach ($tokens as $token) {
|
||||||
|
if (! in_array($token, array_map([self::class, 'normalizeRoleToken'], self::NON_ADMIN_ROLE_TOKENS), true)) {
|
||||||
|
$isAdmin = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'is_admin' => $isAdmin,
|
||||||
|
'is_teacher' => $isTeacher,
|
||||||
|
'is_parent' => $isParent,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function primaryCategory(array $flags): string
|
||||||
|
{
|
||||||
|
if (! empty($flags['is_admin'])) {
|
||||||
|
return self::CATEGORY_ADMIN;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($flags['is_teacher'])) {
|
||||||
|
return self::CATEGORY_TEACHER;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($flags['is_parent'])) {
|
||||||
|
return self::CATEGORY_PARENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::CATEGORY_GUEST;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function rolesForUser(int $userId): array
|
||||||
|
{
|
||||||
|
if (! $this->db->tableExists('user_roles') || ! $this->db->tableExists('roles')) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$builder = $this->db->table('user_roles ur')
|
||||||
|
->select('r.name, r.slug')
|
||||||
|
->join('roles r', 'r.id = ur.role_id', 'inner')
|
||||||
|
->where('ur.user_id', $userId)
|
||||||
|
->where('COALESCE(r.is_active, 1) = 1', null, false);
|
||||||
|
|
||||||
|
if ($this->db->fieldExists('deleted_at', 'user_roles')) {
|
||||||
|
$builder->where('ur.deleted_at', null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $builder->get()->getResultArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function normalizeRoleToken(string $value): string
|
||||||
|
{
|
||||||
|
$value = strtolower(trim($value));
|
||||||
|
return str_replace([' ', '-'], '_', $value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,36 @@ class UserModel extends Model
|
|||||||
protected $useTimestamps = true; // Enable automatic timestamps
|
protected $useTimestamps = true; // Enable automatic timestamps
|
||||||
protected $createdField = 'created_at'; // Define the field name for the created timestamp
|
protected $createdField = 'created_at'; // Define the field name for the created timestamp
|
||||||
protected $updatedField = 'updated_at'; // Define the field name for the updated timestamp
|
protected $updatedField = 'updated_at'; // Define the field name for the updated timestamp
|
||||||
|
protected $afterInsert = ['syncAccessProfileAfterInsert'];
|
||||||
|
protected $afterDelete = ['deleteAccessProfileAfterDelete'];
|
||||||
|
|
||||||
|
protected function syncAccessProfileAfterInsert(array $data): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$userId = (int) ($data['id'] ?? 0);
|
||||||
|
if ($userId > 0) {
|
||||||
|
model(UserAccessProfileModel::class)->syncUser($userId);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
log_message('error', 'UserModel access profile sync failed: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function deleteAccessProfileAfterDelete(array $data): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$ids = array_filter(array_map('intval', (array) ($data['id'] ?? [])));
|
||||||
|
if ($ids !== [] && $this->db->tableExists('user_access_profiles')) {
|
||||||
|
$this->db->table('user_access_profiles')->whereIn('user_id', $ids)->delete();
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
log_message('error', 'UserModel access profile cleanup failed: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
// Existing methods remain unchanged
|
// Existing methods remain unchanged
|
||||||
|
|
||||||
@@ -55,12 +85,12 @@ class UserModel extends Model
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get unverified users created more than 2 minutes ago.
|
* Get unverified users created more than 15 minutes ago.
|
||||||
*/
|
*/
|
||||||
public function getUnverifiedUsers()
|
public function getUnverifiedUsers()
|
||||||
{
|
{
|
||||||
return $this->where('is_verified', 0)
|
return $this->where('is_verified', 0)
|
||||||
->where('created_at <', date('Y-m-d H:i:s', time() - 120))
|
->where('created_at <', date('Y-m-d H:i:s', time() - 15 * 60))
|
||||||
->findAll();
|
->findAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,9 +33,10 @@ class UserRoleModel extends Model
|
|||||||
$userId = (int) ($data['data']['user_id'] ?? 0);
|
$userId = (int) ($data['data']['user_id'] ?? 0);
|
||||||
if ($userId > 0) {
|
if ($userId > 0) {
|
||||||
service('staffDirectorySync')->syncUser($userId);
|
service('staffDirectorySync')->syncUser($userId);
|
||||||
|
model(UserAccessProfileModel::class)->syncUser($userId);
|
||||||
}
|
}
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
log_message('error', 'UserRoleModel staff directory sync failed: ' . $e->getMessage());
|
log_message('error', 'UserRoleModel role-derived sync failed: ' . $e->getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
return $data;
|
return $data;
|
||||||
@@ -45,8 +46,9 @@ class UserRoleModel extends Model
|
|||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
service('staffDirectorySync')->syncAll();
|
service('staffDirectorySync')->syncAll();
|
||||||
|
model(UserAccessProfileModel::class)->syncAll();
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
log_message('error', 'UserRoleModel staff directory delete sync failed: ' . $e->getMessage());
|
log_message('error', 'UserRoleModel role-derived delete sync failed: ' . $e->getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
return $data;
|
return $data;
|
||||||
|
|||||||
@@ -825,16 +825,12 @@ final class EnrollmentTransitionService
|
|||||||
private function firstEnrollmentPlacement(array $student, string $targetSchoolYear): array
|
private function firstEnrollmentPlacement(array $student, string $targetSchoolYear): array
|
||||||
{
|
{
|
||||||
$grade = $this->classBaseName((string) ($student['registration_grade'] ?? ''));
|
$grade = $this->classBaseName((string) ($student['registration_grade'] ?? ''));
|
||||||
$targetClass = $grade !== '' ? $this->classByName($grade, $targetSchoolYear) : null;
|
|
||||||
$targetSection = is_array($targetClass)
|
|
||||||
? $this->baseSectionForClass((int) ($targetClass['id'] ?? 0), $targetSchoolYear)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'assigned_grade_id' => $targetClass['id'] ?? null,
|
'assigned_grade_id' => null,
|
||||||
'assigned_grade_name' => $targetClass['class_name'] ?? ($grade !== '' ? $grade : null),
|
'assigned_grade_name' => $grade !== '' ? $grade : null,
|
||||||
'assigned_class_section_id' => $targetSection['class_section_id'] ?? null,
|
'assigned_class_section_id' => null,
|
||||||
'placement_status' => $targetSection === null ? 'manual_class_required' : 'same_class_assigned',
|
'placement_status' => 'manual_class_required',
|
||||||
'flags' => [],
|
'flags' => [],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,11 +57,13 @@ public function buildRoster(string $selectedYear, string $semester): array
|
|||||||
$students = $this->studentModel->getStudentsWithClassAndEnrollment($selectedYear);
|
$students = $this->studentModel->getStudentsWithClassAndEnrollment($selectedYear);
|
||||||
|
|
||||||
$removedPriorStatuses = $this->removedPriorYearStudentStatuses($selectedYear);
|
$removedPriorStatuses = $this->removedPriorYearStudentStatuses($selectedYear);
|
||||||
|
$makeUpExamStudentIds = array_fill_keys($this->makeUpExamStudentIds($selectedYear), true);
|
||||||
service('studentYearStatus')->attachToStudents($students, $selectedYear);
|
service('studentYearStatus')->attachToStudents($students, $selectedYear);
|
||||||
|
|
||||||
foreach ($students as &$s) {
|
foreach ($students as &$s) {
|
||||||
// ===== Ensure IDs needed by the modal =====
|
// ===== Ensure IDs needed by the modal =====
|
||||||
$s['student_id'] = (int)($s['id'] ?? 0);
|
$s['student_id'] = (int)($s['id'] ?? 0);
|
||||||
|
$s['make_up_exam'] = isset($makeUpExamStudentIds[$s['student_id']]) ? 'Yes' : 'No';
|
||||||
$priorRemovedStatus = $removedPriorStatuses[$s['student_id']] ?? null;
|
$priorRemovedStatus = $removedPriorStatuses[$s['student_id']] ?? null;
|
||||||
$s['removed_previous_year'] = $priorRemovedStatus !== null ? 'Yes' : 'No';
|
$s['removed_previous_year'] = $priorRemovedStatus !== null ? 'Yes' : 'No';
|
||||||
$s['prior_removed_status'] = $priorRemovedStatus;
|
$s['prior_removed_status'] = $priorRemovedStatus;
|
||||||
@@ -528,6 +530,54 @@ private function getPreviousSchoolYear(string $schoolYear): string
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return students whose latest deliberation decision for the source school year
|
||||||
|
* requires a make-up exam.
|
||||||
|
*/
|
||||||
|
private function makeUpExamStudentIds(string $selectedYear): array
|
||||||
|
{
|
||||||
|
$sourceYear = $this->getPreviousSchoolYear($selectedYear);
|
||||||
|
if ($sourceYear === '' || ! $this->db->tableExists('student_decisions')) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$select = ['student_id', 'decision'];
|
||||||
|
$hasStandardDecision = $this->db->fieldExists('deliberation_decision_standard', 'student_decisions');
|
||||||
|
if ($hasStandardDecision) {
|
||||||
|
$select[] = 'deliberation_decision_standard';
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = $this->db->table('student_decisions')
|
||||||
|
->select($select)
|
||||||
|
->where('school_year', $sourceYear)
|
||||||
|
->orderBy('updated_at', 'DESC')
|
||||||
|
->orderBy('id', 'DESC')
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
|
||||||
|
$latestDecisionSeen = [];
|
||||||
|
$studentIds = [];
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$studentId = (int) ($row['student_id'] ?? 0);
|
||||||
|
if ($studentId <= 0 || isset($latestDecisionSeen[$studentId])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$latestDecisionSeen[$studentId] = true;
|
||||||
|
$decision = $hasStandardDecision
|
||||||
|
? DeliberationDecision::normalize($row['deliberation_decision_standard'] ?? null)
|
||||||
|
: null;
|
||||||
|
$decision ??= DeliberationDecision::normalize($row['decision'] ?? null);
|
||||||
|
|
||||||
|
if ($decision === DeliberationDecision::MAKE_UP_EXAM) {
|
||||||
|
$studentIds[] = $studentId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $studentIds;
|
||||||
|
}
|
||||||
|
|
||||||
private function getSchoolYearStartYear(string $schoolYear): ?int
|
private function getSchoolYearStartYear(string $schoolYear): ?int
|
||||||
{
|
{
|
||||||
$schoolYear = trim($schoolYear);
|
$schoolYear = trim($schoolYear);
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Parents;
|
||||||
|
|
||||||
|
use App\Controllers\View\EmailController;
|
||||||
|
use App\Models\AuthorizedUserModel;
|
||||||
|
use App\Models\UserModel;
|
||||||
|
use App\Services\SchoolIdService;
|
||||||
|
use CodeIgniter\Database\BaseConnection;
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
class ParentAccountService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly BaseConnection $db,
|
||||||
|
private readonly UserModel $userModel,
|
||||||
|
private readonly AuthorizedUserModel $authorizedUsersModel,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canAccessUserRecord(int $requestedUserId, int $sessionUserId, array $sessionRoles): bool
|
||||||
|
{
|
||||||
|
if ($sessionUserId <= 0 || $requestedUserId <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($sessionUserId === $requestedUserId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$roles = array_map(
|
||||||
|
static fn ($role): string => strtolower(trim((string) $role)),
|
||||||
|
array_filter($sessionRoles)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (bool) array_intersect($roles, ['administrator', 'administrative staff', 'principal', 'admin']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isEmailUnique(string $email): bool
|
||||||
|
{
|
||||||
|
foreach (['users' => 'email', 'emergency_contacts' => 'email'] as $table => $column) {
|
||||||
|
if ($this->db->table($table)->where($column, $email)->countAllResults() > 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function administratorParentList(): array
|
||||||
|
{
|
||||||
|
return $this->userModel->where('role', 'parent')->findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function parentById(int $id): ?array
|
||||||
|
{
|
||||||
|
$parent = $this->userModel->find($id);
|
||||||
|
|
||||||
|
return is_array($parent) ? $parent : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createAdministratorParent(array $post): bool|int|string
|
||||||
|
{
|
||||||
|
return $this->userModel->insert([
|
||||||
|
'firstname' => $post['firstname'] ?? null,
|
||||||
|
'lastname' => $post['lastname'] ?? null,
|
||||||
|
'email' => strtolower((string) ($post['email'] ?? '')),
|
||||||
|
'password' => password_hash((string) ($post['password'] ?? ''), PASSWORD_DEFAULT),
|
||||||
|
'role' => 'parent',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateAdministratorParent(int $id, array $post): bool
|
||||||
|
{
|
||||||
|
$data = [
|
||||||
|
'firstname' => $post['firstname'] ?? null,
|
||||||
|
'lastname' => $post['lastname'] ?? null,
|
||||||
|
'email' => strtolower((string) ($post['email'] ?? '')),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (! empty($post['password'])) {
|
||||||
|
$data['password'] = password_hash((string) $post['password'], PASSWORD_DEFAULT);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (bool) $this->userModel->update($id, $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteParent(int $id): bool
|
||||||
|
{
|
||||||
|
return (bool) $this->userModel->delete($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createRelatedUser(array $userData, string $relationToStudent, string $semester, string $schoolYear): int|false
|
||||||
|
{
|
||||||
|
$schoolIdService = new SchoolIdService();
|
||||||
|
$token = bin2hex(random_bytes(48));
|
||||||
|
$tokenHash = hash('sha256', $token);
|
||||||
|
$userType = in_array(strtolower($relationToStudent), ['wife', 'husband'], true) ? 'Secondary' : 'Tertiary';
|
||||||
|
|
||||||
|
$validation = \Config\Services::validation();
|
||||||
|
$validation->setRules([
|
||||||
|
'firstname' => [
|
||||||
|
'label' => 'First Name',
|
||||||
|
'rules' => 'required|min_length[2]|max_length[100]|regex_match[/^[A-Za-z\s\-]+$/]',
|
||||||
|
'errors' => ['regex_match' => 'First name may only contain letters, spaces, and dashes.'],
|
||||||
|
],
|
||||||
|
'lastname' => [
|
||||||
|
'label' => 'Last Name',
|
||||||
|
'rules' => 'required|min_length[2]|max_length[100]|regex_match[/^[A-Za-z\s\-]+$/]',
|
||||||
|
'errors' => ['regex_match' => 'Last name may only contain letters, spaces, and dashes.'],
|
||||||
|
],
|
||||||
|
'email' => [
|
||||||
|
'label' => 'Email Address',
|
||||||
|
'rules' => 'required|valid_email|max_length[150]|is_unique[users.email]',
|
||||||
|
'errors' => ['is_unique' => 'This email is already registered.'],
|
||||||
|
],
|
||||||
|
'cellphone' => [
|
||||||
|
'label' => 'Cell Phone',
|
||||||
|
'rules' => 'required|regex_match[/^\d{10}$/]',
|
||||||
|
'errors' => ['regex_match' => 'Phone number must be exactly 10 digits.'],
|
||||||
|
],
|
||||||
|
'gender' => 'required|in_list[Male,Female]',
|
||||||
|
'city' => 'required|max_length[100]',
|
||||||
|
'state' => 'required|max_length[100]',
|
||||||
|
'zip' => 'required|regex_match[/^\d{5}$/]',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (! $validation->run($userData)) {
|
||||||
|
log_message('error', 'User creation failed due to invalid data: ' . json_encode($validation->getErrors()));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$userEntry = [
|
||||||
|
'firstname' => ucfirst(strtolower($userData['firstname'])),
|
||||||
|
'lastname' => ucfirst(strtolower($userData['lastname'])),
|
||||||
|
'gender' => $userData['gender'],
|
||||||
|
'cellphone' => $userData['cellphone'],
|
||||||
|
'email' => strtolower($userData['email']),
|
||||||
|
'address_street' => $userData['address_street'] ?? '',
|
||||||
|
'apt' => $userData['apt'] ?? null,
|
||||||
|
'city' => ucfirst(strtolower($userData['city'])),
|
||||||
|
'state' => strtoupper($userData['state']),
|
||||||
|
'zip' => $userData['zip'],
|
||||||
|
'accept_school_policy' => $userData['accept_school_policy'] ?? 0,
|
||||||
|
'token' => $tokenHash,
|
||||||
|
'is_verified' => 0,
|
||||||
|
'status' => 'Inactive',
|
||||||
|
'user_type' => $userType,
|
||||||
|
'semester' => $semester,
|
||||||
|
'school_year' => $schoolYear,
|
||||||
|
'school_id' => $schoolIdService->generateUserSchoolId(),
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (! $this->userModel->insert($userEntry)) {
|
||||||
|
log_message('error', 'Failed to insert user: ' . print_r($this->userModel->errors(), true));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$userId = (int) $this->userModel->getInsertID();
|
||||||
|
$this->sendActivationEmail((string) $userData['email'], $token);
|
||||||
|
log_message('info', "User with ID $userId created successfully and activation email sent.");
|
||||||
|
|
||||||
|
return $userId;
|
||||||
|
} catch (Exception $e) {
|
||||||
|
log_message('error', 'Exception during user creation: ' . $e->getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateAuthorizedUsers(int $userId, array $data): void
|
||||||
|
{
|
||||||
|
$validation = \Config\Services::validation();
|
||||||
|
$validation->setRules([
|
||||||
|
'email' => [
|
||||||
|
'label' => 'Email',
|
||||||
|
'rules' => 'required|valid_email|max_length[150]',
|
||||||
|
'errors' => [
|
||||||
|
'required' => 'Email is required.',
|
||||||
|
'valid_email' => 'Please provide a valid email address.',
|
||||||
|
'max_length' => 'Email must be less than 150 characters.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'name' => [
|
||||||
|
'label' => 'Name',
|
||||||
|
'rules' => 'required|min_length[3]|max_length[100]|regex_match[/^[A-Za-z\s\-]+$/]',
|
||||||
|
'errors' => [
|
||||||
|
'required' => 'Name is required.',
|
||||||
|
'regex_match' => 'Name can only contain letters, spaces, and dashes.',
|
||||||
|
'min_length' => 'Name must be at least 3 characters long.',
|
||||||
|
'max_length' => 'Name must be less than 100 characters.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (! $validation->run($data)) {
|
||||||
|
log_message('error', 'Invalid authorized user data: ' . json_encode($validation->getErrors()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$existingAuthorizedUser = $this->authorizedUsersModel
|
||||||
|
->where('user_id', $userId)
|
||||||
|
->where('email', $data['email'])
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($existingAuthorizedUser) {
|
||||||
|
$this->authorizedUsersModel->update($existingAuthorizedUser['id'], $data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data['user_id'] = $userId;
|
||||||
|
$data['status'] = 'Pending';
|
||||||
|
$this->authorizedUsersModel->insert($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sendActivationEmail(string $email, string $token): void
|
||||||
|
{
|
||||||
|
$emailController = new EmailController();
|
||||||
|
$subject = 'Activate Your Account';
|
||||||
|
$activationLink = site_url('/user/confirm/' . $token);
|
||||||
|
$message = "Please click the following link to confirm your email and set your password: $activationLink";
|
||||||
|
|
||||||
|
if ($emailController->sendEmail($email, $subject, $message)) {
|
||||||
|
log_message('info', 'Activation email sent successfully to ' . $email);
|
||||||
|
} else {
|
||||||
|
log_message('error', 'Failed to send activation email to ' . $email);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Parents;
|
||||||
|
|
||||||
|
use CodeIgniter\Database\BaseConnection;
|
||||||
|
|
||||||
|
class ParentAttendanceService
|
||||||
|
{
|
||||||
|
public function __construct(private readonly BaseConnection $db)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function attendanceForParent(int $parentId, string $schoolYear): array
|
||||||
|
{
|
||||||
|
if ($parentId <= 0 || $schoolYear === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->db->table('attendance_data')
|
||||||
|
->select('students.firstname, students.lastname, attendance_data.date, attendance_data.status, attendance_data.reason')
|
||||||
|
->join('students', 'students.id = attendance_data.student_id')
|
||||||
|
->where('attendance_data.school_year', $schoolYear)
|
||||||
|
->where('students.parent_id', $parentId)
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Parents;
|
||||||
|
|
||||||
|
use App\Controllers\View\InvoiceController;
|
||||||
|
use App\Models\EnrollmentModel;
|
||||||
|
use App\Models\EventChargesModel;
|
||||||
|
use App\Models\EventModel;
|
||||||
|
|
||||||
|
class ParentEventParticipationService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly EventChargesModel $chargesModel,
|
||||||
|
private readonly EventModel $eventModel,
|
||||||
|
private readonly EnrollmentModel $enrollmentModel,
|
||||||
|
private readonly InvoiceController $invoiceController,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pageData(int $parentId, string $schoolYear, string $semester): array
|
||||||
|
{
|
||||||
|
$activeEvents = $this->eventModel->getActiveEvents($schoolYear, $semester);
|
||||||
|
$chargesList = $this->chargesModel->getChargesWithEventInfo($parentId, $schoolYear, $semester);
|
||||||
|
|
||||||
|
$charges = [];
|
||||||
|
$externalParticipantsByEvent = [];
|
||||||
|
foreach ($chargesList as $charge) {
|
||||||
|
$studentId = $charge['student_id'] ?? null;
|
||||||
|
$eventId = (int) ($charge['event_id'] ?? 0);
|
||||||
|
|
||||||
|
if (! empty($studentId)) {
|
||||||
|
$charges[$studentId . ':' . $eventId] = [
|
||||||
|
'participation' => $charge['participation'],
|
||||||
|
'date' => $charge['updated_at'] ?? $charge['created_at'],
|
||||||
|
];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$externalName = trim((string) ($charge['external_firstname'] ?? '') . ' ' . (string) ($charge['external_lastname'] ?? ''));
|
||||||
|
if ($eventId > 0 && $externalName !== '') {
|
||||||
|
$externalParticipantsByEvent[$eventId][] = [
|
||||||
|
'name' => $externalName,
|
||||||
|
'note' => (string) ($charge['external_note'] ?? ''),
|
||||||
|
'participation' => (string) ($charge['participation'] ?? ''),
|
||||||
|
'event_paid' => ! empty($charge['event_paid']),
|
||||||
|
'charged' => (float) ($charge['charged'] ?? ($charge['event_amount'] ?? 0)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'activeEvents' => $activeEvents,
|
||||||
|
'charges' => $charges,
|
||||||
|
'externalParticipantsByEvent' => $externalParticipantsByEvent,
|
||||||
|
'yourStudents' => $this->enrollmentModel->getEnrolledStudents($parentId, $schoolYear),
|
||||||
|
'activeEventCount' => is_array($activeEvents) ? count($activeEvents) : 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateParticipation(array $participations, int $parentId, string $schoolYear, string $semester): void
|
||||||
|
{
|
||||||
|
foreach ($participations as $key => $value) {
|
||||||
|
[$studentId, $eventId] = explode(':', (string) $key);
|
||||||
|
|
||||||
|
$existing = $this->chargesModel->where([
|
||||||
|
'parent_id' => $parentId,
|
||||||
|
'student_id' => $studentId,
|
||||||
|
'event_id' => $eventId,
|
||||||
|
])->first();
|
||||||
|
|
||||||
|
if ($value === 'no') {
|
||||||
|
if ($existing) {
|
||||||
|
$this->chargesModel->delete($existing['id']);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
$this->chargesModel->update($existing['id'], ['participation' => $value]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$event = $this->eventModel->getEvent($eventId, $schoolYear);
|
||||||
|
$this->chargesModel->insert([
|
||||||
|
'parent_id' => $parentId,
|
||||||
|
'student_id' => $studentId,
|
||||||
|
'event_id' => $eventId,
|
||||||
|
'participation' => $value,
|
||||||
|
'charged' => $event['amount'],
|
||||||
|
'school_year' => $schoolYear,
|
||||||
|
'semester' => $semester,
|
||||||
|
'updated_by' => $parentId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->invoiceController->generateInvoice($parentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Parents;
|
||||||
|
|
||||||
|
use CodeIgniter\Database\BaseConnection;
|
||||||
|
|
||||||
|
class ParentPaymentService
|
||||||
|
{
|
||||||
|
public function __construct(private readonly BaseConnection $db)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function invoicesForParent(int $parentId, bool $includeRegisteredKids = false): array
|
||||||
|
{
|
||||||
|
if ($parentId <= 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$select = $includeRegisteredKids ? 'invoices.*, registeredKids' : '*';
|
||||||
|
|
||||||
|
return $this->db->table('invoices')
|
||||||
|
->select($select)
|
||||||
|
->where('parent_id', $parentId)
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markTuitionPaidForParent(int $parentId): void
|
||||||
|
{
|
||||||
|
if ($parentId <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->db->table('students')
|
||||||
|
->where('parent_id', $parentId)
|
||||||
|
->update(['tuition_paid' => 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Parents;
|
||||||
|
|
||||||
|
use App\Models\StudentModel;
|
||||||
|
use App\Models\UserModel;
|
||||||
|
use App\Services\EmailService;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class ParentRegistrationNotificationService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly UserModel $userModel,
|
||||||
|
private readonly StudentModel $studentModel,
|
||||||
|
private readonly EmailService $emailService,
|
||||||
|
private readonly string $adminEmail = 'registration@alrahmaisgl.org',
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<int> $studentIds
|
||||||
|
*/
|
||||||
|
public function sendAdminNewStudentEmails(array $studentIds, int $parentId): void
|
||||||
|
{
|
||||||
|
$parent = $this->userModel->find($parentId);
|
||||||
|
if (! is_array($parent)) {
|
||||||
|
log_message('warning', 'Unable to send admin student registration email: parent not found for ID ' . $parentId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (array_values(array_unique(array_filter(array_map('intval', $studentIds)))) as $studentId) {
|
||||||
|
$student = $this->studentModel->find($studentId);
|
||||||
|
if (! is_array($student)) {
|
||||||
|
log_message('warning', 'Unable to send admin student registration email: student not found for ID ' . $studentId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->sendAdminNewStudentEmail($student, $parent, $parentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $student
|
||||||
|
* @param array<string, mixed> $parent
|
||||||
|
*/
|
||||||
|
private function sendAdminNewStudentEmail(array $student, array $parent, int $parentId): void
|
||||||
|
{
|
||||||
|
$studentId = (int) ($student['id'] ?? 0);
|
||||||
|
$studentFullName = trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? ''))
|
||||||
|
?: 'Student ID ' . $studentId;
|
||||||
|
|
||||||
|
$payload = $student;
|
||||||
|
$payload['parents'] = [
|
||||||
|
'user_id' => $parentId,
|
||||||
|
'firstname' => (string) ($parent['firstname'] ?? ''),
|
||||||
|
'lastname' => (string) ($parent['lastname'] ?? ''),
|
||||||
|
'email' => (string) ($parent['email'] ?? ''),
|
||||||
|
];
|
||||||
|
|
||||||
|
$adminMessage = view('emails/admin_student_registered', ['student' => $payload], ['saveData' => true]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$sent = $this->emailService->send(
|
||||||
|
$this->adminEmail,
|
||||||
|
'New Student Registered: ' . $studentFullName,
|
||||||
|
$adminMessage,
|
||||||
|
'notifications'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $sent) {
|
||||||
|
log_message('error', 'Admin student registration email failed for student ID ' . $studentId);
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
log_message('error', 'Admin student registration email failed for student ID ' . $studentId . ': ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,724 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Parents;
|
||||||
|
|
||||||
|
use App\Models\EmergencyContactModel;
|
||||||
|
use App\Models\EnrollmentModel;
|
||||||
|
use App\Models\StudentAllergyModel;
|
||||||
|
use App\Models\StudentMedicalConditionModel;
|
||||||
|
use App\Models\StudentModel;
|
||||||
|
use App\Models\UserModel;
|
||||||
|
use App\Services\PhoneFormatterService;
|
||||||
|
use App\Services\SchoolIdService;
|
||||||
|
use CodeIgniter\Database\BaseConnection;
|
||||||
|
use CodeIgniter\Database\Exceptions\DatabaseException;
|
||||||
|
use DateTime;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use DateTimeZone;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class ParentRegistrationService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly BaseConnection $db,
|
||||||
|
private readonly UserModel $userModel,
|
||||||
|
private readonly StudentModel $studentModel,
|
||||||
|
private readonly EnrollmentModel $enrollmentModel,
|
||||||
|
private readonly EmergencyContactModel $emergencyContactModel,
|
||||||
|
private readonly StudentMedicalConditionModel $medicalConditionModel,
|
||||||
|
private readonly StudentAllergyModel $allergyModel,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function registrationData(
|
||||||
|
int $parentId,
|
||||||
|
string $selectedSchoolYear,
|
||||||
|
bool $isEditable,
|
||||||
|
int $maxChilds,
|
||||||
|
int $maxEmergency
|
||||||
|
): array {
|
||||||
|
$enrollments = $this->getEnrollmentsByParent($parentId, $selectedSchoolYear);
|
||||||
|
$enrollmentMap = [];
|
||||||
|
foreach ($enrollments as $enroll) {
|
||||||
|
$enrollmentMap[$enroll['student_id']] = $enroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $this->userModel->find($parentId);
|
||||||
|
if (! $user || ($user['user_type'] ?? '') !== 'primary') {
|
||||||
|
throw new \RuntimeException('Only primary parents are allowed to register children.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$kids = $this->studentModel->where('parent_id', $parentId)->findAll();
|
||||||
|
foreach ($kids as &$kid) {
|
||||||
|
$studentId = (int) ($kid['id'] ?? 0);
|
||||||
|
$kid['allergies'] = $this->allergyModel->where('student_id', $studentId)->findColumn('allergy') ?? [];
|
||||||
|
$kid['medical_conditions'] = $this->medicalConditionModel->where('student_id', $studentId)->findColumn('condition_name') ?? [];
|
||||||
|
$kid['enrollment'] = isset($enrollmentMap[$studentId]['id']) && ! empty($enrollmentMap[$studentId]['id']) ? 1 : 0;
|
||||||
|
}
|
||||||
|
unset($kid);
|
||||||
|
|
||||||
|
$this->ensureStudentYearStatusRows($kids, $selectedSchoolYear);
|
||||||
|
service('studentYearStatus')->attachToStudents($kids, $selectedSchoolYear);
|
||||||
|
|
||||||
|
foreach ($kids as &$kid) {
|
||||||
|
$kid['can_delete'] = $this->canParentDeleteStudent($kid, $parentId);
|
||||||
|
}
|
||||||
|
unset($kid);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'existingKids' => $kids,
|
||||||
|
'emergencies' => $this->emergencyContactModel->where('parent_id', $parentId)->findAll(),
|
||||||
|
'parent' => $user,
|
||||||
|
'maxChilds' => $maxChilds,
|
||||||
|
'maxEmergency' => $maxEmergency,
|
||||||
|
'enrollments' => $enrollments,
|
||||||
|
'selectedYear' => $selectedSchoolYear,
|
||||||
|
'isEditable' => $isEditable,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function validateRegistrationSubmission(array $post, array $registrationData): array
|
||||||
|
{
|
||||||
|
$existingKids = $registrationData['existingKids'] ?? [];
|
||||||
|
$existingECs = $registrationData['emergencies'] ?? [];
|
||||||
|
$maxChilds = (int) ($registrationData['maxChilds'] ?? 0);
|
||||||
|
$maxEmergency = (int) ($registrationData['maxEmergency'] ?? 0);
|
||||||
|
|
||||||
|
$incomingFirstNames = (array) ($post['studentFirstName'] ?? []);
|
||||||
|
$incomingLastNames = (array) ($post['studentLastName'] ?? []);
|
||||||
|
$incomingDOBs = (array) ($post['dob'] ?? []);
|
||||||
|
$newStudentCount = count(array_filter($incomingFirstNames));
|
||||||
|
|
||||||
|
foreach ($incomingFirstNames as $i => $firstName) {
|
||||||
|
$lastName = trim($incomingLastNames[$i] ?? '');
|
||||||
|
$dob = trim($incomingDOBs[$i] ?? '');
|
||||||
|
if (empty($firstName) || empty($lastName) || empty($dob)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($existingKids as $kid) {
|
||||||
|
if (
|
||||||
|
strtolower($kid['firstname']) === strtolower($firstName)
|
||||||
|
&& strtolower($kid['lastname']) === strtolower($lastName)
|
||||||
|
&& $kid['dob'] === $dob
|
||||||
|
) {
|
||||||
|
return ['ok' => false, 'error' => "Duplicate student detected: {$firstName} {$lastName} with DOB {$dob} already exists."];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$seenStudents = [];
|
||||||
|
foreach ($incomingFirstNames as $i => $firstName) {
|
||||||
|
$lastName = trim($incomingLastNames[$i] ?? '');
|
||||||
|
$dob = trim($incomingDOBs[$i] ?? '');
|
||||||
|
if (empty($firstName) || empty($lastName) || empty($dob)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$key = strtolower($firstName . '|' . $lastName . '|' . $dob);
|
||||||
|
if (isset($seenStudents[$key])) {
|
||||||
|
return ['ok' => false, 'error' => "Duplicate student entry in the form: {$firstName} {$lastName} with DOB {$dob}."];
|
||||||
|
}
|
||||||
|
$seenStudents[$key] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$incomingECFirst = (array) ($post['emergency_firstname'] ?? []);
|
||||||
|
$incomingECLast = (array) ($post['emergency_lastname'] ?? []);
|
||||||
|
$incomingECPhones = (array) ($post['emergency_phone'] ?? []);
|
||||||
|
$incomingECEmails = (array) ($post['emergency_email'] ?? []);
|
||||||
|
$newECCount = count(array_filter($incomingECFirst));
|
||||||
|
|
||||||
|
foreach ($incomingECFirst as $i => $first) {
|
||||||
|
$last = trim($incomingECLast[$i] ?? '');
|
||||||
|
$phone = preg_replace('/\D/', '', $incomingECPhones[$i] ?? '');
|
||||||
|
$email = strtolower(trim($incomingECEmails[$i] ?? ''));
|
||||||
|
if (empty($first) || empty($last)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($existingECs as $contact) {
|
||||||
|
$existingPhone = preg_replace('/\D/', '', $contact['cellphone']);
|
||||||
|
$existingEmail = strtolower($contact['email']);
|
||||||
|
if (
|
||||||
|
strtolower($contact['emergency_contact_name']) === strtolower(trim($first . ' ' . $last))
|
||||||
|
|| ($phone && $phone === $existingPhone)
|
||||||
|
|| ($email && $email === $existingEmail)
|
||||||
|
) {
|
||||||
|
return ['ok' => false, 'error' => "Duplicate emergency contact: {$first} {$last} already exists."];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$seenContacts = [];
|
||||||
|
foreach ($incomingECFirst as $i => $first) {
|
||||||
|
$last = trim($incomingECLast[$i] ?? '');
|
||||||
|
$phone = preg_replace('/\D/', '', $incomingECPhones[$i] ?? '');
|
||||||
|
$email = strtolower(trim($incomingECEmails[$i] ?? ''));
|
||||||
|
if (empty($first) || empty($last)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$key = strtolower($first . '|' . $last . '|' . $phone . '|' . $email);
|
||||||
|
if (isset($seenContacts[$key])) {
|
||||||
|
return ['ok' => false, 'error' => "Duplicate emergency contact entry in the form: {$first} {$last}."];
|
||||||
|
}
|
||||||
|
$seenContacts[$key] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$existingKidsCount = count($existingKids);
|
||||||
|
$existingECCount = count($existingECs);
|
||||||
|
if (($existingKidsCount + $newStudentCount) > $maxChilds) {
|
||||||
|
return ['ok' => false, 'error' => "Student limit exceeded. You have $existingKidsCount and tried to add $newStudentCount (limit: $maxChilds)."];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($existingECCount + $newECCount) > $maxEmergency) {
|
||||||
|
return ['ok' => false, 'error' => "Emergency contact limit exceeded. You have $existingECCount and tried to add $newECCount (limit: $maxEmergency)."];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['ok' => true];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveStudentAtIndex(
|
||||||
|
int $idx,
|
||||||
|
array $post,
|
||||||
|
int $parentId,
|
||||||
|
string $schoolYear,
|
||||||
|
SchoolIdService $schoolIdService,
|
||||||
|
?bool $isNew = null,
|
||||||
|
?int $studentId = null,
|
||||||
|
?string $schoolStartDate = null,
|
||||||
|
?string $ageDateReference = null
|
||||||
|
): array {
|
||||||
|
$firstName = $post['studentFirstName'][$idx] ?? null;
|
||||||
|
$lastName = $post['studentLastName'][$idx] ?? null;
|
||||||
|
$dob = $post['dob'][$idx] ?? null;
|
||||||
|
$gender = $post['gender'][$idx] ?? null;
|
||||||
|
$grade = $post['registration_grade'][$idx] ?? null;
|
||||||
|
$conditions = $post['medical_conditions'][$idx] ?? [];
|
||||||
|
$allergies = $post['allergies'][$idx] ?? [];
|
||||||
|
$photoRaw = $post['photo_consent'][$idx] ?? '';
|
||||||
|
|
||||||
|
if (! $firstName || ! $lastName || ! $dob || ! $gender || ! $grade) {
|
||||||
|
return ['ok' => false, 'empty' => true];
|
||||||
|
}
|
||||||
|
|
||||||
|
$firstName = $this->normalizeStudentName((string) $firstName);
|
||||||
|
$lastName = $this->normalizeStudentName((string) $lastName);
|
||||||
|
$this->validateNames($firstName);
|
||||||
|
$this->validateNames($lastName);
|
||||||
|
|
||||||
|
$dobObj = new DateTime((string) $dob);
|
||||||
|
$schoolYearAgeDeadline = $this->schoolYearAgeDeadline($schoolYear, $schoolStartDate);
|
||||||
|
$age = $this->calculateAgeAsOfSchoolYearStartYear((string) $dob, $schoolYear);
|
||||||
|
$validation = $this->validateDobAge(
|
||||||
|
(string) $dob,
|
||||||
|
$this->registrationMinimumAgeDeadline($schoolYear, $ageDateReference),
|
||||||
|
5,
|
||||||
|
18,
|
||||||
|
$schoolYearAgeDeadline
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $validation['isValid']) {
|
||||||
|
$displayDeadline = (new DateTime($schoolYearAgeDeadline))->format('m-d-Y');
|
||||||
|
return [
|
||||||
|
'ok' => false,
|
||||||
|
'error' => "Student '{$firstName} {$lastName}' {$validation['message']}. General age is calculated as of {$displayDeadline}.",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$studentData = [
|
||||||
|
'firstname' => $firstName,
|
||||||
|
'lastname' => $lastName,
|
||||||
|
'age' => $age,
|
||||||
|
'dob' => $dobObj->format('Y-m-d'),
|
||||||
|
'gender' => $gender,
|
||||||
|
'registration_grade' => $grade,
|
||||||
|
'photo_consent' => strtolower((string) $photoRaw) === 'yes' ? 1 : 0,
|
||||||
|
'parent_id' => $parentId,
|
||||||
|
'year_of_registration' => date('Y'),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($this->db->fieldExists('school_year', 'students')) {
|
||||||
|
$studentData['school_year'] = $schoolYear;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($isNew !== null) {
|
||||||
|
$studentData['is_new'] = $isNew ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$existingBuilder = $this->studentModel
|
||||||
|
->where('parent_id', $parentId)
|
||||||
|
->where('dob', $dobObj->format('Y-m-d'))
|
||||||
|
->where('firstname', $firstName)
|
||||||
|
->where('lastname', $lastName);
|
||||||
|
|
||||||
|
if ($this->db->fieldExists('school_year', 'students')) {
|
||||||
|
$existingBuilder->where('school_year', $schoolYear);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $studentId && $existingBuilder->first()) {
|
||||||
|
return ['ok' => false, 'error' => "Student '{$firstName} {$lastName}' with the same birthdate is already registered for $schoolYear."];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($studentId) {
|
||||||
|
$existing = $this->studentModel->find($studentId);
|
||||||
|
if (! is_array($existing) || (int) ($existing['parent_id'] ?? 0) !== $parentId) {
|
||||||
|
return ['ok' => false, 'error' => 'Student record was not found for this parent account.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->auditParentStudentFieldChanges($existing, $studentData, $parentId, 'parent_student_edit');
|
||||||
|
$this->studentModel->update($studentId, $studentData);
|
||||||
|
|
||||||
|
if ($this->parentEditAffectsEligibility($existing, $studentData)) {
|
||||||
|
$this->recheckEligibilityAfterParentEdit($studentId, $parentId, $schoolYear);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$studentData['registration_date'] = utc_now();
|
||||||
|
$studentData['tuition_paid'] = 0;
|
||||||
|
$studentData['school_id'] = $schoolIdService->generateStudentSchoolId();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$studentId = (int) $this->studentModel->insert($studentData, true);
|
||||||
|
} catch (DatabaseException $e) {
|
||||||
|
if (strpos($e->getMessage(), '1062') !== false) {
|
||||||
|
return ['ok' => false, 'error' => "Student '{$firstName} {$lastName}' with the same birthdate is already registered for $schoolYear."];
|
||||||
|
}
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($isNew !== null && $studentId > 0) {
|
||||||
|
$studentYearStatus = service('studentYearStatus');
|
||||||
|
$statusSaved = $studentYearStatus->upsert($studentId, $schoolYear, $isNew);
|
||||||
|
if (! $statusSaved || ! $studentYearStatus->hasStatus($studentId, $schoolYear)) {
|
||||||
|
throw new \RuntimeException('Student year status could not be saved for student ID ' . $studentId . ' and school year ' . $schoolYear . '.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->medicalConditionModel->where('student_id', $studentId)->delete();
|
||||||
|
foreach ((array) $conditions as $condition) {
|
||||||
|
$condition = trim((string) $condition);
|
||||||
|
if ($condition !== '') {
|
||||||
|
$this->medicalConditionModel->insert(['student_id' => $studentId, 'condition_name' => $condition]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->allergyModel->where('student_id', $studentId)->delete();
|
||||||
|
foreach ((array) $allergies as $allergy) {
|
||||||
|
$allergy = trim((string) $allergy);
|
||||||
|
if ($allergy !== '') {
|
||||||
|
$this->allergyModel->insert(['student_id' => $studentId, 'allergy' => $allergy]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['ok' => true, 'student_id' => $studentId];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveEmergencyContact(int $parentId, array $post, ?array $single = null, ?int $id = null): array
|
||||||
|
{
|
||||||
|
$phoneFormatter = new PhoneFormatterService();
|
||||||
|
|
||||||
|
if ($single !== null) {
|
||||||
|
$firstName = $this->formatName($single['first_name'] ?? '');
|
||||||
|
$lastName = $this->formatName($single['last_name'] ?? '');
|
||||||
|
$relation = trim($single['relation'] ?? '');
|
||||||
|
$phone = $phoneFormatter->formatPhoneNumber($single['cellphone'] ?? '');
|
||||||
|
$email = strtolower(trim($single['email'] ?? ''));
|
||||||
|
|
||||||
|
if ($firstName === '' && $lastName === '' && $phone === '(000)-000-0000' && $email === '' && $relation === '') {
|
||||||
|
return ['ok' => true, 'empty' => true];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->validateNames($firstName);
|
||||||
|
$this->validateNames($lastName);
|
||||||
|
|
||||||
|
if ($email && ! filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
throw new \Exception('Invalid email format for emergency contact.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'parent_id' => $parentId,
|
||||||
|
'emergency_contact_name' => $firstName . ' ' . $lastName,
|
||||||
|
'cellphone' => $phone,
|
||||||
|
'email' => $email,
|
||||||
|
'relation' => $relation,
|
||||||
|
'updated_at' => utc_now(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$duplicateBuilder = $this->emergencyContactModel
|
||||||
|
->where('parent_id', $parentId)
|
||||||
|
->where('emergency_contact_name', $data['emergency_contact_name'])
|
||||||
|
->where('cellphone', $phone)
|
||||||
|
->where('email', $email)
|
||||||
|
->where('relation', $relation);
|
||||||
|
|
||||||
|
if ($id !== null) {
|
||||||
|
$duplicateBuilder->where('id !=', $id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($duplicateBuilder->first()) {
|
||||||
|
return ['ok' => false, 'error' => $id !== null
|
||||||
|
? 'Another emergency contact with the same information already exists.'
|
||||||
|
: 'This emergency contact is already registered.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($id !== null) {
|
||||||
|
$this->emergencyContactModel->update($id, $data);
|
||||||
|
} else {
|
||||||
|
$this->emergencyContactModel->insert($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['ok' => true];
|
||||||
|
}
|
||||||
|
|
||||||
|
$firstNames = (array) ($post['emergency_firstname'] ?? []);
|
||||||
|
$lastNames = (array) ($post['emergency_lastname'] ?? []);
|
||||||
|
$relations = (array) ($post['emergency_relation'] ?? []);
|
||||||
|
$phones = (array) ($post['emergency_phone'] ?? []);
|
||||||
|
$emails = (array) ($post['emergency_email'] ?? []);
|
||||||
|
|
||||||
|
foreach ($firstNames as $idx => $first) {
|
||||||
|
$firstName = $this->formatName($first ?? '');
|
||||||
|
$lastName = $this->formatName($lastNames[$idx] ?? '');
|
||||||
|
$relation = trim($relations[$idx] ?? '');
|
||||||
|
$phone = $phoneFormatter->formatPhoneNumber($phones[$idx] ?? '');
|
||||||
|
$email = strtolower(trim($emails[$idx] ?? ''));
|
||||||
|
|
||||||
|
if ($firstName === '' && $lastName === '' && $phone === '(000)-000-0000' && $email === '' && $relation === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($phone === '(000)-000-0000') {
|
||||||
|
throw new \Exception('Invalid phone number.');
|
||||||
|
}
|
||||||
|
if ($email && ! filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
throw new \Exception('Invalid email format for emergency contact.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$fullName = $firstName . ' ' . $lastName;
|
||||||
|
$exists = $this->emergencyContactModel->where([
|
||||||
|
'parent_id' => $parentId,
|
||||||
|
'emergency_contact_name' => $fullName,
|
||||||
|
'cellphone' => $phone,
|
||||||
|
'email' => $email,
|
||||||
|
'relation' => $relation,
|
||||||
|
])->first();
|
||||||
|
|
||||||
|
if (! $exists) {
|
||||||
|
$this->emergencyContactModel->insert([
|
||||||
|
'parent_id' => $parentId,
|
||||||
|
'emergency_contact_name' => $fullName,
|
||||||
|
'cellphone' => $phone,
|
||||||
|
'email' => $email,
|
||||||
|
'relation' => $relation,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['ok' => true];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canParentDeleteStudent(array $student, int $parentId): bool
|
||||||
|
{
|
||||||
|
$studentId = (int) ($student['id'] ?? 0);
|
||||||
|
if ($studentId <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$statusYear = trim((string) ($student['school_year'] ?? ''));
|
||||||
|
if ($statusYear === '') {
|
||||||
|
$statusYear = (string) (service('studentYearStatus')->activeSchoolYear() ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
$isNew = $statusYear !== ''
|
||||||
|
? service('studentYearStatus')->isNew($studentId, $statusYear)
|
||||||
|
: ((string) ($student['is_new'] ?? '1') === '1');
|
||||||
|
|
||||||
|
return $isNew
|
||||||
|
&& ! $this->studentHasEnrollmentHistory($studentId, $parentId)
|
||||||
|
&& ! $this->studentHasClassAssignmentHistory($studentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function validateDobAge(
|
||||||
|
string $dob,
|
||||||
|
string $registrationAgeDeadline,
|
||||||
|
int $minAge = 5,
|
||||||
|
int $maxAge = 18,
|
||||||
|
?string $schoolYearAgeDeadline = null
|
||||||
|
): array {
|
||||||
|
$response = ['isValid' => false, 'message' => '', 'age' => null];
|
||||||
|
$tz = new DateTimeZone('UTC');
|
||||||
|
$dob = trim($dob);
|
||||||
|
if ($dob === '') {
|
||||||
|
$response['message'] = 'Date of birth is required';
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
$birthDate = DateTimeImmutable::createFromFormat('!Y-m-d', $dob, $tz);
|
||||||
|
$errs = DateTimeImmutable::getLastErrors();
|
||||||
|
if ($birthDate === false || (is_array($errs) && (($errs['warning_count'] ?? 0) > 0 || ($errs['error_count'] ?? 0) > 0))) {
|
||||||
|
$response['message'] = 'Invalid date format (Use YYYY-MM-DD)';
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$minimumAgeDeadline = new DateTimeImmutable($registrationAgeDeadline, $tz);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$minimumAgeDeadline = new DateTimeImmutable('now', $tz);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$ageDeadline = new DateTimeImmutable($schoolYearAgeDeadline ?: $registrationAgeDeadline, $tz);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$ageDeadline = $minimumAgeDeadline;
|
||||||
|
}
|
||||||
|
|
||||||
|
$minimumAgeDeadline = $minimumAgeDeadline->setTime(23, 59, 59);
|
||||||
|
$ageDeadline = $ageDeadline->setTime(23, 59, 59);
|
||||||
|
$ageAtDeadline = $birthDate->diff($ageDeadline)->y;
|
||||||
|
$ageAtMinimumAgeDeadline = $birthDate->diff($minimumAgeDeadline)->y;
|
||||||
|
$response['age'] = $ageAtDeadline;
|
||||||
|
|
||||||
|
$minBirthDate = $ageDeadline->modify('-' . ($maxAge + 1) . ' years')->modify('+1 day')->setTime(0, 0, 0);
|
||||||
|
$maxBirthDate = $minimumAgeDeadline->modify("-{$minAge} years")->setTime(23, 59, 59);
|
||||||
|
$response['isValid'] = ($birthDate >= $minBirthDate) && ($birthDate <= $maxBirthDate);
|
||||||
|
|
||||||
|
if (! $response['isValid']) {
|
||||||
|
$response['message'] = sprintf(
|
||||||
|
'Must be at least %d years old by %s and no older than %d by %s. Current registration age would be: %d',
|
||||||
|
$minAge,
|
||||||
|
$minimumAgeDeadline->format('m-d-Y'),
|
||||||
|
$maxAge,
|
||||||
|
$ageDeadline->format('m-d-Y'),
|
||||||
|
$ageAtMinimumAgeDeadline
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function schoolYearAgeDeadline(string $schoolYear, ?string $schoolStartDate = null): string
|
||||||
|
{
|
||||||
|
if (preg_match('/^(\d{4})/', trim($schoolYear), $matches)) {
|
||||||
|
return $matches[1] . '-09-01';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($schoolStartDate) && strtotime($schoolStartDate)) {
|
||||||
|
return (new DateTimeImmutable($schoolStartDate))->format('Y-m-d');
|
||||||
|
}
|
||||||
|
|
||||||
|
return date('Y') . '-09-01';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function registrationMinimumAgeDeadline(string $schoolYear, ?string $ageDateReference = null): string
|
||||||
|
{
|
||||||
|
$configured = trim((string) $ageDateReference);
|
||||||
|
if ($configured !== '' && strtotime($configured)) {
|
||||||
|
return (new DateTimeImmutable($configured))->format('Y-m-d');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/^(\d{4})-(\d{4})$/', trim($schoolYear), $matches)) {
|
||||||
|
return $matches[2] . '-12-31';
|
||||||
|
}
|
||||||
|
|
||||||
|
return date('Y') . '-12-31';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function formatName(string $name): string
|
||||||
|
{
|
||||||
|
$name = trim($name);
|
||||||
|
$name = strtolower($name);
|
||||||
|
$name = ucwords($name, ' ');
|
||||||
|
|
||||||
|
return implode('-', array_map('ucfirst', explode('-', $name)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function validateNames(string $name): void
|
||||||
|
{
|
||||||
|
if (! preg_match('/^[A-Za-z\s\-]{2,30}$/', $name)) {
|
||||||
|
throw new InvalidArgumentException('Invalid name format: Only letters, spaces, or dashes (2-30 chars) allowed.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getEnrollmentsByParent(int $parentId, string $schoolYear): array
|
||||||
|
{
|
||||||
|
return $this->enrollmentModel
|
||||||
|
->where('parent_id', $parentId)
|
||||||
|
->where('school_year', $schoolYear)
|
||||||
|
->orderBy('enrollment_date', 'DESC')
|
||||||
|
->findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ensureStudentYearStatusRows(array $students, string $schoolYear): void
|
||||||
|
{
|
||||||
|
$schoolYear = trim($schoolYear);
|
||||||
|
if ($students === [] || ! preg_match('/^\d{4}-\d{4}$/', $schoolYear)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$studentYearStatus = service('studentYearStatus');
|
||||||
|
foreach ($students as $student) {
|
||||||
|
$studentId = (int) ($student['id'] ?? $student['student_id'] ?? 0);
|
||||||
|
if ($studentId <= 0 || $studentYearStatus->hasStatus($studentId, $schoolYear)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$isNew = (int) ($student['is_new'] ?? 1) === 1;
|
||||||
|
if (! $studentYearStatus->upsert($studentId, $schoolYear, $isNew)) {
|
||||||
|
log_message('error', 'Unable to repair student_year_status for student_id={studentId}, school_year={schoolYear}', [
|
||||||
|
'studentId' => $studentId,
|
||||||
|
'schoolYear' => $schoolYear,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function calculateAgeAsOfSchoolYearStartYear(?string $dob, string $schoolYear): ?int
|
||||||
|
{
|
||||||
|
$dob = trim((string) $dob);
|
||||||
|
$schoolYear = trim($schoolYear);
|
||||||
|
|
||||||
|
if ($dob === '' || ! preg_match('/^(\d{4})/', $schoolYear, $matches)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$timezone = new DateTimeZone((string) (config('School')->attendance['timezone'] ?? user_timezone()));
|
||||||
|
$birthDate = DateTimeImmutable::createFromFormat('!Y-m-d', $dob, $timezone);
|
||||||
|
$errors = DateTimeImmutable::getLastErrors();
|
||||||
|
$hasParseErrors = is_array($errors)
|
||||||
|
&& (($errors['warning_count'] ?? 0) > 0 || ($errors['error_count'] ?? 0) > 0);
|
||||||
|
|
||||||
|
if ($birthDate === false || $hasParseErrors) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$schoolYearStartYearCutoff = new DateTimeImmutable($matches[1] . '-09-01', $timezone);
|
||||||
|
if ($birthDate > $schoolYearStartYearCutoff) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $birthDate->diff($schoolYearStartYearCutoff)->y;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
log_message('warning', 'Unable to calculate school-year age from DOB: {message}', [
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeStudentName(string $name): string
|
||||||
|
{
|
||||||
|
$name = trim(preg_replace('/\s+/', ' ', $name) ?? '');
|
||||||
|
|
||||||
|
return mb_convert_case($name, MB_CASE_TITLE, 'UTF-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function auditParentStudentFieldChanges(array $original, array $updated, int $parentId, string $source): void
|
||||||
|
{
|
||||||
|
if (! $this->db->tableExists('enrollment_transition_audits')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$studentId = (int) ($original['id'] ?? 0);
|
||||||
|
if ($studentId <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$changes = [];
|
||||||
|
foreach (['firstname', 'lastname', 'dob'] as $field) {
|
||||||
|
$oldValue = trim((string) ($original[$field] ?? ''));
|
||||||
|
$newValue = trim((string) ($updated[$field] ?? ''));
|
||||||
|
if ($oldValue !== $newValue) {
|
||||||
|
$changes[$field] = [
|
||||||
|
'old_value' => $oldValue,
|
||||||
|
'new_value' => $newValue,
|
||||||
|
'changed' => true,
|
||||||
|
'changed_by' => $parentId,
|
||||||
|
'changed_at' => date('Y-m-d H:i:s'),
|
||||||
|
'source' => $source,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($changes === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->db->table('enrollment_transition_audits')->insert([
|
||||||
|
'student_id' => $studentId,
|
||||||
|
'school_year' => (string) ($updated['school_year'] ?? $original['school_year'] ?? date('Y')),
|
||||||
|
'source_school_year' => null,
|
||||||
|
'action' => 'parent_student_field_edit',
|
||||||
|
'performed_by' => $parentId,
|
||||||
|
'original_values_json' => json_encode($original, JSON_UNESCAPED_SLASHES),
|
||||||
|
'new_values_json' => json_encode(['changes' => $changes, 'updated' => $updated], JSON_UNESCAPED_SLASHES),
|
||||||
|
'reason' => $source,
|
||||||
|
'created_at' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parentEditAffectsEligibility(array $original, array $updated): bool
|
||||||
|
{
|
||||||
|
return trim((string) ($original['dob'] ?? '')) !== trim((string) ($updated['dob'] ?? ''))
|
||||||
|
|| trim((string) ($original['lastname'] ?? '')) !== trim((string) ($updated['lastname'] ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function recheckEligibilityAfterParentEdit(int $studentId, int $parentId, string $targetSchoolYear): void
|
||||||
|
{
|
||||||
|
$previousSchoolYear = $this->previousSchoolYearName($targetSchoolYear);
|
||||||
|
if ($previousSchoolYear === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$evaluation = service('enrollmentTransition')->evaluateForParent(
|
||||||
|
$parentId,
|
||||||
|
$studentId,
|
||||||
|
$previousSchoolYear,
|
||||||
|
$targetSchoolYear,
|
||||||
|
'parent'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (($evaluation['can_enroll'] ?? false) === true || ($evaluation['parent_enrollment_allowed'] ?? false) === true) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$message = (string) ($evaluation['primary_parent_message'] ?? 'Updated student information affects enrollment eligibility.');
|
||||||
|
service('enrollmentTransition')->logEnrollmentBlock($evaluation, 'parent_student_edit', $parentId, $parentId);
|
||||||
|
session()->setFlashdata('warning', $message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function previousSchoolYearName(string $schoolYear): ?string
|
||||||
|
{
|
||||||
|
if (! preg_match('/^(\d{4})-(\d{4})$/', trim($schoolYear), $matches)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ((int) $matches[1] - 1) . '-' . ((int) $matches[2] - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function studentHasEnrollmentHistory(int $studentId, int $parentId): bool
|
||||||
|
{
|
||||||
|
if (! $this->db->tableExists('enrollments')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->db->table('enrollments')
|
||||||
|
->where('student_id', $studentId)
|
||||||
|
->where('parent_id', $parentId)
|
||||||
|
->countAllResults() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function studentHasClassAssignmentHistory(int $studentId): bool
|
||||||
|
{
|
||||||
|
if (! $this->db->tableExists('student_class')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->db->table('student_class')
|
||||||
|
->where('student_id', $studentId)
|
||||||
|
->countAllResults() > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,14 +57,16 @@ final class SchoolYearClosingService
|
|||||||
$findings[] = $this->finding(
|
$findings[] = $this->finding(
|
||||||
'blocking',
|
'blocking',
|
||||||
'Students missing promotion decisions',
|
'Students missing promotion decisions',
|
||||||
$promotion['summary']['missing_decision'] . ' active student(s) do not have a saved promotion decision for this school year.'
|
$promotion['summary']['missing_decision'] . ' active student(s) do not have a saved promotion decision for this school year.',
|
||||||
|
['students' => $this->promotionStudentsWithStatus($promotion['rows'] ?? [], 'missing')]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (($promotion['summary']['pending_decision'] ?? 0) > 0) {
|
if (($promotion['summary']['pending_decision'] ?? 0) > 0) {
|
||||||
$findings[] = $this->finding(
|
$findings[] = $this->finding(
|
||||||
'blocking',
|
'blocking',
|
||||||
'Students with pending promotion decisions',
|
'Students with pending promotion decisions',
|
||||||
$promotion['summary']['pending_decision'] . ' active student(s) still have pending promotion decisions.'
|
$promotion['summary']['pending_decision'] . ' active student(s) still have pending promotion decisions.',
|
||||||
|
['students' => $this->promotionStudentsWithStatus($promotion['rows'] ?? [], 'pending')]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (($promotion['summary']['missing_queue'] ?? 0) > 0) {
|
if (($promotion['summary']['missing_queue'] ?? 0) > 0) {
|
||||||
@@ -337,6 +339,128 @@ final class SchoolYearClosingService
|
|||||||
->first();
|
->first();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add carry-forward items that were omitted from an already executed batch.
|
||||||
|
* Existing items and invoices are never rewritten, which keeps this repair
|
||||||
|
* idempotent and preserves the original financial audit trail.
|
||||||
|
*/
|
||||||
|
public function repairMissingCarryForward(int $sourceYearId, ?int $userId = null): array
|
||||||
|
{
|
||||||
|
$this->assertClosingTablesExist();
|
||||||
|
|
||||||
|
$batch = $this->latestBatch($sourceYearId);
|
||||||
|
if ($batch === null || ! in_array((string) ($batch['status'] ?? ''), ['executed', 'completed'], true)) {
|
||||||
|
throw new InvalidArgumentException('An executed or completed closing batch is required for carry-forward repair.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$targetYearId = (int) ($batch['target_school_year_id'] ?? 0);
|
||||||
|
$source = $this->requireYear($sourceYearId);
|
||||||
|
$target = $this->requireYear($targetYearId);
|
||||||
|
$preview = $this->preview($sourceYearId, $targetYearId);
|
||||||
|
$batchId = (int) $batch['id'];
|
||||||
|
|
||||||
|
$this->db->transBegin();
|
||||||
|
try {
|
||||||
|
$lockedBatch = $this->db->query(
|
||||||
|
'SELECT id FROM school_year_closing_batches WHERE id = ? FOR UPDATE',
|
||||||
|
[$batchId]
|
||||||
|
)->getRowArray();
|
||||||
|
if ($lockedBatch === null) {
|
||||||
|
throw new InvalidArgumentException('Closing batch was not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$existingItems = $this->itemModel
|
||||||
|
->select('family_id')
|
||||||
|
->where('closing_batch_id', $batchId)
|
||||||
|
->findAll();
|
||||||
|
$existingFamilyIds = array_fill_keys(array_map(
|
||||||
|
static fn (array $item): int => (int) ($item['family_id'] ?? 0),
|
||||||
|
$existingItems
|
||||||
|
), true);
|
||||||
|
|
||||||
|
$repaired = [];
|
||||||
|
foreach ($preview['carry_forward'] as $row) {
|
||||||
|
$familyId = (int) ($row['family_id'] ?? 0);
|
||||||
|
if ($familyId <= 0 || isset($existingFamilyIds[$familyId])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$item = [
|
||||||
|
'closing_batch_id' => $batchId,
|
||||||
|
'family_id' => $familyId,
|
||||||
|
'source_balance' => $row['source_balance'],
|
||||||
|
'credit_amount' => $row['credit_amount'],
|
||||||
|
'adjustment_amount' => $row['adjustment_amount'] ?? 0,
|
||||||
|
'carry_forward_amount' => $row['carry_forward_amount'],
|
||||||
|
'status' => 'pending',
|
||||||
|
'school_year' => (string) ($target['name'] ?? ''),
|
||||||
|
];
|
||||||
|
$itemId = $this->itemModel->insert($item, true);
|
||||||
|
if (! $itemId) {
|
||||||
|
throw new RuntimeException('Unable to create the missing carry-forward item.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$item['id'] = (int) $itemId;
|
||||||
|
$targetInvoiceId = $this->createCarryForwardInvoice(
|
||||||
|
$item,
|
||||||
|
(string) ($source['name'] ?? ''),
|
||||||
|
(string) ($target['name'] ?? ''),
|
||||||
|
$userId
|
||||||
|
);
|
||||||
|
$this->itemModel->update((int) $itemId, [
|
||||||
|
'target_invoice_id' => $targetInvoiceId,
|
||||||
|
'status' => 'completed',
|
||||||
|
'error_message' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$repaired[] = [
|
||||||
|
'family_id' => $familyId,
|
||||||
|
'amount' => round((float) ($row['carry_forward_amount'] ?? 0), 2),
|
||||||
|
'target_invoice_id' => $targetInvoiceId,
|
||||||
|
];
|
||||||
|
$existingFamilyIds[$familyId] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($repaired !== []) {
|
||||||
|
$this->batchModel->update($batchId, [
|
||||||
|
'preview_hash' => $preview['hash'],
|
||||||
|
'total_families' => count($preview['carry_forward']),
|
||||||
|
'total_positive_balance' => $this->sumCarryForwardBalances($preview['carry_forward'], true),
|
||||||
|
'total_credit_balance' => $this->sumCarryForwardBalances($preview['carry_forward'], false),
|
||||||
|
]);
|
||||||
|
$this->managementService->log(
|
||||||
|
$sourceYearId,
|
||||||
|
(string) ($source['status'] ?? SchoolYearStatus::CLOSED),
|
||||||
|
(string) ($source['status'] ?? SchoolYearStatus::CLOSED),
|
||||||
|
'carry_forward_repair',
|
||||||
|
$userId,
|
||||||
|
[
|
||||||
|
'closing_batch_id' => $batchId,
|
||||||
|
'target_school_year_id' => $targetYearId,
|
||||||
|
'repaired_items' => $repaired,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->db->transStatus() === false) {
|
||||||
|
throw new RuntimeException('Unable to repair missing carry-forward balances.');
|
||||||
|
}
|
||||||
|
$this->db->transCommit();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'closing_batch_id' => $batchId,
|
||||||
|
'source_school_year' => (string) ($source['name'] ?? ''),
|
||||||
|
'target_school_year' => (string) ($target['name'] ?? ''),
|
||||||
|
'repaired_count' => count($repaired),
|
||||||
|
'repaired_amount' => round(array_sum(array_column($repaired, 'amount')), 2),
|
||||||
|
'items' => $repaired,
|
||||||
|
];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->db->transRollback();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private function requireYear(int $id): array
|
private function requireYear(int $id): array
|
||||||
{
|
{
|
||||||
$year = $this->schoolYearModel->find($id);
|
$year = $this->schoolYearModel->find($id);
|
||||||
@@ -402,7 +526,7 @@ final class SchoolYearClosingService
|
|||||||
$count = $this->db->table('invoices')
|
$count = $this->db->table('invoices')
|
||||||
->where('school_year', $schoolYear)
|
->where('school_year', $schoolYear)
|
||||||
->where('balance >', 0)
|
->where('balance >', 0)
|
||||||
->where("LOWER(status) IN ('unpaid', 'partially paid')", null, false)
|
->where("LOWER(REPLACE(TRIM(status), '_', ' ')) IN ('unpaid', 'partially paid')", null, false)
|
||||||
->countAllResults();
|
->countAllResults();
|
||||||
|
|
||||||
return $count > 0
|
return $count > 0
|
||||||
@@ -421,7 +545,7 @@ final class SchoolYearClosingService
|
|||||||
->select('COALESCE(SUM(i.balance), 0) AS source_balance')
|
->select('COALESCE(SUM(i.balance), 0) AS source_balance')
|
||||||
->where('i.school_year', $schoolYear)
|
->where('i.school_year', $schoolYear)
|
||||||
->where('i.balance !=', 0)
|
->where('i.balance !=', 0)
|
||||||
->where("LOWER(i.status) IN ('unpaid', 'partially paid')", null, false);
|
->where("LOWER(REPLACE(TRIM(i.status), '_', ' ')) IN ('unpaid', 'partially paid')", null, false);
|
||||||
|
|
||||||
if ($this->db->tableExists('users')) {
|
if ($this->db->tableExists('users')) {
|
||||||
$builder
|
$builder
|
||||||
@@ -1133,6 +1257,53 @@ final class SchoolYearClosingService
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Older decisions may exist only in below_sixty_decisions. The manual
|
||||||
|
// decision screen used that table before it also synchronized the
|
||||||
|
// consolidated student_decisions row, so treating those records as
|
||||||
|
// missing creates a false closing blocker.
|
||||||
|
if ($this->db->tableExists('below_sixty_decisions')) {
|
||||||
|
$fallbackRows = $this->db->table('below_sixty_decisions')
|
||||||
|
->select('student_id, decision, notes')
|
||||||
|
->where('school_year', $schoolYear)
|
||||||
|
->where('LOWER(TRIM(semester))', 'year')
|
||||||
|
->whereIn('student_id', $studentIds)
|
||||||
|
->orderBy('updated_at', 'DESC')
|
||||||
|
->orderBy('id', 'DESC')
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
|
||||||
|
$decisions = $this->mergeFallbackPromotionDecisions($decisions, $fallbackRows);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $decisions;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function mergeFallbackPromotionDecisions(array $decisions, array $fallbackRows): array
|
||||||
|
{
|
||||||
|
foreach ($fallbackRows as $row) {
|
||||||
|
$studentId = (int) ($row['student_id'] ?? 0);
|
||||||
|
$decision = trim((string) ($row['decision'] ?? ''));
|
||||||
|
$existing = $decisions[$studentId] ?? null;
|
||||||
|
|
||||||
|
if (
|
||||||
|
$studentId <= 0
|
||||||
|
|| $decision === ''
|
||||||
|
|| ($existing !== null && ($existing['status'] ?? '') === 'decided')
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decisions[$studentId] = [
|
||||||
|
'class_section_name' => '',
|
||||||
|
'year_score' => null,
|
||||||
|
'decision' => $decision,
|
||||||
|
'normalized_decision' => DeliberationDecision::normalize($decision),
|
||||||
|
'source' => 'manual',
|
||||||
|
'notes' => (string) ($row['notes'] ?? ''),
|
||||||
|
'status' => 'decided',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
return $decisions;
|
return $decisions;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1326,13 +1497,29 @@ final class SchoolYearClosingService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function finding(string $severity, string $title, string $detail): array
|
private function promotionStudentsWithStatus(array $rows, string $status): array
|
||||||
{
|
{
|
||||||
return [
|
return array_values(array_map(
|
||||||
|
static fn (array $row): array => [
|
||||||
|
'student_id' => (int) ($row['student_id'] ?? 0),
|
||||||
|
'student_name' => trim((string) ($row['student_name'] ?? '')),
|
||||||
|
'school_id' => trim((string) ($row['school_id'] ?? '')),
|
||||||
|
'class_section_name' => trim((string) ($row['class_section_name'] ?? '')),
|
||||||
|
],
|
||||||
|
array_filter(
|
||||||
|
$rows,
|
||||||
|
static fn (array $row): bool => (string) ($row['status'] ?? '') === $status
|
||||||
|
)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function finding(string $severity, string $title, string $detail, array $context = []): array
|
||||||
|
{
|
||||||
|
return array_merge([
|
||||||
'severity' => $severity,
|
'severity' => $severity,
|
||||||
'title' => $title,
|
'title' => $title,
|
||||||
'detail' => $detail,
|
'detail' => $detail,
|
||||||
];
|
], $context);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function hashPreview(array $preview): string
|
private function hashPreview(array $preview): string
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use CodeIgniter\Database\BaseConnection;
|
||||||
|
|
||||||
|
final class StudentScoreHistoryService
|
||||||
|
{
|
||||||
|
public function __construct(private readonly BaseConnection $db)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build one score-history row per student and school year.
|
||||||
|
*
|
||||||
|
* @return array<int, array<int, array<string, mixed>>>
|
||||||
|
*/
|
||||||
|
public function forStudents(array $studentIds): array
|
||||||
|
{
|
||||||
|
$studentIds = array_values(array_unique(array_filter(
|
||||||
|
array_map('intval', $studentIds),
|
||||||
|
static fn(int $id): bool => $id > 0
|
||||||
|
)));
|
||||||
|
if ($studentIds === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$history = [];
|
||||||
|
$semesterRows = $this->latestSemesterRows($studentIds);
|
||||||
|
foreach ($semesterRows as $row) {
|
||||||
|
$studentId = (int) ($row['student_id'] ?? 0);
|
||||||
|
$schoolYear = trim((string) ($row['school_year'] ?? ''));
|
||||||
|
$semester = $this->normalizeSemester($row['semester'] ?? '');
|
||||||
|
if ($studentId <= 0 || $schoolYear === '' || !in_array($semester, ['fall', 'spring'], true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$history[$studentId][$schoolYear] ??= $this->emptyYear($schoolYear);
|
||||||
|
if ($semester === 'fall') {
|
||||||
|
$history[$studentId][$schoolYear]['midterm_s1'] = $this->score($row['midterm_exam_score'] ?? null);
|
||||||
|
$history[$studentId][$schoolYear]['ptap_s1'] = $this->score($row['ptap_score'] ?? null);
|
||||||
|
$history[$studentId][$schoolYear]['attendance_s1'] = $this->score($row['attendance_score'] ?? null);
|
||||||
|
$history[$studentId][$schoolYear]['_semester_score_s1'] = $this->score($row['semester_score'] ?? null);
|
||||||
|
} else {
|
||||||
|
$finalScore = $this->score($row['final_exam_score'] ?? null);
|
||||||
|
if ($finalScore === null) {
|
||||||
|
// Some older Spring rows stored the final in this legacy column.
|
||||||
|
$finalScore = $this->score($row['midterm_exam_score'] ?? null);
|
||||||
|
}
|
||||||
|
$history[$studentId][$schoolYear]['final_s2'] = $finalScore;
|
||||||
|
$history[$studentId][$schoolYear]['ptap_s2'] = $this->score($row['ptap_score'] ?? null);
|
||||||
|
$history[$studentId][$schoolYear]['attendance_s2'] = $this->score($row['attendance_score'] ?? null);
|
||||||
|
$history[$studentId][$schoolYear]['_semester_score_s2'] = $this->score($row['semester_score'] ?? null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->commentRows($studentIds) as $row) {
|
||||||
|
$studentId = (int) ($row['student_id'] ?? 0);
|
||||||
|
$schoolYear = trim((string) ($row['school_year'] ?? ''));
|
||||||
|
$semester = $this->normalizeSemester($row['semester'] ?? '');
|
||||||
|
$type = $this->normalizeCommentType($row['score_type'] ?? '');
|
||||||
|
if ($studentId <= 0 || $schoolYear === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$history[$studentId][$schoolYear] ??= $this->emptyYear($schoolYear);
|
||||||
|
|
||||||
|
$field = match ($semester . ':' . $type) {
|
||||||
|
'fall:midterm' => 'midterm_comment_s1',
|
||||||
|
'fall:ptap' => 'ptap_comment_s1',
|
||||||
|
'fall:attendance' => 'attendance_comment_s1',
|
||||||
|
'spring:final' => 'final_comment_s2',
|
||||||
|
'spring:ptap' => 'ptap_comment_s2',
|
||||||
|
'spring:attendance' => 'attendance_comment_s2',
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
if ($field === null || $history[$studentId][$schoolYear][$field] !== '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$comment = trim((string) ($row['comment'] ?? ''));
|
||||||
|
$review = trim((string) ($row['comment_review'] ?? ''));
|
||||||
|
$history[$studentId][$schoolYear][$field] = $type === 'attendance'
|
||||||
|
? ($comment !== '' ? $comment : $review)
|
||||||
|
: ($review !== '' ? $review : $comment);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->decisionRows($studentIds) as $row) {
|
||||||
|
$studentId = (int) ($row['student_id'] ?? 0);
|
||||||
|
$schoolYear = trim((string) ($row['school_year'] ?? ''));
|
||||||
|
if ($studentId <= 0 || $schoolYear === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$history[$studentId][$schoolYear] ??= $this->emptyYear($schoolYear);
|
||||||
|
if ($history[$studentId][$schoolYear]['year_score'] === null) {
|
||||||
|
$history[$studentId][$schoolYear]['year_score'] = $this->score($row['year_score'] ?? null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($history as &$studentHistory) {
|
||||||
|
foreach ($studentHistory as &$year) {
|
||||||
|
if ($year['year_score'] === null) {
|
||||||
|
$semesterScores = array_values(array_filter(
|
||||||
|
[$year['_semester_score_s1'], $year['_semester_score_s2']],
|
||||||
|
static fn($score): bool => $score !== null
|
||||||
|
));
|
||||||
|
if ($semesterScores !== []) {
|
||||||
|
$year['year_score'] = round(array_sum($semesterScores) / count($semesterScores), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($year['_semester_score_s1'], $year['_semester_score_s2']);
|
||||||
|
}
|
||||||
|
unset($year);
|
||||||
|
krsort($studentHistory, SORT_STRING);
|
||||||
|
$studentHistory = array_values($studentHistory);
|
||||||
|
}
|
||||||
|
unset($studentHistory);
|
||||||
|
|
||||||
|
return $history;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function latestSemesterRows(array $studentIds): array
|
||||||
|
{
|
||||||
|
if (!$this->db->tableExists('semester_scores')) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = $this->db->table('semester_scores')
|
||||||
|
->select('id, student_id, school_year, semester, midterm_exam_score, final_exam_score, ptap_score, attendance_score, semester_score, updated_at')
|
||||||
|
->whereIn('student_id', $studentIds)
|
||||||
|
->orderBy('school_year', 'DESC')
|
||||||
|
->orderBy('updated_at', 'DESC')
|
||||||
|
->orderBy('id', 'DESC')
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
|
||||||
|
$latest = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$key = (int) ($row['student_id'] ?? 0)
|
||||||
|
. '|' . trim((string) ($row['school_year'] ?? ''))
|
||||||
|
. '|' . $this->normalizeSemester($row['semester'] ?? '');
|
||||||
|
$latest[$key] ??= $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values($latest);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function commentRows(array $studentIds): array
|
||||||
|
{
|
||||||
|
if (!$this->db->tableExists('score_comments')) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$builder = $this->db->table('score_comments')
|
||||||
|
->select('id, student_id, school_year, semester, score_type, comment, comment_review')
|
||||||
|
->whereIn('student_id', $studentIds);
|
||||||
|
if ($this->db->fieldExists('updated_at', 'score_comments')) {
|
||||||
|
$builder->orderBy('updated_at', 'DESC');
|
||||||
|
} elseif ($this->db->fieldExists('created_at', 'score_comments')) {
|
||||||
|
$builder->orderBy('created_at', 'DESC');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $builder->orderBy('id', 'DESC')->get()->getResultArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function decisionRows(array $studentIds): array
|
||||||
|
{
|
||||||
|
if (!$this->db->tableExists('student_decisions')) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->db->table('student_decisions')
|
||||||
|
->select('id, student_id, school_year, year_score')
|
||||||
|
->whereIn('student_id', $studentIds)
|
||||||
|
->orderBy('school_year', 'DESC')
|
||||||
|
->orderBy('updated_at', 'DESC')
|
||||||
|
->orderBy('id', 'DESC')
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function emptyYear(string $schoolYear): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'school_year' => $schoolYear,
|
||||||
|
'midterm_s1' => null,
|
||||||
|
'midterm_comment_s1' => '',
|
||||||
|
'ptap_s1' => null,
|
||||||
|
'ptap_comment_s1' => '',
|
||||||
|
'attendance_s1' => null,
|
||||||
|
'attendance_comment_s1' => '',
|
||||||
|
'final_s2' => null,
|
||||||
|
'final_comment_s2' => '',
|
||||||
|
'ptap_s2' => null,
|
||||||
|
'ptap_comment_s2' => '',
|
||||||
|
'attendance_s2' => null,
|
||||||
|
'attendance_comment_s2' => '',
|
||||||
|
'year_score' => null,
|
||||||
|
'_semester_score_s1' => null,
|
||||||
|
'_semester_score_s2' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeSemester($value): string
|
||||||
|
{
|
||||||
|
$semester = strtolower(trim((string) $value));
|
||||||
|
return match ($semester) {
|
||||||
|
'fall', 'first', 'first semester', 'semester 1', '1' => 'fall',
|
||||||
|
'spring', 'second', 'second semester', 'semester 2', '2' => 'spring',
|
||||||
|
default => $semester,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeCommentType($value): string
|
||||||
|
{
|
||||||
|
$type = strtolower(trim((string) $value));
|
||||||
|
$type = trim((string) preg_replace('/[^a-z0-9]+/', '_', $type), '_');
|
||||||
|
|
||||||
|
return match ($type) {
|
||||||
|
'midterm_comment', 'midterm_comments' => 'midterm',
|
||||||
|
'final_comment', 'final_comments' => 'final',
|
||||||
|
'ptap_comment', 'ptap_comments' => 'ptap',
|
||||||
|
'attendance_comment', 'attendance_comments', 'attendence', 'attendence_comment' => 'attendance',
|
||||||
|
default => $type,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function score($value): ?float
|
||||||
|
{
|
||||||
|
return $value !== null && $value !== '' && is_numeric($value)
|
||||||
|
? round((float) $value, 2)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<title>Al Rahma Sunday School</title>
|
|
||||||
<meta content="width=device-width, initial-scale=1.0" name="viewport">
|
|
||||||
<meta content="" name="keywords">
|
|
||||||
<meta content="" name="description">
|
|
||||||
|
|
||||||
<!-- Favicon -->
|
|
||||||
<link href="<?= base_url('assets/images/favicon.ico') ?>" rel="icon">
|
|
||||||
|
|
||||||
<!-- Google Web Fonts -->
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Heebo:wght@400;500;600&family=Inter:wght@600&family=Lobster+Two:wght@700&display=swap" rel="stylesheet">
|
|
||||||
|
|
||||||
<!-- Icon Font Stylesheet -->
|
|
||||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.10.0/css/all.min.css" rel="stylesheet">
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.4.1/font/bootstrap-icons.css" rel="stylesheet">
|
|
||||||
|
|
||||||
<!-- Libraries Stylesheet -->
|
|
||||||
<link href="<?= base_url('lib/animate/animate.min.css') ?>" rel="stylesheet">
|
|
||||||
<link href="<?= base_url('lib/owlcarousel/assets/owl.carousel.min.css') ?>" rel="stylesheet">
|
|
||||||
|
|
||||||
<!-- Customized Bootstrap Stylesheet -->
|
|
||||||
<link href="<?= base_url('css/bootstrap.min.css') ?>" rel="stylesheet">
|
|
||||||
|
|
||||||
<!-- Template Stylesheet -->
|
|
||||||
<link href="<?= base_url('css/style.css') ?>" rel="stylesheet">
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<div class="container-xxl bg-white p-0">
|
|
||||||
<!-- Spinner Start -->
|
|
||||||
<div id="spinner" class="show bg-white position-fixed translate-middle w-100 vh-100 top-50 start-50 d-flex align-items-center justify-content-center">
|
|
||||||
<div class="spinner-border text-primary" style="width: 3rem; height: 3rem;" role="status">
|
|
||||||
<span class="sr-only">Loading...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Spinner End -->
|
|
||||||
|
|
||||||
<!-- Navbar Start -->
|
|
||||||
<nav class="navbar navbar-expand-lg bg-white navbar-light sticky-top px-4 px-lg-5 py-lg-0">
|
|
||||||
<a href="<?= base_url('/') ?>" class="navbar-brand d-flex align-items-center">
|
|
||||||
<img src="<?= base_url('images/logo.png') ?>" alt="Al Rahma Sunday School" style="height: 40px; width: 40px; border-radius: 50%; object-fit: contain; background-color: #fff;">
|
|
||||||
<h1 class="m-0 ms-2 green-title">Al Rahma Sunday School</h1>
|
|
||||||
</a>
|
|
||||||
<button type="button" class="navbar-toggler" data-bs-toggle="collapse" data-bs-target="#navbarCollapse">
|
|
||||||
<span class="navbar-toggler-icon"></span>
|
|
||||||
</button>
|
|
||||||
<div class="collapse navbar-collapse" id="navbarCollapse">
|
|
||||||
<div class="navbar-nav mx-auto">
|
|
||||||
<a href="<?= base_url('/') ?>" class="nav-item nav-link">Home</a>
|
|
||||||
<a href="<?= base_url('/about') ?>" class="nav-item nav-link">About Us</a>
|
|
||||||
<a href="<?= base_url('/classes') ?>" class="nav-item nav-link">Classes</a>
|
|
||||||
<a href="<?= base_url('/contact') ?>" class="nav-item nav-link">Contact Us</a>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex">
|
|
||||||
<a href="/user/login" class="btn btn-primary rounded-pill px-3">Login<i class="fa fa-arrow-right ms-3"></i></a>
|
|
||||||
<a href="/register" class="btn btn-primary rounded-pill px-3 me-2">Register<i class="fa fa-arrow-right ms-3"></i></a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</nav>
|
|
||||||
<!-- Navbar End -->
|
|
||||||
|
|
||||||
<!-- Page Header Start -->
|
|
||||||
<!-- Page Header Start -->
|
|
||||||
<div class="container-xxl py-5 page-header position-relative mb-5">
|
|
||||||
<div class="container py-5">
|
|
||||||
<h1 class="display-2 text-white animated slideInDown mb-4">About Us</h1>
|
|
||||||
<nav aria-label="breadcrumb animated slideInDown">
|
|
||||||
<ol class="breadcrumb">
|
|
||||||
<li class="breadcrumb-item text-green"><a href="/">Home</a></li>
|
|
||||||
<li class="breadcrumb-item text-green"><a href="#">Pages</a></li>
|
|
||||||
<li class="breadcrumb-item text-green active" aria-current="page">About Us</li>
|
|
||||||
</ol>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Page Header End -->
|
|
||||||
|
|
||||||
<!-- Page Header End -->
|
|
||||||
|
|
||||||
<!-- About Start -->
|
|
||||||
<div class="container-xxl py-5">
|
|
||||||
<div class="container">
|
|
||||||
<div class="row g-5 align-items-center">
|
|
||||||
<div class="col-lg-6 wow fadeInUp" data-wow-delay="0.1s">
|
|
||||||
<h1 class="mb-4">Learn More About Our Work And Our Cultural Activities</h1>
|
|
||||||
<p>Discover the impactful work we do and immerse yourself in our vibrant cultural activities.
|
|
||||||
</p>
|
|
||||||
<p class="mb-4">Our programs are designed to enrich the community, fostering a deep appreciation for diverse traditions and values.
|
|
||||||
Join us to experience firsthand the creativity and passion that drive our initiatives.
|
|
||||||
</p>
|
|
||||||
<div class="row g-4 align-items-center">
|
|
||||||
<div class="col-sm-6">
|
|
||||||
<a class="btn btn-primary rounded-pill py-3 px-5" href="#">Read More</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-6 about-img wow fadeInUp" data-wow-delay="0.5s">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-12 text-center">
|
|
||||||
<img class="img-fluid w-75 rounded-circle bg-light p-3" src="<?= base_url('images/about-1.jpg') ?>" alt="">
|
|
||||||
</div>
|
|
||||||
<div class="col-6 text-start" style="margin-top: -150px;">
|
|
||||||
<img class="img-fluid w-100 rounded-circle bg-light p-3" src="<?= base_url('images/about-2.jpg') ?>" alt="">
|
|
||||||
</div>
|
|
||||||
<div class="col-6 text-end" style="margin-top: -150px;">
|
|
||||||
<img class="img-fluid w-100 rounded-circle bg-light p-3" src="<?= base_url('images/about-3.jpg') ?>" alt="">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- About End -->
|
|
||||||
|
|
||||||
<!-- Call To Action Start -->
|
|
||||||
<div class="container-xxl py-5">
|
|
||||||
<div class="container">
|
|
||||||
<div class="bg-light rounded">
|
|
||||||
<div class="row g-0">
|
|
||||||
<div class="col-lg-6 wow fadeIn" data-wow-delay="0.1s" style="min-height: 400px;">
|
|
||||||
<div class="position-relative h-100">
|
|
||||||
<img class="position-absolute w-100 h-100 rounded" src="<?= base_url('images/call-to-action.jpg') ?>" style="object-fit: cover;">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-6 wow fadeIn" data-wow-delay="0.5s">
|
|
||||||
<div class="h-100 d-flex flex-column justify-content-center p-5">
|
|
||||||
<h1 class="mb-4">Become A Teacher or Admin</h1>
|
|
||||||
<p class="mb-4">Becoming a teacher or admin at our Sunday school is a rewarding opportunity to make a meaningful impact on young lives. As a teacher, you will inspire and guide children on their spiritual journey, fostering their growth and understanding of faith. As an admin, you will play a crucial role in supporting the school's operations and ensuring a smooth and effective learning environment. Join our dedicated team and contribute to a nurturing environment that shapes the future of our community.</p>
|
|
||||||
<a class="btn btn-primary py-3 px-5" href="#">Get Started Now<i class="fa fa-arrow-right ms-2"></i></a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Call To Action End -->
|
|
||||||
|
|
||||||
<!-- Footer Start -->
|
|
||||||
<div class="container-fluid bg-dark text-white-50 footer pt-5 mt-5 wow fadeIn" data-wow-delay="0.1s">
|
|
||||||
<div class="container py-5">
|
|
||||||
<div class="row g-5">
|
|
||||||
<div class="col-lg-3 col-md-6">
|
|
||||||
<h3 class="text-white mb-4">Get In Touch</h3>
|
|
||||||
<p class="mb-2"><i class="fa fa-map-marker-alt me-3"></i>5 Courthouse Lane, Chelmsford, MA 01824</p>
|
|
||||||
<p class="mb-2"><i class="fa fa-phone-alt me-3"></i>+1 978-364-0219</p>
|
|
||||||
<p class="mb-2"><i class="fa fa-envelope me-3"></i>alrahma.isgl@gmail.com</p>
|
|
||||||
<div class="d-flex pt-2">
|
|
||||||
<a class="btn btn-outline-light btn-social" href=""><i class="fab fa-twitter"></i></a>
|
|
||||||
<a class="btn btn-outline-light btn-social" href=""><i class="fab fa-facebook-f"></i></a>
|
|
||||||
<a class="btn btn-outline-light btn-social" href=""><i class="fab fa-youtube"></i></a>
|
|
||||||
<a class="btn btn-outline-light btn-social" href=""><i class="fab fa-linkedin-in"></i></a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-3 col-md-6">
|
|
||||||
<h3 class="text-white mb-4">Quick Links</h3>
|
|
||||||
<a class="btn btn-link text-white-50" href="<?= base_url('/about') ?>">About Us</a>
|
|
||||||
<a class="btn btn-link text-white-50" href="<?= base_url('/contact') ?>">Contact Us</a>
|
|
||||||
<a class="btn btn-link text-white-50" href="<?= base_url('/services') ?>">Our Services</a>
|
|
||||||
<a class="btn btn-link text-white-50" href="<?= base_url('/privacy') ?>">Privacy Policy</a>
|
|
||||||
<a class="btn btn-link text-white-50" href="<?= base_url('/terms') ?>">Terms & Condition</a>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-3 col-md-6">
|
|
||||||
<h3 class="text-white mb-4">Photo Gallery</h3>
|
|
||||||
<div class="row g-2 pt-2">
|
|
||||||
<div class="col-4">
|
|
||||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-1.jpg') ?>" alt="">
|
|
||||||
</div>
|
|
||||||
<div class="col-4">
|
|
||||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-2.jpg') ?>" alt="">
|
|
||||||
</div>
|
|
||||||
<div class="col-4">
|
|
||||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-3.jpg') ?>" alt="">
|
|
||||||
</div>
|
|
||||||
<div class="col-4">
|
|
||||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-4.jpg') ?>" alt="">
|
|
||||||
</div>
|
|
||||||
<div class="col-4">
|
|
||||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-5.jpg') ?>" alt="">
|
|
||||||
</div>
|
|
||||||
<div class="col-4">
|
|
||||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-6.jpg') ?>" alt="">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-3 col-md-6">
|
|
||||||
<h3 class="text-white mb-4">Newsletter</h3>
|
|
||||||
<p>Our newsletter is your gateway to staying informed and connected with our Sunday school community. </p>
|
|
||||||
<div class="position-relative mx-auto" style="max-width: 400px;">
|
|
||||||
<input class="form-control bg-transparent w-100 py-3 ps-4 pe-5" type="text" placeholder="Your email">
|
|
||||||
<button type="button" class="btn btn-primary py-2 position-absolute top-0 end-0 mt-2 me-2">SignUp</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="container">
|
|
||||||
<div class="copyright">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6 text-center text-md-start mb-3 mb-md-0">
|
|
||||||
© <a class="border-bottom" href="#">Al Rahma Sunday School by ISGL</a>, All Right Reserved.
|
|
||||||
Designed By <a class="border-bottom" href="https://htmlcodex.com">HTML Codex</a>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6 text-center text-md-end">
|
|
||||||
<div class="footer-menu">
|
|
||||||
<a href="#">Home</a>
|
|
||||||
<a href="#">Cookies</a>
|
|
||||||
<a href="#">Help</a>
|
|
||||||
<a href="#">FQAs</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Footer End -->
|
|
||||||
|
|
||||||
<!-- Back to Top -->
|
|
||||||
<a href="#" class="btn btn-lg btn-primary btn-lg-square back-to-top"><i class="bi bi-arrow-up"></i></a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- JavaScript Libraries -->
|
|
||||||
<script src="https://code.jquery.com/jquery-3.4.1.min.js"></script>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0/dist/js/bootstrap.bundle.min.js"></script>
|
|
||||||
<script src="<?= base_url('lib/wow/wow.min.js') ?>"></script>
|
|
||||||
<script src="<?= base_url('lib/easing/easing.min.js') ?>"></script>
|
|
||||||
<script src="<?= base_url('lib/waypoints/waypoints.min.js') ?>"></script>
|
|
||||||
<script src="<?= base_url('lib/owlcarousel/owl.carousel.min.js') ?>"></script>
|
|
||||||
|
|
||||||
<!-- Template Javascript -->
|
|
||||||
<script src="<?= base_url('js/main.js') ?>"></script>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
@@ -91,6 +91,7 @@ $selectedYear = trim((string)($selectedYear ?? ''));
|
|||||||
<th>Allergies</th>
|
<th>Allergies</th>
|
||||||
<th>Photo Consent</th>
|
<th>Photo Consent</th>
|
||||||
<th>Registration Date</th>
|
<th>Registration Date</th>
|
||||||
|
<th style="min-width: 170px;">Assessment</th>
|
||||||
<th style="min-width: 220px;">Action</th>
|
<th style="min-width: 220px;">Action</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -160,13 +161,13 @@ $selectedYear = trim((string)($selectedYear ?? ''));
|
|||||||
<tr>
|
<tr>
|
||||||
<td><?= esc($student['school_id']) ?></td>
|
<td><?= esc($student['school_id']) ?></td>
|
||||||
<td>
|
<td>
|
||||||
<a href="#" class="text-decoration-none" data-family-student-id="<?= (int)($student['id'] ?? 0) ?>">
|
<a href="#" class="text-decoration-none" data-family-student-id="<?= (int)($student['id'] ?? 0) ?>" data-family-school-year="<?= esc($selectedYear) ?>">
|
||||||
<?= esc($student['firstname']) ?>
|
<?= esc($student['firstname']) ?>
|
||||||
</a>
|
</a>
|
||||||
<?= student_enrollment_status_button($student, $selectedYear) ?>
|
<?= student_enrollment_status_button($student, $selectedYear) ?>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<a href="#" class="text-decoration-none" data-family-student-id="<?= (int)($student['id'] ?? 0) ?>">
|
<a href="#" class="text-decoration-none" data-family-student-id="<?= (int)($student['id'] ?? 0) ?>" data-family-school-year="<?= esc($selectedYear) ?>">
|
||||||
<?= esc($student['lastname']) ?>
|
<?= esc($student['lastname']) ?>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
@@ -179,6 +180,20 @@ $selectedYear = trim((string)($selectedYear ?? ''));
|
|||||||
<td><?= esc($student['allergies'] ?? '') ?></td>
|
<td><?= esc($student['allergies'] ?? '') ?></td>
|
||||||
<td><?= !empty($student['photo_consent']) ? 'Yes' : 'No' ?></td>
|
<td><?= !empty($student['photo_consent']) ? 'Yes' : 'No' ?></td>
|
||||||
<td><?= esc($regDisp) ?></td>
|
<td><?= esc($regDisp) ?></td>
|
||||||
|
<td>
|
||||||
|
<?php $studentAssessment = ($assessmentByStudent ?? [])[(int)$student['id']] ?? null; ?>
|
||||||
|
<?php if (!$studentAssessment): ?>
|
||||||
|
<form method="post" action="<?= site_url('administrator/assessments/students/' . $student['id'] . '/start') ?>"><?= csrf_field() ?><button class="btn btn-sm btn-outline-primary">Assign Assessment</button></form>
|
||||||
|
<?php elseif (in_array($studentAssessment['status'], ['not_started', 'in_progress'], true)): ?>
|
||||||
|
<a class="btn btn-sm btn-outline-secondary" href="<?= site_url('administrator/assessments/students/' . $student['id']) ?>">View Status</a>
|
||||||
|
<div class="small text-muted mt-1"><?= esc(ucwords(str_replace('_', ' ', $studentAssessment['status']))) ?></div>
|
||||||
|
<?php elseif ($studentAssessment['status'] === 'completed'): ?>
|
||||||
|
<a class="btn btn-sm btn-warning" href="<?= site_url('administrator/assessments/review/' . $studentAssessment['id']) ?>">Review</a>
|
||||||
|
<?php else: ?>
|
||||||
|
<a class="btn btn-sm btn-success" href="<?= site_url('administrator/assessments/results/' . $studentAssessment['id']) ?>">View Results</a>
|
||||||
|
<div class="small text-muted mt-1">Reviewed</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
<td class="d-flex flex-wrap gap-2">
|
<td class="d-flex flex-wrap gap-2">
|
||||||
<!-- Contact Modal Trigger -->
|
<!-- Contact Modal Trigger -->
|
||||||
<button class="btn btn-warning btn-sm" data-bs-toggle="modal" data-bs-target="#<?= $modalIdContact ?>">
|
<button class="btn btn-warning btn-sm" data-bs-toggle="modal" data-bs-target="#<?= $modalIdContact ?>">
|
||||||
|
|||||||
@@ -1,270 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<title>Al Rahma Sunday School</title>
|
|
||||||
<meta content="width=device-width, initial-scale=1.0" name="viewport">
|
|
||||||
<meta content="" name="keywords">
|
|
||||||
<meta content="" name="description">
|
|
||||||
|
|
||||||
<!-- Favicon -->
|
|
||||||
<link href="<?= base_url('assets/images/favicon.ico') ?>" rel="icon">
|
|
||||||
|
|
||||||
<!-- Google Web Fonts -->
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link
|
|
||||||
href="https://fonts.googleapis.com/css2?family=Heebo:wght@400;500;600&family=Inter:wght@600&family=Lobster+Two:wght@700&display=swap"
|
|
||||||
rel="stylesheet">
|
|
||||||
|
|
||||||
<!-- Icon Font Stylesheet -->
|
|
||||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.10.0/css/all.min.css" rel="stylesheet">
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.4.1/font/bootstrap-icons.css" rel="stylesheet">
|
|
||||||
|
|
||||||
<!-- Libraries Stylesheet -->
|
|
||||||
<link href="lib/animate/animate.min.css" rel="stylesheet">
|
|
||||||
<link href="lib/owlcarousel/assets/owl.carousel.min.css" rel="stylesheet">
|
|
||||||
|
|
||||||
<!-- Customized Bootstrap Stylesheet -->
|
|
||||||
<link href="css/bootstrap.min.css" rel="stylesheet">
|
|
||||||
|
|
||||||
<!-- Template Stylesheet -->
|
|
||||||
<link href="assets/css/style.css" rel="stylesheet">
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<div class="container-xxl bg-white p-0">
|
|
||||||
<!-- Spinner Start -->
|
|
||||||
<div id="spinner"
|
|
||||||
class="show bg-white position-fixed translate-middle w-100 vh-100 top-50 start-50 d-flex align-items-center justify-content-center">
|
|
||||||
<div class="spinner-border text-primary" style="width: 3rem; height: 3rem;" role="status">
|
|
||||||
<span class="sr-only">Loading...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Spinner End -->
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Navbar Start -->
|
|
||||||
<nav class="navbar navbar-expand-lg bg-white navbar-light sticky-top px-4 px-lg-5 py-lg-0">
|
|
||||||
<a href="<?= base_url('/') ?>" class="navbar-brand d-flex align-items-center">
|
|
||||||
<img src="<?= base_url('images/logo.png') ?>" alt="Al Rahma Sunday School" style="height: 40px; width: 40px; border-radius: 50%; object-fit: contain; background-color: #fff;">
|
|
||||||
<h1 class="m-0 ms-2 green-title">Al Rahma Sunday School</h1>
|
|
||||||
</a>
|
|
||||||
<button type="button" class="navbar-toggler" data-bs-toggle="collapse" data-bs-target="#navbarCollapse">
|
|
||||||
<span class="navbar-toggler-icon"></span>
|
|
||||||
</button>
|
|
||||||
<div class="collapse navbar-collapse" id="navbarCollapse">
|
|
||||||
<div class="navbar-nav mx-auto">
|
|
||||||
<a href="<?= base_url('/') ?>" class="nav-item nav-link">Home</a>
|
|
||||||
<a href="<?= base_url('/about') ?>" class="nav-item nav-link">About Us</a>
|
|
||||||
<a href="<?= base_url('/classes') ?>" class="nav-item nav-link">Classes</a>
|
|
||||||
<div class="nav-item dropdown">
|
|
||||||
<a href="#" class="nav-link dropdown-toggle active" data-bs-toggle="dropdown">Pages</a>
|
|
||||||
<div class="dropdown-menu rounded-0 rounded-bottom border-0 shadow-sm m-0">
|
|
||||||
<a href="<?= base_url('/facility') ?>" class="dropdown-item">School Facilities</a>
|
|
||||||
<a href="team.html" class="dropdown-item">Popular Teachers</a>
|
|
||||||
<a href="<?= base_url('/call-to-action') ?>" class="dropdown-item">Become A Teacher or Admins</a>
|
|
||||||
<a href="<?= base_url('/appointment') ?>" class="dropdown-item active">Make Appointment</a>
|
|
||||||
<a href="<?= base_url('/testimonial') ?>" class="dropdown-item">Testimonial</a>
|
|
||||||
<a href="<?= base_url('/notFound') ?>" class="dropdown-item">notFound Error</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<a href="<?= base_url('/contact') ?>" class="nav-item nav-link">Contact Us</a>
|
|
||||||
</div>
|
|
||||||
<a href="/register" class="btn btn-primary rounded-pill px-3 d-none d-lg-block">Register
|
|
||||||
<i class="fa fa-arrow-right ms-3"></i></a>
|
|
||||||
<a href="/login" class="btn btn-primary rounded-pill px-3 d-none d-lg-block">Login<i
|
|
||||||
class="fa fa-arrow-right ms-3"></i></a>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
<!-- Navbar End -->
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Page Header End -->
|
|
||||||
<div class="container-xxl py-5 page-header position-relative mb-5">
|
|
||||||
<div class="container py-5">
|
|
||||||
<h1 class="display-2 text-white animated slideInDown mb-4">Appointment</h1>
|
|
||||||
<nav aria-label="breadcrumb animated slideInDown">
|
|
||||||
<ol class="breadcrumb">
|
|
||||||
<li class="breadcrumb-item"><a href="/">Home</a></li>
|
|
||||||
<li class="breadcrumb-item"><a href="#">Pages</a></li>
|
|
||||||
<li class="breadcrumb-item text-white active" aria-current="page">Appointment</li>
|
|
||||||
</ol>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Page Header End -->
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Appointment Start -->
|
|
||||||
<div class="container-xxl py-5">
|
|
||||||
<div class="container">
|
|
||||||
<div class="bg-light rounded">
|
|
||||||
<div class="row g-0">
|
|
||||||
<div class="col-lg-6 wow fadeIn" data-wow-delay="0.1s">
|
|
||||||
<div class="h-100 d-flex flex-column justify-content-center p-5">
|
|
||||||
<h1 class="mb-4">Make Appointment</h1>
|
|
||||||
<form>
|
|
||||||
<div class="row g-3">
|
|
||||||
<div class="col-sm-6">
|
|
||||||
<div class="form-floating">
|
|
||||||
<input type="text" class="form-control border-0" id="gname"
|
|
||||||
placeholder="Gurdian Name">
|
|
||||||
<label for="gname">Gurdian Name</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-sm-6">
|
|
||||||
<div class="form-floating">
|
|
||||||
<input type="email" class="form-control border-0" id="gmail"
|
|
||||||
placeholder="Gurdian Email">
|
|
||||||
<label for="gmail">Gurdian Email</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-sm-6">
|
|
||||||
<div class="form-floating">
|
|
||||||
<input type="text" class="form-control border-0" id="cname"
|
|
||||||
placeholder="Child Name">
|
|
||||||
<label for="cname">Child Name</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-sm-6">
|
|
||||||
<div class="form-floating">
|
|
||||||
<input type="text" class="form-control border-0" id="cage"
|
|
||||||
placeholder="Child Age">
|
|
||||||
<label for="cage">Child Age</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="form-floating">
|
|
||||||
<textarea class="form-control border-0"
|
|
||||||
placeholder="Leave a message here" id="message"
|
|
||||||
style="height: 100px"></textarea>
|
|
||||||
<label for="message">Message</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-12">
|
|
||||||
<button class="btn btn-primary w-100 py-3" type="submit">Submit</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-6 wow fadeIn" data-wow-delay="0.5s" style="min-height: 400px;">
|
|
||||||
<div class="position-relative h-100">
|
|
||||||
<img class="position-absolute w-100 h-100 rounded" src="images/appointment.jpg"
|
|
||||||
style="object-fit: cover;">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Appointment End -->
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Footer Start -->
|
|
||||||
<div class="container-fluid bg-dark text-white-50 footer pt-5 mt-5 wow fadeIn" data-wow-delay="0.1s">
|
|
||||||
<div class="container py-5">
|
|
||||||
<div class="row g-5">
|
|
||||||
<div class="col-lg-3 col-md-6">
|
|
||||||
<h3 class="text-white mb-4">Get In Touch</h3>
|
|
||||||
<p class="mb-2"><i class="fa fa-map-marker-alt me-3"></i>5 Courthouse Lane, Chelmsford, MA 01824
|
|
||||||
</p>
|
|
||||||
<p class="mb-2"><i class="fa fa-phone-alt me-3"></i>+1 978-364-0219
|
|
||||||
0</p>
|
|
||||||
<p class="mb-2"><i class="fa fa-envelope me-3"></i>alrahma.isgl@gmail.com
|
|
||||||
</p>
|
|
||||||
<div class="d-flex pt-2">
|
|
||||||
<a class="btn btn-outline-light btn-social" href=""><i class="fab fa-twitter"></i></a>
|
|
||||||
<a class="btn btn-outline-light btn-social" href=""><i class="fab fa-facebook-f"></i></a>
|
|
||||||
<a class="btn btn-outline-light btn-social" href=""><i class="fab fa-youtube"></i></a>
|
|
||||||
<a class="btn btn-outline-light btn-social" href=""><i class="fab fa-linkedin-in"></i></a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-3 col-md-6">
|
|
||||||
<h3 class="text-white mb-4">Quick Links</h3>
|
|
||||||
<a class="btn btn-link text-white-50" href="">About Us</a>
|
|
||||||
<a class="btn btn-link text-white-50" href="">Contact Us</a>
|
|
||||||
<a class="btn btn-link text-white-50" href="">Our Services</a>
|
|
||||||
<a class="btn btn-link text-white-50" href="">Privacy Policy</a>
|
|
||||||
<a class="btn btn-link text-white-50" href="">Terms & Condition</a>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-3 col-md-6">
|
|
||||||
<h3 class="text-white mb-4">Photo Gallery</h3>
|
|
||||||
<div class="row g-2 pt-2">
|
|
||||||
<div class="col-4">
|
|
||||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-1.jpg') ?>" alt="">
|
|
||||||
</div>
|
|
||||||
<div class="col-4">
|
|
||||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-2.jpg') ?>" alt="">
|
|
||||||
</div>
|
|
||||||
<div class="col-4">
|
|
||||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-3.jpg') ?>" alt="">
|
|
||||||
</div>
|
|
||||||
<div class="col-4">
|
|
||||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-4.jpg') ?>" alt="">
|
|
||||||
</div>
|
|
||||||
<div class="col-4">
|
|
||||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-5.jpg') ?>" alt="">
|
|
||||||
</div>
|
|
||||||
<div class="col-4">
|
|
||||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-6.jpg') ?>" alt="">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-3 col-md-6">
|
|
||||||
<h3 class="text-white mb-4">Newsletter</h3>
|
|
||||||
<p>Our newsletter is your gateway to staying informed and connected with our Sunday school community. </p>
|
|
||||||
<div class="position-relative mx-auto" style="max-width: 400px;">
|
|
||||||
<input class="form-control bg-transparent w-100 py-3 ps-4 pe-5" type="text"
|
|
||||||
placeholder="Your email">
|
|
||||||
<button type="button"
|
|
||||||
class="btn btn-primary py-2 position-absolute top-0 end-0 mt-2 me-2">SignUp</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="container">
|
|
||||||
<div class="copyright">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6 text-center text-md-start mb-3 mb-md-0">
|
|
||||||
© <a class="border-bottom" href="#">Al Rahma Sunday School by ISGL</a>, All Right
|
|
||||||
Reserved.
|
|
||||||
|
|
||||||
<!--/*** This template is free as long as you keep the footer author’s credit link/attribution link/backlink. If you'd like to use the template without the footer author’s credit link/attribution link/backlink, you can purchase the Credit Removal License from "https://htmlcodex.com/credit-removal". Thank you for your support. ***/-->
|
|
||||||
Designed By <a class="border-bottom" href="https://htmlcodex.com">HTML Codex</a>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6 text-center text-md-end">
|
|
||||||
<div class="footer-menu">
|
|
||||||
<a href="">Home</a>
|
|
||||||
<a href="">Cookies</a>
|
|
||||||
<a href="">Help</a>
|
|
||||||
<a href="">FQAs</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Footer End -->
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Back to Top -->
|
|
||||||
<a href="#" class="btn btn-lg btn-primary btn-lg-square back-to-top"><i class="bi bi-arrow-up"></i></a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- JavaScript Libraries -->
|
|
||||||
<script src="https://code.jquery.com/jquery-3.4.1.min.js"></script>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0/dist/js/bootstrap.bundle.min.js"></script>
|
|
||||||
<script src="lib/wow/wow.min.js"></script>
|
|
||||||
<script src="lib/easing/easing.min.js"></script>
|
|
||||||
<script src="lib/waypoints/waypoints.min.js"></script>
|
|
||||||
<script src="lib/owlcarousel/owl.carousel.min.js"></script>
|
|
||||||
|
|
||||||
<!-- Template Javascript -->
|
|
||||||
<script src="js/main.js"></script>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php if (session()->getFlashdata('success')): ?>
|
||||||
|
<div class="alert alert-success"><?= esc(session()->getFlashdata('success')) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (session()->getFlashdata('error')): ?>
|
||||||
|
<div class="alert alert-danger"><?= esc(session()->getFlashdata('error')) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (session()->getFlashdata('errors')): ?>
|
||||||
|
<div class="alert alert-danger"><ul class="mb-0">
|
||||||
|
<?php foreach ((array) session()->getFlashdata('errors') as $error): ?><li><?= esc($error) ?></li><?php endforeach; ?>
|
||||||
|
</ul></div>
|
||||||
|
<?php endif; ?>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?= $this->extend('layout/management_layout') ?>
|
||||||
|
<?= $this->section('content') ?>
|
||||||
|
<div class="container py-4" style="max-width:900px">
|
||||||
|
<div class="d-flex justify-content-between align-items-start mb-3"><div><h2><?= esc($assessment['form_name']) ?></h2><p class="text-muted mb-0">Interviewing <?= esc($assessment['firstname'].' '.$assessment['lastname']) ?> · <?= esc($assessment['school_year']) ?></p></div><a class="btn btn-outline-secondary" href="<?= site_url('administrator/assessments/students') ?>">New Students</a></div>
|
||||||
|
<?= $this->include('assessments/_alerts') ?>
|
||||||
|
<?php if(!empty($assessment['form_education_committee_note'])): ?><div class="alert alert-info"><strong>Form instructions</strong><div class="mt-1" style="white-space:pre-wrap"><?= esc($assessment['form_education_committee_note']) ?></div></div><?php endif; ?>
|
||||||
|
<div id="saveNotice" class="alert alert-light border py-2">Responses save automatically as you type.</div>
|
||||||
|
<form id="adminAssessmentForm" method="post" action="<?= site_url('administrator/assessments/attempts/'.$assessment['id'].'/complete') ?>"><?= csrf_field() ?>
|
||||||
|
<?php foreach($questions as $index=>$q): ?><div class="card shadow-sm mb-3"><div class="card-body"><label class="form-label fw-semibold" for="answer-<?= (int)$q['id'] ?>"><?= $index+1 ?>. <?= esc($q['text']) ?></label>
|
||||||
|
<?php if($q['type']==='short_answer'): ?><input id="answer-<?= (int)$q['id'] ?>" class="form-control assessment-answer" name="answers[<?= (int)$q['id'] ?>]" value="<?= esc($q['answer_value']??'') ?>" autocomplete="off">
|
||||||
|
<?php else: ?><textarea id="answer-<?= (int)$q['id'] ?>" class="form-control assessment-answer" name="answers[<?= (int)$q['id'] ?>]" rows="4"><?= esc($q['answer_value']??'') ?></textarea><?php endif; ?>
|
||||||
|
</div></div><?php endforeach; ?>
|
||||||
|
<div class="card shadow-sm mb-3"><div class="card-body"><label class="form-label fw-semibold" for="educationCommitteeNote">Education Committee Note</label><textarea id="educationCommitteeNote" class="form-control assessment-answer" name="education_committee_note" rows="5" maxlength="10000" placeholder="Add the Education Committee's note for this student."><?= esc($assessment['education_committee_note'] ?? '') ?></textarea><div class="form-text">This note is saved automatically with the responses.</div></div></div>
|
||||||
|
<button class="btn btn-success" onclick="return confirm('Complete this assessment and review the responses?')">Complete Assessment</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<script>(function(){var form=document.getElementById('adminAssessmentForm'),notice=document.getElementById('saveNotice'),timer=null,savePromise=null,pending=false,completing=false;
|
||||||
|
async function save(){if(savePromise){pending=true;return savePromise}savePromise=(async function(){notice.textContent='Saving…';try{var response=await fetch('<?= site_url('administrator/assessments/attempts/'.$assessment['id'].'/progress') ?>',{method:'POST',body:new FormData(form),headers:{'X-Requested-With':'XMLHttpRequest','Accept':'application/json'}}),data=await response.json();if(!response.ok)throw new Error(data.message||'Save failed');if(data.csrfName&&data.csrfHash){var token=form.querySelector('input[name="'+data.csrfName+'"]');if(token)token.value=data.csrfHash}notice.textContent='All responses saved.'}catch(e){notice.textContent='Autosave failed. Keep this page open and continue typing while it retries.'}})();await savePromise;savePromise=null;if(pending){pending=false;return save()}}
|
||||||
|
document.querySelectorAll('.assessment-answer').forEach(function(field){field.addEventListener('input',function(){clearTimeout(timer);timer=setTimeout(save,700)});field.addEventListener('change',save)});
|
||||||
|
form.addEventListener('submit',async function(event){if(completing)return;event.preventDefault();clearTimeout(timer);await save();completing=true;form.submit()});})();</script>
|
||||||
|
<?= $this->endSection() ?>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?= $this->extend('layout/management_layout') ?>
|
||||||
|
<?= $this->section('content') ?>
|
||||||
|
<?php
|
||||||
|
$isEdit = !empty($form);
|
||||||
|
$singlePool = count($pools) === 1;
|
||||||
|
$action = $isEdit ? site_url('administrator/assessments/forms/' . $form['id']) : site_url('administrator/assessments/forms');
|
||||||
|
$yearNames = array_column($schoolYears ?? [], 'name');
|
||||||
|
if ($selectedSchoolYear !== '' && !in_array($selectedSchoolYear, $yearNames, true)) $schoolYears[] = ['name' => $selectedSchoolYear, 'status' => ''];
|
||||||
|
?>
|
||||||
|
<div class="container py-3" style="max-width:1000px">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3"><div><h2><?= $isEdit ? 'Edit' : 'Create' ?> Assessment Form</h2><p class="text-muted mb-0">Choose questions from one shared pool and set their order.</p></div><a class="btn btn-outline-secondary" href="<?= site_url('administrator/assessments/forms') ?>">Back</a></div>
|
||||||
|
<?= $this->include('assessments/_alerts') ?>
|
||||||
|
<?php if ($locked): ?><div class="alert alert-info">This form has student assignments. Its school year, pool, and question list are locked, but its name, note, and status can still be changed.</div><?php endif; ?>
|
||||||
|
<form method="post" action="<?= $action ?>"><?= csrf_field() ?>
|
||||||
|
<div class="card shadow-sm mb-3"><div class="card-body"><div class="row g-3">
|
||||||
|
<div class="col-md-4"><label class="form-label">Form name</label><input class="form-control" name="name" maxlength="150" required value="<?= set_value('name', $form['name'] ?? '') ?>"></div>
|
||||||
|
<div class="col-md-3"><label class="form-label">Question pool</label><select id="poolSelect" class="form-select" name="pool_id" required <?= ($isEdit || $singlePool) ? 'disabled' : '' ?>><option value="">Select a pool</option><?php foreach ($pools as $pool): ?><option value="<?= (int)$pool['id'] ?>" <?= ($singlePool || (int)($form['pool_id'] ?? 0)===(int)$pool['id']) ? 'selected' : '' ?>><?= esc($pool['name']) ?></option><?php endforeach; ?></select><?php if ($isEdit || $singlePool): ?><input type="hidden" name="pool_id" value="<?= (int)($form['pool_id'] ?? $pools[0]['id'] ?? 0) ?>"><?php endif; ?></div>
|
||||||
|
<div class="col-md-3"><label class="form-label">School year</label><select class="form-select" name="school_year" required <?= $locked ? 'disabled' : '' ?>><option value="">Select school year</option><?php foreach (($schoolYears ?? []) as $year): ?><option value="<?= esc($year['name']) ?>" <?= set_select('school_year', $year['name'], $selectedSchoolYear === $year['name']) ?>><?= esc($year['name']) ?><?= !empty($year['status']) ? ' — '.esc(ucfirst($year['status'])) : '' ?></option><?php endforeach; ?></select><?php if($locked): ?><input type="hidden" name="school_year" value="<?= esc($selectedSchoolYear) ?>"><?php endif; ?></div>
|
||||||
|
<div class="col-md-2"><label class="form-label">Status</label><select class="form-select" name="status"><option value="draft" <?= ($form['status'] ?? 'draft')==='draft'?'selected':'' ?>>Draft</option><option value="published" <?= ($form['status'] ?? '')==='published'?'selected':'' ?>>Published</option><option value="archived" <?= ($form['status'] ?? '')==='archived'?'selected':'' ?>>Archived</option></select></div>
|
||||||
|
<div class="col-12"><label class="form-label">Education Committee Note <span class="text-muted">(optional, admin only)</span></label><textarea class="form-control" name="education_committee_note" rows="3" maxlength="10000" placeholder="Add context or instructions for the Education Committee."><?= set_value('education_committee_note', $form['education_committee_note'] ?? '') ?></textarea></div>
|
||||||
|
</div></div></div>
|
||||||
|
<?php if (!$isEdit): ?><div id="newFormHint" class="alert alert-secondary">Pick individual questions or select “Use all questions.”</div><?php endif; ?>
|
||||||
|
<div class="card shadow-sm"><div class="card-header d-flex justify-content-between"><strong>Questions</strong><?php if (!$locked): ?><label><input type="checkbox" name="use_all" value="1"> Use all questions</label><?php endif; ?></div><div class="card-body">
|
||||||
|
<?php if (empty($questions)): ?><p class="text-muted">There are no questions available. Add questions to a pool first.</p><?php endif; ?>
|
||||||
|
<p id="poolQuestionHint" class="text-muted <?= $isEdit ? 'd-none' : '' ?>">Select a pool to see its questions.</p>
|
||||||
|
<?php foreach ($questions as $index => $question): ?><div class="border rounded p-3 mb-2 question-choice" data-pool-id="<?= (int)$question['pool_id'] ?>"><div class="row align-items-start"><div class="col-auto"><input class="form-check-input" type="checkbox" name="question_ids[]" value="<?= (int)$question['id'] ?>" <?= in_array((int)$question['id'], $selectedIds, true)?'checked':'' ?> <?= $locked?'disabled':'' ?>></div><div class="col"><div><?= esc($question['text']) ?></div><small class="text-muted"><?= esc(str_replace('_',' ',ucfirst($question['type']))) ?></small></div><div class="col-auto"><label class="small">Order</label><input style="width:80px" class="form-control form-control-sm" type="number" min="1" name="order_<?= (int)$question['id'] ?>" value="<?= ($pos=array_search((int)$question['id'],$selectedIds,true))!==false ? $pos+1 : $index+1 ?>" <?= $locked?'disabled':'' ?>></div></div></div><?php endforeach; ?>
|
||||||
|
</div></div>
|
||||||
|
<button class="btn btn-primary mt-3" <?= $isEdit && empty($questions) && !$locked ? 'disabled' : '' ?>>Save Form</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<?php if (!$isEdit): ?><script>(function(){var select=document.getElementById('poolSelect'),hint=document.getElementById('poolQuestionHint');function sync(){var pool=select.value,shown=0;document.querySelectorAll('.question-choice').forEach(function(row){var active=pool!==''&&row.dataset.poolId===pool;row.classList.toggle('d-none',!active);row.querySelectorAll('input').forEach(function(input){input.disabled=!active;if(!active&&input.type==='checkbox')input.checked=false});if(active)shown++});hint.textContent=pool===''?'Select a pool to see its questions.':(shown?'':'This pool has no questions.');hint.classList.toggle('d-none',shown>0)}select.addEventListener('change',sync);sync()})();</script><?php endif; ?>
|
||||||
|
<?= $this->endSection() ?>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<?= $this->extend('layout/management_layout') ?>
|
||||||
|
<?= $this->section('content') ?>
|
||||||
|
<div class="container-fluid py-3">
|
||||||
|
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
|
||||||
|
<div><h2 class="mb-1">Assessment Forms</h2><p class="text-muted mb-0">Build, publish, preview, and track individual assessments.</p></div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a class="btn btn-outline-secondary" href="<?= site_url('administrator/assessments/students') ?>">New Students</a>
|
||||||
|
<a class="btn btn-outline-primary" href="<?= site_url('administrator/assessments/pools') ?>">Question Pool</a>
|
||||||
|
<a class="btn btn-primary" href="<?= site_url('administrator/assessments/forms/new') ?>">Create Form</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?= $this->include('assessments/_alerts') ?>
|
||||||
|
<div class="card border-primary-subtle shadow-sm mb-3">
|
||||||
|
<div class="card-header bg-primary-subtle"><strong>How to use assessments</strong></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3 small">
|
||||||
|
<div class="col-md"><span class="badge bg-primary me-1">1</span><strong>Questions</strong><div class="text-muted mt-1">Add or reorder questions in the shared <a href="<?= site_url('administrator/assessments/pools') ?>">Question Pool</a>.</div></div>
|
||||||
|
<div class="col-md"><span class="badge bg-primary me-1">2</span><strong>Create</strong><div class="text-muted mt-1">Create a form, choose its school year and questions, then publish it.</div></div>
|
||||||
|
<div class="col-md"><span class="badge bg-primary me-1">3</span><strong>Assess</strong><div class="text-muted mt-1">Open <a href="<?= site_url('administrator/assessments/students') ?>">New Students</a>, assign a form, and enter responses. Changes save automatically.</div></div>
|
||||||
|
<div class="col-md"><span class="badge bg-primary me-1">4</span><strong>Complete</strong><div class="text-muted mt-1">Add the student-specific Education Committee Note and complete the assessment.</div></div>
|
||||||
|
<div class="col-md"><span class="badge bg-primary me-1">5</span><strong>Review</strong><div class="text-muted mt-1">Review responses and edit the committee note at any time. Assessments are not scored.</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card shadow-sm"><div class="card-body"><div class="table-responsive">
|
||||||
|
<table class="table align-middle no-mgmt-sticky" data-no-mgmt-sticky>
|
||||||
|
<thead><tr><th>Name</th><th>School Year</th><th>Pool</th><th>Education Committee Note</th><th>Status</th><th>Questions</th><th>Assignments</th><th>Actions</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<?php if (empty($forms)): ?><tr><td colspan="8" class="text-muted text-center">No assessment forms yet.</td></tr><?php endif; ?>
|
||||||
|
<?php foreach ($forms as $form): ?>
|
||||||
|
<tr>
|
||||||
|
<td><?= esc($form['name']) ?></td>
|
||||||
|
<td><?= esc($form['school_year'] ?: '—') ?></td>
|
||||||
|
<td><?= esc($form['pool_name']) ?></td>
|
||||||
|
<td style="max-width:280px;white-space:pre-wrap"><?= esc($form['education_committee_note'] ?: '—') ?></td>
|
||||||
|
<td><span class="badge <?= $form['status'] === 'published' ? 'bg-success' : ($form['status'] === 'archived' ? 'bg-secondary' : 'bg-warning text-dark') ?>"><?= esc(ucfirst($form['status'])) ?></span></td>
|
||||||
|
<td><?= (int) $form['question_count'] ?></td>
|
||||||
|
<td><?= (int) $form['assignment_count'] ?></td>
|
||||||
|
<td><div class="d-flex flex-wrap gap-1">
|
||||||
|
<a class="btn btn-sm btn-outline-primary" href="<?= site_url('administrator/assessments/forms/' . $form['id'] . '/edit') ?>">Edit</a>
|
||||||
|
<a class="btn btn-sm btn-outline-secondary" href="<?= site_url('administrator/assessments/forms/' . $form['id'] . '/preview') ?>">Preview</a>
|
||||||
|
<?php if ($form['status'] !== 'published'): ?>
|
||||||
|
<form method="post" action="<?= site_url('administrator/assessments/forms/' . $form['id'] . '/publish') ?>" onsubmit="return confirm('Publish this assessment form?')">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<button class="btn btn-sm btn-success" <?= (int)$form['question_count'] === 0 ? 'disabled title="Add questions before publishing"' : '' ?>>Publish</button>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div></div></div>
|
||||||
|
</div>
|
||||||
|
<?= $this->endSection() ?>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?= $this->extend('layout/management_layout') ?>
|
||||||
|
<?= $this->section('content') ?>
|
||||||
|
<div class="container py-4" style="max-width:950px"><div class="d-flex justify-content-between"><div><h2>Review: <?= esc($assessment['form_name']) ?></h2><p class="text-muted"><?= esc($assessment['firstname'].' '.$assessment['lastname']) ?> · <?= esc($assessment['school_year']) ?></p></div><a class="btn btn-outline-secondary align-self-start" href="<?= site_url('administrator/assessments/students/'.$assessment['student_id']) ?>">Back</a></div><?= $this->include('assessments/_alerts') ?>
|
||||||
|
<form method="post" action="<?= site_url('administrator/assessments/review/'.$assessment['id']) ?>"><?= csrf_field() ?>
|
||||||
|
<?php foreach($questions as $index=>$q): ?><div class="card shadow-sm mb-3"><div class="card-body"><h5><?= $index+1 ?>. <?= esc($q['text']) ?></h5><div class="p-3 bg-light rounded"><strong>Response</strong><div class="mt-1" style="white-space:pre-wrap"><?= esc($q['answer_value'] ?? 'No answer') ?></div></div></div></div><?php endforeach; ?>
|
||||||
|
<div class="card shadow-sm mb-3"><div class="card-body"><label class="form-label fw-semibold" for="reviewCommitteeNote">Education Committee Note</label><textarea id="reviewCommitteeNote" class="form-control" name="education_committee_note" rows="5" maxlength="10000"><?= esc($assessment['education_committee_note'] ?? '') ?></textarea><div id="noteSaveNotice" class="form-text">This note remains editable and saves automatically.</div></div></div>
|
||||||
|
<button class="btn btn-success">Complete Review</button></form></div>
|
||||||
|
<script>(function(){var form=document.querySelector('form[action*="/review/"]'),field=document.getElementById('reviewCommitteeNote'),notice=document.getElementById('noteSaveNotice'),timer;if(!form||!field)return;async function save(){notice.textContent='Saving note…';try{var data=new FormData();var token=form.querySelector('input[type="hidden"]');if(token)data.append(token.name,token.value);data.append('education_committee_note',field.value);var response=await fetch('<?= site_url('administrator/assessments/attempts/'.$assessment['id'].'/note') ?>',{method:'POST',body:data,headers:{'X-Requested-With':'XMLHttpRequest','Accept':'application/json'}}),json=await response.json();if(!response.ok)throw new Error(json.message||'Save failed');if(json.csrfName&&json.csrfHash){var csrf=form.querySelector('input[name="'+json.csrfName+'"]');if(csrf)csrf.value=json.csrfHash}notice.textContent='Note saved.'}catch(e){notice.textContent='Note could not be saved. Keep this page open and try typing again.'}}field.addEventListener('input',function(){clearTimeout(timer);timer=setTimeout(save,700)});field.addEventListener('change',save)})();</script>
|
||||||
|
<?= $this->endSection() ?>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?= $this->extend('layout/management_layout') ?>
|
||||||
|
<?= $this->section('content') ?>
|
||||||
|
<div class="container py-4" style="max-width:900px"><h2>My Assessments</h2><p class="text-muted">Open assigned assessments, continue saved work, or review completed responses.</p><?= $this->include('assessments/_alerts') ?>
|
||||||
|
<div class="row g-3"><?php if(empty($assignments)): ?><div class="col-12"><div class="alert alert-info">There are no assigned assessments.</div></div><?php endif; ?>
|
||||||
|
<?php foreach($assignments as $a): ?><div class="col-md-6"><div class="card h-100 shadow-sm"><div class="card-body"><h5><?= esc($a['form_name']) ?></h5><p class="mb-1"><?= esc($a['firstname'].' '.$a['lastname']) ?></p><p class="text-muted mb-2"><?= esc($a['school_year'] ?? '—') ?></p><p><span class="badge <?= $a['status']==='graded'?'bg-success':($a['status']==='completed'?'bg-warning text-dark':'bg-primary') ?>"><?= $a['status']==='graded' ? 'Reviewed' : esc(ucwords(str_replace('_',' ',$a['status']))) ?></span></p>
|
||||||
|
<?php if(in_array($a['status'],['not_started','in_progress'],true)): ?><a class="btn btn-primary" href="<?= site_url('student/assessments/'.$a['id']) ?>"><?= $a['status']==='in_progress'?'Continue':'Start' ?></a><?php elseif($a['status']==='graded'): ?><a class="btn btn-success" href="<?= site_url('student/assessments/'.$a['id'].'/results') ?>">View Responses</a><?php else: ?><span class="text-muted">Awaiting review</span><?php endif; ?>
|
||||||
|
</div></div></div><?php endforeach; ?></div>
|
||||||
|
</div>
|
||||||
|
<?= $this->endSection() ?>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?= $this->extend('layout/management_layout') ?>
|
||||||
|
<?= $this->section('content') ?>
|
||||||
|
<div class="container-fluid py-3">
|
||||||
|
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
|
||||||
|
<div><h2 class="mb-1">New Student Assessments</h2><p class="text-muted mb-0">New students from the <?= esc($schoolYear) ?> enrollment roster.</p></div>
|
||||||
|
<a class="btn btn-outline-secondary" href="<?= site_url('administrator/assessments/forms') ?>">Assessment Forms</a>
|
||||||
|
</div>
|
||||||
|
<?= $this->include('assessments/_alerts') ?>
|
||||||
|
<div class="card shadow-sm"><div class="card-body"><div class="table-responsive">
|
||||||
|
<table id="assessmentStudentTable" class="table table-striped align-middle no-mgmt-sticky" data-no-mgmt-sticky><thead><tr><th>Student</th><th>School ID</th><th>Age</th><th>Current Class</th><th>Enrollment Status</th><th>Assessment</th></tr></thead><tbody>
|
||||||
|
<?php if(empty($new_students)): ?><tr><td colspan="6" class="text-center text-muted">No new students found for this school year.</td></tr><?php endif; ?>
|
||||||
|
<?php foreach($new_students as $student): $sid=(int)$student['id']; $a=($assessmentByStudent??[])[$sid]??null; ?><tr>
|
||||||
|
<td><?= esc($student['firstname'].' '.$student['lastname']) ?></td><td><?= esc($student['school_id']??'—') ?></td><td><?= esc($student['age']??'—') ?></td><td><?= esc($student['class_section']??'Class not Assigned') ?></td><td><?= esc(ucwords((string)($student['enrollment_status']??'unknown'))) ?></td><td>
|
||||||
|
<?php if(!$a): ?><form method="post" action="<?= site_url('administrator/assessments/students/'.$sid.'/start') ?>"><?= csrf_field() ?><button class="btn btn-sm btn-primary">Assign Assessment</button></form>
|
||||||
|
<?php elseif(in_array($a['status'],['not_started','in_progress'],true)): ?><a class="btn btn-sm btn-outline-secondary" href="<?= site_url('administrator/assessments/students/'.$sid) ?>">View Status</a><div class="small text-muted"><?= esc(ucwords(str_replace('_',' ',$a['status']))) ?></div>
|
||||||
|
<?php elseif($a['status']==='completed'): ?><a class="btn btn-sm btn-warning" href="<?= site_url('administrator/assessments/review/'.$a['id']) ?>">Review</a>
|
||||||
|
<?php else: ?><a class="btn btn-sm btn-success" href="<?= site_url('administrator/assessments/results/'.$a['id']) ?>">View Results</a><?php endif; ?>
|
||||||
|
</td></tr><?php endforeach; ?>
|
||||||
|
</tbody></table>
|
||||||
|
</div></div></div>
|
||||||
|
</div>
|
||||||
|
<?= $this->endSection() ?>
|
||||||
|
<?= $this->section('scripts') ?>
|
||||||
|
<script>$(function(){if($.fn.DataTable)$('#assessmentStudentTable').DataTable({pageLength:100,order:[[0,'asc']],fixedHeader:false})});</script>
|
||||||
|
<?= $this->endSection() ?>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?= $this->extend('layout/management_layout') ?>
|
||||||
|
<?= $this->section('content') ?>
|
||||||
|
<?php $typeLabels = ['multiple_choice' => 'Multiple choice', 'short_answer' => 'Short answer', 'true_false' => 'True / False', 'essay' => 'Essay']; ?>
|
||||||
|
<div class="container-fluid py-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3"><div><h2><?= esc($pool['name']) ?> Question Pool</h2><p class="text-muted mb-0">Add, edit, delete, or reorder questions in the shared assessment pool.</p></div><a class="btn btn-outline-secondary" href="<?= site_url('administrator/assessments/forms') ?>">Assessment Forms</a></div>
|
||||||
|
<?= $this->include('assessments/_alerts') ?>
|
||||||
|
<div class="card shadow-sm mb-4"><div class="card-header"><strong>Add a question</strong></div><div class="card-body">
|
||||||
|
<form method="post" action="<?= site_url('administrator/assessments/pools/' . $pool['id'] . '/questions') ?>"><?= csrf_field() ?>
|
||||||
|
<div class="row g-3"><div class="col-md-4"><label class="form-label">Type</label><select class="form-select question-type" name="type" required><?php foreach ($typeLabels as $value => $label): ?><option value="<?= $value ?>"><?= esc($label) ?></option><?php endforeach; ?></select></div>
|
||||||
|
<div class="col-md-8"><label class="form-label">Question</label><textarea class="form-control" name="text" rows="2" required><?= set_value('text') ?></textarea></div>
|
||||||
|
<div class="col-md-6 options-wrap"><label class="form-label">Choices <span class="text-muted">(one per line)</span></label><textarea class="form-control" name="options_text" rows="3"></textarea></div>
|
||||||
|
<div class="col-md-6"><label class="form-label">Correct answer <span class="text-muted">(reference only)</span></label><textarea class="form-control" name="correct_answer" rows="3"></textarea></div></div>
|
||||||
|
<button class="btn btn-primary mt-3">Add Question</button>
|
||||||
|
</form>
|
||||||
|
</div></div>
|
||||||
|
<?php if (empty($questions)): ?><div class="alert alert-info">This pool has no questions yet.</div><?php endif; ?>
|
||||||
|
<?php foreach ($questions as $index => $question): ?>
|
||||||
|
<?php $opts = implode("\n", (array) json_decode((string) ($question['options'] ?? '[]'), true)); ?>
|
||||||
|
<div class="card shadow-sm mb-3"><div class="card-header d-flex justify-content-between"><span><span class="badge bg-secondary me-2">#<?= $index + 1 ?></span><?= esc($typeLabels[$question['type']] ?? $question['type']) ?></span><span class="d-flex gap-1">
|
||||||
|
<form method="post" action="<?= site_url('administrator/assessments/questions/' . $question['id'] . '/move') ?>"><?= csrf_field() ?><input type="hidden" name="direction" value="up"><button class="btn btn-sm btn-outline-secondary" aria-label="Move up" <?= $index === 0 ? 'disabled' : '' ?>>↑</button></form>
|
||||||
|
<form method="post" action="<?= site_url('administrator/assessments/questions/' . $question['id'] . '/move') ?>"><?= csrf_field() ?><input type="hidden" name="direction" value="down"><button class="btn btn-sm btn-outline-secondary" aria-label="Move down" <?= $index === count($questions)-1 ? 'disabled' : '' ?>>↓</button></form>
|
||||||
|
</span></div><div class="card-body">
|
||||||
|
<form method="post" action="<?= site_url('administrator/assessments/questions/' . $question['id'] . '/update') ?>"><?= csrf_field() ?>
|
||||||
|
<div class="row g-3"><div class="col-md-4"><label class="form-label">Type</label><select class="form-select question-type" name="type"><?php foreach ($typeLabels as $value => $label): ?><option value="<?= $value ?>" <?= $question['type'] === $value ? 'selected' : '' ?>><?= esc($label) ?></option><?php endforeach; ?></select></div>
|
||||||
|
<div class="col-md-8"><label class="form-label">Question</label><textarea class="form-control" name="text" rows="2" required><?= esc($question['text']) ?></textarea></div>
|
||||||
|
<div class="col-md-6 options-wrap"><label class="form-label">Choices (one per line)</label><textarea class="form-control" name="options_text" rows="3"><?= esc($opts) ?></textarea></div>
|
||||||
|
<div class="col-md-6"><label class="form-label">Correct answer (reference only)</label><textarea class="form-control" name="correct_answer" rows="3"><?= esc($question['correct_answer'] ?? '') ?></textarea></div></div>
|
||||||
|
<button class="btn btn-sm btn-primary mt-3">Save Changes</button>
|
||||||
|
</form>
|
||||||
|
<form class="d-inline" method="post" action="<?= site_url('administrator/assessments/questions/' . $question['id'] . '/delete') ?>" onsubmit="return confirm('Delete this question?')"><?= csrf_field() ?><button class="btn btn-sm btn-outline-danger mt-2">Delete</button></form>
|
||||||
|
</div></div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<script>document.querySelectorAll('.question-type').forEach(function(s){function sync(){var w=s.closest('form').querySelector('.options-wrap');if(w)w.style.display=s.value==='multiple_choice'?'block':'none'}s.addEventListener('change',sync);sync()});</script>
|
||||||
|
<?= $this->endSection() ?>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?= $this->extend('layout/management_layout') ?>
|
||||||
|
<?= $this->section('content') ?>
|
||||||
|
<div class="container py-4" style="max-width:950px">
|
||||||
|
<div class="d-flex justify-content-end mb-3"><a class="btn btn-outline-secondary" href="<?= !empty($adminView) ? site_url('administrator/assessments/students') : site_url('student/assessments') ?>"><?= !empty($adminView) ? 'Back to Students' : 'Back to My Assessments' ?></a></div>
|
||||||
|
<div class="card shadow-sm mb-4"><div class="card-body text-center"><h2><?= esc($assessment['form_name']) ?></h2><p class="mb-1"><?= esc($assessment['firstname'].' '.$assessment['lastname']) ?></p><p class="text-muted mb-2"><?= esc($assessment['school_year'] ?? '—') ?></p><span class="badge bg-success">Reviewed</span></div></div><?= $this->include('assessments/_alerts') ?>
|
||||||
|
<?php foreach($questions as $index=>$q): ?><div class="card mb-3"><div class="card-body"><h5><?= $index+1 ?>. <?= esc($q['text']) ?></h5><p class="mb-1"><strong>Response:</strong></p><div style="white-space:pre-wrap"><?= esc($q['answer_value'] ?? 'No answer') ?></div></div></div><?php endforeach; ?>
|
||||||
|
<?php if(!empty($adminView)): ?><div class="card mb-3"><div class="card-body"><form id="resultsCommitteeNoteForm"><?= csrf_field() ?><label class="form-label fw-semibold" for="resultsCommitteeNote">Education Committee Note</label><textarea id="resultsCommitteeNote" class="form-control" name="education_committee_note" rows="5" maxlength="10000"><?= esc($assessment['education_committee_note'] ?? '') ?></textarea><div id="resultsNoteSaveNotice" class="form-text">This note remains editable and saves automatically.</div></form></div></div><?php endif; ?></div>
|
||||||
|
<?php if(!empty($adminView)): ?><script>(function(){var form=document.getElementById('resultsCommitteeNoteForm'),field=document.getElementById('resultsCommitteeNote'),notice=document.getElementById('resultsNoteSaveNotice'),timer;async function save(){notice.textContent='Saving note…';try{var response=await fetch('<?= site_url('administrator/assessments/attempts/'.$assessment['id'].'/note') ?>',{method:'POST',body:new FormData(form),headers:{'X-Requested-With':'XMLHttpRequest','Accept':'application/json'}}),json=await response.json();if(!response.ok)throw new Error(json.message||'Save failed');if(json.csrfName&&json.csrfHash){var csrf=form.querySelector('input[name="'+json.csrfName+'"]');if(csrf)csrf.value=json.csrfHash}notice.textContent='Note saved.'}catch(e){notice.textContent='Note could not be saved. Keep this page open and try typing again.'}}field.addEventListener('input',function(){clearTimeout(timer);timer=setTimeout(save,700)});field.addEventListener('change',save)})();</script><?php endif; ?>
|
||||||
|
<?= $this->endSection() ?>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?= $this->extend('layout/management_layout') ?>
|
||||||
|
<?= $this->section('content') ?>
|
||||||
|
<?php $byForm=[]; foreach($assignments as $a)$byForm[(int)$a['form_id']]=$a; ?>
|
||||||
|
<div class="container py-3" style="max-width:1000px">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3"><div><h2>Assessments: <?= esc($student['firstname'].' '.$student['lastname']) ?></h2><p class="text-muted mb-0"><?= esc($student['school_id'] ?? '') ?></p></div><a class="btn btn-outline-secondary" href="<?= site_url('administrator/assessments/students') ?>">Back to Students</a></div>
|
||||||
|
<?= $this->include('assessments/_alerts') ?>
|
||||||
|
<div class="card shadow-sm mb-4"><div class="card-header"><strong>Assign a published form</strong></div><div class="card-body">
|
||||||
|
<?php if(empty($forms)): ?><p class="text-muted mb-0">No published assessment forms are available.</p><?php else: ?><div class="table-responsive"><table class="table align-middle no-mgmt-sticky" data-no-mgmt-sticky><thead><tr><th>Form</th><th>Pool</th><th>Questions</th><th>Status / Action</th></tr></thead><tbody>
|
||||||
|
<?php foreach($forms as $form): $existing=$byForm[(int)$form['id']]??null; ?><tr><td><?= esc($form['name']) ?></td><td><?= esc($form['pool_name']) ?></td><td><?= (int)$form['question_count'] ?></td><td><?php if($existing): ?><span class="badge bg-secondary"><?= esc(ucwords(str_replace('_',' ',$existing['status']))) ?></span><?php else: ?><form method="post" action="<?= site_url('administrator/assessments/students/'.$student['id'].'/assign') ?>"><?= csrf_field() ?><input type="hidden" name="form_id" value="<?= (int)$form['id'] ?>"><button class="btn btn-sm btn-primary">Assign</button></form><?php endif; ?></td></tr><?php endforeach; ?>
|
||||||
|
</tbody></table></div><?php endif; ?>
|
||||||
|
</div></div>
|
||||||
|
<div class="card shadow-sm"><div class="card-header"><strong>Assignment history</strong></div><div class="card-body"><div class="table-responsive"><table class="table align-middle no-mgmt-sticky" data-no-mgmt-sticky><thead><tr><th>Form</th><th>School Year</th><th>Assigned</th><th>Status</th><th>Action</th></tr></thead><tbody>
|
||||||
|
<?php if(empty($assignments)): ?><tr><td colspan="5" class="text-center text-muted">Nothing assigned yet.</td></tr><?php endif; ?>
|
||||||
|
<?php foreach($assignments as $a): ?><tr><td><?= esc($a['form_name']) ?></td><td><?= esc($a['school_year'] ?? '—') ?></td><td><?= esc(local_datetime($a['assigned_at'],'m-d-Y H:i')) ?></td><td><?= $a['status']==='graded' ? 'Reviewed' : esc(ucwords(str_replace('_',' ',$a['status']))) ?></td><td><?php if($a['status']==='completed'): ?><a class="btn btn-sm btn-warning" href="<?= site_url('administrator/assessments/review/'.$a['id']) ?>">Review</a><?php elseif($a['status']==='graded'): ?><a class="btn btn-sm btn-success" href="<?= site_url('administrator/assessments/results/'.$a['id']) ?>">View Results</a><?php else: ?><span class="text-muted">View Status</span><?php endif; ?></td></tr><?php endforeach; ?>
|
||||||
|
</tbody></table></div></div></div>
|
||||||
|
</div>
|
||||||
|
<?= $this->endSection() ?>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?= $this->extend('layout/management_layout') ?>
|
||||||
|
<?= $this->section('content') ?>
|
||||||
|
<?php $preview=$preview??false; $assessment=$assessment??null; $answers=$answers??[]; ?>
|
||||||
|
<div class="container py-4" style="max-width:900px"><div class="d-flex justify-content-between align-items-center mb-3"><div><h2><?= esc($form['name']) ?></h2><p class="text-muted mb-0"><?= esc($form['school_year'] ?? '—') ?> · <?= count($questions) ?> questions</p></div><?php if($preview): ?><a class="btn btn-outline-secondary" href="<?= site_url('administrator/assessments/forms') ?>">Exit Preview</a><?php endif; ?></div>
|
||||||
|
<?php if($preview): ?><div class="alert alert-info">Preview mode — answers cannot be saved or submitted.</div><?php else: ?><div id="saveNotice" class="alert alert-light border py-2">Changes are saved as you work.</div><?php endif; ?>
|
||||||
|
<form id="assessmentForm" method="post" action="<?= !$preview ? site_url('student/assessments/'.$assessment['id'].'/submit') : '#' ?>"><?= csrf_field() ?>
|
||||||
|
<?php foreach($questions as $index=>$q): $options=(array)json_decode((string)($q['options']??'[]'),true); $value=(string)($answers[$q['id']]??''); ?>
|
||||||
|
<fieldset class="card shadow-sm mb-3" <?= $preview?'disabled':'' ?>><div class="card-body"><legend class="fs-6 mb-3"><span class="badge bg-secondary me-2"><?= $index+1 ?></span><?= esc($q['text']) ?></legend>
|
||||||
|
<?php if(in_array($q['type'],['multiple_choice','true_false'],true)): foreach($options as $option): ?><div class="form-check mb-2"><input class="form-check-input assessment-answer" type="radio" name="answers[<?= (int)$q['id'] ?>]" value="<?= esc($option) ?>" <?= $value===(string)$option?'checked':'' ?>><label class="form-check-label"><?= esc($option) ?></label></div><?php endforeach; ?>
|
||||||
|
<?php elseif($q['type']==='short_answer'): ?><input class="form-control assessment-answer" name="answers[<?= (int)$q['id'] ?>]" value="<?= esc($value) ?>">
|
||||||
|
<?php else: ?><textarea class="form-control assessment-answer" rows="6" name="answers[<?= (int)$q['id'] ?>]"><?= esc($value) ?></textarea><?php endif; ?>
|
||||||
|
</div></fieldset>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php if(!$preview): ?><div class="d-flex gap-2"><button type="button" id="saveButton" class="btn btn-outline-primary">Save Progress</button><button class="btn btn-success" onclick="return confirm('Submit this assessment? You cannot change answers after submission.')">Submit Assessment</button></div><?php endif; ?>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<?php if(!$preview): ?><script>
|
||||||
|
(function(){var form=document.getElementById('assessmentForm'),notice=document.getElementById('saveNotice'),timer;
|
||||||
|
async function save(){var fd=new FormData(form);notice.textContent='Saving…';try{var r=await fetch('<?= site_url('student/assessments/'.$assessment['id'].'/progress') ?>',{method:'POST',body:fd,headers:{'X-Requested-With':'XMLHttpRequest','Accept':'application/json'}});var j=await r.json();if(!r.ok)throw new Error(j.message||'Save failed');if(j.csrfName&&j.csrfHash){var token=form.querySelector('input[name="'+j.csrfName+'"]');if(token)token.value=j.csrfHash}notice.textContent='Progress saved.'}catch(e){notice.textContent='Could not save automatically. Use Save Progress to retry.'}}
|
||||||
|
document.querySelectorAll('.assessment-answer').forEach(function(el){el.addEventListener('change',function(){clearTimeout(timer);timer=setTimeout(save,500)});if(el.tagName==='TEXTAREA'||el.type==='text')el.addEventListener('input',function(){clearTimeout(timer);timer=setTimeout(save,1200)})});document.getElementById('saveButton').addEventListener('click',save);
|
||||||
|
})();</script><?php endif; ?>
|
||||||
|
<?= $this->endSection() ?>
|
||||||
@@ -1,230 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<title>Al Rahma Sunday School</title>
|
|
||||||
<meta content="width=device-width, initial-scale=1.0" name="viewport">
|
|
||||||
<meta content="" name="keywords">
|
|
||||||
<meta content="" name="description">
|
|
||||||
|
|
||||||
<!-- Favicon -->
|
|
||||||
<link href="<?= base_url('assets/images/favicon.ico') ?>" rel="icon">
|
|
||||||
|
|
||||||
<!-- Google Web Fonts -->
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link
|
|
||||||
href="https://fonts.googleapis.com/css2?family=Heebo:wght@400;500;600&family=Inter:wght@600&family=Lobster+Two:wght@700&display=swap"
|
|
||||||
rel="stylesheet">
|
|
||||||
|
|
||||||
<!-- Icon Font Stylesheet -->
|
|
||||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.10.0/css/all.min.css" rel="stylesheet">
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.4.1/font/bootstrap-icons.css" rel="stylesheet">
|
|
||||||
|
|
||||||
<!-- Libraries Stylesheet -->
|
|
||||||
<link href="lib/animate/animate.min.css" rel="stylesheet">
|
|
||||||
<link href="lib/owlcarousel/assets/owl.carousel.min.css" rel="stylesheet">
|
|
||||||
|
|
||||||
<!-- Customized Bootstrap Stylesheet -->
|
|
||||||
<link href="css/bootstrap.min.css" rel="stylesheet">
|
|
||||||
|
|
||||||
<!-- Template Stylesheet -->
|
|
||||||
<link href="assets/css/style.css" rel="stylesheet">
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<div class="container-xxl bg-white p-0">
|
|
||||||
<!-- Spinner Start -->
|
|
||||||
<div id="spinner"
|
|
||||||
class="show bg-white position-fixed translate-middle w-100 vh-100 top-50 start-50 d-flex align-items-center justify-content-center">
|
|
||||||
<div class="spinner-border text-primary" style="width: 3rem; height: 3rem;" role="status">
|
|
||||||
<span class="sr-only">Loading...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Spinner End -->
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Navbar Start -->
|
|
||||||
<nav class="navbar navbar-expand-lg bg-white navbar-light sticky-top px-4 px-lg-5 py-lg-0">
|
|
||||||
<a href="<?= base_url('/') ?>" class="navbar-brand d-flex align-items-center">
|
|
||||||
<img src="<?= base_url('images/logo.png') ?>" alt="Al Rahma Sunday School" style="height: 40px; width: 40px; border-radius: 50%; object-fit: contain; background-color: #fff;">
|
|
||||||
<h1 class="m-0 ms-2 green-title">Al Rahma Sunday School</h1>
|
|
||||||
</a>
|
|
||||||
<button type="button" class="navbar-toggler" data-bs-toggle="collapse" data-bs-target="#navbarCollapse">
|
|
||||||
<span class="navbar-toggler-icon"></span>
|
|
||||||
</button>
|
|
||||||
<div class="collapse navbar-collapse" id="navbarCollapse">
|
|
||||||
<div class="navbar-nav mx-auto">
|
|
||||||
<a href="<?= base_url('/') ?>" class="nav-item nav-link">Home</a>
|
|
||||||
<a href="<?= base_url('/about') ?>" class="nav-item nav-link">About Us</a>
|
|
||||||
<a href="<?= base_url('/classes') ?>" class="nav-item nav-link">Classes</a>
|
|
||||||
<div class="nav-item dropdown">
|
|
||||||
<a href="#" class="nav-link dropdown-toggle active" data-bs-toggle="dropdown">Pages</a>
|
|
||||||
<div class="dropdown-menu rounded-0 rounded-bottom border-0 shadow-sm m-0">
|
|
||||||
<a href="<?= base_url('/facility') ?>" class="dropdown-item">School Facilities</a>
|
|
||||||
<a href="team.html" class="dropdown-item">Popular Teachers</a>
|
|
||||||
<a href="call-to-action.html" class="dropdown-item active">Become A Teacher or Admins</a>
|
|
||||||
<a href="appointment.html" class="dropdown-item">Make Appointment</a>
|
|
||||||
<a href="<?= base_url('/testimonial') ?>" class="dropdown-item">Testimonial</a>
|
|
||||||
<a href="<?= base_url('/notFound') ?>" class="dropdown-item">404 Error</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<a href="<?= base_url('/contact') ?>" class="nav-item nav-link">Contact Us</a>
|
|
||||||
</div>
|
|
||||||
<a href="/register" class="btn btn-primary rounded-pill px-3 d-none d-lg-block">Register
|
|
||||||
<i class="fa fa-arrow-right ms-3"></i></a>
|
|
||||||
<a href="/login" class="btn btn-primary rounded-pill px-3 d-none d-lg-block">Login<i
|
|
||||||
class="fa fa-arrow-right ms-3"></i></a>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
<!-- Navbar End -->
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Page Header End -->
|
|
||||||
<div class="container-xxl py-5 page-header position-relative mb-5">
|
|
||||||
<div class="container py-5">
|
|
||||||
<h1 class="display-2 text-white animated slideInDown mb-4">Become A Teacher or Admins</h1>
|
|
||||||
<nav aria-label="breadcrumb animated slideInDown">
|
|
||||||
<ol class="breadcrumb">
|
|
||||||
<li class="breadcrumb-item"><a href="/">Home</a></li>
|
|
||||||
<li class="breadcrumb-item"><a href="#">Pages</a></li>
|
|
||||||
<li class="breadcrumb-item text-white active" aria-current="page">Become A Teacher or Admins</li>
|
|
||||||
</ol>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Page Header End -->
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Call To Action Start -->
|
|
||||||
<div class="container-xxl py-5">
|
|
||||||
<div class="container">
|
|
||||||
<div class="bg-light rounded">
|
|
||||||
<div class="row g-0">
|
|
||||||
<div class="col-lg-6 wow fadeIn" data-wow-delay="0.1s" style="min-height: 400px;">
|
|
||||||
<div class="position-relative h-100">
|
|
||||||
<img class="position-absolute w-100 h-100 rounded" src="images/call-to-action.jpg"
|
|
||||||
style="object-fit: cover;">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-6 wow fadeIn" data-wow-delay="0.5s">
|
|
||||||
<div class="h-100 d-flex flex-column justify-content-center p-5">
|
|
||||||
<h1 class="mb-4">Become A Teacher or Admin</h1>
|
|
||||||
<p class="mb-4">Becoming a teacher or admin at our Sunday school is a rewarding opportunity to make a meaningful impact on young lives. As a teacher, you will inspire and guide children on their spiritual journey, fostering their growth and understanding of faith. As an admin, you will play a crucial role in supporting the school's operations and ensuring a smooth and effective learning environment. Join our dedicated team and contribute to a nurturing environment that shapes the future of our community.</p>
|
|
||||||
<a class="btn btn-primary py-3 px-5" href="/register">Get Started Now<i
|
|
||||||
class="fa fa-arrow-right ms-2"></i></a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Call To Action End -->
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Footer Start -->
|
|
||||||
<div class="container-fluid bg-dark text-white-50 footer pt-5 mt-5 wow fadeIn" data-wow-delay="0.1s">
|
|
||||||
<div class="container py-5">
|
|
||||||
<div class="row g-5">
|
|
||||||
<div class="col-lg-3 col-md-6">
|
|
||||||
<h3 class="text-white mb-4">Get In Touch</h3>
|
|
||||||
<p class="mb-2"><i class="fa fa-map-marker-alt me-3"></i>5 Courthouse Lane, Chelmsford, MA 01824
|
|
||||||
</p>
|
|
||||||
<p class="mb-2"><i class="fa fa-phone-alt me-3"></i>+1 978-364-0219
|
|
||||||
0</p>
|
|
||||||
<p class="mb-2"><i class="fa fa-envelope me-3"></i>alrahma.isgl@gmail.com
|
|
||||||
</p>
|
|
||||||
<div class="d-flex pt-2">
|
|
||||||
<a class="btn btn-outline-light btn-social" href=""><i class="fab fa-twitter"></i></a>
|
|
||||||
<a class="btn btn-outline-light btn-social" href=""><i class="fab fa-facebook-f"></i></a>
|
|
||||||
<a class="btn btn-outline-light btn-social" href=""><i class="fab fa-youtube"></i></a>
|
|
||||||
<a class="btn btn-outline-light btn-social" href=""><i class="fab fa-linkedin-in"></i></a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-3 col-md-6">
|
|
||||||
<h3 class="text-white mb-4">Quick Links</h3>
|
|
||||||
<a class="btn btn-link text-white-50" href="">About Us</a>
|
|
||||||
<a class="btn btn-link text-white-50" href="">Contact Us</a>
|
|
||||||
<a class="btn btn-link text-white-50" href="">Our Services</a>
|
|
||||||
<a class="btn btn-link text-white-50" href="">Privacy Policy</a>
|
|
||||||
<a class="btn btn-link text-white-50" href="">Terms & Condition</a>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-3 col-md-6">
|
|
||||||
<h3 class="text-white mb-4">Photo Gallery</h3>
|
|
||||||
<div class="row g-2 pt-2">
|
|
||||||
<div class="col-4">
|
|
||||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-1.jpg') ?>" alt="">
|
|
||||||
</div>
|
|
||||||
<div class="col-4">
|
|
||||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-2.jpg') ?>" alt="">
|
|
||||||
</div>
|
|
||||||
<div class="col-4">
|
|
||||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-3.jpg') ?>" alt="">
|
|
||||||
</div>
|
|
||||||
<div class="col-4">
|
|
||||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-4.jpg') ?>" alt="">
|
|
||||||
</div>
|
|
||||||
<div class="col-4">
|
|
||||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-5.jpg') ?>" alt="">
|
|
||||||
</div>
|
|
||||||
<div class="col-4">
|
|
||||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-6.jpg') ?>" alt="">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-3 col-md-6">
|
|
||||||
<h3 class="text-white mb-4">Newsletter</h3>
|
|
||||||
<p>Our newsletter is your gateway to staying informed and connected with our Sunday school community. </p>
|
|
||||||
<div class="position-relative mx-auto" style="max-width: 400px;">
|
|
||||||
<input class="form-control bg-transparent w-100 py-3 ps-4 pe-5" type="text"
|
|
||||||
placeholder="Your email">
|
|
||||||
<button type="button"
|
|
||||||
class="btn btn-primary py-2 position-absolute top-0 end-0 mt-2 me-2">SignUp</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="container">
|
|
||||||
<div class="copyright">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6 text-center text-md-start mb-3 mb-md-0">
|
|
||||||
© <a class="border-bottom" href="#">Al Rahma Sunday School by ISGL</a>, All Right
|
|
||||||
Reserved.
|
|
||||||
|
|
||||||
<!--/*** This template is free as long as you keep the footer author’s credit link/attribution link/backlink. If you'd like to use the template without the footer author’s credit link/attribution link/backlink, you can purchase the Credit Removal License from "https://htmlcodex.com/credit-removal". Thank you for your support. ***/-->
|
|
||||||
Designed By <a class="border-bottom" href="https://htmlcodex.com">HTML Codex</a>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6 text-center text-md-end">
|
|
||||||
<div class="footer-menu">
|
|
||||||
<a href="">Home</a>
|
|
||||||
<a href="">Cookies</a>
|
|
||||||
<a href="">Help</a>
|
|
||||||
<a href="">FQAs</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Footer End -->
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Back to Top -->
|
|
||||||
<a href="#" class="btn btn-lg btn-primary btn-lg-square back-to-top"><i class="bi bi-arrow-up"></i></a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- JavaScript Libraries -->
|
|
||||||
<script src="https://code.jquery.com/jquery-3.4.1.min.js"></script>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0/dist/js/bootstrap.bundle.min.js"></script>
|
|
||||||
<script src="lib/wow/wow.min.js"></script>
|
|
||||||
<script src="lib/easing/easing.min.js"></script>
|
|
||||||
<script src="lib/waypoints/waypoints.min.js"></script>
|
|
||||||
<script src="lib/owlcarousel/owl.carousel.min.js"></script>
|
|
||||||
|
|
||||||
<!-- Template Javascript -->
|
|
||||||
<script src="js/main.js"></script>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Careers | Al Rahma Sunday School</title>
|
||||||
|
<meta content="width=device-width, initial-scale=1.0" name="viewport">
|
||||||
|
<meta content="Volunteer careers and openings at Al Rahma Sunday School" name="description">
|
||||||
|
|
||||||
|
<link href="<?= base_url('assets/images/favicon.ico') ?>" rel="icon">
|
||||||
|
<link href="<?= base_url('assets/boot_css/bootstrap.min.css') ?>" rel="stylesheet">
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Amiri:wght@400;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.4.1/font/bootstrap-icons.css" rel="stylesheet">
|
||||||
|
<link href="<?= base_url('css/style.css') ?>" rel="stylesheet">
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--ink: #16262B;
|
||||||
|
--ink-soft: #3E4E51;
|
||||||
|
--paper: #F3EFE3;
|
||||||
|
--paper-deep: #E9E3D2;
|
||||||
|
--sage: #E7EEE6;
|
||||||
|
--primary: #0B5D52;
|
||||||
|
--primary-dark: #073F38;
|
||||||
|
--accent: #C6963C;
|
||||||
|
--accent-soft: #EFE1BC;
|
||||||
|
--line: rgba(22, 38, 43, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
color: var(--ink);
|
||||||
|
background-color: var(--paper);
|
||||||
|
overflow-x: hidden;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
font-family: 'Amiri', serif;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ink);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule {
|
||||||
|
width: 56px;
|
||||||
|
height: 3px;
|
||||||
|
background-color: var(--accent);
|
||||||
|
border: none;
|
||||||
|
margin: 0 0 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-brand {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
background-color: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 0.85rem 1.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-brand:hover {
|
||||||
|
background-color: var(--primary-dark);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-brand-sm {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background-color: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 0.6rem 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-brand-sm:hover {
|
||||||
|
background-color: var(--primary-dark);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-brand-outline {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
border: 1.5px solid var(--ink);
|
||||||
|
color: var(--ink);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.5rem 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-brand-outline:hover {
|
||||||
|
background-color: var(--ink);
|
||||||
|
color: var(--paper);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-brand-danger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
border: 1.5px solid #a33;
|
||||||
|
color: #a33;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.5rem 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-brand-danger:hover {
|
||||||
|
background-color: #a33;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navbar */
|
||||||
|
.navbar {
|
||||||
|
background-color: var(--paper) !important;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
padding-top: 0.6rem;
|
||||||
|
padding-bottom: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar .nav-link {
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar .nav-link:hover {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-text {
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hero */
|
||||||
|
.careers-hero {
|
||||||
|
position: relative;
|
||||||
|
background: linear-gradient(rgba(7, 63, 56, .88), rgba(7, 63, 56, .88)), url("<?= base_url('images/call-to-action.jpg') ?>") center center / cover no-repeat;
|
||||||
|
min-height: 320px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--paper);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.careers-hero::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
opacity: 0.14;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='84' height='84' viewBox='0 0 84 84'%3E%3Cg fill='none' stroke='%23C6963C' stroke-width='1'%3E%3Cpath d='M42 2 L62 22 L42 42 L22 22 Z'/%3E%3Cpath d='M0 42 L20 22 L40 42 L20 62 Z'/%3E%3Cpath d='M42 42 L62 22 L84 42 L62 62 Z'/%3E%3Cpath d='M42 42 L62 62 L42 84 L22 62 Z'/%3E%3C/g%3E%3C/svg%3E");
|
||||||
|
background-size: 84px 84px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.careers-hero .container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.careers-hero h1 {
|
||||||
|
color: var(--paper);
|
||||||
|
font-size: 2.6rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.careers-hero p.lead {
|
||||||
|
color: #DCE6DD;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
max-width: 56ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Culture strip */
|
||||||
|
.culture-item {
|
||||||
|
background: #ffffff;
|
||||||
|
border-top: 3px solid var(--accent);
|
||||||
|
padding: 1.75rem 1.5rem;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.culture-item h3 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.culture-item p {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Openings */
|
||||||
|
.openings-section {
|
||||||
|
background-color: var(--sage);
|
||||||
|
}
|
||||||
|
|
||||||
|
.openings-intro h2 {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opening-card {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: #ffffff;
|
||||||
|
height: 100%;
|
||||||
|
padding: 1.75rem 1.75rem 3rem;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opening-new-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
border-radius: 0 0 0 8px;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--primary-dark);
|
||||||
|
border: 1px solid rgba(198, 150, 60, 0.45);
|
||||||
|
padding: 0.25rem 0.65rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opening-new-badge i {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opening-card h3 {
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 1.3rem;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opening-meta {
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opening-clicks {
|
||||||
|
position: absolute;
|
||||||
|
right: 1.25rem;
|
||||||
|
bottom: 1rem;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opening-list {
|
||||||
|
padding-left: 1.1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.opening-list li {
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opening-list li::marker {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-light sticky-top px-4 px-lg-5">
|
||||||
|
<a href="<?= base_url('/') ?>" class="navbar-brand d-flex align-items-center">
|
||||||
|
<img src="<?= base_url('images/logo.png') ?>" alt="Al Rahma Sunday School" style="height: 56px; width: 56px; border-radius: 50%; object-fit: contain; background-color: #fff;">
|
||||||
|
</a>
|
||||||
|
<button type="button" class="navbar-toggler" data-bs-toggle="collapse" data-bs-target="#navbarCollapse">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<div class="collapse navbar-collapse" id="navbarCollapse">
|
||||||
|
<div class="navbar-nav mx-auto"></div>
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<?php if (session()->get('is_logged_in')): ?>
|
||||||
|
<span class="navbar-text me-3">Welcome, <?= esc(session()->get('user_name')) ?></span>
|
||||||
|
<a href="<?= base_url('/dashboard') ?>" class="btn-brand-outline me-2">Dashboard <i class="fa fa-tachometer-alt"></i></a>
|
||||||
|
<a href="<?= base_url('/logout') ?>" class="btn-brand-danger">Logout <i class="fa fa-sign-out-alt"></i></a>
|
||||||
|
<?php else: ?>
|
||||||
|
<a href="<?= base_url('/login') ?>" class="btn-brand-outline me-2">Login <i class="fa fa-arrow-right"></i></a>
|
||||||
|
<a href="<?= base_url('/register') ?>" class="btn-brand-sm">Register <i class="fa fa-arrow-right"></i></a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<header class="careers-hero">
|
||||||
|
<div class="container py-5">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<h1>Open Positions</h1>
|
||||||
|
<p class="lead mb-4">Serve with Al Rahma Sunday School and help students grow in Quran, Arabic, Islamic Studies and character.</p>
|
||||||
|
<a class="btn-brand" href="#openings">View Openings <i class="fa fa-arrow-down"></i></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section class="container-xxl py-5">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="culture-item">
|
||||||
|
<h3>Faith-Centered Work</h3>
|
||||||
|
<p>Support a school community focused on Islamic learning, strong character and service.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="culture-item">
|
||||||
|
<h3>Supportive Team</h3>
|
||||||
|
<p>Collaborate with teachers, assistants and administrators committed to student success.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="culture-item">
|
||||||
|
<h3>Sunday Schedule</h3>
|
||||||
|
<p>Volunteer in a structured weekend program serving families in the Greater Lowell community.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="openings" class="openings-section py-5">
|
||||||
|
<div class="container">
|
||||||
|
<div class="text-center openings-intro mb-5">
|
||||||
|
<hr class="rule mx-auto">
|
||||||
|
<h2>Current Open Positions</h2>
|
||||||
|
<p class="mb-0">Review the available roles below and apply by creating an account.</p>
|
||||||
|
<p class="mb-0 fw-bold">All positions are non-paid and take place at ISGL: 5 Courthouse Lane, Chelmsford, MA 01824</p>
|
||||||
|
</div>
|
||||||
|
<div class="row g-4">
|
||||||
|
<?php if (!empty($positions)): ?>
|
||||||
|
<?php foreach ($positions as $position): ?>
|
||||||
|
<?php
|
||||||
|
$postedAt = !empty($position['posted_at']) ? strtotime((string) $position['posted_at']) : false;
|
||||||
|
$isNewPosition = $postedAt !== false && $postedAt >= strtotime('-14 days');
|
||||||
|
?>
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<article class="opening-card">
|
||||||
|
<?php if ($isNewPosition): ?>
|
||||||
|
<span class="opening-new-badge"><i class="fa fa-star" aria-hidden="true"></i> New</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
<h3><?= esc($position['title']) ?></h3>
|
||||||
|
<div class="opening-meta">
|
||||||
|
<?= esc($position['department'] ?? '') ?>
|
||||||
|
<?php if (!empty($position['location'])): ?> · <?= esc($position['location']) ?><?php endif; ?>
|
||||||
|
<?php if (!empty($position['employment_type'])): ?> · <?= esc($position['employment_type']) ?><?php endif; ?>
|
||||||
|
<?php if (!empty($position['posted_at'])): ?><br><?= esc(date('M j, Y', strtotime((string) $position['posted_at']))) ?><?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php if (!empty($position['description'])): ?>
|
||||||
|
<?php
|
||||||
|
$descriptionPreview = trim(preg_replace('/\s+/', ' ', (string) $position['description']));
|
||||||
|
$descriptionWords = preg_split('/\s+/', $descriptionPreview) ?: [];
|
||||||
|
if (count($descriptionWords) > 32) {
|
||||||
|
$descriptionPreview = implode(' ', array_slice($descriptionWords, 0, 32)) . '...';
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<p class="mb-4"><?= esc($descriptionPreview) ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
<a class="btn-brand-sm" href="<?= site_url('careers/' . $position['position_id'] . '/details') ?>">View Details <i class="fa fa-arrow-right"></i></a>
|
||||||
|
<div class="opening-clicks"><?= esc(number_format((int) ($position['details_click_count'] ?? 0))) ?> views</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="col-lg-8 mx-auto">
|
||||||
|
<article class="opening-card text-center">
|
||||||
|
<h3>No open positions right now</h3>
|
||||||
|
<p class="mb-0">Please check back for future opportunities to serve with Al Rahma Sunday School.</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<?php include(__DIR__ . '/partials/footer.php'); ?>
|
||||||
|
|
||||||
|
<script src="https://code.jquery.com/jquery-3.4.1.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -1,231 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<title>Al Rahma Sunday School</title>
|
|
||||||
<meta content="width=device-width, initial-scale=1.0" name="viewport">
|
|
||||||
<meta content="" name="keywords">
|
|
||||||
<meta content="" name="description">
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Favicon -->
|
|
||||||
<link href="<?= base_url('assets/images/favicon.ico') ?>" rel="icon">
|
|
||||||
|
|
||||||
<!-- Google Web Fonts -->
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Heebo:wght@400;500;600&family=Inter:wght@600&family=Lobster+Two:wght@700&display=swap" rel="stylesheet">
|
|
||||||
|
|
||||||
<!-- Icon Font Stylesheet -->
|
|
||||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.10.0/css/all.min.css" rel="stylesheet">
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.4.1/font/bootstrap-icons.css" rel="stylesheet">
|
|
||||||
|
|
||||||
<!-- Libraries Stylesheet -->
|
|
||||||
<link href="<?= base_url('lib/animate/animate.min.css') ?>" rel="stylesheet">
|
|
||||||
<link href="<?= base_url('lib/owlcarousel/assets/owl.carousel.min.css') ?>" rel="stylesheet">
|
|
||||||
|
|
||||||
<!-- Customized Bootstrap Stylesheet -->
|
|
||||||
<link href="<?= base_url('css/bootstrap.min.css') ?>" rel="stylesheet">
|
|
||||||
|
|
||||||
<!-- Template Stylesheet -->
|
|
||||||
<link href="<?= base_url('css/style.css') ?>" rel="stylesheet">
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<div class="container-xxl bg-white p-0">
|
|
||||||
<!-- Spinner Start -->
|
|
||||||
<div id="spinner" class="show bg-white position-fixed translate-middle w-100 vh-100 top-50 start-50 d-flex align-items-center justify-content-center">
|
|
||||||
<div class="spinner-border text-primary" style="width: 3rem; height: 3rem;" role="status">
|
|
||||||
<span class="sr-only">Loading...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Spinner End -->
|
|
||||||
|
|
||||||
<!-- Navbar Start -->
|
|
||||||
<nav class="navbar navbar-expand-lg bg-white navbar-light sticky-top px-4 px-lg-5 py-lg-0">
|
|
||||||
<a href="<?= base_url('/') ?>" class="navbar-brand d-flex align-items-center">
|
|
||||||
<img src="<?= base_url('images/logo.png') ?>" alt="Al Rahma Sunday School" style="height: 40px; width: 40px; border-radius: 50%; object-fit: contain; background-color: #fff;">
|
|
||||||
<h1 class="m-0 ms-2 green-title">Al Rahma Sunday School</h1>
|
|
||||||
</a>
|
|
||||||
<button type="button" class="navbar-toggler" data-bs-toggle="collapse" data-bs-target="#navbarCollapse">
|
|
||||||
<span class="navbar-toggler-icon"></span>
|
|
||||||
</button>
|
|
||||||
<div class="collapse navbar-collapse" id="navbarCollapse">
|
|
||||||
<div class="navbar-nav mx-auto">
|
|
||||||
<a href="<?= base_url('/') ?>" class="nav-item nav-link">Home</a>
|
|
||||||
<a href="<?= base_url('/about') ?>" class="nav-item nav-link">About Us</a>
|
|
||||||
<a href="<?= base_url('/classes') ?>" class="nav-item nav-link">Classes</a>
|
|
||||||
<a href="<?= base_url('/contact') ?>" class="nav-item nav-link">Contact Us</a>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex">
|
|
||||||
<a href="/user/login" class="btn btn-primary rounded-pill px-3">Login<i class="fa fa-arrow-right ms-3"></i></a>
|
|
||||||
<a href="/register" class="btn btn-primary rounded-pill px-3 me-2">Register<i class="fa fa-arrow-right ms-3"></i></a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</nav>
|
|
||||||
<!-- Navbar End -->
|
|
||||||
|
|
||||||
<!-- Page Header End -->
|
|
||||||
<div class="container-xxl py-5 page-header position-relative mb-5">
|
|
||||||
<div class="container py-5">
|
|
||||||
<h1 class="display-2 text-white animated slideInDown mb-4">Classes</h1>
|
|
||||||
<nav aria-label="breadcrumb animated slideInDown">
|
|
||||||
<ol class="breadcrumb">
|
|
||||||
<li class="breadcrumb-item text-green"><a href="/">Home</a></li>
|
|
||||||
<li class="breadcrumb-item text-green"><a href="#">Pages</a></li>
|
|
||||||
<li class="breadcrumb-item text-green active" aria-current="page">Classes</li>
|
|
||||||
</ol>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Page Header End -->
|
|
||||||
|
|
||||||
<!-- Classes Start -->
|
|
||||||
<div class="container-xxl py-5">
|
|
||||||
<div class="container">
|
|
||||||
<div class="text-center mx-auto mb-5 wow fadeInUp" data-wow-delay="0.1s" style="max-width: 600px;">
|
|
||||||
<h1 class="mb-3">School Classes</h1>
|
|
||||||
<p>Our school offers classes for students from grades 1 to 10, providing a comprehensive and engaging curriculum that fosters academic and personal growth. Additionally, our youth program offers enriching activities and mentorship for older students, helping them develop leadership skills and a sense of community. Together, these programs ensure a well-rounded education and support for every stage of your child's development.</p>
|
|
||||||
</div>
|
|
||||||
<div class="row g-4">
|
|
||||||
<div class="col-lg-4 col-md-6 wow fadeInUp" data-wow-delay="0.1s">
|
|
||||||
<div class="classes-item">
|
|
||||||
<div class="bg-light rounded-circle w-75 mx-auto p-3">
|
|
||||||
<img class="img-fluid rounded-circle" src="<?= base_url('assets/images/classes-1.jpg') ?>" alt="">
|
|
||||||
</div>
|
|
||||||
<div class="bg-light rounded p-4 pt-5 mt-n5">
|
|
||||||
<a class="d-block text-center h3 mt-3 mb-4" href="">Quran Learning</a>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-4 col-md-6 wow fadeInUp" data-wow-delay="0.3s">
|
|
||||||
<div class="classes-item">
|
|
||||||
<div class="bg-light rounded-circle w-75 mx-auto p-3">
|
|
||||||
<img class="img-fluid rounded-circle" src="<?= base_url('assets/images/classes-2.jpg') ?>" alt="">
|
|
||||||
</div>
|
|
||||||
<div class="bg-light rounded p-4 pt-5 mt-n5">
|
|
||||||
<a class="d-block text-center h3 mt-3 mb-4" href="">Islamic Studies</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-4 col-md-6 wow fadeInUp" data-wow-delay="0.5s">
|
|
||||||
<div class="classes-item">
|
|
||||||
<div class="bg-light rounded-circle w-75 mx-auto p-3">
|
|
||||||
<img class="img-fluid rounded-circle" src="<?= base_url('assets/images/classes-3.jpg') ?>" alt="">
|
|
||||||
</div>
|
|
||||||
<div class="bg-light rounded p-4 pt-5 mt-n5">
|
|
||||||
<a class="d-block text-center h3 mt-3 mb-4" href="">Arabic Learning</a>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Classes End -->
|
|
||||||
|
|
||||||
<!-- Footer Start -->
|
|
||||||
<div class="container-fluid bg-dark text-white-50 footer pt-5 mt-5 wow fadeIn" data-wow-delay="0.1s">
|
|
||||||
<div class="container py-5">
|
|
||||||
<div class="row g-5">
|
|
||||||
<div class="col-lg-3 col-md-6">
|
|
||||||
<h3 class="text-white mb-4">Get In Touch</h3>
|
|
||||||
<p class="mb-2"><i class="fa fa-map-marker-alt me-3"></i>5 Courthouse Lane, Chelmsford, MA 01824
|
|
||||||
</p>
|
|
||||||
<p class="mb-2"><i class="fa fa-phone-alt me-3"></i>+1 978-364-0219
|
|
||||||
</p>
|
|
||||||
<p class="mb-2"><i class="fa fa-envelope me-3"></i>alrahma.isgl@gmail.com
|
|
||||||
</p>
|
|
||||||
<div class="d-flex pt-2">
|
|
||||||
<a class="btn btn-outline-light btn-social" href=""><i class="fab fa-twitter"></i></a>
|
|
||||||
<a class="btn btn-outline-light btn-social" href=""><i class="fab fa-facebook-f"></i></a>
|
|
||||||
<a class="btn btn-outline-light btn-social" href=""><i class="fab fa-youtube"></i></a>
|
|
||||||
<a class="btn btn-outline-light btn-social" href=""><i class="fab fa-linkedin-in"></i></a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-3 col-md-6">
|
|
||||||
<h3 class="text-white mb-4">Quick Links</h3>
|
|
||||||
<a class="btn btn-link text-white-50" href="<?= base_url('about.html') ?>">About Us</a>
|
|
||||||
|
|
||||||
<a class="btn btn-link text-white-50" href="<?= base_url('contact.html') ?>">Classes</a>
|
|
||||||
<a class="btn btn-link text-white-50" href="<?= base_url('services.html') ?>">Our Services</a>
|
|
||||||
<a class="btn btn-link text-white-50" href="<?= base_url('privacy.html') ?>">Privacy Policy</a>
|
|
||||||
<a class="btn btn-link text-white-50" href="<?= base_url('terms.html') ?>">Terms & Condition</a>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-3 col-md-6">
|
|
||||||
<h3 class="text-white mb-4">Photo Gallery</h3>
|
|
||||||
<div class="row g-2 pt-2">
|
|
||||||
<div class="col-4">
|
|
||||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-1.jpg') ?>" alt="">
|
|
||||||
</div>
|
|
||||||
<div class="col-4">
|
|
||||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-2.jpg') ?>" alt="">
|
|
||||||
</div>
|
|
||||||
<div class="col-4">
|
|
||||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-3.jpg') ?>" alt="">
|
|
||||||
</div>
|
|
||||||
<div class="col-4">
|
|
||||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-4.jpg') ?>" alt="">
|
|
||||||
</div>
|
|
||||||
<div class="col-4">
|
|
||||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-5.jpg') ?>" alt="">
|
|
||||||
</div>
|
|
||||||
<div class="col-4">
|
|
||||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-6.jpg') ?>" alt="">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-3 col-md-6">
|
|
||||||
<h3 class="text-white mb-4">Newsletter</h3>
|
|
||||||
<p>Our newsletter is your gateway to staying informed and connected with our Sunday school community. </p>
|
|
||||||
<div class="position-relative mx-auto" style="max-width: 400px;">
|
|
||||||
<input class="form-control bg-transparent w-100 py-3 ps-4 pe-5" type="text" placeholder="Your email">
|
|
||||||
<button type="button" class="btn btn-primary py-2 position-absolute top-0 end-0 mt-2 me-2">SignUp</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="container">
|
|
||||||
<div class="copyright">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6 text-center text-md-start mb-3 mb-md-0">
|
|
||||||
© <a class="border-bottom" href="#">Al Rahma Sunday School by ISGL</a>, All Right Reserved.
|
|
||||||
|
|
||||||
<!--/*** This template is free as long as you keep the footer author’s credit link/attribution link/backlink. If you'd like to use the template without the footer author’s credit link/attribution link/backlink, you can purchase the Credit Removal License from "https://htmlcodex.com/credit-removal". Thank you for your support. ***/-->
|
|
||||||
Designed By <a class="border-bottom" href="https://htmlcodex.com">HTML Codex</a>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6 text-center text-md-end">
|
|
||||||
<div class="footer-menu">
|
|
||||||
<a href="<?= base_url('/') ?>" class="nav-item nav-link">Home</a>
|
|
||||||
<a href="#">Cookies</a>
|
|
||||||
<a href="#">Help</a>
|
|
||||||
<a href="#">FQAs</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Footer End -->
|
|
||||||
|
|
||||||
<!-- Back to Top -->
|
|
||||||
<a href="#" class="btn btn-lg btn-primary btn-lg-square back-to-top"><i class="bi bi-arrow-up"></i></a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- JavaScript Libraries -->
|
|
||||||
<script src="https://code.jquery.com/jquery-3.4.1.min.js"></script>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0/dist/js/bootstrap.bundle.min.js"></script>
|
|
||||||
<script src="<?= base_url('lib/wow/wow.min.js') ?>"></script>
|
|
||||||
<script src="<?= base_url('lib/easing/easing.min.js') ?>"></script>
|
|
||||||
<script src="<?= base_url('lib/waypoints/waypoints.min.js') ?>"></script>
|
|
||||||
<script src="<?= base_url('lib/owlcarousel/owl.carousel.min.js') ?>"></script>
|
|
||||||
|
|
||||||
<!-- Template Javascript -->
|
|
||||||
<script src="<?= base_url('js/main.js') ?>"></script>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
@@ -1,283 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<title>Al Rahma Sunday School</title>
|
|
||||||
<meta content="width=device-width, initial-scale=1.0" name="viewport">
|
|
||||||
<meta content="" name="keywords">
|
|
||||||
<meta content="" name="description">
|
|
||||||
|
|
||||||
<!-- Favicon -->
|
|
||||||
<link href="<?= base_url('assets/images/favicon.ico') ?>" rel="icon">
|
|
||||||
|
|
||||||
<!-- Google Web Fonts -->
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Heebo:wght@400;500;600&family=Inter:wght@600&family=Lobster+Two:wght@700&display=swap" rel="stylesheet">
|
|
||||||
|
|
||||||
<!-- Icon Font Stylesheet -->
|
|
||||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.10.0/css/all.min.css" rel="stylesheet">
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.4.1/font/bootstrap-icons.css" rel="stylesheet">
|
|
||||||
|
|
||||||
<!-- Libraries Stylesheet -->
|
|
||||||
<link href="<?= base_url('lib/animate/animate.min.css') ?>" rel="stylesheet">
|
|
||||||
<link href="<?= base_url('lib/owlcarousel/assets/owl.carousel.min.css') ?>" rel="stylesheet">
|
|
||||||
|
|
||||||
<!-- Customized Bootstrap Stylesheet -->
|
|
||||||
<link href="<?= base_url('css/bootstrap.min.css') ?>" rel="stylesheet">
|
|
||||||
|
|
||||||
<!-- Template Stylesheet -->
|
|
||||||
<link href="<?= base_url('css/style.css') ?>" rel="stylesheet">
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<div class="container-xxl bg-white p-0">
|
|
||||||
<!-- Spinner Start -->
|
|
||||||
<div id="spinner" class="show bg-white position-fixed translate-middle w-100 vh-100 top-50 start-50 d-flex align-items-center justify-content-center">
|
|
||||||
<div class="spinner-border text-primary" style="width: 3rem; height: 3rem;" role="status">
|
|
||||||
<span class="sr-only">Loading...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Spinner End -->
|
|
||||||
|
|
||||||
<!-- Navbar Start -->
|
|
||||||
<nav class="navbar navbar-expand-lg bg-white navbar-light sticky-top px-4 px-lg-5 py-lg-0">
|
|
||||||
<a href="<?= base_url('/') ?>" class="navbar-brand d-flex align-items-center">
|
|
||||||
<img src="<?= base_url('images/logo.png') ?>" alt="Al Rahma Sunday School" style="height: 40px; width: 40px; border-radius: 50%; object-fit: contain; background-color: #fff;">
|
|
||||||
<h1 class="m-0 ms-2 green-title">Al Rahma Sunday School</h1>
|
|
||||||
</a>
|
|
||||||
<button type="button" class="navbar-toggler" data-bs-toggle="collapse" data-bs-target="#navbarCollapse">
|
|
||||||
<span class="navbar-toggler-icon"></span>
|
|
||||||
</button>
|
|
||||||
<div class="collapse navbar-collapse" id="navbarCollapse">
|
|
||||||
<div class="navbar-nav mx-auto">
|
|
||||||
<a href="<?= base_url('/') ?>" class="nav-item nav-link">Home</a>
|
|
||||||
<a href="<?= base_url('/about') ?>" class="nav-item nav-link">About Us</a>
|
|
||||||
<a href="<?= base_url('/classes') ?>" class="nav-item nav-link">Classes</a>
|
|
||||||
<a href="<?= base_url('/contact') ?>" class="nav-item nav-link">Contact Us</a>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex">
|
|
||||||
<a href="/user/login" class="btn btn-primary rounded-pill px-3">Login<i class="fa fa-arrow-right ms-3"></i></a>
|
|
||||||
<a href="/register" class="btn btn-primary rounded-pill px-3 me-2">Register<i class="fa fa-arrow-right ms-3"></i></a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</nav>
|
|
||||||
<!-- Navbar End -->
|
|
||||||
|
|
||||||
<!-- Page Header Start -->
|
|
||||||
<div class="container-xxl py-5 page-header position-relative mb-5">
|
|
||||||
<div class="container py-5">
|
|
||||||
<h1 class="display-2 text-white animated slideInDown mb-4">Contact Us</h1>
|
|
||||||
<nav aria-label="breadcrumb animated slideInDown">
|
|
||||||
<ol class="breadcrumb">
|
|
||||||
<li class="breadcrumb-item text-green"><a href="/">Home</a></li>
|
|
||||||
<li class="breadcrumb-item text-green"><a href="#">Pages</a></li>
|
|
||||||
<li class="breadcrumb-item text-green active" aria-current="page">Contact Us</li>
|
|
||||||
</ol>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Page Header End -->
|
|
||||||
|
|
||||||
<!-- Contact Start -->
|
|
||||||
<div class="container-xxl py-5">
|
|
||||||
<div class="container">
|
|
||||||
<div class="text-center mx-auto mb-5 wow fadeInUp" data-wow-delay="0.1s" style="max-width: 600px;">
|
|
||||||
<h1 class="mb-3">Get In Touch</h1>
|
|
||||||
<p>Get in touch with us today to learn more about our programs and how you can get involved.</p>
|
|
||||||
</div>
|
|
||||||
<div class="row g-4 mb-5">
|
|
||||||
<div class="col-md-6 col-lg-4 text-center wow fadeInUp" data-wow-delay="0.1s">
|
|
||||||
<div class="bg-light rounded-circle d-inline-flex align-items-center justify-content-center mb-4"
|
|
||||||
style="width: 75px; height: 75px;">
|
|
||||||
<i class="fa fa-map-marker-alt fa-2x text-primary"></i>
|
|
||||||
</div>
|
|
||||||
<h6>5 Courthouse Lane, Chelmsford, MA 01824</h6>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6 col-lg-4 text-center wow fadeInUp" data-wow-delay="0.3s">
|
|
||||||
<div class="bg-light rounded-circle d-inline-flex align-items-center justify-content-center mb-4"
|
|
||||||
style="width: 75px; height: 75px;">
|
|
||||||
<i class="fa fa-envelope-open fa-2x text-primary"></i>
|
|
||||||
</div>
|
|
||||||
<h6>alrahma.isgl@gmail.com</h6>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6 col-lg-4 text-center wow fadeInUp" data-wow-delay="0.5s">
|
|
||||||
<div class="bg-light rounded-circle d-inline-flex align-items-center justify-content-center mb-4"
|
|
||||||
style="width: 75px; height: 75px;">
|
|
||||||
<i class="fa fa-phone-alt fa-2x text-primary"></i>
|
|
||||||
</div>
|
|
||||||
<h6>+1 978-364-0219</h6>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="bg-light rounded">
|
|
||||||
<div class="row g-0">
|
|
||||||
<div class="col-lg-6 wow fadeIn" data-wow-delay="0.1s">
|
|
||||||
<div class="h-100 d-flex flex-column justify-content-center p-5">
|
|
||||||
<form id="contactForm">
|
|
||||||
<div class="row g-3">
|
|
||||||
<div class="col-sm-6">
|
|
||||||
<div class="form-floating">
|
|
||||||
<input type="text" class="form-control border-0" id="name" name="name" placeholder="Your Name">
|
|
||||||
<label for="name">Your Name</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-sm-6">
|
|
||||||
<div class="form-floating">
|
|
||||||
<input type="email" class="form-control border-0" id="email" name="email" placeholder="Your Email">
|
|
||||||
<label for="email">Your Email</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="form-floating">
|
|
||||||
<input type="text" class="form-control border-0" id="subject" name="subject" placeholder="Subject">
|
|
||||||
<label for="subject">Subject</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="form-floating">
|
|
||||||
<textarea class="form-control border-0" placeholder="Leave a message here" id="message" name="message" style="height: 100px"></textarea>
|
|
||||||
<label for="message">Message</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-12">
|
|
||||||
<button class="btn btn-primary w-100 py-3" type="submit">Send Message</button>
|
|
||||||
</div>
|
|
||||||
<div id="formResponse" class="mt-3"></div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-6 wow fadeIn" data-wow-delay="0.5s" style="min-height: 400px;">
|
|
||||||
<div class="position-relative h-100">
|
|
||||||
<iframe class="position-relative rounded w-100 h-100"
|
|
||||||
src="https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d2949.902891223497!2d-71.3606721845427!3d42.59682637917086!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x89e3a489bb344a85%3A0xeaba1b29330726cb!2s5%20Courthouse%20Ln%2C%20Chelmsford%2C%20MA%2001824%2C%20USA!5e0!3m2!1sen!2sbd!4v1627993075161!5m2!1sen!2sbd"
|
|
||||||
frameborder="0" style="min-height: 400px; border:0;" allowfullscreen=""
|
|
||||||
aria-hidden="false" tabindex="0"></iframe>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Contact End -->
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Footer Start -->
|
|
||||||
<div class="container-fluid bg-dark text-white-50 footer pt-5 mt-5 wow fadeIn" data-wow-delay="0.1s">
|
|
||||||
<div class="container py-5">
|
|
||||||
<div class="row g-5">
|
|
||||||
<div class="col-lg-3 col-md-6">
|
|
||||||
<h3 class="text-white mb-4">Get In Touch</h3>
|
|
||||||
<p class="mb-2"><i class="fa fa-map-marker-alt me-3"></i>5 Courthouse Lane, Chelmsford, MA 01824
|
|
||||||
</p>
|
|
||||||
<p class="mb-2"><i class="fa fa-phone-alt me-3"></i>+1 978-364-0219
|
|
||||||
</p>
|
|
||||||
<p class="mb-2"><i class="fa fa-envelope me-3"></i>alrahma.isgl@gmail.com
|
|
||||||
</p>
|
|
||||||
<div class="d-flex pt-2">
|
|
||||||
<a class="btn btn-outline-light btn-social" href=""><i class="fab fa-twitter"></i></a>
|
|
||||||
<a class="btn btn-outline-light btn-social" href=""><i class="fab fa-facebook-f"></i></a>
|
|
||||||
<a class="btn btn-outline-light btn-social" href=""><i class="fab fa-youtube"></i></a>
|
|
||||||
<a class="btn btn-outline-light btn-social" href=""><i class="fab fa-linkedin-in"></i></a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-3 col-md-6">
|
|
||||||
<h3 class="text-white mb-4">Quick Links</h3>
|
|
||||||
<a class="btn btn-link text-white-50" href="<?= base_url('/about') ?>">About Us</a>
|
|
||||||
<a class="btn btn-link text-white-50" href="<?= base_url('/contact') ?>">Contact Us</a>
|
|
||||||
<a class="btn btn-link text-white-50" href="<?= base_url('/services') ?>">Our Services</a>
|
|
||||||
<a class="btn btn-link text-white-50" href="<?= base_url('/privacy') ?>">Privacy Policy</a>
|
|
||||||
<a class="btn btn-link text-white-50" href="<?= base_url('/terms') ?>">Terms & Condition</a>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-3 col-md-6">
|
|
||||||
<h3 class="text-white mb-4">Photo Gallery</h3>
|
|
||||||
<div class="row g-2 pt-2">
|
|
||||||
<div class="col-4">
|
|
||||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-1.jpg') ?>" alt="">
|
|
||||||
</div>
|
|
||||||
<div class="col-4">
|
|
||||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-2.jpg') ?>" alt="">
|
|
||||||
</div>
|
|
||||||
<div class="col-4">
|
|
||||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-3.jpg') ?>" alt="">
|
|
||||||
</div>
|
|
||||||
<div class="col-4">
|
|
||||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-4.jpg') ?>" alt="">
|
|
||||||
</div>
|
|
||||||
<div class="col-4">
|
|
||||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-5.jpg') ?>" alt="">
|
|
||||||
</div>
|
|
||||||
<div class="col-4">
|
|
||||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-6.jpg') ?>" alt="">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-3 col-md-6">
|
|
||||||
<h3 class="text-white mb-4">Newsletter</h3>
|
|
||||||
<p>Our newsletter is your gateway to staying informed and connected with our Sunday school community. </p>
|
|
||||||
<div class="position-relative mx-auto" style="max-width: 400px;">
|
|
||||||
<input class="form-control bg-transparent w-100 py-3 ps-4 pe-5" type="text" placeholder="Your email">
|
|
||||||
<button type="button" class="btn btn-primary py-2 position-absolute top-0 end-0 mt-2 me-2">SignUp</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="container">
|
|
||||||
<div class="copyright">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6 text-center text-md-start mb-3 mb-md-0">
|
|
||||||
© <a class="border-bottom" href="#">Al Rahma Sunday School by ISGL</a>, All Right Reserved.
|
|
||||||
Designed By <a class="border-bottom" href="https://htmlcodex.com">HTML Codex</a>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6 text-center text-md-end">
|
|
||||||
<div class="footer-menu">
|
|
||||||
<a href="#">Home</a>
|
|
||||||
<a href="#">Cookies</a>
|
|
||||||
<a href="#">Help</a>
|
|
||||||
<a href="#">FQAs</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Footer End -->
|
|
||||||
|
|
||||||
<!-- Back to Top -->
|
|
||||||
<a href="#" class="btn btn-lg btn-primary btn-lg-square back-to-top"><i class="bi bi-arrow-up"></i></a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- JavaScript Libraries -->
|
|
||||||
<script src="https://code.jquery.com/jquery-3.4.1.min.js"></script>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0/dist/js/bootstrap.bundle.min.js"></script>
|
|
||||||
<script src="<?= base_url('lib/wow/wow.min.js') ?>"></script>
|
|
||||||
<script src="<?= base_url('lib/easing/easing.min.js') ?>"></script>
|
|
||||||
<script src="<?= base_url('lib/waypoints/waypoints.min.js') ?>"></script>
|
|
||||||
<script src="<?= base_url('lib/owlcarousel/owl.carousel.min.js') ?>"></script>
|
|
||||||
|
|
||||||
<!-- Template Javascript -->
|
|
||||||
<script src="<?= base_url('js/main.js') ?>"></script>
|
|
||||||
<script>
|
|
||||||
document.getElementById('contactForm').addEventListener('submit', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
let formData = new FormData(this);
|
|
||||||
|
|
||||||
fetch('contact_process.php', {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData
|
|
||||||
})
|
|
||||||
.then(response => response.text())
|
|
||||||
.then(data => {
|
|
||||||
document.getElementById('formResponse').innerHTML = data;
|
|
||||||
document.getElementById('contactForm').reset();
|
|
||||||
})
|
|
||||||
.catch(error => console.error('Error:', error));
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
<?php
|
|
||||||
if ($_SERVER["REQUEST_METHOD"] == "POST") {
|
|
||||||
$name = strip_tags(trim($_POST["name"]));
|
|
||||||
$email = filter_var(trim($_POST["email"]), FILTER_SANITIZE_EMAIL);
|
|
||||||
$subject = strip_tags(trim($_POST["subject"]));
|
|
||||||
$message = trim($_POST["message"]);
|
|
||||||
|
|
||||||
// Check that data was sent to the mailer.
|
|
||||||
if (empty($name) || empty($subject) || empty($message) || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
|
||||||
echo "Oops! There was a problem with your submission. Please complete the form and try again.";
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set the recipient email address.
|
|
||||||
$recipient = "alrahma.isgl@gmail.com";
|
|
||||||
|
|
||||||
// Set the email subject.
|
|
||||||
$email_subject = "New contact from $name: $subject";
|
|
||||||
|
|
||||||
// Build the email content.
|
|
||||||
$email_content = "Name: $name\n";
|
|
||||||
$email_content .= "Email: $email\n\n";
|
|
||||||
$email_content .= "Message:\n$message\n";
|
|
||||||
|
|
||||||
// Build the email headers.
|
|
||||||
$email_headers = "From: $name <$email>";
|
|
||||||
|
|
||||||
// Send the email.
|
|
||||||
if (mail($recipient, $email_subject, $email_content, $email_headers)) {
|
|
||||||
echo "Thank you! Your message has been sent.";
|
|
||||||
} else {
|
|
||||||
echo "Oops! Something went wrong and we couldn't send your message.";
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
echo "There was a problem with your submission, please try again.";
|
|
||||||
}
|
|
||||||
?>
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<p>Assalamu alaikum <?= esc($name) ?>,</p>
|
||||||
|
|
||||||
|
<p>Thank you for applying for the <?= esc($position['title']) ?> position at Al Rahma Sunday School.</p>
|
||||||
|
|
||||||
|
<p>We received your application and will review it with the hiring team. If your background matches the role, we will follow up with next steps.</p>
|
||||||
|
|
||||||
|
<p>Al Rahma Sunday School</p>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<p>Assalamu alaikum <?= esc($name) ?>,</p>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
$statusMessages = [
|
||||||
|
'New' => 'Thank you for applying for ' . esc($positionTitle) . '. We have received your application and it is now in our review queue.',
|
||||||
|
'Reviewed' => 'Thank you for applying for ' . esc($positionTitle) . '. Our team has reviewed your application and will contact you if additional information or next steps are needed.',
|
||||||
|
'Contacted' => 'Thank you for applying for ' . esc($positionTitle) . '. Our team has moved your application forward and has contacted you, or will contact you shortly, about next steps.',
|
||||||
|
'Rejected' => 'Thank you for your interest in serving as ' . esc($positionTitle) . '. After reviewing the current needs of the program, we are not moving forward with your application for this role at this time. We sincerely appreciate your willingness to support Al Rahma Sunday School and encourage you to consider future volunteer opportunities.',
|
||||||
|
'Hired' => 'Congratulations. We are pleased to move forward with your application for ' . esc($positionTitle) . '. Our team will follow up with next steps.',
|
||||||
|
];
|
||||||
|
$message = $statusMessages[$statusLabel] ?? 'Thank you for applying for ' . esc($positionTitle) . '. We are writing to share an update about your application.';
|
||||||
|
?>
|
||||||
|
|
||||||
|
<p><?= $message ?></p>
|
||||||
|
|
||||||
|
<?php if (!empty($adminNotes)): ?>
|
||||||
|
<p><strong>Message from Al Rahma Sunday School:</strong><br><?= nl2br(esc($adminNotes)) ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<p>Thank you for your interest in serving with Al Rahma Sunday School.</p>
|
||||||
|
|
||||||
|
<p>Al Rahma Sunday School</p>
|
||||||
@@ -35,16 +35,18 @@
|
|||||||
<table id="enrollmentTable" class="table table-bordered table-striped mt-4 align-middle">
|
<table id="enrollmentTable" class="table table-bordered table-striped mt-4 align-middle">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Registration Date</th>
|
<th>Register Date</th>
|
||||||
<th>Parent/Guardian</th>
|
<th>Parent</th>
|
||||||
<th>Student Name</th>
|
<th>Student Name</th>
|
||||||
<th>School ID</th>
|
<th>School ID</th>
|
||||||
<th>Age</th>
|
<th>Age</th>
|
||||||
<th>New Student</th>
|
<th>New Student</th>
|
||||||
<th>Current Class</th>
|
<th>Make Up Exam</th>
|
||||||
<th>Actual Status</th>
|
<th>Registered Class</th>
|
||||||
<th>Update Enrollment Status</th>
|
<th>Current Class</th>
|
||||||
<th>Assign Class</th>
|
<th>Actual Status</th>
|
||||||
|
<th>Update Status</th>
|
||||||
|
<th>Assign Class</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -100,11 +102,23 @@
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<!-- Class -->
|
<!-- Make-up Exam -->
|
||||||
<td><?= esc($student['class_section'] ?? 'Class not Assigned') ?></td>
|
<td class="text-center">
|
||||||
|
<?php if (($student['make_up_exam'] ?? 'No') === 'Yes'): ?>
|
||||||
|
<span class="badge bg-warning text-dark">Yes</span>
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="badge bg-secondary">No</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
|
||||||
<!-- Enrollment Status -->
|
<!-- Registered Class -->
|
||||||
<td>
|
<td><?= esc(trim((string)($student['registration_grade'] ?? '')) !== '' ? (string)$student['registration_grade'] : '-') ?></td>
|
||||||
|
|
||||||
|
<!-- Class -->
|
||||||
|
<td><?= esc($student['class_section'] ?? 'Class not Assigned') ?></td>
|
||||||
|
|
||||||
|
<!-- Enrollment Status -->
|
||||||
|
<td>
|
||||||
<?php
|
<?php
|
||||||
$status = $student['enrollment_status'] ?? 'not enrolled';
|
$status = $student['enrollment_status'] ?? 'not enrolled';
|
||||||
switch ($status) {
|
switch ($status) {
|
||||||
@@ -185,11 +199,11 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="10">No students available.</td>
|
<td colspan="12">No students available.</td>
|
||||||
</tr>
|
</tr>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -383,10 +397,10 @@
|
|||||||
dom: "<'row mb-2'<'col-sm-6'l><'col-sm-6'f>>" + "t" +
|
dom: "<'row mb-2'<'col-sm-6'l><'col-sm-6'f>>" + "t" +
|
||||||
"<'row mt-2'<'col-sm-5'i><'col-sm-7'p>>",
|
"<'row mt-2'<'col-sm-5'i><'col-sm-7'p>>",
|
||||||
|
|
||||||
// Disable sort/search on interactive columns (status select, assign select)
|
// Disable sort/search on interactive columns (status select, assign select)
|
||||||
columnDefs: [
|
columnDefs: [
|
||||||
{ targets: [8, 9], orderable: false, searchable: false },
|
{ targets: [10, 11], orderable: false, searchable: false },
|
||||||
{ targets: [0, 1, 2, 3, 4, 5, 6, 7], render: function(data, type) {
|
{ targets: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], render: function(data, type) {
|
||||||
if (type === 'filter' || type === 'sort' || type === 'type') {
|
if (type === 'filter' || type === 'sort' || type === 'type') {
|
||||||
return stripHtml(data);
|
return stripHtml(data);
|
||||||
}
|
}
|
||||||
@@ -446,7 +460,7 @@
|
|||||||
}
|
}
|
||||||
const CLASS_COL_INDEX = findColIndexByHeader('Current Class'); // was index 4
|
const CLASS_COL_INDEX = findColIndexByHeader('Current Class'); // was index 4
|
||||||
const STATUS_COL_INDEX = findColIndexByHeader('Actual Status'); // was index 5
|
const STATUS_COL_INDEX = findColIndexByHeader('Actual Status'); // was index 5
|
||||||
const STATUS_SELECT_COL_INDEX = findColIndexByHeader('Update Enrollment Status');
|
const STATUS_SELECT_COL_INDEX = findColIndexByHeader('Update Status');
|
||||||
const ASSIGN_COL_INDEX = findColIndexByHeader('Assign Class');
|
const ASSIGN_COL_INDEX = findColIndexByHeader('Assign Class');
|
||||||
|
|
||||||
/* ===== Helpers ===== */
|
/* ===== Helpers ===== */
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table id="enrollmentTable" class="table table-bordered table-striped mt-4 align-middle w-100">
|
<table id="enrollmentTable" class="table table-bordered table-striped mt-4 align-middle w-100 no-mgmt-sticky" data-no-mgmt-sticky>
|
||||||
|
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -30,6 +30,7 @@
|
|||||||
<th>Actual Status</th>
|
<th>Actual Status</th>
|
||||||
<th>Age</th> <!-- NEW -->
|
<th>Age</th> <!-- NEW -->
|
||||||
<th>Registration Date</th> <!-- NEW -->
|
<th>Registration Date</th> <!-- NEW -->
|
||||||
|
<th>Assessment</th>
|
||||||
<th>Action</th>
|
<th>Action</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
@@ -107,6 +108,20 @@
|
|||||||
<td><?= esc($student['age'] ?? '-') ?></td>
|
<td><?= esc($student['age'] ?? '-') ?></td>
|
||||||
<td><?= esc(!empty($student['registration_date']) ? local_date($student['registration_date'], 'm-d-Y') : '-') ?></td>
|
<td><?= esc(!empty($student['registration_date']) ? local_date($student['registration_date'], 'm-d-Y') : '-') ?></td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<?php $studentAssessment = ($assessmentByStudent ?? [])[$sid] ?? null; ?>
|
||||||
|
<?php if (!$studentAssessment): ?>
|
||||||
|
<form method="post" action="<?= site_url('administrator/assessments/students/' . $sid . '/start') ?>"><?= csrf_field() ?><button class="btn btn-sm btn-outline-primary">Assign Assessment</button></form>
|
||||||
|
<?php elseif (in_array($studentAssessment['status'], ['not_started', 'in_progress'], true)): ?>
|
||||||
|
<a class="btn btn-sm btn-outline-secondary" href="<?= site_url('administrator/assessments/students/' . $sid) ?>">View Status</a>
|
||||||
|
<div class="small text-muted mt-1"><?= esc(ucwords(str_replace('_', ' ', $studentAssessment['status']))) ?></div>
|
||||||
|
<?php elseif ($studentAssessment['status'] === 'completed'): ?>
|
||||||
|
<a class="btn btn-sm btn-warning" href="<?= site_url('administrator/assessments/review/' . $studentAssessment['id']) ?>">Review</a>
|
||||||
|
<?php else: ?>
|
||||||
|
<a class="btn btn-sm btn-success" href="<?= site_url('administrator/assessments/results/' . $studentAssessment['id']) ?>">View Results</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
|
||||||
<td class="d-flex gap-2">
|
<td class="d-flex gap-2">
|
||||||
<!-- Contact info modal trigger ONLY -->
|
<!-- Contact info modal trigger ONLY -->
|
||||||
<button class="btn btn-warning btn-sm" data-bs-toggle="modal" data-bs-target="#<?= esc($modalIdContact) ?>">
|
<button class="btn btn-warning btn-sm" data-bs-toggle="modal" data-bs-target="#<?= esc($modalIdContact) ?>">
|
||||||
@@ -156,7 +171,7 @@
|
|||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="8" class="text-center">No students found.</td>
|
<td colspan="9" class="text-center">No students found.</td>
|
||||||
</tr>
|
</tr>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -169,52 +184,7 @@
|
|||||||
<?= $this->section('scripts') ?>
|
<?= $this->section('scripts') ?>
|
||||||
<script>
|
<script>
|
||||||
$(function() {
|
$(function() {
|
||||||
function getFixedHeaderOffset() {
|
$('#enrollmentTable').DataTable({
|
||||||
let total = 0;
|
|
||||||
const stack = [];
|
|
||||||
const header = document.querySelector('header.navbar.sticky-top, header.navbar.fixed-top');
|
|
||||||
if (header) stack.push(header);
|
|
||||||
const mgmt = document.getElementById('navbarManagement');
|
|
||||||
if (mgmt && (mgmt.classList.contains('sticky-top') || mgmt.classList.contains('fixed-top'))) stack.push(mgmt);
|
|
||||||
document.querySelectorAll('.navbar.sticky-top, .navbar.fixed-top').forEach(el => { if (!stack.includes(el)) stack.push(el); });
|
|
||||||
stack.forEach(el => { const h = el.offsetHeight || el.getBoundingClientRect().height || 0; total += Math.max(0, Math.round(h)); });
|
|
||||||
return total;
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadScript(src, id) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
if (id && document.getElementById(id)) return resolve();
|
|
||||||
const s = document.createElement('script');
|
|
||||||
if (id) s.id = id;
|
|
||||||
s.src = src;
|
|
||||||
s.onload = resolve;
|
|
||||||
s.onerror = reject;
|
|
||||||
document.head.appendChild(s);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function loadCss(href, id) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
if (id && document.getElementById(id)) return resolve();
|
|
||||||
const l = document.createElement('link');
|
|
||||||
if (id) l.id = id;
|
|
||||||
l.rel = 'stylesheet';
|
|
||||||
l.href = href;
|
|
||||||
l.onload = resolve;
|
|
||||||
document.head.appendChild(l);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureFixedHeaderAssets() {
|
|
||||||
const hasFH = !!($.fn.dataTable && $.fn.dataTable.FixedHeader);
|
|
||||||
if (hasFH) return Promise.resolve();
|
|
||||||
return Promise.all([
|
|
||||||
loadScript('https://cdn.jsdelivr.net/npm/datatables.net-fixedheader@3.4.0/js/dataTables.fixedHeader.min.js', 'dt-fixedheader'),
|
|
||||||
loadCss('https://cdn.jsdelivr.net/npm/datatables.net-fixedheader-bs5@3.4.0/css/fixedHeader.bootstrap5.min.css', 'dt-fixedheader-css')
|
|
||||||
]).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
function initTable() {
|
|
||||||
$('#enrollmentTable').DataTable({
|
|
||||||
pageLength: 100,
|
pageLength: 100,
|
||||||
lengthMenu: [10, 25, 50, 100],
|
lengthMenu: [10, 25, 50, 100],
|
||||||
stateSave: true,
|
stateSave: true,
|
||||||
@@ -222,18 +192,8 @@
|
|||||||
columnDefs: [
|
columnDefs: [
|
||||||
{ targets: [1, 2, 3], searchable: false }
|
{ targets: [1, 2, 3], searchable: false }
|
||||||
],
|
],
|
||||||
fixedHeader: {
|
fixedHeader: false
|
||||||
header: true,
|
});
|
||||||
headerOffset: getFixedHeaderOffset()
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($.fn.dataTable && $.fn.dataTable.FixedHeader) {
|
|
||||||
initTable();
|
|
||||||
} else {
|
|
||||||
ensureFixedHeaderAssets().then(() => initTable()).catch(() => initTable());
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<?= $this->endSection() ?>
|
<?= $this->endSection() ?>
|
||||||
|
|||||||
@@ -1,91 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<title>Al Rahma Sunday School</title>
|
|
||||||
<meta content="width=device-width, initial-scale=1.0" name="viewport">
|
|
||||||
<meta content="" name="keywords">
|
|
||||||
<meta content="" name="description">
|
|
||||||
|
|
||||||
<!-- Favicon -->
|
|
||||||
<link href="<?= base_url('assets/images/favicon.ico') ?>" rel="icon">
|
|
||||||
|
|
||||||
<!-- Google Web Fonts -->
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Heebo:wght@400;500;600&family=Inter:wght@600&family=Lobster+Two:wght@700&display=swap" rel="stylesheet">
|
|
||||||
|
|
||||||
<!-- Icon Font Stylesheet -->
|
|
||||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.10.0/css/all.min.css" rel="stylesheet">
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.4.1/font/bootstrap-icons.css" rel="stylesheet">
|
|
||||||
|
|
||||||
<!-- Libraries Stylesheet -->
|
|
||||||
<link href="<?= base_url('public/lib/animate/animate.min.css'); ?>" rel="stylesheet">
|
|
||||||
<link href="<?= base_url('public/lib/owlcarousel/assets/owl.carousel.min.css'); ?>" rel="stylesheet">
|
|
||||||
|
|
||||||
<!-- Customized Bootstrap Stylesheet -->
|
|
||||||
<link href="<?= base_url('public/css/bootstrap.min.css'); ?>" rel="stylesheet">
|
|
||||||
|
|
||||||
<!-- Template Stylesheet -->
|
|
||||||
<link href="<?= base_url('public/assets/css/style.css'); ?>" rel="stylesheet">
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<div class="container-xxl bg-white p-0">
|
|
||||||
<!-- Spinner Start -->
|
|
||||||
<div id="spinner" class="show bg-white position-fixed translate-middle w-100 vh-100 top-50 start-50 d-flex align-items-center justify-content-center">
|
|
||||||
<div class="spinner-border text-primary" style="width: 3rem; height: 3rem;" role="status">
|
|
||||||
<span class="sr-only">Loading...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Spinner End -->
|
|
||||||
|
|
||||||
<!-- Navbar Start -->
|
|
||||||
<nav class="navbar navbar-expand-lg bg-white navbar-light sticky-top px-4 px-lg-5 py-lg-0">
|
|
||||||
<a href="<?= base_url('/') ?>" class="navbar-brand d-flex align-items-center">
|
|
||||||
<img src="<?= base_url('images/logo.png') ?>" alt="Al Rahma Sunday School" style="height: 40px; width: 40px; border-radius: 50%; object-fit: contain; background-color: #fff;">
|
|
||||||
<h1 class="m-0 ms-2 green-title">Al Rahma Sunday School</h1>
|
|
||||||
</a>
|
|
||||||
<button type="button" class="navbar-toggler" data-bs-toggle="collapse" data-bs-target="#navbarCollapse">
|
|
||||||
<span class="navbar-toggler-icon"></span>
|
|
||||||
</button>
|
|
||||||
<div class="collapse navbar-collapse" id="navbarCollapse">
|
|
||||||
<div class="navbar-nav mx-auto">
|
|
||||||
<a href="<?= base_url('/') ?>" class="nav-item nav-link active">Home</a>
|
|
||||||
<a href="<?= base_url('/about') ?>" class="nav-item nav-link">About Us</a>
|
|
||||||
<a href="<?= base_url('/classes') ?>" class="nav-item nav-link">Classes</a>
|
|
||||||
<a href="<?= base_url('/contact') ?>" class="nav-item nav-link">Contact Us</a>
|
|
||||||
</div>
|
|
||||||
<a href="<?= base_url('/register') ?>" class="btn btn-primary rounded-pill px-3 d-none d-lg-block">Register
|
|
||||||
<i class="fa fa-arrow-right ms-3"></i></a>
|
|
||||||
<a href="<?= base_url('/user/login') ?>" class="btn btn-primary rounded-pill px-3 d-none d-lg-block">Login<i class="fa fa-arrow-right ms-3"></i></a>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<!-- Navbar End -->
|
|
||||||
|
|
||||||
<!-- Your page content here -->
|
|
||||||
|
|
||||||
<!-- Footer -->
|
|
||||||
<footer>
|
|
||||||
<!-- Your footer content -->
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
<!-- Back to Top -->
|
|
||||||
<a href="#" class="btn btn-lg btn-primary btn-lg-square back-to-top"><i class="bi bi-arrow-up"></i></a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- JavaScript Libraries -->
|
|
||||||
<script src="https://code.jquery.com/jquery-3.4.1.min.js"></script>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
|
|
||||||
<script src="<?= base_url('public/lib/wow/wow.min.js'); ?>"></script>
|
|
||||||
<script src="<?= base_url('public/lib/easing/easing.min.js'); ?>"></script>
|
|
||||||
<script src="<?= base_url('public/lib/waypoints/waypoints.min.js'); ?>"></script>
|
|
||||||
<script src="<?= base_url('public/lib/owlcarousel/owl.carousel.min.js'); ?>"></script>
|
|
||||||
|
|
||||||
<!-- Template Javascript -->
|
|
||||||
<script src="<?= base_url('public/js/main.js'); ?>"></script>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
+253
-22
@@ -4,6 +4,41 @@
|
|||||||
$gCount = count($f['guardians'] ?? []);
|
$gCount = count($f['guardians'] ?? []);
|
||||||
$sCount = count($f['students'] ?? []);
|
$sCount = count($f['students'] ?? []);
|
||||||
$sum = $f['finance_summary'] ?? ['invoices_count'=>0,'total_amount'=>0,'paid_amount'=>0,'balance'=>0];
|
$sum = $f['finance_summary'] ?? ['invoices_count'=>0,'total_amount'=>0,'paid_amount'=>0,'balance'=>0];
|
||||||
|
$canViewInvoices = !empty($f['can_view_invoices']);
|
||||||
|
$selectedStudentId = (int)($f['selected_student_id'] ?? 0);
|
||||||
|
$scoreDefaultStudentId = $selectedStudentId > 0
|
||||||
|
? $selectedStudentId
|
||||||
|
: (int)($f['students'][0]['id'] ?? 0);
|
||||||
|
|
||||||
|
$displayValue = static function ($value, string $fallback = 'Not provided'): string {
|
||||||
|
if ($value === null || trim((string)$value) === '') {
|
||||||
|
return $fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string)$value;
|
||||||
|
};
|
||||||
|
|
||||||
|
$formatDate = static function ($value, bool $includeTime = false) use ($displayValue): string {
|
||||||
|
if ($value === null || trim((string)$value) === '') {
|
||||||
|
return 'Not provided';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return $includeTime
|
||||||
|
? local_datetime((string)$value, 'm-d-Y g:i A')
|
||||||
|
: (new \DateTime((string)$value))->format('m-d-Y');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return $displayValue($value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$formatScore = static function ($value): string {
|
||||||
|
if ($value === null || $value === '' || !is_numeric($value)) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
return rtrim(rtrim(number_format((float)$value, 2, '.', ''), '0'), '.');
|
||||||
|
};
|
||||||
|
|
||||||
// Title: prefer provided household_name unless it's generic like "Family of User 53" or empty.
|
// Title: prefer provided household_name unless it's generic like "Family of User 53" or empty.
|
||||||
$titleRaw = trim((string)($f['household_name'] ?? ''));
|
$titleRaw = trim((string)($f['household_name'] ?? ''));
|
||||||
@@ -41,6 +76,14 @@ if ($returnUrl === '') {
|
|||||||
.family-card-root .fc-title { font-size: 1.3rem; letter-spacing: .2px; }
|
.family-card-root .fc-title { font-size: 1.3rem; letter-spacing: .2px; }
|
||||||
.family-card-root .fc-name { font-size: 1.08rem; font-weight: 600; color: #0b5ed7; }
|
.family-card-root .fc-name { font-size: 1.08rem; font-weight: 600; color: #0b5ed7; }
|
||||||
.family-card-root .fc-name:hover { color: #084298; text-decoration: underline; }
|
.family-card-root .fc-name:hover { color: #084298; text-decoration: underline; }
|
||||||
|
.family-card-root .fc-student-details { background: #f8fafc; border-top: 1px solid #e5e7eb; }
|
||||||
|
.family-card-root .fc-detail-label { color: #64748b; font-size: .76rem; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; }
|
||||||
|
.family-card-root .fc-detail-value { color: #12344d; overflow-wrap: anywhere; }
|
||||||
|
.family-card-root .fc-health-card { border-left: 4px solid #2d89ec !important; }
|
||||||
|
.family-card-root .fc-score-table th { white-space: normal; min-width: 105px; vertical-align: middle; }
|
||||||
|
.family-card-root .fc-score-table td { white-space: normal; vertical-align: top; }
|
||||||
|
.family-card-root .fc-score-table .fc-score-comment { min-width: 220px; max-width: 320px; }
|
||||||
|
.family-card-root .fc-score-student-nav { flex-wrap: nowrap; overflow-x: auto; }
|
||||||
.family-card-root .fc-badges .badge { background: rgba(255,255,255,.18); color: #fff; font-weight: 500; }
|
.family-card-root .fc-badges .badge { background: rgba(255,255,255,.18); color: #fff; font-weight: 500; }
|
||||||
.family-card-root .nav-tabs { padding-left: .5rem; padding-right: .5rem; }
|
.family-card-root .nav-tabs { padding-left: .5rem; padding-right: .5rem; }
|
||||||
.family-card-root .nav-tabs .nav-link { color: #2161a7; font-weight: 600; }
|
.family-card-root .nav-tabs .nav-link { color: #2161a7; font-weight: 600; }
|
||||||
@@ -92,10 +135,12 @@ if ($returnUrl === '') {
|
|||||||
<div class="d-flex flex-wrap gap-2 fc-badges">
|
<div class="d-flex flex-wrap gap-2 fc-badges">
|
||||||
<span class="badge">Guardians: <?= (int)$gCount ?></span>
|
<span class="badge">Guardians: <?= (int)$gCount ?></span>
|
||||||
<span class="badge">Students: <?= (int)$sCount ?></span>
|
<span class="badge">Students: <?= (int)$sCount ?></span>
|
||||||
<span class="badge">Invoices: <?= (int)($sum['invoices_count'] ?? 0) ?></span>
|
<?php if ($canViewInvoices): ?>
|
||||||
<span class="badge">
|
<span class="badge">Invoices: <?= (int)($sum['invoices_count'] ?? 0) ?></span>
|
||||||
Balance: $<?= number_format((float)($sum['balance'] ?? 0), 2) ?>
|
<span class="badge">
|
||||||
</span>
|
Balance: $<?= number_format((float)($sum['balance'] ?? 0), 2) ?>
|
||||||
|
</span>
|
||||||
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -105,15 +150,20 @@ if ($returnUrl === '') {
|
|||||||
<li class="nav-item" role="presentation">
|
<li class="nav-item" role="presentation">
|
||||||
<button class="nav-link active" id="fc-tab-overview" data-bs-toggle="tab" data-bs-target="#fc-overview" type="button" role="tab">Students</button>
|
<button class="nav-link active" id="fc-tab-overview" data-bs-toggle="tab" data-bs-target="#fc-overview" type="button" role="tab">Students</button>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="fc-tab-scores" data-bs-toggle="tab" data-bs-target="#fc-scores" type="button" role="tab">Scores History</button>
|
||||||
|
</li>
|
||||||
<li class="nav-item" role="presentation">
|
<li class="nav-item" role="presentation">
|
||||||
<button class="nav-link" id="fc-tab-guardians" data-bs-toggle="tab" data-bs-target="#fc-guardians" type="button" role="tab">Parents/Guardians</button>
|
<button class="nav-link" id="fc-tab-guardians" data-bs-toggle="tab" data-bs-target="#fc-guardians" type="button" role="tab">Parents/Guardians</button>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item" role="presentation">
|
<li class="nav-item" role="presentation">
|
||||||
<button class="nav-link" id="fc-tab-ec" data-bs-toggle="tab" data-bs-target="#fc-ec" type="button" role="tab">Emergency</button>
|
<button class="nav-link" id="fc-tab-ec" data-bs-toggle="tab" data-bs-target="#fc-ec" type="button" role="tab">Emergency</button>
|
||||||
</li>
|
</li>
|
||||||
<!--li class="nav-item" role="presentation">
|
<?php if ($canViewInvoices): ?>
|
||||||
<button class="nav-link" id="fc-tab-fin" data-bs-toggle="tab" data-bs-target="#fc-fin" type="button" role="tab">Financials</button>
|
<li class="nav-item" role="presentation">
|
||||||
</li-->
|
<button class="nav-link" id="fc-tab-fin" data-bs-toggle="tab" data-bs-target="#fc-fin" type="button" role="tab">Invoices</button>
|
||||||
|
</li>
|
||||||
|
<?php endif; ?>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
@@ -126,26 +176,202 @@ if ($returnUrl === '') {
|
|||||||
<?php if (empty($f['students'])): ?>
|
<?php if (empty($f['students'])): ?>
|
||||||
<div class="text-muted small">No students linked.</div>
|
<div class="text-muted small">No students linked.</div>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<ul class="list-group">
|
<div class="accordion" id="fc-students-<?= (int)($f['id'] ?? 0) ?>">
|
||||||
<?php foreach ($f['students'] as $s): ?>
|
<?php foreach ($f['students'] as $s): ?>
|
||||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
<?php
|
||||||
<div>
|
$studentId = (int)($s['id'] ?? 0);
|
||||||
<a href="#" class="text-decoration-none fc-name" data-family-student-id="<?= (int)($s['id'] ?? 0) ?>">
|
$detailId = 'fc-student-details-' . (int)($f['id'] ?? 0) . '-' . $studentId;
|
||||||
<?= esc(($s['firstname'] ?? '').' '.($s['lastname'] ?? '')) ?>
|
$isSelected = $selectedStudentId > 0 && $selectedStudentId === $studentId;
|
||||||
</a>
|
$allergies = array_values(array_filter(array_map('trim', (array)($s['allergies'] ?? []))));
|
||||||
|
$conditions = array_values(array_filter(array_map('trim', (array)($s['medical_conditions'] ?? []))));
|
||||||
|
?>
|
||||||
|
<div class="accordion-item">
|
||||||
|
<h2 class="accordion-header">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="accordion-button <?= $isSelected ? '' : 'collapsed' ?>"
|
||||||
|
data-bs-toggle="collapse"
|
||||||
|
data-bs-target="#<?= esc($detailId) ?>"
|
||||||
|
aria-expanded="<?= $isSelected ? 'true' : 'false' ?>"
|
||||||
|
aria-controls="<?= esc($detailId) ?>"
|
||||||
|
>
|
||||||
|
<span class="fc-name">
|
||||||
|
<?= esc(trim(($s['firstname'] ?? '').' '.($s['lastname'] ?? ''))) ?>
|
||||||
|
</span>
|
||||||
|
<?php if (!empty($s['grade'])): ?>
|
||||||
|
<span class="badge text-bg-secondary ms-2"><?= esc($s['grade']) ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</button>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="<?= esc($detailId) ?>"
|
||||||
|
class="accordion-collapse collapse <?= $isSelected ? 'show' : '' ?>"
|
||||||
|
data-bs-parent="#fc-students-<?= (int)($f['id'] ?? 0) ?>"
|
||||||
|
>
|
||||||
|
<div class="accordion-body fc-student-details">
|
||||||
|
<h6 class="mb-3">Student Details</h6>
|
||||||
|
<div class="row g-3">
|
||||||
|
<?php
|
||||||
|
$details = [
|
||||||
|
'School ID' => $displayValue($s['school_id'] ?? null),
|
||||||
|
'Date of Birth' => $formatDate($s['dob'] ?? null),
|
||||||
|
'Age' => $displayValue($s['age'] ?? null),
|
||||||
|
'Gender' => $displayValue($s['gender'] ?? null),
|
||||||
|
'Assigned Class / Section' => $displayValue($s['grade'] ?? null),
|
||||||
|
'Enrollment Status' => $displayValue($s['enrollment_status'] ?? null),
|
||||||
|
'Registration Grade' => $displayValue($s['registration_grade'] ?? null),
|
||||||
|
'Status' => ((int)($s['is_active'] ?? 0) === 1) ? 'Active' : 'Inactive',
|
||||||
|
'Photo Consent' => ((int)($s['photo_consent'] ?? 0) === 1) ? 'Yes' : 'No',
|
||||||
|
'Registration Date' => $formatDate($s['registration_date'] ?? null, true),
|
||||||
|
'Year of Registration' => $displayValue($s['year_of_registration'] ?? null),
|
||||||
|
'RFID Tag' => $displayValue($s['rfid_tag'] ?? null),
|
||||||
|
'Tuition Paid' => ((int)($s['tuition_paid'] ?? 0) === 1) ? 'Yes' : 'No',
|
||||||
|
];
|
||||||
|
?>
|
||||||
|
<?php foreach ($details as $label => $value): ?>
|
||||||
|
<div class="col-12 col-sm-6 col-lg-4">
|
||||||
|
<div class="fc-detail-label"><?= esc($label) ?></div>
|
||||||
|
<div class="fc-detail-value"><?= esc($value) ?></div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3 mt-1">
|
||||||
|
<div class="col-12 col-lg-6">
|
||||||
|
<div class="card h-100 fc-health-card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="fc-detail-label mb-2">Medical Conditions</div>
|
||||||
|
<?php if (empty($conditions)): ?>
|
||||||
|
<div class="text-muted">None on file</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<ul class="mb-0 ps-3">
|
||||||
|
<?php foreach ($conditions as $condition): ?>
|
||||||
|
<li><?= esc($condition) ?></li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-lg-6">
|
||||||
|
<div class="card h-100 fc-health-card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="fc-detail-label mb-2">Allergies</div>
|
||||||
|
<?php if (empty($allergies)): ?>
|
||||||
|
<div class="text-muted">None on file</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<ul class="mb-0 ps-3">
|
||||||
|
<?php foreach ($allergies as $allergy): ?>
|
||||||
|
<li><?= esc($allergy) ?></li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<?php if (!empty($s['grade'])): ?>
|
</div>
|
||||||
<span class="badge text-bg-secondary"><?= esc($s['grade']) ?></span>
|
|
||||||
<?php endif; ?>
|
|
||||||
</li>
|
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</ul>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Scores History -->
|
||||||
|
<div class="tab-pane fade" id="fc-scores" role="tabpanel" aria-labelledby="fc-tab-scores">
|
||||||
|
<div class="p-3">
|
||||||
|
<?php if (empty($f['students'])): ?>
|
||||||
|
<div class="alert alert-light border mb-0">No students linked.</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<ul class="nav nav-pills fc-score-student-nav gap-2 mb-3" role="tablist">
|
||||||
|
<?php foreach ($f['students'] as $s): ?>
|
||||||
|
<?php
|
||||||
|
$scoreStudentId = (int)($s['id'] ?? 0);
|
||||||
|
$scoreStudentActive = $scoreStudentId === $scoreDefaultStudentId;
|
||||||
|
$scorePaneId = 'fc-score-student-' . (int)($f['id'] ?? 0) . '-' . $scoreStudentId;
|
||||||
|
?>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button
|
||||||
|
class="nav-link text-nowrap <?= $scoreStudentActive ? 'active' : '' ?>"
|
||||||
|
data-bs-toggle="pill"
|
||||||
|
data-bs-target="#<?= esc($scorePaneId) ?>"
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected="<?= $scoreStudentActive ? 'true' : 'false' ?>"
|
||||||
|
>
|
||||||
|
<?= esc(trim(($s['firstname'] ?? '') . ' ' . ($s['lastname'] ?? ''))) ?>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="tab-content">
|
||||||
|
<?php foreach ($f['students'] as $s): ?>
|
||||||
|
<?php
|
||||||
|
$scoreStudentId = (int)($s['id'] ?? 0);
|
||||||
|
$scoreStudentActive = $scoreStudentId === $scoreDefaultStudentId;
|
||||||
|
$scorePaneId = 'fc-score-student-' . (int)($f['id'] ?? 0) . '-' . $scoreStudentId;
|
||||||
|
$scoreHistory = (array)($s['score_history'] ?? []);
|
||||||
|
?>
|
||||||
|
<div class="tab-pane fade <?= $scoreStudentActive ? 'show active' : '' ?>" id="<?= esc($scorePaneId) ?>" role="tabpanel">
|
||||||
|
<?php if (empty($scoreHistory)): ?>
|
||||||
|
<div class="alert alert-light border mb-0">No score history found for this student.</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-bordered table-striped align-middle fc-score-table mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>School Year</th>
|
||||||
|
<th>Midterm S1</th>
|
||||||
|
<th class="fc-score-comment">Midterm Comment S1</th>
|
||||||
|
<th>PTAP S1</th>
|
||||||
|
<th class="fc-score-comment">PTAP Comment S1</th>
|
||||||
|
<th>Attendance S1</th>
|
||||||
|
<th class="fc-score-comment">Attendance Comment S1</th>
|
||||||
|
<th>Final S2</th>
|
||||||
|
<th class="fc-score-comment">Final Comment S2</th>
|
||||||
|
<th>PTAP S2</th>
|
||||||
|
<th class="fc-score-comment">PTAP Comment S2</th>
|
||||||
|
<th>Attendance S2</th>
|
||||||
|
<th class="fc-score-comment">Attendance Comment S2</th>
|
||||||
|
<th>Year Score</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($scoreHistory as $history): ?>
|
||||||
|
<tr>
|
||||||
|
<td data-label="School Year" class="text-nowrap fw-semibold"><?= esc($history['school_year'] ?? '—') ?></td>
|
||||||
|
<td data-label="Midterm S1"><?= esc($formatScore($history['midterm_s1'] ?? null)) ?></td>
|
||||||
|
<td data-label="Midterm Comment S1" class="fc-score-comment"><?= esc($displayValue($history['midterm_comment_s1'] ?? null, '—')) ?></td>
|
||||||
|
<td data-label="PTAP S1"><?= esc($formatScore($history['ptap_s1'] ?? null)) ?></td>
|
||||||
|
<td data-label="PTAP Comment S1" class="fc-score-comment"><?= esc($displayValue($history['ptap_comment_s1'] ?? null, '—')) ?></td>
|
||||||
|
<td data-label="Attendance S1"><?= esc($formatScore($history['attendance_s1'] ?? null)) ?></td>
|
||||||
|
<td data-label="Attendance Comment S1" class="fc-score-comment"><?= esc($displayValue($history['attendance_comment_s1'] ?? null, '—')) ?></td>
|
||||||
|
<td data-label="Final S2"><?= esc($formatScore($history['final_s2'] ?? null)) ?></td>
|
||||||
|
<td data-label="Final Comment S2" class="fc-score-comment"><?= esc($displayValue($history['final_comment_s2'] ?? null, '—')) ?></td>
|
||||||
|
<td data-label="PTAP S2"><?= esc($formatScore($history['ptap_s2'] ?? null)) ?></td>
|
||||||
|
<td data-label="PTAP Comment S2" class="fc-score-comment"><?= esc($displayValue($history['ptap_comment_s2'] ?? null, '—')) ?></td>
|
||||||
|
<td data-label="Attendance S2"><?= esc($formatScore($history['attendance_s2'] ?? null)) ?></td>
|
||||||
|
<td data-label="Attendance Comment S2" class="fc-score-comment"><?= esc($displayValue($history['attendance_comment_s2'] ?? null, '—')) ?></td>
|
||||||
|
<td data-label="Year Score" class="fw-semibold"><?= esc($formatScore($history['year_score'] ?? null)) ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Guardians -->
|
<!-- Guardians -->
|
||||||
<div class="tab-pane fade" id="fc-guardians" role="tabpanel" aria-labelledby="fc-tab-guardians">
|
<div class="tab-pane fade" id="fc-guardians" role="tabpanel" aria-labelledby="fc-tab-guardians">
|
||||||
<div class="p-3">
|
<div class="p-3">
|
||||||
@@ -281,7 +507,8 @@ if ($returnUrl === '') {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Financials -->
|
<?php if ($canViewInvoices): ?>
|
||||||
|
<!-- Invoices (administrative roles only) -->
|
||||||
<div class="tab-pane fade" id="fc-fin" role="tabpanel" aria-labelledby="fc-tab-fin">
|
<div class="tab-pane fade" id="fc-fin" role="tabpanel" aria-labelledby="fc-tab-fin">
|
||||||
<div class="p-3">
|
<div class="p-3">
|
||||||
<div class="row g-3 mb-2">
|
<div class="row g-3 mb-2">
|
||||||
@@ -313,7 +540,7 @@ if ($returnUrl === '') {
|
|||||||
|
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<h6 class="mb-2">Invoices</h6>
|
<h6 class="mb-2">Parent Invoices</h6>
|
||||||
<?php if (empty($f['invoices'])): ?>
|
<?php if (empty($f['invoices'])): ?>
|
||||||
<div class="alert alert-light border">No invoices on record.</div>
|
<div class="alert alert-light border">No invoices on record.</div>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
@@ -321,18 +548,20 @@ if ($returnUrl === '') {
|
|||||||
<table class="table table-sm table-striped table-hover align-middle fc-table-stack">
|
<table class="table table-sm table-striped table-hover align-middle fc-table-stack">
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
<tr>
|
<tr>
|
||||||
<th>#</th><th>Status</th><th>Total</th><th>Paid</th><th>Balance</th><th>Date</th>
|
<th>Parent</th><th>#</th><th>Status</th><th>Total</th><th>Paid</th><th>Balance</th><th>Issued</th><th>Due</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<?php foreach ($f['invoices'] as $iv): ?>
|
<?php foreach ($f['invoices'] as $iv): ?>
|
||||||
<tr>
|
<tr>
|
||||||
|
<td data-label="Parent"><?= esc($iv['parent_name'] ?? ('Parent #' . (int)($iv['parent_id'] ?? 0))) ?></td>
|
||||||
<td data-label="Invoice"><?= esc($iv['invoice_number']) ?></td>
|
<td data-label="Invoice"><?= esc($iv['invoice_number']) ?></td>
|
||||||
<td data-label="Status"><?= esc($iv['status']) ?></td>
|
<td data-label="Status"><?= esc($iv['status']) ?></td>
|
||||||
<td data-label="Total">$<?= number_format((float)($iv['total_amount'] ?? 0), 2) ?></td>
|
<td data-label="Total">$<?= number_format((float)($iv['total_amount'] ?? 0), 2) ?></td>
|
||||||
<td data-label="Paid">$<?= number_format((float)($iv['paid_amount'] ?? 0), 2) ?></td>
|
<td data-label="Paid">$<?= number_format((float)($iv['paid_amount'] ?? 0), 2) ?></td>
|
||||||
<td data-label="Balance">$<?= number_format((float)($iv['balance'] ?? 0), 2) ?></td>
|
<td data-label="Balance">$<?= number_format((float)($iv['balance'] ?? 0), 2) ?></td>
|
||||||
<td data-label="Date"><?= esc(!empty($iv['issue_date']) ? local_date($iv['issue_date'], 'm-d-Y') : '') ?></td>
|
<td data-label="Date"><?= esc(!empty($iv['issue_date']) ? local_date($iv['issue_date'], 'm-d-Y') : '') ?></td>
|
||||||
|
<td data-label="Due"><?= esc(!empty($iv['due_date']) ? local_date($iv['due_date'], 'm-d-Y') : '—') ?></td>
|
||||||
</tr>
|
</tr>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -350,13 +579,14 @@ if ($returnUrl === '') {
|
|||||||
<table class="table table-sm table-striped table-hover align-middle fc-table-stack">
|
<table class="table table-sm table-striped table-hover align-middle fc-table-stack">
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
<tr>
|
<tr>
|
||||||
<th>Invoice</th><th>Amount</th><th>Invoice Balance</th><th>Method</th><th>Date</th><th>Payment Status</th><th>Invoice Status</th>
|
<th>Parent</th><th>Invoice</th><th>Amount</th><th>Invoice Balance</th><th>Method</th><th>Date</th><th>Payment Status</th><th>Invoice Status</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<?php $imap = $f['invoice_map'] ?? []; ?>
|
<?php $imap = $f['invoice_map'] ?? []; ?>
|
||||||
<?php foreach ($f['payments'] as $p): ?>
|
<?php foreach ($f['payments'] as $p): ?>
|
||||||
<tr>
|
<tr>
|
||||||
|
<td data-label="Parent"><?= esc($p['parent_name'] ?? ('Parent #' . (int)($p['parent_id'] ?? 0))) ?></td>
|
||||||
<td data-label="Invoice"><?php $iid = (int)($p['invoice_id'] ?? 0); echo esc($p['invoice_number'] ?? $imap[$iid] ?? ('#'.$iid)); ?></td>
|
<td data-label="Invoice"><?php $iid = (int)($p['invoice_id'] ?? 0); echo esc($p['invoice_number'] ?? $imap[$iid] ?? ('#'.$iid)); ?></td>
|
||||||
<td data-label="Amount">$<?= number_format((float)($p['paid_amount'] ?? 0), 2) ?></td>
|
<td data-label="Amount">$<?= number_format((float)($p['paid_amount'] ?? 0), 2) ?></td>
|
||||||
<td data-label="Invoice Balance">$<?= number_format((float)($p['invoice_current_balance'] ?? 0), 2) ?></td>
|
<td data-label="Invoice Balance">$<?= number_format((float)($p['invoice_current_balance'] ?? 0), 2) ?></td>
|
||||||
@@ -374,5 +604,6 @@ if ($returnUrl === '') {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
<!-- footer.php -->
|
|
||||||
<div class="container-fluid bg-dark text-white-50 footer pt-5 mt-5 wow fadeIn" data-wow-delay="0.1s">
|
|
||||||
<div class="container py-5">
|
|
||||||
<div class="row g-5">
|
|
||||||
<div class="col-lg-3 col-md-6">
|
|
||||||
<h3 class="text-white mb-4">Get In Touch</h3>
|
|
||||||
<p class="mb-2"><i class="fa fa-map-marker-alt me-3"></i>5 Courthouse Lane, Chelmsford, MA 01824</p>
|
|
||||||
<p class="mb-2"><i class="fa fa-phone-alt me-3"></i>+1 978-364-0219</p>
|
|
||||||
<p class="mb-2"><i class="fa fa-envelope me-3"></i>alrahma.isgl@gmail.com</p>
|
|
||||||
<div class="d-flex pt-2">
|
|
||||||
<a class="btn btn-outline-light btn-social" href=""><i class="fab fa-twitter"></i></a>
|
|
||||||
<a class="btn btn-outline-light btn-social" href=""><i class="fab fa-facebook-f"></i></a>
|
|
||||||
<a class="btn btn-outline-light btn-social" href=""><i class="fab fa-youtube"></i></a>
|
|
||||||
<a class="btn btn-outline-light btn-social" href=""><i class="fab fa-linkedin-in"></i></a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-3 col-md-6">
|
|
||||||
<h3 class="text-white mb-4">Quick Links</h3>
|
|
||||||
<a class="btn btn-link text-white-50" href="about.php">About Us</a>
|
|
||||||
<a class="btn btn-link text-white-50" href="contact.php">Contact Us</a>
|
|
||||||
<a class="btn btn-link text-white-50" href="services.php">Our Services</a>
|
|
||||||
<a class="btn btn-link text-white-50" href="privacy.php">Privacy Policy</a>
|
|
||||||
<a class="btn btn-link text-white-50" href="terms.php">Terms & Condition</a>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-3 col-md-6">
|
|
||||||
<h3 class="text-white mb-4">Photo Gallery</h3>
|
|
||||||
<div class="row g-2 pt-2">
|
|
||||||
<div class="col-4">
|
|
||||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-1.jpg') ?>" alt="">
|
|
||||||
</div>
|
|
||||||
<div class="col-4">
|
|
||||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-2.jpg') ?>" alt="">
|
|
||||||
</div>
|
|
||||||
<div class="col-4">
|
|
||||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-3.jpg') ?>" alt="">
|
|
||||||
</div>
|
|
||||||
<div class="col-4">
|
|
||||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-4.jpg') ?>" alt="">
|
|
||||||
</div>
|
|
||||||
<div class="col-4">
|
|
||||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-5.jpg') ?>" alt="">
|
|
||||||
</div>
|
|
||||||
<div class="col-4">
|
|
||||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-6.jpg') ?>" alt="">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-3 col-md-6">
|
|
||||||
<h3 class="text-white mb-4">Newsletter</h3>
|
|
||||||
<p>Discover the latest updates and exciting events happening at our school in this month's newsletter. From academic achievements to upcoming activities, stay informed and engaged with our vibrant school community.</p>
|
|
||||||
<div class="position-relative mx-auto" style="max-width: 400px;">
|
|
||||||
<input class="form-control bg-transparent w-100 py-3 ps-4 pe-5" type="text" placeholder="Your email">
|
|
||||||
<button type="button" class="btn btn-primary py-2 position-absolute top-0 end-0 mt-2 me-2">SignUp</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="container">
|
|
||||||
<div class="copyright">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6 text-center text-md-start mb-3 mb-md-0">
|
|
||||||
© <a class="border-bottom" href="#">Al Rahma Sunday School by ISGL</a>, All Right Reserved.
|
|
||||||
<!--/*** This template is free as long as you keep the footer author’s credit link/attribution link/backlink. If you'd like to use the template without the footer author’s credit link/attribution link/backlink, you can purchase the Credit Removal License from "https://htmlcodex.com/credit-removal". Thank you for your support. ***/-->
|
|
||||||
Designed By <a class="border-bottom" href="https://htmlcodex.com">HTML Codex</a>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6 text-center text-md-end">
|
|
||||||
<div class="footer-menu">
|
|
||||||
<a href="index.php">Home</a>
|
|
||||||
<a href="">Cookies</a>
|
|
||||||
<a href="">Help</a>
|
|
||||||
<a href="">FQAs</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Back to Top -->
|
|
||||||
<a href="#" class="btn btn-lg btn-primary btn-lg-square back-to-top"><i class="bi bi-arrow-up"></i></a>
|
|
||||||
<!-- JavaScript Libraries -->
|
|
||||||
<script src="https://code.jquery.com/jquery-3.4.1.min.js"></script>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0/dist/js/bootstrap.bundle.min.js"></script>
|
|
||||||
<script src="lib/wow/wow.min.js"></script>
|
|
||||||
<script src="lib/easing/easing.min.js"></script>
|
|
||||||
<script src="lib/waypoints/waypoints.min.js"></script>
|
|
||||||
<script src="lib/owlcarousel/owl.carousel.min.js"></script>
|
|
||||||
<!-- Template Javascript -->
|
|
||||||
<script src="js/main.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
<!-- header.php -->
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<title>Al Rahma Sunday School</title>
|
|
||||||
<meta content="width=device-width, initial-scale=1.0" name="viewport">
|
|
||||||
<meta content="" name="keywords">
|
|
||||||
<meta content="" name="description">
|
|
||||||
<!-- Favicon -->
|
|
||||||
<link href="<?= base_url('assets/images/favicon.ico') ?>" rel="icon">
|
|
||||||
<!-- Google Web Fonts -->
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Heebo:wght@400;500;600&family=Inter:wght@600&family=Lobster+Two:wght@700&display=swap" rel="stylesheet">
|
|
||||||
<!-- Icon Font Stylesheet -->
|
|
||||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.10.0/css/all.min.css" rel="stylesheet">
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.4.1/font/bootstrap-icons.css" rel="stylesheet">
|
|
||||||
<!-- Libraries Stylesheet -->
|
|
||||||
<link href="lib/animate/animate.min.css" rel="stylesheet">
|
|
||||||
<link href="lib/owlcarousel/assets/owl.carousel.min.css" rel="stylesheet">
|
|
||||||
<!-- Customized Bootstrap Stylesheet -->
|
|
||||||
<link href="css/bootstrap.min.css" rel="stylesheet">
|
|
||||||
<!-- Template Stylesheet -->
|
|
||||||
<link href="assets/css/style.css" rel="stylesheet">
|
|
||||||
</head>
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>School</title>
|
|
||||||
<link rel="stylesheet" href="/css/styles.css"> <!-- Adjust the path accordingly -->
|
|
||||||
<link href="<?= base_url('assets/images/favicon.ico') ?>" rel="icon">
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="left-side">
|
|
||||||
Al Rahma Sunday School
|
|
||||||
<!-- Add the logo image below the text -->
|
|
||||||
<img src="<?= base_url('assets/images/logo.png') ?>" alt="School Logo" class="logo"> <!-- Adjust the path accordingly -->
|
|
||||||
</div>
|
|
||||||
<div class="right-side">
|
|
||||||
<div class="login-container">
|
|
||||||
<h2>Login to your account</h2>
|
|
||||||
<div class="social-login">
|
|
||||||
<a href="#" class="facebook">f</a>
|
|
||||||
<a href="#" class="google">G+</a>
|
|
||||||
<a href="#" class="linkedin">in</a>
|
|
||||||
</div>
|
|
||||||
<p>____________________ OR ____________________</p>
|
|
||||||
<form method="post" action="/user/login">
|
|
||||||
<?= csrf_field(); ?>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="email">Email:</label>
|
|
||||||
<input type="email" id="email" name="email" required>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="password">Password:</label>
|
|
||||||
<input type="password" id="password" name="password" required>
|
|
||||||
</div>
|
|
||||||
<div class="form-actions">
|
|
||||||
<button type="submit">Login</button>
|
|
||||||
<div class="form-links">
|
|
||||||
<a href="/register">Register</a>
|
|
||||||
<a href="<?= site_url('user/forgot_password') ?>">Forgot Password</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
+633
-629
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
|||||||
|
<?php $item = $item ?? []; ?>
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label" for="title">Title</label>
|
||||||
|
<input id="title" name="title" class="form-control" value="<?= esc(old('title', $item['title'] ?? '')) ?>" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label" for="department">Department</label>
|
||||||
|
<input id="department" name="department" class="form-control" value="<?= esc(old('department', $item['department'] ?? '')) ?>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label" for="location">Location</label>
|
||||||
|
<input id="location" name="location" class="form-control" value="<?= esc(old('location', $item['location'] ?? '')) ?>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label" for="employment_type">Employment Type</label>
|
||||||
|
<input id="employment_type" name="employment_type" class="form-control" value="<?= esc(old('employment_type', $item['employment_type'] ?? '')) ?>">
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label" for="description">Description</label>
|
||||||
|
<textarea id="description" name="description" class="form-control" rows="6"><?= esc(old('description', $item['description'] ?? '')) ?></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label" for="responsibilities">Responsibilities</label>
|
||||||
|
<textarea id="responsibilities" name="responsibilities" class="form-control" rows="6"><?= esc(old('responsibilities', $item['responsibilities'] ?? '')) ?></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label" for="requirements">Requirements</label>
|
||||||
|
<textarea id="requirements" name="requirements" class="form-control" rows="6"><?= esc(old('requirements', $item['requirements'] ?? '')) ?></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?= $this->extend('layout/management_layout') ?>
|
||||||
|
<?= $this->section('content') ?>
|
||||||
|
|
||||||
|
<div class="container-fluid mt-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h2>Job Applications</h2>
|
||||||
|
<a href="<?= site_url('administrator/job-postings/positions') ?>" class="btn btn-outline-secondary">Positions</a>
|
||||||
|
</div>
|
||||||
|
<?php if (session('success')): ?><div class="alert alert-success"><?= esc(session('success')) ?></div><?php endif; ?>
|
||||||
|
<?php if (session('error')): ?><div class="alert alert-danger"><?= esc(session('error')) ?></div><?php endif; ?>
|
||||||
|
<form method="get" class="row g-2 mb-4">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<select name="position_id" class="form-select">
|
||||||
|
<option value="">All positions</option>
|
||||||
|
<?php foreach ($positions as $position): ?>
|
||||||
|
<option value="<?= esc($position['position_id']) ?>" <?= $selectedPosition === $position['position_id'] ? 'selected' : '' ?>><?= esc($position['title']) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<select name="status" class="form-select">
|
||||||
|
<option value="">All statuses</option>
|
||||||
|
<?php foreach ($statuses as $status): ?>
|
||||||
|
<option value="<?= esc($status) ?>" <?= $selectedStatus === $status ? 'selected' : '' ?>><?= esc(ucfirst($status)) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto"><button class="btn btn-outline-primary" type="submit">Filter</button></div>
|
||||||
|
</form>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped table-bordered align-middle no-mgmt-sticky">
|
||||||
|
<thead><tr><th>Applicant</th><th>Position</th><th>Position ID</th><th>Email</th><th>Phone</th><th>Submitted</th><th>Status / Notes</th><th>Resume</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($applications as $application): ?>
|
||||||
|
<tr>
|
||||||
|
<td><?= esc(trim($application['first_name'] . ' ' . $application['last_name'])) ?></td>
|
||||||
|
<td><?= esc($application['position_title'] ?? '') ?></td>
|
||||||
|
<td><?= esc(substr((string) ($application['position_id'] ?? ''), 0, 8)) ?></td>
|
||||||
|
<td><a href="mailto:<?= esc($application['email']) ?>"><?= esc($application['email']) ?></a></td>
|
||||||
|
<td><?= esc($application['phone']) ?></td>
|
||||||
|
<td><?= esc($application['submitted_at']) ?></td>
|
||||||
|
<td style="min-width: 280px;">
|
||||||
|
<form action="<?= site_url('administrator/job-postings/applications/' . $application['application_id']) ?>" method="post">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<select name="status" class="form-select form-select-sm mb-2">
|
||||||
|
<?php foreach ($statuses as $status): ?>
|
||||||
|
<option value="<?= esc($status) ?>" <?= $application['status'] === $status ? 'selected' : '' ?>><?= esc(ucfirst($status)) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
<textarea name="admin_notes" class="form-control form-control-sm mb-2" rows="2"><?= esc($application['admin_notes'] ?? '') ?></textarea>
|
||||||
|
<button class="btn btn-sm btn-primary" type="submit">Save</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
<td><a class="btn btn-sm btn-outline-secondary" href="<?= site_url('administrator/job-postings/applications/' . $application['application_id'] . '/resume') ?>">Download</a></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?= $this->endSection() ?>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user