Commit 254c2711 authored by Administrator's avatar Administrator

Update 51 files via Son of Anton

parent 46a6b3b0
<?php
return [
// Phase 1
'base_salary' => Modules\Salary\Calculators\BaseSalaryCalculator::class,
'daily_rate' => Modules\Salary\Calculators\DailyRateCalculator::class,
'expected_working_days' => Modules\Salary\Calculators\ExpectedWorkingDaysCalculator::class,
'live_salary' => Modules\Salary\Calculators\LiveSalaryCalculator::class,
// Phase 2
'technical_auto_score' => Modules\Evaluations\Calculators\TechnicalAutoScoreCalculator::class,
'professional_auto_score' => Modules\Evaluations\Calculators\ProfessionalAutoScoreCalculator::class,
'overall_eval_score' => Modules\Evaluations\Calculators\OverallScoreCalculator::class,
];
\ No newline at end of file
<?php
return [
// Phase 1
'detect_unreported_days' => Modules\Reports\Jobs\DetectUnreportedDaysJob::class,
'auto_archive_done_cards' => Modules\Cards\Jobs\AutoArchiveDoneCardsJob::class,
'send_deadline_reminders' => Modules\Cards\Jobs\SendDeadlineRemindersJob::class,
'invite_expiry' => Modules\Onboarding\Jobs\InviteExpiryJob::class,
'session_cleanup' => Modules\Auth\Jobs\SessionCleanupJob::class,
'auto_apply_deductions' => Modules\Deductions\Jobs\AutoApplyExpiredDeductionsJob::class,
// Phase 2
'escalate_deadline_deductions' => Modules\Deductions\Jobs\EscalateDeadlineDeductionsJob::class,
'open_evaluation_cycle' => Modules\Evaluations\Jobs\OpenEvaluationCycleJob::class,
'evaluation_reminders' => Modules\Evaluations\Jobs\EvaluationReminderJob::class,
'compile_evaluations' => Modules\Evaluations\Jobs\CompileEvaluationsJob::class,
'pip_checkin_reminders' => Modules\PIPs\Jobs\PIPCheckinReminderJob::class,
'learning_goal_reminders' => Modules\LearningGoals\Jobs\LearningGoalReminderJob::class,
'meeting_reminders' => Modules\Meetings\Jobs\MeetingReminderJob::class,
'contract_expiry_warnings' => Modules\Contracts\Jobs\ContractExpiryWarningJob::class,
'create_recurring_cards' => Modules\RecurringCards\Jobs\CreateRecurringCardsJob::class,
];
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\BoardTemplates\Controllers;
use Engine\Core\Container;
use Engine\Core\Request;
use Engine\Core\Response;
use Engine\Database\Connection;
use Engine\Auth\PermissionEngine;
final class BoardTemplateController
{
private Connection $db;
private PermissionEngine $perms;
public function __construct()
{
$c = Container::getInstance();
$this->db = $c->resolve(Connection::class);
$this->perms = $c->resolve(PermissionEngine::class);
}
public function index(Request $request): Response
{
$templates = $this->db->fetchAll(
"SELECT bt.*, u.full_name_en as created_by_name FROM board_templates bt
JOIN users u ON u.id = bt.created_by_id ORDER BY bt.name"
);
return Response::json(['templates' => $templates]);
}
public function create(Request $request): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'boards.save_as_template');
$id = $this->db->insert('board_templates', [
'name' => $request->input('name'),
'description' => $request->input('description'),
'settings_json' => json_encode($request->input('settings', [])),
'columns_json' => json_encode($request->input('columns', [])),
'labels_json' => $request->input('labels') ? json_encode($request->input('labels')) : null,
'created_by_id' => $user['id'],
]);
return Response::json(['success' => true, 'id' => $id]);
}
public function saveFromBoard(Request $request, string $boardId): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'boards.save_as_template');
$board = $this->db->fetchOne("SELECT * FROM boards WHERE id = ?", [(int)$boardId]);
if (!$board) return Response::json(['error' => 'Board not found'], 404);
$columns = $this->db->fetchAll("SELECT name, slug, icon, position, is_system, wip_limit_per_user, wip_limit_total FROM board_columns WHERE board_id = ? ORDER BY position", [(int)$boardId]);
$labels = $this->db->fetchAll("SELECT text, bg_color, text_color FROM labels WHERE scope = 'board' AND board_id = ?", [(int)$boardId]);
$settings = [
'allow_contractor_card_creation' => $board['allow_contractor_card_creation'],
'auto_archive_done_days' => $board['auto_archive_done_days'],
'deadline_excludes_holidays' => $board['deadline_excludes_holidays'],
];
$id = $this->db->insert('board_templates', [
'name' => $request->input('name', $board['name'] . ' Template'),
'description' => $request->input('description', $board['description']),
'settings_json' => json_encode($settings),
'columns_json' => json_encode($columns),
'labels_json' => json_encode($labels),
'created_by_id' => $user['id'],
]);
return Response::json(['success' => true, 'id' => $id]);
}
public function delete(Request $request, string $templateId): Response
{
$user = $request->user();
if (!in_array($user['role'], ['super_admin', 'admin'])) {
return Response::json(['error' => 'Forbidden'], 403);
}
$this->db->delete('board_templates', 'id = ?', [(int)$templateId]);
return Response::json(['success' => true]);
}
}
\ No newline at end of file
<?php
use Engine\Core\Container;
$router = Container::getInstance()->resolve(\Engine\Core\Router::class);
$router->get('/board-templates', \Modules\BoardTemplates\Controllers\BoardTemplateController::class, 'index', ['auth', 'blocking']);
$router->post('/board-templates', \Modules\BoardTemplates\Controllers\BoardTemplateController::class, 'create', ['auth', 'blocking']);
$router->post('/board-templates/from-board/{boardId}', \Modules\BoardTemplates\Controllers\BoardTemplateController::class, 'saveFromBoard', ['auth', 'blocking']);
$router->delete('/board-templates/{id}', \Modules\BoardTemplates\Controllers\BoardTemplateController::class, 'delete', ['auth', 'blocking']);
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\CardTemplates\Controllers;
use Engine\Core\Container;
use Engine\Core\Request;
use Engine\Core\Response;
use Engine\Database\Connection;
final class CardTemplateController
{
private Connection $db;
public function __construct()
{
$this->db = Container::getInstance()->resolve(Connection::class);
}
public function index(Request $request): Response
{
$boardId = $request->query('board_id');
$sql = "SELECT ct.*, u.full_name_en as created_by_name FROM card_templates ct
JOIN users u ON u.id = ct.created_by_id WHERE ct.scope = 'organization'";
$params = [];
if ($boardId) {
$sql .= " OR (ct.scope = 'board' AND ct.board_id = ?)";
$params[] = (int)$boardId;
}
$sql .= " ORDER BY ct.name";
return Response::json(['templates' => $this->db->fetchAll($sql, $params)]);
}
public function create(Request $request): Response
{
$user = $request->user();
if (!in_array($user['role'], ['super_admin', 'admin', 'project_leader'])) {
return Response::json(['error' => 'Forbidden'], 403);
}
$id = $this->db->insert('card_templates', [
'name' => $request->input('name'),
'scope' => $request->input('scope', 'organization'),
'board_id' => $request->input('board_id') ? (int)$request->input('board_id') : null,
'title_template' => $request->input('title_template'),
'description_template' => $request->input('description_template'),
'priority' => $request->input('priority'),
'estimated_hours' => $request->input('estimated_hours') ? (float)$request->input('estimated_hours') : null,
'labels_json' => $request->input('labels') ? json_encode($request->input('labels')) : null,
'checklists_json' => $request->input('checklists') ? json_encode($request->input('checklists')) : null,
'created_by_id' => $user['id'],
]);
return Response::json(['success' => true, 'id' => $id]);
}
public function update(Request $request, string $templateId): Response
{
$user = $request->user();
$data = $request->only(['name', 'title_template', 'description_template', 'priority', 'estimated_hours']);
$data = array_filter($data, fn($v) => $v !== null);
if ($request->input('labels') !== null) $data['labels_json'] = json_encode($request->input('labels'));
if ($request->input('checklists') !== null) $data['checklists_json'] = json_encode($request->input('checklists'));
$this->db->update('card_templates', $data, 'id = ?', [(int)$templateId]);
return Response::json(['success' => true]);
}
public function delete(Request $request, string $templateId): Response
{
$user = $request->user();
if (!in_array($user['role'], ['super_admin', 'admin'])) {
return Response::json(['error' => 'Forbidden'], 403);
}
$this->db->delete('card_templates', 'id = ?', [(int)$templateId]);
return Response::json(['success' => true]);
}
}
\ No newline at end of file
<?php
use Engine\Core\Container;
$router = Container::getInstance()->resolve(\Engine\Core\Router::class);
$router->get('/card-templates', \Modules\CardTemplates\Controllers\CardTemplateController::class, 'index', ['auth', 'blocking']);
$router->post('/card-templates', \Modules\CardTemplates\Controllers\CardTemplateController::class, 'create', ['auth', 'blocking']);
$router->put('/card-templates/{id}', \Modules\CardTemplates\Controllers\CardTemplateController::class, 'update', ['auth', 'blocking']);
$router->delete('/card-templates/{id}', \Modules\CardTemplates\Controllers\CardTemplateController::class, 'delete', ['auth', 'blocking']);
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\Contracts\Controllers;
use Engine\Core\Container;
use Engine\Core\Request;
use Engine\Core\Response;
use Engine\Database\Connection;
use Engine\Auth\PermissionEngine;
final class ContractController
{
private Connection $db;
private PermissionEngine $perms;
public function __construct()
{
$c = Container::getInstance();
$this->db = $c->resolve(Connection::class);
$this->perms = $c->resolve(PermissionEngine::class);
}
public function index(Request $request): Response
{
$user = $request->user();
if ($user['role'] === 'contractor') {
$contracts = $this->db->fetchAll(
"SELECT id, contractor_type_at_signing, base_salary_at_signing, signed_at, created_at
FROM contracts WHERE contractor_id = ? ORDER BY signed_at DESC",
[$user['id']]
);
} else {
$this->perms->denyUnlessAllowed($user, 'contracts.view.any');
$contracts = $this->db->fetchAll(
"SELECT c.*, u.full_name_en as contractor_name FROM contracts c
JOIN users u ON u.id = c.contractor_id ORDER BY c.signed_at DESC"
);
}
return Response::json(['contracts' => $contracts]);
}
public function show(Request $request, string $contractId): Response
{
$user = $request->user();
$contract = $this->db->fetchOne("SELECT * FROM contracts WHERE id = ?", [(int)$contractId]);
if (!$contract) return Response::json(['error' => 'Not found'], 404);
if ($user['role'] === 'contractor' && $contract['contractor_id'] !== $user['id']) {
return Response::json(['error' => 'Forbidden'], 403);
}
$clauses = $this->db->fetchAll(
"SELECT * FROM contract_clause_acknowledgments WHERE contract_id = ?", [(int)$contractId]
);
return Response::json(['contract' => $contract, 'clauses' => $clauses]);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\Contracts\Controllers;
use Engine\Core\Container;
use Engine\Core\Request;
use Engine\Core\Response;
use Engine\Database\Connection;
use Engine\Auth\PermissionEngine;
use Engine\Notifications\NotificationManager;
final class NoticeController
{
private Connection $db;
private PermissionEngine $perms;
private NotificationManager $notif;
public function __construct()
{
$c = Container::getInstance();
$this->db = $c->resolve(Connection::class);
$this->perms = $c->resolve(PermissionEngine::class);
$this->notif = $c->resolve(NotificationManager::class);
}
public function index(Request $request): Response
{
$notices = $this->db->fetchAll(
"SELECT n.*, u.full_name_en as created_by_name,
(SELECT COUNT(*) FROM notice_recipients WHERE notice_id = n.id AND acknowledged_at IS NOT NULL) as ack_count,
(SELECT COUNT(*) FROM notice_recipients WHERE notice_id = n.id) as total_recipients
FROM notices n JOIN users u ON u.id = n.created_by_id ORDER BY n.created_at DESC LIMIT 100"
);
return Response::json(['notices' => $notices]);
}
public function create(Request $request): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'notices.create');
$noticeId = $this->db->insert('notices', [
'title' => $request->input('title'),
'content' => $request->input('content'),
'type' => $request->input('type', 'general_announcement'),
'is_blocking' => (int)($request->input('is_blocking', 0)),
'target_type' => $request->input('target_type', 'all_users'),
'target_filter_json' => $request->input('target_filter_json') ? json_encode($request->input('target_filter_json')) : null,
'priority' => $request->input('priority', 'normal'),
'created_by_id' => $user['id'],
]);
$recipientIds = $this->resolveRecipients(
$request->input('target_type', 'all_users'),
$request->input('target_filter_json')
);
$isBlocking = (bool)$request->input('is_blocking', 0);
foreach ($recipientIds as $rid) {
$this->db->insert('notice_recipients', [
'notice_id' => $noticeId,
'user_id' => $rid,
]);
if ($isBlocking) {
$this->notif->createBlocking($rid, $request->input('title'), $request->input('content'),
"/notices/{$noticeId}", 'notice', $noticeId);
} else {
$this->notif->createImportant($rid, $request->input('title'),
substr($request->input('content'), 0, 200),
"/notices/{$noticeId}", 'notice', $noticeId);
}
}
return Response::json(['success' => true, 'id' => $noticeId, 'recipients' => count($recipientIds)]);
}
public function acknowledgeNotice(Request $request, string $noticeId): Response
{
$user = $request->user();
$this->db->update('notice_recipients', [
'acknowledged_at' => date('Y-m-d H:i:s'),
], 'notice_id = ? AND user_id = ?', [(int)$noticeId, $user['id']]);
return Response::json(['success' => true]);
}
public function delete(Request $request, string $noticeId): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'notices.delete');
$this->db->delete('notices', 'id = ?', [(int)$noticeId]);
return Response::json(['success' => true]);
}
private function resolveRecipients(string $targetType, mixed $filter): array
{
switch ($targetType) {
case 'all_users':
return array_column($this->db->fetchAll("SELECT id FROM users WHERE is_active = 1"), 'id');
case 'all_contractors':
return array_column($this->db->fetchAll("SELECT id FROM users WHERE role = 'contractor' AND is_active = 1"), 'id');
case 'specific_users':
return is_array($filter) ? array_map('intval', $filter) : [];
case 'by_board':
$boardId = is_array($filter) ? (int)($filter['board_id'] ?? 0) : 0;
return array_column($this->db->fetchAll("SELECT user_id FROM board_members WHERE board_id = ?", [$boardId]), 'user_id');
case 'by_role':
$role = is_array($filter) ? ($filter['role'] ?? '') : '';
return array_column($this->db->fetchAll("SELECT id FROM users WHERE role = ? AND is_active = 1", [$role]), 'id');
default:
return [];
}
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\Contracts\Controllers;
use Engine\Core\Container;
use Engine\Core\Request;
use Engine\Core\Response;
use Engine\Database\Connection;
use Engine\Auth\PermissionEngine;
use Engine\Notifications\NotificationManager;
final class PolicyController
{
private Connection $db;
private PermissionEngine $perms;
private NotificationManager $notif;
public function __construct()
{
$c = Container::getInstance();
$this->db = $c->resolve(Connection::class);
$this->perms = $c->resolve(PermissionEngine::class);
$this->notif = $c->resolve(NotificationManager::class);
}
public function index(Request $request): Response
{
$policies = $this->db->fetchAll(
"SELECT p.*, (SELECT MAX(pv.version_number) FROM policy_versions pv WHERE pv.policy_id = p.id) as latest_version
FROM policies p WHERE p.is_archived = 0 ORDER BY p.name"
);
return Response::json(['policies' => $policies]);
}
public function show(Request $request, string $policyId): Response
{
$policy = $this->db->fetchOne("SELECT * FROM policies WHERE id = ?", [(int)$policyId]);
if (!$policy) return Response::json(['error' => 'Not found'], 404);
$versions = $this->db->fetchAll(
"SELECT pv.*, u.full_name_en as published_by_name FROM policy_versions pv
JOIN users u ON u.id = pv.published_by_id
WHERE pv.policy_id = ? ORDER BY pv.version_number DESC",
[(int)$policyId]
);
$latestVersion = $versions[0] ?? null;
$ackStatus = [];
if ($latestVersion) {
$ackStatus = $this->db->fetchAll(
"SELECT pa.*, u.full_name_en FROM policy_acknowledgments pa
JOIN users u ON u.id = pa.user_id WHERE pa.policy_version_id = ?",
[$latestVersion['id']]
);
}
return Response::json(['policy' => $policy, 'versions' => $versions, 'acknowledgments' => $ackStatus]);
}
public function create(Request $request): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'policies.manage');
$policyId = $this->db->insert('policies', [
'name' => $request->input('name'),
'requires_acknowledgment' => (int)($request->input('requires_acknowledgment', 1)),
]);
$this->db->insert('policy_versions', [
'policy_id' => $policyId,
'version_number' => 1,
'content' => $request->input('content'),
'published_at' => date('Y-m-d H:i:s'),
'published_by_id' => $user['id'],
]);
return Response::json(['success' => true, 'id' => $policyId]);
}
public function publish(Request $request, string $policyId): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'policies.manage');
$policy = $this->db->fetchOne("SELECT * FROM policies WHERE id = ?", [(int)$policyId]);
if (!$policy) return Response::json(['error' => 'Not found'], 404);
$latestVersion = (int)$this->db->fetchColumn(
"SELECT COALESCE(MAX(version_number), 0) FROM policy_versions WHERE policy_id = ?",
[(int)$policyId]
);
$versionId = $this->db->insert('policy_versions', [
'policy_id' => (int)$policyId,
'version_number' => $latestVersion + 1,
'content' => $request->input('content'),
'published_at' => date('Y-m-d H:i:s'),
'published_by_id' => $user['id'],
]);
if ($policy['requires_acknowledgment']) {
$contractors = $this->db->fetchAll(
"SELECT id FROM users WHERE status IN ('active','on_pip') AND is_active = 1"
);
foreach ($contractors as $c) {
$this->notif->createBlocking($c['id'], 'Policy Update: ' . $policy['name'],
"Policy \"{$policy['name']}\" has been updated to version " . ($latestVersion + 1) . ". Please review and acknowledge.",
"/policies/{$policyId}", 'policy', (int)$policyId);
}
}
return Response::json(['success' => true, 'version_id' => $versionId]);
}
public function acknowledge(Request $request, string $versionId): Response
{
$user = $request->user();
$exists = $this->db->fetchOne(
"SELECT id FROM policy_acknowledgments WHERE policy_version_id = ? AND user_id = ?",
[(int)$versionId, $user['id']]
);
if ($exists) return Response::json(['success' => true, 'already_acknowledged' => true]);
$this->db->insert('policy_acknowledgments', [
'policy_version_id' => (int)$versionId,
'user_id' => $user['id'],
'acknowledged_at' => date('Y-m-d H:i:s'),
]);
return Response::json(['success' => true]);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\Contracts\Jobs;
use Engine\Scheduler\JobInterface;
use Engine\Core\Container;
use Engine\Database\Connection;
use Engine\Notifications\NotificationManager;
final class ContractExpiryWarningJob implements JobInterface
{
private Connection $db;
private NotificationManager $notif;
public function __construct()
{
$c = Container::getInstance();
$this->db = $c->resolve(Connection::class);
$this->notif = $c->resolve(NotificationManager::class);
}
public function run(): void
{
$warningDays = [90, 60, 30];
foreach ($warningDays as $days) {
$targetDate = date('Y-m-d', strtotime("+{$days} days"));
$expiring = $this->db->fetchAll(
"SELECT id, full_name_en, contract_end_date FROM users
WHERE contract_end_date = ? AND status = 'active'",
[$targetDate]
);
foreach ($expiring as $u) {
$admins = $this->db->fetchAll("SELECT id FROM users WHERE role IN ('super_admin','admin') AND is_active = 1");
foreach ($admins as $a) {
$this->notif->createImportant($a['id'], "Contract Expiring in {$days} Days",
"{$u['full_name_en']}'s contract expires on {$u['contract_end_date']}.",
"/users/{$u['id']}", 'user', $u['id']);
}
}
}
}
}
\ No newline at end of file
<?php
use Engine\Core\Container;
$router = Container::getInstance()->resolve(\Engine\Core\Router::class);
$router->get('/contracts', \Modules\Contracts\Controllers\ContractController::class, 'index', ['auth', 'blocking']);
$router->get('/contracts/{id}', \Modules\Contracts\Controllers\ContractController::class, 'show', ['auth', 'blocking']);
$router->get('/policies', \Modules\Contracts\Controllers\PolicyController::class, 'index', ['auth', 'blocking']);
$router->get('/policies/{id}', \Modules\Contracts\Controllers\PolicyController::class, 'show', ['auth', 'blocking']);
$router->post('/policies', \Modules\Contracts\Controllers\PolicyController::class, 'create', ['auth', 'blocking']);
$router->post('/policies/{id}/publish', \Modules\Contracts\Controllers\PolicyController::class, 'publish', ['auth', 'blocking']);
$router->post('/policies/versions/{versionId}/acknowledge', \Modules\Contracts\Controllers\PolicyController::class, 'acknowledge', ['auth']);
$router->get('/notices', \Modules\Contracts\Controllers\NoticeController::class, 'index', ['auth', 'blocking']);
$router->post('/notices', \Modules\Contracts\Controllers\NoticeController::class, 'create', ['auth', 'blocking']);
$router->post('/notices/{id}/acknowledge', \Modules\Contracts\Controllers\NoticeController::class, 'acknowledgeNotice', ['auth']);
$router->delete('/notices/{id}', \Modules\Contracts\Controllers\NoticeController::class, 'delete', ['auth', 'blocking']);
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\DeductionPresets\Controllers;
use Engine\Core\Container;
use Engine\Core\Request;
use Engine\Core\Response;
use Engine\Database\Connection;
use Engine\Auth\PermissionEngine;
final class DeductionPresetController
{
private Connection $db;
private PermissionEngine $perms;
public function __construct()
{
$c = Container::getInstance();
$this->db = $c->resolve(Connection::class);
$this->perms = $c->resolve(PermissionEngine::class);
}
public function index(Request $request): Response
{
$presets = $this->db->fetchAll(
"SELECT dp.*, u.full_name_en as created_by_name FROM deduction_presets dp
JOIN users u ON u.id = dp.created_by_id ORDER BY dp.category, dp.sub_category"
);
return Response::json(['presets' => $presets]);
}
public function create(Request $request): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'deduction_presets.manage');
$id = $this->db->insert('deduction_presets', [
'name' => $request->input('name'),
'category' => $request->input('category'),
'sub_category' => $request->input('sub_category'),
'default_description' => $request->input('default_description'),
'created_by_id' => $user['id'],
]);
return Response::json(['success' => true, 'id' => $id]);
}
public function update(Request $request, string $presetId): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'deduction_presets.manage');
$data = $request->only(['name', 'category', 'sub_category', 'default_description']);
$data = array_filter($data, fn($v) => $v !== null);
$this->db->update('deduction_presets', $data, 'id = ?', [(int)$presetId]);
return Response::json(['success' => true]);
}
public function delete(Request $request, string $presetId): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'deduction_presets.manage');
$this->db->delete('deduction_presets', 'id = ?', [(int)$presetId]);
return Response::json(['success' => true]);
}
}
\ No newline at end of file
<?php
use Engine\Core\Container;
$router = Container::getInstance()->resolve(\Engine\Core\Router::class);
$router->get('/deduction-presets', \Modules\DeductionPresets\Controllers\DeductionPresetController::class, 'index', ['auth', 'blocking']);
$router->post('/deduction-presets', \Modules\DeductionPresets\Controllers\DeductionPresetController::class, 'create', ['auth', 'blocking']);
$router->put('/deduction-presets/{id}', \Modules\DeductionPresets\Controllers\DeductionPresetController::class, 'update', ['auth', 'blocking']);
$router->delete('/deduction-presets/{id}', \Modules\DeductionPresets\Controllers\DeductionPresetController::class, 'delete', ['auth', 'blocking']);
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\Deductions\Jobs;
use Engine\Scheduler\JobInterface;
use Engine\Core\Container;
use Engine\Database\Connection;
final class EscalateDeadlineDeductionsJob implements JobInterface
{
private Connection $db;
public function __construct()
{
$this->db = Container::getInstance()->resolve(Connection::class);
}
public function run(): void
{
$overdueCards = $this->db->fetchAll(
"SELECT c.id, c.card_key, c.deadline, c.board_id,
DATEDIFF(NOW(), c.deadline) as days_late,
ca.user_id as assignee_id
FROM cards c
JOIN card_assignments ca ON ca.card_id = c.id
WHERE c.deadline IS NOT NULL AND c.deadline < NOW()
AND c.done_at IS NULL AND c.is_archived = 0"
);
foreach ($overdueCards as $card) {
$daysLate = (int)$card['days_late'];
if ($daysLate <= 0) continue;
$subCategory = match(true) {
$daysLate >= 15 => 'A4',
$daysLate >= 8 => 'A3',
$daysLate >= 4 => 'A2',
default => 'A1',
};
$existing = $this->db->fetchOne(
"SELECT id, sub_category FROM deductions
WHERE related_card_id = ? AND contractor_id = ? AND category = 'A'
AND status NOT IN ('dismissed','applied','applied_no_response','reduced','accepted')
AND deleted_at IS NULL",
[$card['id'], $card['assignee_id']]
);
if ($existing) {
if ($existing['sub_category'] !== $subCategory) {
$contractor = $this->db->fetchOne("SELECT actual_salary FROM users WHERE id = ?", [$card['assignee_id']]);
$actualSalary = (float)($contractor['actual_salary'] ?? 0);
$dailyRate = $actualSalary > 0 ? round($actualSalary / 22, 2) : 0;
$newAmount = match($subCategory) {
'A1' => round($dailyRate * 0.05 * $daysLate, 2),
'A2' => round($dailyRate * 0.10 * $daysLate, 2),
'A3' => round($dailyRate * 0.15 * $daysLate, 2),
'A4' => round($actualSalary * 0.25, 2),
default => 0,
};
$this->db->update('deductions', [
'sub_category' => $subCategory,
'calculated_amount' => $newAmount,
'description' => "Auto-escalated: Card {$card['card_key']} is {$daysLate} days overdue.",
], 'id = ?', [$existing['id']]);
}
}
}
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\Evaluations\Calculators;
use Engine\Calculation\CalculatorInterface;
final class OverallScoreCalculator implements CalculatorInterface
{
public function calculate(array $context): mixed
{
$techScore = (float)($context['technical_score'] ?? 0);
$profScore = (float)($context['professional_score'] ?? 0);
$criteria = require ROOT_PATH . '/config/evaluation_criteria.php';
$techWeight = (float)($criteria['overall_weights']['technical'] ?? 0.5);
$profWeight = (float)($criteria['overall_weights']['professional'] ?? 0.5);
$overall = round(($techScore * $techWeight) + ($profScore * $profWeight), 2);
$rating = 'adequate';
$ratings = $criteria['ratings'] ?? [];
foreach ($ratings as $r) {
if ($overall >= $r['min'] && $overall <= $r['max']) {
$rating = $r['rating'];
break;
}
}
return [
'overall_score' => $overall,
'rating' => $rating,
];
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\Evaluations\Calculators;
use Engine\Calculation\CalculatorInterface;
use Engine\Core\Container;
use Engine\Database\Connection;
final class ProfessionalAutoScoreCalculator implements CalculatorInterface
{
private Connection $db;
public function __construct()
{
$this->db = Container::getInstance()->resolve(Connection::class);
}
public function calculate(array $context): mixed
{
$contractorId = (int)$context['contractor_id'];
$month = $context['month'];
$totalReports = (int)$this->db->fetchColumn(
"SELECT COUNT(*) FROM daily_reports WHERE user_id = ? AND report_date LIKE ?",
[$contractorId, $month . '%']
);
$onTimeReports = (int)$this->db->fetchColumn(
"SELECT COUNT(*) FROM daily_reports WHERE user_id = ? AND report_date LIKE ? AND is_on_time = 1",
[$contractorId, $month . '%']
);
$reportingCompliance = $totalReports > 0 ? round(($onTimeReports / $totalReports) * 5, 2) : 1.0;
$reportingCompliance = min(5.0, max(1.0, $reportingCompliance));
$violations = (int)$this->db->fetchColumn(
"SELECT COUNT(*) FROM deductions WHERE contractor_id = ? AND payroll_month = ?
AND status IN ('applied','applied_no_response','reduced','accepted') AND deleted_at IS NULL",
[$contractorId, $month]
);
$policyCompliance = max(1.0, min(5.0, 5.0 - ($violations * 0.5)));
return [
'reporting_compliance' => $reportingCompliance,
'policy_compliance' => round($policyCompliance, 2),
'total_reports' => $totalReports,
'on_time_reports' => $onTimeReports,
'violations' => $violations,
];
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\Evaluations\Calculators;
use Engine\Calculation\CalculatorInterface;
use Engine\Core\Container;
use Engine\Database\Connection;
final class TechnicalAutoScoreCalculator implements CalculatorInterface
{
private Connection $db;
public function __construct()
{
$this->db = Container::getInstance()->resolve(Connection::class);
}
public function calculate(array $context): mixed
{
$contractorId = (int)$context['contractor_id'];
$month = $context['month'];
$startDate = $month . '-01';
$endDate = date('Y-m-t', strtotime($startDate));
$cardsAssigned = (int)$this->db->fetchColumn(
"SELECT COUNT(DISTINCT ca.card_id) FROM card_assignments ca
JOIN cards c ON c.id = ca.card_id
WHERE ca.user_id = ? AND ca.created_at BETWEEN ? AND ?",
[$contractorId, $startDate . ' 00:00:00', $endDate . ' 23:59:59']
);
$cardsDone = (int)$this->db->fetchColumn(
"SELECT COUNT(DISTINCT c.id) FROM cards c
JOIN card_assignments ca ON ca.card_id = c.id
WHERE ca.user_id = ? AND c.done_at BETWEEN ? AND ?",
[$contractorId, $startDate . ' 00:00:00', $endDate . ' 23:59:59']
);
$cardsWithDeadline = (int)$this->db->fetchColumn(
"SELECT COUNT(DISTINCT c.id) FROM cards c
JOIN card_assignments ca ON ca.card_id = c.id
WHERE ca.user_id = ? AND c.deadline IS NOT NULL AND c.done_at BETWEEN ? AND ?",
[$contractorId, $startDate . ' 00:00:00', $endDate . ' 23:59:59']
);
$cardsOnTime = (int)$this->db->fetchColumn(
"SELECT COUNT(DISTINCT c.id) FROM cards c
JOIN card_assignments ca ON ca.card_id = c.id
WHERE ca.user_id = ? AND c.deadline IS NOT NULL AND c.done_at IS NOT NULL
AND c.done_at <= c.deadline AND c.done_at BETWEEN ? AND ?",
[$contractorId, $startDate . ' 00:00:00', $endDate . ' 23:59:59']
);
$taskCompletionRate = $cardsAssigned > 0 ? round(($cardsDone / $cardsAssigned) * 5, 2) : 3.0;
$deadlineCompliance = $cardsWithDeadline > 0 ? round(($cardsOnTime / $cardsWithDeadline) * 5, 2) : 3.0;
$taskCompletionRate = min(5.0, max(1.0, $taskCompletionRate));
$deadlineCompliance = min(5.0, max(1.0, $deadlineCompliance));
return [
'task_completion_rate' => $taskCompletionRate,
'deadline_compliance' => $deadlineCompliance,
'cards_assigned' => $cardsAssigned,
'cards_done' => $cardsDone,
'cards_with_deadline' => $cardsWithDeadline,
'cards_on_time' => $cardsOnTime,
];
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\Evaluations\Controllers;
use Engine\Core\Container;
use Engine\Core\Request;
use Engine\Core\Response;
use Engine\Database\Connection;
use Engine\Auth\PermissionEngine;
use Engine\Audit\AuditLogger;
use Engine\Notifications\NotificationManager;
use Engine\Calculation\CalculationEngine;
use Engine\Template\TemplateEngine;
final class EvaluationController
{
private Connection $db;
private PermissionEngine $perms;
private AuditLogger $audit;
private NotificationManager $notif;
private CalculationEngine $calc;
private TemplateEngine $templates;
public function __construct()
{
$c = Container::getInstance();
$this->db = $c->resolve(Connection::class);
$this->perms = $c->resolve(PermissionEngine::class);
$this->audit = $c->resolve(AuditLogger::class);
$this->notif = $c->resolve(NotificationManager::class);
$this->calc = $c->resolve(CalculationEngine::class);
$this->templates = $c->resolve(TemplateEngine::class);
}
public function myEvaluations(Request $request): Response
{
$user = $request->user();
$evaluations = $this->db->fetchAll(
"SELECT ce.*, ec.month FROM compiled_evaluations ce
JOIN evaluation_cycles ec ON ec.id = ce.cycle_id
WHERE ce.contractor_id = ? ORDER BY ec.month DESC",
[$user['id']]
);
if ($request->wantsJson()) {
return Response::json(['evaluations' => $evaluations]);
}
return Response::html($this->templates->render('evaluations/history', [
'user' => $user, 'evaluations' => $evaluations,
]));
}
public function showCompiled(Request $request, string $compiledId): Response
{
$user = $request->user();
$compiled = $this->db->fetchOne(
"SELECT ce.*, ec.month, u.full_name_en as contractor_name
FROM compiled_evaluations ce
JOIN evaluation_cycles ec ON ec.id = ce.cycle_id
JOIN users u ON u.id = ce.contractor_id
WHERE ce.id = ?",
[(int)$compiledId]
);
if (!$compiled) return Response::json(['error' => 'Not found'], 404);
if ($user['role'] === 'contractor' && $compiled['contractor_id'] !== $user['id']) {
return Response::json(['error' => 'Forbidden'], 403);
}
$techEval = $this->db->fetchOne(
"SELECT e.*, u.full_name_en as evaluator_name FROM evaluations e
JOIN users u ON u.id = e.evaluator_id
WHERE e.cycle_id = ? AND e.contractor_id = ? AND e.type = 'technical'",
[$compiled['cycle_id'], $compiled['contractor_id']]
);
$profEval = $this->db->fetchOne(
"SELECT e.*, u.full_name_en as evaluator_name FROM evaluations e
JOIN users u ON u.id = e.evaluator_id
WHERE e.cycle_id = ? AND e.contractor_id = ? AND e.type = 'professional'",
[$compiled['cycle_id'], $compiled['contractor_id']]
);
$techScores = $techEval ? $this->db->fetchAll(
"SELECT * FROM evaluation_criterion_scores WHERE evaluation_id = ?", [$techEval['id']]
) : [];
$profScores = $profEval ? $this->db->fetchAll(
"SELECT * FROM evaluation_criterion_scores WHERE evaluation_id = ?", [$profEval['id']]
) : [];
$data = [
'user' => $user,
'compiled' => $compiled,
'tech_eval' => $techEval,
'prof_eval' => $profEval,
'tech_scores' => $techScores,
'prof_scores' => $profScores,
];
if ($request->wantsJson()) return Response::json($data);
return Response::html($this->templates->render('evaluations/compiled', $data));
}
public function technicalForm(Request $request, string $evaluationId): Response
{
$user = $request->user();
$eval = $this->db->fetchOne("SELECT * FROM evaluations WHERE id = ? AND type = 'technical'", [(int)$evaluationId]);
if (!$eval) return Response::json(['error' => 'Not found'], 404);
if ($eval['evaluator_id'] !== $user['id'] && $user['role'] !== 'super_admin') {
return Response::json(['error' => 'Forbidden'], 403);
}
$contractor = $this->db->fetchOne("SELECT full_name_en FROM users WHERE id = ?", [$eval['contractor_id']]);
$cycle = $this->db->fetchOne("SELECT month FROM evaluation_cycles WHERE id = ?", [$eval['cycle_id']]);
$autoScores = [];
if ($this->calc->has('technical_auto_score')) {
$autoScores = $this->calc->calculate('technical_auto_score', [
'contractor_id' => $eval['contractor_id'],
'month' => $cycle['month'],
]);
}
$existingScores = $this->db->fetchAll(
"SELECT * FROM evaluation_criterion_scores WHERE evaluation_id = ?", [(int)$evaluationId]
);
$data = [
'user' => $user,
'evaluation' => $eval,
'contractor_name' => $contractor['full_name_en'],
'month' => $cycle['month'],
'auto_scores' => $autoScores,
'existing_scores' => $existingScores,
];
if ($request->wantsJson()) return Response::json($data);
return Response::html($this->templates->render('evaluations/technical_form', $data));
}
public function submitTechnical(Request $request, string $evaluationId): Response
{
$user = $request->user();
$eval = $this->db->fetchOne("SELECT * FROM evaluations WHERE id = ? AND type = 'technical'", [(int)$evaluationId]);
if (!$eval) return Response::json(['error' => 'Not found'], 404);
if ($eval['evaluator_id'] !== $user['id'] && $user['role'] !== 'super_admin') {
return Response::json(['error' => 'Forbidden'], 403);
}
if ($eval['submitted_at']) {
return Response::json(['error' => 'Already submitted'], 422);
}
$scores = $request->input('scores', []);
$criteria = require ROOT_PATH . '/config/evaluation_criteria.php';
$techCriteria = $criteria['technical'] ?? [];
$this->db->transaction(function () use ($eval, $evaluationId, $scores, $techCriteria) {
$this->db->delete('evaluation_criterion_scores', 'evaluation_id = ?', [(int)$evaluationId]);
$totalScore = 0;
foreach ($techCriteria as $key => $criterion) {
$autoVal = isset($scores[$key]['auto_value']) ? (float)$scores[$key]['auto_value'] : null;
$manualVal = isset($scores[$key]['manual_value']) ? (float)$scores[$key]['manual_value'] : null;
$finalVal = $manualVal ?? $autoVal ?? 3.0;
$weight = (float)$criterion['weight'];
$this->db->insert('evaluation_criterion_scores', [
'evaluation_id' => (int)$evaluationId,
'criterion_key' => $key,
'auto_value' => $autoVal,
'manual_value' => $manualVal,
'final_value' => $finalVal,
'weight' => $weight,
'justification' => $scores[$key]['justification'] ?? null,
]);
$totalScore += $finalVal * $weight;
}
$this->db->update('evaluations', [
'total_score' => round($totalScore, 2),
'submitted_at' => date('Y-m-d H:i:s'),
], 'id = ?', [(int)$evaluationId]);
});
$this->audit->log($user, 'EVALUATION_TECH_SUBMITTED', 'evaluation', (int)$evaluationId, 'evaluations',
"/evaluations/{$evaluationId}", null, null, $request->ip(), $request->userAgent());
return Response::json(['success' => true]);
}
public function professionalForm(Request $request, string $evaluationId): Response
{
$user = $request->user();
$eval = $this->db->fetchOne("SELECT * FROM evaluations WHERE id = ? AND type = 'professional'", [(int)$evaluationId]);
if (!$eval) return Response::json(['error' => 'Not found'], 404);
if ($eval['evaluator_id'] !== $user['id'] && $user['role'] !== 'super_admin') {
return Response::json(['error' => 'Forbidden'], 403);
}
$contractor = $this->db->fetchOne("SELECT full_name_en FROM users WHERE id = ?", [$eval['contractor_id']]);
$cycle = $this->db->fetchOne("SELECT month FROM evaluation_cycles WHERE id = ?", [$eval['cycle_id']]);
$autoScores = [];
if ($this->calc->has('professional_auto_score')) {
$autoScores = $this->calc->calculate('professional_auto_score', [
'contractor_id' => $eval['contractor_id'],
'month' => $cycle['month'],
]);
}
$existingScores = $this->db->fetchAll(
"SELECT * FROM evaluation_criterion_scores WHERE evaluation_id = ?", [(int)$evaluationId]
);
$data = [
'user' => $user,
'evaluation' => $eval,
'contractor_name' => $contractor['full_name_en'],
'month' => $cycle['month'],
'auto_scores' => $autoScores,
'existing_scores' => $existingScores,
];
if ($request->wantsJson()) return Response::json($data);
return Response::html($this->templates->render('evaluations/professional_form', $data));
}
public function submitProfessional(Request $request, string $evaluationId): Response
{
$user = $request->user();
$eval = $this->db->fetchOne("SELECT * FROM evaluations WHERE id = ? AND type = 'professional'", [(int)$evaluationId]);
if (!$eval) return Response::json(['error' => 'Not found'], 404);
if ($eval['evaluator_id'] !== $user['id'] && $user['role'] !== 'super_admin') {
return Response::json(['error' => 'Forbidden'], 403);
}
if ($eval['submitted_at']) {
return Response::json(['error' => 'Already submitted'], 422);
}
$scores = $request->input('scores', []);
$criteria = require ROOT_PATH . '/config/evaluation_criteria.php';
$profCriteria = $criteria['professional'] ?? [];
$this->db->transaction(function () use ($eval, $evaluationId, $scores, $profCriteria) {
$this->db->delete('evaluation_criterion_scores', 'evaluation_id = ?', [(int)$evaluationId]);
$totalScore = 0;
foreach ($profCriteria as $key => $criterion) {
$autoVal = isset($scores[$key]['auto_value']) ? (float)$scores[$key]['auto_value'] : null;
$manualVal = isset($scores[$key]['manual_value']) ? (float)$scores[$key]['manual_value'] : null;
$finalVal = $manualVal ?? $autoVal ?? 3.0;
$weight = (float)$criterion['weight'];
$this->db->insert('evaluation_criterion_scores', [
'evaluation_id' => (int)$evaluationId,
'criterion_key' => $key,
'auto_value' => $autoVal,
'manual_value' => $manualVal,
'final_value' => $finalVal,
'weight' => $weight,
'justification' => $scores[$key]['justification'] ?? null,
]);
$totalScore += $finalVal * $weight;
}
$this->db->update('evaluations', [
'total_score' => round($totalScore, 2),
'submitted_at' => date('Y-m-d H:i:s'),
], 'id = ?', [(int)$evaluationId]);
});
$this->audit->log($user, 'EVALUATION_PROF_SUBMITTED', 'evaluation', (int)$evaluationId, 'evaluations',
"/evaluations/{$evaluationId}", null, null, $request->ip(), $request->userAgent());
return Response::json(['success' => true]);
}
public function acknowledge(Request $request, string $compiledId): Response
{
$user = $request->user();
$compiled = $this->db->fetchOne(
"SELECT * FROM compiled_evaluations WHERE id = ? AND contractor_id = ?",
[(int)$compiledId, $user['id']]
);
if (!$compiled) return Response::json(['error' => 'Not found'], 404);
$this->db->update('compiled_evaluations', [
'acknowledged_at' => date('Y-m-d H:i:s'),
], 'id = ?', [(int)$compiledId]);
return Response::json(['success' => true]);
}
public function respond(Request $request, string $compiledId): Response
{
$user = $request->user();
$compiled = $this->db->fetchOne(
"SELECT * FROM compiled_evaluations WHERE id = ? AND contractor_id = ?",
[(int)$compiledId, $user['id']]
);
if (!$compiled) return Response::json(['error' => 'Not found'], 404);
$this->db->update('compiled_evaluations', [
'contractor_response' => $request->input('response'),
'responded_at' => date('Y-m-d H:i:s'),
], 'id = ?', [(int)$compiledId]);
return Response::json(['success' => true]);
}
public function pending(Request $request): Response
{
$user = $request->user();
$pending = $this->db->fetchAll(
"SELECT e.*, u.full_name_en as contractor_name, ec.month
FROM evaluations e
JOIN users u ON u.id = e.contractor_id
JOIN evaluation_cycles ec ON ec.id = e.cycle_id
WHERE e.evaluator_id = ? AND e.submitted_at IS NULL
ORDER BY ec.month DESC, u.full_name_en",
[$user['id']]
);
return Response::json(['pending' => $pending]);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\Evaluations\Controllers;
use Engine\Core\Container;
use Engine\Core\Request;
use Engine\Core\Response;
use Engine\Database\Connection;
use Engine\Auth\PermissionEngine;
use Engine\Audit\AuditLogger;
final class EvaluationCycleController
{
private Connection $db;
private PermissionEngine $perms;
private AuditLogger $audit;
public function __construct()
{
$c = Container::getInstance();
$this->db = $c->resolve(Connection::class);
$this->perms = $c->resolve(PermissionEngine::class);
$this->audit = $c->resolve(AuditLogger::class);
}
public function index(Request $request): Response
{
$user = $request->user();
if (!in_array($user['role'], ['super_admin', 'admin'])) {
return Response::json(['error' => 'Forbidden'], 403);
}
$cycles = $this->db->fetchAll(
"SELECT ec.*,
(SELECT COUNT(*) FROM evaluations WHERE cycle_id = ec.id AND type = 'technical' AND submitted_at IS NOT NULL) as tech_submitted,
(SELECT COUNT(*) FROM evaluations WHERE cycle_id = ec.id AND type = 'professional' AND submitted_at IS NOT NULL) as prof_submitted,
(SELECT COUNT(*) FROM compiled_evaluations WHERE cycle_id = ec.id) as compiled_count
FROM evaluation_cycles ec ORDER BY ec.month DESC"
);
return Response::json(['cycles' => $cycles]);
}
public function show(Request $request, string $cycleId): Response
{
$user = $request->user();
if (!in_array($user['role'], ['super_admin', 'admin'])) {
return Response::json(['error' => 'Forbidden'], 403);
}
$cycle = $this->db->fetchOne("SELECT * FROM evaluation_cycles WHERE id = ?", [(int)$cycleId]);
if (!$cycle) return Response::json(['error' => 'Not found'], 404);
$evaluations = $this->db->fetchAll(
"SELECT e.*, u.full_name_en as contractor_name, ev.full_name_en as evaluator_name
FROM evaluations e
JOIN users u ON u.id = e.contractor_id
JOIN users ev ON ev.id = e.evaluator_id
WHERE e.cycle_id = ?
ORDER BY u.full_name_en, e.type",
[(int)$cycleId]
);
$compiled = $this->db->fetchAll(
"SELECT ce.*, u.full_name_en as contractor_name
FROM compiled_evaluations ce
JOIN users u ON u.id = ce.contractor_id
WHERE ce.cycle_id = ?
ORDER BY ce.overall_score DESC",
[(int)$cycleId]
);
return Response::json([
'cycle' => $cycle,
'evaluations' => $evaluations,
'compiled' => $compiled,
]);
}
public function create(Request $request): Response
{
$user = $request->user();
if ($user['role'] !== 'super_admin') {
return Response::json(['error' => 'Forbidden'], 403);
}
$month = $request->input('month', date('Y-m', strtotime('-1 month')));
$exists = $this->db->fetchOne("SELECT id FROM evaluation_cycles WHERE month = ?", [$month]);
if ($exists) {
return Response::json(['error' => 'Evaluation cycle already exists for this month'], 422);
}
$now = date('Y-m-d H:i:s');
$techDeadline = date('Y-m-d H:i:s', strtotime('+5 weekdays'));
$profDeadline = date('Y-m-d H:i:s', strtotime('+7 weekdays'));
$cycleId = $this->db->transaction(function () use ($month, $now, $techDeadline, $profDeadline, $user) {
$cycleId = $this->db->insert('evaluation_cycles', [
'month' => $month,
'status' => 'open',
'opened_at' => $now,
'tech_deadline' => $techDeadline,
'prof_deadline' => $profDeadline,
]);
$contractors = $this->db->fetchAll(
"SELECT id FROM users WHERE role = 'contractor' AND status IN ('active','on_pip') AND is_active = 1"
);
foreach ($contractors as $contractor) {
$pl = $this->db->fetchOne(
"SELECT bm.user_id FROM board_members bm
JOIN board_members bm2 ON bm2.board_id = bm.board_id
WHERE bm2.user_id = ? AND bm.role_on_board = 'project_leader' LIMIT 1",
[$contractor['id']]
);
$plId = $pl ? $pl['user_id'] : $user['id'];
$this->db->insert('evaluations', [
'cycle_id' => $cycleId,
'contractor_id' => $contractor['id'],
'type' => 'technical',
'evaluator_id' => $plId,
]);
$admins = $this->db->fetchAll(
"SELECT id FROM users WHERE role IN ('admin','super_admin') AND is_active = 1 LIMIT 1"
);
$adminId = !empty($admins) ? $admins[0]['id'] : $user['id'];
$this->db->insert('evaluations', [
'cycle_id' => $cycleId,
'contractor_id' => $contractor['id'],
'type' => 'professional',
'evaluator_id' => $adminId,
]);
}
return $cycleId;
});
$this->audit->log($user, 'EVALUATION_CYCLE_CREATED', 'evaluation_cycle', $cycleId, 'evaluations',
'/evaluations/cycles', null, ['month' => $month], $request->ip(), $request->userAgent());
return Response::json(['success' => true, 'id' => $cycleId]);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\Evaluations\Jobs;
use Engine\Scheduler\JobInterface;
use Engine\Core\Container;
use Engine\Database\Connection;
use Engine\Notifications\NotificationManager;
use Engine\Calculation\CalculationEngine;
final class CompileEvaluationsJob implements JobInterface
{
private Connection $db;
private NotificationManager $notif;
private CalculationEngine $calc;
public function __construct()
{
$c = Container::getInstance();
$this->db = $c->resolve(Connection::class);
$this->notif = $c->resolve(NotificationManager::class);
$this->calc = $c->resolve(CalculationEngine::class);
}
public function run(): void
{
$cycle = $this->db->fetchOne(
"SELECT * FROM evaluation_cycles WHERE status IN ('open','technical_phase','professional_phase','compiling') LIMIT 1"
);
if (!$cycle) return;
$contractors = $this->db->fetchAll(
"SELECT DISTINCT contractor_id FROM evaluations WHERE cycle_id = ?",
[$cycle['id']]
);
$compiledCount = 0;
foreach ($contractors as $c) {
$cid = $c['contractor_id'];
$existing = $this->db->fetchOne(
"SELECT id FROM compiled_evaluations WHERE cycle_id = ? AND contractor_id = ?",
[$cycle['id'], $cid]
);
if ($existing) continue;
$tech = $this->db->fetchOne(
"SELECT total_score FROM evaluations WHERE cycle_id = ? AND contractor_id = ? AND type = 'technical' AND submitted_at IS NOT NULL",
[$cycle['id'], $cid]
);
$prof = $this->db->fetchOne(
"SELECT total_score FROM evaluations WHERE cycle_id = ? AND contractor_id = ? AND type = 'professional' AND submitted_at IS NOT NULL",
[$cycle['id'], $cid]
);
if (!$tech || !$prof) continue;
$result = $this->calc->calculate('overall_eval_score', [
'technical_score' => (float)$tech['total_score'],
'professional_score' => (float)$prof['total_score'],
]);
$metrics = [
'technical_score' => (float)$tech['total_score'],
'professional_score' => (float)$prof['total_score'],
'month' => $cycle['month'],
];
$this->db->insert('compiled_evaluations', [
'cycle_id' => $cycle['id'],
'contractor_id' => $cid,
'technical_score' => (float)$tech['total_score'],
'professional_score' => (float)$prof['total_score'],
'overall_score' => $result['overall_score'],
'rating' => $result['rating'],
'system_metrics_json' => json_encode($metrics),
'compiled_at' => date('Y-m-d H:i:s'),
]);
$this->notif->createBlocking($cid, 'Monthly Evaluation Published',
"Your evaluation for {$cycle['month']} has been compiled. Overall score: {$result['overall_score']}/5.00 ({$result['rating']})",
"/evaluations/compiled/{$cid}", 'compiled_evaluation', $cid);
if ($result['overall_score'] < 2.5) {
$admins = $this->db->fetchAll("SELECT id FROM users WHERE role = 'super_admin' AND is_active = 1");
foreach ($admins as $a) {
$this->notif->createImportant($a['id'], 'Low Evaluation Score Alert',
"Contractor ID {$cid} scored {$result['overall_score']} ({$result['rating']}). PIP recommended.",
"/users/{$cid}", 'user', $cid);
}
}
$compiledCount++;
}
$totalExpected = count($contractors);
$totalCompiled = (int)$this->db->fetchColumn(
"SELECT COUNT(*) FROM compiled_evaluations WHERE cycle_id = ?", [$cycle['id']]
);
if ($totalCompiled >= $totalExpected && $totalExpected > 0) {
$this->db->update('evaluation_cycles', [
'status' => 'completed',
'completed_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$cycle['id']]);
}
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\Evaluations\Jobs;
use Engine\Scheduler\JobInterface;
use Engine\Core\Container;
use Engine\Database\Connection;
use Engine\Notifications\NotificationManager;
final class EvaluationReminderJob implements JobInterface
{
private Connection $db;
private NotificationManager $notif;
public function __construct()
{
$c = Container::getInstance();
$this->db = $c->resolve(Connection::class);
$this->notif = $c->resolve(NotificationManager::class);
}
public function run(): void
{
$cycle = $this->db->fetchOne(
"SELECT * FROM evaluation_cycles WHERE status IN ('open','technical_phase','professional_phase') LIMIT 1"
);
if (!$cycle) return;
$now = time();
$techDeadline = strtotime($cycle['tech_deadline']);
$profDeadline = strtotime($cycle['prof_deadline']);
$daysToTech = (int)ceil(($techDeadline - $now) / 86400);
$daysToProf = (int)ceil(($profDeadline - $now) / 86400);
if ($daysToTech <= 2 && $daysToTech >= 0) {
$pending = $this->db->fetchAll(
"SELECT DISTINCT evaluator_id FROM evaluations WHERE cycle_id = ? AND type = 'technical' AND submitted_at IS NULL",
[$cycle['id']]
);
foreach ($pending as $p) {
$this->notif->createImportant($p['evaluator_id'], 'Technical Evaluation Due',
"Technical evaluations for {$cycle['month']} are due in {$daysToTech} day(s). Please submit them.",
'/evaluations/pending', 'evaluation_cycle', $cycle['id']);
}
}
if ($daysToProf <= 2 && $daysToProf >= 0) {
$pending = $this->db->fetchAll(
"SELECT DISTINCT evaluator_id FROM evaluations WHERE cycle_id = ? AND type = 'professional' AND submitted_at IS NULL",
[$cycle['id']]
);
foreach ($pending as $p) {
$this->notif->createImportant($p['evaluator_id'], 'Professional Evaluation Due',
"Professional evaluations for {$cycle['month']} are due in {$daysToProf} day(s). Please submit them.",
'/evaluations/pending', 'evaluation_cycle', $cycle['id']);
}
}
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\Evaluations\Jobs;
use Engine\Scheduler\JobInterface;
use Engine\Core\Container;
use Engine\Database\Connection;
final class OpenEvaluationCycleJob implements JobInterface
{
private Connection $db;
public function __construct()
{
$this->db = Container::getInstance()->resolve(Connection::class);
}
public function run(): void
{
if ((int)date('j') !== 1) return;
$month = date('Y-m', strtotime('-1 month'));
$exists = $this->db->fetchOne("SELECT id FROM evaluation_cycles WHERE month = ?", [$month]);
if ($exists) return;
$now = date('Y-m-d H:i:s');
$cycleId = $this->db->insert('evaluation_cycles', [
'month' => $month,
'status' => 'open',
'opened_at' => $now,
'tech_deadline' => date('Y-m-d 23:59:59', strtotime('+5 weekdays')),
'prof_deadline' => date('Y-m-d 23:59:59', strtotime('+7 weekdays')),
]);
$contractors = $this->db->fetchAll(
"SELECT u.id, (SELECT bm.user_id FROM board_members bm
JOIN board_members bm2 ON bm2.board_id = bm.board_id
WHERE bm2.user_id = u.id AND bm.role_on_board = 'project_leader' LIMIT 1) as pl_id
FROM users u WHERE u.role = 'contractor' AND u.status IN ('active','on_pip') AND u.is_active = 1"
);
$defaultAdmin = $this->db->fetchOne("SELECT id FROM users WHERE role IN ('admin','super_admin') AND is_active = 1 LIMIT 1");
$defaultAdminId = $defaultAdmin ? $defaultAdmin['id'] : 1;
foreach ($contractors as $c) {
$this->db->insert('evaluations', [
'cycle_id' => $cycleId,
'contractor_id' => $c['id'],
'type' => 'technical',
'evaluator_id' => $c['pl_id'] ?? $defaultAdminId,
]);
$this->db->insert('evaluations', [
'cycle_id' => $cycleId,
'contractor_id' => $c['id'],
'type' => 'professional',
'evaluator_id' => $defaultAdminId,
]);
}
}
}
\ No newline at end of file
<?php
use Engine\Core\Container;
$router = Container::getInstance()->resolve(\Engine\Core\Router::class);
$router->get('/evaluations', \Modules\Evaluations\Controllers\EvaluationController::class, 'myEvaluations', ['auth', 'blocking']);
$router->get('/evaluations/pending', \Modules\Evaluations\Controllers\EvaluationController::class, 'pending', ['auth', 'blocking']);
$router->get('/evaluations/compiled/{id}', \Modules\Evaluations\Controllers\EvaluationController::class, 'showCompiled', ['auth', 'blocking']);
$router->post('/evaluations/compiled/{id}/acknowledge', \Modules\Evaluations\Controllers\EvaluationController::class, 'acknowledge', ['auth']);
$router->post('/evaluations/compiled/{id}/respond', \Modules\Evaluations\Controllers\EvaluationController::class, 'respond', ['auth']);
$router->get('/evaluations/{id}/technical', \Modules\Evaluations\Controllers\EvaluationController::class, 'technicalForm', ['auth', 'blocking']);
$router->post('/evaluations/{id}/technical', \Modules\Evaluations\Controllers\EvaluationController::class, 'submitTechnical', ['auth', 'blocking']);
$router->get('/evaluations/{id}/professional', \Modules\Evaluations\Controllers\EvaluationController::class, 'professionalForm', ['auth', 'blocking']);
$router->post('/evaluations/{id}/professional', \Modules\Evaluations\Controllers\EvaluationController::class, 'submitProfessional', ['auth', 'blocking']);
$router->get('/evaluations/cycles', \Modules\Evaluations\Controllers\EvaluationCycleController::class, 'index', ['auth', 'blocking']);
$router->get('/evaluations/cycles/{id}', \Modules\Evaluations\Controllers\EvaluationCycleController::class, 'show', ['auth', 'blocking']);
$router->post('/evaluations/cycles', \Modules\Evaluations\Controllers\EvaluationCycleController::class, 'create', ['auth', 'blocking']);
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\LearningGoals\Controllers;
use Engine\Core\Container;
use Engine\Core\Request;
use Engine\Core\Response;
use Engine\Database\Connection;
final class CompetencyController
{
private Connection $db;
public function __construct()
{
$this->db = Container::getInstance()->resolve(Connection::class);
}
public function areas(Request $request): Response
{
$areas = $this->db->fetchAll("SELECT * FROM competency_areas WHERE is_active = 1 ORDER BY position");
return Response::json(['areas' => $areas]);
}
public function profile(Request $request, string $userId): Response
{
$user = $request->user();
$targetId = (int)$userId;
if ($targetId !== $user['id'] && !in_array($user['role'], ['super_admin', 'admin', 'project_leader'])) {
return Response::json(['error' => 'Forbidden'], 403);
}
$areas = $this->db->fetchAll("SELECT * FROM competency_areas WHERE is_active = 1 ORDER BY position");
$assessments = $this->db->fetchAll(
"SELECT ca.*, comp.name as area_name FROM competency_assessments ca
JOIN competency_areas comp ON comp.id = ca.competency_area_id
WHERE ca.contractor_id = ? ORDER BY ca.created_at DESC",
[$targetId]
);
$selfScores = [];
$plScores = [];
foreach ($assessments as $a) {
if ($a['assessor_type'] === 'self' && !isset($selfScores[$a['competency_area_id']])) {
$selfScores[$a['competency_area_id']] = $a['level'];
}
if ($a['assessor_type'] === 'project_leader' && !isset($plScores[$a['competency_area_id']])) {
$plScores[$a['competency_area_id']] = $a['level'];
}
}
$radarData = [];
foreach ($areas as $area) {
$radarData[] = [
'area' => $area['name'],
'area_id' => $area['id'],
'self' => $selfScores[$area['id']] ?? 0,
'pl' => $plScores[$area['id']] ?? 0,
];
}
return Response::json(['radar' => $radarData, 'areas' => $areas, 'assessments' => $assessments]);
}
public function submitAssessment(Request $request, string $userId): Response
{
$user = $request->user();
$targetId = (int)$userId;
$assessorType = ($targetId === $user['id']) ? 'self' : 'project_leader';
if ($assessorType === 'project_leader' && !in_array($user['role'], ['super_admin', 'admin', 'project_leader'])) {
return Response::json(['error' => 'Forbidden'], 403);
}
$scores = $request->input('scores', []);
foreach ($scores as $areaId => $level) {
$this->db->insert('competency_assessments', [
'contractor_id' => $targetId,
'competency_area_id' => (int)$areaId,
'assessor_type' => $assessorType,
'assessor_id' => $user['id'],
'level' => min(5, max(0, (int)$level)),
]);
}
return Response::json(['success' => true]);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\LearningGoals\Controllers;
use Engine\Core\Container;
use Engine\Core\Request;
use Engine\Core\Response;
use Engine\Database\Connection;
use Engine\Auth\PermissionEngine;
use Engine\Audit\AuditLogger;
use Engine\Notifications\NotificationManager;
final class LearningGoalController
{
private Connection $db;
private PermissionEngine $perms;
private AuditLogger $audit;
private NotificationManager $notif;
public function __construct()
{
$c = Container::getInstance();
$this->db = $c->resolve(Connection::class);
$this->perms = $c->resolve(PermissionEngine::class);
$this->audit = $c->resolve(AuditLogger::class);
$this->notif = $c->resolve(NotificationManager::class);
}
public function index(Request $request): Response
{
$user = $request->user();
$contractorId = (int)($request->query('contractor_id', $user['id']));
if ($contractorId !== $user['id'] && !in_array($user['role'], ['super_admin', 'admin'])) {
if ($user['role'] === 'project_leader') {
$onTeam = $this->db->fetchOne(
"SELECT 1 FROM board_members bm1 JOIN board_members bm2 ON bm2.board_id = bm1.board_id
WHERE bm1.user_id = ? AND bm2.user_id = ?",
[$user['id'], $contractorId]
);
if (!$onTeam) return Response::json(['error' => 'Forbidden'], 403);
} else {
return Response::json(['error' => 'Forbidden'], 403);
}
}
$goals = $this->db->fetchAll(
"SELECT lg.*, ca.name as competency_name, u.full_name_en as assessed_by_name
FROM learning_goals lg
JOIN competency_areas ca ON ca.id = lg.competency_area_id
LEFT JOIN users u ON u.id = lg.assessed_by_id
WHERE lg.contractor_id = ? AND lg.deleted_at IS NULL
ORDER BY FIELD(lg.status, 'active', 'overdue', 'extended', 'passed', 'failed'), lg.deadline",
[$contractorId]
);
return Response::json(['goals' => $goals]);
}
public function create(Request $request): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'learning_goals.create');
$id = $this->db->insert('learning_goals', [
'contractor_id' => (int)$request->input('contractor_id'),
'competency_area_id' => (int)$request->input('competency_area_id'),
'title' => $request->input('title'),
'description' => $request->input('description'),
'assessment_method' => $request->input('assessment_method'),
'pass_fail_criteria' => $request->input('pass_fail_criteria'),
'deadline' => $request->input('deadline'),
'is_auto_generated' => (int)($request->input('is_auto_generated', 0)),
'created_by_id' => $user['id'],
]);
$this->notif->createImportant((int)$request->input('contractor_id'), 'New Learning Goal',
"New learning goal: {$request->input('title')} — due {$request->input('deadline')}",
"/learning-goals", 'learning_goal', $id);
return Response::json(['success' => true, 'id' => $id]);
}
public function update(Request $request, string $goalId): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'learning_goals.edit');
$goal = $this->db->fetchOne("SELECT * FROM learning_goals WHERE id = ? AND deleted_at IS NULL", [(int)$goalId]);
if (!$goal) return Response::json(['error' => 'Not found'], 404);
$data = $request->only(['title', 'description', 'assessment_method', 'pass_fail_criteria', 'deadline']);
$data = array_filter($data, fn($v) => $v !== null);
if (isset($data['deadline']) && $data['deadline'] > $goal['deadline']) {
$data['extension_count'] = $goal['extension_count'] + 1;
$data['extension_reason'] = $request->input('extension_reason');
$data['status'] = 'extended';
}
$this->db->update('learning_goals', $data, 'id = ?', [(int)$goalId]);
return Response::json(['success' => true]);
}
public function assess(Request $request, string $goalId): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'learning_goals.assess');
$goal = $this->db->fetchOne("SELECT * FROM learning_goals WHERE id = ? AND deleted_at IS NULL", [(int)$goalId]);
if (!$goal) return Response::json(['error' => 'Not found'], 404);
$result = $request->input('result');
if (!in_array($result, ['passed', 'failed'])) {
return Response::json(['error' => 'Invalid result'], 422);
}
$this->db->update('learning_goals', [
'status' => $result,
'assessed_by_id' => $user['id'],
'assessment_notes' => $request->input('assessment_notes'),
'assessed_at' => date('Y-m-d H:i:s'),
], 'id = ?', [(int)$goalId]);
$statusLabel = $result === 'passed' ? '✅ Passed' : '❌ Failed';
$this->notif->createImportant($goal['contractor_id'], "Learning Goal {$statusLabel}",
"Your learning goal \"{$goal['title']}\" has been assessed: {$statusLabel}",
"/learning-goals", 'learning_goal', (int)$goalId);
return Response::json(['success' => true]);
}
public function delete(Request $request, string $goalId): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'learning_goals.delete');
$this->db->update('learning_goals', ['deleted_at' => date('Y-m-d H:i:s')], 'id = ?', [(int)$goalId]);
return Response::json(['success' => true]);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\LearningGoals\Jobs;
use Engine\Scheduler\JobInterface;
use Engine\Core\Container;
use Engine\Database\Connection;
use Engine\Notifications\NotificationManager;
final class LearningGoalReminderJob implements JobInterface
{
private Connection $db;
private NotificationManager $notif;
public function __construct()
{
$c = Container::getInstance();
$this->db = $c->resolve(Connection::class);
$this->notif = $c->resolve(NotificationManager::class);
}
public function run(): void
{
$today = date('Y-m-d');
$reminderDays = [14, 7, 2, 0];
foreach ($reminderDays as $days) {
$targetDate = date('Y-m-d', strtotime("+{$days} days"));
$goals = $this->db->fetchAll(
"SELECT * FROM learning_goals WHERE deadline = ? AND status IN ('active','extended') AND deleted_at IS NULL",
[$targetDate]
);
foreach ($goals as $g) {
$msg = $days === 0
? "Learning goal \"{$g['title']}\" is due TODAY."
: "Learning goal \"{$g['title']}\" is due in {$days} days.";
$this->notif->createImportant($g['contractor_id'], '⏰ Learning Goal Deadline', $msg,
'/learning-goals', 'learning_goal', $g['id']);
}
}
$this->db->query(
"UPDATE learning_goals SET status = 'overdue' WHERE deadline < ? AND status = 'active' AND deleted_at IS NULL",
[$today]
);
}
}
\ No newline at end of file
<?php
use Engine\Core\Container;
$router = Container::getInstance()->resolve(\Engine\Core\Router::class);
$router->get('/learning-goals', \Modules\LearningGoals\Controllers\LearningGoalController::class, 'index', ['auth', 'blocking']);
$router->post('/learning-goals', \Modules\LearningGoals\Controllers\LearningGoalController::class, 'create', ['auth', 'blocking']);
$router->put('/learning-goals/{id}', \Modules\LearningGoals\Controllers\LearningGoalController::class, 'update', ['auth', 'blocking']);
$router->post('/learning-goals/{id}/assess', \Modules\LearningGoals\Controllers\LearningGoalController::class, 'assess', ['auth', 'blocking']);
$router->delete('/learning-goals/{id}', \Modules\LearningGoals\Controllers\LearningGoalController::class, 'delete', ['auth', 'blocking']);
$router->get('/competency/areas', \Modules\LearningGoals\Controllers\CompetencyController::class, 'areas', ['auth']);
$router->get('/competency/profile/{userId}', \Modules\LearningGoals\Controllers\CompetencyController::class, 'profile', ['auth', 'blocking']);
$router->post('/competency/assess/{userId}', \Modules\LearningGoals\Controllers\CompetencyController::class, 'submitAssessment', ['auth', 'blocking']);
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\Meetings\Controllers;
use Engine\Core\Container;
use Engine\Core\Request;
use Engine\Core\Response;
use Engine\Database\Connection;
use Engine\Auth\PermissionEngine;
use Engine\Audit\AuditLogger;
use Engine\Notifications\NotificationManager;
final class MeetingController
{
private Connection $db;
private PermissionEngine $perms;
private AuditLogger $audit;
private NotificationManager $notif;
public function __construct()
{
$c = Container::getInstance();
$this->db = $c->resolve(Connection::class);
$this->perms = $c->resolve(PermissionEngine::class);
$this->audit = $c->resolve(AuditLogger::class);
$this->notif = $c->resolve(NotificationManager::class);
}
public function index(Request $request): Response
{
$user = $request->user();
$upcoming = $request->query('upcoming', '1');
if (in_array($user['role'], ['super_admin', 'admin'])) {
$sql = "SELECT m.*, u.full_name_en as created_by_name FROM meetings m
JOIN users u ON u.id = m.created_by_id WHERE m.status != 'cancelled'";
$params = [];
} else {
$sql = "SELECT m.*, u.full_name_en as created_by_name FROM meetings m
JOIN users u ON u.id = m.created_by_id
JOIN meeting_invitees mi ON mi.meeting_id = m.id
WHERE mi.user_id = ? AND m.status != 'cancelled'";
$params = [$user['id']];
}
if ($upcoming === '1') {
$sql .= " AND m.meeting_date >= CURDATE()";
}
$sql .= " ORDER BY m.meeting_date, m.start_time";
$meetings = $this->db->fetchAll($sql, $params);
foreach ($meetings as &$m) {
$m['invitees'] = $this->db->fetchAll(
"SELECT mi.*, u.full_name_en, u.profile_photo_id FROM meeting_invitees mi
JOIN users u ON u.id = mi.user_id WHERE mi.meeting_id = ?",
[$m['id']]
);
}
return Response::json(['meetings' => $meetings]);
}
public function show(Request $request, string $meetingId): Response
{
$meeting = $this->db->fetchOne(
"SELECT m.*, u.full_name_en as created_by_name FROM meetings m
JOIN users u ON u.id = m.created_by_id WHERE m.id = ?",
[(int)$meetingId]
);
if (!$meeting) return Response::json(['error' => 'Not found'], 404);
$invitees = $this->db->fetchAll(
"SELECT mi.*, u.full_name_en, u.profile_photo_id FROM meeting_invitees mi
JOIN users u ON u.id = mi.user_id WHERE mi.meeting_id = ?",
[(int)$meetingId]
);
$notes = $this->db->fetchAll(
"SELECT mn.*, u.full_name_en as logged_by_name FROM meeting_notes mn
JOIN users u ON u.id = mn.logged_by_id WHERE mn.meeting_id = ? ORDER BY mn.created_at",
[(int)$meetingId]
);
return Response::json(['meeting' => $meeting, 'invitees' => $invitees, 'notes' => $notes]);
}
public function create(Request $request): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'meetings.create');
$meetingId = $this->db->transaction(function () use ($request, $user) {
$meetingId = $this->db->insert('meetings', [
'title' => $request->input('title'),
'description' => $request->input('description'),
'meeting_date' => $request->input('meeting_date'),
'start_time' => $request->input('start_time'),
'end_time' => $request->input('end_time'),
'location' => $request->input('location'),
'recurrence' => $request->input('recurrence', 'none'),
'related_entity_type' => $request->input('related_entity_type'),
'related_entity_id' => $request->input('related_entity_id') ? (int)$request->input('related_entity_id') : null,
'created_by_id' => $user['id'],
]);
$inviteeIds = $request->input('invitee_ids', []);
foreach ($inviteeIds as $iid) {
$this->db->insert('meeting_invitees', [
'meeting_id' => $meetingId,
'user_id' => (int)$iid,
]);
$this->notif->createImportant((int)$iid, 'Meeting Scheduled',
"Meeting: {$request->input('title')} on {$request->input('meeting_date')} at {$request->input('start_time')}",
"/meetings/{$meetingId}", 'meeting', $meetingId);
}
return $meetingId;
});
return Response::json(['success' => true, 'id' => $meetingId]);
}
public function update(Request $request, string $meetingId): Response
{
$user = $request->user();
$meeting = $this->db->fetchOne("SELECT * FROM meetings WHERE id = ?", [(int)$meetingId]);
if (!$meeting) return Response::json(['error' => 'Not found'], 404);
if ($meeting['created_by_id'] !== $user['id'] && !in_array($user['role'], ['super_admin', 'admin'])) {
return Response::json(['error' => 'Forbidden'], 403);
}
$data = $request->only(['title', 'description', 'meeting_date', 'start_time', 'end_time', 'location', 'status']);
$data = array_filter($data, fn($v) => $v !== null);
$this->db->update('meetings', $data, 'id = ?', [(int)$meetingId]);
if (($data['status'] ?? '') === 'cancelled') {
$invitees = $this->db->fetchAll("SELECT user_id FROM meeting_invitees WHERE meeting_id = ?", [(int)$meetingId]);
foreach ($invitees as $inv) {
$this->notif->createImportant($inv['user_id'], 'Meeting Cancelled',
"Meeting \"{$meeting['title']}\" on {$meeting['meeting_date']} has been cancelled.",
null, 'meeting', (int)$meetingId);
}
}
return Response::json(['success' => true]);
}
public function addNotes(Request $request, string $meetingId): Response
{
$user = $request->user();
$noteId = $this->db->insert('meeting_notes', [
'meeting_id' => (int)$meetingId,
'logged_by_id' => $user['id'],
'summary' => $request->input('summary'),
'action_items' => $request->input('action_items'),
]);
$attendeeIds = $request->input('attendee_ids', []);
foreach ($attendeeIds as $aid) {
$this->db->update('meeting_invitees', ['attended' => 1],
'meeting_id = ? AND user_id = ?', [(int)$meetingId, (int)$aid]);
}
$allInvitees = $this->db->fetchAll("SELECT user_id FROM meeting_invitees WHERE meeting_id = ?", [(int)$meetingId]);
foreach ($allInvitees as $inv) {
if (!in_array($inv['user_id'], $attendeeIds)) {
$this->db->update('meeting_invitees', ['attended' => 0],
'meeting_id = ? AND user_id = ?', [(int)$meetingId, (int)$inv['user_id']]);
}
}
$this->db->update('meetings', ['status' => 'completed'], 'id = ?', [(int)$meetingId]);
return Response::json(['success' => true, 'id' => $noteId]);
}
public function delete(Request $request, string $meetingId): Response
{
$user = $request->user();
if ($user['role'] !== 'super_admin') return Response::json(['error' => 'Forbidden'], 403);
$this->db->delete('meetings', 'id = ?', [(int)$meetingId]);
return Response::json(['success' => true]);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\Meetings\Jobs;
use Engine\Scheduler\JobInterface;
use Engine\Core\Container;
use Engine\Database\Connection;
use Engine\Notifications\NotificationManager;
final class MeetingReminderJob implements JobInterface
{
private Connection $db;
private NotificationManager $notif;
public function __construct()
{
$c = Container::getInstance();
$this->db = $c->resolve(Connection::class);
$this->notif = $c->resolve(NotificationManager::class);
}
public function run(): void
{
$now = time();
$oneHour = date('Y-m-d H:i:s', $now + 3600);
$oneHourAgo = date('Y-m-d H:i:s', $now + 3000);
$meetings = $this->db->fetchAll(
"SELECT m.* FROM meetings m
WHERE m.status = 'scheduled'
AND CONCAT(m.meeting_date, ' ', m.start_time) BETWEEN ? AND ?",
[$oneHourAgo, $oneHour]
);
foreach ($meetings as $m) {
$invitees = $this->db->fetchAll("SELECT user_id FROM meeting_invitees WHERE meeting_id = ?", [$m['id']]);
foreach ($invitees as $inv) {
$this->notif->createImportant($inv['user_id'], '⏰ Meeting in 1 Hour',
"Meeting: \"{$m['title']}\" starts in about 1 hour.",
"/meetings/{$m['id']}", 'meeting', $m['id']);
}
}
$tomorrow = date('Y-m-d', strtotime('+1 day'));
$tomorrowMeetings = $this->db->fetchAll(
"SELECT * FROM meetings WHERE meeting_date = ? AND status = 'scheduled'",
[$tomorrow]
);
foreach ($tomorrowMeetings as $m) {
$invitees = $this->db->fetchAll("SELECT user_id FROM meeting_invitees WHERE meeting_id = ?", [$m['id']]);
foreach ($invitees as $inv) {
$this->notif->createImportant($inv['user_id'], 'Meeting Tomorrow',
"Meeting: \"{$m['title']}\" is scheduled for tomorrow at {$m['start_time']}.",
"/meetings/{$m['id']}", 'meeting', $m['id']);
}
}
}
}
\ No newline at end of file
<?php
use Engine\Core\Container;
$router = Container::getInstance()->resolve(\Engine\Core\Router::class);
$router->get('/meetings', \Modules\Meetings\Controllers\MeetingController::class, 'index', ['auth', 'blocking']);
$router->get('/meetings/{id}', \Modules\Meetings\Controllers\MeetingController::class, 'show', ['auth', 'blocking']);
$router->post('/meetings', \Modules\Meetings\Controllers\MeetingController::class, 'create', ['auth', 'blocking']);
$router->put('/meetings/{id}', \Modules\Meetings\Controllers\MeetingController::class, 'update', ['auth', 'blocking']);
$router->post('/meetings/{id}/notes', \Modules\Meetings\Controllers\MeetingController::class, 'addNotes', ['auth', 'blocking']);
$router->delete('/meetings/{id}', \Modules\Meetings\Controllers\MeetingController::class, 'delete', ['auth', 'blocking']);
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\Offboarding\Controllers;
use Engine\Core\Container;
use Engine\Core\Request;
use Engine\Core\Response;
use Engine\Database\Connection;
use Engine\Auth\PermissionEngine;
use Engine\Audit\AuditLogger;
use Engine\Notifications\NotificationManager;
final class OffboardingController
{
private Connection $db;
private PermissionEngine $perms;
private AuditLogger $audit;
private NotificationManager $notif;
public function __construct()
{
$c = Container::getInstance();
$this->db = $c->resolve(Connection::class);
$this->perms = $c->resolve(PermissionEngine::class);
$this->audit = $c->resolve(AuditLogger::class);
$this->notif = $c->resolve(NotificationManager::class);
}
public function initiate(Request $request): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'users.terminate');
$contractorId = (int)$request->input('contractor_id');
$contractor = $this->db->fetchOne("SELECT * FROM users WHERE id = ?", [$contractorId]);
if (!$contractor) return Response::json(['error' => 'Contractor not found'], 404);
$effectiveDate = $request->input('effective_date', date('Y-m-d'));
$this->db->transaction(function () use ($contractor, $contractorId, $effectiveDate, $user, $request) {
$this->db->update('users', [
'status' => 'terminated',
'is_active' => 0,
], 'id = ?', [$contractorId]);
$this->db->insert('contractor_status_history', [
'user_id' => $contractorId,
'from_status' => $contractor['status'],
'to_status' => 'terminated',
'reason' => $request->input('reason'),
'changed_by_id' => $user['id'],
]);
$this->db->delete('sessions', 'user_id = ?', [$contractorId]);
$this->db->query(
"DELETE ca FROM card_assignments ca
JOIN cards c ON c.id = ca.card_id
WHERE ca.user_id = ? AND c.done_at IS NULL AND c.is_archived = 0",
[$contractorId]
);
});
$this->notif->createBlocking($contractorId, 'Termination Notice',
"Your engagement has been terminated effective {$effectiveDate}. Reason: {$request->input('reason')}");
$this->audit->log($user, 'CONTRACTOR_TERMINATED', 'user', $contractorId, 'offboarding',
"/offboarding", ['status' => $contractor['status']], ['status' => 'terminated'],
$request->ip(), $request->userAgent());
return Response::json(['success' => true]);
}
public function calculateFinalSettlement(Request $request, string $userId): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'payroll.calculate');
$contractorId = (int)$userId;
$contractor = $this->db->fetchOne("SELECT * FROM users WHERE id = ?", [$contractorId]);
if (!$contractor) return Response::json(['error' => 'Not found'], 404);
$month = date('Y-m');
$daysWorked = (int)$this->db->fetchColumn(
"SELECT COUNT(*) FROM daily_reports WHERE user_id = ? AND report_date LIKE ? AND status NOT IN ('draft','unreported')",
[$contractorId, $month . '%']
);
$actualSalary = (float)($contractor['actual_salary'] ?? 0);
$expectedDays = 22;
$proratedSalary = $expectedDays > 0 ? round(($actualSalary / $expectedDays) * $daysWorked, 2) : 0;
$bounties = (float)$this->db->fetchColumn(
"SELECT COALESCE(SUM(amount), 0) FROM bounty_payouts WHERE recipient_id = ? AND payroll_month = ?",
[$contractorId, $month]
);
$deductions = (float)$this->db->fetchColumn(
"SELECT COALESCE(SUM(COALESCE(final_amount, calculated_amount)), 0) FROM deductions
WHERE contractor_id = ? AND payroll_month = ?
AND status IN ('applied','applied_no_response','reduced','accepted') AND deleted_at IS NULL",
[$contractorId, $month]
);
$negAdj = (float)$this->db->fetchColumn(
"SELECT COALESCE(SUM(amount), 0) FROM manual_adjustments
WHERE contractor_id = ? AND effective_month = ? AND type = 'negative' AND status = 'approved' AND deleted_at IS NULL",
[$contractorId, $month]
);
$net = $proratedSalary + $bounties - $deductions - $negAdj;
return Response::json([
'contractor_id' => $contractorId,
'month' => $month,
'days_worked' => $daysWorked,
'prorated_salary' => $proratedSalary,
'bounties' => $bounties,
'deductions' => $deductions,
'negative_adjustments' => $negAdj,
'net_final' => max(0, $net),
]);
}
}
\ No newline at end of file
<?php
use Engine\Core\Container;
$router = Container::getInstance()->resolve(\Engine\Core\Router::class);
$router->post('/offboarding/initiate', \Modules\Offboarding\Controllers\OffboardingController::class, 'initiate', ['auth', 'blocking']);
$router->get('/offboarding/settlement/{userId}', \Modules\Offboarding\Controllers\OffboardingController::class, 'calculateFinalSettlement', ['auth', 'blocking']);
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\PIPs\Controllers;
use Engine\Core\Container;
use Engine\Core\Request;
use Engine\Core\Response;
use Engine\Database\Connection;
use Engine\Auth\PermissionEngine;
use Engine\Audit\AuditLogger;
use Engine\Notifications\NotificationManager;
use Engine\Template\TemplateEngine;
final class PIPController
{
private Connection $db;
private PermissionEngine $perms;
private AuditLogger $audit;
private NotificationManager $notif;
private TemplateEngine $templates;
public function __construct()
{
$c = Container::getInstance();
$this->db = $c->resolve(Connection::class);
$this->perms = $c->resolve(PermissionEngine::class);
$this->audit = $c->resolve(AuditLogger::class);
$this->notif = $c->resolve(NotificationManager::class);
$this->templates = $c->resolve(TemplateEngine::class);
}
public function index(Request $request): Response
{
$user = $request->user();
if ($user['role'] === 'contractor') {
$pips = $this->db->fetchAll(
"SELECT p.*, u.full_name_en as created_by_name FROM pips p
JOIN users u ON u.id = p.created_by_id
WHERE p.contractor_id = ? AND p.deleted_at IS NULL ORDER BY p.created_at DESC",
[$user['id']]
);
} elseif ($user['role'] === 'project_leader') {
$pips = $this->db->fetchAll(
"SELECT p.*, u.full_name_en as contractor_name, c.full_name_en as created_by_name
FROM pips p
JOIN users u ON u.id = p.contractor_id
JOIN users c ON c.id = p.created_by_id
WHERE p.contractor_id IN (
SELECT bm2.user_id FROM board_members bm1
JOIN board_members bm2 ON bm2.board_id = bm1.board_id
WHERE bm1.user_id = ?
) AND p.deleted_at IS NULL ORDER BY p.created_at DESC",
[$user['id']]
);
} else {
$pips = $this->db->fetchAll(
"SELECT p.*, u.full_name_en as contractor_name, c.full_name_en as created_by_name
FROM pips p
JOIN users u ON u.id = p.contractor_id
JOIN users c ON c.id = p.created_by_id
WHERE p.deleted_at IS NULL ORDER BY p.created_at DESC"
);
}
if ($request->wantsJson()) return Response::json(['pips' => $pips]);
return Response::html($this->templates->render('pips/index', ['user' => $user, 'pips' => $pips]));
}
public function show(Request $request, string $pipId): Response
{
$user = $request->user();
$pip = $this->db->fetchOne(
"SELECT p.*, u.full_name_en as contractor_name, c.full_name_en as created_by_name
FROM pips p JOIN users u ON u.id = p.contractor_id JOIN users c ON c.id = p.created_by_id
WHERE p.id = ? AND p.deleted_at IS NULL",
[(int)$pipId]
);
if (!$pip) return Response::json(['error' => 'Not found'], 404);
if ($user['role'] === 'contractor' && $pip['contractor_id'] !== $user['id']) {
return Response::json(['error' => 'Forbidden'], 403);
}
$targets = $this->db->fetchAll("SELECT * FROM pip_targets WHERE pip_id = ? ORDER BY position", [(int)$pipId]);
$checkins = $this->db->fetchAll(
"SELECT pc.*, u.full_name_en as logged_by_name FROM pip_checkins pc
LEFT JOIN users u ON u.id = pc.logged_by_id
WHERE pc.pip_id = ? ORDER BY pc.scheduled_date",
[(int)$pipId]
);
$data = ['user' => $user, 'pip' => $pip, 'targets' => $targets, 'checkins' => $checkins];
if ($request->wantsJson()) return Response::json($data);
return Response::html($this->templates->render('pips/detail', $data));
}
public function create(Request $request): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'pips.create');
$contractorId = (int)$request->input('contractor_id');
$durationDays = (int)$request->input('duration_days', 30);
$startDate = $request->input('start_date', date('Y-m-d'));
$endDate = date('Y-m-d', strtotime($startDate . " +{$durationDays} days"));
$pipId = $this->db->transaction(function () use ($request, $user, $contractorId, $durationDays, $startDate, $endDate) {
$pipId = $this->db->insert('pips', [
'contractor_id' => $contractorId,
'created_by_id' => $user['id'],
'duration_days' => $durationDays,
'start_date' => $startDate,
'end_date' => $endDate,
'specific_issues' => json_encode($request->input('specific_issues', [])),
'check_in_frequency' => $request->input('check_in_frequency', 'weekly'),
'check_in_day' => (int)$request->input('check_in_day', 1),
'success_criteria' => $request->input('success_criteria'),
'consequence_of_failure' => $request->input('consequence_of_failure', 'Termination of engagement.'),
'status' => 'created',
]);
$targets = $request->input('targets', []);
foreach ($targets as $i => $target) {
$this->db->insert('pip_targets', [
'pip_id' => $pipId,
'description' => $target['description'],
'target_metric' => $target['target_metric'],
'position' => $i + 1,
]);
}
$freq = $request->input('check_in_frequency', 'weekly');
$interval = $freq === 'biweekly' ? 14 : 7;
$checkDay = (int)$request->input('check_in_day', 1);
$current = strtotime($startDate);
$end = strtotime($endDate);
while ($current <= $end) {
$dow = (int)date('w', $current);
if ($dow === $checkDay) {
$this->db->insert('pip_checkins', [
'pip_id' => $pipId,
'scheduled_date' => date('Y-m-d', $current),
]);
$current = strtotime("+{$interval} days", $current);
} else {
$current = strtotime('+1 day', $current);
}
}
$this->db->update('users', ['status' => 'on_pip'], 'id = ?', [$contractorId]);
$this->db->insert('contractor_status_history', [
'user_id' => $contractorId,
'from_status' => 'active',
'to_status' => 'on_pip',
'reason' => 'PIP created',
'changed_by_id' => $user['id'],
]);
return $pipId;
});
$this->notif->createBlocking($contractorId, 'Performance Improvement Plan Issued',
"A PIP has been issued. Duration: {$durationDays} days. Please review the details.",
"/pips/{$pipId}", 'pip', $pipId);
$this->audit->log($user, 'PIP_CREATED', 'pip', $pipId, 'pips', '/pips',
null, ['contractor_id' => $contractorId, 'duration' => $durationDays],
$request->ip(), $request->userAgent());
return Response::json(['success' => true, 'id' => $pipId]);
}
public function acknowledge(Request $request, string $pipId): Response
{
$user = $request->user();
$pip = $this->db->fetchOne("SELECT * FROM pips WHERE id = ? AND contractor_id = ?", [(int)$pipId, $user['id']]);
if (!$pip) return Response::json(['error' => 'Not found'], 404);
$this->db->update('pips', [
'status' => 'acknowledged',
'acknowledged_at' => date('Y-m-d H:i:s'),
], 'id = ?', [(int)$pipId]);
$this->db->update('pips', ['status' => 'active'], 'id = ? AND status = ?', [(int)$pipId, 'acknowledged']);
return Response::json(['success' => true]);
}
public function logCheckin(Request $request, string $pipId, string $checkinId): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'pips.log_checkin');
$this->db->update('pip_checkins', [
'logged_by_id' => $user['id'],
'attendees_json' => json_encode($request->input('attendees', [])),
'observations' => $request->input('observations'),
'progress_notes' => $request->input('progress_notes'),
'logged_at' => date('Y-m-d H:i:s'),
], 'id = ? AND pip_id = ?', [(int)$checkinId, (int)$pipId]);
return Response::json(['success' => true]);
}
public function decide(Request $request, string $pipId): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'pips.close');
$pip = $this->db->fetchOne("SELECT * FROM pips WHERE id = ? AND deleted_at IS NULL", [(int)$pipId]);
if (!$pip) return Response::json(['error' => 'Not found'], 404);
$decision = $request->input('decision');
if (!in_array($decision, ['passed', 'failed'])) {
return Response::json(['error' => 'Invalid decision'], 422);
}
$this->db->transaction(function () use ($pip, $pipId, $decision, $user) {
$this->db->update('pips', [
'status' => $decision,
'result_decided_at' => date('Y-m-d H:i:s'),
'result_decided_by_id' => $user['id'],
], 'id = ?', [(int)$pipId]);
if ($decision === 'passed') {
$this->db->update('users', ['status' => 'active'], 'id = ?', [$pip['contractor_id']]);
$this->db->insert('contractor_status_history', [
'user_id' => $pip['contractor_id'],
'from_status' => 'on_pip',
'to_status' => 'active',
'reason' => 'PIP passed',
'changed_by_id' => $user['id'],
]);
$this->notif->createImportant($pip['contractor_id'], 'PIP Passed',
'You have successfully completed your Performance Improvement Plan. Your status has been restored to Active.',
'/dashboard');
} else {
$this->notif->createBlocking($pip['contractor_id'], 'PIP Failed',
'Your Performance Improvement Plan has been marked as failed. Management will contact you regarding next steps.',
"/pips/{$pipId}", 'pip', (int)$pipId);
$admins = $this->db->fetchAll("SELECT id FROM users WHERE role = 'super_admin' AND is_active = 1");
foreach ($admins as $a) {
$this->notif->createImportant($a['id'], 'PIP Failed — Termination Review',
"Contractor (PIP #{$pipId}) has failed their PIP. Termination review required.",
"/pips/{$pipId}", 'pip', (int)$pipId);
}
}
});
$this->audit->log($user, 'PIP_DECIDED', 'pip', (int)$pipId, 'pips', "/pips/{$pipId}",
null, ['decision' => $decision], $request->ip(), $request->userAgent());
return Response::json(['success' => true]);
}
public function delete(Request $request, string $pipId): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'pips.delete');
$this->db->update('pips', ['deleted_at' => date('Y-m-d H:i:s')], 'id = ?', [(int)$pipId]);
return Response::json(['success' => true]);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\PIPs\Jobs;
use Engine\Scheduler\JobInterface;
use Engine\Core\Container;
use Engine\Database\Connection;
use Engine\Notifications\NotificationManager;
final class PIPCheckinReminderJob implements JobInterface
{
private Connection $db;
private NotificationManager $notif;
public function __construct()
{
$c = Container::getInstance();
$this->db = $c->resolve(Connection::class);
$this->notif = $c->resolve(NotificationManager::class);
}
public function run(): void
{
$today = date('Y-m-d');
$checkins = $this->db->fetchAll(
"SELECT pc.*, p.contractor_id, p.created_by_id FROM pip_checkins pc
JOIN pips p ON p.id = pc.pip_id
WHERE pc.scheduled_date = ? AND pc.logged_at IS NULL AND p.status = 'active' AND p.deleted_at IS NULL",
[$today]
);
foreach ($checkins as $ci) {
$this->notif->createImportant($ci['contractor_id'], 'PIP Check-in Today',
'You have a PIP check-in scheduled for today.', "/pips/{$ci['pip_id']}", 'pip', (int)$ci['pip_id']);
$this->notif->createImportant($ci['created_by_id'], 'PIP Check-in Due',
"PIP check-in for contractor ID {$ci['contractor_id']} is scheduled today.",
"/pips/{$ci['pip_id']}", 'pip', (int)$ci['pip_id']);
}
}
}
\ No newline at end of file
<?php
use Engine\Core\Container;
$router = Container::getInstance()->resolve(\Engine\Core\Router::class);
$router->get('/pips', \Modules\PIPs\Controllers\PIPController::class, 'index', ['auth', 'blocking']);
$router->get('/pips/{id}', \Modules\PIPs\Controllers\PIPController::class, 'show', ['auth', 'blocking']);
$router->post('/pips', \Modules\PIPs\Controllers\PIPController::class, 'create', ['auth', 'blocking']);
$router->post('/pips/{id}/acknowledge', \Modules\PIPs\Controllers\PIPController::class, 'acknowledge', ['auth']);
$router->post('/pips/{id}/checkins/{checkinId}', \Modules\PIPs\Controllers\PIPController::class, 'logCheckin', ['auth', 'blocking']);
$router->post('/pips/{id}/decide', \Modules\PIPs\Controllers\PIPController::class, 'decide', ['auth', 'blocking']);
$router->delete('/pips/{id}', \Modules\PIPs\Controllers\PIPController::class, 'delete', ['auth', 'blocking']);
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\RecurringCards\Controllers;
use Engine\Core\Container;
use Engine\Core\Request;
use Engine\Core\Response;
use Engine\Database\Connection;
use Engine\Auth\PermissionEngine;
final class RecurringCardController
{
private Connection $db;
private PermissionEngine $perms;
public function __construct()
{
$c = Container::getInstance();
$this->db = $c->resolve(Connection::class);
$this->perms = $c->resolve(PermissionEngine::class);
}
public function index(Request $request): Response
{
$boardId = $request->query('board_id');
$sql = "SELECT rcd.*, b.name as board_name FROM recurring_card_definitions rcd
JOIN boards b ON b.id = rcd.board_id WHERE 1=1";
$params = [];
if ($boardId) {
$sql .= " AND rcd.board_id = ?";
$params[] = (int)$boardId;
}
$sql .= " ORDER BY rcd.created_at DESC";
return Response::json(['definitions' => $this->db->fetchAll($sql, $params)]);
}
public function create(Request $request): Response
{
$user = $request->user();
if (!in_array($user['role'], ['super_admin', 'admin', 'project_leader'])) {
return Response::json(['error' => 'Forbidden'], 403);
}
$frequency = $request->input('frequency');
$now = time();
$nextCreation = match($frequency) {
'daily' => date('Y-m-d H:i:s', strtotime('+1 day', $now)),
'weekly' => date('Y-m-d H:i:s', strtotime('next ' . ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'][$request->input('day_of_week', 1)])),
'biweekly' => date('Y-m-d H:i:s', strtotime('+2 weeks', $now)),
'monthly' => date('Y-m-d H:i:s', strtotime('+1 month', $now)),
default => date('Y-m-d H:i:s', strtotime('+' . (int)$request->input('frequency_days', 7) . ' days')),
};
$id = $this->db->insert('recurring_card_definitions', [
'board_id' => (int)$request->input('board_id'),
'card_template_json' => json_encode($request->input('card_template', [])),
'frequency' => $frequency,
'frequency_days' => $request->input('frequency_days') ? (int)$request->input('frequency_days') : null,
'day_of_week' => $request->input('day_of_week') !== null ? (int)$request->input('day_of_week') : null,
'day_of_month' => $request->input('day_of_month') !== null ? (int)$request->input('day_of_month') : null,
'assignees_json' => $request->input('assignees') ? json_encode($request->input('assignees')) : null,
'next_creation_at' => $nextCreation,
'created_by_id' => $user['id'],
]);
return Response::json(['success' => true, 'id' => $id]);
}
public function update(Request $request, string $defId): Response
{
$user = $request->user();
if (!in_array($user['role'], ['super_admin', 'admin', 'project_leader'])) {
return Response::json(['error' => 'Forbidden'], 403);
}
$data = [];
if ($request->input('card_template') !== null) $data['card_template_json'] = json_encode($request->input('card_template'));
if ($request->input('assignees') !== null) $data['assignees_json'] = json_encode($request->input('assignees'));
if ($request->input('is_active') !== null) $data['is_active'] = (int)$request->input('is_active');
$this->db->update('recurring_card_definitions', $data, 'id = ?', [(int)$defId]);
return Response::json(['success' => true]);
}
public function delete(Request $request, string $defId): Response
{
$user = $request->user();
if (!in_array($user['role'], ['super_admin', 'admin'])) {
return Response::json(['error' => 'Forbidden'], 403);
}
$this->db->delete('recurring_card_definitions', 'id = ?', [(int)$defId]);
return Response::json(['success' => true]);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\RecurringCards\Jobs;
use Engine\Scheduler\JobInterface;
use Engine\Core\Container;
use Engine\Database\Connection;
final class CreateRecurringCardsJob implements JobInterface
{
private Connection $db;
public function __construct()
{
$this->db = Container::getInstance()->resolve(Connection::class);
}
public function run(): void
{
$now = date('Y-m-d H:i:s');
$definitions = $this->db->fetchAll(
"SELECT * FROM recurring_card_definitions WHERE is_active = 1 AND (next_creation_at IS NULL OR next_creation_at <= ?)",
[$now]
);
foreach ($definitions as $def) {
$template = json_decode($def['card_template_json'], true);
if (!$template) continue;
$board = $this->db->fetchOne("SELECT * FROM boards WHERE id = ? AND is_archived = 0", [$def['board_id']]);
if (!$board) continue;
$backlogCol = $this->db->fetchOne(
"SELECT id FROM board_columns WHERE board_id = ? AND slug = 'backlog'", [$board['id']]
);
if (!$backlogCol) continue;
$this->db->transaction(function () use ($def, $template, $board, $backlogCol) {
$this->db->query("UPDATE boards SET card_sequence = card_sequence + 1 WHERE id = ?", [$board['id']]);
$updated = $this->db->fetchOne("SELECT card_sequence FROM boards WHERE id = ?", [$board['id']]);
$cardKey = $board['board_key'] . '-' . $updated['card_sequence'];
$title = ($template['title'] ?? 'Recurring Task') . ' — ' . date('M j, Y');
$cardId = $this->db->insert('cards', [
'board_id' => $board['id'],
'column_id' => $backlogCol['id'],
'card_number' => $updated['card_sequence'],
'card_key' => $cardKey,
'title' => $title,
'description' => $template['description'] ?? null,
'priority' => $template['priority'] ?? 'none',
'estimated_hours' => $template['estimated_hours'] ?? null,
'position_in_column' => 0,
'created_by_id' => $def['created_by_id'],
]);
$assignees = $def['assignees_json'] ? json_decode($def['assignees_json'], true) : [];
foreach ($assignees as $uid) {
$this->db->insert('card_assignments', [
'card_id' => $cardId,
'user_id' => (int)$uid,
'assigned_by_id' => $def['created_by_id'],
]);
}
$nextCreation = $this->calculateNextCreation($def);
$this->db->update('recurring_card_definitions', [
'last_created_at' => date('Y-m-d H:i:s'),
'next_creation_at' => $nextCreation,
], 'id = ?', [$def['id']]);
});
}
}
private function calculateNextCreation(array $def): string
{
$now = time();
return match($def['frequency']) {
'daily' => date('Y-m-d 04:00:00', strtotime('+1 day', $now)),
'weekly' => date('Y-m-d 04:00:00', strtotime('+1 week', $now)),
'biweekly' => date('Y-m-d 04:00:00', strtotime('+2 weeks', $now)),
'monthly' => date('Y-m-d 04:00:00', strtotime('+1 month', $now)),
'custom' => date('Y-m-d 04:00:00', strtotime('+' . (int)$def['frequency_days'] . ' days', $now)),
default => date('Y-m-d 04:00:00', strtotime('+1 week', $now)),
};
}
}
\ No newline at end of file
<?php
use Engine\Core\Container;
$router = Container::getInstance()->resolve(\Engine\Core\Router::class);
$router->get('/recurring-cards', \Modules\RecurringCards\Controllers\RecurringCardController::class, 'index', ['auth', 'blocking']);
$router->post('/recurring-cards', \Modules\RecurringCards\Controllers\RecurringCardController::class, 'create', ['auth', 'blocking']);
$router->put('/recurring-cards/{id}', \Modules\RecurringCards\Controllers\RecurringCardController::class, 'update', ['auth', 'blocking']);
$router->delete('/recurring-cards/{id}', \Modules\RecurringCards\Controllers\RecurringCardController::class, 'delete', ['auth', 'blocking']);
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\SavedFilters\Controllers;
use Engine\Core\Container;
use Engine\Core\Request;
use Engine\Core\Response;
use Engine\Database\Connection;
final class SavedFilterController
{
private Connection $db;
public function __construct()
{
$this->db = Container::getInstance()->resolve(Connection::class);
}
public function index(Request $request): Response
{
$user = $request->user();
$context = $request->query('context');
$sql = "SELECT * FROM saved_filters WHERE user_id = ?";
$params = [$user['id']];
if ($context) {
$sql .= " AND context = ?";
$params[] = $context;
}
$sql .= " ORDER BY name";
return Response::json(['filters' => $this->db->fetchAll($sql, $params)]);
}
public function create(Request $request): Response
{
$user = $request->user();
$id = $this->db->insert('saved_filters', [
'user_id' => $user['id'],
'context' => $request->input('context'),
'name' => $request->input('name'),
'filter_json' => json_encode($request->input('filter', [])),
]);
return Response::json(['success' => true, 'id' => $id]);
}
public function delete(Request $request, string $filterId): Response
{
$user = $request->user();
$this->db->delete('saved_filters', 'id = ? AND user_id = ?', [(int)$filterId, $user['id']]);
return Response::json(['success' => true]);
}
}
\ No newline at end of file
<?php
use Engine\Core\Container;
$router = Container::getInstance()->resolve(\Engine\Core\Router::class);
$router->get('/saved-filters', \Modules\SavedFilters\Controllers\SavedFilterController::class, 'index', ['auth']);
$router->post('/saved-filters', \Modules\SavedFilters\Controllers\SavedFilterController::class, 'create', ['auth']);
$router->delete('/saved-filters/{id}', \Modules\SavedFilters\Controllers\SavedFilterController::class, 'delete', ['auth']);
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\Schedules\Controllers;
use Engine\Core\Container;
use Engine\Core\Request;
use Engine\Core\Response;
use Engine\Database\Connection;
use Engine\Auth\PermissionEngine;
use Engine\Audit\AuditLogger;
use Engine\Notifications\NotificationManager;
use Engine\Calculation\CalculationEngine;
final class ScheduleController
{
private Connection $db;
private PermissionEngine $perms;
private AuditLogger $audit;
private NotificationManager $notif;
private CalculationEngine $calc;
public function __construct()
{
$c = Container::getInstance();
$this->db = $c->resolve(Connection::class);
$this->perms = $c->resolve(PermissionEngine::class);
$this->audit = $c->resolve(AuditLogger::class);
$this->notif = $c->resolve(NotificationManager::class);
$this->calc = $c->resolve(CalculationEngine::class);
}
public function currentSchedule(Request $request, string $userId): Response
{
$schedule = $this->db->fetchAll(
"SELECT * FROM user_schedule_days WHERE user_id = ? AND effective_to IS NULL ORDER BY day_of_week",
[(int)$userId]
);
return Response::json(['schedule' => $schedule]);
}
public function requests(Request $request): Response
{
$user = $request->user();
if (in_array($user['role'], ['super_admin', 'admin'])) {
$reqs = $this->db->fetchAll(
"SELECT scr.*, u.full_name_en as contractor_name FROM schedule_change_requests scr
JOIN users u ON u.id = scr.user_id ORDER BY scr.created_at DESC"
);
} else {
$reqs = $this->db->fetchAll(
"SELECT * FROM schedule_change_requests WHERE user_id = ? ORDER BY created_at DESC",
[$user['id']]
);
}
return Response::json(['requests' => $reqs]);
}
public function submitRequest(Request $request): Response
{
$user = $request->user();
$recentCount = (int)$this->db->fetchColumn(
"SELECT COUNT(*) FROM schedule_change_requests WHERE user_id = ? AND created_at > DATE_SUB(NOW(), INTERVAL 3 MONTH)",
[$user['id']]
);
$maxPerQuarter = (int)($this->db->fetchOne("SELECT value FROM system_settings WHERE `key` = 'max_schedule_changes_per_quarter'")['value'] ?? 1);
if ($recentCount >= $maxPerQuarter) {
return Response::json(['error' => 'Maximum schedule change requests per quarter exceeded.'], 422);
}
$effectiveDate = $request->input('effective_date');
$minNotice = (int)($this->db->fetchOne("SELECT value FROM system_settings WHERE `key` = 'schedule_change_notice_days'")['value'] ?? 7);
if (strtotime($effectiveDate) < strtotime("+{$minNotice} days")) {
return Response::json(['error' => "Effective date must be at least {$minNotice} days in the future."], 422);
}
$currentSchedule = $this->db->fetchAll(
"SELECT day_of_week, work_mode FROM user_schedule_days WHERE user_id = ? AND effective_to IS NULL ORDER BY day_of_week",
[$user['id']]
);
$currentJson = [];
foreach ($currentSchedule as $s) {
$currentJson[$s['day_of_week']] = $s['work_mode'];
}
$proposedSchedule = $request->input('proposed_schedule', []);
$currentBase = (float)($user['base_salary'] ?? 0);
$proposedBase = $this->calc->calculate('base_salary', [
'schedule' => $proposedSchedule,
'contractor_type' => $user['contractor_type'],
]);
$id = $this->db->insert('schedule_change_requests', [
'user_id' => $user['id'],
'proposed_schedule_json' => json_encode($proposedSchedule),
'current_schedule_json' => json_encode($currentJson),
'current_base_salary' => $currentBase,
'proposed_base_salary' => is_numeric($proposedBase) ? (float)$proposedBase : $currentBase,
'effective_date' => $effectiveDate,
'reason' => $request->input('reason'),
]);
$admins = $this->db->fetchAll("SELECT id FROM users WHERE role IN ('super_admin','admin') AND is_active = 1");
foreach ($admins as $a) {
$this->notif->createImportant($a['id'], 'Schedule Change Request',
"{$user['full_name_en']} has requested a schedule change effective {$effectiveDate}.",
"/schedules/requests/{$id}", 'schedule_change_request', $id);
}
return Response::json(['success' => true, 'id' => $id]);
}
public function reviewRequest(Request $request, string $requestId): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'schedules.approve_request');
$scr = $this->db->fetchOne("SELECT * FROM schedule_change_requests WHERE id = ?", [(int)$requestId]);
if (!$scr) return Response::json(['error' => 'Not found'], 404);
$action = $request->input('action');
if ($action === 'approve') {
$this->db->transaction(function () use ($scr, $requestId, $user, $request) {
$this->db->update('schedule_change_requests', [
'status' => 'approved',
'reviewed_by_id' => $user['id'],
'reviewed_at' => date('Y-m-d H:i:s'),
'review_reason' => $request->input('review_reason'),
], 'id = ?', [(int)$requestId]);
$this->db->query(
"UPDATE user_schedule_days SET effective_to = ? WHERE user_id = ? AND effective_to IS NULL",
[$scr['effective_date'], $scr['user_id']]
);
$proposed = json_decode($scr['proposed_schedule_json'], true);
foreach ($proposed as $dow => $mode) {
$this->db->insert('user_schedule_days', [
'user_id' => $scr['user_id'],
'day_of_week' => (int)$dow,
'work_mode' => $mode,
'effective_from' => $scr['effective_date'],
]);
}
$this->db->update('users', [
'base_salary' => $scr['proposed_base_salary'],
], 'id = ?', [$scr['user_id']]);
});
$this->notif->createImportant($scr['user_id'], 'Schedule Change Approved',
"Your schedule change request has been approved, effective {$scr['effective_date']}.");
} else {
$this->db->update('schedule_change_requests', [
'status' => 'rejected',
'reviewed_by_id' => $user['id'],
'reviewed_at' => date('Y-m-d H:i:s'),
'review_reason' => $request->input('review_reason'),
], 'id = ?', [(int)$requestId]);
$this->notif->createImportant($scr['user_id'], 'Schedule Change Rejected',
"Your schedule change request was rejected. Reason: " . ($request->input('review_reason') ?? 'Not specified'));
}
return Response::json(['success' => true]);
}
public function directEdit(Request $request, string $userId): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'schedules.edit_direct');
$targetId = (int)$userId;
$effectiveDate = $request->input('effective_date', date('Y-m-d'));
$this->db->transaction(function () use ($targetId, $effectiveDate, $request, $user) {
$this->db->query(
"UPDATE user_schedule_days SET effective_to = ? WHERE user_id = ? AND effective_to IS NULL",
[$effectiveDate, $targetId]
);
$schedule = $request->input('schedule', []);
foreach ($schedule as $dow => $mode) {
$this->db->insert('user_schedule_days', [
'user_id' => $targetId,
'day_of_week' => (int)$dow,
'work_mode' => $mode,
'effective_from' => $effectiveDate,
]);
}
});
$this->audit->log($user, 'SCHEDULE_DIRECT_EDIT', 'user', $targetId, 'schedules',
"/users/{$userId}/schedule", null, $request->input('schedule'),
$request->ip(), $request->userAgent());
return Response::json(['success' => true]);
}
}
\ No newline at end of file
<?php
use Engine\Core\Container;
$router = Container::getInstance()->resolve(\Engine\Core\Router::class);
$router->get('/schedules/users/{userId}', \Modules\Schedules\Controllers\ScheduleController::class, 'currentSchedule', ['auth', 'blocking']);
$router->get('/schedules/requests', \Modules\Schedules\Controllers\ScheduleController::class, 'requests', ['auth', 'blocking']);
$router->post('/schedules/requests', \Modules\Schedules\Controllers\ScheduleController::class, 'submitRequest', ['auth', 'blocking']);
$router->post('/schedules/requests/{id}/review', \Modules\Schedules\Controllers\ScheduleController::class, 'reviewRequest', ['auth', 'blocking']);
$router->post('/schedules/users/{userId}/edit', \Modules\Schedules\Controllers\ScheduleController::class, 'directEdit', ['auth', 'blocking']);
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\TeamAvailability\Controllers;
use Engine\Core\Container;
use Engine\Core\Request;
use Engine\Core\Response;
use Engine\Database\Connection;
final class TeamAvailabilityController
{
private Connection $db;
public function __construct()
{
$this->db = Container::getInstance()->resolve(Connection::class);
}
public function index(Request $request): Response
{
$user = $request->user();
if (!in_array($user['role'], ['super_admin', 'admin', 'project_leader'])) {
return Response::json(['error' => 'Forbidden'], 403);
}
$startDate = $request->query('start_date', date('Y-m-d'));
$endDate = $request->query('end_date', date('Y-m-d', strtotime('+6 days')));
$boardId = $request->query('board_id');
if ($user['role'] === 'project_leader' && $boardId) {
$members = $this->db->fetchAll(
"SELECT u.id, u.full_name_en, u.contractor_type FROM users u
JOIN board_members bm ON bm.user_id = u.id
WHERE bm.board_id = ? AND u.role = 'contractor' AND u.status = 'active' ORDER BY u.full_name_en",
[(int)$boardId]
);
} elseif (in_array($user['role'], ['super_admin', 'admin'])) {
$members = $this->db->fetchAll(
"SELECT id, full_name_en, contractor_type FROM users
WHERE role = 'contractor' AND status = 'active' ORDER BY full_name_en"
);
} else {
$members = $this->db->fetchAll(
"SELECT DISTINCT u.id, u.full_name_en, u.contractor_type FROM users u
JOIN board_members bm ON bm.user_id = u.id
JOIN board_members mybm ON mybm.board_id = bm.board_id AND mybm.user_id = ?
WHERE u.role = 'contractor' AND u.status = 'active' ORDER BY u.full_name_en",
[$user['id']]
);
}
$holidays = $this->db->fetchAll(
"SELECT * FROM holidays WHERE (start_date <= ? AND end_date >= ?) OR is_recurring = 1",
[$endDate, $startDate]
);
$holidayDates = [];
foreach ($holidays as $h) {
$s = strtotime($h['start_date']);
$e = strtotime($h['end_date']);
for ($d = $s; $d <= $e; $d += 86400) {
$holidayDates[date('Y-m-d', $d)] = $h['name'];
}
}
$availability = [];
foreach ($members as $m) {
$schedule = $this->db->fetchAll(
"SELECT day_of_week, work_mode FROM user_schedule_days
WHERE user_id = ? AND effective_to IS NULL",
[$m['id']]
);
$scheduleMap = [];
foreach ($schedule as $s) {
$scheduleMap[$s['day_of_week']] = $s['work_mode'];
}
$unavail = $this->db->fetchAll(
"SELECT start_date, end_date FROM unavailability_records
WHERE user_id = ? AND start_date <= ? AND end_date >= ?",
[$m['id'], $endDate, $startDate]
);
$unavailDates = [];
foreach ($unavail as $u) {
$s = strtotime($u['start_date']);
$e = strtotime($u['end_date']);
for ($d = $s; $d <= $e; $d += 86400) {
$unavailDates[date('Y-m-d', $d)] = true;
}
}
$days = [];
$current = strtotime($startDate);
$end = strtotime($endDate);
while ($current <= $end) {
$dateStr = date('Y-m-d', $current);
$dow = (int)date('w', $current);
if (isset($holidayDates[$dateStr])) {
$status = 'holiday';
} elseif (isset($unavailDates[$dateStr])) {
$status = 'unavailable';
} else {
$mode = $scheduleMap[$dow] ?? 'off';
$status = match($mode) {
'in_office' => 'in_office',
'remote' => 'remote',
default => 'off',
};
}
$days[$dateStr] = $status;
$current += 86400;
}
$availability[] = [
'user_id' => $m['id'],
'name' => $m['full_name_en'],
'type' => $m['contractor_type'],
'days' => $days,
];
}
return Response::json([
'availability' => $availability,
'holidays' => $holidayDates,
'start_date' => $startDate,
'end_date' => $endDate,
]);
}
}
\ No newline at end of file
<?php
use Engine\Core\Container;
$router = Container::getInstance()->resolve(\Engine\Core\Router::class);
$router->get('/team-availability', \Modules\TeamAvailability\Controllers\TeamAvailabilityController::class, 'index', ['auth', 'blocking']);
\ No newline at end of file
<?php $this->layout('layouts/app', ['title' => 'Evaluation Details']); ?>
<div class="container mx-auto px-4 py-6">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold">Evaluation: <?= htmlspecialchars($compiled['month']) ?></h1>
<a href="/evaluations" class="text-gray-400 hover:text-white">← Back</a>
</div>
<div class="bg-gray-800 rounded-lg p-6 mb-6">
<div class="text-center mb-6">
<div class="text-4xl font-bold text-blue-400"><?= number_format((float)$compiled['overall_score'], 2) ?>/5.00</div>
<div class="text-lg mt-2 <?= match($compiled['rating']) {
'exceptional' => 'text-yellow-400',
'strong' => 'text-green-400',
'adequate' => 'text-yellow-500',
'below_expectations' => 'text-orange-400',
'unacceptable' => 'text-red-400',
default => 'text-gray-400',
} ?>">
<?= htmlspecialchars(ucfirst(str_replace('_', ' ', $compiled['rating']))) ?>
</div>
</div>
<div class="grid grid-cols-2 gap-6">
<div>
<h3 class="font-semibold mb-3">Technical Score: <?= number_format((float)($compiled['technical_score'] ?? 0), 2) ?></h3>
<?php foreach ($tech_scores as $s): ?>
<div class="flex justify-between py-1 border-b border-gray-700">
<span class="text-gray-400"><?= htmlspecialchars($s['criterion_key']) ?></span>
<span><?= number_format((float)$s['final_value'], 2) ?></span>
</div>
<?php endforeach; ?>
</div>
<div>
<h3 class="font-semibold mb-3">Professional Score: <?= number_format((float)($compiled['professional_score'] ?? 0), 2) ?></h3>
<?php foreach ($prof_scores as $s): ?>
<div class="flex justify-between py-1 border-b border-gray-700">
<span class="text-gray-400"><?= htmlspecialchars($s['criterion_key']) ?></span>
<span><?= number_format((float)$s['final_value'], 2) ?></span>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
<?php if ($compiled['contractor_id'] === $user['id'] && !$compiled['acknowledged_at']): ?>
<form method="POST" action="/evaluations/compiled/<?= $compiled['id'] ?>/acknowledge" class="mb-4">
<button type="submit" class="w-full bg-blue-600 hover:bg-blue-700 text-white py-3 rounded-lg font-bold">
I Acknowledge This Evaluation
</button>
</form>
<?php endif; ?>
<?php if ($compiled['contractor_id'] === $user['id'] && $compiled['acknowledged_at'] && !$compiled['contractor_response']): ?>
<form method="POST" action="/evaluations/compiled/<?= $compiled['id'] ?>/respond" class="bg-gray-800 rounded-lg p-6">
<h3 class="font-semibold mb-3">Submit Your Response (Optional)</h3>
<textarea name="response" rows="4" class="w-full bg-gray-700 rounded p-3 text-white" placeholder="Your thoughts on this evaluation..."></textarea>
<button type="submit" class="mt-3 bg-green-600 hover:bg-green-700 text-white px-6 py-2 rounded">Submit Response</button>
</form>
<?php endif; ?>
<?php if ($compiled['contractor_response']): ?>
<div class="bg-gray-800 rounded-lg p-6 mt-4">
<h3 class="font-semibold mb-2">Your Response</h3>
<p class="text-gray-300"><?= nl2br(htmlspecialchars($compiled['contractor_response'])) ?></p>
<p class="text-gray-500 text-sm mt-2">Submitted: <?= htmlspecialchars($compiled['responded_at']) ?></p>
</div>
<?php endif; ?>
</div>
\ No newline at end of file
<?php $this->layout('layouts/app', ['title' => 'My Evaluations']); ?>
<div class="container mx-auto px-4 py-6">
<h1 class="text-2xl font-bold mb-6">My Evaluations</h1>
<?php if (empty($evaluations)): ?>
<div class="bg-gray-800 rounded-lg p-8 text-center text-gray-400">
<p>No evaluations yet. They are compiled at the end of each month.</p>
</div>
<?php else: ?>
<div class="space-y-4">
<?php foreach ($evaluations as $eval): ?>
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-semibold"><?= htmlspecialchars($eval['month']) ?></h2>
<span class="px-3 py-1 rounded-full text-sm font-bold
<?= match($eval['rating']) {
'exceptional' => 'bg-yellow-500 text-black',
'strong' => 'bg-green-600 text-white',
'adequate' => 'bg-yellow-600 text-black',
'below_expectations' => 'bg-orange-600 text-white',
'unacceptable' => 'bg-red-600 text-white',
default => 'bg-gray-600 text-white',
} ?>">
<?= htmlspecialchars(ucfirst(str_replace('_', ' ', $eval['rating']))) ?>
</span>
</div>
<div class="grid grid-cols-3 gap-4 text-center">
<div>
<div class="text-gray-400 text-sm">Technical</div>
<div class="text-xl font-bold"><?= number_format((float)$eval['technical_score'], 2) ?></div>
</div>
<div>
<div class="text-gray-400 text-sm">Professional</div>
<div class="text-xl font-bold"><?= number_format((float)$eval['professional_score'], 2) ?></div>
</div>
<div>
<div class="text-gray-400 text-sm">Overall</div>
<div class="text-xl font-bold text-blue-400"><?= number_format((float)$eval['overall_score'], 2) ?>/5.00</div>
</div>
</div>
<div class="mt-4 text-right">
<a href="/evaluations/compiled/<?= $eval['id'] ?>" class="text-blue-400 hover:text-blue-300">View Details →</a>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
\ No newline at end of file
<?php $this->layout('layouts/app', ['title' => 'Professional Evaluation']); ?>
<div class="container mx-auto px-4 py-6">
<h1 class="text-2xl font-bold mb-2">Professional Evaluation</h1>
<p class="text-gray-400 mb-6">Contractor: <?= htmlspecialchars($contractor_name) ?> | Month: <?= htmlspecialchars($month) ?></p>
<form method="POST" action="/evaluations/<?= $evaluation['id'] ?>/professional" class="space-y-6">
<?php
$criteria = require ROOT_PATH . '/config/evaluation_criteria.php';
$profCriteria = $criteria['professional'] ?? [];
foreach ($profCriteria as $key => $c):
$autoVal = $auto_scores[$key] ?? null;
?>
<div class="bg-gray-800 rounded-lg p-6">
<div class="flex justify-between items-start mb-3">
<div>
<h3 class="font-semibold"><?= htmlspecialchars($c['name']) ?></h3>
<p class="text-gray-400 text-sm">Weight: <?= ($c['weight'] * 100) ?>%</p>
</div>
<?php if ($autoVal !== null): ?>
<span class="bg-purple-900 text-purple-300 px-3 py-1 rounded text-sm">Auto: <?= number_format((float)$autoVal, 2) ?></span>
<?php endif; ?>
</div>
<?php if ($autoVal !== null): ?>
<input type="hidden" name="scores[<?= $key ?>][auto_value]" value="<?= (float)$autoVal ?>">
<?php endif; ?>
<div class="mb-3">
<label class="text-sm text-gray-400">Score (1.00 - 5.00)</label>
<input type="number" step="0.01" min="1" max="5" name="scores[<?= $key ?>][manual_value]"
value="<?= $autoVal !== null ? number_format((float)$autoVal, 2) : '3.00' ?>"
class="w-full bg-gray-700 rounded p-2 mt-1 text-white" required>
</div>
<div>
<label class="text-sm text-gray-400">Justification</label>
<textarea name="scores[<?= $key ?>][justification]" rows="2"
class="w-full bg-gray-700 rounded p-2 mt-1 text-white"
placeholder="Provide justification for your score"
<?= ($c['auto'] ?? false) ? '' : 'required' ?>></textarea>
</div>
</div>
<?php endforeach; ?>
<button type="submit" class="w-full bg-green-600 hover:bg-green-700 text-white py-3 rounded-lg font-bold">
Submit Professional Evaluation
</button>
</form>
</div>
\ No newline at end of file
<?php $this->layout('layouts/app', ['title' => 'Technical Evaluation']); ?>
<div class="container mx-auto px-4 py-6">
<h1 class="text-2xl font-bold mb-2">Technical Evaluation</h1>
<p class="text-gray-400 mb-6">Contractor: <?= htmlspecialchars($contractor_name) ?> | Month: <?= htmlspecialchars($month) ?></p>
<form method="POST" action="/evaluations/<?= $evaluation['id'] ?>/technical" class="space-y-6">
<?php
$criteria = require ROOT_PATH . '/config/evaluation_criteria.php';
$techCriteria = $criteria['technical'] ?? [];
foreach ($techCriteria as $key => $c):
$autoVal = $auto_scores[$key] ?? null;
?>
<div class="bg-gray-800 rounded-lg p-6">
<div class="flex justify-between items-start mb-3">
<div>
<h3 class="font-semibold"><?= htmlspecialchars($c['name']) ?></h3>
<p class="text-gray-400 text-sm">Weight: <?= ($c['weight'] * 100) ?>%</p>
</div>
<?php if ($autoVal !== null): ?>
<span class="bg-blue-900 text-blue-300 px-3 py-1 rounded text-sm">Auto: <?= number_format((float)$autoVal, 2) ?></span>
<?php endif; ?>
</div>
<?php if ($autoVal !== null): ?>
<input type="hidden" name="scores[<?= $key ?>][auto_value]" value="<?= (float)$autoVal ?>">
<?php endif; ?>
<div class="mb-3">
<label class="text-sm text-gray-400">Score (1.00 - 5.00)</label>
<input type="number" step="0.01" min="1" max="5" name="scores[<?= $key ?>][manual_value]"
value="<?= $autoVal !== null ? number_format((float)$autoVal, 2) : '3.00' ?>"
class="w-full bg-gray-700 rounded p-2 mt-1 text-white" required>
</div>
<div>
<label class="text-sm text-gray-400">Justification</label>
<textarea name="scores[<?= $key ?>][justification]" rows="2"
class="w-full bg-gray-700 rounded p-2 mt-1 text-white"
placeholder="<?= ($c['auto'] ?? false) ? 'Override justification (optional if keeping auto value)' : 'Required justification' ?>"
<?= ($c['auto'] ?? false) ? '' : 'required' ?>></textarea>
</div>
</div>
<?php endforeach; ?>
<button type="submit" class="w-full bg-green-600 hover:bg-green-700 text-white py-3 rounded-lg font-bold">
Submit Technical Evaluation
</button>
</form>
</div>
\ No newline at end of file
<?php $this->layout('layouts/app', ['title' => 'Meetings']); ?>
<div class="container mx-auto px-4 py-6">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold">Meetings</h1>
<?php if (in_array($user['role'], ['super_admin', 'admin', 'project_leader'])): ?>
<button class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded" onclick="document.getElementById('new-meeting').classList.toggle('hidden')">
+ Schedule Meeting
</button>
<?php endif; ?>
</div>
<div class="text-gray-400 text-center py-12">
<p>Meeting data loads via API. Use the JSON endpoints for full functionality.</p>
<p class="mt-2"><code class="bg-gray-800 px-2 py-1 rounded">GET /meetings</code></p>
</div>
</div>
\ No newline at end of file
<?php $this->layout('layouts/app', ['title' => 'PIP Details']); ?>
<div class="container mx-auto px-4 py-6">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold">PIP: <?= htmlspecialchars($pip['contractor_name']) ?></h1>
<a href="/pips" class="text-gray-400 hover:text-white">← Back</a>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="lg:col-span-2 space-y-6">
<div class="bg-gray-800 rounded-lg p-6">
<h2 class="font-semibold mb-4">Specific Issues</h2>
<?php $issues = json_decode($pip['specific_issues'], true) ?? []; ?>
<ol class="list-decimal list-inside space-y-2">
<?php foreach ($issues as $issue): ?>
<li class="text-gray-300"><?= htmlspecialchars($issue) ?></li>
<?php endforeach; ?>
</ol>
</div>
<div class="bg-gray-800 rounded-lg p-6">
<h2 class="font-semibold mb-4">Improvement Targets</h2>
<?php foreach ($targets as $t): ?>
<div class="border-b border-gray-700 pb-3 mb-3 last:border-0">
<p class="font-medium"><?= htmlspecialchars($t['description']) ?></p>
<p class="text-gray-400 text-sm">Metric: <?= htmlspecialchars($t['target_metric']) ?></p>
</div>
<?php endforeach; ?>
</div>
<div class="bg-gray-800 rounded-lg p-6">
<h2 class="font-semibold mb-4">Check-ins</h2>
<?php foreach ($checkins as $ci): ?>
<div class="border-b border-gray-700 pb-3 mb-3 last:border-0">
<div class="flex justify-between">
<span class="font-medium"><?= htmlspecialchars($ci['scheduled_date']) ?></span>
<span class="text-sm <?= $ci['logged_at'] ? 'text-green-400' : 'text-gray-500' ?>">
<?= $ci['logged_at'] ? '✅ Logged' : '⏳ Pending' ?>
</span>
</div>
<?php if ($ci['progress_notes']): ?>
<p class="text-gray-300 mt-1 text-sm"><?= nl2br(htmlspecialchars($ci['progress_notes'])) ?></p>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
</div>
<div class="space-y-6">
<div class="bg-gray-800 rounded-lg p-6">
<h3 class="font-semibold mb-3">Details</h3>
<dl class="space-y-2 text-sm">
<div class="flex justify-between"><dt class="text-gray-400">Status</dt><dd class="font-bold"><?= ucfirst($pip['status']) ?></dd></div>
<div class="flex justify-between"><dt class="text-gray-400">Duration</dt><dd><?= $pip['duration_days'] ?> days</dd></div>
<div class="flex justify-between"><dt class="text-gray-400">Start</dt><dd><?= $pip['start_date'] ?></dd></div>
<div class="flex justify-between"><dt class="text-gray-400">End</dt><dd><?= $pip['end_date'] ?></dd></div>
<div class="flex justify-between"><dt class="text-gray-400">Check-ins</dt><dd><?= ucfirst($pip['check_in_frequency']) ?></dd></div>
<div class="flex justify-between"><dt class="text-gray-400">Created By</dt><dd><?= htmlspecialchars($pip['created_by_name']) ?></dd></div>
</dl>
</div>
<div class="bg-gray-800 rounded-lg p-6">
<h3 class="font-semibold mb-3">Success Criteria</h3>
<p class="text-gray-300 text-sm"><?= nl2br(htmlspecialchars($pip['success_criteria'])) ?></p>
</div>
<div class="bg-red-900/30 rounded-lg p-6 border border-red-800">
<h3 class="font-semibold mb-3 text-red-400">Consequence of Failure</h3>
<p class="text-gray-300 text-sm"><?= nl2br(htmlspecialchars($pip['consequence_of_failure'])) ?></p>
</div>
<?php if ($pip['contractor_id'] === $user['id'] && $pip['status'] === 'created'): ?>
<form method="POST" action="/pips/<?= $pip['id'] ?>/acknowledge">
<button type="submit" class="w-full bg-blue-600 hover:bg-blue-700 text-white py-3 rounded-lg font-bold">
I Acknowledge This PIP
</button>
</form>
<?php endif; ?>
<?php if (in_array($user['role'], ['super_admin', 'admin']) && $pip['status'] === 'active'): ?>
<div class="space-y-2">
<form method="POST" action="/pips/<?= $pip['id'] ?>/decide">
<input type="hidden" name="decision" value="passed">
<button type="submit" class="w-full bg-green-600 hover:bg-green-700 text-white py-2 rounded">✅ Mark as Passed</button>
</form>
<form method="POST" action="/pips/<?= $pip['id'] ?>/decide">
<input type="hidden" name="decision" value="failed">
<button type="submit" class="w-full bg-red-600 hover:bg-red-700 text-white py-2 rounded">❌ Mark as Failed</button>
</form>
</div>
<?php endif; ?>
</div>
</div>
</div>
\ No newline at end of file
<?php $this->layout('layouts/app', ['title' => 'Performance Improvement Plans']); ?>
<div class="container mx-auto px-4 py-6">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold">Performance Improvement Plans</h1>
<?php if (in_array($user['role'], ['super_admin', 'admin'])): ?>
<a href="#" onclick="document.getElementById('create-pip-modal').classList.toggle('hidden')" class="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded">+ Create PIP</a>
<?php endif; ?>
</div>
<?php if (empty($pips)): ?>
<div class="bg-gray-800 rounded-lg p-8 text-center text-gray-400">
<p>No PIPs found.</p>
</div>
<?php else: ?>
<div class="space-y-4">
<?php foreach ($pips as $pip): ?>
<a href="/pips/<?= $pip['id'] ?>" class="block bg-gray-800 rounded-lg p-6 border border-gray-700 hover:border-gray-500 transition">
<div class="flex justify-between items-center">
<div>
<h3 class="font-semibold"><?= htmlspecialchars($pip['contractor_name'] ?? 'Your PIP') ?></h3>
<p class="text-gray-400 text-sm"><?= htmlspecialchars($pip['start_date']) ?> to <?= htmlspecialchars($pip['end_date']) ?> (<?= $pip['duration_days'] ?> days)</p>
</div>
<span class="px-3 py-1 rounded-full text-sm <?= match($pip['status']) {
'created' => 'bg-gray-600 text-white',
'acknowledged','active' => 'bg-yellow-600 text-black',
'passed' => 'bg-green-600 text-white',
'failed' => 'bg-red-600 text-white',
default => 'bg-gray-600 text-white',
} ?>">
<?= ucfirst($pip['status']) ?>
</span>
</div>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment