add open position system
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 48s
Tests / PHPUnit (push) Successful in 1m29s

This commit is contained in:
root
2026-09-02 00:53:55 -04:00
parent 5b11e2d859
commit dbfd72c2f9
24 changed files with 1627 additions and 47 deletions
+23 -1
View File
@@ -222,7 +222,29 @@ $routes->post('/user/store', 'View\UserController::store');
$routes->get('/thankyou', 'View\UserController::thankyou'); // Thank you page route
$routes->get('/', 'View\UserController::home'); // Home page route
$routes->get('/about', 'View\UserController::about'); // About page route
$routes->get('/careers', 'View\UserController::careers'); // Careers page route
$routes->get('/careers', 'View\JobPostingController::publicIndex'); // Careers page route
$routes->get('/careers/application-received', 'View\JobPostingController::applicationReceived');
$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('/contact', 'View\UserController::contact'); // Contact Us page route
$routes->post('/user/login', 'AuthController::login');
@@ -0,0 +1,508 @@
<?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 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->openPositions(),
]);
}
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 (!$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)
{
$payload = $this->positionPayload();
if ($payload === null) {
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
}
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')),
'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'] ?? ''),
'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 -1
View File
@@ -12,6 +12,7 @@ use App\Models\UserRoleModel;
use App\Models\PasswordResetRequestModel;
use App\Models\RolePermissionModel;
use App\Models\IpAttemptModel;
use App\Models\JobPositionModel;
use CodeIgniter\Controller;
use App\Controllers\View\EmailController;
use App\Models\LoginActivityModel; // Make sure this import is present
@@ -108,7 +109,18 @@ class UserController extends BaseController
// Method to show the home page
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
@@ -0,0 +1,98 @@
<?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],
'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],
'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],
'requirements' => ['type' => 'TEXT', 'null' => true],
'status' => ['type' => 'VARCHAR', 'constraint' => 20, 'default' => 'draft'],
'posted_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
'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,123 @@
<?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.\n\nResponsibilities:\n- 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.\n\nResponsibilities:\n- 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.\n\nResponsibilities:\n- 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',
'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();
}
}
}
}
+26
View File
@@ -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;
}
+35
View File
@@ -0,0 +1,35 @@
<?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',
'requirements',
'status',
'posted_by',
'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('created_at', 'DESC')->findAll();
}
}
+30
View File
@@ -0,0 +1,30 @@
<?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',
'requirements',
'version',
'is_active',
'created_by',
'created_at',
'updated_at',
];
protected $useTimestamps = true;
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
}
+27
View File
@@ -0,0 +1,27 @@
<?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',
'requirements',
'saved_by',
'saved_at',
];
protected $useTimestamps = false;
}
+30 -40
View File
@@ -329,45 +329,35 @@
<p class="mb-0">Review the available roles below and apply by creating an account.</p>
</div>
<div class="row g-4">
<div class="col-lg-4">
<article class="opening-card">
<h3>Volunteer Teacher</h3>
<div class="opening-meta">Instruction &middot; Chelmsford, MA &middot; Part-time volunteer</div>
<p>Lead Quran, Arabic or Islamic Studies lessons in a warm classroom environment.</p>
<ul class="opening-list">
<li>Prepare weekly lessons and classroom activities.</li>
<li>Guide students with patience and clear communication.</li>
<li>Partner with school administration and families when needed.</li>
</ul>
<a class="btn-brand-sm" href="<?= base_url('/register') ?>">Apply Now <i class="fa fa-arrow-right"></i></a>
</article>
</div>
<div class="col-lg-4">
<article class="opening-card">
<h3>Teacher Assistant</h3>
<div class="opening-meta">Classroom Support &middot; Chelmsford, MA &middot; Part-time volunteer</div>
<p>Support teachers and help students stay engaged throughout Sunday classes.</p>
<ul class="opening-list">
<li>Assist with activities, materials and classroom routines.</li>
<li>Provide individual support to students during lessons.</li>
<li>Help maintain a respectful and focused learning environment.</li>
</ul>
<a class="btn-brand-sm" href="<?= base_url('/register') ?>">Apply Now <i class="fa fa-arrow-right"></i></a>
</article>
</div>
<div class="col-lg-4">
<article class="opening-card">
<h3>Administrative Volunteer</h3>
<div class="opening-meta">Operations &middot; Chelmsford, MA &middot; Part-time volunteer</div>
<p>Help the school operate smoothly through weekly administrative and event support.</p>
<ul class="opening-list">
<li>Support communications, supplies and weekly coordination.</li>
<li>Help organize school events and program logistics.</li>
<li>Assist staff with structured tasks before or during Sunday school.</li>
</ul>
<a class="btn-brand-sm" href="<?= base_url('/register') ?>">Apply Now <i class="fa fa-arrow-right"></i></a>
</article>
</div>
<?php if (!empty($positions)): ?>
<?php foreach ($positions as $position): ?>
<div class="col-lg-4">
<article class="opening-card">
<h3><?= esc($position['title']) ?></h3>
<div class="opening-meta">
<?= esc($position['department'] ?? '') ?>
<?php if (!empty($position['location'])): ?> &middot; <?= esc($position['location']) ?><?php endif; ?>
<?php if (!empty($position['employment_type'])): ?> &middot; <?= esc($position['employment_type']) ?><?php endif; ?>
</div>
<?php if (!empty($position['requirements'])): ?>
<ul class="opening-list">
<?php foreach (array_slice(array_filter(preg_split('/\r\n|\r|\n/', (string) $position['requirements'])), 0, 3) as $requirement): ?>
<li><?= esc(preg_replace('/^\s*-\s*/', '', $requirement)) ?></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<a class="btn-brand-sm" href="<?= site_url('careers/' . $position['position_id']) ?>">View Details <i class="fa fa-arrow-right"></i></a>
</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>
@@ -379,4 +369,4 @@
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
</html>
@@ -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>
+115 -4
View File
@@ -198,6 +198,74 @@
background-color: var(--accent);
}
/* Open positions banner */
.careers-ticker {
display: block;
background-color: var(--primary-dark);
color: var(--paper);
text-decoration: none;
overflow: hidden;
border-top: 1px solid rgba(243, 239, 227, 0.14);
border-bottom: 1px solid rgba(243, 239, 227, 0.14);
}
.careers-ticker:hover,
.careers-ticker:focus {
color: var(--paper);
}
.ticker-track {
display: flex;
width: max-content;
animation: ticker-scroll 10s linear infinite;
}
.careers-ticker:hover .ticker-track,
.careers-ticker:focus .ticker-track,
.careers-ticker:focus-within .ticker-track {
animation-play-state: paused;
}
.ticker-group {
display: flex;
align-items: center;
min-width: max-content;
}
.ticker-label,
.ticker-item {
display: inline-flex;
align-items: center;
min-height: 54px;
white-space: nowrap;
}
.ticker-label {
padding: 0 1.5rem;
font-weight: 700;
color: var(--accent-soft);
}
.ticker-item {
gap: 0.5rem;
padding: 0 1.75rem;
border-left: 1px solid rgba(243, 239, 227, 0.16);
}
.ticker-title {
font-weight: 700;
}
.ticker-meta {
color: #DCE6DD;
font-size: 0.9rem;
}
@keyframes ticker-scroll {
from { transform: translateX(100vw); }
to { transform: translateX(-50%); }
}
@media (max-width: 992px) {
.carousel-item { height: 480px; }
.carousel-caption { max-width: 78%; padding: 1.5rem; bottom: 8%; }
@@ -209,6 +277,9 @@
.carousel-caption { left: 5%; right: 5%; max-width: none; padding: 1.1rem 1.25rem; bottom: 6%; }
.carousel-caption h1 { font-size: 1.2rem; }
.carousel-caption p { font-size: 0.85rem; }
.ticker-track { animation-duration: 10s; }
.ticker-label, .ticker-item { min-height: 48px; }
.ticker-item { padding: 0 1.25rem; }
}
/* Join line */
@@ -506,6 +577,46 @@
</div>
<!-- Carousel End -->
<?php
$tickerPositions = !empty($openPositions) && is_array($openPositions) ? $openPositions : [];
$tickerItems = [];
foreach ($tickerPositions as $position) {
$meta = array_filter([
$position['department'] ?? '',
$position['location'] ?? '',
$position['employment_type'] ?? '',
], static fn ($value) => trim((string) $value) !== '');
$tickerItems[] = [
'title' => (string) ($position['title'] ?? 'Open Position'),
'meta' => implode(' · ', $meta),
];
}
if ($tickerItems === []) {
$tickerItems[] = [
'title' => 'Volunteer opportunities',
'meta' => 'See current openings',
];
}
?>
<a class="careers-ticker" href="<?= site_url('careers') ?>" aria-label="View open volunteer positions">
<div class="ticker-track">
<?php for ($copy = 0; $copy < 2; $copy++): ?>
<div class="ticker-group" <?= $copy === 1 ? 'aria-hidden="true"' : '' ?>>
<span class="ticker-label">Now recruiting volunteers</span>
<?php foreach ($tickerItems as $item): ?>
<span class="ticker-item">
<span class="ticker-title"><?= esc($item['title']) ?></span>
<?php if ($item['meta'] !== ''): ?>
<span class="ticker-meta"><?= esc($item['meta']) ?></span>
<?php endif; ?>
<i class="fa fa-arrow-right" aria-hidden="true"></i>
</span>
<?php endforeach; ?>
</div>
<?php endfor; ?>
</div>
</a>
<!-- Join line -->
<div class="join-strip">
<a href="<?= base_url('account_creation_guide.pdf') ?>" target="_blank">
@@ -515,7 +626,7 @@
<!-- Statistics Start -->
<div class="stats-band section-tight">
<div class="container-narrow" data-dashboard-endpoint="/api/administrator/dashboard">
<div class="container-narrow" data-dashboard-endpoint="<?= site_url('api/administrator/dashboard') ?>">
<h2>Active Participants</h2>
<div class="stats-row">
<div class="stat-cell">
@@ -644,7 +755,7 @@
</script>
<script>
document.addEventListener('DOMContentLoaded', function () {
const container = document.querySelector('.stats-band[data-dashboard-endpoint]');
const container = document.querySelector('.stats-band [data-dashboard-endpoint]');
if (!container) return;
const endpoint = container.dataset.dashboardEndpoint;
const statElements = container.querySelectorAll('[data-stat]');
@@ -666,7 +777,7 @@
const counts = payload && typeof payload === 'object' && payload.counts ? payload.counts : {};
statElements.forEach(function (element) {
const key = element.dataset.stat;
const value = counts[key];
const value = Number(counts[key]);
element.textContent = Number.isFinite(value) ? numberFormatter.format(value) : '—';
});
})
@@ -685,4 +796,4 @@
</script>
</body>
</html>
</html>
+27
View File
@@ -0,0 +1,27 @@
<?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="requirements">Requirements</label>
<textarea id="requirements" name="requirements" class="form-control" rows="6"><?= esc(old('requirements', $item['requirements'] ?? '')) ?></textarea>
</div>
</div>
+62
View File
@@ -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() ?>
+27
View File
@@ -0,0 +1,27 @@
<?= $this->extend('layout/management_layout') ?>
<?= $this->section('content') ?>
<?php $isEdit = !empty($position['position_id']); ?>
<div class="container mt-4">
<h2><?= $isEdit ? 'Edit Job Position' : 'New Job Position' ?></h2>
<?php if (session('errors')): ?>
<div class="alert alert-danger"><?php foreach ((array) session('errors') as $error): ?><div><?= esc($error) ?></div><?php endforeach; ?></div>
<?php endif; ?>
<form action="<?= $isEdit ? site_url('administrator/job-postings/positions/' . $position['position_id']) : site_url('administrator/job-postings/positions') ?>" method="post">
<?= csrf_field() ?>
<input type="hidden" name="template_id" value="<?= esc(old('template_id', $position['template_id'] ?? '')) ?>">
<?= view('jobs/admin/_posting_fields', ['item' => $position ?? []]) ?>
<div class="mb-3">
<label class="form-label" for="status">Status</label>
<select id="status" name="status" class="form-select">
<?php foreach ($statuses as $status): ?>
<option value="<?= esc($status) ?>" <?= old('status', $position['status'] ?? 'draft') === $status ? 'selected' : '' ?>><?= esc(ucfirst($status)) ?></option>
<?php endforeach; ?>
</select>
</div>
<button type="submit" class="btn btn-primary">Save Position</button>
<a href="<?= site_url('administrator/job-postings/positions') ?>" class="btn btn-outline-secondary">Cancel</a>
</form>
</div>
<?= $this->endSection() ?>
+38
View File
@@ -0,0 +1,38 @@
<?= $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>Open Positions</h2>
<div class="d-flex gap-2">
<a href="<?= site_url('administrator/job-postings/applications') ?>" class="btn btn-outline-secondary">Applications</a>
<a href="<?= site_url('administrator/job-postings/templates') ?>" class="btn btn-primary">New Position</a>
</div>
</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; ?>
<div class="table-responsive">
<table class="table table-striped table-bordered no-mgmt-sticky">
<thead><tr><th>Title</th><th>Department</th><th>Location</th><th>Type</th><th>Status</th><th>Actions</th></tr></thead>
<tbody>
<?php foreach ($positions as $position): ?>
<tr>
<td><?= esc($position['title']) ?></td>
<td><?= esc($position['department'] ?? '') ?></td>
<td><?= esc($position['location'] ?? '') ?></td>
<td><?= esc($position['employment_type'] ?? '') ?></td>
<td><?= esc(ucfirst($position['status'])) ?></td>
<td>
<a class="btn btn-sm btn-outline-primary" href="<?= site_url('administrator/job-postings/positions/' . $position['position_id'] . '/edit') ?>">Edit</a>
<?php if ($position['status'] === 'open'): ?>
<a class="btn btn-sm btn-outline-secondary" href="<?= site_url('careers/' . $position['position_id']) ?>">Public View</a>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?= $this->endSection() ?>
+47
View File
@@ -0,0 +1,47 @@
<?= $this->extend('layout/management_layout') ?>
<?= $this->section('content') ?>
<?php $isEdit = !empty($template); ?>
<div class="container mt-4">
<h2><?= $isEdit ? 'Edit Job Template' : 'New Job Template' ?></h2>
<?php if (session('errors')): ?>
<div class="alert alert-danger"><?php foreach ((array) session('errors') as $error): ?><div><?= esc($error) ?></div><?php endforeach; ?></div>
<?php endif; ?>
<form action="<?= $isEdit ? site_url('administrator/job-postings/templates/' . $template['template_id']) : site_url('administrator/job-postings/templates') ?>" method="post">
<?= csrf_field() ?>
<?= view('jobs/admin/_posting_fields', ['item' => $template ?? []]) ?>
<?php if ($isEdit): ?>
<div class="mb-3">
<label class="form-label" for="save_mode">Save Mode</label>
<select id="save_mode" name="save_mode" class="form-select">
<option value="overwrite">Overwrite current version</option>
<option value="new_version">Save as new version</option>
</select>
</div>
<?php endif; ?>
<button type="submit" class="btn btn-primary">Save Template</button>
<a href="<?= site_url('administrator/job-postings/templates') ?>" class="btn btn-outline-secondary">Cancel</a>
</form>
<?php if ($isEdit && !empty($versions)): ?>
<h3 class="h5 mt-5">Version History</h3>
<table class="table table-sm table-bordered">
<thead><tr><th>Version</th><th>Saved At</th><th>Actions</th></tr></thead>
<tbody>
<?php foreach ($versions as $version): ?>
<tr>
<td><?= esc($version['version']) ?></td>
<td><?= esc($version['saved_at']) ?></td>
<td>
<form action="<?= site_url('administrator/job-postings/templates/versions/' . $version['version_id'] . '/restore') ?>" method="post">
<?= csrf_field() ?>
<button class="btn btn-sm btn-outline-primary" type="submit">Restore</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<?= $this->endSection() ?>
+53
View File
@@ -0,0 +1,53 @@
<?= $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 Templates</h2>
<div class="d-flex gap-2">
<a href="<?= site_url('administrator/job-postings/positions') ?>" class="btn btn-outline-secondary">Open Positions</a>
<a href="<?= site_url('administrator/job-postings/templates/new') ?>" class="btn btn-primary">New Template</a>
</div>
</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; ?>
<div class="table-responsive">
<table class="table table-striped table-bordered no-mgmt-sticky">
<thead>
<tr>
<th>Title</th>
<th>Department</th>
<th>Location</th>
<th>Type</th>
<th>Version</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($templates as $template): ?>
<tr>
<td><?= esc($template['title']) ?></td>
<td><?= esc($template['department'] ?? '') ?></td>
<td><?= esc($template['location'] ?? '') ?></td>
<td><?= esc($template['employment_type'] ?? '') ?></td>
<td><?= esc($template['version']) ?></td>
<td><?= !empty($template['is_active']) ? 'Active' : 'Archived' ?></td>
<td class="d-flex gap-2">
<a class="btn btn-sm btn-outline-primary" href="<?= site_url('administrator/job-postings/templates/' . $template['template_id'] . '/edit') ?>">Edit</a>
<a class="btn btn-sm btn-outline-success" href="<?= site_url('administrator/job-postings/positions/new?template_id=' . $template['template_id']) ?>">Create Position</a>
<?php if (!empty($template['is_active'])): ?>
<form action="<?= site_url('administrator/job-postings/templates/' . $template['template_id'] . '/archive') ?>" method="post">
<?= csrf_field() ?>
<button class="btn btn-sm btn-outline-danger" type="submit">Archive</button>
</form>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?= $this->endSection() ?>
+187
View File
@@ -0,0 +1,187 @@
<?= $this->extend('layout/main_layout') ?>
<?= $this->section('styles') ?>
<style>
.job-apply-page {
max-width: 920px;
margin: 0 auto;
}
.job-apply-page h1 {
font-family: "Inter", sans-serif;
font-size: 2rem;
font-weight: 600;
line-height: 1.25;
margin-bottom: 2rem;
text-align: left;
}
.job-apply-page .form-label {
font-family: "Heebo", sans-serif;
font-weight: 500;
}
.job-apply-page .btn-primary {
background-color: #198754;
border-color: #198754;
color: #fff;
}
.job-apply-page .btn-primary:hover {
background-color: #146c43;
border-color: #146c43;
color: #fff;
}
.job-apply-page .field-error {
min-height: 1.25rem;
}
</style>
<?= $this->endSection() ?>
<?= $this->section('content') ?>
<div class="container py-5">
<a href="<?= site_url('careers/' . $position['position_id']) ?>" class="btn btn-outline-secondary btn-sm mb-4">Back to position</a>
<div class="row justify-content-center">
<div class="col-lg-8 job-apply-page">
<h1>Apply: <?= esc($position['title']) ?></h1>
<?php if (session('error')): ?><div class="alert alert-danger"><?= esc(session('error')) ?></div><?php endif; ?>
<?php if (session('errors')): ?>
<div class="alert alert-danger">
<?php foreach ((array) session('errors') as $error): ?>
<div><?= esc($error) ?></div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<p class="text-secondary mb-3">(All fields with * are required)</p>
<form action="<?= site_url('careers/' . $position['position_id'] . '/apply') ?>" method="post" enctype="multipart/form-data" class="mt-4">
<?= csrf_field() ?>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label" for="first_name">First Name*</label>
<input id="first_name" name="first_name" class="form-control" maxlength="30" pattern="[A-Za-z\s-]{2,30}" placeholder="First Name*" title="2-30 characters. Letters, spaces, and dashes only." value="<?= esc(old('first_name')) ?>" required>
<div class="form-text text-muted">2-30 characters. Letters, spaces, and dashes only.</div>
<div id="first_name-error" class="text-danger small field-error"></div>
</div>
<div class="col-md-6">
<label class="form-label" for="last_name">Last Name*</label>
<input id="last_name" name="last_name" class="form-control" maxlength="30" pattern="[A-Za-z\s-]{2,30}" placeholder="Last Name*" title="2-30 characters. Letters, spaces, and dashes only." value="<?= esc(old('last_name')) ?>" required>
<div class="form-text text-muted">2-30 characters. Letters, spaces, and dashes only.</div>
<div id="last_name-error" class="text-danger small field-error"></div>
</div>
<div class="col-md-6">
<label class="form-label" for="email">Email*</label>
<input id="email" type="email" name="email" class="form-control" maxlength="50" placeholder="Email*" value="<?= esc(old('email')) ?>" required>
<div class="form-text text-muted">Valid email format, maximum 50 characters.</div>
<div id="email-error" class="text-danger small field-error"></div>
</div>
<div class="col-md-6">
<label class="form-label" for="phone">Phone*</label>
<input id="phone" type="tel" name="phone" class="form-control" minlength="10" maxlength="20" inputmode="tel" pattern="[\d\s\-\(\)\.]+" placeholder="Phone*" title="Enter a valid 10-digit phone number, for example 123-456-7890" value="<?= esc(old('phone')) ?>" required>
<div class="form-text text-muted">Enter a 10-digit phone number, for example 123-456-7890.</div>
<div id="phone-error" class="text-danger small field-error"></div>
</div>
<div class="col-12">
<label class="form-label" for="resume">Resume*</label>
<input id="resume" type="file" name="resume" class="form-control" accept=".pdf,.doc,.docx" required>
<div class="form-text">PDF, DOC, or DOCX. Maximum size 5 MB.</div>
</div>
</div>
<button type="submit" class="btn btn-primary mt-4">Submit Application</button>
</form>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function () {
const form = document.querySelector('.job-apply-page form');
const fields = {
first_name: {
element: document.getElementById('first_name'),
validate: value => /^[A-Za-z\s-]{2,30}$/.test(value),
message: 'First name must be 2-30 characters and use only letters, spaces, and dashes.'
},
last_name: {
element: document.getElementById('last_name'),
validate: value => /^[A-Za-z\s-]{2,30}$/.test(value),
message: 'Last name must be 2-30 characters and use only letters, spaces, and dashes.'
},
email: {
element: document.getElementById('email'),
validate: value => value.length <= 50 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value),
message: 'Please enter a valid email address, maximum 50 characters.'
},
phone: {
element: document.getElementById('phone'),
validate: value => value.replace(/\D/g, '').length === 10,
message: 'Please enter a valid 10-digit phone number.'
}
};
function setFieldState(input, valid, message) {
const error = document.getElementById(input.id + '-error');
const hasValue = input.value.trim() !== '';
input.classList.toggle('is-valid', valid && hasValue);
input.classList.toggle('is-invalid', !valid && hasValue);
if (error) {
error.textContent = !valid && hasValue ? message : '';
}
}
function formatPhone(input) {
const digits = input.value.replace(/\D/g, '').slice(0, 10);
let formatted = digits;
if (digits.length > 3) {
formatted = digits.slice(0, 3) + '-' + digits.slice(3);
}
if (digits.length > 6) {
formatted = digits.slice(0, 3) + '-' + digits.slice(3, 6) + '-' + digits.slice(6);
}
input.value = formatted;
}
function validateField(name) {
const config = fields[name];
if (!config || !config.element) {
return true;
}
if (name === 'phone') {
formatPhone(config.element);
}
const value = config.element.value.trim();
const valid = config.validate(value);
setFieldState(config.element, valid, config.message);
return valid;
}
Object.keys(fields).forEach(name => {
const input = fields[name].element;
if (!input) {
return;
}
input.addEventListener('input', () => validateField(name));
input.addEventListener('blur', () => validateField(name));
});
if (form) {
form.addEventListener('submit', function (event) {
const ok = Object.keys(fields).every(validateField);
if (!ok) {
event.preventDefault();
const firstInvalid = form.querySelector('.is-invalid');
if (firstInvalid) {
firstInvalid.focus();
}
}
});
}
});
</script>
<?= $this->endSection() ?>
+14
View File
@@ -0,0 +1,14 @@
<?= $this->extend('layout/main_layout') ?>
<?= $this->section('content') ?>
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-lg-7">
<div class="alert alert-success">Thank you. Your application has been received.</div>
<p>We will review your submission and follow up if there is a match for the role.</p>
<a href="<?= site_url('careers') ?>" class="btn btn-primary">Return to Openings</a>
</div>
</div>
</div>
<?= $this->endSection() ?>
+110
View File
@@ -0,0 +1,110 @@
<?= $this->extend('layout/main_layout') ?>
<?= $this->section('styles') ?>
<style>
.job-posting-copy,
.job-posting-copy p,
.job-posting-copy li {
font-family: "Heebo", sans-serif;
font-size: 1rem;
line-height: 1.7;
}
.job-posting-copy h2,
.job-posting-copy h3 {
font-family: "Inter", sans-serif;
font-weight: 600;
}
</style>
<?= $this->endSection() ?>
<?= $this->section('content') ?>
<?php
$renderPostingText = static function (?string $text): string {
$lines = preg_split('/\r\n|\r|\n/', trim((string) $text));
$html = '';
$paragraph = [];
$list = [];
$flushParagraph = static function () use (&$html, &$paragraph): void {
if ($paragraph === []) {
return;
}
$html .= '<p>' . esc(implode(' ', $paragraph)) . '</p>';
$paragraph = [];
};
$flushList = static function () use (&$html, &$list): void {
if ($list === []) {
return;
}
$html .= '<ul>';
foreach ($list as $item) {
$html .= '<li>' . esc($item) . '</li>';
}
$html .= '</ul>';
$list = [];
};
foreach ($lines as $line) {
$line = trim((string) $line);
if ($line === '') {
$flushParagraph();
$flushList();
continue;
}
if (str_ends_with($line, ':')) {
$flushParagraph();
$flushList();
$html .= '<h3 class="h4 mt-4">' . esc(rtrim($line, ':')) . '</h3>';
continue;
}
if (str_starts_with($line, '- ')) {
$flushParagraph();
$list[] = substr($line, 2);
continue;
}
$flushList();
$paragraph[] = $line;
}
$flushParagraph();
$flushList();
return $html;
};
?>
<div class="container py-5">
<a href="<?= site_url('careers') ?>" class="btn btn-outline-secondary btn-sm mb-4">Back to openings</a>
<div class="row g-4">
<div class="col-lg-8 job-posting-copy">
<h1><?= esc($position['title']) ?></h1>
<p class="text-muted">
<?= esc($position['department'] ?? '') ?>
<?php if (!empty($position['location'])): ?> &middot; <?= esc($position['location']) ?><?php endif; ?>
<?php if (!empty($position['employment_type'])): ?> &middot; <?= esc($position['employment_type']) ?><?php endif; ?>
</p>
<h2 class="h4 mt-4">Description</h2>
<div><?= $renderPostingText($position['description'] ?? '') ?></div>
<h2 class="h4 mt-4">Requirements</h2>
<div><?= $renderPostingText($position['requirements'] ?? '') ?></div>
</div>
<div class="col-lg-4">
<div class="card">
<div class="card-body">
<h2 class="h5">Apply for this position</h2>
<p class="text-muted">Submit your contact information and resume for review.</p>
<a href="<?= site_url('careers/' . $position['position_id'] . '/apply') ?>" class="btn btn-primary w-100">Apply Now</a>
</div>
</div>
</div>
</div>
</div>
<?= $this->endSection() ?>
+4
View File
@@ -58,6 +58,7 @@ $role = strtolower(session()->get('role') ?? 'guest');
</a>
<div class="dropdown-menu" aria-labelledby="parentsManagementDropdown">
<a class="dropdown-item" href="/staff/index">Staff Profile</a>
<a class="dropdown-item" href="<?= site_url('administrator/job-postings/positions') ?>">Volunteer Positions</a>
<a class="dropdown-item" href="/administrator/teacher_class_assignment">Teacher Class Assignment</a>
</div>
</li>
@@ -190,6 +191,9 @@ $role = strtolower(session()->get('role') ?? 'guest');
</a>
<div class="dropdown-menu" aria-labelledby="parentsManagementDropdown">
<a class="dropdown-item" href="/staff/index">Staff Profile</a>
<?php if ($role === 'principal'): ?>
<a class="dropdown-item" href="<?= site_url('administrator/job-postings/positions') ?>">Volunteer Positions</a>
<?php endif; ?>
<a class="dropdown-item" href="/administrator/teacher_class_assignment">Teacher Class Assignment</a>
</div>
</li>
+1 -1
View File
@@ -2,7 +2,7 @@
## 1. Goal
Tighten re-enrollment eligibility for existing Al-Rahma Sunday School students without changing the separate new-student registration flow. Parents should only be able to submit re-enrollment for students already linked to their account and already transitioned or eligible for transition into the selected school year.
Tighten re-enrollment eligibility for existing Al Rahma Sunday School students without changing the separate new-student registration flow. Parents should only be able to submit re-enrollment for students already linked to their account and already transitioned or eligible for transition into the selected school year.
This plan matches the current project enrollment architecture: