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
+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() ?>