Commit 7d22a012 authored by Administrator's avatar Administrator

Update 30 files via Son of Anton

parent aa7e8d7b
<?php <?php
use Engine\Core\Container; use Engine\Core\Router;
use Modules\BoardTemplates\Controllers\BoardTemplateController;
$router = Container::getInstance()->resolve(\Engine\Core\Router::class); return function (Router $router) {
$router->get('/board-templates', [BoardTemplateController::class, 'index']);
$router->post('/board-templates', [BoardTemplateController::class, 'create']);
$router->post('/board-templates/from-board/{boardId}', [BoardTemplateController::class, 'saveFromBoard']);
$router->delete('/board-templates/{templateId}', [BoardTemplateController::class, 'delete']);
$router->get('/board-templates', \Modules\BoardTemplates\Controllers\BoardTemplateController::class, 'index', ['auth', 'blocking']); $router->get('/api/board-templates', [BoardTemplateController::class, 'index']);
$router->post('/board-templates', \Modules\BoardTemplates\Controllers\BoardTemplateController::class, 'create', ['auth', 'blocking']); $router->post('/api/board-templates', [BoardTemplateController::class, 'create']);
$router->post('/board-templates/from-board/{boardId}', \Modules\BoardTemplates\Controllers\BoardTemplateController::class, 'saveFromBoard', ['auth', 'blocking']); $router->post('/api/board-templates/from-board/{boardId}', [BoardTemplateController::class, 'saveFromBoard']);
$router->delete('/board-templates/{id}', \Modules\BoardTemplates\Controllers\BoardTemplateController::class, 'delete', ['auth', 'blocking']); $router->delete('/api/board-templates/{templateId}', [BoardTemplateController::class, 'delete']);
\ No newline at end of file };
\ No newline at end of file
<?php <?php
use Engine\Core\Container; use Engine\Core\Router;
use Modules\CardTemplates\Controllers\CardTemplateController;
$router = Container::getInstance()->resolve(\Engine\Core\Router::class); return function (Router $router) {
$router->get('/card-templates', [CardTemplateController::class, 'index']);
$router->post('/card-templates', [CardTemplateController::class, 'create']);
$router->put('/card-templates/{templateId}', [CardTemplateController::class, 'update']);
$router->delete('/card-templates/{templateId}', [CardTemplateController::class, 'delete']);
$router->get('/card-templates', \Modules\CardTemplates\Controllers\CardTemplateController::class, 'index', ['auth', 'blocking']); $router->get('/api/card-templates', [CardTemplateController::class, 'index']);
$router->post('/card-templates', \Modules\CardTemplates\Controllers\CardTemplateController::class, 'create', ['auth', 'blocking']); $router->post('/api/card-templates', [CardTemplateController::class, 'create']);
$router->put('/card-templates/{id}', \Modules\CardTemplates\Controllers\CardTemplateController::class, 'update', ['auth', 'blocking']); $router->put('/api/card-templates/{templateId}', [CardTemplateController::class, 'update']);
$router->delete('/card-templates/{id}', \Modules\CardTemplates\Controllers\CardTemplateController::class, 'delete', ['auth', 'blocking']); $router->delete('/api/card-templates/{templateId}', [CardTemplateController::class, 'delete']);
\ No newline at end of file };
\ No newline at end of file
...@@ -10,33 +10,47 @@ use Engine\Notifications\NotificationManager; ...@@ -10,33 +10,47 @@ use Engine\Notifications\NotificationManager;
final class ContractExpiryWarningJob implements JobInterface final class ContractExpiryWarningJob implements JobInterface
{ {
private Connection $db; public function key(): string
private NotificationManager $notif; {
return 'contract_expiry_warnings';
}
public function __construct() public function shouldRun(): bool
{ {
$c = Container::getInstance(); return true;
$this->db = $c->resolve(Connection::class);
$this->notif = $c->resolve(NotificationManager::class);
} }
public function run(): void public function run(): void
{ {
$db = Container::getInstance()->resolve(Connection::class);
$notif = Container::getInstance()->resolve(NotificationManager::class);
$warningDays = [90, 60, 30]; $warningDays = [90, 60, 30];
$today = date('Y-m-d');
foreach ($warningDays as $days) { foreach ($warningDays as $days) {
$targetDate = date('Y-m-d', strtotime("+{$days} days")); $targetDate = date('Y-m-d', strtotime("+{$days} days"));
$expiring = $this->db->fetchAll(
$expiring = $db->fetchAll(
"SELECT id, full_name_en, contract_end_date FROM users "SELECT id, full_name_en, contract_end_date FROM users
WHERE contract_end_date = ? AND status = 'active'", WHERE contract_end_date = ? AND status = 'active' AND is_active = 1",
[$targetDate] [$targetDate]
); );
foreach ($expiring as $u) { foreach ($expiring as $user) {
$admins = $this->db->fetchAll("SELECT id FROM users WHERE role IN ('super_admin','admin') AND is_active = 1"); $admins = $db->fetchAll(
"SELECT id FROM users WHERE role IN ('super_admin','admin') AND is_active = 1"
);
foreach ($admins as $a) { foreach ($admins as $a) {
$this->notif->createImportant($a['id'], "Contract Expiring in {$days} Days", $notif->createImportant($a['id'], "Contract Expiring in {$days} Days",
"{$u['full_name_en']}'s contract expires on {$u['contract_end_date']}.", "{$user['full_name_en']}'s contract expires on {$user['contract_end_date']}.",
"/users/{$u['id']}", 'user', $u['id']); "/users/{$user['id']}", 'user', (int)$user['id']);
}
if ($days === 30) {
$notif->createImportant((int)$user['id'], 'Contract Expiring Soon',
"Your contract expires on {$user['contract_end_date']}. Please contact administration.",
'/dashboard');
} }
} }
} }
......
<?php <?php
use Engine\Core\Container; use Engine\Core\Router;
use Modules\Contracts\Controllers\ContractController;
use Modules\Contracts\Controllers\PolicyController;
use Modules\Contracts\Controllers\NoticeController;
$router = Container::getInstance()->resolve(\Engine\Core\Router::class); return function (Router $router) {
$router->get('/contracts', [ContractController::class, 'index']);
$router->get('/contracts/{contractId}', [ContractController::class, 'show']);
$router->get('/contracts', \Modules\Contracts\Controllers\ContractController::class, 'index', ['auth', 'blocking']); $router->get('/policies', [PolicyController::class, 'index']);
$router->get('/contracts/{id}', \Modules\Contracts\Controllers\ContractController::class, 'show', ['auth', 'blocking']); $router->get('/policies/{policyId}', [PolicyController::class, 'show']);
$router->post('/policies', [PolicyController::class, 'create']);
$router->post('/policies/{policyId}/publish', [PolicyController::class, 'publish']);
$router->post('/policies/versions/{versionId}/acknowledge', [PolicyController::class, 'acknowledge']);
$router->get('/policies', \Modules\Contracts\Controllers\PolicyController::class, 'index', ['auth', 'blocking']); $router->get('/notices', [NoticeController::class, 'index']);
$router->get('/policies/{id}', \Modules\Contracts\Controllers\PolicyController::class, 'show', ['auth', 'blocking']); $router->post('/notices', [NoticeController::class, 'create']);
$router->post('/policies', \Modules\Contracts\Controllers\PolicyController::class, 'create', ['auth', 'blocking']); $router->post('/notices/{noticeId}/acknowledge', [NoticeController::class, 'acknowledgeNotice']);
$router->post('/policies/{id}/publish', \Modules\Contracts\Controllers\PolicyController::class, 'publish', ['auth', 'blocking']); $router->delete('/notices/{noticeId}', [NoticeController::class, 'delete']);
$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->get('/api/contracts', [ContractController::class, 'index']);
$router->post('/notices', \Modules\Contracts\Controllers\NoticeController::class, 'create', ['auth', 'blocking']); $router->get('/api/contracts/{contractId}', [ContractController::class, 'show']);
$router->post('/notices/{id}/acknowledge', \Modules\Contracts\Controllers\NoticeController::class, 'acknowledgeNotice', ['auth']); $router->get('/api/policies', [PolicyController::class, 'index']);
$router->delete('/notices/{id}', \Modules\Contracts\Controllers\NoticeController::class, 'delete', ['auth', 'blocking']); $router->get('/api/policies/{policyId}', [PolicyController::class, 'show']);
\ No newline at end of file $router->post('/api/policies', [PolicyController::class, 'create']);
$router->post('/api/policies/{policyId}/publish', [PolicyController::class, 'publish']);
$router->post('/api/policies/versions/{versionId}/acknowledge', [PolicyController::class, 'acknowledge']);
$router->get('/api/notices', [NoticeController::class, 'index']);
$router->post('/api/notices', [NoticeController::class, 'create']);
$router->post('/api/notices/{noticeId}/acknowledge', [NoticeController::class, 'acknowledgeNotice']);
$router->delete('/api/notices/{noticeId}', [NoticeController::class, 'delete']);
};
\ No newline at end of file
<?php <?php
use Engine\Core\Container; use Engine\Core\Router;
use Modules\DeductionPresets\Controllers\DeductionPresetController;
$router = Container::getInstance()->resolve(\Engine\Core\Router::class); return function (Router $router) {
$router->get('/deduction-presets', [DeductionPresetController::class, 'index']);
$router->post('/deduction-presets', [DeductionPresetController::class, 'create']);
$router->put('/deduction-presets/{presetId}', [DeductionPresetController::class, 'update']);
$router->delete('/deduction-presets/{presetId}', [DeductionPresetController::class, 'delete']);
$router->get('/deduction-presets', \Modules\DeductionPresets\Controllers\DeductionPresetController::class, 'index', ['auth', 'blocking']); $router->get('/api/deduction-presets', [DeductionPresetController::class, 'index']);
$router->post('/deduction-presets', \Modules\DeductionPresets\Controllers\DeductionPresetController::class, 'create', ['auth', 'blocking']); $router->post('/api/deduction-presets', [DeductionPresetController::class, 'create']);
$router->put('/deduction-presets/{id}', \Modules\DeductionPresets\Controllers\DeductionPresetController::class, 'update', ['auth', 'blocking']); $router->put('/api/deduction-presets/{presetId}', [DeductionPresetController::class, 'update']);
$router->delete('/deduction-presets/{id}', \Modules\DeductionPresets\Controllers\DeductionPresetController::class, 'delete', ['auth', 'blocking']); $router->delete('/api/deduction-presets/{presetId}', [DeductionPresetController::class, 'delete']);
\ No newline at end of file };
\ No newline at end of file
...@@ -6,34 +6,78 @@ namespace Modules\Deductions\Jobs; ...@@ -6,34 +6,78 @@ namespace Modules\Deductions\Jobs;
use Engine\Scheduler\JobInterface; use Engine\Scheduler\JobInterface;
use Engine\Core\Container; use Engine\Core\Container;
use Engine\Database\Connection; use Engine\Database\Connection;
use Engine\Notifications\NotificationManager;
final class AutoApplyExpiredDeductionsJob implements JobInterface final class AutoApplyExpiredDeductionsJob implements JobInterface
{ {
private Connection $db; public function key(): string
{
return 'auto_apply_deductions';
}
public function __construct() public function shouldRun(): bool
{ {
$this->db = Container::getInstance()->resolve(Connection::class); return true;
} }
public function execute(): void public function run(): void
{ {
// Apply deductions where response window has expired with no response $db = Container::getInstance()->resolve(Connection::class);
$this->db->query( $notif = Container::getInstance()->resolve(NotificationManager::class);
"UPDATE deductions
SET status = 'applied_no_response', $now = date('Y-m-d H:i:s');
final_amount = calculated_amount,
applied_at = NOW(), $expired = $db->fetchAll(
payroll_month = DATE_FORMAT(NOW(), '%Y-%m') "SELECT d.*, u.full_name_en as contractor_name FROM deductions d
WHERE status = 'acknowledged' JOIN users u ON u.id = d.contractor_id
AND response_deadline IS NOT NULL WHERE d.status = 'acknowledged'
AND response_deadline < NOW() AND d.response_deadline IS NOT NULL
AND deleted_at IS NULL" AND d.response_deadline < ?
AND d.deleted_at IS NULL",
[$now]
); );
foreach ($expired as $deduction) {
$db->update('deductions', [
'status' => 'applied_no_response',
'final_amount' => $deduction['calculated_amount'],
'applied_at' => $now,
], 'id = ?', [(int)$deduction['id']]);
$notif->createImportant($deduction['contractor_id'], 'Deduction Applied — No Response',
"Deduction #{$deduction['id']} ({$deduction['category']}{$deduction['sub_category']}) of " .
number_format($deduction['calculated_amount'], 2) .
" EGP has been automatically applied. Response window expired.",
"/deductions/{$deduction['id']}", 'deduction', (int)$deduction['id']);
$this->checkThreshold($db, $notif, (int)$deduction['contractor_id']);
}
} }
public function nextRunAt(): string private function checkThreshold(Connection $db, NotificationManager $notif, int $contractorId): void
{ {
return date('Y-m-d H:i:s', strtotime('+1 hour')); $month = date('Y-m');
$contractor = $db->fetchOne("SELECT actual_salary, full_name_en FROM users WHERE id = ?", [$contractorId]);
if (!$contractor || !$contractor['actual_salary']) return;
$totalDeductions = (float)$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]
);
$threshold = $contractor['actual_salary'] * 0.40;
if ($totalDeductions >= $threshold) {
$notif->createBlocking($contractorId, 'Critical Deduction Threshold',
'Your deductions have reached 40% of your salary. This is critical.');
$admins = $db->fetchAll("SELECT id FROM users WHERE role = 'super_admin' AND is_active = 1");
foreach ($admins as $a) {
$notif->createImportant($a['id'], '🚨 40% Deduction Threshold',
"{$contractor['full_name_en']} has reached the 40% deduction threshold. PIP recommended.",
"/users/{$contractorId}", 'user', $contractorId);
}
}
} }
} }
\ No newline at end of file
...@@ -9,65 +9,137 @@ use Engine\Database\Connection; ...@@ -9,65 +9,137 @@ use Engine\Database\Connection;
final class EscalateDeadlineDeductionsJob implements JobInterface final class EscalateDeadlineDeductionsJob implements JobInterface
{ {
private Connection $db; public function key(): string
{
return 'escalate_deadline_deductions';
}
public function __construct() public function shouldRun(): bool
{ {
$this->db = Container::getInstance()->resolve(Connection::class); return true;
} }
public function run(): void public function run(): void
{ {
$overdueCards = $this->db->fetchAll( $db = Container::getInstance()->resolve(Connection::class);
"SELECT c.id, c.card_key, c.deadline, c.board_id,
DATEDIFF(NOW(), c.deadline) as days_late, $today = date('Y-m-d');
ca.user_id as assignee_id
$overdueCards = $db->fetchAll(
"SELECT c.id as card_id, c.card_key, c.deadline, c.board_id,
DATEDIFF(?, DATE(c.deadline)) as days_late
FROM cards c FROM cards c
JOIN card_assignments ca ON ca.card_id = c.id WHERE c.deadline IS NOT NULL AND c.deadline < ?
WHERE c.deadline IS NOT NULL AND c.deadline < NOW() AND c.done_at IS NULL AND c.is_archived = 0",
AND c.done_at IS NULL AND c.is_archived = 0" [$today, $today . ' 00:00:00']
); );
foreach ($overdueCards as $card) { foreach ($overdueCards as $card) {
$daysLate = (int)$card['days_late']; $daysLate = (int)$card['days_late'];
if ($daysLate <= 0) continue; if ($daysLate <= 0) continue;
$subCategory = match(true) { $newSub = $this->determineSubCategory($daysLate);
$daysLate >= 15 => 'A4', if (!$newSub) continue;
$daysLate >= 8 => 'A3',
$daysLate >= 4 => 'A2', $assignees = $db->fetchAll(
default => 'A1', "SELECT user_id FROM card_assignments WHERE card_id = ?",
}; [(int)$card['card_id']]
);
$existing = $this->db->fetchOne( foreach ($assignees as $assignee) {
$existingDeduction = $db->fetchOne(
"SELECT id, sub_category FROM deductions "SELECT id, sub_category FROM deductions
WHERE related_card_id = ? AND contractor_id = ? AND category = 'A' WHERE contractor_id = ? AND related_card_id = ? AND category = 'A'
AND status NOT IN ('dismissed','applied','applied_no_response','reduced','accepted') AND status NOT IN ('dismissed','applied','applied_no_response','reduced','accepted')
AND deleted_at IS NULL", AND deleted_at IS NULL
[$card['id'], $card['assignee_id']] ORDER BY created_at DESC LIMIT 1",
[$assignee['user_id'], (int)$card['card_id']]
); );
if ($existing) { if ($existingDeduction) {
if ($existing['sub_category'] !== $subCategory) { $existingSub = $existingDeduction['sub_category'];
$contractor = $this->db->fetchOne("SELECT actual_salary FROM users WHERE id = ?", [$card['assignee_id']]); if ($this->subCategoryLevel($newSub) > $this->subCategoryLevel($existingSub)) {
$contractor = $db->fetchOne("SELECT actual_salary FROM users WHERE id = ?", [$assignee['user_id']]);
$actualSalary = (float)($contractor['actual_salary'] ?? 0); $actualSalary = (float)($contractor['actual_salary'] ?? 0);
$dailyRate = $actualSalary > 0 ? round($actualSalary / 22, 2) : 0;
$newAmount = match($subCategory) { $expectedDays = $this->getExpectedWorkingDays($db, $assignee['user_id']);
$dailyRate = $expectedDays > 0 ? round($actualSalary / $expectedDays, 2) : 0;
$newAmount = $this->calculateAmount($newSub, $dailyRate, $actualSalary, $daysLate);
$db->update('deductions', [
'sub_category' => $newSub,
'calculated_amount' => $newAmount,
'description' => "Auto-escalated: Card {$card['card_key']} is now {$daysLate} days overdue (category {$newSub}).",
], 'id = ?', [(int)$existingDeduction['id']]);
}
}
}
}
}
private function determineSubCategory(int $daysLate): ?string
{
if ($daysLate >= 15) return 'A4';
if ($daysLate >= 8) return 'A3';
if ($daysLate >= 4) return 'A2';
if ($daysLate >= 1) return 'A1';
return null;
}
private function subCategoryLevel(string $sub): int
{
return (int)substr($sub, 1);
}
private function calculateAmount(string $sub, float $dailyRate, float $actualSalary, int $daysLate): float
{
return match ($sub) {
'A1' => round($dailyRate * 0.05 * $daysLate, 2), 'A1' => round($dailyRate * 0.05 * $daysLate, 2),
'A2' => round($dailyRate * 0.10 * $daysLate, 2), 'A2' => round($dailyRate * 0.10 * $daysLate, 2),
'A3' => round($dailyRate * 0.15 * $daysLate, 2), 'A3' => round($dailyRate * 0.15 * $daysLate, 2),
'A4' => round($actualSalary * 0.25, 2), 'A4' => round($actualSalary * 0.25, 2),
default => 0, default => 0,
}; };
}
$this->db->update('deductions', [ private function getExpectedWorkingDays(Connection $db, int $userId): int
'sub_category' => $subCategory, {
'calculated_amount' => $newAmount, $month = date('Y-m');
'description' => "Auto-escalated: Card {$card['card_key']} is {$daysLate} days overdue.", $startDate = $month . '-01';
], 'id = ?', [$existing['id']]); $endDate = date('Y-m-t', strtotime($startDate));
$schedule = $db->fetchAll(
"SELECT day_of_week FROM user_schedule_days WHERE user_id = ? AND effective_to IS NULL AND work_mode != 'off'",
[$userId]
);
$workDows = array_column($schedule, 'day_of_week');
if (empty($workDows)) return 22;
$holidays = $db->fetchAll(
"SELECT start_date, end_date FROM holidays WHERE start_date <= ? AND end_date >= ?",
[$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)] = true;
}
} }
$count = 0;
$current = strtotime($startDate);
$end = strtotime($endDate);
while ($current <= $end) {
$dow = (int)date('w', $current);
$dateStr = date('Y-m-d', $current);
if (in_array($dow, $workDows) && !isset($holidayDates[$dateStr])) {
$count++;
} }
$current += 86400;
} }
return $count ?: 22;
} }
} }
\ No newline at end of file
...@@ -7,29 +7,47 @@ use Engine\Calculation\CalculatorInterface; ...@@ -7,29 +7,47 @@ use Engine\Calculation\CalculatorInterface;
final class OverallScoreCalculator implements CalculatorInterface final class OverallScoreCalculator implements CalculatorInterface
{ {
public function calculate(array $context): mixed public function calculate(array $context): array
{ {
$techScore = (float)($context['technical_score'] ?? 0); $techScore = (float)($context['technical_score'] ?? 0);
$profScore = (float)($context['professional_score'] ?? 0); $profScore = (float)($context['professional_score'] ?? 0);
$techWeight = (float)($context['tech_weight'] ?? 0.5);
$profWeight = (float)($context['prof_weight'] ?? 0.5);
$criteria = require ROOT_PATH . '/config/evaluation_criteria.php'; $overallScore = round(($techScore * $techWeight) + ($profScore * $profWeight), 2);
$techWeight = (float)($criteria['overall_weights']['technical'] ?? 0.5);
$profWeight = (float)($criteria['overall_weights']['professional'] ?? 0.5);
$overall = round(($techScore * $techWeight) + ($profScore * $profWeight), 2); $rating = $this->determineRating($overallScore);
return [
'overall_score' => $overallScore,
'rating' => $rating['rating'],
'rating_label' => $rating['label'],
'technical_score' => $techScore,
'professional_score' => $profScore,
];
}
private function determineRating(float $score): array
{
$ratings = [
['min' => 4.5, 'max' => 5.0, 'rating' => 'exceptional', 'label' => '⭐ Exceptional'],
['min' => 3.5, 'max' => 4.49, 'rating' => 'strong', 'label' => '🟢 Strong'],
['min' => 2.5, 'max' => 3.49, 'rating' => 'adequate', 'label' => '🟡 Adequate'],
['min' => 1.5, 'max' => 2.49, 'rating' => 'below_expectations', 'label' => '🟠 Below Expectations'],
['min' => 1.0, 'max' => 1.49, 'rating' => 'unacceptable', 'label' => '🔴 Unacceptable'],
];
$rating = 'adequate';
$ratings = $criteria['ratings'] ?? [];
foreach ($ratings as $r) { foreach ($ratings as $r) {
if ($overall >= $r['min'] && $overall <= $r['max']) { if ($score >= $r['min'] && $score <= $r['max']) {
$rating = $r['rating']; return $r;
break;
} }
} }
return [ return ['rating' => 'unacceptable', 'label' => '🔴 Unacceptable'];
'overall_score' => $overall, }
'rating' => $rating,
]; public function name(): string
{
return 'overall_eval_score';
} }
} }
\ No newline at end of file
...@@ -9,45 +9,96 @@ use Engine\Database\Connection; ...@@ -9,45 +9,96 @@ use Engine\Database\Connection;
final class ProfessionalAutoScoreCalculator implements CalculatorInterface final class ProfessionalAutoScoreCalculator implements CalculatorInterface
{ {
private Connection $db; public function calculate(array $context): array
public function __construct()
{
$this->db = Container::getInstance()->resolve(Connection::class);
}
public function calculate(array $context): mixed
{ {
$db = Container::getInstance()->resolve(Connection::class);
$contractorId = (int)$context['contractor_id']; $contractorId = (int)$context['contractor_id'];
$month = $context['month']; $month = $context['month']; // YYYY-MM
$startDate = $month . '-01';
$endDate = date('Y-m-t', strtotime($startDate));
$totalReports = (int)$this->db->fetchColumn( // Reporting Compliance: (reports_on_time / expected_reports) * 5
"SELECT COUNT(*) FROM daily_reports WHERE user_id = ? AND report_date LIKE ?", $expectedReports = (int)$db->fetchColumn(
[$contractorId, $month . '%'] "SELECT COUNT(*) FROM daily_reports
WHERE user_id = ? AND report_date >= ? AND report_date <= ?
AND status != 'draft'",
[$contractorId, $startDate, $endDate]
); );
$onTimeReports = (int)$this->db->fetchColumn( // Count working days from schedule for a more accurate expected count
"SELECT COUNT(*) FROM daily_reports WHERE user_id = ? AND report_date LIKE ? AND is_on_time = 1", $scheduleDays = $db->fetchAll(
[$contractorId, $month . '%'] "SELECT day_of_week FROM user_schedule_days
WHERE user_id = ? AND effective_to IS NULL AND work_mode != 'off'",
[$contractorId]
); );
$workDows = array_column($scheduleDays, 'day_of_week');
$reportingCompliance = $totalReports > 0 ? round(($onTimeReports / $totalReports) * 5, 2) : 1.0; $holidays = $db->fetchAll(
$reportingCompliance = min(5.0, max(1.0, $reportingCompliance)); "SELECT start_date, end_date 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)] = true;
}
}
$violations = (int)$this->db->fetchColumn( $totalExpected = 0;
"SELECT COUNT(*) FROM deductions WHERE contractor_id = ? AND payroll_month = ? $current = strtotime($startDate);
AND status IN ('applied','applied_no_response','reduced','accepted') AND deleted_at IS NULL", $end = min(strtotime($endDate), strtotime(date('Y-m-d')));
while ($current <= $end) {
$dow = (int)date('w', $current);
$dateStr = date('Y-m-d', $current);
if (in_array($dow, $workDows) && !isset($holidayDates[$dateStr])) {
$unavail = $db->fetchOne(
"SELECT id FROM unavailability_records WHERE user_id = ? AND start_date <= ? AND end_date >= ?",
[$contractorId, $dateStr, $dateStr]
);
if (!$unavail) {
$totalExpected++;
}
}
$current += 86400;
}
$reportsOnTime = (int)$db->fetchColumn(
"SELECT COUNT(*) FROM daily_reports
WHERE user_id = ? AND report_date >= ? AND report_date <= ?
AND is_on_time = 1 AND status NOT IN ('draft','unreported')",
[$contractorId, $startDate, $endDate]
);
$reportingCompliance = $totalExpected > 0
? min(5.0, round(($reportsOnTime / $totalExpected) * 5, 2))
: 3.0;
// Policy Compliance: max(1, 5 - (violations * 0.5))
$violations = (int)$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] [$contractorId, $month]
); );
$policyCompliance = max(1.0, min(5.0, 5.0 - ($violations * 0.5))); $policyCompliance = max(1.0, round(5.0 - ($violations * 0.5), 2));
return [ return [
'reporting_compliance' => $reportingCompliance, 'reporting_compliance' => $reportingCompliance,
'policy_compliance' => round($policyCompliance, 2), 'policy_compliance' => $policyCompliance,
'total_reports' => $totalReports, 'reports_on_time' => $reportsOnTime,
'on_time_reports' => $onTimeReports, 'expected_reports' => $totalExpected,
'violations' => $violations, 'violations' => $violations,
]; ];
} }
public function name(): string
{
return 'professional_auto_score';
}
} }
\ No newline at end of file
...@@ -9,54 +9,58 @@ use Engine\Database\Connection; ...@@ -9,54 +9,58 @@ use Engine\Database\Connection;
final class TechnicalAutoScoreCalculator implements CalculatorInterface final class TechnicalAutoScoreCalculator implements CalculatorInterface
{ {
private Connection $db; public function calculate(array $context): array
public function __construct()
{
$this->db = Container::getInstance()->resolve(Connection::class);
}
public function calculate(array $context): mixed
{ {
$db = Container::getInstance()->resolve(Connection::class);
$contractorId = (int)$context['contractor_id']; $contractorId = (int)$context['contractor_id'];
$month = $context['month']; $month = $context['month']; // YYYY-MM
$startDate = $month . '-01'; $startDate = $month . '-01';
$endDate = date('Y-m-t', strtotime($startDate)); $endDate = date('Y-m-t', strtotime($startDate));
$cardsAssigned = (int)$this->db->fetchColumn( // Task Completion Rate: (cards_done / cards_assigned) * 5
$cardsAssigned = (int)$db->fetchColumn(
"SELECT COUNT(DISTINCT ca.card_id) FROM card_assignments ca "SELECT COUNT(DISTINCT ca.card_id) FROM card_assignments ca
JOIN cards c ON c.id = ca.card_id JOIN cards c ON c.id = ca.card_id
WHERE ca.user_id = ? AND ca.created_at BETWEEN ? AND ?", WHERE ca.user_id = ? AND ca.created_at <= ?",
[$contractorId, $startDate . ' 00:00:00', $endDate . ' 23:59:59'] [$contractorId, $endDate . ' 23:59:59']
); );
$cardsDone = (int)$this->db->fetchColumn( $cardsDone = (int)$db->fetchColumn(
"SELECT COUNT(DISTINCT c.id) FROM cards c "SELECT COUNT(DISTINCT c.id) FROM cards c
JOIN card_assignments ca ON ca.card_id = c.id JOIN card_assignments ca ON ca.card_id = c.id
WHERE ca.user_id = ? AND c.done_at BETWEEN ? AND ?", WHERE ca.user_id = ? AND c.done_at IS NOT NULL
AND c.done_at >= ? AND c.done_at <= ?",
[$contractorId, $startDate . ' 00:00:00', $endDate . ' 23:59:59'] [$contractorId, $startDate . ' 00:00:00', $endDate . ' 23:59:59']
); );
$cardsWithDeadline = (int)$this->db->fetchColumn( $taskCompletionRate = $cardsAssigned > 0
? min(5.0, round(($cardsDone / $cardsAssigned) * 5, 2))
: 3.0;
// Deadline Compliance: (cards_on_time / cards_with_deadline) * 5
$cardsWithDeadline = (int)$db->fetchColumn(
"SELECT COUNT(DISTINCT c.id) FROM cards c "SELECT COUNT(DISTINCT c.id) FROM cards c
JOIN card_assignments ca ON ca.card_id = c.id 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 ?", WHERE ca.user_id = ? AND c.deadline IS NOT NULL
AND c.done_at IS NOT NULL
AND c.done_at >= ? AND c.done_at <= ?",
[$contractorId, $startDate . ' 00:00:00', $endDate . ' 23:59:59'] [$contractorId, $startDate . ' 00:00:00', $endDate . ' 23:59:59']
); );
$cardsOnTime = (int)$this->db->fetchColumn( $cardsOnTime = (int)$db->fetchColumn(
"SELECT COUNT(DISTINCT c.id) FROM cards c "SELECT COUNT(DISTINCT c.id) FROM cards c
JOIN card_assignments ca ON ca.card_id = c.id 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 WHERE ca.user_id = ? AND c.deadline IS NOT NULL
AND c.done_at <= c.deadline AND c.done_at BETWEEN ? AND ?", AND c.done_at IS NOT NULL
AND c.done_at <= c.deadline
AND c.done_at >= ? AND c.done_at <= ?",
[$contractorId, $startDate . ' 00:00:00', $endDate . ' 23:59:59'] [$contractorId, $startDate . ' 00:00:00', $endDate . ' 23:59:59']
); );
$taskCompletionRate = $cardsAssigned > 0 ? round(($cardsDone / $cardsAssigned) * 5, 2) : 3.0; $deadlineCompliance = $cardsWithDeadline > 0
$deadlineCompliance = $cardsWithDeadline > 0 ? round(($cardsOnTime / $cardsWithDeadline) * 5, 2) : 3.0; ? min(5.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 [ return [
'task_completion_rate' => $taskCompletionRate, 'task_completion_rate' => $taskCompletionRate,
...@@ -67,4 +71,9 @@ final class TechnicalAutoScoreCalculator implements CalculatorInterface ...@@ -67,4 +71,9 @@ final class TechnicalAutoScoreCalculator implements CalculatorInterface
'cards_on_time' => $cardsOnTime, 'cards_on_time' => $cardsOnTime,
]; ];
} }
public function name(): string
{
return 'technical_auto_score';
}
} }
\ No newline at end of file
...@@ -11,98 +11,124 @@ use Engine\Calculation\CalculationEngine; ...@@ -11,98 +11,124 @@ use Engine\Calculation\CalculationEngine;
final class CompileEvaluationsJob implements JobInterface final class CompileEvaluationsJob implements JobInterface
{ {
private Connection $db; public function key(): string
private NotificationManager $notif; {
private CalculationEngine $calc; return 'compile_evaluations';
}
public function __construct() public function shouldRun(): bool
{ {
$c = Container::getInstance(); return true;
$this->db = $c->resolve(Connection::class);
$this->notif = $c->resolve(NotificationManager::class);
$this->calc = $c->resolve(CalculationEngine::class);
} }
public function run(): void public function run(): void
{ {
$cycle = $this->db->fetchOne( $db = Container::getInstance()->resolve(Connection::class);
$notif = Container::getInstance()->resolve(NotificationManager::class);
$calc = Container::getInstance()->resolve(CalculationEngine::class);
$activeCycle = $db->fetchOne(
"SELECT * FROM evaluation_cycles WHERE status IN ('open','technical_phase','professional_phase','compiling') LIMIT 1" "SELECT * FROM evaluation_cycles WHERE status IN ('open','technical_phase','professional_phase','compiling') LIMIT 1"
); );
if (!$cycle) return;
$contractors = $this->db->fetchAll( if (!$activeCycle) {
return;
}
$contractors = $db->fetchAll(
"SELECT DISTINCT contractor_id FROM evaluations WHERE cycle_id = ?", "SELECT DISTINCT contractor_id FROM evaluations WHERE cycle_id = ?",
[$cycle['id']] [$activeCycle['id']]
); );
$compiledCount = 0; $allCompiled = true;
foreach ($contractors as $c) { foreach ($contractors as $c) {
$cid = $c['contractor_id']; $contractorId = $c['contractor_id'];
$existing = $this->db->fetchOne( $alreadyCompiled = $db->fetchOne(
"SELECT id FROM compiled_evaluations WHERE cycle_id = ? AND contractor_id = ?", "SELECT id FROM compiled_evaluations WHERE cycle_id = ? AND contractor_id = ?",
[$cycle['id'], $cid] [$activeCycle['id'], $contractorId]
); );
if ($existing) continue; if ($alreadyCompiled) {
continue;
}
$tech = $this->db->fetchOne( $techEval = $db->fetchOne(
"SELECT total_score FROM evaluations WHERE cycle_id = ? AND contractor_id = ? AND type = 'technical' AND submitted_at IS NOT NULL", "SELECT * FROM evaluations WHERE cycle_id = ? AND contractor_id = ? AND type = 'technical'",
[$cycle['id'], $cid] [$activeCycle['id'], $contractorId]
); );
$prof = $this->db->fetchOne( $profEval = $db->fetchOne(
"SELECT total_score FROM evaluations WHERE cycle_id = ? AND contractor_id = ? AND type = 'professional' AND submitted_at IS NOT NULL", "SELECT * FROM evaluations WHERE cycle_id = ? AND contractor_id = ? AND type = 'professional'",
[$cycle['id'], $cid] [$activeCycle['id'], $contractorId]
); );
if (!$tech || !$prof) continue; if (!$techEval || !$techEval['submitted_at'] || !$profEval || !$profEval['submitted_at']) {
$allCompiled = false;
continue;
}
$techScore = (float)$techEval['total_score'];
$profScore = (float)$profEval['total_score'];
$result = $this->calc->calculate('overall_eval_score', [ $overallResult = $calc->calculate('overall_eval_score', [
'technical_score' => (float)$tech['total_score'], 'technical_score' => $techScore,
'professional_score' => (float)$prof['total_score'], 'professional_score' => $profScore,
'tech_weight' => 0.5,
'prof_weight' => 0.5,
]); ]);
$metrics = [ $contractor = $db->fetchOne("SELECT * FROM users WHERE id = ?", [$contractorId]);
'technical_score' => (float)$tech['total_score'],
'professional_score' => (float)$prof['total_score'], $systemMetrics = [
'month' => $cycle['month'], 'month' => $activeCycle['month'],
'actual_salary' => $contractor['actual_salary'] ?? 0,
'technical_score' => $techScore,
'professional_score' => $profScore,
]; ];
$this->db->insert('compiled_evaluations', [ if ($calc->has('technical_auto_score')) {
'cycle_id' => $cycle['id'], $systemMetrics['tech_auto'] = $calc->calculate('technical_auto_score', [
'contractor_id' => $cid, 'contractor_id' => $contractorId,
'technical_score' => (float)$tech['total_score'], 'month' => $activeCycle['month'],
'professional_score' => (float)$prof['total_score'], ]);
'overall_score' => $result['overall_score'], }
'rating' => $result['rating'], if ($calc->has('professional_auto_score')) {
'system_metrics_json' => json_encode($metrics), $systemMetrics['prof_auto'] = $calc->calculate('professional_auto_score', [
'contractor_id' => $contractorId,
'month' => $activeCycle['month'],
]);
}
$compiledId = $db->insert('compiled_evaluations', [
'cycle_id' => $activeCycle['id'],
'contractor_id' => $contractorId,
'technical_score' => $techScore,
'professional_score' => $profScore,
'overall_score' => $overallResult['overall_score'],
'rating' => $overallResult['rating'],
'system_metrics_json' => json_encode($systemMetrics),
'compiled_at' => date('Y-m-d H:i:s'), 'compiled_at' => date('Y-m-d H:i:s'),
]); ]);
$this->notif->createBlocking($cid, 'Monthly Evaluation Published', $notif->createBlocking($contractorId, 'Monthly Evaluation Published',
"Your evaluation for {$cycle['month']} has been compiled. Overall score: {$result['overall_score']}/5.00 ({$result['rating']})", "Your evaluation for {$activeCycle['month']} has been compiled. Overall score: {$overallResult['overall_score']} ({$overallResult['rating_label']})",
"/evaluations/compiled/{$cid}", 'compiled_evaluation', $cid); "/evaluations/compiled/{$compiledId}", 'compiled_evaluation', $compiledId);
if ($result['overall_score'] < 2.5) { if ($overallResult['overall_score'] < 2.5) {
$admins = $this->db->fetchAll("SELECT id FROM users WHERE role = 'super_admin' AND is_active = 1"); $admins = $db->fetchAll("SELECT id FROM users WHERE role IN ('super_admin','admin') AND is_active = 1");
foreach ($admins as $a) { foreach ($admins as $a) {
$this->notif->createImportant($a['id'], 'Low Evaluation Score Alert', $notif->createImportant($a['id'], 'Low Evaluation Score — PIP Recommended',
"Contractor ID {$cid} scored {$result['overall_score']} ({$result['rating']}). PIP recommended.", "Contractor {$contractor['full_name_en']} scored {$overallResult['overall_score']} ({$overallResult['rating_label']}) for {$activeCycle['month']}. PIP recommended.",
"/users/{$cid}", 'user', $cid); "/users/{$contractorId}", 'user', $contractorId);
} }
} }
$compiledCount++;
} }
$totalExpected = count($contractors); if ($allCompiled) {
$totalCompiled = (int)$this->db->fetchColumn( $db->update('evaluation_cycles', [
"SELECT COUNT(*) FROM compiled_evaluations WHERE cycle_id = ?", [$cycle['id']]
);
if ($totalCompiled >= $totalExpected && $totalExpected > 0) {
$this->db->update('evaluation_cycles', [
'status' => 'completed', 'status' => 'completed',
'completed_at' => date('Y-m-d H:i:s'), 'completed_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$cycle['id']]); ], 'id = ?', [$activeCycle['id']]);
} }
} }
} }
\ No newline at end of file
...@@ -10,51 +10,71 @@ use Engine\Notifications\NotificationManager; ...@@ -10,51 +10,71 @@ use Engine\Notifications\NotificationManager;
final class EvaluationReminderJob implements JobInterface final class EvaluationReminderJob implements JobInterface
{ {
private Connection $db; public function key(): string
private NotificationManager $notif; {
return 'evaluation_reminders';
}
public function __construct() public function shouldRun(): bool
{ {
$c = Container::getInstance(); return true;
$this->db = $c->resolve(Connection::class);
$this->notif = $c->resolve(NotificationManager::class);
} }
public function run(): void public function run(): void
{ {
$cycle = $this->db->fetchOne( $db = Container::getInstance()->resolve(Connection::class);
$notif = Container::getInstance()->resolve(NotificationManager::class);
$activeCycle = $db->fetchOne(
"SELECT * FROM evaluation_cycles WHERE status IN ('open','technical_phase','professional_phase') LIMIT 1" "SELECT * FROM evaluation_cycles WHERE status IN ('open','technical_phase','professional_phase') LIMIT 1"
); );
if (!$cycle) return;
if (!$activeCycle) {
return;
}
$now = time(); $now = time();
$techDeadline = strtotime($cycle['tech_deadline']); $techDeadline = strtotime($activeCycle['tech_deadline']);
$profDeadline = strtotime($cycle['prof_deadline']); $profDeadline = strtotime($activeCycle['prof_deadline']);
$daysToTech = (int)ceil(($techDeadline - $now) / 86400); $daysTilTech = (int)ceil(($techDeadline - $now) / 86400);
$daysToProf = (int)ceil(($profDeadline - $now) / 86400); $daysTilProf = (int)ceil(($profDeadline - $now) / 86400);
if ($daysToTech <= 2 && $daysToTech >= 0) { if ($daysTilTech <= 2 && $daysTilTech >= 0) {
$pending = $this->db->fetchAll( $pendingTech = $db->fetchAll(
"SELECT DISTINCT evaluator_id FROM evaluations WHERE cycle_id = ? AND type = 'technical' AND submitted_at IS NULL", "SELECT DISTINCT evaluator_id FROM evaluations
[$cycle['id']] WHERE cycle_id = ? AND type = 'technical' AND submitted_at IS NULL",
[$activeCycle['id']]
);
foreach ($pendingTech as $e) {
$count = (int)$db->fetchColumn(
"SELECT COUNT(*) FROM evaluations
WHERE cycle_id = ? AND type = 'technical' AND evaluator_id = ? AND submitted_at IS NULL",
[$activeCycle['id'], $e['evaluator_id']]
); );
foreach ($pending as $p) { $urgency = $daysTilTech === 0 ? '🚨 DUE TODAY' : "⏰ {$daysTilTech} days remaining";
$this->notif->createImportant($p['evaluator_id'], 'Technical Evaluation Due', $notif->createImportant($e['evaluator_id'], "Technical Evaluations {$urgency}",
"Technical evaluations for {$cycle['month']} are due in {$daysToTech} day(s). Please submit them.", "You have {$count} technical evaluation(s) pending for {$activeCycle['month']}. Deadline: " . date('M j', $techDeadline),
'/evaluations/pending', 'evaluation_cycle', $cycle['id']); '/evaluations/pending', 'evaluation_cycle', $activeCycle['id']);
} }
} }
if ($daysToProf <= 2 && $daysToProf >= 0) { if ($daysTilProf <= 2 && $daysTilProf >= 0) {
$pending = $this->db->fetchAll( $pendingProf = $db->fetchAll(
"SELECT DISTINCT evaluator_id FROM evaluations WHERE cycle_id = ? AND type = 'professional' AND submitted_at IS NULL", "SELECT DISTINCT evaluator_id FROM evaluations
[$cycle['id']] WHERE cycle_id = ? AND type = 'professional' AND submitted_at IS NULL",
[$activeCycle['id']]
);
foreach ($pendingProf as $e) {
$count = (int)$db->fetchColumn(
"SELECT COUNT(*) FROM evaluations
WHERE cycle_id = ? AND type = 'professional' AND evaluator_id = ? AND submitted_at IS NULL",
[$activeCycle['id'], $e['evaluator_id']]
); );
foreach ($pending as $p) { $urgency = $daysTilProf === 0 ? '🚨 DUE TODAY' : "⏰ {$daysTilProf} days remaining";
$this->notif->createImportant($p['evaluator_id'], 'Professional Evaluation Due', $notif->createImportant($e['evaluator_id'], "Professional Evaluations {$urgency}",
"Professional evaluations for {$cycle['month']} are due in {$daysToProf} day(s). Please submit them.", "You have {$count} professional evaluation(s) pending for {$activeCycle['month']}. Deadline: " . date('M j', $profDeadline),
'/evaluations/pending', 'evaluation_cycle', $cycle['id']); '/evaluations/pending', 'evaluation_cycle', $activeCycle['id']);
} }
} }
} }
......
...@@ -6,56 +6,99 @@ namespace Modules\Evaluations\Jobs; ...@@ -6,56 +6,99 @@ namespace Modules\Evaluations\Jobs;
use Engine\Scheduler\JobInterface; use Engine\Scheduler\JobInterface;
use Engine\Core\Container; use Engine\Core\Container;
use Engine\Database\Connection; use Engine\Database\Connection;
use Engine\Notifications\NotificationManager;
final class OpenEvaluationCycleJob implements JobInterface final class OpenEvaluationCycleJob implements JobInterface
{ {
private Connection $db; public function key(): string
{
return 'open_evaluation_cycle';
}
public function __construct() public function shouldRun(): bool
{ {
$this->db = Container::getInstance()->resolve(Connection::class); return (int)date('j') === 1;
} }
public function run(): void public function run(): void
{ {
if ((int)date('j') !== 1) return; $db = Container::getInstance()->resolve(Connection::class);
$notif = Container::getInstance()->resolve(NotificationManager::class);
$month = date('Y-m', strtotime('-1 month')); $month = date('Y-m', strtotime('-1 month'));
$exists = $this->db->fetchOne("SELECT id FROM evaluation_cycles WHERE month = ?", [$month]);
if ($exists) return; $exists = $db->fetchOne("SELECT id FROM evaluation_cycles WHERE month = ?", [$month]);
if ($exists) {
return;
}
$now = date('Y-m-d H:i:s'); $now = date('Y-m-d H:i:s');
$cycleId = $this->db->insert('evaluation_cycles', [ $techDeadline = date('Y-m-d H:i:s', strtotime('+5 weekdays'));
$profDeadline = date('Y-m-d H:i:s', strtotime('+7 weekdays'));
$db->beginTransaction();
try {
$cycleId = $db->insert('evaluation_cycles', [
'month' => $month, 'month' => $month,
'status' => 'open', 'status' => 'open',
'opened_at' => $now, 'opened_at' => $now,
'tech_deadline' => date('Y-m-d 23:59:59', strtotime('+5 weekdays')), 'tech_deadline' => $techDeadline,
'prof_deadline' => date('Y-m-d 23:59:59', strtotime('+7 weekdays')), 'prof_deadline' => $profDeadline,
]); ]);
$contractors = $this->db->fetchAll( $contractors = $db->fetchAll(
"SELECT u.id, (SELECT bm.user_id FROM board_members bm "SELECT id FROM users WHERE role = 'contractor' AND status IN ('active','on_pip') AND is_active = 1"
);
foreach ($contractors as $contractor) {
$pl = $db->fetchOne(
"SELECT bm.user_id FROM board_members bm
JOIN board_members bm2 ON bm2.board_id = bm.board_id 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 WHERE bm2.user_id = ? AND bm.role_on_board = 'project_leader' LIMIT 1",
FROM users u WHERE u.role = 'contractor' AND u.status IN ('active','on_pip') AND u.is_active = 1" [$contractor['id']]
); );
$defaultAdmin = $this->db->fetchOne("SELECT id FROM users WHERE role IN ('admin','super_admin') AND is_active = 1 LIMIT 1"); $plId = $pl ? $pl['user_id'] : null;
$defaultAdminId = $defaultAdmin ? $defaultAdmin['id'] : 1; if (!$plId) {
$sa = $db->fetchOne("SELECT id FROM users WHERE role = 'super_admin' AND is_active = 1 LIMIT 1");
$plId = $sa ? $sa['id'] : 1;
}
foreach ($contractors as $c) { $db->insert('evaluations', [
$this->db->insert('evaluations', [
'cycle_id' => $cycleId, 'cycle_id' => $cycleId,
'contractor_id' => $c['id'], 'contractor_id' => $contractor['id'],
'type' => 'technical', 'type' => 'technical',
'evaluator_id' => $c['pl_id'] ?? $defaultAdminId, 'evaluator_id' => $plId,
]); ]);
$this->db->insert('evaluations', [
$admin = $db->fetchOne(
"SELECT id FROM users WHERE role IN ('admin','super_admin') AND is_active = 1 LIMIT 1"
);
$adminId = $admin ? $admin['id'] : $plId;
$db->insert('evaluations', [
'cycle_id' => $cycleId, 'cycle_id' => $cycleId,
'contractor_id' => $c['id'], 'contractor_id' => $contractor['id'],
'type' => 'professional', 'type' => 'professional',
'evaluator_id' => $defaultAdminId, 'evaluator_id' => $adminId,
]); ]);
} }
$db->commit();
$evaluators = $db->fetchAll(
"SELECT DISTINCT evaluator_id FROM evaluations WHERE cycle_id = ?",
[$cycleId]
);
foreach ($evaluators as $e) {
$notif->createImportant($e['evaluator_id'], 'Evaluation Cycle Opened',
"The evaluation cycle for {$month} is now open. Please submit your evaluations.",
'/evaluations/pending', 'evaluation_cycle', $cycleId);
}
} catch (\Throwable $e) {
$db->rollBack();
throw $e;
}
} }
} }
\ No newline at end of file
<?php <?php
use Engine\Core\Container; use Engine\Core\Router;
use Modules\Evaluations\Controllers\EvaluationController;
use Modules\Evaluations\Controllers\EvaluationCycleController;
$router = Container::getInstance()->resolve(\Engine\Core\Router::class); return function (Router $router) {
$router->get('/evaluations/mine', [EvaluationController::class, 'myEvaluations']);
$router->get('/evaluations/pending', [EvaluationController::class, 'pending']);
$router->get('/evaluations/compiled/{compiledId}', [EvaluationController::class, 'showCompiled']);
$router->get('/evaluations/{evaluationId}/technical', [EvaluationController::class, 'technicalForm']);
$router->post('/evaluations/{evaluationId}/technical', [EvaluationController::class, 'submitTechnical']);
$router->get('/evaluations/{evaluationId}/professional', [EvaluationController::class, 'professionalForm']);
$router->post('/evaluations/{evaluationId}/professional', [EvaluationController::class, 'submitProfessional']);
$router->post('/evaluations/compiled/{compiledId}/acknowledge', [EvaluationController::class, 'acknowledge']);
$router->post('/evaluations/compiled/{compiledId}/respond', [EvaluationController::class, 'respond']);
$router->get('/evaluations', \Modules\Evaluations\Controllers\EvaluationController::class, 'myEvaluations', ['auth', 'blocking']); $router->get('/evaluations/cycles', [EvaluationCycleController::class, 'index']);
$router->get('/evaluations/pending', \Modules\Evaluations\Controllers\EvaluationController::class, 'pending', ['auth', 'blocking']); $router->get('/evaluations/cycles/{cycleId}', [EvaluationCycleController::class, 'show']);
$router->get('/evaluations/compiled/{id}', \Modules\Evaluations\Controllers\EvaluationController::class, 'showCompiled', ['auth', 'blocking']); $router->post('/evaluations/cycles', [EvaluationCycleController::class, 'create']);
$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']); // API mirrors
$router->get('/evaluations/cycles/{id}', \Modules\Evaluations\Controllers\EvaluationCycleController::class, 'show', ['auth', 'blocking']); $router->get('/api/evaluations', [EvaluationController::class, 'myEvaluations']);
$router->post('/evaluations/cycles', \Modules\Evaluations\Controllers\EvaluationCycleController::class, 'create', ['auth', 'blocking']); $router->get('/api/evaluations/pending', [EvaluationController::class, 'pending']);
\ No newline at end of file $router->get('/api/evaluations/compiled/{compiledId}', [EvaluationController::class, 'showCompiled']);
$router->post('/api/evaluations/{evaluationId}/technical', [EvaluationController::class, 'submitTechnical']);
$router->post('/api/evaluations/{evaluationId}/professional', [EvaluationController::class, 'submitProfessional']);
$router->post('/api/evaluations/compiled/{compiledId}/acknowledge', [EvaluationController::class, 'acknowledge']);
$router->post('/api/evaluations/compiled/{compiledId}/respond', [EvaluationController::class, 'respond']);
$router->get('/api/evaluations/cycles', [EvaluationCycleController::class, 'index']);
$router->get('/api/evaluations/cycles/{cycleId}', [EvaluationCycleController::class, 'show']);
$router->post('/api/evaluations/cycles', [EvaluationCycleController::class, 'create']);
};
\ No newline at end of file
<?php <?php
declare(strict_types=1); use Engine\Core\Router;
use Modules\Holidays\Controllers\HolidayController;
use Engine\Core\Container; return function (Router $router) {
$router->get('/holidays', [HolidayController::class, 'index']);
$router->post('/holidays', [HolidayController::class, 'create']);
$router->put('/holidays/{id}', [HolidayController::class, 'update']);
$router->delete('/holidays/{id}', [HolidayController::class, 'delete']);
$router = Container::getInstance()->resolve(Engine\Core\Router::class); $router->get('/api/holidays', [HolidayController::class, 'index']);
$router->post('/api/holidays', [HolidayController::class, 'create']);
$router->group([ $router->put('/api/holidays/{id}', [HolidayController::class, 'update']);
'prefix' => '/api/holidays', $router->delete('/api/holidays/{id}', [HolidayController::class, 'delete']);
'middleware' => [Middleware\AuthenticationMiddleware::class, Middleware\CSRFMiddleware::class] };
], function ($router) { \ No newline at end of file
$router->get('/', Modules\Holidays\Controllers\HolidayController::class, 'index');
$router->post('/', Modules\Holidays\Controllers\HolidayController::class, 'create');
$router->post('/{id}', Modules\Holidays\Controllers\HolidayController::class, 'update');
$router->post('/{id}/delete', Modules\Holidays\Controllers\HolidayController::class, 'delete');
});
\ No newline at end of file
...@@ -10,39 +10,81 @@ use Engine\Notifications\NotificationManager; ...@@ -10,39 +10,81 @@ use Engine\Notifications\NotificationManager;
final class LearningGoalReminderJob implements JobInterface final class LearningGoalReminderJob implements JobInterface
{ {
private Connection $db; public function key(): string
private NotificationManager $notif; {
return 'learning_goal_reminders';
}
public function __construct() public function shouldRun(): bool
{ {
$c = Container::getInstance(); return true;
$this->db = $c->resolve(Connection::class);
$this->notif = $c->resolve(NotificationManager::class);
} }
public function run(): void public function run(): void
{ {
$db = Container::getInstance()->resolve(Connection::class);
$notif = Container::getInstance()->resolve(NotificationManager::class);
$today = date('Y-m-d'); $today = date('Y-m-d');
$reminderDays = [14, 7, 2, 0];
foreach ($reminderDays as $days) { $goals = $db->fetchAll(
$targetDate = date('Y-m-d', strtotime("+{$days} days")); "SELECT lg.*, u.full_name_en as contractor_name, u.assigned_pl_id, ca.name as competency_name
$goals = $this->db->fetchAll( FROM learning_goals lg
"SELECT * FROM learning_goals WHERE deadline = ? AND status IN ('active','extended') AND deleted_at IS NULL", JOIN users u ON u.id = lg.contractor_id
[$targetDate] JOIN competency_areas ca ON ca.id = lg.competency_area_id
WHERE lg.status = 'active' AND lg.deleted_at IS NULL"
); );
foreach ($goals as $g) {
$msg = $days === 0 foreach ($goals as $goal) {
? "Learning goal \"{$g['title']}\" is due TODAY." $daysRemaining = (int)((strtotime($goal['deadline']) - strtotime($today)) / 86400);
: "Learning goal \"{$g['title']}\" is due in {$days} days.";
$this->notif->createImportant($g['contractor_id'], '⏰ Learning Goal Deadline', $msg, if (in_array($daysRemaining, [14, 7, 2, 0])) {
'/learning-goals', 'learning_goal', $g['id']); $urgencyMap = [
14 => '14 days remaining',
7 => '7 days remaining',
2 => '2 days remaining ⚠️',
0 => 'DUE TODAY 🚨',
];
$urgency = $urgencyMap[$daysRemaining];
$notif->createImportant($goal['contractor_id'], "Learning Goal: {$urgency}",
"Your learning goal \"{$goal['title']}\" ({$goal['competency_name']}) is due {$goal['deadline']}. {$urgency}",
'/learning-goals', 'learning_goal', (int)$goal['id']);
} }
if ($daysRemaining < 0 && $goal['status'] === 'active') {
$db->update('learning_goals', ['status' => 'overdue'], 'id = ?', [(int)$goal['id']]);
$notif->createImportant($goal['contractor_id'], 'Learning Goal Overdue',
"Your learning goal \"{$goal['title']}\" is now overdue.",
'/learning-goals', 'learning_goal', (int)$goal['id']);
$recipients = [];
if ($goal['assigned_pl_id']) {
$recipients[] = $goal['assigned_pl_id'];
}
$admins = $db->fetchAll("SELECT id FROM users WHERE role IN ('super_admin','admin') AND is_active = 1");
foreach ($admins as $a) {
$recipients[] = $a['id'];
} }
$this->db->query( foreach (array_unique($recipients) as $rid) {
"UPDATE learning_goals SET status = 'overdue' WHERE deadline < ? AND status = 'active' AND deleted_at IS NULL", $notif->createImportant($rid, 'Learning Goal Overdue',
[$today] "{$goal['contractor_name']}'s learning goal \"{$goal['title']}\" is overdue.",
); "/users/{$goal['contractor_id']}", 'learning_goal', (int)$goal['id']);
}
$daysOverdue = abs($daysRemaining);
$initialDeadlineDays = $goal['is_auto_generated'] ? 45 : null;
if ($initialDeadlineDays && $daysOverdue >= $initialDeadlineDays) {
$admins = $db->fetchAll("SELECT id FROM users WHERE role = 'super_admin' AND is_active = 1");
foreach ($admins as $a) {
$notif->createImportant($a['id'], '🚨 Learning Goal — Double Deadline Exceeded',
"{$goal['contractor_name']}'s auto-generated learning goal \"{$goal['title']}\" has exceeded double the original deadline. Termination review required.",
"/users/{$goal['contractor_id']}", 'user', (int)$goal['contractor_id']);
}
}
}
}
} }
} }
\ No newline at end of file
<?php <?php
use Engine\Core\Container; use Engine\Core\Router;
use Modules\LearningGoals\Controllers\LearningGoalController;
use Modules\LearningGoals\Controllers\CompetencyController;
$router = Container::getInstance()->resolve(\Engine\Core\Router::class); return function (Router $router) {
$router->get('/learning-goals', [LearningGoalController::class, 'index']);
$router->post('/learning-goals', [LearningGoalController::class, 'create']);
$router->put('/learning-goals/{goalId}', [LearningGoalController::class, 'update']);
$router->post('/learning-goals/{goalId}/assess', [LearningGoalController::class, 'assess']);
$router->delete('/learning-goals/{goalId}', [LearningGoalController::class, 'delete']);
$router->get('/learning-goals', \Modules\LearningGoals\Controllers\LearningGoalController::class, 'index', ['auth', 'blocking']); $router->get('/competency/areas', [CompetencyController::class, 'areas']);
$router->post('/learning-goals', \Modules\LearningGoals\Controllers\LearningGoalController::class, 'create', ['auth', 'blocking']); $router->get('/competency/profile/{userId}', [CompetencyController::class, 'profile']);
$router->put('/learning-goals/{id}', \Modules\LearningGoals\Controllers\LearningGoalController::class, 'update', ['auth', 'blocking']); $router->post('/competency/assess/{userId}', [CompetencyController::class, 'submitAssessment']);
$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('/api/learning-goals', [LearningGoalController::class, 'index']);
$router->get('/competency/profile/{userId}', \Modules\LearningGoals\Controllers\CompetencyController::class, 'profile', ['auth', 'blocking']); $router->post('/api/learning-goals', [LearningGoalController::class, 'create']);
$router->post('/competency/assess/{userId}', \Modules\LearningGoals\Controllers\CompetencyController::class, 'submitAssessment', ['auth', 'blocking']); $router->put('/api/learning-goals/{goalId}', [LearningGoalController::class, 'update']);
\ No newline at end of file $router->put('/api/learning-goals/{goalId}/assess', [LearningGoalController::class, 'assess']);
$router->delete('/api/learning-goals/{goalId}', [LearningGoalController::class, 'delete']);
$router->get('/api/competency/areas', [CompetencyController::class, 'areas']);
$router->get('/api/competency/profile/{userId}', [CompetencyController::class, 'profile']);
$router->post('/api/competency/assess/{userId}', [CompetencyController::class, 'submitAssessment']);
};
\ No newline at end of file
...@@ -10,50 +10,80 @@ use Engine\Notifications\NotificationManager; ...@@ -10,50 +10,80 @@ use Engine\Notifications\NotificationManager;
final class MeetingReminderJob implements JobInterface final class MeetingReminderJob implements JobInterface
{ {
private Connection $db; public function key(): string
private NotificationManager $notif; {
return 'meeting_reminders';
}
public function __construct() public function shouldRun(): bool
{ {
$c = Container::getInstance(); return true;
$this->db = $c->resolve(Connection::class);
$this->notif = $c->resolve(NotificationManager::class);
} }
public function run(): void public function run(): void
{ {
$db = Container::getInstance()->resolve(Connection::class);
$notif = Container::getInstance()->resolve(NotificationManager::class);
$now = time(); $now = time();
$oneHour = date('Y-m-d H:i:s', $now + 3600); $today = date('Y-m-d');
$oneHourAgo = date('Y-m-d H:i:s', $now + 3000); $tomorrow = date('Y-m-d', strtotime('+1 day'));
$currentTime = date('H:i:s');
$meetings = $this->db->fetchAll( // 1-day reminders: meetings tomorrow
"SELECT m.* FROM meetings m $tomorrowMeetings = $db->fetchAll(
WHERE m.status = 'scheduled' "SELECT m.*, u.full_name_en as creator_name FROM meetings m
AND CONCAT(m.meeting_date, ' ', m.start_time) BETWEEN ? AND ?", JOIN users u ON u.id = m.created_by_id
[$oneHourAgo, $oneHour] WHERE m.meeting_date = ? AND m.status = 'scheduled'",
[$tomorrow]
); );
foreach ($meetings as $m) { foreach ($tomorrowMeetings as $m) {
$invitees = $this->db->fetchAll("SELECT user_id FROM meeting_invitees WHERE meeting_id = ?", [$m['id']]); $invitees = $db->fetchAll(
"SELECT user_id FROM meeting_invitees WHERE meeting_id = ?",
[$m['id']]
);
foreach ($invitees as $inv) { foreach ($invitees as $inv) {
$this->notif->createImportant($inv['user_id'], '⏰ Meeting in 1 Hour', $alreadySent = $db->fetchOne(
"Meeting: \"{$m['title']}\" starts in about 1 hour.", "SELECT id FROM notifications WHERE user_id = ? AND link_entity_type = 'meeting'
"/meetings/{$m['id']}", 'meeting', $m['id']); AND link_entity_id = ? AND title LIKE '%tomorrow%' AND created_at >= ?",
[$inv['user_id'], $m['id'], date('Y-m-d 00:00:00')]
);
if (!$alreadySent) {
$notif->createImportant($inv['user_id'], "Meeting tomorrow: {$m['title']}",
"Reminder: \"{$m['title']}\" is scheduled for tomorrow at {$m['start_time']}." .
($m['location'] ? " Location: {$m['location']}" : ''),
"/meetings/{$m['id']}", 'meeting', (int)$m['id']);
}
} }
} }
$tomorrow = date('Y-m-d', strtotime('+1 day')); // 1-hour reminders: meetings today within the next hour
$tomorrowMeetings = $this->db->fetchAll( $oneHourFromNow = date('H:i:s', strtotime('+1 hour'));
"SELECT * FROM meetings WHERE meeting_date = ? AND status = 'scheduled'", $soonMeetings = $db->fetchAll(
[$tomorrow] "SELECT m.* FROM meetings m
WHERE m.meeting_date = ? AND m.status = 'scheduled'
AND m.start_time > ? AND m.start_time <= ?",
[$today, $currentTime, $oneHourFromNow]
); );
foreach ($tomorrowMeetings as $m) { foreach ($soonMeetings as $m) {
$invitees = $this->db->fetchAll("SELECT user_id FROM meeting_invitees WHERE meeting_id = ?", [$m['id']]); $invitees = $db->fetchAll(
"SELECT user_id FROM meeting_invitees WHERE meeting_id = ?",
[$m['id']]
);
foreach ($invitees as $inv) { foreach ($invitees as $inv) {
$this->notif->createImportant($inv['user_id'], 'Meeting Tomorrow', $alreadySent = $db->fetchOne(
"Meeting: \"{$m['title']}\" is scheduled for tomorrow at {$m['start_time']}.", "SELECT id FROM notifications WHERE user_id = ? AND link_entity_type = 'meeting'
"/meetings/{$m['id']}", 'meeting', $m['id']); AND link_entity_id = ? AND title LIKE '%1 hour%' AND created_at >= ?",
[$inv['user_id'], $m['id'], date('Y-m-d 00:00:00')]
);
if (!$alreadySent) {
$notif->createImportant($inv['user_id'], "Meeting in 1 hour: {$m['title']}",
"Starting at {$m['start_time']}." .
($m['location'] ? " Location: {$m['location']}" : ''),
"/meetings/{$m['id']}", 'meeting', (int)$m['id']);
}
} }
} }
} }
......
<?php <?php
use Engine\Core\Container; use Engine\Core\Router;
use Modules\Meetings\Controllers\MeetingController;
$router = Container::getInstance()->resolve(\Engine\Core\Router::class); return function (Router $router) {
$router->get('/meetings', [MeetingController::class, 'index']);
$router->get('/meetings/{meetingId}', [MeetingController::class, 'show']);
$router->post('/meetings', [MeetingController::class, 'create']);
$router->put('/meetings/{meetingId}', [MeetingController::class, 'update']);
$router->post('/meetings/{meetingId}/notes', [MeetingController::class, 'addNotes']);
$router->delete('/meetings/{meetingId}', [MeetingController::class, 'delete']);
$router->get('/meetings', \Modules\Meetings\Controllers\MeetingController::class, 'index', ['auth', 'blocking']); $router->get('/api/meetings', [MeetingController::class, 'index']);
$router->get('/meetings/{id}', \Modules\Meetings\Controllers\MeetingController::class, 'show', ['auth', 'blocking']); $router->get('/api/meetings/{meetingId}', [MeetingController::class, 'show']);
$router->post('/meetings', \Modules\Meetings\Controllers\MeetingController::class, 'create', ['auth', 'blocking']); $router->post('/api/meetings', [MeetingController::class, 'create']);
$router->put('/meetings/{id}', \Modules\Meetings\Controllers\MeetingController::class, 'update', ['auth', 'blocking']); $router->put('/api/meetings/{meetingId}', [MeetingController::class, 'update']);
$router->post('/meetings/{id}/notes', \Modules\Meetings\Controllers\MeetingController::class, 'addNotes', ['auth', 'blocking']); $router->post('/api/meetings/{meetingId}/notes', [MeetingController::class, 'addNotes']);
$router->delete('/meetings/{id}', \Modules\Meetings\Controllers\MeetingController::class, 'delete', ['auth', 'blocking']); $router->delete('/api/meetings/{meetingId}', [MeetingController::class, 'delete']);
\ No newline at end of file };
\ No newline at end of file
<?php <?php
use Engine\Core\Container; use Engine\Core\Router;
use Modules\Offboarding\Controllers\OffboardingController;
$router = Container::getInstance()->resolve(\Engine\Core\Router::class); return function (Router $router) {
$router->post('/offboarding/initiate', [OffboardingController::class, 'initiate']);
$router->get('/offboarding/settlement/{userId}', [OffboardingController::class, 'calculateFinalSettlement']);
$router->post('/offboarding/initiate', \Modules\Offboarding\Controllers\OffboardingController::class, 'initiate', ['auth', 'blocking']); $router->post('/api/offboarding/initiate', [OffboardingController::class, 'initiate']);
$router->get('/offboarding/settlement/{userId}', \Modules\Offboarding\Controllers\OffboardingController::class, 'calculateFinalSettlement', ['auth', 'blocking']); $router->get('/api/offboarding/settlement/{userId}', [OffboardingController::class, 'calculateFinalSettlement']);
\ No newline at end of file };
\ No newline at end of file
...@@ -10,32 +10,78 @@ use Engine\Notifications\NotificationManager; ...@@ -10,32 +10,78 @@ use Engine\Notifications\NotificationManager;
final class PIPCheckinReminderJob implements JobInterface final class PIPCheckinReminderJob implements JobInterface
{ {
private Connection $db; public function key(): string
private NotificationManager $notif; {
return 'pip_checkin_reminders';
}
public function __construct() public function shouldRun(): bool
{ {
$c = Container::getInstance(); return true;
$this->db = $c->resolve(Connection::class);
$this->notif = $c->resolve(NotificationManager::class);
} }
public function run(): void public function run(): void
{ {
$db = Container::getInstance()->resolve(Connection::class);
$notif = Container::getInstance()->resolve(NotificationManager::class);
$today = date('Y-m-d'); $today = date('Y-m-d');
$checkins = $this->db->fetchAll( $tomorrow = date('Y-m-d', strtotime('+1 day'));
"SELECT pc.*, p.contractor_id, p.created_by_id FROM pip_checkins pc
$checkins = $db->fetchAll(
"SELECT pc.*, p.contractor_id, p.created_by_id, u.full_name_en as contractor_name,
u.assigned_pl_id
FROM pip_checkins pc
JOIN pips p ON p.id = pc.pip_id 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", JOIN users u ON u.id = p.contractor_id
WHERE pc.scheduled_date IN (?, ?)
AND pc.logged_at IS NULL
AND p.status = 'active'
AND p.deleted_at IS NULL",
[$today, $tomorrow]
);
foreach ($checkins as $checkin) {
$isToday = $checkin['scheduled_date'] === $today;
$urgency = $isToday ? 'TODAY' : 'tomorrow';
$notif->createImportant($checkin['contractor_id'], "PIP Check-in {$urgency}",
"You have a PIP check-in scheduled for {$urgency}.",
"/pips/{$checkin['pip_id']}", 'pip', (int)$checkin['pip_id']);
$notif->createImportant($checkin['created_by_id'], "PIP Check-in {$urgency}",
"PIP check-in for {$checkin['contractor_name']} is scheduled {$urgency}.",
"/pips/{$checkin['pip_id']}", 'pip', (int)$checkin['pip_id']);
if ($checkin['assigned_pl_id'] && $checkin['assigned_pl_id'] !== $checkin['created_by_id']) {
$notif->createImportant($checkin['assigned_pl_id'], "PIP Check-in {$urgency}",
"PIP check-in for {$checkin['contractor_name']} is scheduled {$urgency}.",
"/pips/{$checkin['pip_id']}", 'pip', (int)$checkin['pip_id']);
}
}
$missedCheckins = $db->fetchAll(
"SELECT pc.*, p.contractor_id, u.full_name_en as contractor_name
FROM pip_checkins pc
JOIN pips p ON p.id = pc.pip_id
JOIN users u ON u.id = p.contractor_id
WHERE pc.scheduled_date < ?
AND pc.logged_at IS NULL
AND p.status = 'active'
AND p.deleted_at IS NULL",
[$today] [$today]
); );
foreach ($checkins as $ci) { foreach ($missedCheckins as $missed) {
$this->notif->createImportant($ci['contractor_id'], 'PIP Check-in Today', $daysSince = (int)((strtotime($today) - strtotime($missed['scheduled_date'])) / 86400);
'You have a PIP check-in scheduled for today.', "/pips/{$ci['pip_id']}", 'pip', (int)$ci['pip_id']); if ($daysSince === 2) {
$this->notif->createImportant($ci['created_by_id'], 'PIP Check-in Due', $admins = $db->fetchAll("SELECT id FROM users WHERE role IN ('super_admin','admin') AND is_active = 1");
"PIP check-in for contractor ID {$ci['contractor_id']} is scheduled today.", foreach ($admins as $a) {
"/pips/{$ci['pip_id']}", 'pip', (int)$ci['pip_id']); $notif->createImportant($a['id'], 'Missed PIP Check-in',
"PIP check-in for {$missed['contractor_name']} on {$missed['scheduled_date']} was missed. Notes have not been logged.",
"/pips/{$missed['pip_id']}", 'pip', (int)$missed['pip_id']);
}
}
} }
} }
} }
\ No newline at end of file
<?php <?php
use Engine\Core\Container; use Engine\Core\Router;
use Modules\PIPs\Controllers\PIPController;
$router = Container::getInstance()->resolve(\Engine\Core\Router::class); return function (Router $router) {
$router->get('/pips', [PIPController::class, 'index']);
$router->get('/pips/{pipId}', [PIPController::class, 'show']);
$router->post('/pips', [PIPController::class, 'create']);
$router->post('/pips/{pipId}/acknowledge', [PIPController::class, 'acknowledge']);
$router->post('/pips/{pipId}/checkins/{checkinId}', [PIPController::class, 'logCheckin']);
$router->post('/pips/{pipId}/decide', [PIPController::class, 'decide']);
$router->delete('/pips/{pipId}', [PIPController::class, 'delete']);
$router->get('/pips', \Modules\PIPs\Controllers\PIPController::class, 'index', ['auth', 'blocking']); $router->get('/api/pips', [PIPController::class, 'index']);
$router->get('/pips/{id}', \Modules\PIPs\Controllers\PIPController::class, 'show', ['auth', 'blocking']); $router->get('/api/pips/{pipId}', [PIPController::class, 'show']);
$router->post('/pips', \Modules\PIPs\Controllers\PIPController::class, 'create', ['auth', 'blocking']); $router->post('/api/pips', [PIPController::class, 'create']);
$router->post('/pips/{id}/acknowledge', \Modules\PIPs\Controllers\PIPController::class, 'acknowledge', ['auth']); $router->post('/api/pips/{pipId}/acknowledge', [PIPController::class, 'acknowledge']);
$router->post('/pips/{id}/checkins/{checkinId}', \Modules\PIPs\Controllers\PIPController::class, 'logCheckin', ['auth', 'blocking']); $router->post('/api/pips/{pipId}/checkin', [PIPController::class, 'logCheckin']);
$router->post('/pips/{id}/decide', \Modules\PIPs\Controllers\PIPController::class, 'decide', ['auth', 'blocking']); $router->put('/api/pips/{pipId}/result', [PIPController::class, 'decide']);
$router->delete('/pips/{id}', \Modules\PIPs\Controllers\PIPController::class, 'delete', ['auth', 'blocking']); $router->delete('/api/pips/{pipId}', [PIPController::class, 'delete']);
\ No newline at end of file };
\ No newline at end of file
...@@ -6,84 +6,161 @@ namespace Modules\RecurringCards\Jobs; ...@@ -6,84 +6,161 @@ namespace Modules\RecurringCards\Jobs;
use Engine\Scheduler\JobInterface; use Engine\Scheduler\JobInterface;
use Engine\Core\Container; use Engine\Core\Container;
use Engine\Database\Connection; use Engine\Database\Connection;
use Engine\Notifications\NotificationManager;
final class CreateRecurringCardsJob implements JobInterface final class CreateRecurringCardsJob implements JobInterface
{ {
private Connection $db; public function key(): string
{
return 'create_recurring_cards';
}
public function __construct() public function shouldRun(): bool
{ {
$this->db = Container::getInstance()->resolve(Connection::class); return true;
} }
public function run(): void public function run(): void
{ {
$db = Container::getInstance()->resolve(Connection::class);
$notif = Container::getInstance()->resolve(NotificationManager::class);
$now = date('Y-m-d H:i:s'); $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 <= ?)", $definitions = $db->fetchAll(
"SELECT rcd.*, b.board_key, b.card_sequence, b.is_archived
FROM recurring_card_definitions rcd
JOIN boards b ON b.id = rcd.board_id
WHERE rcd.is_active = 1 AND rcd.next_creation_at IS NOT NULL AND rcd.next_creation_at <= ?
AND b.is_archived = 0",
[$now] [$now]
); );
foreach ($definitions as $def) { foreach ($definitions as $def) {
$template = json_decode($def['card_template_json'], true); $db->beginTransaction();
if (!$template) continue; try {
$template = json_decode($def['card_template_json'], true) ?? [];
$board = $this->db->fetchOne("SELECT * FROM boards WHERE id = ? AND is_archived = 0", [$def['board_id']]); $db->query("UPDATE boards SET card_sequence = card_sequence + 1 WHERE id = ?", [(int)$def['board_id']]);
if (!$board) continue; $updatedBoard = $db->fetchOne("SELECT card_sequence FROM boards WHERE id = ?", [(int)$def['board_id']]);
$cardKey = $def['board_key'] . '-' . $updatedBoard['card_sequence'];
$backlogCol = $this->db->fetchOne( $backlogCol = $db->fetchOne(
"SELECT id FROM board_columns WHERE board_id = ? AND slug = 'backlog'", [$board['id']] "SELECT id FROM board_columns WHERE board_id = ? AND slug = 'backlog'",
[(int)$def['board_id']]
); );
if (!$backlogCol) continue;
$this->db->transaction(function () use ($def, $template, $board, $backlogCol) { if (!$backlogCol) {
$this->db->query("UPDATE boards SET card_sequence = card_sequence + 1 WHERE id = ?", [$board['id']]); $db->rollBack();
$updated = $this->db->fetchOne("SELECT card_sequence FROM boards WHERE id = ?", [$board['id']]); continue;
$cardKey = $board['board_key'] . '-' . $updated['card_sequence']; }
$title = ($template['title'] ?? 'Recurring Task') . ' — ' . date('M j, Y'); $dateStr = date('M j, Y');
$title = ($template['title'] ?? 'Recurring Task') . " — {$dateStr}";
$cardId = $this->db->insert('cards', [ $cardId = $db->insert('cards', [
'board_id' => $board['id'], 'board_id' => (int)$def['board_id'],
'column_id' => $backlogCol['id'], 'column_id' => $backlogCol['id'],
'card_number' => $updated['card_sequence'], 'card_number' => $updatedBoard['card_sequence'],
'card_key' => $cardKey, 'card_key' => $cardKey,
'title' => $title, 'title' => $title,
'description' => $template['description'] ?? null, 'description' => $template['description'] ?? null,
'priority' => $template['priority'] ?? 'none', 'priority' => $template['priority'] ?? 'none',
'estimated_hours' => $template['estimated_hours'] ?? null, 'estimated_hours' => isset($template['estimated_hours']) ? (float)$template['estimated_hours'] : null,
'deadline' => $template['deadline_offset_days'] ?? null
? date('Y-m-d 23:59:00', strtotime("+{$template['deadline_offset_days']} days"))
: null,
'position_in_column' => 0, 'position_in_column' => 0,
'created_by_id' => $def['created_by_id'], 'created_by_id' => (int)$def['created_by_id'],
]); ]);
$db->insert('card_activity_log', [
'card_id' => $cardId,
'user_id' => null,
'action' => 'created',
'details_json' => json_encode(['source' => 'recurring', 'definition_id' => $def['id']]),
]);
if (!empty($template['labels'])) {
foreach ($template['labels'] as $labelId) {
$labelExists = $db->fetchOne("SELECT id FROM labels WHERE id = ?", [(int)$labelId]);
if ($labelExists) {
$db->query("INSERT IGNORE INTO card_labels (card_id, label_id) VALUES (?, ?)", [$cardId, (int)$labelId]);
}
}
}
if (!empty($template['checklists'])) {
foreach ($template['checklists'] as $ci => $checklist) {
$clId = $db->insert('card_checklists', [
'card_id' => $cardId,
'name' => $checklist['name'] ?? 'Checklist',
'position' => $ci + 1,
]);
if (!empty($checklist['items'])) {
foreach ($checklist['items'] as $ii => $item) {
$db->insert('card_checklist_items', [
'checklist_id' => $clId,
'text' => is_string($item) ? $item : ($item['text'] ?? ''),
'position' => $ii + 1,
]);
}
}
}
}
$assignees = $def['assignees_json'] ? json_decode($def['assignees_json'], true) : []; $assignees = $def['assignees_json'] ? json_decode($def['assignees_json'], true) : [];
foreach ($assignees as $uid) { foreach ($assignees as $assigneeId) {
$this->db->insert('card_assignments', [ $db->insert('card_assignments', [
'card_id' => $cardId, 'card_id' => $cardId,
'user_id' => (int)$uid, 'user_id' => (int)$assigneeId,
'assigned_by_id' => $def['created_by_id'], 'assigned_by_id' => (int)$def['created_by_id'],
]); ]);
$notif->createImportant((int)$assigneeId, 'Recurring Task Created',
"Recurring card {$cardKey}: {$title} has been created.",
"/cards/{$cardId}", 'card', $cardId);
} }
$nextCreation = $this->calculateNextCreation($def); $nextCreation = $this->calculateNextCreation($def);
$this->db->update('recurring_card_definitions', [ $db->update('recurring_card_definitions', [
'last_created_at' => date('Y-m-d H:i:s'), 'last_created_at' => $now,
'next_creation_at' => $nextCreation, 'next_creation_at' => $nextCreation,
], 'id = ?', [$def['id']]); ], 'id = ?', [(int)$def['id']]);
});
$db->commit();
} catch (\Throwable $e) {
$db->rollBack();
error_log("CreateRecurringCardsJob error for def {$def['id']}: " . $e->getMessage());
}
} }
} }
private function calculateNextCreation(array $def): string private function calculateNextCreation(array $def): string
{ {
$now = time(); $now = time();
return match($def['frequency']) { switch ($def['frequency']) {
'daily' => date('Y-m-d 04:00:00', strtotime('+1 day', $now)), case 'daily':
'weekly' => date('Y-m-d 04:00:00', strtotime('+1 week', $now)), return date('Y-m-d H:i:s', strtotime('+1 day', $now));
'biweekly' => date('Y-m-d 04:00:00', strtotime('+2 weeks', $now)), case 'weekly':
'monthly' => date('Y-m-d 04:00:00', strtotime('+1 month', $now)), $dayNames = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'];
'custom' => date('Y-m-d 04:00:00', strtotime('+' . (int)$def['frequency_days'] . ' days', $now)), $targetDay = $dayNames[$def['day_of_week'] ?? 1] ?? 'Monday';
default => date('Y-m-d 04:00:00', strtotime('+1 week', $now)), return date('Y-m-d H:i:s', strtotime("next {$targetDay}", $now));
}; case 'biweekly':
return date('Y-m-d H:i:s', strtotime('+2 weeks', $now));
case 'monthly':
$dom = $def['day_of_month'] ?? 1;
$nextMonth = strtotime('+1 month', $now);
$nextDate = date('Y-m', $nextMonth) . '-' . str_pad((string)$dom, 2, '0', STR_PAD_LEFT);
if (!checkdate((int)date('m', $nextMonth), $dom, (int)date('Y', $nextMonth))) {
$nextDate = date('Y-m-t', $nextMonth);
}
return $nextDate . ' 04:00:00';
case 'custom':
$days = (int)($def['frequency_days'] ?? 7);
return date('Y-m-d H:i:s', strtotime("+{$days} days", $now));
default:
return date('Y-m-d H:i:s', strtotime('+7 days', $now));
}
} }
} }
\ No newline at end of file
<?php <?php
use Engine\Core\Container; use Engine\Core\Router;
use Modules\RecurringCards\Controllers\RecurringCardController;
$router = Container::getInstance()->resolve(\Engine\Core\Router::class); return function (Router $router) {
$router->get('/recurring-cards', [RecurringCardController::class, 'index']);
$router->post('/recurring-cards', [RecurringCardController::class, 'create']);
$router->put('/recurring-cards/{defId}', [RecurringCardController::class, 'update']);
$router->delete('/recurring-cards/{defId}', [RecurringCardController::class, 'delete']);
$router->get('/recurring-cards', \Modules\RecurringCards\Controllers\RecurringCardController::class, 'index', ['auth', 'blocking']); $router->get('/api/recurring-cards', [RecurringCardController::class, 'index']);
$router->post('/recurring-cards', \Modules\RecurringCards\Controllers\RecurringCardController::class, 'create', ['auth', 'blocking']); $router->post('/api/recurring-cards', [RecurringCardController::class, 'create']);
$router->put('/recurring-cards/{id}', \Modules\RecurringCards\Controllers\RecurringCardController::class, 'update', ['auth', 'blocking']); $router->put('/api/recurring-cards/{defId}', [RecurringCardController::class, 'update']);
$router->delete('/recurring-cards/{id}', \Modules\RecurringCards\Controllers\RecurringCardController::class, 'delete', ['auth', 'blocking']); $router->delete('/api/recurring-cards/{defId}', [RecurringCardController::class, 'delete']);
\ No newline at end of file };
\ No newline at end of file
<?php <?php
declare(strict_types=1); use Engine\Core\Router;
// Salary routes are served via the Dashboard HUD and API endpoints use Modules\Dashboard\Controllers\DashboardController;
// Additional salary-specific routes will be added in Phase 2+
\ No newline at end of file return function (Router $router) {
// HUD data is served via the Dashboard controller's getHudData
// and SSE stream. Salary module routes for API access:
$router->get('/api/users/{userId}/hud', function (\Engine\Core\Request $request, string $userId) {
$user = $request->user();
$db = \Engine\Core\Container::getInstance()->resolve(\Engine\Database\Connection::class);
$targetId = (int)$userId;
if ($user['role'] === 'contractor' && $user['id'] !== $targetId) {
return \Engine\Core\Response::json(['error' => 'Forbidden'], 403);
}
$target = $db->fetchOne("SELECT * FROM users WHERE id = ?", [$targetId]);
if (!$target) {
return \Engine\Core\Response::json(['error' => 'User not found'], 404);
}
$month = date('Y-m');
$actualSalary = (float)($target['actual_salary'] ?? 0);
$totalBounties = (float)$db->fetchColumn(
"SELECT COALESCE(SUM(amount), 0) FROM bounty_payouts WHERE recipient_id = ? AND payroll_month = ?",
[$targetId, $month]
);
$totalDeductions = (float)$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",
[$targetId, $month]
);
$totalPosAdj = (float)$db->fetchColumn(
"SELECT COALESCE(SUM(amount), 0) FROM manual_adjustments
WHERE contractor_id = ? AND effective_month = ? AND type = 'positive' AND status = 'approved' AND deleted_at IS NULL",
[$targetId, $month]
);
$totalNegAdj = (float)$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",
[$targetId, $month]
);
$liveSalary = $actualSalary + $totalBounties + $totalPosAdj - $totalDeductions - $totalNegAdj;
$retentionPct = $actualSalary > 0 ? ($liveSalary / $actualSalary) * 100 : 100;
return \Engine\Core\Response::json([
'actual_salary' => $actualSalary,
'live_salary' => round($liveSalary, 2),
'total_bounties' => $totalBounties,
'total_deductions' => $totalDeductions,
'total_pos_adj' => $totalPosAdj,
'total_neg_adj' => $totalNegAdj,
'retention_pct' => round($retentionPct, 2),
'month' => $month,
]);
});
};
\ No newline at end of file
<?php <?php
use Engine\Core\Container; use Engine\Core\Router;
use Modules\SavedFilters\Controllers\SavedFilterController;
$router = Container::getInstance()->resolve(\Engine\Core\Router::class); return function (Router $router) {
$router->get('/saved-filters', [SavedFilterController::class, 'index']);
$router->post('/saved-filters', [SavedFilterController::class, 'create']);
$router->delete('/saved-filters/{filterId}', [SavedFilterController::class, 'delete']);
$router->get('/saved-filters', \Modules\SavedFilters\Controllers\SavedFilterController::class, 'index', ['auth']); $router->get('/api/saved-filters', [SavedFilterController::class, 'index']);
$router->post('/saved-filters', \Modules\SavedFilters\Controllers\SavedFilterController::class, 'create', ['auth']); $router->post('/api/saved-filters', [SavedFilterController::class, 'create']);
$router->delete('/saved-filters/{id}', \Modules\SavedFilters\Controllers\SavedFilterController::class, 'delete', ['auth']); $router->delete('/api/saved-filters/{filterId}', [SavedFilterController::class, 'delete']);
\ No newline at end of file };
\ No newline at end of file
<?php <?php
use Engine\Core\Container; use Engine\Core\Router;
use Modules\Schedules\Controllers\ScheduleController;
$router = Container::getInstance()->resolve(\Engine\Core\Router::class); return function (Router $router) {
$router->get('/schedules/user/{userId}', [ScheduleController::class, 'currentSchedule']);
$router->get('/schedules/requests', [ScheduleController::class, 'requests']);
$router->post('/schedules/requests', [ScheduleController::class, 'submitRequest']);
$router->post('/schedules/requests/{requestId}/review', [ScheduleController::class, 'reviewRequest']);
$router->put('/schedules/user/{userId}/direct', [ScheduleController::class, 'directEdit']);
$router->get('/schedules/users/{userId}', \Modules\Schedules\Controllers\ScheduleController::class, 'currentSchedule', ['auth', 'blocking']); $router->get('/api/users/{userId}/schedule', [ScheduleController::class, 'currentSchedule']);
$router->get('/schedules/requests', \Modules\Schedules\Controllers\ScheduleController::class, 'requests', ['auth', 'blocking']); $router->get('/api/schedule-requests', [ScheduleController::class, 'requests']);
$router->post('/schedules/requests', \Modules\Schedules\Controllers\ScheduleController::class, 'submitRequest', ['auth', 'blocking']); $router->post('/api/schedule-requests', [ScheduleController::class, 'submitRequest']);
$router->post('/schedules/requests/{id}/review', \Modules\Schedules\Controllers\ScheduleController::class, 'reviewRequest', ['auth', 'blocking']); $router->post('/api/schedule-requests/{requestId}/review', [ScheduleController::class, 'reviewRequest']);
$router->post('/schedules/users/{userId}/edit', \Modules\Schedules\Controllers\ScheduleController::class, 'directEdit', ['auth', 'blocking']); $router->put('/api/users/{userId}/schedule', [ScheduleController::class, 'directEdit']);
\ No newline at end of file };
\ No newline at end of file
<?php <?php
use Engine\Core\Container; use Engine\Core\Router;
use Modules\TeamAvailability\Controllers\TeamAvailabilityController;
$router = Container::getInstance()->resolve(\Engine\Core\Router::class); return function (Router $router) {
$router->get('/team-availability', [TeamAvailabilityController::class, 'index']);
$router->get('/team-availability', \Modules\TeamAvailability\Controllers\TeamAvailabilityController::class, 'index', ['auth', 'blocking']); $router->get('/api/team-availability', [TeamAvailabilityController::class, 'index']);
\ No newline at end of file };
\ No newline at end of file
<?php <?php
declare(strict_types=1); use Engine\Core\Router;
use Modules\Unavailability\Controllers\UnavailabilityController;
use Engine\Core\Container; return function (Router $router) {
$router->get('/unavailability', [UnavailabilityController::class, 'index']);
$router->post('/unavailability', [UnavailabilityController::class, 'create']);
$router->put('/unavailability/{id}', [UnavailabilityController::class, 'update']);
$router->delete('/unavailability/{id}', [UnavailabilityController::class, 'delete']);
$router = Container::getInstance()->resolve(Engine\Core\Router::class); $router->get('/api/unavailability', [UnavailabilityController::class, 'index']);
$router->post('/api/unavailability', [UnavailabilityController::class, 'create']);
$router->group([ $router->put('/api/unavailability/{id}', [UnavailabilityController::class, 'update']);
'prefix' => '/api/unavailability', $router->delete('/api/unavailability/{id}', [UnavailabilityController::class, 'delete']);
'middleware' => [Middleware\AuthenticationMiddleware::class, Middleware\CSRFMiddleware::class] };
], function ($router) { \ No newline at end of file
$router->get('/', Modules\Unavailability\Controllers\UnavailabilityController::class, 'index');
$router->post('/', Modules\Unavailability\Controllers\UnavailabilityController::class, 'create');
$router->post('/{id}', Modules\Unavailability\Controllers\UnavailabilityController::class, 'update');
$router->post('/{id}/delete', Modules\Unavailability\Controllers\UnavailabilityController::class, 'delete');
});
\ No newline at end of file
This diff is collapsed.
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